This commit is contained in:
mofeng-git 2025-02-03 13:05:14 +08:00
parent ddb4d752c0
commit 5bf2466037
108 changed files with 0 additions and 17103 deletions

View File

@ -1,27 +0,0 @@
/pkg/
/src/
/site/
/dist/
/kvmd.egg-info/
/testenv/run/
/testenv/.tox/
/testenv/.mypy_cache/
/testenv/.ssl/
/hid/arduino/.pio/
/hid/arduino/.platformio/
/hid/pico/.pico-sdk.tmp/
/hid/pico/.pico-sdk/
/hid/pico/.tinyusb.tmp/
/hid/pico/.tinyusb/
/hid/pico/.build/
/hid/pico/*.uf2
/.git/
/v*.tar.gz
/*.pkg.tar.xz
/*.pkg.tar.zst
/*.egg-info
/*kvmd-*.tar.gz
kvmd-launcher.bin
kvmd-launcher.build
kvmd-launcher.dist
kvmd-launcher.onefile-build

268
PKGBUILD
View File

@ -1,268 +0,0 @@
# Contributor: Maxim Devaev <mdevaev@gmail.com>
# Author: Maxim Devaev <mdevaev@gmail.com>
_variants=(
v0-hdmi:zero2w
v0-hdmi:rpi2
v0-hdmi:rpi3
v0-hdmiusb:zero2w
v0-hdmiusb:rpi2
v0-hdmiusb:rpi3
v1-hdmi:zero2w
v1-hdmi:rpi2
v1-hdmi:rpi3
v1-hdmiusb:zero2w
v1-hdmiusb:rpi2
v1-hdmiusb:rpi3
v2-hdmi:zero2w
v2-hdmi:rpi3
v2-hdmi:rpi4
v2-hdmiusb:rpi4
v3-hdmi:rpi4
v4mini-hdmi:rpi4
v4plus-hdmi:rpi4
)
pkgname=(kvmd)
for _variant in "${_variants[@]}"; do
_platform=${_variant%:*}
_board=${_variant#*:}
pkgname+=(kvmd-platform-$_platform-$_board)
done
pkgbase=kvmd
pkgver=4.20
pkgrel=1
pkgdesc="The main PiKVM daemon"
url="https://github.com/pikvm/kvmd"
license=(GPL)
arch=(any)
depends=(
"python>=3.12"
"python<3.13"
python-yaml
python-aiohttp
python-aiofiles
python-async-lru
python-passlib
python-pyotp
python-qrcode
python-periphery
python-pyserial
python-pyserial-asyncio
python-spidev
python-setproctitle
python-psutil
python-netifaces
python-systemd
python-dbus
python-dbus-next
python-pygments
python-pyghmi
python-pam
python-pillow
python-xlib
libxkbcommon
python-hidapi
python-six
python-pyrad
python-ldap
python-zstandard
python-mako
python-luma-oled
python-pyusb
"libgpiod>=2.1"
freetype2
"v4l-utils>=1.22.1-1"
"nginx-mainline>=1.25.1"
openssl
sudo
iptables
iproute2
dnsmasq
ipmitool
"janus-gateway-pikvm>=0.14.2-3"
certbot
platform-io-access
raspberrypi-utils
"ustreamer>=6.16"
# Systemd UDEV bug
"systemd>=248.3-2"
# https://bugzilla.redhat.com/show_bug.cgi?id=2035802
# https://archlinuxarm.org/forum/viewtopic.php?f=15&t=15725&start=40
"zstd>=1.5.1-2.1"
# Possible hotfix for the new os update
openssl-1.1
# Bootconfig
dos2unix
parted
e2fsprogs
openssh
# FIXME:
# - https://archlinuxarm.org/forum/viewtopic.php?f=15&t=17007&p=72789
# - https://github.com/pikvm/pikvm/issues/1375
wpa_supplicant-pikvm
run-parts
# fsck for /boot
dosfstools
# pgrep for kvmd-udev-restart-pass
procps-ng
# Misc
hostapd
)
optdepends=(
tesseract
)
conflicts=(
python-pikvm
python-aiohttp-pikvm
platformio
avrdude-pikvm
kvmd-oled
)
makedepends=(
python-setuptools
python-pip
)
source=("$url/archive/v$pkgver.tar.gz")
md5sums=(SKIP)
backup=(
etc/kvmd/{override,logging,auth,meta}.yaml
etc/kvmd/{ht,ipmi,vnc}passwd
etc/kvmd/totp.secret
etc/kvmd/nginx/{kvmd.ctx-{http,server},certbot.ctx-server}.conf
etc/kvmd/nginx/loc-{login,nocache,proxy,websocket,nobuffering,bigpost}.conf
etc/kvmd/nginx/{mime-types,ssl}.conf
etc/kvmd/nginx/nginx.conf.mako
etc/kvmd/janus/janus{,.plugin.ustreamer,.transport.websockets}.jcfg
etc/kvmd/web.css
)
package_kvmd() {
install=$pkgname.install
cd "$srcdir/kvmd-$pkgver"
pip install --root="$pkgdir" --no-deps .
install -Dm755 -t "$pkgdir/usr/bin" scripts/kvmd-{bootconfig,gencert,certbot}
install -Dm644 -t "$pkgdir/usr/lib/systemd/system" configs/os/services/*
install -DTm644 configs/os/sysusers.conf "$pkgdir/usr/lib/sysusers.d/kvmd.conf"
install -DTm644 configs/os/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/kvmd.conf"
mkdir -p "$pkgdir/usr/share/kvmd"
cp -r {hid,web,extras,contrib/keymaps} "$pkgdir/usr/share/kvmd"
find "$pkgdir/usr/share/kvmd/web" -name '*.pug' -exec rm -f '{}' \;
local _cfg_default="$pkgdir/usr/share/kvmd/configs.default"
mkdir -p "$_cfg_default"
cp -r configs/* "$_cfg_default"
find "$pkgdir" -name ".gitignore" -delete
find "$_cfg_default" -type f -exec chmod 444 '{}' \;
chmod 400 "$_cfg_default/kvmd"/*passwd
chmod 400 "$_cfg_default/kvmd"/*.secret
chmod 750 "$_cfg_default/os/sudoers"
chmod 400 "$_cfg_default/os/sudoers"/*
mkdir -p "$pkgdir/etc/kvmd/"{nginx,vnc}"/ssl"
chmod 755 "$pkgdir/etc/kvmd/"{nginx,vnc}"/ssl"
install -Dm444 -t "$pkgdir/etc/kvmd/nginx" "$_cfg_default/nginx"/*.conf*
chmod 644 "$pkgdir/etc/kvmd/nginx/"{nginx,ssl}.conf*
mkdir -p "$pkgdir/etc/kvmd/janus"
chmod 755 "$pkgdir/etc/kvmd/janus"
install -Dm444 -t "$pkgdir/etc/kvmd/janus" "$_cfg_default/janus"/*.jcfg
install -Dm644 -t "$pkgdir/etc/kvmd" "$_cfg_default/kvmd"/*.yaml
install -Dm600 -t "$pkgdir/etc/kvmd" "$_cfg_default/kvmd"/*passwd
install -Dm600 -t "$pkgdir/etc/kvmd" "$_cfg_default/kvmd"/*.secret
install -Dm644 -t "$pkgdir/etc/kvmd" "$_cfg_default/kvmd"/web.css
mkdir -p "$pkgdir/etc/kvmd/override.d"
mkdir -p "$pkgdir/var/lib/kvmd/"{msd,pst}
}
for _variant in "${_variants[@]}"; do
_platform=${_variant%:*}
_board=${_variant#*:}
_base=${_platform%-*}
_video=${_platform#*-}
eval "package_kvmd-platform-$_platform-$_board() {
cd \"kvmd-\$pkgver\"
pkgdesc=\"PiKVM platform configs - $_platform for $_board\"
depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-1\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
backup=(
etc/sysctl.d/99-kvmd.conf
etc/udev/rules.d/99-kvmd.rules
etc/kvmd/main.yaml
)
if [[ $_base == v0 ]]; then
depends=(\"\${depends[@]}\" platformio-core avrdude make patch)
elif [[ $_base == v4plus ]]; then
depends=(\"\${depends[@]}\" flashrom-pikvm)
fi
if [[ $_platform =~ ^.*-hdmiusb$ ]]; then
install -Dm755 -t \"\$pkgdir/usr/bin\" scripts/kvmd-udev-hdmiusb-check
fi
if [[ $_base == v4plus ]]; then
install -Dm755 -t \"\$pkgdir/usr/bin\" scripts/kvmd-udev-restart-pass
fi
install -DTm644 configs/os/sysctl.conf \"\$pkgdir/etc/sysctl.d/99-kvmd.conf\"
install -DTm644 configs/os/udev/common.rules \"\$pkgdir/usr/lib/udev/rules.d/99-kvmd-common.rules\"
install -DTm644 configs/os/udev/$_platform-$_board.rules \"\$pkgdir/etc/udev/rules.d/99-kvmd.rules\"
install -DTm444 configs/kvmd/main/$_platform-$_board.yaml \"\$pkgdir/etc/kvmd/main.yaml\"
if [ -f configs/kvmd/fan/$_platform.ini ]; then
backup=(\"\${backup[@]}\" etc/kvmd/fan.ini)
depends=(\"\${depends[@]}\" \"kvmd-fan>=0.18\")
install -DTm444 configs/kvmd/fan/$_platform.ini \"\$pkgdir/etc/kvmd/fan.ini\"
fi
if [ -f configs/os/modules-load/$_platform.conf ]; then
backup=(\"\${backup[@]}\" etc/modules-load.d/kvmd.conf)
install -DTm644 configs/os/modules-load/$_platform.conf \"\$pkgdir/etc/modules-load.d/kvmd.conf\"
fi
if [ -f configs/os/sudoers/$_platform ]; then
backup=(\"\${backup[@]}\" etc/sudoers.d/99_kvmd)
install -DTm440 configs/os/sudoers/$_platform \"\$pkgdir/etc/sudoers.d/99_kvmd\"
chmod 750 \"\$pkgdir/etc/sudoers.d\"
fi
if [[ $_platform =~ ^.*-hdmi$ ]]; then
backup=(\"\${backup[@]}\" etc/kvmd/tc358743-edid.hex)
install -DTm444 configs/kvmd/edid/$_base.hex \"\$pkgdir/etc/kvmd/tc358743-edid.hex\"
fi
mkdir -p \"\$pkgdir/usr/share/kvmd\"
local _platform=\"\$pkgdir/usr/share/kvmd/platform\"
rm -f \"\$_platform\"
echo PIKVM_MODEL=$_base > \"\$_platform\"
echo PIKVM_VIDEO=$_video >> \"\$_platform\"
echo PIKVM_BOARD=$_board >> \"\$_platform\"
chmod 444 \"\$_platform\"
}"
done

View File

@ -1,82 +0,0 @@
# syntax = docker/dockerfile:experimental
FROM python:3.12.8-slim-bookworm AS builder
ARG TARGETARCH
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends build-essential libssl-dev libffi-dev python3-dev libevent-dev libjpeg-dev \
libbsd-dev libudev-dev git pkg-config wget curl libmicrohttpd-dev libjansson-dev libssl-dev libsofia-sip-ua-dev \
libopus-dev libogg-dev libcurl4-openssl-dev liblua5.3-dev libconfig-dev libopus-dev libtool automake autoconf \
libx264-dev libyuv-dev libasound2-dev libspeex-dev libspeexdsp-dev libopus-dev libgpiod-dev libsystemd-dev \
libglib2.0-dev libwebsockets-dev libsrtp2-dev libnice-dev patchelf libxkbcommon0 sudo iproute2 iptables libusb-dev \
&& rm -rf /var/lib/apt/lists/*
COPY build/cargo_config /tmp/config
RUN --security=insecure pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
&& if [ ${TARGETARCH} = arm ]; then \
mkdir -p /root/.cargo \
&& chmod 777 /root/.cargo && mount -t tmpfs none /root/.cargo \
&& export RUSTUP_DIST_SERVER="https://mirrors.tuna.tsinghua.edu.cn/rustup" \
#&& export RUSTUP_UPDATE_ROOT="https://mirrors.ustc.edu.cn/rust-static/rustup" \
&& wget https://sh.rustup.rs -O /root/rustup-init.sh \
&& sh /root/rustup-init.sh -y \
&& export PATH=$PATH:/root/.cargo/bin \
&& cp /tmp/config /root/.cargo/config.toml; \
fi \
&& pip install cryptography
RUN pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check build nuitka \
&& pip install --no-cache-dir aiofiles aiohttp appdirs asn1crypto async_lru async-timeout bottle cffi chardet click colorama \
dbus_next gpiod hidapi idna mako marshmallow more-itertools multidict netifaces packaging passlib pillow ply psutil pycparser \
pyelftools pyghmi pygments pyparsing pyotp qrcode requests semantic-version setproctitle setuptools six spidev \
tabulate urllib3 wrapt xlib yarl pyserial pyyaml zstandard supervisor
RUN git clone --depth=1 https://github.com/meetecho/janus-gateway.git /tmp/janus-gateway \
&& cd /tmp/janus-gateway \
&& bash autogen.sh \
&& ./configure --enable-static --enable-websockets --enable-plugin-audiobridge \
--disable-data-channels --disable-rabbitmq --disable-mqtt --disable-all-plugins --disable-all-loggers \
--prefix=/usr \
&& make && make install
RUN sed --in-place --expression 's|^#include "refcount.h"$|#include "../refcount.h"|g' /usr/include/janus/plugins/plugin.h \
&& git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer \
&& make clean -C /tmp/ustreamer \
&& make WITH_GPIO=1 WITH_JANUS=1 WITH_PYTHON=1 WITH_LIBX264=1 WITH_SETPROCTITLE=0 -C /tmp/ustreamer install
COPY kvmd /One-KVM/kvmd
COPY kvmd-launcher.py /One-KVM/
RUN bash -c "cd /One-KVM && ls && python3 -m nuitka kvmd-launcher.py --standalone --onefile \
--show-progress --no-deployment-flag=self-execution --include-module=\
kvmd.plugins.auth.htpasswd,kvmd.plugins.auth.http,kvmd.plugins.auth.ldap,\
kvmd.plugins.auth.pam,kvmd.plugins.auth.radius,\
kvmd.plugins.hid.ch9329,kvmd.plugins.hid.bt,kvmd.plugins.hid.otg,\
kvmd.plugins.atx.disabled,kvmd.plugins.atx.gpio,\
kvmd.plugins.msd.disabled,kvmd.plugins.msd.otg,\
kvmd.plugins.ugpio.gpio,kvmd.plugins.ugpio.wol,kvmd.plugins.ugpio.cmd,\
kvmd.plugins.ugpio.ipmi,kvmd.plugins.ugpio.anelpwr,kvmd.plugins.ugpio.cmdret,\
kvmd.plugins.ugpio.extron,kvmd.plugins.ugpio.ezcoo,kvmd.plugins.ugpio.hidrelay,\
kvmd.plugins.ugpio.hue,kvmd.plugins.ugpio.locator,kvmd.plugins.ugpio.noyito,\
kvmd.plugins.ugpio.otgconf,kvmd.plugins.ugpio.pway,kvmd.plugins.ugpio.pwm,\
kvmd.plugins.ugpio.servo,kvmd.plugins.ugpio.tesmart,kvmd.plugins.ugpio.xh_hk4401,\
passlib.handlers.sha1_crypt,pygments.formatters.terminal"
COPY kvmd_data /One-KVM/kvmd_data
RUN cp /usr/local/bin/ustreamer /usr/local/bin/ustreamer-dump /One-KVM/kvmd_data/usr/bin \
&& cd /One-KVM && python3 -m kvmd.apps.kvmd -m -c kvmd_data/etc/kvmd/main.yaml
FROM debian:stable-slim
COPY --from=builder /One-KVM/kvmd_data kvmd_data
COPY --from=builder /One-KVM/kvmd-launcher.bin kvmd-launcher.bin
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y libxkbcommon0 sudo iproute2 iptables nano \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "/kvmd-launcher.bin" ]
#RUN cd /One-KVM && /One-KVM/kvmd-launcher.bin

View File

@ -1,38 +0,0 @@
FROM silentwind0/kvmd-stage-0 AS builder
FROM python:3.12.0rc2-slim-bookworm
LABEL maintainer="mofeng654321@hotmail.com"
COPY --from=builder /tmp/lib/* /tmp/lib/
COPY --from=builder /tmp/ustreamer/ustreamer /tmp/ustreamer/ustreamer-dump /usr/bin/janus /usr/bin/
COPY --from=builder /tmp/wheel/*.whl /tmp/wheel/
COPY --from=builder /tmp/ustreamer/libjanus_ustreamer.so /usr/lib/ustreamer/janus/
COPY --from=builder /usr/lib/janus/transports/* /usr/lib/janus/transports/
ARG TARGETARCH
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV TZ=Asia/Shanghai
RUN cp /tmp/lib/* /lib/*-linux-*/ \
&& pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check /tmp/wheel/*.whl \
&& pip install --no-cache-dir --root-user-action=ignore --disable-pip-version-check pyfatfs \
&& rm -rf /tmp/lib /tmp/wheel
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends libxkbcommon-x11-0 nginx tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim iptables sudo curl kmod \
libmicrohttpd12 libjansson4 libssl3 libsofia-sip-ua0 libglib2.0-0 libopus0 libogg0 libcurl4 libconfig9 libusrsctp2 libwebsockets17 libnss3 libasound2 nano \
&& rm -rf /var/lib/apt/lists/*
RUN if [ ${TARGETARCH} = arm ]; then ARCH=armhf; elif [ ${TARGETARCH} = arm64 ]; then ARCH=aarch64; elif [ ${TARGETARCH} = amd64 ]; then ARCH=x86_64; fi \
&& curl https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.$ARCH -L -o /usr/local/bin/ttyd \
&& chmod +x /usr/local/bin/ttyd \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata
COPY . /One-KVM
ENTRYPOINT ["bash"]

View File

@ -1,311 +0,0 @@
#!/bin/bash
SRCPATH=/mnt/nas/src
BOOTFS=/tmp/bootfs
ROOTFS=/tmp/rootfs
OUTPUTDIR=/mnt/nas/src/output
LOOPDEV=/dev/loop10
DATE=241204
export LC_ALL=C
write_meta() {
sudo chroot --userspec "root:root" $ROOTFS bash -c "sed -i 's/localhost.localdomain/$1/g' /etc/kvmd/meta.yaml"
}
mount_rootfs() {
mkdir $ROOTFS
sudo mount $LOOPDEV $ROOTFS || exit -1
sudo mount -t proc proc $ROOTFS/proc || exit -1
sudo mount -t sysfs sys $ROOTFS/sys || exit -1
sudo mount -o bind /dev $ROOTFS/dev || exit -1
}
umount_rootfs() {
sudo umount $ROOTFS/sys
sudo umount $ROOTFS/dev
sudo umount $ROOTFS/proc
sudo umount $ROOTFS
sudo losetup -d $LOOPDEV
}
parpare_dns() {
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
mkdir -p /run/systemd/resolve/ \
&& touch /run/systemd/resolve/stub-resolv.conf \
&& printf '%s\n' 'nameserver 1.1.1.1' 'nameserver 1.0.0.1' > /etc/resolv.conf \
&& bash <(curl -sSL https://gitee.com/SuperManito/LinuxMirrors/raw/main/ChangeMirrors.sh) \
--source mirrors.tuna.tsinghua.edu.cn --updata-software false --web-protocol http "
}
delete_armbain_verify(){
sudo chroot --userspec "root:root" $ROOTFS bash -c "echo 'deb http://mirrors.ustc.edu.cn/armbian bullseye main bullseye-utils bullseye-desktop' > /etc/apt/sources.list.d/armbian.list "
}
config_file() {
sudo mkdir -p $ROOTFS/etc/kvmd/override.d $ROOTFS/etc/kvmd/vnc $ROOTFS/var/lib/kvmd/msd $ROOTFS/opt/vc/bin $ROOTFS/usr/share/kvmd $ROOTFS/One-KVM \
$ROOTFS/usr/share/janus/javascript $ROOTFS/usr/lib/ustreamer/janus $ROOTFS/run/kvmd $ROOTFS/var/lib/kvmd/msd/images $ROOTFS/var/lib/kvmd/msd/meta
sudo rsync -a --exclude={src,.github} . $ROOTFS/One-KVM
sudo cp -r configs/kvmd/* configs/nginx configs/janus $ROOTFS/etc/kvmd
sudo cp -r web extras contrib/keymaps $ROOTFS/usr/share/kvmd
sudo cp testenv/fakes/vcgencmd $ROOTFS/usr/bin/
sudo cp -r testenv/js/* $ROOTFS/usr/share/janus/javascript/
sudo cp build/platform/$1 $ROOTFS/usr/share/kvmd/platform
if [ -f "$SRCPATH/image/$1/rc.local" ]; then
sudo cp $SRCPATH/image/$1/rc.local $ROOTFS/etc/
fi
}
pack_img() {
sudo mv $SRCPATH/tmp/rootfs.img $OUTPUTDIR/One-KVM_by-SilentWind_$1_$DATE.img
if [ "$1" = "Vm" ]; then
sudo qemu-img convert -f raw -O vmdk $OUTPUTDIR/One-KVM_by-SilentWind_Vm_$DATE.img $OUTPUTDIR/One-KVM_by-SilentWind_Vmare-uefi_$DATE.vmdk
sudo qemu-img convert -f raw -O vdi $OUTPUTDIR/One-KVM_by-SilentWind_Vm_$DATE.img $OUTPUTDIR/One-KVM_by-SilentWind_Virtualbox-uefi_$DATE.vdi
fi
}
onecloud_rootfs() {
$SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64 unpack $SRCPATH/image/onecloud/Armbian_by-SilentWind_24.5.0-trunk_Onecloud_bookworm_legacy_5.9.0-rc7_minimal.burn.img $SRCPATH/tmp
simg2img $SRCPATH/tmp/6.boot.PARTITION.sparse $SRCPATH/tmp/bootfs.img
simg2img $SRCPATH/tmp/7.rootfs.PARTITION.sparse $SRCPATH/tmp/rootfs.img
mkdir $BOOTFS
sudo losetup $LOOPDEV $SRCPATH/tmp/bootfs.img || exit -1
sudo mount $LOOPDEV $BOOTFS
sudo cp $SRCPATH/image/onecloud/meson8b-onecloud-fix.dtb $BOOTFS/dtb/meson8b-onecloud.dtb
sudo umount $BOOTFS
sudo losetup -d $LOOPDEV
dd if=/dev/zero of=/tmp/add.img bs=1M count=1024 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
e2fsck -f $SRCPATH/tmp/rootfs.img && resize2fs $SRCPATH/tmp/rootfs.img
sudo losetup $LOOPDEV $SRCPATH/tmp/rootfs.img
}
cumebox2_rootfs() {
cp $SRCPATH/image/cumebox2/Armbian_24.8.1_Khadas-vim1_bookworm_current_6.6.47_minimal.img $SRCPATH/tmp/rootfs.img
dd if=/dev/zero of=/tmp/add.img bs=1M count=1500 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
sudo parted -s $SRCPATH/tmp/rootfs.img resizepart 1 100% || exit -1
sudo losetup --offset $((8192*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo e2fsck -f $LOOPDEV && sudo resize2fs $LOOPDEV
}
chainedbox_rootfs_and_fix_dtb() {
cp $SRCPATH/image/chainedbox/Armbian_24.11.0_rockchip_chainedbox_bookworm_6.1.112_server_2024.10.02_add800m.img $SRCPATH/tmp/rootfs.img
mkdir $BOOTFS
sudo losetup --offset $((32768*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo mount $LOOPDEV $BOOTFS
sudo cp $SRCPATH/image/chainedbox/rk3328-l1pro-1296mhz-fix.dtb $BOOTFS/dtb/rockchip/rk3328-l1pro-1296mhz.dtb
sudo umount $BOOTFS
sudo losetup -d $LOOPDEV
sudo losetup --offset $((1081344*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img
}
vm_rootfs() {
cp $SRCPATH/image/vm/Armbian_24.8.1_Uefi-x86_bookworm_current_6.6.47_minimal_add1g.img $SRCPATH/tmp/rootfs.img
sudo losetup --offset $((540672*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
}
e900v22c_rootfs() {
cp $SRCPATH/image/e900v22c/Armbian_23.08.0_amlogic_s905l3a_bookworm_5.15.123_server_2023.08.01.img $SRCPATH/tmp/rootfs.img
dd if=/dev/zero of=/tmp/add.img bs=1M count=400 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
sudo parted -s $SRCPATH/tmp/rootfs.img resizepart 2 100% || exit -1
sudo losetup --offset $((532480*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo e2fsck -f $LOOPDEV && sudo resize2fs $LOOPDEV
}
octopus-flanet_rootfs() {
cp $SRCPATH/image/octopus-flanet/Armbian_24.11.0_amlogic_s912_bookworm_6.1.114_server_2024.11.01.img $SRCPATH/tmp/rootfs.img
mkdir $BOOTFS
sudo losetup --offset $((8192*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo mount $LOOPDEV $BOOTFS
sudo sed -i "s/meson-gxm-octopus-planet.dtb/meson-gxm-khadas-vim2.dtb/g" $BOOTFS/uEnv.txt
sudo umount $BOOTFS
sudo losetup -d $LOOPDEV
dd if=/dev/zero of=/tmp/add.img bs=1M count=400 && cat /tmp/add.img >> $SRCPATH/tmp/rootfs.img && rm /tmp/add.img
sudo parted -s $SRCPATH/tmp/rootfs.img resizepart 2 100% || exit -1
sudo losetup --offset $((1056768*512)) $LOOPDEV $SRCPATH/tmp/rootfs.img || exit -1
sudo e2fsck -f $LOOPDEV && sudo resize2fs $LOOPDEV
}
config_cumebox2_file() {
sudo mkdir $ROOTFS/etc/oled
sudo cp $SRCPATH/image/cumebox2/v-fix.dtb $ROOTFS/boot/dtb/amlogic/meson-gxl-s905x-khadas-vim.dtb
sudo cp $SRCPATH/image/cumebox2/ssd $ROOTFS/usr/bin/
sudo cp $SRCPATH/image/cumebox2/config.json $ROOTFS/etc/oled/config.json
}
config_octopus-flanet_file() {
sudo cp $SRCPATH/image/octopus-flanet/model_database.conf $ROOTFS/etc/model_database.conf
}
instal_one-kvm() {
#$1 arch; $2 deivce: "gpio" or "video1"; $3 network: "systemd-networkd",default is network-manager
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
df -h \
&& apt-get update \
&& apt-get install -y python3-aiofiles python3-aiohttp python3-appdirs python3-asn1crypto python3-async-timeout \
python3-bottle python3-cffi python3-chardet python3-click python3-colorama python3-cryptography python3-dateutil \
python3-dbus python3-dev python3-hidapi python3-hid python3-idna python3-libgpiod python3-mako python3-marshmallow python3-more-itertools \
python3-multidict python3-netifaces python3-packaging python3-passlib python3-pillow python3-ply python3-psutil \
python3-pycparser python3-pyelftools python3-pyghmi python3-pygments python3-pyparsing python3-requests \
python3-semantic-version python3-setproctitle python3-setuptools python3-six python3-spidev python3-systemd \
python3-tabulate python3-urllib3 python3-wrapt python3-xlib python3-yaml python3-yarl python3-pyotp python3-qrcode \
python3-serial python3-zstandard python3-dbus-next python3-pip python3-dev python3-build python3-wheel \
nginx net-tools tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim cpufrequtils iptables network-manager \
git gpiod libxkbcommon0 build-essential janus-dev libssl-dev libffi-dev libevent-dev libjpeg-dev libbsd-dev libudev-dev \
pkg-config libx264-dev libyuv-dev libasound2-dev libsndfile-dev libspeexdsp-dev \
&& rm -rf /var/lib/apt/lists/* "
sudo chroot --userspec "root:root" $ROOTFS sed --in-place --expression 's|^#include "refcount.h"$|#include "../refcount.h"|g' /usr/include/janus/plugins/plugin.h
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
git clone --depth=1 https://github.com/mofeng-git/ustreamer /tmp/ustreamer \
&& make -j WITH_PYTHON=1 WITH_JANUS=1 WITH_LIBX264=1 -C /tmp/ustreamer \
&& cp /tmp/ustreamer/src/ustreamer.bin /usr/bin/ustreamer \
&& cp /tmp/ustreamer/src/ustreamer-dump.bin /usr/bin/ustreamer-dump \
&& chmod +x /usr/bin/ustreamer /usr/bin/ustreamer-dump \
&& cp /tmp/ustreamer/janus/libjanus_ustreamer.so /usr/lib/ustreamer/janus \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages /tmp/ustreamer/python/dist/*.whl "
if [ "$3" = "systemd-networkd" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
echo -e '[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n\n[Link]\nMACAddress=B6:AE:B3:21:42:0C' > /etc/systemd/network/99-eth0.network \
&& systemctl mask NetworkManager \
&& systemctl unmask systemd-networkd \
&& systemctl enable systemd-networkd systemd-resolved "
fi
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
pip3 config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple \
&& pip3 install --target=/usr/lib/python3/dist-packages --break-system-packages async-lru gpiod pyfatfs \
&& pip3 cache purge "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cd /One-KVM \
&& python3 setup.py install \
&& bash scripts/kvmd-gencert --do-the-thing \
&& bash scripts/kvmd-gencert --do-the-thing --vnc \
&& kvmd-nginx-mkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf \
&& kvmd -m "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
cat /One-KVM/configs/os/sudoers/v2-hdmiusb >> /etc/sudoers \
&& cat /One-KVM/configs/os/udev/v2-hdmiusb-generic.rules > /etc/udev/rules.d/99-kvmd.rules \
&& echo 'libcomposite' >> /etc/modules \
&& mv /usr/local/bin/kvmd* /usr/bin \
&& cp /One-KVM/configs/os/services/* /etc/systemd/system/ \
&& cp /One-KVM/configs/os/tmpfiles.conf /usr/lib/tmpfiles.d/ \
&& chmod +x /etc/update-motd.d/* \
&& echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/gpio.sh' >> /etc/sudoers \
&& echo 'kvmd ALL=(ALL) NOPASSWD: /etc/kvmd/custom_atx/usbrelay_hid.sh' >> /etc/sudoers \
&& systemd-sysusers /One-KVM/configs/os/sysusers.conf \
&& systemd-sysusers /One-KVM/configs/os/kvmd-webterm.conf \
&& ln -sf /usr/share/tesseract-ocr/*/tessdata /usr/share/tessdata \
&& sed -i 's/8080/80/g' /etc/kvmd/override.yaml \
&& sed -i 's/4430/443/g' /etc/kvmd/override.yaml \
&& chown kvmd -R /var/lib/kvmd/msd/ \
&& systemctl enable kvmd kvmd-otg kvmd-nginx kvmd-vnc kvmd-ipmi kvmd-webterm kvmd-janus \
&& systemctl disable nginx janus \
&& rm -r /One-KVM "
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
curl https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.$1 -L -o /usr/bin/ttyd \
&& chmod +x /usr/bin/ttyd \
&& mkdir -p /home/kvmd-webterm \
&& chown kvmd-webterm /home/kvmd-webterm "
if [ "$1" = "x86_64" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
systemctl disable kvmd-otg \
&& sed -i '2c ATX=USBRELAY_HID' /etc/kvmd/atx.sh \
&& sed -i 's/device: \/dev\/ttyUSB0/device: \/dev\/kvmd-hid/g' /etc/kvmd/override.yaml "
else
if [ "$2" = "gpio" ]; then
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
sed -i '2c ATX=GPIO' /etc/kvmd/atx.sh \
&& sed -i 's/SHUTDOWNPIN/gpiochip1 7/g' /etc/kvmd/custom_atx/gpio.sh \
&& sed -i 's/REBOOTPIN/gpiochip0 11/g' /etc/kvmd/custom_atx/gpio.sh "
else
sudo chroot --userspec "root:root" $ROOTFS sed -i '2c ATX=USBRELAY_HID' /etc/kvmd/atx.sh
fi
if [ "$2" = "video1" ]; then
sudo chroot --userspec "root:root" $ROOTFS sed -i 's/\/dev\/video0/\/dev\/video1/g' /etc/kvmd/override.yaml
fi
sudo chroot --userspec "root:root" $ROOTFS bash -c " \
sed -i 's/ch9329/otg/g' /etc/kvmd/override.yaml \
&& sed -i 's/device: \/dev\/ttyUSB0//g' /etc/kvmd/override.yaml \
&& sed -i 's/#type: otg/type: otg/g' /etc/kvmd/override.yaml "
fi
}
pack_img_onecloud() {
sudo rm $SRCPATH/tmp/7.rootfs.PARTITION.sparse
sudo img2simg $SRCPATH/tmp/rootfs.img $SRCPATH/tmp/7.rootfs.PARTITION.sparse
sudo $SRCPATH/image/onecloud/AmlImg_v0.3.1_linux_amd64 pack $OUTPUTDIR/One-KVM_by-SilentWind_Onecloud_$DATE.burn.img $SRCPATH/tmp/
sudo rm $SRCPATH/tmp/*
}
case $1 in
onecloud)
onecloud_rootfs
mount_rootfs
config_file $1
instal_one-kvm armhf gpio systemd-networkd
write_meta $1
umount_rootfs
pack_img_onecloud
;;
cumebox2)
cumebox2_rootfs
mount_rootfs
config_file $1
config_cumebox2_file
parpare_dns
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img Cumebox2
;;
chainedbox)
chainedbox_rootfs_and_fix_dtb
mount_rootfs
config_file $1
parpare_dns
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img Chainedbox
;;
vm)
vm_rootfs
mount_rootfs
config_file $1
parpare_dns
instal_one-kvm x86_64
write_meta $1
umount_rootfs
pack_img Vm
;;
e900v22c)
e900v22c_rootfs
mount_rootfs
config_file $1
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img E900v22c
;;
octopus-flanet)
octopus-flanet_rootfs
mount_rootfs
config_file $1
config_octopus-flanet_file
parpare_dns
instal_one-kvm aarch64 video1
write_meta $1
umount_rootfs
pack_img Octopus-Flanet
;;
*)
echo "Do no thing."
;;
esac

View File

@ -1,5 +0,0 @@
[source.crates-io]
replace-with = 'ustc'
[source.ustc]
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"

View File

@ -1,166 +0,0 @@
#!/bin/bash
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${GREEN}One-KVM pre-starting...${NC}"
if [ ! -f /etc/kvmd/.init_flag ]; then
echo -e "${GREEN}One-KVM is initializing first...${NC}" \
&& mkdir -p /etc/kvmd/ \
&& mv /etc/kvmd_backup/* /etc/kvmd/ \
&& touch /etc/kvmd/.docker_flag \
&& sed -i 's/localhost.localdomain/docker/g' /etc/kvmd/meta.yaml \
&& sed -i 's/localhost/localhost:4430/g' /etc/kvmd/kvm_input.sh \
&& /usr/share/kvmd/kvmd-gencert --do-the-thing \
&& /usr/share/kvmd/kvmd-gencert --do-the-thing --vnc \
|| echo -e "${RED}One-KVM config moving and self-signed SSL certificates init failed.${NC}"
if [ "$NOSSL" == 1 ]; then
echo -e "${GREEN}One-KVM self-signed SSL is disabled.${NC}" \
&& python -m kvmd.apps.ngxmkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf -o nginx/https/enabled=false \
|| echo -e "${RED}One-KVM nginx config init failed.${NC}"
else
python -m kvmd.apps.ngxmkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf \
|| echo -e "${RED}One-KVM nginx config init failed.${NC}"
fi
if [ "$NOAUTH" == "1" ]; then
sed -i "s/enabled: true/enabled: false/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM auth is disabled.${NC}"
fi
#add supervisord conf
if [ "$NOWEBTERM" == "1" ]; then
echo -e "${GREEN}One-KVM webterm is disabled.${NC}"
rm -r /usr/share/kvmd/extras/webterm
else
cat >> /etc/kvmd/supervisord.conf << EOF
[program:kvmd-webterm]
command=/usr/local/bin/ttyd --interface=/run/kvmd/ttyd.sock --port=0 --writable /bin/bash -c '/etc/kvmd/armbain-motd; bash'
directory=/
autostart=true
autorestart=true
priority=14
stopasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0
redirect_stderr=true
EOF
fi
if [ "$NOWEBTERMWRITE" == "1" ]; then
sed -i "s/--writable//g" /etc/kvmd/supervisord.conf
fi
if [ "$NOVNC" == "1" ]; then
echo -e "${GREEN}One-KVM VNC is disabled.${NC}"
rm -r /usr/share/kvmd/extras/vnc
else
cat >> /etc/kvmd/supervisord.conf << EOF
[program:kvmd-vnc]
command=python -m kvmd.apps.vnc --run
directory=/
autostart=true
autorestart=true
priority=11
stopasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0
redirect_stderr=true
EOF
fi
if [ "$NOIPMI" == "1" ]; then
echo -e "${GREEN}One-KVM IPMI is disabled.${NC}"
rm -r /usr/share/kvmd/extras/ipmi
else
cat >> /etc/kvmd/supervisord.conf << EOF
[program:kvmd-ipmi]
command=python -m kvmd.apps.ipmi --run
directory=/
autostart=true
autorestart=true
priority=12
stopasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes = 0
redirect_stderr=true
EOF
fi
#switch OTG config
if [ "$OTG" == "1" ]; then
echo -e "${GREEN}One-KVM OTG is enabled.${NC}"
sed -i "s/ch9329/otg/g" /etc/kvmd/override.yaml
sed -i "s/device: \/dev\/ttyUSB0//g" /etc/kvmd/override.yaml
if [ "$NOMSD" == 1 ]; then
echo -e "${GREEN}One-KVM MSD is disabled.${NC}"
else
sed -i "s/#type: otg/type: otg/g" /etc/kvmd/override.yaml
fi
fi
#if [ ! -z "$SHUTDOWNPIN" ! -z "$REBOOTPIN" ]; then
if [ ! -z "$VIDEONUM" ]; then
sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/override.yaml \
&& sed -i "s/\/dev\/video0/\/dev\/video$VIDEONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg \
&& echo -e "${GREEN}One-KVM video device is set to /dev/video$VIDEONUM.${NC}"
fi
if [ ! -z "$AUDIONUM" ]; then
sed -i "s/hw:0/hw:$AUDIONUM/g" /etc/kvmd/janus/janus.plugin.ustreamer.jcfg \
&& echo -e "${GREEN}One-KVM audio device is set to hw:$VIDEONUM.${NC}"
fi
if [ ! -z "$CH9329SPEED" ]; then
sed -i "s/speed: 9600/speed: $CH9329SPEED/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM CH9329 serial speed is set to $CH9329SPEED.${NC}"
fi
if [ ! -z "$CH9329TIMEOUT" ]; then
sed -i "s/read_timeout: 0.3/read_timeout: $CH9329TIMEOUT/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM CH9329 timeout is set to $CH9329TIMEOUT s.${NC}"
fi
#set htpasswd
if [ ! -z "$USERNAME" ] && [ ! -z "$PASSWORD" ]; then
python -m kvmd.apps.htpasswd del admin \
&& echo $PASSWORD | python -m kvmd.apps.htpasswd set -i "$USERNAME" \
&& echo "$PASSWORD -> $USERNAME:$PASSWORD" > /etc/kvmd/vncpasswd \
&& echo "$USERNAME:$PASSWORD -> $USERNAME:$PASSWORD" > /etc/kvmd/ipmipasswd \
|| echo -e "${RED}One-KVM htpasswd init failed.${NC}"
else
echo -e "${YELLOW} USERNAME and PASSWORD environment variables are not set, using defalut(admin/admin).${NC}"
fi
if [ ! -z "$VIDEOFORMAT" ]; then
sed -i "s/format=mjpeg/format=$VIDFORMAT/g" /etc/kvmd/override.yaml \
&& echo -e "${GREEN}One-KVM input video format is set to $VIDFORMAT.${NC}"
fi
touch /etc/kvmd/.init_flag
fi
#Trying usb_gadget
if [ "$OTG" == "1" ]; then
echo "Trying OTG Port..."
rm -r /run/kvmd/otg &> /dev/null
modprobe libcomposite || echo -e "${RED}Linux libcomposite module modprobe failed.${NC}"
python -m kvmd.apps.otg start \
&& ln -s /dev/hidg1 /dev/kvmd-hid-mouse \
&& ln -s /dev/hidg0 /dev/kvmd-hid-keyboard \
|| echo -e "${RED}OTG Port mount failed.${NC}"
ln -s /dev/hidg2 /dev/kvmd-hid-mouse-alt
fi
echo -e "${GREEN}One-KVM starting...${NC}"
exec supervisord -c /etc/kvmd/supervisord.conf

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=chainedbox

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=cumebox2

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=docker_model
PIKVM_VIDEO=docker_video
PIKVM_BOARD=docker_board

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=e900v22c

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=octopus-flanet

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=onecloud

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=v2_model
PIKVM_VIDEO=usb_video
PIKVM_BOARD=vm

View File

@ -1,108 +0,0 @@
# shellcheck disable=SC2148
# arg 1: the new package version
post_install() {
post_upgrade "$1" ""
}
# arg 1: the new package version
# arg 2: the old package version
post_upgrade() {
echo "==> Ensuring KVMD users and groups ..."
systemd-sysusers /usr/lib/sysusers.d/kvmd.conf
# https://github.com/systemd/systemd/issues/13522
# shellcheck disable=SC2013
for user in $(grep '^u ' /usr/lib/sysusers.d/kvmd.conf | awk '{print $2}'); do
usermod --expiredate= "$user" >/dev/null
done
chown kvmd:kvmd /etc/kvmd/htpasswd || true
chown kvmd:kvmd /etc/kvmd/totp.secret || true
chown kvmd-ipmi:kvmd-ipmi /etc/kvmd/ipmipasswd || true
chown kvmd-vnc:kvmd-vnc /etc/kvmd/vncpasswd || true
chmod 600 /etc/kvmd/*passwd || true
for target in nginx.conf.mako ssl.conf; do
chmod 644 "/etc/kvmd/nginx/$target" || true
done
chown kvmd /var/lib/kvmd/msd 2>/dev/null || true
chown kvmd-pst:kvmd-pst /var/lib/kvmd/pst 2>/dev/null || true
chmod 1775 /var/lib/kvmd/pst 2>/dev/null || true
if [ ! -e /etc/kvmd/nginx/ssl/server.crt ]; then
echo "==> Generating KVMD-Nginx certificate ..."
kvmd-gencert --do-the-thing
fi
if [ ! -e /etc/kvmd/vnc/ssl/server.crt ]; then
echo "==> Generating KVMD-VNC certificate ..."
kvmd-gencert --do-the-thing --vnc
fi
for target in nginx vnc; do
chown root:root /etc/kvmd/$target/ssl || true
owner="root:kvmd-$target"
path="/etc/kvmd/$target/ssl/server.key"
if [ ! -L "$path" ]; then
chown "$owner" "$path" || true
chmod 440 "$path" || true
fi
path="/etc/kvmd/$target/ssl/server.crt"
if [ ! -L "$path" ]; then
chown "$owner" "$path" || true
chmod 444 "$path" || true
fi
done
echo "==> Patching configs ..."
if [[ "$(vercmp "$2" 3.301)" -lt 0 ]]; then
[ ! -f /etc/fstab ] || (sed -i -e "s|,data=journal||g" /etc/fstab && touch -t 200701011000 /etc/fstab)
[ ! -f /etc/fstab ] || (sed -i -e "/tmpfs \/run\s/d" /etc/fstab && touch -t 200701011000 /etc/fstab)
[ ! -f /etc/pacman.conf ] || sed -i -e "s|^Server = https://pikvm.org/repos/|Server = https://files.pikvm.org/repos/arch/|g" /etc/pacman.conf
[ ! -f /boot/config.txt ] || sed -i -e 's/^dtoverlay=pi3-disable-bt$/dtoverlay=disable-bt/g' /boot/config.txt
[ ! -f /boot/config.txt ] || sed -i -e 's/^dtoverlay=dwc2$/dtoverlay=dwc2,dr_mode=peripheral/g' /boot/config.txt
[ ! -f /etc/conf.d/rngd ] || (echo 'RNGD_OPTS="-o /dev/random -r /dev/hwrng -x jitter -x pkcs11 -x rtlsdr"' > /etc/conf.d/rngd)
[ ! -f /etc/pam.d/system-login ] || sed -i -e '/\<pam_systemd\.so\>/ s/^#*/#/' /etc/pam.d/system-login
[ ! -f /etc/pam.d/system-auth ] || sed -i -e '/\<pam_systemd_home\.so\>/ s/^#*/#/' /etc/pam.d/system-auth
[ -e /etc/systemd/network/99-default.link ] || ln -s /dev/null /etc/systemd/network/99-default.link
fi
if [[ "$(vercmp "$2" 3.317)" -lt 0 ]]; then
[ ! -f /boot/config.txt ] || sed -i -e 's/^dtoverlay=i2c-rtc,pcf8563$/dtoverlay=i2c-rtc,pcf8563,wakeup-source/g' /boot/config.txt
fi
if [[ "$(vercmp "$2" 3.320)" -lt 0 ]]; then
# https://github.com/pikvm/pikvm/issues/1245
systemctl mask \
dirmngr@etc-pacman.d-gnupg.socket \
gpg-agent-browser@etc-pacman.d-gnupg.socket \
gpg-agent-extra@etc-pacman.d-gnupg.socket \
gpg-agent-ssh@etc-pacman.d-gnupg.socket \
gpg-agent@etc-pacman.d-gnupg.socket \
keyboxd@etc-pacman.d-gnupg.socket
fi
if [[ "$(vercmp "$2" 3.332)" -lt 0 ]]; then
grep -q "^dtoverlay=vc4-kms-v3d" /boot/config.txt || cat << EOF >> /boot/config.txt
# Passthrough
dtoverlay=vc4-kms-v3d
disable_overscan=1
EOF
fi
if [[ "$(vercmp "$2" 4.4)" -lt 0 ]]; then
systemctl disable kvmd-pass || true
fi
if [[ "$(vercmp "$2" 4.5)" -lt 0 ]]; then
sed -i 's/X-kvmd\.pst-user=kvmd-pst/X-kvmd.pst-user=kvmd-pst,X-kvmd.pst-group=kvmd-pst/g' /etc/fstab
touch -t 200701011000 /etc/fstab
fi
# Some update deletes /etc/motd, WTF
# shellcheck disable=SC2015,SC2166
[ ! -f /etc/motd -a -f /etc/motd.pacsave ] && mv /etc/motd.pacsave /etc/motd || true
}

View File

@ -1,354 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import re
import shutil
import json
import time
import argparse
from os.path import join # pylint: disable=ungrouped-imports
from ...logging import get_logger
from ...yamlconf import Section
from ...validators import ValidatorError
from ... import usb
from .. import init
from .hid import Hid
from .hid.keyboard import make_keyboard_hid
from .hid.mouse import make_mouse_hid
# =====
def _mkdir(path: str) -> None:
get_logger().info("MKDIR --- %s", path)
os.mkdir(path)
def _chown(path: str, user: str, optional: bool=False) -> None:
logger = get_logger()
if optional and not os.access(path, os.F_OK):
logger.info("CHOWN --- %s - [SKIPPED] %s", user, path)
return
logger.info("CHOWN --- %s - %s", user, path)
shutil.chown(path, user)
def _symlink(src: str, dest: str) -> None:
get_logger().info("SYMLINK - %s --> %s", dest, src)
os.symlink(src, dest)
def _rmdir(path: str) -> None:
get_logger().info("RMDIR --- %s", path)
os.rmdir(path)
def _unlink(path: str, optional: bool=False) -> None:
logger = get_logger()
if optional and not os.access(path, os.F_OK):
logger.info("RM ------ [SKIPPED] %s", path)
return
logger.info("RM ------ %s", path)
os.unlink(path)
def _write(path: str, value: (str | int), optional: bool=False) -> None:
logger = get_logger()
if optional and not os.access(path, os.F_OK):
logger.info("WRITE --- [SKIPPED] %s", path)
return
logger.info("WRITE --- %s", path)
with open(path, "w") as file:
file.write(str(value))
def _write_bytes(path: str, data: bytes) -> None:
get_logger().info("WRITE --- %s", path)
with open(path, "wb") as file:
file.write(data)
def _check_config(config: Section) -> None:
if (
not config.otg.devices.serial.enabled
and not config.otg.devices.ethernet.enabled
and config.kvmd.hid.type != "otg"
and config.kvmd.msd.type != "otg"
):
raise RuntimeError("Nothing to do")
# =====
class _GadgetConfig:
def __init__(self, gadget_path: str, profile_path: str, meta_path: str) -> None:
self.__gadget_path = gadget_path
self.__profile_path = profile_path
self.__meta_path = meta_path
self.__hid_instance = 0
self.__msd_instance = 0
_mkdir(meta_path)
def add_serial(self, start: bool) -> None:
func = "acm.usb0"
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
if start:
_symlink(func_path, join(self.__profile_path, func))
self.__create_meta(func, "Serial Port")
def add_ethernet(self, start: bool, driver: str, host_mac: str, kvm_mac: str) -> None:
if host_mac and kvm_mac and host_mac == kvm_mac:
raise RuntimeError("Ethernet host_mac should not be equal to kvm_mac")
real_driver = driver
if driver == "rndis5":
real_driver = "rndis"
func = f"{real_driver}.usb0"
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
if host_mac:
_write(join(func_path, "host_addr"), host_mac)
if kvm_mac:
_write(join(func_path, "dev_addr"), kvm_mac)
if driver in ["ncm", "rndis"]:
_write(join(self.__gadget_path, "os_desc/use"), "1")
_write(join(self.__gadget_path, "os_desc/b_vendor_code"), "0xCD")
_write(join(self.__gadget_path, "os_desc/qw_sign"), "MSFT100")
if driver == "ncm":
_write(join(func_path, "os_desc/interface.ncm/compatible_id"), "WINNCM")
elif driver == "rndis":
# On Windows 7 and later, the RNDIS 5.1 driver would be used by default,
# but it does not work very well. The RNDIS 6.0 driver works better.
# In order to get this driver to load automatically, we have to use
# a Microsoft-specific extension of USB.
_write(join(func_path, "os_desc/interface.rndis/compatible_id"), "RNDIS")
_write(join(func_path, "os_desc/interface.rndis/sub_compatible_id"), "5162001")
_symlink(self.__profile_path, join(self.__gadget_path, "os_desc", usb.G_PROFILE_NAME))
if start:
_symlink(func_path, join(self.__profile_path, func))
self.__create_meta(func, "Ethernet")
def add_keyboard(self, start: bool, remote_wakeup: bool) -> None:
self.__add_hid("Keyboard", start, remote_wakeup, make_keyboard_hid())
def add_mouse(self, start: bool, remote_wakeup: bool, absolute: bool, horizontal_wheel: bool) -> None:
name = ("Absolute" if absolute else "Relative") + " Mouse"
self.__add_hid(name, start, remote_wakeup, make_mouse_hid(absolute, horizontal_wheel))
def __add_hid(self, name: str, start: bool, remote_wakeup: bool, hid: Hid) -> None:
func = f"hid.usb{self.__hid_instance}"
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
_write(join(func_path, "no_out_endpoint"), "1", optional=True)
if remote_wakeup:
_write(join(func_path, "wakeup_on_write"), "1", optional=True)
_write(join(func_path, "protocol"), hid.protocol)
_write(join(func_path, "subclass"), hid.subclass)
_write(join(func_path, "report_length"), hid.report_length)
_write_bytes(join(func_path, "report_desc"), hid.report_descriptor)
if start:
_symlink(func_path, join(self.__profile_path, func))
self.__create_meta(func, name)
self.__hid_instance += 1
def add_msd(self, start: bool, user: str, stall: bool, cdrom: bool, rw: bool, removable: bool, fua: bool) -> None:
func = f"mass_storage.usb{self.__msd_instance}"
func_path = join(self.__gadget_path, "functions", func)
_mkdir(func_path)
_write(join(func_path, "stall"), int(stall))
_write(join(func_path, "lun.0/cdrom"), int(cdrom))
_write(join(func_path, "lun.0/ro"), int(not rw))
_write(join(func_path, "lun.0/removable"), int(removable))
_write(join(func_path, "lun.0/nofua"), int(not fua))
if user != "root":
_chown(join(func_path, "lun.0/cdrom"), user)
_chown(join(func_path, "lun.0/ro"), user)
_chown(join(func_path, "lun.0/file"), user)
_chown(join(func_path, "lun.0/forced_eject"), user, optional=True)
if start:
_symlink(func_path, join(self.__profile_path, func))
name = ("Mass Storage Drive" if self.__msd_instance == 0 else f"Extra Drive #{self.__msd_instance}")
self.__create_meta(func, name)
self.__msd_instance += 1
def __create_meta(self, func: str, name: str) -> None:
_write(join(self.__meta_path, f"{func}@meta.json"), json.dumps({"func": func, "name": name}))
def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,too-many-branches
# https://www.kernel.org/doc/Documentation/usb/gadget_configfs.txt
# https://www.isticktoit.net/?p=1383
logger = get_logger()
_check_config(config)
udc = usb.find_udc(config.otg.udc)
logger.info("Using UDC %s", udc)
logger.info("Creating gadget %r ...", config.otg.gadget)
gadget_path = usb.get_gadget_path(config.otg.gadget)
_mkdir(gadget_path)
_write(join(gadget_path, "idVendor"), f"0x{config.otg.vendor_id:04X}")
_write(join(gadget_path, "idProduct"), f"0x{config.otg.product_id:04X}")
_write(join(gadget_path, "bcdUSB"), f"0x{config.otg.usb_version:04X}")
# bcdDevice should be incremented any time there are breaking changes
# to this script so that the host OS sees it as a new device
# and re-enumerates everything rather than relying on cached values.
device_version = config.otg.device_version
if device_version < 0:
device_version = 0x0100
if config.otg.devices.ethernet.enabled:
if config.otg.devices.ethernet.driver == "ncm":
device_version = 0x0102
elif config.otg.devices.ethernet.driver == "rndis":
device_version = 0x0101
_write(join(gadget_path, "bcdDevice"), f"0x{device_version:04X}")
lang_path = join(gadget_path, "strings/0x409")
_mkdir(lang_path)
_write(join(lang_path, "manufacturer"), config.otg.manufacturer)
_write(join(lang_path, "product"), config.otg.product)
if config.otg.serial is not None:
_write(join(lang_path, "serialnumber"), config.otg.serial)
profile_path = join(gadget_path, usb.G_PROFILE)
_mkdir(profile_path)
_mkdir(join(profile_path, "strings/0x409"))
_write(join(profile_path, "strings/0x409/configuration"), f"Config 1: {config.otg.config}")
_write(join(profile_path, "MaxPower"), config.otg.max_power)
if config.otg.remote_wakeup:
# XXX: Should we use MaxPower=100 with Remote Wakeup?
_write(join(profile_path, "bmAttributes"), "0xA0")
gc = _GadgetConfig(gadget_path, profile_path, config.otg.meta)
cod = config.otg.devices
if cod.serial.enabled:
logger.info("===== Serial =====")
gc.add_serial(cod.serial.start)
if cod.ethernet.enabled:
logger.info("===== Ethernet =====")
gc.add_ethernet(**cod.ethernet._unpack(ignore=["enabled"]))
if config.kvmd.hid.type == "otg":
logger.info("===== HID-Keyboard =====")
gc.add_keyboard(cod.hid.keyboard.start, config.otg.remote_wakeup)
logger.info("===== HID-Mouse =====")
gc.add_mouse(cod.hid.mouse.start, config.otg.remote_wakeup, config.kvmd.hid.mouse.absolute, config.kvmd.hid.mouse.horizontal_wheel)
if config.kvmd.hid.mouse_alt.device:
logger.info("===== HID-Mouse-Alt =====")
gc.add_mouse(cod.hid.mouse.start, config.otg.remote_wakeup, (not config.kvmd.hid.mouse.absolute), config.kvmd.hid.mouse.horizontal_wheel)
if config.kvmd.msd.type == "otg":
logger.info("===== MSD =====")
gc.add_msd(cod.msd.start, config.otg.user, **cod.msd.default._unpack())
if cod.drives.enabled:
for count in range(cod.drives.count):
logger.info("===== MSD Extra: %d =====", count + 1)
gc.add_msd(cod.drives.start, "root", **cod.drives.default._unpack())
logger.info("===== Preparing complete =====")
logger.info("Enabling the gadget ...")
_write(join(gadget_path, "UDC"), udc)
time.sleep(config.otg.init_delay)
_chown(join(gadget_path, "UDC"), config.otg.user)
_chown(profile_path, config.otg.user)
logger.info("Ready to work")
# =====
def _cmd_stop(config: Section) -> None:
# https://www.kernel.org/doc/Documentation/usb/gadget_configfs.txt
logger = get_logger()
_check_config(config)
gadget_path = usb.get_gadget_path(config.otg.gadget)
logger.info("Disabling gadget %r ...", config.otg.gadget)
_write(join(gadget_path, "UDC"), "\n")
_unlink(join(gadget_path, "os_desc", usb.G_PROFILE_NAME), optional=True)
profile_path = join(gadget_path, usb.G_PROFILE)
for func in os.listdir(profile_path):
if re.search(r"\.usb\d+$", func):
_unlink(join(profile_path, func))
_rmdir(join(profile_path, "strings/0x409"))
_rmdir(profile_path)
funcs_path = join(gadget_path, "functions")
for func in os.listdir(funcs_path):
if re.search(r"\.usb\d+$", func):
_rmdir(join(funcs_path, func))
_rmdir(join(gadget_path, "strings/0x409"))
_rmdir(gadget_path)
for meta in os.listdir(config.otg.meta):
_unlink(join(config.otg.meta, meta))
_rmdir(config.otg.meta)
logger.info("Bye-bye")
# =====
def main(argv: (list[str] | None)=None) -> None:
(parent_parser, argv, config) = init(
add_help=False,
argv=argv,
load_hid=True,
load_atx=True,
load_msd=True,
)
parser = argparse.ArgumentParser(
prog="kvmd-otg",
description="Control KVMD OTG device",
parents=[parent_parser],
)
parser.set_defaults(cmd=(lambda *_: parser.print_help()))
subparsers = parser.add_subparsers()
cmd_start_parser = subparsers.add_parser("start", help="Start OTG")
cmd_start_parser.set_defaults(cmd=_cmd_start)
cmd_stop_parser = subparsers.add_parser("stop", help="Stop OTG")
cmd_stop_parser.set_defaults(cmd=_cmd_stop)
options = parser.parse_args(argv[1:])
try:
options.cmd(config)
except ValidatorError as ex:
raise SystemExit(str(ex))

View File

@ -1,24 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from . import main
main()

View File

@ -1,32 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import dataclasses
# =====
@dataclasses.dataclass(frozen=True)
class Hid:
protocol: int
subclass: int
report_length: int
report_descriptor: bytes

View File

@ -1,86 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from . import Hid
# =====
def make_keyboard_hid(report_id: (int | None)=None) -> Hid:
return Hid(
protocol=1, # Keyboard protocol
subclass=1, # Boot interface subclass
report_length=8,
report_descriptor=bytes([
# Logitech descriptor. It's very similar to https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt
# Dumped using usbhid-dump; parsed using https://eleccelerator.com/usbdescreqparser
# Keyboard
0x05, 0x01, # USAGE_PAGE (Generic Desktop)
0x09, 0x06, # USAGE (Keyboard)
0xA1, 0x01, # COLLECTION (Application)
# Report ID
*([0x85, report_id] if report_id is not None else []),
# Modifiers
0x05, 0x07, # USAGE_PAGE (Keyboard)
0x19, 0xE0, # USAGE_MINIMUM (Keyboard LeftControl)
0x29, 0xE7, # USAGE_MAXIMUM (Keyboard Right GUI)
0x15, 0x00, # LOGICAL_MINIMUM (0)
0x25, 0x01, # LOGICAL_MAXIMUM (1)
0x75, 0x01, # REPORT_SIZE (1)
0x95, 0x08, # REPORT_COUNT (8)
0x81, 0x02, # INPUT (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
# Reserved byte
0x95, 0x01, # REPORT_COUNT (1)
0x75, 0x08, # REPORT_SIZE (8)
0x81, 0x01, # INPUT (Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position)
# LEDs output
0x95, 0x05, # REPORT_COUNT (5)
0x75, 0x01, # REPORT_SIZE (1)
0x05, 0x08, # USAGE_PAGE (LEDs)
0x19, 0x01, # USAGE_MINIMUM (Num Lock)
0x29, 0x05, # USAGE_MAXIMUM (Kana)
0x91, 0x02, # OUTPUT (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
# Reserved 3 bits in output
0x95, 0x01, # REPORT_COUNT (1)
0x75, 0x03, # REPORT_SIZE (3)
0x91, 0x01, # OUTPUT (Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
# 6 keys
0x95, 0x06, # REPORT_COUNT (6)
0x75, 0x08, # REPORT_SIZE (8)
0x15, 0x00, # LOGICAL_MINIMUM (0)
0x26, 0xFF, 0x00, # LOGICAL_MAXIMUM (0xFF)
0x05, 0x07, # USAGE_PAGE (Keyboard)
0x19, 0x00, # USAGE_MINIMUM (Reserved)
0x2A, 0xFF, 0x00, # USAGE_MAXIMUM (0xFF)
0x81, 0x00, # INPUT (Data,Array,Abs,No Wrap,Linear,Preferred State,No Null Position)
0xC0, # END_COLLECTION
]),
)

View File

@ -1,158 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from . import Hid
# =====
def make_mouse_hid(absolute: bool, horizontal_wheel: bool, report_id: (int | None)=None) -> Hid:
maker = (_make_absolute_hid if absolute else _make_relative_hid)
return maker(horizontal_wheel, report_id)
_HORIZONTAL_WHEEL = [
0x05, 0x0C, # USAGE PAGE (Consumer Devices)
0x0A, 0x38, 0x02, # USAGE (AC Pan)
0x15, 0x81, # LOGICAL_MINIMUM (-127)
0x25, 0x7F, # LOGICAL_MAXIMUM (127)
0x75, 0x08, # REPORT_SIZE (8)
0x95, 0x01, # REPORT_COUNT (1)
0x81, 0x06, # INPUT (Data,Var,Rel)
]
def _make_absolute_hid(horizontal_wheel: bool, report_id: (int | None)) -> Hid:
return Hid(
protocol=0, # None protocol
subclass=0, # No subclass
report_length=(7 if horizontal_wheel else 6),
report_descriptor=bytes([
# https://github.com/NicoHood/HID/blob/0835e6a/src/SingleReport/SingleAbsoluteMouse.cpp
# Репорт взят отсюда ^^^, но изменен диапазон значений координат перемещений.
# Автор предлагает использовать -32768...32767, но семерка почему-то не хочет работать
# с отрицательными значениями координат, как не хочет хавать 65536 и 32768.
# Так что мы ей скармливаем диапазон 0...32767, и передаем рукожопам из микрософта привет,
# потому что линуксы прекрасно работают с любыми двухбайтовыми диапазонами.
# Absolute mouse
0x05, 0x01, # USAGE_PAGE (Generic Desktop)
0x09, 0x02, # USAGE (Mouse)
0xA1, 0x01, # COLLECTION (Application)
# Report ID
*([0x85, report_id] if report_id is not None else []),
# Pointer and Physical are required by Apple Recovery
0x09, 0x01, # USAGE (Pointer)
0xA1, 0x00, # COLLECTION (Physical)
# 8 Buttons
0x05, 0x09, # USAGE_PAGE (Button)
0x19, 0x01, # USAGE_MINIMUM (Button 1)
0x29, 0x08, # USAGE_MAXIMUM (Button 8)
0x15, 0x00, # LOGICAL_MINIMUM (0)
0x25, 0x01, # LOGICAL_MAXIMUM (1)
0x95, 0x08, # REPORT_COUNT (8)
0x75, 0x01, # REPORT_SIZE (1)
0x81, 0x02, # INPUT (Data,Var,Abs)
# X, Y
0x05, 0x01, # USAGE_PAGE (Generic Desktop)
0x09, 0x30, # USAGE (X)
0x09, 0x31, # USAGE (Y)
0x16, 0x00, 0x00, # LOGICAL_MINIMUM (0)
0x26, 0xFF, 0x7F, # LOGICAL_MAXIMUM (32767)
0x75, 0x10, # REPORT_SIZE (16)
0x95, 0x02, # REPORT_COUNT (2)
0x81, 0x02, # INPUT (Data,Var,Abs)
# Wheel
0x09, 0x38, # USAGE (Wheel)
0x15, 0x81, # LOGICAL_MINIMUM (-127)
0x25, 0x7F, # LOGICAL_MAXIMUM (127)
0x75, 0x08, # REPORT_SIZE (8)
0x95, 0x01, # REPORT_COUNT (1)
0x81, 0x06, # INPUT (Data,Var,Rel)
*(_HORIZONTAL_WHEEL if horizontal_wheel else []),
# End
0xC0, # END_COLLECTION (Physical)
0xC0, # END_COLLECTION
]),
)
def _make_relative_hid(horizontal_wheel: bool, report_id: (int | None)) -> Hid:
return Hid(
protocol=2, # Mouse protocol
subclass=1, # Boot interface subclass
report_length=(5 if horizontal_wheel else 4),
report_descriptor=bytes([
# https://github.com/NicoHood/HID/blob/0835e6a/src/SingleReport/BootMouse.cpp
# Relative mouse
0x05, 0x01, # USAGE_PAGE (Generic Desktop)
0x09, 0x02, # USAGE (Mouse)
0xA1, 0x01, # COLLECTION (Application)
# Report ID
*([0x85, report_id] if report_id is not None else []),
# Pointer and Physical are required by Apple Recovery
0x09, 0x01, # USAGE (Pointer)
0xA1, 0x00, # COLLECTION (Physical)
# 8 Buttons
0x05, 0x09, # USAGE_PAGE (Button)
0x19, 0x01, # USAGE_MINIMUM (Button 1)
0x29, 0x08, # USAGE_MAXIMUM (Button 8)
0x15, 0x00, # LOGICAL_MINIMUM (0)
0x25, 0x01, # LOGICAL_MAXIMUM (1)
0x95, 0x08, # REPORT_COUNT (8)
0x75, 0x01, # REPORT_SIZE (1)
0x81, 0x02, # INPUT (Data,Var,Abs)
# X, Y
0x05, 0x01, # USAGE_PAGE (Generic Desktop)
0x09, 0x30, # USAGE (X)
0x09, 0x31, # USAGE (Y)
# Wheel
0x09, 0x38, # USAGE (Wheel)
0x15, 0x81, # LOGICAL_MINIMUM (-127)
0x25, 0x7F, # LOGICAL_MAXIMUM (127)
0x75, 0x08, # REPORT_SIZE (8)
0x95, 0x03, # REPORT_COUNT (3)
0x81, 0x06, # INPUT (Data,Var,Rel)
*(_HORIZONTAL_WHEEL if horizontal_wheel else []),
# End
0xC0, # END_COLLECTION (Physical)
0xC0, # END_COLLECTION
]),
)

View File

@ -1,190 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import json
import contextlib
import argparse
import time
from typing import Generator
import yaml
from ...validators.basic import valid_stripped_string_not_empty
from ... import usb
from .. import init
# =====
class _GadgetControl:
def __init__(self, meta_path: str, gadget: str, udc: str, init_delay: float) -> None:
self.__meta_path = meta_path
self.__gadget = gadget
self.__udc = udc
self.__init_delay = init_delay
@contextlib.contextmanager
def __udc_stopped(self) -> Generator[None, None, None]:
udc = usb.find_udc(self.__udc)
udc_path = usb.get_gadget_path(self.__gadget, usb.G_UDC)
with open(udc_path) as file:
enabled = bool(file.read().strip())
if enabled:
with open(udc_path, "w") as file:
file.write("\n")
try:
yield
finally:
self.__recreate_profile()
time.sleep(self.__init_delay)
with open(udc_path, "w") as file:
file.write(udc)
def __recreate_profile(self) -> None:
# XXX: See pikvm/pikvm#1235
# After unbind and bind, the gadgets stop working,
# unless we recreate their links in the profile.
# Some kind of kernel bug.
for func in os.listdir(self.__get_fdest_path()):
path = self.__get_fdest_path(func)
if os.path.islink(path):
try:
os.unlink(path)
os.symlink(self.__get_fsrc_path(func), path)
except (FileNotFoundError, FileExistsError):
pass
def __read_metas(self) -> Generator[dict, None, None]:
for meta_name in sorted(os.listdir(self.__meta_path)):
with open(os.path.join(self.__meta_path, meta_name)) as file:
yield json.loads(file.read())
def __get_fsrc_path(self, func: str) -> str:
return usb.get_gadget_path(self.__gadget, usb.G_FUNCTIONS, func)
def __get_fdest_path(self, func: (str | None)=None) -> str:
if func is None:
return usb.get_gadget_path(self.__gadget, usb.G_PROFILE)
return usb.get_gadget_path(self.__gadget, usb.G_PROFILE, func)
def enable_functions(self, funcs: list[str]) -> None:
with self.__udc_stopped():
for func in funcs:
os.symlink(self.__get_fsrc_path(func), self.__get_fdest_path(func))
def disable_functions(self, funcs: list[str]) -> None:
with self.__udc_stopped():
for func in funcs:
os.unlink(self.__get_fdest_path(func))
def list_functions(self) -> None:
for meta in self.__read_metas():
enabled = os.path.exists(self.__get_fdest_path(meta["func"]))
print(f"{'+' if enabled else '-'} {meta['func']} # {meta['name']}")
def make_gpio_config(self) -> None:
class Dumper(yaml.Dumper):
def increase_indent(self, flow: bool=False, indentless: bool=False) -> None:
_ = indentless
super().increase_indent(flow, False)
def ignore_aliases(self, data) -> bool: # type: ignore
_ = data
return True
class InlineList(list):
pass
def represent_inline_list(dumper: yaml.Dumper, data): # type: ignore
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
Dumper.add_representer(InlineList, represent_inline_list)
config = {
"drivers": {"otgconf": {"type": "otgconf"}},
"scheme": {},
"view": {"table": []},
}
for meta in self.__read_metas():
config["scheme"][meta["func"]] = { # type: ignore
"driver": "otgconf",
"pin": meta["func"],
"mode": "output",
"pulse": False,
}
config["view"]["table"].append(InlineList([ # type: ignore
"#" + meta["name"],
"#" + meta["func"],
meta["func"],
]))
print(yaml.dump({"kvmd": {"gpio": config}}, indent=4, Dumper=Dumper))
def reset(self) -> None:
with self.__udc_stopped():
pass
# =====
def main(argv: (list[str] | None)=None) -> None:
(parent_parser, argv, config) = init(
add_help=False,
cli_logging=True,
argv=argv,
)
parser = argparse.ArgumentParser(
prog="kvmd-otgconf",
description="KVMD OTG low-level runtime configuration tool",
parents=[parent_parser],
)
parser.add_argument("-l", "--list-functions", action="store_true", help="List functions")
parser.add_argument("-e", "--enable-function", nargs="+", metavar="<name>", help="Enable function(s)")
parser.add_argument("-d", "--disable-function", nargs="+", metavar="<name>", help="Disable function(s)")
parser.add_argument("-r", "--reset-gadget", action="store_true", help="Reset gadget")
parser.add_argument("--make-gpio-config", action="store_true")
options = parser.parse_args(argv[1:])
gc = _GadgetControl(config.otg.meta, config.otg.gadget, config.otg.udc, config.otg.init_delay)
if options.list_functions:
gc.list_functions()
elif options.enable_function:
funcs = list(map(valid_stripped_string_not_empty, options.enable_function))
gc.enable_functions(funcs)
gc.list_functions()
elif options.disable_function:
funcs = list(map(valid_stripped_string_not_empty, options.disable_function))
gc.disable_functions(funcs)
gc.list_functions()
elif options.reset_gadget:
gc.reset()
elif options.make_gpio_config:
gc.make_gpio_config()
else:
gc.list_functions()

View File

@ -1,24 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from . import main
main()

View File

@ -1,112 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import errno
import argparse
from ...validators.basic import valid_bool
from ...validators.basic import valid_int_f0
from ...validators.os import valid_abs_file
from ... import usb
from .. import init
# =====
def _has_param(gadget: str, instance: int, param: str) -> bool:
return os.access(_get_param_path(gadget, instance, param), os.F_OK)
def _get_param_path(gadget: str, instance: int, param: str) -> str:
return usb.get_gadget_path(gadget, usb.G_FUNCTIONS, f"mass_storage.usb{instance}/lun.0", param)
def _get_param(gadget: str, instance: int, param: str) -> str:
with open(_get_param_path(gadget, instance, param)) as file:
return file.read().strip()
def _set_param(gadget: str, instance: int, param: str, value: str) -> None:
try:
with open(_get_param_path(gadget, instance, param), "w") as file:
file.write(value + "\n")
except OSError as ex:
if ex.errno == errno.EBUSY:
raise SystemExit(f"Can't change {param!r} value because device is locked: {ex}")
raise
# =====
def main(argv: (list[str] | None)=None) -> None:
(parent_parser, argv, config) = init(
add_help=False,
cli_logging=True,
argv=argv,
load_msd=True,
)
parser = argparse.ArgumentParser(
prog="kvmd-otgmsd",
description="KVMD OTG-MSD low-level hand tool",
parents=[parent_parser],
)
parser.add_argument("-i", "--instance", default=0, type=valid_int_f0,
metavar="<N>", help="Drive instance (0 for KVMD drive)")
parser.add_argument("--set-cdrom", default=None, type=valid_bool,
metavar="<1|0|yes|no>", help="Set CD-ROM flag")
parser.add_argument("--set-rw", default=None, type=valid_bool,
metavar="<1|0|yes|no>", help="Set RW flag")
parser.add_argument("--set-image", default=None, type=valid_abs_file,
metavar="<path>", help="Set the image file")
parser.add_argument("--eject", action="store_true",
help="Eject the image")
parser.add_argument("--unlock", action="store_true",
help="Does nothing, just for backward compatibility")
options = parser.parse_args(argv[1:])
if config.kvmd.msd.type != "otg":
raise SystemExit(f"Error: KVMD MSD not using 'otg'"
f" (now configured {config.kvmd.msd.type!r})")
has_param = (lambda param: _has_param(config.otg.gadget, options.instance, param))
set_param = (lambda param, value: _set_param(config.otg.gadget, options.instance, param, value))
get_param = (lambda param: _get_param(config.otg.gadget, options.instance, param))
if options.eject:
if has_param("forced_eject"):
set_param("forced_eject", "")
else:
set_param("file", "")
if options.set_cdrom is not None:
set_param("cdrom", str(int(options.set_cdrom)))
if options.set_rw is not None:
set_param("ro", str(int(not options.set_rw)))
if options.set_image:
if not os.path.isfile(options.set_image):
raise SystemExit(f"Not a file: {options.set_image}")
set_param("file", options.set_image)
print("Image file: ", (get_param("file") or "<none>"))
print("CD-ROM flag:", ("yes" if int(get_param("cdrom")) else "no"))
print("RW flag: ", ("no" if int(get_param("ro")) else "yes"))

View File

@ -1,24 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from . import main
main()

View File

@ -1,209 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import ipaddress
import dataclasses
import itertools
import argparse
from ...logging import get_logger
from ...yamlconf import Section
from ... import tools
from ... import aioproc
from ... import usb
from .. import init
from .netctl import BaseCtl
from .netctl import IfaceUpCtl
from .netctl import IfaceAddIpCtl
from .netctl import IptablesAllowEstRelCtl
from .netctl import IptablesDropAllCtl
from .netctl import IptablesAllowIcmpCtl
from .netctl import IptablesAllowPortCtl
from .netctl import IptablesForwardOut
from .netctl import IptablesForwardIn
from .netctl import CustomCtl
# =====
@dataclasses.dataclass(frozen=True)
class _Netcfg: # pylint: disable=too-many-instance-attributes
iface: str
iface_ip: str
net_ip: str
net_prefix: int
net_mask: str
dhcp_ip_begin: str
dhcp_ip_end: str
dhcp_option_3: str
class _Service: # pylint: disable=too-many-instance-attributes
def __init__(self, config: Section) -> None:
self.__iface_net: str = config.otgnet.iface.net
self.__ip_cmd: list[str] = config.otgnet.iface.ip_cmd
self.__allow_icmp: bool = config.otgnet.firewall.allow_icmp
self.__allow_tcp: list[int] = sorted(set(config.otgnet.firewall.allow_tcp))
self.__allow_udp: list[int] = sorted(set(config.otgnet.firewall.allow_udp))
self.__forward_iface: str = config.otgnet.firewall.forward_iface
self.__iptables_cmd: list[str] = config.otgnet.firewall.iptables_cmd
def build_cmd(key: str) -> list[str]:
return tools.build_cmd(
getattr(config.otgnet.commands, key),
getattr(config.otgnet.commands, f"{key}_remove"),
getattr(config.otgnet.commands, f"{key}_append"),
)
self.__pre_start_cmd: list[str] = build_cmd("pre_start_cmd")
self.__post_start_cmd: list[str] = build_cmd("post_start_cmd")
self.__pre_stop_cmd: list[str] = build_cmd("pre_stop_cmd")
self.__post_stop_cmd: list[str] = build_cmd("post_stop_cmd")
self.__gadget: str = config.otg.gadget
self.__driver: str = config.otg.devices.ethernet.driver
def start(self) -> None:
asyncio.run(self.__run(True))
def stop(self) -> None:
asyncio.run(self.__run(False))
async def __run(self, direct: bool) -> None:
netcfg = self.__make_netcfg()
placeholders = {
key: str(value)
for (key, value) in dataclasses.asdict(netcfg).items()
}
ctls: list[BaseCtl] = [
CustomCtl(self.__pre_start_cmd, self.__post_stop_cmd, placeholders),
IfaceUpCtl(self.__ip_cmd, netcfg.iface),
IptablesAllowEstRelCtl(self.__iptables_cmd, netcfg.iface),
*([IptablesAllowIcmpCtl(self.__iptables_cmd, netcfg.iface)] if self.__allow_icmp else []),
*[
IptablesAllowPortCtl(self.__iptables_cmd, netcfg.iface, port, tcp)
for (port, tcp) in [
*zip(self.__allow_tcp, itertools.repeat(True)),
*zip(self.__allow_udp, itertools.repeat(False)),
]
],
*([IptablesForwardOut(self.__iptables_cmd, self.__forward_iface)] if self.__forward_iface else []),
*([IptablesForwardIn(self.__iptables_cmd, netcfg.iface)] if self.__forward_iface else []),
IptablesDropAllCtl(self.__iptables_cmd, netcfg.iface),
IfaceAddIpCtl(self.__ip_cmd, netcfg.iface, f"{netcfg.iface_ip}/{netcfg.net_prefix}"),
CustomCtl(self.__post_start_cmd, self.__pre_stop_cmd, placeholders),
]
if direct:
for ctl in ctls:
if not (await self.__run_ctl(ctl, True)):
raise SystemExit(1)
get_logger(0).info("Ready to work")
else:
for ctl in reversed(ctls):
await self.__run_ctl(ctl, False)
get_logger(0).info("Bye-bye")
async def __run_ctl(self, ctl: BaseCtl, direct: bool) -> bool:
logger = get_logger()
cmd = ctl.get_command(direct)
logger.info("CMD: %s", tools.cmdfmt(cmd))
try:
return (not (await aioproc.log_process(cmd, logger)).returncode)
except Exception as ex:
logger.exception("Can't execute command: %s", ex)
return False
# =====
def __make_netcfg(self) -> _Netcfg:
iface = self.__find_iface()
logger = get_logger()
logger.info("Using IPv4 network %s ...", self.__iface_net)
net = ipaddress.IPv4Network(self.__iface_net)
if net.prefixlen > 31:
raise RuntimeError("Too small network, required at least /31")
if net.prefixlen == 31:
iface_ip = str(net[0])
dhcp_ip_begin = dhcp_ip_end = str(net[1])
else:
iface_ip = str(net[1])
dhcp_ip_begin = str(net[2])
dhcp_ip_end = str(net[-2])
netcfg = _Netcfg(
iface=iface,
iface_ip=iface_ip,
net_ip=str(net.network_address),
net_prefix=net.prefixlen,
net_mask=str(net.netmask),
dhcp_ip_begin=dhcp_ip_begin,
dhcp_ip_end=dhcp_ip_end,
dhcp_option_3=(f"3,{iface_ip}" if self.__forward_iface else "3"),
)
logger.info("Calculated %r address is %s/%d", iface, iface_ip, netcfg.net_prefix)
return netcfg
def __find_iface(self) -> str:
logger = get_logger()
real_driver = self.__driver
if self.__driver == "rndis5":
real_driver = "rndis"
path = usb.get_gadget_path(self.__gadget, usb.G_FUNCTIONS, f"{real_driver}.usb0/ifname")
logger.info("Using OTG gadget %r ...", self.__gadget)
with open(path) as file:
iface = file.read().strip()
logger.info("Using OTG Ethernet interface %r ...", iface)
assert iface
return iface
# =====
def main(argv: (list[str] | None)=None) -> None:
(parent_parser, argv, config) = init(
add_help=False,
argv=argv,
)
parser = argparse.ArgumentParser(
prog="kvmd-otgnet",
description="Control KVMD OTG network",
parents=[parent_parser],
)
parser.set_defaults(cmd=(lambda *_: parser.print_help()))
subparsers = parser.add_subparsers()
service = _Service(config)
cmd_start_parser = subparsers.add_parser("start", help="Start OTG network")
cmd_start_parser.set_defaults(cmd=service.start)
cmd_stop_parser = subparsers.add_parser("stop", help="Stop OTG network")
cmd_stop_parser.set_defaults(cmd=service.stop)
options = parser.parse_args(argv[1:])
options.cmd()

View File

@ -1,24 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from . import main
main()

View File

@ -1,140 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
# =====
class BaseCtl:
def get_command(self, direct: bool) -> list[str]:
raise NotImplementedError
class IfaceUpCtl(BaseCtl):
def __init__(self, base_cmd: list[str], iface: str) -> None:
self.__base_cmd = base_cmd
self.__iface = iface
def get_command(self, direct: bool) -> list[str]:
return [*self.__base_cmd, "link", "set", self.__iface, ("up" if direct else "down")]
class IfaceAddIpCtl(BaseCtl):
def __init__(self, base_cmd: list[str], iface: str, cidr: str) -> None:
self.__base_cmd = base_cmd
self.__iface = iface
self.__cidr = cidr
def get_command(self, direct: bool) -> list[str]:
return [*self.__base_cmd, "address", ("add" if direct else "del"), self.__cidr, "dev", self.__iface]
class IptablesAllowEstRelCtl(BaseCtl):
def __init__(self, base_cmd: list[str], iface: str) -> None:
self.__base_cmd = base_cmd
self.__iface = iface
def get_command(self, direct: bool) -> list[str]:
return [
*self.__base_cmd,
("-A" if direct else "-D"), "INPUT", "-i", self.__iface,
"-m", "state", "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT",
]
class IptablesDropAllCtl(BaseCtl):
def __init__(self, base_cmd: list[str], iface: str) -> None:
self.__base_cmd = base_cmd
self.__iface = iface
def get_command(self, direct: bool) -> list[str]:
return [*self.__base_cmd, ("-A" if direct else "-D"), "INPUT", "-i", self.__iface, "-j", "DROP"]
class IptablesAllowIcmpCtl(BaseCtl):
def __init__(self, base_cmd: list[str], iface: str) -> None:
self.__base_cmd = base_cmd
self.__iface = iface
def get_command(self, direct: bool) -> list[str]:
return [
*self.__base_cmd,
("-A" if direct else "-D"), "INPUT", "-i", self.__iface, "-p", "icmp", "-j", "ACCEPT",
]
class IptablesAllowPortCtl(BaseCtl):
def __init__(self, base_cmd: list[str], iface: str, port: int, tcp: bool) -> None:
self.__base_cmd = base_cmd
self.__iface = iface
self.__port = port
self.__proto = ("tcp" if tcp else "udp")
def get_command(self, direct: bool) -> list[str]:
return [
*self.__base_cmd,
("-A" if direct else "-D"), "INPUT", "-i", self.__iface, "-p", self.__proto,
"--dport", str(self.__port), "-j", "ACCEPT",
]
class IptablesForwardOut(BaseCtl):
def __init__(self, base_cmd: list[str], iface: str) -> None:
self.__base_cmd = base_cmd
self.__iface = iface
def get_command(self, direct: bool) -> list[str]:
return [
*self.__base_cmd,
"--table", "nat",
("-A" if direct else "-D"), "POSTROUTING",
"-o", self.__iface, "-j", "MASQUERADE",
]
class IptablesForwardIn(BaseCtl):
def __init__(self, base_cmd: list[str], iface: str) -> None:
self.__base_cmd = base_cmd
self.__iface = iface
def get_command(self, direct: bool) -> list[str]:
return [
*self.__base_cmd,
("-A" if direct else "-D"), "FORWARD",
"-i", self.__iface, "-j", "ACCEPT",
]
class CustomCtl(BaseCtl):
def __init__(
self,
direct_cmd: list[str],
reverse_cmd: list[str],
placeholders: dict[str, str],
) -> None:
self.__direct_cmd = direct_cmd
self.__reverse_cmd = reverse_cmd
self.__placeholders = placeholders
def get_command(self, direct: bool) -> list[str]:
return [
part.format(**self.__placeholders)
for part in (self.__direct_cmd if direct else self.__reverse_cmd)
]

View File

@ -1,211 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import copy
from typing import AsyncGenerator
import gpiod
from ...logging import get_logger
from ... import aiotools
from ... import aiogp
from ...yamlconf import Option
from ...validators.basic import valid_bool
from ...validators.basic import valid_float_f0
from ...validators.basic import valid_float_f01
from ...validators.os import valid_abs_path
from ...validators.hw import valid_gpio_pin
from . import AtxIsBusyError
from . import BaseAtx
# =====
class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=too-many-arguments,super-init-not-called
self,
device_path: str,
power_led_pin: int,
power_led_inverted: bool,
power_led_debounce: float,
hdd_led_pin: int,
hdd_led_inverted: bool,
hdd_led_debounce: float,
power_switch_pin: int,
reset_switch_pin: int,
click_delay: float,
long_click_delay: float,
) -> None:
self.__device_path = device_path
self.__power_led_pin = power_led_pin
self.__hdd_led_pin = hdd_led_pin
self.__power_switch_pin = power_switch_pin
self.__reset_switch_pin = reset_switch_pin
self.__click_delay = click_delay
self.__long_click_delay = long_click_delay
self.__notifier = aiotools.AioNotifier()
self.__region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier)
self.__line_req: (gpiod.LineRequest | None) = None
self.__reader = aiogp.AioReader(
path=self.__device_path,
consumer="kvmd::atx",
pins={
power_led_pin: aiogp.AioReaderPinParams(power_led_inverted, power_led_debounce),
hdd_led_pin: aiogp.AioReaderPinParams(hdd_led_inverted, hdd_led_debounce),
},
notifier=self.__notifier,
)
@classmethod
def get_plugin_options(cls) -> dict:
return {
"device": Option("/dev/gpiochip0", type=valid_abs_path, unpack_as="device_path"),
"power_led_pin": Option(24, type=valid_gpio_pin),
"power_led_inverted": Option(False, type=valid_bool),
"power_led_debounce": Option(0.1, type=valid_float_f0),
"hdd_led_pin": Option(22, type=valid_gpio_pin),
"hdd_led_inverted": Option(False, type=valid_bool),
"hdd_led_debounce": Option(0.1, type=valid_float_f0),
"power_switch_pin": Option(23, type=valid_gpio_pin),
"reset_switch_pin": Option(27, type=valid_gpio_pin),
"click_delay": Option(0.1, type=valid_float_f01),
"long_click_delay": Option(5.5, type=valid_float_f01),
}
def sysprep(self) -> None:
assert self.__line_req is None
self.__line_req = gpiod.request_lines(
self.__device_path,
consumer="kvmd::atx",
config={
(self.__power_switch_pin, self.__reset_switch_pin): gpiod.LineSettings(
direction=gpiod.line.Direction.OUTPUT,
output_value=gpiod.line.Value(False),
),
},
)
async def get_state(self) -> dict:
return {
"enabled": True,
"busy": self.__region.is_busy(),
"leds": {
"power": self.__reader.get(self.__power_led_pin),
"hdd": self.__reader.get(self.__hdd_led_pin),
},
}
async def trigger_state(self) -> None:
self.__notifier.notify(1)
async def poll_state(self) -> AsyncGenerator[dict, None]:
prev: dict = {}
while True:
if (await self.__notifier.wait()) > 0:
prev = {}
new = await self.get_state()
if new != prev:
prev = copy.deepcopy(new)
yield new
async def systask(self) -> None:
await self.__reader.poll()
async def cleanup(self) -> None:
if self.__line_req:
try:
self.__line_req.release()
except Exception:
pass
# =====
async def power_on(self, wait: bool) -> None:
if not (await self.__get_power()):
await self.click_power(wait)
async def power_off(self, wait: bool) -> None:
if (await self.__get_power()):
await self.click_power(wait)
async def power_off_hard(self, wait: bool) -> None:
if (await self.__get_power()):
await self.click_power_long(wait)
async def power_reset_hard(self, wait: bool) -> None:
if (await self.__get_power()):
await self.click_reset(wait)
# =====
async def click_power(self, wait: bool) -> None:
await self.__click("power", self.__power_switch_pin, self.__click_delay, wait)
async def click_power_long(self, wait: bool) -> None:
await self.__click("power_long", self.__power_switch_pin, self.__long_click_delay, wait)
async def click_reset(self, wait: bool) -> None:
await self.__click("reset", self.__reset_switch_pin, self.__click_delay, wait)
# =====
async def __get_power(self) -> bool:
return (await self.get_state())["leds"]["power"]
@aiotools.atomic_fg
async def __click(self, name: str, pin: int, delay: float, wait: bool) -> None:
if wait:
with self.__region:
await self.__inner_click(name, pin, delay)
else:
await aiotools.run_region_task(
f"Can't perform ATX {name} click or operation was not completed",
self.__region, self.__inner_click, name, pin, delay,
)
@aiotools.atomic_fg
async def __inner_click(self, name: str, pin: int, delay: float) -> None:
assert self.__line_req
try:
self.__line_req.set_value(pin, gpiod.line.Value(True))
await asyncio.sleep(delay)
finally:
self.__line_req.set_value(pin, gpiod.line.Value(False))
await asyncio.sleep(1)
get_logger(0).info("Clicked ATX button %r", name)

View File

@ -1,237 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import copy
from typing import AsyncGenerator
from typing import Any
from ....logging import get_logger
from .... import aiomulti
from .... import usb
from ....yamlconf import Option
from ....validators.basic import valid_bool
from ....validators.basic import valid_int_f1
from ....validators.basic import valid_float_f01
from ....validators.os import valid_abs_path
from .. import BaseHid
from .keyboard import KeyboardProcess
from .mouse import MouseProcess
# =====
class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes
def __init__(
self,
ignore_keys: list[str],
mouse_x_range: dict[str, Any],
mouse_y_range: dict[str, Any],
jiggler: dict[str, Any],
keyboard: dict[str, Any],
mouse: dict[str, Any],
mouse_alt: dict[str, Any],
noop: bool,
udc: str, # XXX: Not from options, see /kvmd/apps/kvmd/__init__.py for details
) -> None:
super().__init__(ignore_keys=ignore_keys, **mouse_x_range, **mouse_y_range, **jiggler)
self.__udc = udc
self.__notifier = aiomulti.AioProcessNotifier()
win98_fix = mouse.pop("absolute_win98_fix")
common = {"notifier": self.__notifier, "noop": noop}
self.__keyboard_proc = KeyboardProcess(**common, **keyboard)
self.__mouse_current = self.__mouse_proc = MouseProcess(**common, **mouse)
self.__mouse_alt_proc: (MouseProcess | None) = None
self.__mouses: dict[str, MouseProcess] = {}
if mouse_alt["device_path"]:
self.__mouse_alt_proc = MouseProcess(
absolute=(not mouse["absolute"]),
**common,
**mouse_alt,
)
self.__mouses = {
"usb": (self.__mouse_proc if mouse["absolute"] else self.__mouse_alt_proc),
"usb_rel": (self.__mouse_alt_proc if mouse["absolute"] else self.__mouse_proc),
}
if win98_fix:
# На самом деле мультимышка и win95 не зависят друг от друга,
# но так было проще реализовать переключение режимов
self.__mouses["usb_win98"] = self.__mouses["usb"]
self._set_jiggler_absolute(self.__mouse_current.is_absolute())
@classmethod
def get_plugin_options(cls) -> dict:
return {
"keyboard": {
"device": Option("/dev/kvmd-hid-keyboard", type=valid_abs_path, unpack_as="device_path"),
"select_timeout": Option(0.1, type=valid_float_f01),
"queue_timeout": Option(0.1, type=valid_float_f01),
"write_retries": Option(150, type=valid_int_f1),
},
"mouse": {
"device": Option("/dev/kvmd-hid-mouse", type=valid_abs_path, unpack_as="device_path"),
"select_timeout": Option(0.1, type=valid_float_f01),
"queue_timeout": Option(0.1, type=valid_float_f01),
"write_retries": Option(150, type=valid_int_f1),
"absolute": Option(True, type=valid_bool),
"absolute_win98_fix": Option(False, type=valid_bool),
"horizontal_wheel": Option(True, type=valid_bool),
},
"mouse_alt": {
"device": Option("", type=valid_abs_path, if_empty="", unpack_as="device_path"),
"select_timeout": Option(0.1, type=valid_float_f01),
"queue_timeout": Option(0.1, type=valid_float_f01),
"write_retries": Option(150, type=valid_int_f1),
# No absolute option here, initialized by (not mouse.absolute)
# Also no absolute_win98_fix
"horizontal_wheel": Option(True, type=valid_bool),
},
"noop": Option(False, type=valid_bool),
**cls._get_base_options(),
}
def sysprep(self) -> None:
udc = usb.find_udc(self.__udc)
get_logger(0).info("Using UDC %s", udc)
self.__keyboard_proc.start(udc)
self.__mouse_proc.start(udc)
if self.__mouse_alt_proc:
self.__mouse_alt_proc.start(udc)
async def get_state(self) -> dict:
keyboard_state = await self.__keyboard_proc.get_state()
mouse_state = await self.__mouse_current.get_state()
return {
"enabled": True,
"online": True,
"busy": False,
"connected": None,
"keyboard": {
"online": keyboard_state["online"],
"leds": {
"caps": keyboard_state["caps"],
"scroll": keyboard_state["scroll"],
"num": keyboard_state["num"],
},
"outputs": {"available": [], "active": ""},
},
"mouse": {
"outputs": {
"available": list(self.__mouses),
"active": self.__get_current_mouse_mode(),
},
**mouse_state,
},
**self._get_jiggler_state(),
}
async def trigger_state(self) -> None:
self.__notifier.notify(1)
async def poll_state(self) -> AsyncGenerator[dict, None]:
prev: dict = {}
while True:
if (await self.__notifier.wait()) > 0:
prev = {}
new = await self.get_state()
if new != prev:
prev = copy.deepcopy(new)
yield new
async def reset(self) -> None:
self.__keyboard_proc.send_reset_event()
self.__mouse_proc.send_reset_event()
if self.__mouse_alt_proc:
self.__mouse_alt_proc.send_reset_event()
async def cleanup(self) -> None:
try:
self.__keyboard_proc.cleanup()
finally:
try:
self.__mouse_proc.cleanup()
finally:
if self.__mouse_alt_proc:
self.__mouse_alt_proc.cleanup()
# =====
def set_params(
self,
keyboard_output: (str | None)=None,
mouse_output: (str | None)=None,
jiggler: (bool | None)=None,
) -> None:
_ = keyboard_output
if mouse_output in self.__mouses and mouse_output != self.__get_current_mouse_mode():
self.__mouse_current.send_clear_event()
self.__mouse_current = self.__mouses[mouse_output]
self.__mouse_current.set_win98_fix(mouse_output == "usb_win98")
self._set_jiggler_absolute(self.__mouse_current.is_absolute())
self.__notifier.notify()
if jiggler is not None:
self._set_jiggler_active(jiggler)
self.__notifier.notify()
def _send_key_event(self, key: str, state: bool) -> None:
self.__keyboard_proc.send_key_event(key, state)
def _send_mouse_button_event(self, button: str, state: bool) -> None:
self.__mouse_current.send_button_event(button, state)
def _send_mouse_move_event(self, to_x: int, to_y: int) -> None:
self.__mouse_current.send_move_event(to_x, to_y)
def _send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None:
self.__mouse_current.send_relative_event(delta_x, delta_y)
def _send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None:
self.__mouse_current.send_wheel_event(delta_x, delta_y)
def _clear_events(self) -> None:
self.__keyboard_proc.send_clear_event()
self.__mouse_proc.send_clear_event()
if self.__mouse_alt_proc:
self.__mouse_alt_proc.send_clear_event()
# =====
def __get_current_mouse_mode(self) -> str:
if len(self.__mouses) == 0:
return ""
if self.__mouse_current.is_absolute():
return ("usb_win98" if self.__mouse_current.get_win98_fix() else "usb")
return "usb_rel"

View File

@ -1,284 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import select
import multiprocessing
import queue
import errno
import logging
import time
from typing import Generator
from ....logging import get_logger
from .... import tools
from .... import aiomulti
from .... import aioproc
from .... import usb
from .events import BaseEvent
# =====
class BaseDeviceProcess(multiprocessing.Process): # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=too-many-arguments
self,
name: str,
read_size: int,
initial_state: dict,
notifier: aiomulti.AioProcessNotifier,
device_path: str,
select_timeout: float,
queue_timeout: float,
write_retries: int,
noop: bool,
) -> None:
super().__init__(daemon=True)
self.__name = name
self.__read_size = read_size
self.__device_path = device_path
self.__select_timeout = select_timeout
self.__queue_timeout = queue_timeout
self.__write_retries = write_retries
self.__noop = noop
self.__udc_state_path = ""
self.__fd = -1
self.__events_queue: "multiprocessing.Queue[BaseEvent]" = multiprocessing.Queue()
self.__state_flags = aiomulti.AioSharedFlags({"online": True, **initial_state}, notifier)
self.__stop_event = multiprocessing.Event()
self.__no_device_reported = False
self.__logger: (logging.Logger | None) = None
def start(self, udc: str) -> None: # type: ignore # pylint: disable=arguments-differ
self.__udc_state_path = usb.get_udc_path(udc, usb.U_STATE)
super().start()
def run(self) -> None: # pylint: disable=too-many-branches
self.__logger = aioproc.settle(f"HID-{self.__name}", f"hid-{self.__name}")
report = b""
retries = 0
while not self.__stop_event.is_set():
try:
while not self.__stop_event.is_set():
if self.__ensure_device():
self.__read_all_reports()
try:
event = self.__events_queue.get(timeout=self.__queue_timeout)
except queue.Empty:
# Проблема в том, что устройство может отвечать EAGAIN или ESHUTDOWN,
# если оно было отключено физически. См:
# - https://github.com/raspberrypi/linux/issues/3870
# - https://github.com/raspberrypi/linux/pull/3151
# Так что нам нужно проверять состояние контроллера, чтобы не спамить
# в устройство и отслеживать его состояние.
if not self.__is_udc_configured():
self.__state_flags.update(online=False)
else:
# Посылка свежих репортов важнее старого
for report in self._process_event(event):
retries = self.__write_retries
if self.__ensure_device():
if self.__write_report(report):
retries = 0
continue
# Повторение последнего репорта до победного или пока не кончатся попытки
if retries > 0 and self.__ensure_device():
if self.__write_report(report):
retries = 0
else:
retries -= 1
except Exception:
self.__logger.exception("Unexpected HID-%s error", self.__name)
time.sleep(1)
self.__close_device()
async def get_state(self) -> dict:
return (await self.__state_flags.get())
# =====
def _process_event(self, event: BaseEvent) -> Generator[bytes, None, None]:
_ = event
if self is not None: # XXX: Vulture and pylint hack
raise NotImplementedError()
yield
def _process_read_report(self, report: bytes) -> None:
pass
def _update_state(self, **kwargs: bool) -> None:
assert "online" not in kwargs
self.__state_flags.update(**kwargs)
# =====
def _stop(self) -> None:
if self.is_alive():
get_logger().info("Stopping HID-%s daemon ...", self.__name)
self.__stop_event.set()
if self.is_alive() or self.exitcode is not None:
self.join()
def _queue_event(self, event: BaseEvent) -> None:
self.__events_queue.put_nowait(event)
def _clear_queue(self) -> None:
tools.clear_queue(self.__events_queue)
def _cleanup_write(self, report: bytes) -> None:
assert not self.is_alive()
assert self.__fd < 0
if self.__ensure_device():
self.__write_report(report)
self.__close_device()
# =====
def __get_logger(self) -> logging.Logger:
# Внутри процесса логгер из цикла, снаружи - каждый раз берем новый
if self.__logger is not None:
return self.__logger
return get_logger()
def __is_udc_configured(self) -> bool:
with open(self.__udc_state_path) as file:
return (file.read().strip().lower() == "configured")
def __write_report(self, report: bytes) -> bool:
assert report
if self.__noop:
return True
assert self.__fd >= 0
logger = self.__get_logger()
try:
written = os.write(self.__fd, report)
if written == len(report):
self.__state_flags.update(online=True)
return True
else:
logger.error("HID-%s write() error: written (%s) != report length (%d)",
self.__name, written, len(report))
except Exception as ex:
if isinstance(ex, OSError) and (
# https://github.com/raspberrypi/linux/commit/61b7f805dc2fd364e0df682de89227e94ce88e25
ex.errno == errno.EAGAIN # pylint: disable=no-member
or ex.errno == errno.ESHUTDOWN # pylint: disable=no-member
):
logger.debug("HID-%s busy/unplugged (write): %s", self.__name, tools.efmt(ex))
else:
logger.exception("Can't write report to HID-%s", self.__name)
self.__state_flags.update(online=False)
return False
def __read_all_reports(self) -> None:
if self.__noop or self.__read_size == 0:
return
assert self.__fd >= 0
logger = self.__get_logger()
read = True
while read:
try:
read = bool(select.select([self.__fd], [], [], 0)[0])
except Exception as ex:
logger.error("Can't select() for read HID-%s: %s", self.__name, tools.efmt(ex))
break
if read:
try:
report = os.read(self.__fd, self.__read_size)
except Exception as ex:
if isinstance(ex, OSError) and ex.errno == errno.EAGAIN: # pylint: disable=no-member
logger.debug("HID-%s busy/unplugged (read): %s", self.__name, tools.efmt(ex))
else:
logger.exception("Can't read report from HID-%s", self.__name)
else:
self._process_read_report(report)
def __ensure_device(self) -> bool:
if self.__noop:
return True
logger = self.__get_logger()
if not os.path.exists(self.__device_path):
# Во-первых, не пытаемся открыть устройство, если его нет.
# Во-вторых, если у нас из под ног вытаскивают UDC, то надо закрыть устройство,
# чтобы избежать гонки при пересоздании оного.
self.__close_device()
self.__state_flags.update(online=False)
if not self.__no_device_reported:
logger.error("Missing HID-%s device: %s", self.__name, self.__device_path)
self.__no_device_reported = True
return False
self.__no_device_reported = False
if self.__fd < 0:
if os.path.exists(self.__device_path):
try:
flags = os.O_NONBLOCK
flags |= (os.O_RDWR if self.__read_size else os.O_WRONLY)
self.__fd = os.open(self.__device_path, flags)
except Exception as ex:
logger.error("Can't open HID-%s device %s: %s",
self.__name, self.__device_path, tools.efmt(ex))
if self.__fd >= 0:
try:
if select.select([], [self.__fd], [], self.__select_timeout)[1]:
# Закомментировано, потому что иногда запись доступна, но устройство отключено
# self.__state_flags.update(online=True)
return True
else:
# Если запись недоступна, то скорее всего устройство отключено
logger.debug("HID-%s is busy/unplugged (write select)", self.__name)
except Exception as ex:
logger.error("Can't select() for write HID-%s: %s", self.__name, tools.efmt(ex))
self.__state_flags.update(online=False)
return False
def __close_device(self) -> None:
if self.__fd >= 0:
try:
os.close(self.__fd)
except Exception:
pass
finally:
self.__fd = -1

View File

@ -1,176 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import struct
import dataclasses
from ....keyboard.mappings import UsbKey
from ....keyboard.mappings import KEYMAP
from ....mouse import MouseRange
# =====
class BaseEvent:
pass
class ClearEvent(BaseEvent):
pass
class ResetEvent(BaseEvent):
pass
# =====
@dataclasses.dataclass(frozen=True)
class KeyEvent(BaseEvent):
key: UsbKey
state: bool
def __post_init__(self) -> None:
assert (not self.key.is_modifier)
@dataclasses.dataclass(frozen=True)
class ModifierEvent(BaseEvent):
modifier: UsbKey
state: bool
def __post_init__(self) -> None:
assert self.modifier.is_modifier
def make_keyboard_event(key: str, state: bool) -> (KeyEvent | ModifierEvent):
usb_key = KEYMAP[key].usb
if usb_key.is_modifier:
return ModifierEvent(usb_key, state)
return KeyEvent(usb_key, state)
def get_led_caps(flags: int) -> bool:
# https://wiki.osdev.org/USB_Human_Interface_Devices#LED_lamps
return bool(flags & 2)
def get_led_scroll(flags: int) -> bool:
return bool(flags & 4)
def get_led_num(flags: int) -> bool:
return bool(flags & 1)
def make_keyboard_report(
pressed_modifiers: set[UsbKey],
pressed_keys: list[UsbKey | None],
) -> bytes:
modifiers = 0
for modifier in pressed_modifiers:
modifiers |= modifier.code
assert len(pressed_keys) == 6
keys = [
(0 if key is None else key.code)
for key in pressed_keys
]
return bytes([modifiers, 0] + keys)
# =====
@dataclasses.dataclass(frozen=True)
class MouseButtonEvent(BaseEvent):
button: str
state: bool
code: int = 0
def __post_init__(self) -> None:
object.__setattr__(self, "code", {
"left": 0x1,
"right": 0x2,
"middle": 0x4,
"up": 0x8, # Back
"down": 0x10, # Forward
}[self.button])
@dataclasses.dataclass(frozen=True)
class MouseMoveEvent(BaseEvent):
to_x: int
to_y: int
win98_fix: bool = False
to_fixed_x: int = 0
to_fixed_y: int = 0
def __post_init__(self) -> None:
assert MouseRange.MIN <= self.to_x <= MouseRange.MAX
assert MouseRange.MIN <= self.to_y <= MouseRange.MAX
to_fixed_x = MouseRange.remap(self.to_x, 0, MouseRange.MAX)
to_fixed_y = MouseRange.remap(self.to_y, 0, MouseRange.MAX)
if self.win98_fix:
# https://github.com/pikvm/pikvm/issues/159
# For some reason, the correct implementation of this fix
# is a shift to the left, and not to the right, as in VirtualBox
to_fixed_x <<= 1
to_fixed_y <<= 1
object.__setattr__(self, "to_fixed_x", to_fixed_x)
object.__setattr__(self, "to_fixed_y", to_fixed_y)
@dataclasses.dataclass(frozen=True)
class MouseRelativeEvent(BaseEvent):
delta_x: int
delta_y: int
def __post_init__(self) -> None:
assert -127 <= self.delta_x <= 127
assert -127 <= self.delta_y <= 127
@dataclasses.dataclass(frozen=True)
class MouseWheelEvent(BaseEvent):
delta_x: int
delta_y: int
def __post_init__(self) -> None:
assert -127 <= self.delta_x <= 127
assert -127 <= self.delta_y <= 127
def make_mouse_report(
absolute: bool,
buttons: int,
move_x: int,
move_y: int,
wheel_x: (int | None),
wheel_y: int,
) -> bytes:
# XXX: Wheel Y before X: it's ok.
# See /kvmd/apps/otg/hid/mouse.py for details
if wheel_x is not None:
return struct.pack(("<BHHbb" if absolute else "<Bbbbb"), buttons, move_x, move_y, wheel_y, wheel_x)
else:
return struct.pack(("<BHHb" if absolute else "<Bbbb"), buttons, move_x, move_y, wheel_y)

View File

@ -1,133 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Generator
from typing import Any
from ....logging import get_logger
from ....keyboard.mappings import UsbKey
from .device import BaseDeviceProcess
from .events import BaseEvent
from .events import ClearEvent
from .events import ResetEvent
from .events import KeyEvent
from .events import ModifierEvent
from .events import make_keyboard_event
from .events import get_led_caps
from .events import get_led_scroll
from .events import get_led_num
from .events import make_keyboard_report
# =====
class KeyboardProcess(BaseDeviceProcess):
def __init__(self, **kwargs: Any) -> None:
super().__init__(
name="keyboard",
read_size=1,
initial_state={"caps": False, "scroll": False, "num": False},
**kwargs,
)
self.__pressed_modifiers: set[UsbKey] = set()
self.__pressed_keys: list[UsbKey | None] = [None] * 6
def cleanup(self) -> None:
self._stop()
get_logger().info("Clearing HID-keyboard events ...")
self._cleanup_write(b"\x00" * 8) # Release all keys and modifiers
def send_clear_event(self) -> None:
self._clear_queue()
self._queue_event(ClearEvent())
def send_reset_event(self) -> None:
self._clear_queue()
self._queue_event(ResetEvent())
def send_key_event(self, key: str, state: bool) -> None:
self._queue_event(make_keyboard_event(key, state))
# =====
def _process_read_report(self, report: bytes) -> None:
assert len(report) == 1, report
self._update_state(
caps=get_led_caps(report[0]),
scroll=get_led_scroll(report[0]),
num=get_led_num(report[0]),
)
# =====
def _process_event(self, event: BaseEvent) -> Generator[bytes, None, None]:
if isinstance(event, (ClearEvent, ResetEvent)):
yield self.__process_clear_event()
elif isinstance(event, ModifierEvent):
yield from self.__process_modifier_event(event)
elif isinstance(event, KeyEvent):
yield from self.__process_key_event(event)
else:
raise RuntimeError(f"Not implemented event: {event}")
def __process_clear_event(self) -> bytes:
self.__clear_modifiers()
self.__clear_keys()
return self.__make_report()
def __process_modifier_event(self, event: ModifierEvent) -> Generator[bytes, None, None]:
if event.modifier in self.__pressed_modifiers:
# Ранее нажатый модификатор отжимаем
self.__pressed_modifiers.remove(event.modifier)
yield self.__make_report()
if event.state:
# Нажимаем если нужно
self.__pressed_modifiers.add(event.modifier)
yield self.__make_report()
def __process_key_event(self, event: KeyEvent) -> Generator[bytes, None, None]:
if event.key in self.__pressed_keys:
# Ранее нажатую клавишу отжимаем
self.__pressed_keys[self.__pressed_keys.index(event.key)] = None
yield self.__make_report()
elif event.state and None not in self.__pressed_keys:
# Если нужно нажать что-то новое, но свободных слотов нет - отжимаем всё
self.__clear_keys()
yield self.__make_report()
if event.state:
# Нажимаем если нужно
self.__pressed_keys[self.__pressed_keys.index(None)] = event.key
yield self.__make_report()
# =====
def __make_report(self) -> bytes:
return make_keyboard_report(self.__pressed_modifiers, self.__pressed_keys)
def __clear_modifiers(self) -> None:
self.__pressed_modifiers.clear()
def __clear_keys(self) -> None:
self.__pressed_keys = [None] * 6

View File

@ -1,181 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Generator
from typing import Any
from ....logging import get_logger
from .device import BaseDeviceProcess
from .events import BaseEvent
from .events import ClearEvent
from .events import ResetEvent
from .events import MouseButtonEvent
from .events import MouseMoveEvent
from .events import MouseRelativeEvent
from .events import MouseWheelEvent
from .events import make_mouse_report
# =====
class MouseProcess(BaseDeviceProcess):
def __init__(self, **kwargs: Any) -> None:
self.__absolute: bool = kwargs.pop("absolute")
self.__horizontal_wheel: bool = kwargs.pop("horizontal_wheel")
super().__init__(
name="mouse",
read_size=0,
initial_state={"absolute": self.__absolute}, # Just for the state
**kwargs,
)
self.__pressed_buttons = 0
self.__x = 0 # For absolute
self.__y = 0
self.__win98_fix = False
def is_absolute(self) -> bool:
return self.__absolute
def set_win98_fix(self, enabled: bool) -> None:
self.__win98_fix = enabled
def get_win98_fix(self) -> bool:
return self.__win98_fix
def cleanup(self) -> None:
self._stop()
get_logger().info("Clearing HID-mouse events ...")
report = make_mouse_report(
absolute=self.__absolute,
buttons=0,
move_x=(self.__x if self.__absolute else 0),
move_y=(self.__y if self.__absolute else 0),
wheel_x=(0 if self.__horizontal_wheel else None),
wheel_y=0,
)
self._cleanup_write(report) # Release all buttons
def send_clear_event(self) -> None:
self._clear_queue()
self._queue_event(ClearEvent())
def send_reset_event(self) -> None:
self._clear_queue()
self._queue_event(ResetEvent())
def send_button_event(self, button: str, state: bool) -> None:
self._queue_event(MouseButtonEvent(button, state))
def send_move_event(self, to_x: int, to_y: int) -> None:
if self.__absolute:
self._queue_event(MouseMoveEvent(to_x, to_y, self.__win98_fix))
def send_relative_event(self, delta_x: int, delta_y: int) -> None:
if not self.__absolute:
self._queue_event(MouseRelativeEvent(delta_x, delta_y))
def send_wheel_event(self, delta_x: int, delta_y: int) -> None:
self._queue_event(MouseWheelEvent(delta_x, delta_y))
# =====
def _process_event(self, event: BaseEvent) -> Generator[bytes, None, None]:
if isinstance(event, (ClearEvent, ResetEvent)):
yield self.__process_clear_event()
elif isinstance(event, MouseButtonEvent):
yield from self.__process_button_event(event)
elif isinstance(event, MouseMoveEvent):
yield self.__process_move_event(event)
elif isinstance(event, MouseRelativeEvent):
yield self.__process_relative_event(event)
elif isinstance(event, MouseWheelEvent):
yield self.__process_wheel_event(event)
else:
raise RuntimeError(f"Not implemented event: {event}")
def __process_clear_event(self) -> bytes:
self.__clear_state()
return self.__make_report()
def __process_button_event(self, event: MouseButtonEvent) -> Generator[bytes, None, None]:
if event.code & self.__pressed_buttons:
# Ранее нажатую кнопку отжимаем
self.__pressed_buttons &= ~event.code
yield self.__make_report()
if event.state:
# Нажимаем если нужно
self.__pressed_buttons |= event.code
yield self.__make_report()
def __process_move_event(self, event: MouseMoveEvent) -> bytes:
self.__x = event.to_fixed_x
self.__y = event.to_fixed_y
return self.__make_report()
def __process_relative_event(self, event: MouseRelativeEvent) -> bytes:
return self.__make_report(relative_event=event)
def __process_wheel_event(self, event: MouseWheelEvent) -> bytes:
return self.__make_report(wheel_event=event)
# =====
def __make_report(
self,
relative_event: (MouseRelativeEvent | None)=None,
wheel_event: (MouseWheelEvent | None)=None,
) -> bytes:
if self.__absolute:
assert relative_event is None
move_x = self.__x
move_y = self.__y
else:
assert self.__x == self.__y == 0
if relative_event is not None:
move_x = relative_event.delta_x
move_y = relative_event.delta_y
else:
move_x = move_y = 0
if wheel_event is not None:
wheel_x = wheel_event.delta_x
wheel_y = wheel_event.delta_y
else:
wheel_x = wheel_y = 0
return make_mouse_report(
absolute=self.__absolute,
buttons=self.__pressed_buttons,
move_x=move_x,
move_y=move_y,
wheel_x=(wheel_x if self.__horizontal_wheel else None),
wheel_y=wheel_y,
)
def __clear_state(self) -> None:
self.__pressed_buttons = 0
self.__x = 0
self.__y = 0

View File

@ -1,670 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import contextlib
import dataclasses
import functools
import time
import os
import copy
import pyfatfs
import pyfatfs.PyFat
import pyfatfs.PyFatFS
from typing import AsyncGenerator
from ....logging import get_logger
from ....inotify import Inotify
from ....yamlconf import Option
from ....validators.basic import valid_bool
from ....validators.basic import valid_number, valid_stripped_string_not_empty
from ....validators.os import valid_command, valid_abs_path
from ....validators.kvm import valid_msd_image_name
from .... import aiotools
from .... import fstab
from .. import MsdIsBusyError
from .. import MsdOfflineError
from .. import MsdConnectedError
from .. import MsdDisconnectedError
from .. import MsdImageNotSelected
from .. import MsdUnknownImageError
from .. import MsdImageExistsError
from .. import BaseMsd
from .. import MsdFileReader
from .. import MsdFileWriter
from .storage import Image
from .storage import Storage
from .drive import Drive
# =====
@dataclasses.dataclass(frozen=True)
class _DriveState:
image: (Image | None)
cdrom: bool
rw: bool
@dataclasses.dataclass
class _VirtualDriveState:
image: (Image | None)
connected: bool
cdrom: bool
rw: bool
@classmethod
def from_drive_state(cls, state: _DriveState) -> "_VirtualDriveState":
return _VirtualDriveState(
image=state.image,
connected=bool(state.image),
cdrom=state.cdrom,
rw=state.rw,
)
class _State:
def __init__(self, notifier: aiotools.AioNotifier) -> None:
self.__notifier = notifier
self.storage: (Storage | None) = None
self.vd: (_VirtualDriveState | None) = None
self._region = aiotools.AioExclusiveRegion(MsdIsBusyError)
self._lock = asyncio.Lock()
@contextlib.asynccontextmanager
async def busy(self, check_online: bool=True) -> AsyncGenerator[None, None]:
try:
with self._region:
async with self._lock:
self.__notifier.notify()
if check_online:
if self.vd is None:
raise MsdOfflineError()
assert self.storage
yield
finally:
self.__notifier.notify()
def is_busy(self) -> bool:
return self._region.is_busy()
# =====
class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=super-init-not-called
self,
read_chunk_size: int,
write_chunk_size: int,
sync_chunk_size: int,
normalfiles_size: int,
remount_cmd: list[str],
initial: dict,
normalfiles_path: str,
msd_path: str,
gadget: str, # XXX: Not from options, see /kvmd/apps/kvmd/__init__.py for details
) -> None:
self.__read_chunk_size = read_chunk_size
self.__write_chunk_size = write_chunk_size
self.__sync_chunk_size = sync_chunk_size
self.__normalfiles_path = normalfiles_path
self.__msd_path = msd_path
self.__normalfiles_size = normalfiles_size
self.__initial_image: str = initial["image"]
self.__initial_cdrom: bool = initial["cdrom"]
self.__drive = Drive(gadget, instance=0, lun=0)
self.__storage = Storage(fstab.find_msd(msd_path).root_path, remount_cmd)
self.__reader: (MsdFileReader | None) = None
self.__writer: (MsdFileWriter | None) = None
self.__notifier = aiotools.AioNotifier()
self.__state = _State(self.__notifier)
self.__reset = False
logger = get_logger(0)
logger.info("Using OTG gadget %r as MSD", gadget)
aiotools.run_sync(self.__unsafe_reload_state())
@classmethod
def get_plugin_options(cls) -> dict:
return {
"read_chunk_size": Option(65536, type=functools.partial(valid_number, min=1024)),
"write_chunk_size": Option(65536, type=functools.partial(valid_number, min=1024)),
"sync_chunk_size": Option(4194304, type=functools.partial(valid_number, min=1024)),
"remount_cmd": Option([
"/usr/bin/sudo", "--non-interactive",
"/usr/bin/kvmd-helper-otgmsd-remount", "{mode}",
], type=valid_command),
"initial": {
"image": Option("", type=valid_msd_image_name, if_empty=""),
"cdrom": Option(False, type=valid_bool),
},
"msd_path": Option("/var/lib/kvmd/msd", type=valid_abs_path),
"normalfiles_path": Option("NormalFiles", type=valid_stripped_string_not_empty),
"normalfiles_size": Option(256, type=functools.partial(valid_number, min=64)),
}
# =====
# =====
async def get_state(self) -> dict:
async with self.__state._lock: # pylint: disable=protected-access
storage: (dict | None) = None
if self.__state.storage:
assert self.__state.vd
storage = dataclasses.asdict(self.__state.storage)
for name in list(storage["images"]):
del storage["images"][name]["name"]
del storage["images"][name]["path"]
del storage["images"][name]["in_storage"]
for name in list(storage["parts"]):
del storage["parts"][name]["name"]
storage["downloading"] = (self.__reader.get_state() if self.__reader else None)
storage["uploading"] = (self.__writer.get_state() if self.__writer else None)
storage["filespath"] = self.__normalfiles_path
vd: (dict | None) = None
if self.__state.vd:
assert self.__state.storage
vd = dataclasses.asdict(self.__state.vd)
if vd["image"]:
del vd["image"]["path"]
return {
"enabled": True,
"online": (bool(vd) and self.__drive.is_enabled()),
"busy": self.__state.is_busy(),
"storage": storage,
"drive": vd,
}
async def trigger_state(self) -> None:
self.__notifier.notify(1)
async def poll_state(self) -> AsyncGenerator[dict, None]:
prev: dict = {}
while True:
if (await self.__notifier.wait()) > 0:
prev = {}
new = await self.get_state()
if not prev or (prev.get("online") != new["online"]):
prev = copy.deepcopy(new)
yield new
else:
diff: dict = {}
for sub in ["busy", "drive"]:
if prev.get(sub) != new[sub]:
diff[sub] = new[sub]
for sub in ["images", "parts", "downloading", "uploading"]:
if (prev.get("storage") or {}).get(sub) != (new["storage"] or {}).get(sub):
if "storage" not in diff:
diff["storage"] = {}
diff["storage"][sub] = new["storage"][sub]
if diff:
prev = copy.deepcopy(new)
yield diff
@aiotools.atomic_fg
async def reset(self) -> None:
async with self.__state.busy(check_online=False):
try:
self.__reset = True
self.__drive.set_image_path("")
self.__drive.set_cdrom_flag(False)
self.__drive.set_rw_flag(False)
await self.__storage.remount_rw(False)
except Exception:
get_logger(0).exception("Can't reset MSD properly")
# =====
@aiotools.atomic_fg
async def set_params(
self,
name: (str | None)=None,
cdrom: (bool | None)=None,
rw: (bool | None)=None,
) -> None:
async with self.__state.busy():
assert self.__state.vd
self.__STATE_check_disconnected()
if name is not None:
if name:
self.__state.vd.image = await self.__STATE_get_storage_image(name)
else:
self.__state.vd.image = None
if cdrom is not None:
self.__state.vd.cdrom = cdrom
if cdrom:
rw = False
if rw is not None:
self.__state.vd.rw = rw
if rw:
self.__state.vd.cdrom = False
@aiotools.atomic_fg
async def set_connected(self, connected: bool) -> None:
print(self.__drive)
async with self.__state.busy():
assert self.__state.vd
if connected:
self.__STATE_check_disconnected()
if self.__state.vd.image is None:
raise MsdImageNotSelected()
if not (await self.__state.vd.image.exists()):
raise MsdUnknownImageError()
assert self.__state.vd.image.in_storage
self.__drive.set_rw_flag(self.__state.vd.rw)
self.__drive.set_cdrom_flag(self.__state.vd.cdrom)
#reset UDC to fix otg cd-rom and flash switch
try:
udc_path = self.__drive.get_udc_path()
with open(udc_path) as file:
enabled = bool(file.read().strip())
if enabled:
with open(udc_path, "w") as file:
file.write("\n")
with open(udc_path, "w") as file:
file.write(sorted(os.listdir("/sys/class/udc"))[0])
except:
logger = get_logger(0)
logger.error("Can't reset UDC")
if self.__state.vd.rw:
await self.__state.vd.image.remount_rw(True)
self.__drive.set_image_path(self.__state.vd.image.path)
else:
self.__STATE_check_connected()
self.__drive.set_image_path("")
await self.__storage.remount_rw(False, fatal=False)
self.__state.vd.connected = connected
@aiotools.atomic_fg
async def make_image(self, zipped: bool) -> None:
#Note: img size >= 64M
def create_fat_image(img_size: int, file_img_path: str, source_dir: str, fat_type: int = 32, label: str = 'One-KVM'):
def add_directory_to_fat(fat: str, src_path: str, dst_path: str):
for item in os.listdir(src_path):
src_item_path = os.path.join(src_path, item)
dst_item_path = os.path.join(dst_path, item)
if os.path.isdir(src_item_path):
fat.makedir(dst_item_path)
add_directory_to_fat(fat, src_item_path, dst_item_path)
elif os.path.isfile(src_item_path):
with open(src_item_path, 'rb') as src_file:
fat.create(dst_item_path)
with fat.open(dst_item_path, 'wb') as dst_file:
dst_file.write(src_file.read())
print(file_img_path)
with open(file_img_path, 'wb') as f:
f.seek(img_size * 1024 *1024 - 1)
f.write(b'\0')
fat_file = pyfatfs.PyFat.PyFat()
try:
fat_file.mkfs(file_img_path, fat_type = fat_type, label = label)
except Exception as e:
get_logger(0).exception(f"Error making FAT Filesystem: {e}")
finally:
fat_file.close()
fat_handle = pyfatfs.PyFatFS.PyFatFS(file_img_path)
try:
add_directory_to_fat(fat_handle, source_dir, '/')
except Exception as e:
get_logger(0).exception(f"Error adding directory to FAT image: {e}")
finally:
fat_handle.close()
def extract_fat_image(file_img_path: str, output_dir: str):
try:
for root, dirs, files in os.walk(output_dir, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))
except Exception as e:
get_logger(0).exception(f"Error removing normal file or directory: {e}")
fat_handle = pyfatfs.PyFatFS.PyFatFS(file_img_path)
try:
def extract_directory(fat_handle, src_path: str, dst_path: str):
for entry in fat_handle.listdir(src_path):
src_item_path = os.path.join(src_path, entry)
dst_item_path = os.path.join(dst_path, entry)
if fat_handle.gettype(src_item_path) is pyfatfs.PyFatFS.ResourceType.directory:
os.makedirs(dst_item_path, exist_ok=True)
extract_directory(fat_handle, src_item_path, dst_item_path)
else:
with fat_handle.open(src_item_path, 'rb') as src_file:
with open(dst_item_path, 'wb') as dst_file:
dst_file.write(src_file.read())
extract_directory(fat_handle, '/', output_dir)
except Exception as e:
get_logger(0).exception(f"Error extracting FAT image: {e}")
finally:
fat_handle.close()
async with self.__state.busy():
msd_path = self.__msd_path
file_storage_path = os.path.join(msd_path, self.__normalfiles_path)
file_img_path = os.path.join(msd_path, self.__normalfiles_path + ".img")
img_size = self.__normalfiles_size
if zipped:
if not os.path.exists(file_storage_path):
os.makedirs(file_storage_path)
if os.path.exists(file_img_path):
os.remove(file_img_path)
create_fat_image(img_size, file_img_path, file_storage_path)
else:
if os.path.exists(file_img_path):
extract_fat_image(file_img_path, file_storage_path)
@contextlib.asynccontextmanager
async def read_image(self, name: str) -> AsyncGenerator[MsdFileReader, None]:
try:
with self.__state._region: # pylint: disable=protected-access
try:
async with self.__state._lock: # pylint: disable=protected-access
self.__notifier.notify()
self.__STATE_check_disconnected()
image = await self.__STATE_get_storage_image(name)
self.__reader = await MsdFileReader(
notifier=self.__notifier,
name=image.name,
path=image.path,
chunk_size=self.__read_chunk_size,
).open()
self.__notifier.notify()
yield self.__reader
finally:
await aiotools.shield_fg(self.__close_reader())
finally:
self.__notifier.notify()
@contextlib.asynccontextmanager
async def write_image(self, name: str, size: int, remove_incomplete: (bool | None)) -> AsyncGenerator[MsdFileWriter, None]:
image: (Image | None) = None
complete = False
async def finish_writing() -> None:
# Делаем под блокировкой, чтобы эвент айнотифи не был обработан
# до того, как мы не закончим все процедуры.
async with self.__state._lock: # pylint: disable=protected-access
try:
self.__notifier.notify()
finally:
try:
if image:
await image.set_complete(complete)
finally:
try:
if image and remove_incomplete and not complete:
await image.remove(fatal=False)
finally:
try:
await self.__close_writer()
finally:
if image:
await image.remount_rw(False, fatal=False)
try:
with self.__state._region: # pylint: disable=protected-access
try:
async with self.__state._lock: # pylint: disable=protected-access
self.__notifier.notify()
self.__STATE_check_disconnected()
image = await self.__STORAGE_create_new_image(name)
await image.remount_rw(True)
await image.set_complete(False)
self.__writer = await MsdFileWriter(
notifier=self.__notifier,
name=image.name,
path=image.path,
file_size=size,
sync_size=self.__sync_chunk_size,
chunk_size=self.__write_chunk_size,
).open()
self.__notifier.notify()
yield self.__writer
complete = await self.__writer.finish()
finally:
await aiotools.shield_fg(finish_writing())
finally:
self.__notifier.notify()
@aiotools.atomic_fg
async def remove(self, name: str) -> None:
async with self.__state.busy():
assert self.__state.storage
assert self.__state.vd
self.__STATE_check_disconnected()
image = await self.__STATE_get_storage_image(name)
if self.__state.vd.image == image:
self.__state.vd.image = None
await image.remount_rw(True)
try:
await image.remove(fatal=True)
finally:
await aiotools.shield_fg(image.remount_rw(False, fatal=False))
# =====
def __STATE_check_connected(self) -> None: # pylint: disable=invalid-name
assert self.__state.vd
if not (self.__state.vd.connected or self.__drive.get_image_path()):
raise MsdDisconnectedError()
def __STATE_check_disconnected(self) -> None: # pylint: disable=invalid-name
assert self.__state.vd
if self.__state.vd.connected or self.__drive.get_image_path():
raise MsdConnectedError()
async def __STATE_get_storage_image(self, name: str) -> Image: # pylint: disable=invalid-name
assert self.__state.storage
image = self.__state.storage.images.get(name)
if image is None or not (await image.exists()):
raise MsdUnknownImageError()
assert image.in_storage
return image
async def __STORAGE_create_new_image(self, name: str) -> Image: # pylint: disable=invalid-name
assert self.__state.storage
image = await self.__storage.make_image_by_name(name)
if image.name in self.__state.storage.images or (await image.exists()):
raise MsdImageExistsError()
return image
# =====
async def __close_reader(self) -> None:
if self.__reader:
try:
await self.__reader.close()
finally:
self.__reader = None
async def __close_writer(self) -> None:
if self.__writer:
try:
await self.__writer.close()
finally:
self.__writer = None
# =====
@aiotools.atomic_fg
async def cleanup(self) -> None:
await self.__close_reader()
await self.__close_writer()
async def systask(self) -> None:
logger = get_logger(0)
while True:
try:
while True:
# Активно ждем, пока не будут на месте все каталоги.
await self.__reload_state()
if self.__state.vd:
break
await asyncio.sleep(5)
with Inotify() as inotify:
# Из-за гонки между первым релоадом и установкой вотчеров,
# мы можем потерять какие-то каталоги стораджа, но это допустимо,
# так как всегда есть ручной перезапуск.
await inotify.watch_all_changes(*self.__storage.get_watchable_paths())
await inotify.watch_all_changes(*self.__drive.get_watchable_paths())
# После установки вотчеров еще раз проверяем стейт,
# чтобы не потерять состояние привода.
await self.__reload_state()
while self.__state.vd: # Если живы после предыдущей проверки
need_restart = self.__reset
self.__reset = False
need_reload_state = False
for event in (await inotify.get_series(timeout=1)):
need_reload_state = True
if event.restart:
# Если выгрузили OTG, изменили каталоги, что-то отмонтировали или делают еще какую-то странную фигню.
# Проверяется маска InotifyMask.ALL_RESTART_EVENTS
logger.info("Got a big inotify event: %s; reinitializing MSD ...", event)
need_restart = True
break
if need_restart:
break
if need_reload_state:
await self.__reload_state()
elif self.__writer:
# При загрузке файла обновляем статистику раз в секунду (по таймауту).
# Это не нужно при обычном релоаде, потому что там и так проверяются все разделы.
await self.__reload_parts_info()
except Exception:
logger.exception("Unexpected MSD watcher error")
await asyncio.sleep(1)
async def __reload_state(self) -> None:
async with self.__state._lock: # pylint: disable=protected-access
await self.__unsafe_reload_state()
self.__notifier.notify()
async def __reload_parts_info(self) -> None:
assert self.__writer # Использовать только при записи образа
async with self.__state._lock: # pylint: disable=protected-access
await self.__storage.reload_parts_info()
self.__notifier.notify()
# ===== Don't call this directly ====
async def __unsafe_reload_state(self) -> None:
logger = get_logger(0)
try:
path = self.__drive.get_image_path()
drive_state = _DriveState(
image=((await self.__storage.make_image_by_path(path)) if path else None),
cdrom=self.__drive.get_cdrom_flag(),
rw=self.__drive.get_rw_flag(),
)
await self.__storage.reload()
if self.__state.vd is None and drive_state.image is None:
# Если только что включились и образ не подключен - попробовать
# перемонтировать хранилище (и создать images и meta).
logger.info("Probing to remount storage ...")
await self.__storage.remount_rw(True)
await self.__storage.remount_rw(False)
await self.__unsafe_setup_initial()
except Exception:
logger.exception("Error while reloading MSD state; switching to offline")
self.__state.storage = None
self.__state.vd = None
else:
self.__state.storage = self.__storage
if drive_state.image:
# При подключенном образе виртуальный стейт заменяется реальным
self.__state.vd = _VirtualDriveState.from_drive_state(drive_state)
else:
if self.__state.vd is None:
# Если раньше MSD был отключен
self.__state.vd = _VirtualDriveState.from_drive_state(drive_state)
image = self.__state.vd.image
if image and (not image.in_storage or not (await image.exists())):
# Если только что отключили ручной образ вне хранилища или ранее выбранный образ был удален
self.__state.vd.image = None
self.__state.vd.connected = False
async def __unsafe_setup_initial(self) -> None:
if self.__initial_image:
logger = get_logger(0)
image = await self.__storage.make_image_by_name(self.__initial_image)
if (await image.exists()):
logger.info("Setting up initial image %r ...", self.__initial_image)
try:
self.__drive.set_rw_flag(False)
self.__drive.set_cdrom_flag(self.__initial_cdrom)
self.__drive.set_image_path(image.path)
except Exception:
logger.exception("Can't setup initial image: ignored")
else:
logger.error("Can't find initial image %r: ignored", self.__initial_image)

View File

@ -1,94 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import errno
from .... import usb
from .. import MsdOperationError
# =====
class MsdDriveLockedError(MsdOperationError):
def __init__(self) -> None:
super().__init__("MSD drive is locked on IO operation")
# =====
class Drive:
def __init__(self, gadget: str, instance: int, lun: int) -> None:
func = f"mass_storage.usb{instance}"
self.__udc_path = os.path.join(f"/sys/kernel/config/usb_gadget", gadget, usb.G_UDC)
self.__profile_func_path = usb.get_gadget_path(gadget, usb.G_PROFILE, func)
self.__profile_path = usb.get_gadget_path(gadget, usb.G_PROFILE)
self.__lun_path = usb.get_gadget_path(gadget, usb.G_FUNCTIONS, func, f"lun.{lun}")
def is_enabled(self) -> bool:
return os.path.exists(self.__profile_func_path)
def get_watchable_paths(self) -> list[str]:
return [self.__lun_path, self.__profile_path]
def get_udc_path(self) -> str:
return self.__udc_path
# =====
def set_image_path(self, path: str) -> None:
if path or not self.__has_param("forced_eject"):
self.__set_param("file", path)
else:
self.__set_param("forced_eject", "")
def get_image_path(self) -> str:
path = self.__get_param("file")
return (os.path.normpath(path) if path else "")
def set_cdrom_flag(self, flag: bool) -> None:
self.__set_param("cdrom", str(int(flag)))
def get_cdrom_flag(self) -> bool:
return bool(int(self.__get_param("cdrom")))
def set_rw_flag(self, flag: bool) -> None:
self.__set_param("ro", str(int(not flag)))
def get_rw_flag(self) -> bool:
return (not int(self.__get_param("ro")))
# =====
def __has_param(self, param: str) -> bool:
return os.access(os.path.join(self.__lun_path, param), os.F_OK)
def __get_param(self, param: str) -> str:
with open(os.path.join(self.__lun_path, param)) as file:
return file.read().strip()
def __set_param(self, param: str, value: str) -> None:
try:
with open(os.path.join(self.__lun_path, param), "w") as file:
file.write(value + "\n")
except OSError as ex:
if ex.errno == errno.EBUSY:
raise MsdDriveLockedError()
raise

View File

@ -1,284 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import asyncio
import operator
import dataclasses
from typing import Generator
from typing import Optional
import aiofiles
import aiofiles.os
from .... import aiotools
from .... import aiohelpers
from .. import MsdError
# =====
@dataclasses.dataclass(frozen=True)
class _ImageDc:
name: str
path: str
in_storage: bool = dataclasses.field(init=False, compare=False)
complete: bool = dataclasses.field(init=False, compare=False)
removable: bool = dataclasses.field(init=False, compare=False)
size: int = dataclasses.field(init=False, compare=False)
mod_ts: float = dataclasses.field(init=False, compare=False)
class Image(_ImageDc):
def __init__(self, name: str, path: str, storage: Optional["Storage"]) -> None:
super().__init__(name, path)
self.__storage = storage
(self.__dir_path, file_name) = os.path.split(path)
self.__incomplete_path = os.path.join(self.__dir_path, f".__{file_name}.incomplete")
self.__adopted = False
async def _reload(self) -> None: # Only for Storage() and set_complete()
# adopted используется в последующих проверках
self.__adopted = await aiotools.run_async(self.__is_adopted)
complete = await self.__is_complete()
removable = await self.__is_removable()
(size, mod_ts) = await self.__get_stat()
object.__setattr__(self, "complete", complete)
object.__setattr__(self, "removable", removable)
object.__setattr__(self, "size", size)
object.__setattr__(self, "mod_ts", mod_ts)
def __is_adopted(self) -> bool:
# True, если образ находится вне хранилища
# или в другой точке монтирования под ним
if self.__storage is None:
return True
path = self.path
while not os.path.ismount(path):
path = os.path.dirname(path)
return (self.__storage._get_root_path() != path) # pylint: disable=protected-access
async def __is_complete(self) -> bool:
if self.__storage:
return (not (await aiofiles.os.path.exists(self.__incomplete_path)))
return True
async def __is_removable(self) -> bool:
if not self.__storage:
return False
if not self.__adopted:
return True
return (await aiofiles.os.access(self.__dir_path, os.W_OK)) # type: ignore
async def __get_stat(self) -> tuple[int, float]:
try:
st = (await aiofiles.os.stat(self.path))
return (st.st_size, st.st_mtime)
except Exception:
return (0, 0.0)
# =====
@property
def in_storage(self) -> bool:
return bool(self.__storage)
async def exists(self) -> bool:
return (await aiofiles.os.path.exists(self.path))
async def remount_rw(self, rw: bool, fatal: bool=True) -> None:
assert self.__storage
if not self.__adopted:
await self.__storage.remount_rw(rw, fatal)
async def remove(self, fatal: bool) -> None:
assert self.__storage
removed = False
try:
await aiofiles.os.remove(self.path)
removed = True
self.__storage.images.pop(self.name, None)
except FileNotFoundError:
pass
except Exception:
if fatal:
raise
finally:
# Удаляем .incomplete вместе с файлом
if removed:
await self.set_complete(True)
async def set_complete(self, flag: bool) -> None:
assert self.__storage
if flag:
try:
await aiofiles.os.remove(self.__incomplete_path)
except FileNotFoundError:
pass
else:
async with aiofiles.open(self.__incomplete_path, "w"):
pass
await self._reload()
# =====
@dataclasses.dataclass(frozen=True)
class _PartDc:
name: str
size: int = dataclasses.field(init=False, compare=False)
free: int = dataclasses.field(init=False, compare=False)
writable: bool = dataclasses.field(init=False, compare=False)
class _Part(_PartDc):
def __init__(self, name: str, path: str) -> None:
super().__init__(name)
self.__path = path
async def _reload(self) -> None: # Only for Storage()
st = await aiotools.run_async(os.statvfs, self.__path)
if self.name == "":
writable = True
else:
writable = await aiofiles.os.access(self.__path, os.W_OK) # type: ignore
object.__setattr__(self, "size", st.f_blocks * st.f_frsize)
object.__setattr__(self, "free", st.f_bavail * st.f_frsize)
object.__setattr__(self, "writable", writable)
# =====
@dataclasses.dataclass(frozen=True, eq=False)
class _StorageDc:
images: dict[str, Image] = dataclasses.field(init=False)
parts: dict[str, _Part] = dataclasses.field(init=False)
class Storage(_StorageDc):
def __init__(self, path: str, remount_cmd: list[str]) -> None:
super().__init__()
self.__path = path
self.__remount_cmd = remount_cmd
self.__watchable_paths: (list[str] | None) = None
self.__images: (dict[str, Image] | None) = None
self.__parts: (dict[str, _Part] | None) = None
@property
def images(self) -> dict[str, Image]:
assert self.__images is not None
return dict(self.__images)
@property
def parts(self) -> dict[str, _Part]:
assert self.__parts is not None
return dict(self.__parts)
async def reload(self) -> None:
self.__watchable_paths = None
self.__images = {}
watchable_paths: list[str] = []
images: dict[str, Image] = {}
parts: dict[str, _Part] = {}
for (root_path, is_part, files) in (await aiotools.run_async(self.__walk)):
watchable_paths.append(root_path)
for path in files:
name = self.__make_relative_name(path)
images[name] = await self.make_image_by_name(name)
if is_part:
name = self.__make_relative_name(root_path, dot_to_empty=True)
part = _Part(name, root_path)
await part._reload() # pylint: disable=protected-access
parts[name] = part
assert "" in parts, parts
self.__watchable_paths = watchable_paths
self.__images = images
self.__parts = parts
async def reload_parts_info(self) -> None:
await asyncio.gather(*[part._reload() for part in self.parts.values()]) # pylint: disable=protected-access
def get_watchable_paths(self) -> list[str]:
assert self.__watchable_paths is not None
return list(self.__watchable_paths)
def __walk(self) -> list[tuple[str, bool, list[str]]]:
return list(self.__inner_walk(self.__path))
def __inner_walk(self, root_path: str) -> Generator[tuple[str, bool, list[str]], None, None]:
files: list[str] = []
with os.scandir(root_path) as dir_iter:
for item in sorted(dir_iter, key=operator.attrgetter("name")):
if item.name.startswith(".") or item.name == "lost+found":
continue
try:
if item.is_dir(follow_symlinks=False):
item.stat() # Проверяем, не сдохла ли смонтированная NFS
yield from self.__inner_walk(item.path)
elif item.is_file(follow_symlinks=False):
files.append(item.path)
except Exception:
pass
yield (root_path, (root_path == self.__path or os.path.ismount(root_path)), files)
def __make_relative_name(self, path: str, dot_to_empty: bool=False) -> str:
name = os.path.relpath(path, self.__path)
assert name
if dot_to_empty and name == ".":
name = ""
assert not name.startswith(".")
return name
# =====
async def make_image_by_name(self, name: str) -> Image:
assert name
path = os.path.join(self.__path, name)
return (await self.__make_image(name, path, True))
async def make_image_by_path(self, path: str) -> Image:
assert path
in_storage = (os.path.commonpath([self.__path, path]) == self.__path)
if in_storage:
name = self.__make_relative_name(path)
else:
name = os.path.basename(path)
return (await self.__make_image(name, path, in_storage))
async def __make_image(self, name: str, path: str, in_storage: bool) -> Image:
assert name
assert path
image = Image(name, path, (self if in_storage else None))
await image._reload() # pylint: disable=protected-access
return image
def _get_root_path(self) -> str: # Only for Image()
return self.__path
# =====
async def remount_rw(self, rw: bool, fatal: bool=True) -> None:
if not (await aiohelpers.remount("MSD", self.__remount_cmd, rw)):
if fatal:
raise MsdError("Can't execute remount helper")

View File

@ -1,171 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import functools
from typing import Callable
from typing import Any
import aiohttp
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ... import htclient
from ...yamlconf import Option
from ...validators.basic import valid_stripped_string_not_empty
from ...validators.basic import valid_bool
from ...validators.basic import valid_number
from ...validators.basic import valid_float_f01
from . import BaseUserGpioDriver
from . import GpioDriverOfflineError
# =====
class Plugin(BaseUserGpioDriver): # pylint: disable=too-many-instance-attributes
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
url: str,
verify: bool,
user: str,
passwd: str,
state_poll: float,
timeout: float,
) -> None:
super().__init__(instance_name, notifier)
self.__url = url
self.__verify = verify
self.__user = user
self.__passwd = passwd
self.__state_poll = state_poll
self.__timeout = timeout
self.__initial: dict[str, (bool | None)] = {}
self.__state: dict[str, (bool | None)] = {}
self.__update_notifier = aiotools.AioNotifier()
self.__http_session: (aiohttp.ClientSession | None) = None
@classmethod
def get_plugin_options(cls) -> dict[str, Option]:
return {
"url": Option("", type=valid_stripped_string_not_empty),
"verify": Option(True, type=valid_bool),
"user": Option(""),
"passwd": Option(""),
"state_poll": Option(5.0, type=valid_float_f01),
"timeout": Option(5.0, type=valid_float_f01),
}
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return functools.partial(valid_number, min=0, max=7, name="ANELPWR channel")
def register_input(self, pin: str, debounce: float) -> None:
_ = debounce
self.__state[pin] = None
def register_output(self, pin: str, initial: (bool | None)) -> None:
self.__initial[pin] = initial
self.__state[pin] = None
def prepare(self) -> None:
async def inner_prepare() -> None:
await asyncio.gather(*[
self.write(pin, state)
for (pin, state) in self.__initial.items()
if state is not None
], return_exceptions=True)
aiotools.run_sync(inner_prepare())
async def run(self) -> None:
prev_state: (dict | None) = None
while True:
session = self.__ensure_http_session()
try:
async with session.get(f"{self.__url}/strg.cfg") as resp:
htclient.raise_not_200(resp)
parts = (await resp.text()).split(";")
for pin in self.__state:
self.__state[pin] = (parts[1 + int(pin) * 5] == "1")
except Exception as ex:
get_logger().error("Failed ANELPWR bulk GET request: %s", tools.efmt(ex))
self.__state = dict.fromkeys(self.__state, None)
if self.__state != prev_state:
self._notifier.notify()
prev_state = self.__state
await self.__update_notifier.wait(self.__state_poll)
async def cleanup(self) -> None:
if self.__http_session:
await self.__http_session.close()
self.__http_session = None
async def read(self, pin: str) -> bool:
if self.__state[pin] is None:
raise GpioDriverOfflineError(self)
return self.__state[pin] # type: ignore
async def write(self, pin: str, state: bool) -> None:
session = self.__ensure_http_session()
try:
async with session.post(
url=f"{self.__url}/ctrl.htm",
data=f"F{pin}={int(state)}",
headers={"Content-Type": "text/plain"},
) as resp:
htclient.raise_not_200(resp)
except Exception as ex:
get_logger().error("Failed ANELPWR POST request to pin %s: %s", pin, tools.efmt(ex))
raise GpioDriverOfflineError(self)
self.__update_notifier.notify()
def __ensure_http_session(self) -> aiohttp.ClientSession:
if not self.__http_session:
kwargs: dict = {
"headers": {
"User-Agent": htclient.make_user_agent("KVMD"),
},
"timeout": aiohttp.ClientTimeout(total=self.__timeout),
}
if self.__user:
kwargs["auth"] = aiohttp.BasicAuth(self.__user, self.__passwd)
if not self.__verify:
kwargs["connector"] = aiohttp.TCPConnector(ssl=False)
self.__http_session = aiohttp.ClientSession(**kwargs)
return self.__http_session
def __str__(self) -> str:
return f"ANELPWR({self._instance_name})"
__repr__ = __str__

View File

@ -1,86 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Callable
from typing import Any
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ... import aioproc
from ...yamlconf import Option
from ...validators.os import valid_command
from . import GpioDriverOfflineError
from . import UserGpioModes
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver): # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=super-init-not-called
self,
instance_name: str,
notifier: aiotools.AioNotifier,
cmd: list[str],
) -> None:
super().__init__(instance_name, notifier)
self.__cmd = cmd
@classmethod
def get_plugin_options(cls) -> dict:
return {
"cmd": Option([], type=valid_command),
}
@classmethod
def get_modes(cls) -> set[str]:
return set([UserGpioModes.INPUT])
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return str
async def read(self, pin: str) -> bool:
_ = pin
try:
proc = await aioproc.log_process(self.__cmd, logger=get_logger(0), prefix=str(self))
return (proc.returncode == 0)
except Exception as ex:
get_logger(0).error("Can't run custom command [ %s ]: %s",
tools.cmdfmt(self.__cmd), tools.efmt(ex))
raise GpioDriverOfflineError(self)
async def write(self, pin: str, state: bool) -> None:
_ = pin
_ = state
def __str__(self) -> str:
return f"CMDRET({self._instance_name})"
__repr__ = __str__

View File

@ -1,188 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import re
import multiprocessing
import functools
import errno
import time
from typing import Callable
from typing import Any
import serial
from ...logging import get_logger
from ... import aiotools
from ... import aiomulti
from ... import aioproc
from ...yamlconf import Option
from ...validators.basic import valid_number
from ...validators.basic import valid_float_f01
from ...validators.os import valid_abs_path
from ...validators.hw import valid_tty_speed
from . import GpioDriverOfflineError
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver): # pylint: disable=too-many-instance-attributes
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
device_path: str,
speed: int,
read_timeout: float,
protocol: int,
) -> None:
super().__init__(instance_name, notifier)
self.__device_path = device_path
self.__speed = speed
self.__read_timeout = read_timeout
self.__protocol = protocol
self.__ctl_queue: "multiprocessing.Queue[int]" = multiprocessing.Queue()
self.__channel_queue: "multiprocessing.Queue[int | None]" = multiprocessing.Queue()
self.__channel: (int | None) = -1
self.__proc: (multiprocessing.Process | None) = None
self.__stop_event = multiprocessing.Event()
@classmethod
def get_plugin_options(cls) -> dict:
return {
"device": Option("", type=valid_abs_path, unpack_as="device_path"),
"speed": Option(9600, type=valid_tty_speed),
"read_timeout": Option(2.0, type=valid_float_f01),
"protocol": Option(1, type=functools.partial(valid_number, min=1, max=2)),
}
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return functools.partial(valid_number, min=0, max=3, name="Extron USB channel")
def prepare(self) -> None:
assert self.__proc is None
self.__proc = multiprocessing.Process(target=self.__serial_worker, daemon=True)
self.__proc.start()
async def run(self) -> None:
while True:
(got, channel) = await aiomulti.queue_get_last(self.__channel_queue, 1)
if got and self.__channel != channel:
self.__channel = channel
self._notifier.notify()
async def cleanup(self) -> None:
if self.__proc is not None:
if self.__proc.is_alive():
get_logger(0).info("Stopping %s daemon ...", self)
self.__stop_event.set()
if self.__proc.is_alive() or self.__proc.exitcode is not None:
self.__proc.join()
async def read(self, pin: str) -> bool:
if not self.__is_online():
raise GpioDriverOfflineError(self)
return (self.__channel == int(pin))
async def write(self, pin: str, state: bool) -> None:
if not self.__is_online():
raise GpioDriverOfflineError(self)
if state:
self.__ctl_queue.put_nowait(int(pin))
# =====
def __is_online(self) -> bool:
return (
self.__proc is not None
and self.__proc.is_alive()
and self.__channel is not None
)
def __serial_worker(self) -> None:
logger = aioproc.settle(str(self), f"gpio-extron-{self._instance_name}")
while not self.__stop_event.is_set():
try:
with self.__get_serial() as tty:
data = b""
self.__channel_queue.put_nowait(-1)
# Switch and then recieve the state.
# FIXME: Get actual state without modifying the current.
self.__send_channel(tty, 0)
while not self.__stop_event.is_set():
(channel, data) = self.__recv_channel(tty, data)
if channel is not None:
self.__channel_queue.put_nowait(channel)
(got, channel) = aiomulti.queue_get_last_sync(self.__ctl_queue, 0.1) # type: ignore
if got:
assert channel is not None
self.__send_channel(tty, channel)
except Exception as ex:
self.__channel_queue.put_nowait(None)
if isinstance(ex, serial.SerialException) and ex.errno == errno.ENOENT: # pylint: disable=no-member
logger.error("Missing %s serial device: %s", self, self.__device_path)
else:
logger.exception("Unexpected %s error", self)
time.sleep(1)
def __get_serial(self) -> serial.Serial:
return serial.Serial(self.__device_path, self.__speed, timeout=self.__read_timeout)
def __recv_channel(self, tty: serial.Serial, data: bytes) -> tuple[(int | None), bytes]:
channel: (int | None) = None
if tty.in_waiting:
data += tty.read_all()
found = re.findall(b"Chn[0-3]", data)
if found:
channel = {
b"Chn1": 0,
b"Chn2": 1,
b"Chn3": 2,
b"Chn4": 3,
}.get(found[-1], -1)
data = data[-8:]
return (channel, data)
def __send_channel(self, tty: serial.Serial, channel: int) -> None:
assert 0 <= channel <= 3
cmd = b"%d!\n" % (channel + 1)
tty.write(cmd)
tty.flush()
def __str__(self) -> str:
return f"Extron({self._instance_name})"
__repr__ = __str__

View File

@ -1,191 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import re
import multiprocessing
import functools
import errno
import time
from typing import Callable
from typing import Any
import serial
from ...logging import get_logger
from ... import aiotools
from ... import aiomulti
from ... import aioproc
from ...yamlconf import Option
from ...validators.basic import valid_number
from ...validators.basic import valid_float_f01
from ...validators.os import valid_abs_path
from ...validators.hw import valid_tty_speed
from . import GpioDriverOfflineError
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver): # pylint: disable=too-many-instance-attributes
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
device_path: str,
speed: int,
read_timeout: float,
protocol: int,
) -> None:
super().__init__(instance_name, notifier)
self.__device_path = device_path
self.__speed = speed
self.__read_timeout = read_timeout
self.__protocol = protocol
self.__ctl_queue: "multiprocessing.Queue[int]" = multiprocessing.Queue()
self.__channel_queue: "multiprocessing.Queue[int | None]" = multiprocessing.Queue()
self.__channel: (int | None) = -1
self.__proc: (multiprocessing.Process | None) = None
self.__stop_event = multiprocessing.Event()
@classmethod
def get_plugin_options(cls) -> dict:
return {
"device": Option("", type=valid_abs_path, unpack_as="device_path"),
"speed": Option(115200, type=valid_tty_speed),
"read_timeout": Option(2.0, type=valid_float_f01),
"protocol": Option(1, type=functools.partial(valid_number, min=1, max=2)),
}
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return functools.partial(valid_number, min=0, max=3, name="Ezcoo channel")
def prepare(self) -> None:
assert self.__proc is None
self.__proc = multiprocessing.Process(target=self.__serial_worker, daemon=True)
self.__proc.start()
async def run(self) -> None:
while True:
(got, channel) = await aiomulti.queue_get_last(self.__channel_queue, 1)
if got and self.__channel != channel:
self.__channel = channel
self._notifier.notify()
async def cleanup(self) -> None:
if self.__proc is not None:
if self.__proc.is_alive():
get_logger(0).info("Stopping %s daemon ...", self)
self.__stop_event.set()
if self.__proc.is_alive() or self.__proc.exitcode is not None:
self.__proc.join()
async def read(self, pin: str) -> bool:
if not self.__is_online():
raise GpioDriverOfflineError(self)
return (self.__channel == int(pin))
async def write(self, pin: str, state: bool) -> None:
if not self.__is_online():
raise GpioDriverOfflineError(self)
if state:
self.__ctl_queue.put_nowait(int(pin))
# =====
def __is_online(self) -> bool:
return (
self.__proc is not None
and self.__proc.is_alive()
and self.__channel is not None
)
def __serial_worker(self) -> None:
logger = aioproc.settle(str(self), f"gpio-ezcoo-{self._instance_name}")
while not self.__stop_event.is_set():
try:
with self.__get_serial() as tty:
data = b""
self.__channel_queue.put_nowait(-1)
# Switch and then recieve the state.
# FIXME: Get actual state without modifying the current.
self.__send_channel(tty, 0)
while not self.__stop_event.is_set():
(channel, data) = self.__recv_channel(tty, data)
if channel is not None:
self.__channel_queue.put_nowait(channel)
(got, channel) = aiomulti.queue_get_last_sync(self.__ctl_queue, 0.1) # type: ignore
if got:
assert channel is not None
self.__send_channel(tty, channel)
except Exception as ex:
self.__channel_queue.put_nowait(None)
if isinstance(ex, serial.SerialException) and ex.errno == errno.ENOENT: # pylint: disable=no-member
logger.error("Missing %s serial device: %s", self, self.__device_path)
else:
logger.exception("Unexpected %s error", self)
time.sleep(1)
def __get_serial(self) -> serial.Serial:
return serial.Serial(self.__device_path, self.__speed, timeout=self.__read_timeout)
def __recv_channel(self, tty: serial.Serial, data: bytes) -> tuple[(int | None), bytes]:
channel: (int | None) = None
if tty.in_waiting:
data += tty.read_all()
found = re.findall(b"V[0-9a-fA-F]{2}S", data)
if found:
channel = {
b"V0CS": 0,
b"V18S": 1,
b"V5ES": 2,
b"V08S": 3,
}.get(found[-1], -1)
data = data[-8:]
return (channel, data)
def __send_channel(self, tty: serial.Serial, channel: int) -> None:
assert 0 <= channel <= 3
cmd = b"%s OUT1 VS IN%d\n" % (
(b"SET" if self.__protocol == 1 else b"EZS"),
channel + 1,
)
tty.write(cmd * 2) # Twice because of ezcoo bugs
tty.flush()
def __str__(self) -> str:
return f"Ezcoo({self._instance_name})"
__repr__ = __str__

View File

@ -1,189 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import contextlib
import functools
from typing import Callable
from typing import Any
import hid
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ...yamlconf import Option
from ...validators.basic import valid_number
from ...validators.basic import valid_float_f01
from ...validators.os import valid_abs_path
from . import GpioDriverOfflineError
from . import UserGpioModes
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver):
# http://vusb.wikidot.com/project:driver-less-usb-relays-hid-interface
# https://github.com/trezor/cython-hidapi/blob/6057d41b5a2552a70ff7117a9d19fc21bf863867/chid.pxd
def __init__( # pylint: disable=super-init-not-called
self,
instance_name: str,
notifier: aiotools.AioNotifier,
device_path: str,
state_poll: float,
) -> None:
super().__init__(instance_name, notifier)
self.__device_path = device_path
self.__state_poll = state_poll
self.__device: (hid.device | None) = None # type: ignore
self.__stop = False
self.__initials: dict[int, (bool | None)] = {}
@classmethod
def get_plugin_options(cls) -> dict:
return {
"device": Option("", type=valid_abs_path, unpack_as="device_path"),
"state_poll": Option(5.0, type=valid_float_f01),
}
@classmethod
def get_modes(cls) -> set[str]:
return set([UserGpioModes.OUTPUT])
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return functools.partial(valid_number, min=0, max=7, name="HID relay channel")
def register_output(self, pin: str, initial: (bool | None)) -> None:
self.__initials[int(pin)] = initial
def prepare(self) -> None:
logger = get_logger(0)
logger.info("Probing driver %s on %s ...", self, self.__device_path)
try:
with self.__ensure_device("probing"):
pass
except Exception as ex:
logger.error("Can't probe %s on %s: %s",
self, self.__device_path, tools.efmt(ex))
self.__reset_pins()
async def run(self) -> None:
prev_raw = -1
while True:
try:
raw = self.__inner_read_raw()
except Exception:
raw = -1
if raw != prev_raw:
self._notifier.notify()
prev_raw = raw
await asyncio.sleep(self.__state_poll)
async def cleanup(self) -> None:
self.__reset_pins()
self.__close_device()
self.__stop = True
async def read(self, pin: str) -> bool:
try:
return self.__inner_read(int(pin))
except Exception:
raise GpioDriverOfflineError(self)
async def write(self, pin: str, state: bool) -> None:
try:
return self.__inner_write(int(pin), state)
except Exception:
raise GpioDriverOfflineError(self)
# =====
def __reset_pins(self) -> None:
logger = get_logger(0)
for (pin, state) in self.__initials.items():
if state is not None:
logger.info("Resetting pin=%d to state=%d of %s on %s: ...",
pin, state, self, self.__device_path)
try:
self.__inner_write(pin, state)
except Exception as ex:
logger.error("Can't reset pin=%d of %s on %s: %s",
pin, self, self.__device_path, tools.efmt(ex))
def __inner_read(self, pin: int) -> bool:
assert 0 <= pin <= 7
return bool(self.__inner_read_raw() & (1 << pin))
def __inner_read_raw(self) -> int:
with self.__ensure_device("reading") as device:
return device.get_feature_report(1, 8)[7]
def __inner_write(self, pin: int, state: bool) -> None:
assert 0 <= pin <= 7
with self.__ensure_device("writing") as device:
report = [(0xFF if state else 0xFD), pin + 1] # Pin numeration starts from 0
result = device.send_feature_report(report)
if result < 0:
raise RuntimeError(f"Retval of send_feature_report() < 0: {result}")
@contextlib.contextmanager
def __ensure_device(self, context: str) -> hid.device: # type: ignore
assert not self.__stop
if self.__device is None:
device = hid.device() # type: ignore
device.open_path(self.__device_path.encode("utf-8"))
device.set_nonblocking(True)
self.__device = device
get_logger(0).info("Opened %s on %s while %s", self, self.__device_path, context)
try:
yield self.__device
except Exception as ex:
get_logger(0).error("Error occured on %s on %s while %s: %s",
self, self.__device_path, context, tools.efmt(ex))
self.__close_device()
raise
def __close_device(self) -> None:
if self.__device:
try:
self.__device.close()
except Exception:
pass
self.__device = None
get_logger(0).info("Closed %s on %s", self, self.__device_path)
def __str__(self) -> str:
return f"HidRelay({self._instance_name})"
__repr__ = __str__

View File

@ -1,166 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
from typing import Callable
from typing import Any
import aiohttp
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ... import htclient
from ...yamlconf import Option
from ...validators.basic import valid_stripped_string_not_empty
from ...validators.basic import valid_bool
from ...validators.basic import valid_float_f01
from . import GpioDriverOfflineError
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver): # pylint: disable=too-many-instance-attributes
# https://developers.meethue.com/develop/hue-api/lights-api
# https://www.burgestrand.se/hue-api/api/lights
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
url: str,
verify: bool,
token: str,
state_poll: float,
timeout: float,
) -> None:
super().__init__(instance_name, notifier)
self.__url = url
self.__verify = verify
self.__token = token
self.__state_poll = state_poll
self.__timeout = timeout
self.__initial: dict[str, (bool | None)] = {}
self.__state: dict[str, (bool | None)] = {}
self.__update_notifier = aiotools.AioNotifier()
self.__http_session: (aiohttp.ClientSession | None) = None
@classmethod
def get_plugin_options(cls) -> dict:
return {
"url": Option("", type=valid_stripped_string_not_empty),
"verify": Option(True, type=valid_bool),
"token": Option("", type=valid_stripped_string_not_empty),
"state_poll": Option(5.0, type=valid_float_f01),
"timeout": Option(5.0, type=valid_float_f01),
}
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return valid_stripped_string_not_empty
def register_input(self, pin: str, debounce: float) -> None:
_ = debounce
self.__state[pin] = None
def register_output(self, pin: str, initial: (bool | None)) -> None:
self.__initial[pin] = initial
self.__state[pin] = None
def prepare(self) -> None:
async def inner_prepare() -> None:
await asyncio.gather(*[
self.write(pin, state)
for (pin, state) in self.__initial.items()
if state is not None
], return_exceptions=True)
aiotools.run_sync(inner_prepare())
async def run(self) -> None:
prev_state: (dict | None) = None
while True:
session = self.__ensure_http_session()
try:
async with session.get(f"{self.__url}/api/{self.__token}/lights") as resp:
results = await resp.json()
for pin in self.__state:
if pin in results:
self.__state[pin] = bool(results[pin]["state"]["on"])
except Exception as ex:
get_logger().error("Failed Hue bulk GET request: %s", tools.efmt(ex))
self.__state = dict.fromkeys(self.__state, None)
if self.__state != prev_state:
self._notifier.notify()
prev_state = self.__state
await self.__update_notifier.wait(self.__state_poll)
async def cleanup(self) -> None:
if self.__http_session:
await self.__http_session.close()
self.__http_session = None
async def read(self, pin: str) -> bool:
if self.__state[pin] is None:
raise GpioDriverOfflineError(self)
return self.__state[pin] # type: ignore
async def write(self, pin: str, state: bool) -> None:
session = self.__ensure_http_session()
try:
async with session.put(
url=f"{self.__url}/api/{self.__token}/lights/{pin}/state",
json={"on": state},
) as resp:
htclient.raise_not_200(resp)
except Exception as ex:
get_logger().error("Failed Hue PUT request to pin %s: %s", pin, tools.efmt(ex))
raise GpioDriverOfflineError(self)
self.__update_notifier.notify()
def __ensure_http_session(self) -> aiohttp.ClientSession:
if not self.__http_session:
kwargs: dict = {
"headers": {
"User-Agent": htclient.make_user_agent("KVMD"),
},
"timeout": aiohttp.ClientTimeout(total=self.__timeout),
}
if not self.__verify:
kwargs["connector"] = aiohttp.TCPConnector(ssl=False)
self.__http_session = aiohttp.ClientSession(**kwargs)
return self.__http_session
def __str__(self) -> str:
return f"Hue({self._instance_name})"
__repr__ = __str__

View File

@ -1,133 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
from typing import Callable
from typing import Any
import gpiod
from ... import aiotools
from ...yamlconf import Option
from ...validators.os import valid_abs_path
from ...validators.hw import valid_gpio_pin
from . import UserGpioModes
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver):
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
device_path: str,
) -> None:
super().__init__(instance_name, notifier)
self.__device_path = device_path
self.__tasks: dict[int, (asyncio.Task | None)] = {}
self.__line_req: (gpiod.LineRequest | None) = None
@classmethod
def get_plugin_options(cls) -> dict:
return {
"device": Option("/dev/gpiochip0", type=valid_abs_path, unpack_as="device_path"),
}
@classmethod
def get_modes(cls) -> set[str]:
return set([UserGpioModes.OUTPUT])
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return valid_gpio_pin
def register_output(self, pin: str, initial: (bool | None)) -> None:
_ = initial
self.__tasks[int(pin)] = None
def prepare(self) -> None:
self.__line_req = gpiod.request_lines(
self.__device_path,
consumer="kvmd::locator",
config={
tuple(self.__tasks): gpiod.LineSettings(
direction=gpiod.line.Direction.OUTPUT,
output_value=gpiod.line.Value(False),
),
},
)
async def cleanup(self) -> None:
tasks = [
task
for task in self.__tasks.values()
if task is not None
]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if self.__line_req:
try:
self.__line_req.release()
except Exception:
pass
async def read(self, pin: str) -> bool:
return (self.__tasks[int(pin)] is not None)
async def write(self, pin: str, state: bool) -> None:
pin_int = int(pin)
task = self.__tasks[pin_int]
if state and task is None:
self.__tasks[pin_int] = asyncio.create_task(self.__blink(pin_int))
elif not state and task is not None:
task.cancel()
await task
self.__tasks[pin_int] = None
async def __blink(self, pin: int) -> None:
assert pin in self.__tasks
assert self.__line_req
try:
state = True
while True:
self.__line_req.set_value(pin, gpiod.line.Value(state))
state = (not state)
await asyncio.sleep(0.1)
except asyncio.CancelledError:
pass
finally:
self.__line_req.set_value(pin, gpiod.line.Value(False))
def __str__(self) -> str:
return f"Locator({self._instance_name})"
__repr__ = __str__

View File

@ -1,165 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import contextlib
import functools
from typing import Callable
from typing import Any
import hid
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ...yamlconf import Option
from ...validators.basic import valid_number
from ...validators.os import valid_abs_path
from . import GpioDriverOfflineError
from . import UserGpioModes
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver):
# This is like a HID relay, but does not support the common protocol.
# So no status reports, ugh.
# Why make a HID USB if you can't implement such simple things?
# So many questions, and so few answers...
def __init__( # pylint: disable=super-init-not-called
self,
instance_name: str,
notifier: aiotools.AioNotifier,
device_path: str,
) -> None:
super().__init__(instance_name, notifier)
self.__device_path = device_path
self.__device: (hid.device | None) = None # type: ignore
self.__stop = False
self.__initials: dict[int, bool] = {}
self.__state: dict[int, bool] = dict.fromkeys(range(8), False)
@classmethod
def get_plugin_options(cls) -> dict:
return {
"device": Option("", type=valid_abs_path, unpack_as="device_path"),
}
@classmethod
def get_modes(cls) -> set[str]:
return set([UserGpioModes.OUTPUT])
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return functools.partial(valid_number, min=0, max=7, name="NOYITO relay channel")
def register_output(self, pin: str, initial: (bool | None)) -> None:
self.__initials[int(pin)] = bool(initial)
def prepare(self) -> None:
logger = get_logger(0)
logger.info("Probing driver %s on %s ...", self, self.__device_path)
try:
with self.__ensure_device("probing"):
pass
except Exception as ex:
logger.error("Can't probe %s on %s: %s",
self, self.__device_path, tools.efmt(ex))
self.__reset_pins()
async def cleanup(self) -> None:
self.__reset_pins()
self.__close_device()
self.__stop = True
async def read(self, pin: str) -> bool:
return self.__state[int(pin)]
async def write(self, pin: str, state: bool) -> None:
try:
return self.__inner_write(int(pin), state)
except Exception:
raise GpioDriverOfflineError(self)
# =====
def __reset_pins(self) -> None:
logger = get_logger(0)
for (pin, state) in self.__initials.items():
logger.info("Resetting pin=%d to state=%d of %s on %s: ...",
pin, state, self, self.__device_path)
try:
self.__inner_write(pin, state)
except Exception as ex:
logger.error("Can't reset pin=%d of %s on %s: %s",
pin, self, self.__device_path, tools.efmt(ex))
def __inner_write(self, pin: int, state: bool) -> None:
assert 0 <= pin <= 7
with self.__ensure_device("writing") as device:
report = [0xA0, pin + 1, int(state), 0]
report[-1] = sum(report)
result = device.write(report)
if result < 0:
raise RuntimeError(f"Retval of send_feature_report() < 0: {result}")
self.__state[pin] = state
@contextlib.contextmanager
def __ensure_device(self, context: str) -> hid.device: # type: ignore
assert not self.__stop
if self.__device is None:
device = hid.device() # type: ignore
device.open_path(self.__device_path.encode("utf-8"))
device.set_nonblocking(True)
self.__device = device
get_logger(0).info("Opened %s on %s while %s", self, self.__device_path, context)
try:
yield self.__device
except Exception as ex:
get_logger(0).error("Error occured on %s on %s while %s: %s",
self, self.__device_path, context, tools.efmt(ex))
self.__close_device()
raise
def __close_device(self) -> None:
if self.__device:
try:
self.__device.close()
except Exception:
pass
self.__device = None
get_logger(0).info("Closed %s on %s", self, self.__device_path)
def __str__(self) -> str:
return f"Noyito({self._instance_name})"
__repr__ = __str__

View File

@ -1,165 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import asyncio
from typing import Callable
from typing import Any
from ...logging import get_logger
from ...inotify import Inotify
from ... import aiotools
from ... import usb
from ...yamlconf import Section
from ...validators.basic import valid_stripped_string_not_empty
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver):
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
otg_config: Section, # XXX: Not from options, see /kvmd/apps/kvmd/__init__.py for details
) -> None:
super().__init__(instance_name, notifier)
self.__udc: str = otg_config.udc
self.__init_delay: float = otg_config.init_delay
gadget: str = otg_config.gadget
self.__udc_path = usb.get_gadget_path(gadget, usb.G_UDC)
self.__functions_path = usb.get_gadget_path(gadget, usb.G_FUNCTIONS)
self.__profile_path = usb.get_gadget_path(gadget, usb.G_PROFILE)
self.__lock = asyncio.Lock()
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return valid_stripped_string_not_empty
def prepare(self) -> None:
self.__udc = usb.find_udc(self.__udc)
get_logger().info("Using UDC %s", self.__udc)
async def run(self) -> None:
logger = get_logger(0)
while True:
try:
while True:
self._notifier.notify()
if os.path.isfile(self.__udc_path):
break
await asyncio.sleep(5)
with Inotify() as inotify:
await inotify.watch_all_changes(os.path.dirname(self.__udc_path))
await inotify.watch_all_changes(self.__profile_path)
self._notifier.notify()
while True:
need_restart = False
need_notify = False
for event in (await inotify.get_series(timeout=1)):
need_notify = True
if event.restart:
logger.warning("Got fatal inotify event: %s; reinitializing OTG-bind ...", event)
need_restart = True
break
if need_restart:
break
if need_notify:
self._notifier.notify()
except Exception:
logger.exception("Unexpected OTG-bind watcher error")
await asyncio.sleep(1)
async def read(self, pin: str) -> bool:
if pin == "udc":
return self.__is_udc_enabled()
return os.path.exists(self.__get_fdest_path(pin))
async def write(self, pin: str, state: bool) -> None:
async with self.__lock:
if self.read(pin) == state:
return
if pin == "udc":
if state:
self.__recreate_profile()
self.__set_udc_enabled(state)
else:
if self.__is_udc_enabled():
self.__set_udc_enabled(False)
try:
if state:
os.symlink(self.__get_fsrc_path(pin), self.__get_fdest_path(pin))
else:
os.unlink(self.__get_fdest_path(pin))
except (FileNotFoundError, FileExistsError):
pass
finally:
self.__recreate_profile()
try:
await asyncio.sleep(self.__init_delay)
finally:
self.__set_udc_enabled(True)
def __recreate_profile(self) -> None:
# XXX: See pikvm/pikvm#1235
# After unbind and bind, the gadgets stop working,
# unless we recreate their links in the profile.
# Some kind of kernel bug.
for func in os.listdir(self.__profile_path):
path = self.__get_fdest_path(func)
if os.path.islink(path):
try:
os.unlink(path)
os.symlink(self.__get_fsrc_path(func), path)
except (FileNotFoundError, FileNotFoundError):
pass
def __get_fsrc_path(self, func: str) -> str:
return os.path.join(self.__functions_path, func)
def __get_fdest_path(self, func: str) -> str:
return os.path.join(self.__profile_path, func)
def __set_udc_enabled(self, enabled: bool) -> None:
with open(self.__udc_path, "w") as file:
file.write(self.__udc if enabled else "\n")
def __is_udc_enabled(self) -> bool:
with open(self.__udc_path) as file:
return bool(file.read().strip())
def __str__(self) -> str:
return f"GPIO({self._instance_name})"
__repr__ = __str__

View File

@ -1,193 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# Modified by SppokHCK September 2021 <Find me on Discord spook#8911> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import re
import multiprocessing
import functools
import errno
import time
from typing import Callable
from typing import Any
import serial
from ...logging import get_logger
from ... import aiotools
from ... import aiomulti
from ... import aioproc
from ...yamlconf import Option
from ...validators.basic import valid_number
from ...validators.basic import valid_float_f01
from ...validators.os import valid_abs_path
from ...validators.hw import valid_tty_speed
from . import GpioDriverOfflineError
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver): # pylint: disable=too-many-instance-attributes
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
device_path: str,
speed: int,
read_timeout: float,
protocol: int,
) -> None:
super().__init__(instance_name, notifier)
self.__device_path = device_path
self.__speed = speed
self.__read_timeout = read_timeout
self.__protocol = protocol
self.__ctl_queue: "multiprocessing.Queue[int]" = multiprocessing.Queue()
self.__channel_queue: "multiprocessing.Queue[int | None]" = multiprocessing.Queue()
self.__channel: (int | None) = -1
self.__proc: (multiprocessing.Process | None) = None
self.__stop_event = multiprocessing.Event()
@classmethod
def get_plugin_options(cls) -> dict:
return {
"device": Option("", type=valid_abs_path, unpack_as="device_path"),
"speed": Option(19200, type=valid_tty_speed),
"read_timeout": Option(2.0, type=valid_float_f01),
"protocol": Option(1, type=functools.partial(valid_number, min=1, max=2)),
}
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return functools.partial(valid_number, min=0, max=15, name="PWAY channel")
def prepare(self) -> None:
assert self.__proc is None
self.__proc = multiprocessing.Process(target=self.__serial_worker, daemon=True)
self.__proc.start()
async def run(self) -> None:
while True:
(got, channel) = await aiomulti.queue_get_last(self.__channel_queue, 1)
if got and self.__channel != channel:
self.__channel = channel
self._notifier.notify()
async def cleanup(self) -> None:
if self.__proc is not None:
if self.__proc.is_alive():
get_logger(0).info("Stopping %s daemon ...", self)
self.__stop_event.set()
if self.__proc.is_alive() or self.__proc.exitcode is not None:
self.__proc.join()
async def read(self, pin: str) -> bool:
if not self.__is_online():
raise GpioDriverOfflineError(self)
return (self.__channel == int(pin))
async def write(self, pin: str, state: bool) -> None:
if not self.__is_online():
raise GpioDriverOfflineError(self)
if state:
self.__ctl_queue.put_nowait(int(pin))
# =====
def __is_online(self) -> bool:
return (
self.__proc is not None
and self.__proc.is_alive()
and self.__channel is not None
)
def __serial_worker(self) -> None:
logger = aioproc.settle(str(self), f"gpio-pway-{self._instance_name}")
while not self.__stop_event.is_set():
try:
with self.__get_serial() as tty:
data = b""
self.__channel_queue.put_nowait(-1)
# Switch and then recieve the state.
# FIXME: Get actual state without modifying the current.
# I'm lazy and like the idea of the KVM resetting to port 1 on reboot of the PiKVM.
self.__reset(tty)
while not self.__stop_event.is_set():
(channel, data) = self.__recv_channel(tty, data)
if channel is not None:
self.__channel_queue.put_nowait(channel)
(got, channel) = aiomulti.queue_get_last_sync(self.__ctl_queue, 0.1) # type: ignore
if got:
assert channel is not None
self.__send_channel(tty, channel)
except Exception as ex:
self.__channel_queue.put_nowait(None)
if isinstance(ex, serial.SerialException) and ex.errno == errno.ENOENT: # pylint: disable=no-member
logger.error("Missing %s serial device: %s", self, self.__device_path)
else:
logger.exception("Unexpected %s error", self)
time.sleep(1)
def __get_serial(self) -> serial.Serial:
return serial.Serial(self.__device_path, self.__speed, timeout=self.__read_timeout)
def __recv_channel(self, tty: serial.Serial, data: bytes) -> tuple[(int | None), bytes]:
channel: (int | None) = None
if tty.in_waiting:
data += tty.read_all()
# When you switch ports you see something like "VGA_SWITCH_CONTROL=[0-15]" for ports 1-16
found = re.findall(b"VGA_SWITCH_CONTROL=[0-9]*", data)
if found:
channel = int(found[0].decode().split("=")[1])
data = data[-8:]
return (channel, data)
def __send_channel(self, tty: serial.Serial, channel: int) -> None:
# Set a channel by sending PS [1-16]
# Note that the recv is 0-based index, while send is 1-based
# We add 1 to the "channel" to normalize for the 1-based index on send
tty.write(b"PS %d\r" % (channel + 1))
tty.flush()
def __reset(self, tty: serial.Serial) -> None:
# Reset by sending PS without port number
tty.write(b"PS\r")
tty.flush()
def __str__(self) -> str:
return f"PWAY({self._instance_name})"
__repr__ = __str__

View File

@ -1,128 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Shantur Rathore <i@shantur.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Callable
from typing import Any
from periphery import PWM
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ...yamlconf import Option
from ...validators.basic import valid_int_f0
from ...validators.hw import valid_gpio_pin
from . import GpioDriverOfflineError
from . import UserGpioModes
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver):
def __init__( # pylint: disable=super-init-not-called
self,
instance_name: str,
notifier: aiotools.AioNotifier,
chip: int,
period: int,
duty_cycle_push: int,
duty_cycle_release: int,
) -> None:
super().__init__(instance_name, notifier)
self.__chip = chip
self.__period = period
self.__duty_cycle_push = duty_cycle_push
self.__duty_cycle_release = duty_cycle_release
self.__channels: dict[int, (bool | None)] = {}
self.__pwms: dict[int, PWM] = {}
@classmethod
def get_plugin_options(cls) -> dict:
return {
"chip": Option(0, type=valid_int_f0),
"period": Option(20000000, type=valid_int_f0),
"duty_cycle_push": Option(1500000, type=valid_int_f0),
"duty_cycle_release": Option(1000000, type=valid_int_f0),
}
@classmethod
def get_modes(cls) -> set[str]:
return set([UserGpioModes.OUTPUT])
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return valid_gpio_pin
def register_output(self, pin: str, initial: (bool | None)) -> None:
self.__channels[int(pin)] = initial
def prepare(self) -> None:
logger = get_logger(0)
for (pin, initial) in self.__channels.items():
try:
logger.info("Probing pwm chip %d channel %d ...", self.__chip, pin)
pwm = PWM(self.__chip, pin)
self.__pwms[pin] = pwm
pwm.period_ns = self.__period
pwm.duty_cycle_ns = self.__get_duty_cycle(bool(initial))
pwm.enable()
except Exception as ex:
logger.error("Can't get PWM chip %d channel %d: %s",
self.__chip, pin, tools.efmt(ex))
async def cleanup(self) -> None:
for (pin, pwm) in self.__pwms.items():
try:
pwm.disable()
pwm.close()
except Exception as ex:
get_logger(0).error("Can't cleanup PWM chip %d channel %d: %s",
self.__chip, pin, tools.efmt(ex))
async def read(self, pin: str) -> bool:
try:
return (self.__pwms[int(pin)].duty_cycle_ns == self.__duty_cycle_push)
except Exception:
raise GpioDriverOfflineError(self)
async def write(self, pin: str, state: bool) -> None:
try:
self.__pwms[int(pin)].duty_cycle_ns = self.__get_duty_cycle(state)
except Exception:
raise GpioDriverOfflineError(self)
def __get_duty_cycle(self, state: bool) -> int:
return (self.__duty_cycle_push if state else self.__duty_cycle_release)
def __str__(self) -> str:
return f"PWM({self._instance_name})"
__repr__ = __str__

View File

@ -1,86 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# Shantur Rathore <i@shantur.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from ... import aiotools
from ...yamlconf import Option
from ...validators.basic import valid_number
from ...validators.basic import valid_int_f0
from .pwm import Plugin as PwmPlugin
# =====
class Plugin(PwmPlugin):
def __init__( # pylint: disable=super-init-not-called,too-many-arguments
self,
instance_name: str,
notifier: aiotools.AioNotifier,
chip: int,
period: int,
duty_cycle_min: int,
duty_cycle_max: int,
angle_min: float,
angle_max: float,
angle_push: float,
angle_release: float,
) -> None:
angle_push = min(max(angle_push, angle_min), angle_max)
angle_release = min(max(angle_release, angle_min), angle_max)
duty_cycle_per_degree = (duty_cycle_max - duty_cycle_min) / (angle_max - angle_min)
duty_cycle_push = int(duty_cycle_per_degree * (angle_push - angle_min) + duty_cycle_min)
duty_cycle_release = int(duty_cycle_per_degree * (angle_release - angle_min) + duty_cycle_min)
super().__init__(
instance_name=instance_name,
notifier=notifier,
chip=chip,
period=period,
duty_cycle_push=duty_cycle_push,
duty_cycle_release=duty_cycle_release,
)
@classmethod
def get_plugin_options(cls) -> dict:
valid_angle = (lambda arg: valid_number(arg, min=-360.0, max=360.0, type=float))
return {
"chip": Option(0, type=valid_int_f0),
"period": Option(20000000, type=valid_int_f0),
"duty_cycle_min": Option(1000000, type=valid_int_f0),
"duty_cycle_max": Option(2000000, type=valid_int_f0),
"angle_min": Option(0.0, type=valid_angle),
"angle_max": Option(180.0, type=valid_angle),
"angle_push": Option(100.0, type=valid_angle),
"angle_release": Option(120.0, type=valid_angle),
}
def __str__(self) -> str:
return f"Servo({self._instance_name})"
__repr__ = __str__

View File

@ -1,198 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import functools
from typing import Callable
from typing import Any
import serial_asyncio
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ...yamlconf import Option
from ...validators.basic import valid_number
from ...validators.basic import valid_float_f0
from ...validators.basic import valid_float_f01
from ...validators.net import valid_ip_or_host
from ...validators.net import valid_port
from ...validators.os import valid_abs_path
from ...validators.hw import valid_tty_speed
from . import BaseUserGpioDriver
from . import GpioDriverOfflineError
# =====
class Plugin(BaseUserGpioDriver): # pylint: disable=too-many-instance-attributes
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
host: str,
port: int,
device_path: str,
speed: int,
timeout: float,
switch_delay: float,
state_poll: float,
) -> None:
super().__init__(instance_name, notifier)
self.__host = host
self.__port = port
self.__device_path = device_path
self.__speed = speed
self.__timeout = timeout
self.__switch_delay = switch_delay
self.__state_poll = state_poll
self.__reader: (asyncio.StreamReader | None) = None
self.__writer: (asyncio.StreamWriter | None) = None
self.__active: int = -1
self.__update_notifier = aiotools.AioNotifier()
@classmethod
def get_plugin_options(cls) -> dict:
return {
"host": Option("", type=valid_ip_or_host, if_empty=""),
"port": Option(5000, type=valid_port),
"device": Option("", type=valid_abs_path, only_if="!host", unpack_as="device_path"),
"speed": Option(9600, type=valid_tty_speed),
"timeout": Option(5.0, type=valid_float_f01),
"switch_delay": Option(1.0, type=valid_float_f0),
"state_poll": Option(10.0, type=valid_float_f01),
}
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return functools.partial(valid_number, min=0, max=15, name="TESmart channel")
async def run(self) -> None:
prev_active = -2
while True:
try:
# Current active port command uses 0-based numbering (0x00->PC1...0x0F->PC16)
self.__active = int(await self.__send_command(b"\x10\x00"))
except Exception:
pass
if self.__active != prev_active:
self._notifier.notify()
prev_active = self.__active
await self.__update_notifier.wait(self.__state_poll)
async def cleanup(self) -> None:
await self.__close_device()
self.__active = -1
async def read(self, pin: str) -> bool:
return (self.__active == int(pin))
async def write(self, pin: str, state: bool) -> None:
# Switch input source command uses 1-based numbering (0x01->PC1...0x10->PC16)
channel = int(pin) + 1
assert 1 <= channel <= 16
if state:
await self.__send_command("{:c}{:c}".format(1, channel).encode())
await asyncio.sleep(self.__switch_delay) # Slowdown
self.__update_notifier.notify()
# =====
async def __send_command(self, cmd: bytes) -> int:
assert len(cmd) == 2
await self.__ensure_device()
assert self.__reader is not None
assert self.__writer is not None
try:
self.__writer.write(b"\xAA\xBB\x03%s\xEE" % (cmd))
await asyncio.wait_for(
asyncio.ensure_future(self.__writer.drain()),
timeout=self.__timeout,
)
return (await asyncio.wait_for(
asyncio.ensure_future(self.__reader.readexactly(6)),
timeout=self.__timeout,
))[4]
except Exception as ex:
get_logger(0).error("Can't send command to TESmart KVM [%s]:%d: %s",
self.__host, self.__port, tools.efmt(ex))
await self.__close_device()
self.__active = -1
raise GpioDriverOfflineError(self)
finally:
await self.__close_device()
async def __ensure_device(self) -> None:
if self.__reader is None or self.__writer is None:
if self.__host:
await self.__ensure_device_net()
else:
await self.__ensure_device_serial()
async def __ensure_device_net(self) -> None:
try:
(self.__reader, self.__writer) = await asyncio.wait_for(
asyncio.ensure_future(asyncio.open_connection(self.__host, self.__port)),
timeout=self.__timeout,
)
except Exception as ex:
get_logger(0).error("Can't connect to TESmart KVM [%s]:%d: %s",
self.__host, self.__port, tools.efmt(ex))
raise GpioDriverOfflineError(self)
async def __ensure_device_serial(self) -> None:
try:
(self.__reader, self.__writer) = await asyncio.wait_for(
serial_asyncio.open_serial_connection(url=self.__device_path, baudrate=self.__speed),
timeout=self.__timeout,
)
except Exception as ex:
get_logger(0).error("Can't connect to TESmart KVM [%s]:%d: %s",
self.__device_path, self.__speed, tools.efmt(ex))
raise GpioDriverOfflineError(self)
async def __close_device(self) -> None:
if self.__writer:
await aiotools.close_writer(self.__writer)
self.__reader = None
self.__writer = None
# =====
def __str__(self) -> str:
return f"TESmart({self._instance_name})"
__repr__ = __str__

View File

@ -1,193 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# 2021-2021 Sebastian Goscik <sebastian.goscik@live.co.uk> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import re
import multiprocessing
import functools
import errno
import time
from typing import Callable
from typing import Any
import serial
from ...logging import get_logger
from ... import aiotools
from ... import aiomulti
from ... import aioproc
from ...yamlconf import Option
from ...validators.basic import valid_number
from ...validators.basic import valid_float_f01
from ...validators.os import valid_abs_path
from ...validators.hw import valid_tty_speed
from . import GpioDriverOfflineError
from . import BaseUserGpioDriver
# =====
class Plugin(BaseUserGpioDriver): # pylint: disable=too-many-instance-attributes
def __init__(
self,
instance_name: str,
notifier: aiotools.AioNotifier,
device_path: str,
speed: int,
read_timeout: float,
protocol: int,
) -> None:
super().__init__(instance_name, notifier)
self.__device_path = device_path
self.__speed = speed
self.__read_timeout = read_timeout
self.__protocol = protocol # https://github.com/pikvm/kvmd/pull/158
self.__ctl_queue: "multiprocessing.Queue[int]" = multiprocessing.Queue()
self.__channel_queue: "multiprocessing.Queue[int | None]" = multiprocessing.Queue()
self.__channel: (int | None) = -1
self.__proc: (multiprocessing.Process | None) = None
self.__stop_event = multiprocessing.Event()
@classmethod
def get_plugin_options(cls) -> dict:
return {
"device": Option("", type=valid_abs_path, unpack_as="device_path"),
"speed": Option(19200, type=valid_tty_speed),
"read_timeout": Option(2.0, type=valid_float_f01),
"protocol": Option(1, type=functools.partial(valid_number, min=1, max=2)),
}
@classmethod
def get_pin_validator(cls) -> Callable[[Any], Any]:
return functools.partial(valid_number, min=0, max=3, name="XH-HK4401 channel")
def prepare(self) -> None:
assert self.__proc is None
self.__proc = multiprocessing.Process(target=self.__serial_worker, daemon=True)
self.__proc.start()
async def run(self) -> None:
while True:
(got, channel) = await aiomulti.queue_get_last(self.__channel_queue, 1)
if got and self.__channel != channel:
self.__channel = channel
self._notifier.notify()
async def cleanup(self) -> None:
if self.__proc is not None:
if self.__proc.is_alive():
get_logger(0).info("Stopping %s daemon ...", self)
self.__stop_event.set()
if self.__proc.is_alive() or self.__proc.exitcode is not None:
self.__proc.join()
async def read(self, pin: str) -> bool:
if not self.__is_online():
raise GpioDriverOfflineError(self)
return (self.__channel == int(pin))
async def write(self, pin: str, state: bool) -> None:
if not self.__is_online():
raise GpioDriverOfflineError(self)
if state:
self.__ctl_queue.put_nowait(int(pin))
# =====
def __is_online(self) -> bool:
return (
self.__proc is not None
and self.__proc.is_alive()
and self.__channel is not None
)
def __serial_worker(self) -> None:
logger = aioproc.settle(str(self), f"gpio-xh-hk4401-{self._instance_name}")
while not self.__stop_event.is_set():
try:
with self.__get_serial() as tty:
data = b""
self.__channel_queue.put_nowait(-1)
# Wait for first port heartbeat to set correct channel (~2 sec max).
# Only for the classic switch with protocol version 1.
while self.__protocol == 1:
(channel, data) = self.__recv_channel(tty, data)
if channel is not None:
self.__channel_queue.put_nowait(channel)
break
while not self.__stop_event.is_set():
(channel, data) = self.__recv_channel(tty, data)
if channel is not None:
self.__channel_queue.put_nowait(channel)
(got, channel) = aiomulti.queue_get_last_sync(self.__ctl_queue, 0.1) # type: ignore
if got:
assert channel is not None
self.__send_channel(tty, channel)
if self.__protocol == 2:
self.__channel_queue.put_nowait(channel)
except Exception as ex:
self.__channel_queue.put_nowait(None)
if isinstance(ex, serial.SerialException) and ex.errno == errno.ENOENT: # pylint: disable=no-member
logger.error("Missing %s serial device: %s", self, self.__device_path)
else:
logger.exception("Unexpected %s error", self)
time.sleep(1)
def __get_serial(self) -> serial.Serial:
return serial.Serial(self.__device_path, self.__speed, timeout=self.__read_timeout)
def __recv_channel(self, tty: serial.Serial, data: bytes) -> tuple[(int | None), bytes]:
channel: (int | None) = None
if tty.in_waiting:
data += tty.read_all()
found = re.findall((b"AG0[1-4]gA" if self.__protocol == 1 else b"G0[1-4]gA\x00"), data)
if found:
try:
channel = int(found[-1][2:4] if self.__protocol == 1 else found[-1][1:3]) - 1
except Exception:
channel = None
data = data[-12:]
return (channel, data)
def __send_channel(self, tty: serial.Serial, channel: int) -> None:
assert 0 <= channel <= 3
cmd = ("SW{port}\r\nAG{port:02d}gA" if self.__protocol == 1 else "G{port:02d}gA\x00")
tty.write(cmd.format(port=(channel + 1)).encode())
tty.flush()
def __str__(self) -> str:
return f"XH-HK4401({self._instance_name})"
__repr__ = __str__

View File

@ -1,115 +0,0 @@
FROM archlinux/archlinux:base
RUN mkdir -p /etc/pacman.d/hooks \
&& ln -s /dev/null /etc/pacman.d/hooks/30-systemd-tmpfiles.hook
RUN echo 'Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch' > /etc/pacman.d/mirrorlist \
&& pacman-key --init \
&& pacman-key --populate archlinux
RUN pacman --noconfirm --ask=4 -Syy \
&& pacman --needed --noconfirm --ask=4 -S \
glibc \
pacman \
openssl \
openssl-1.1 \
&& pacman-db-upgrade \
&& pacman --noconfirm --ask=4 -Syu \
&& pacman --needed --noconfirm --ask=4 -S \
p11-kit \
archlinux-keyring \
ca-certificates \
ca-certificates-mozilla \
ca-certificates-utils \
&& pacman -Syu --noconfirm --ask=4 \
&& pacman -S --needed --noconfirm --ask=4 \
base-devel \
autoconf-archive \
help2man \
m4 \
vim \
git \
libjpeg \
libevent \
libutil-linux \
libbsd \
python \
python-pip \
python-build \
python-wheel \
python-setuptools \
python-tox \
python-mako \
python-yaml \
python-aiohttp \
python-aiofiles \
python-async-lru \
python-passlib \
python-pyotp \
python-qrcode \
python-pyserial \
python-setproctitle \
python-psutil \
python-netifaces \
python-systemd \
python-dbus \
python-dbus-next \
python-pygments \
python-pam \
python-pillow \
python-xlib \
python-mako \
libxkbcommon \
python-hidapi \
python-ldap \
python-zstandard \
libgpiod \
freetype2 \
nginx-mainline \
tesseract \
tesseract-data-eng \
tesseract-data-rus \
ipmitool \
socat \
eslint \
npm \
shellcheck \
&& (pacman -Sc --noconfirm || true) \
&& rm -rf /var/cache/pacman/pkg/*
COPY testenv/requirements.txt requirements.txt
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
&& pip install --break-system-packages -r requirements.txt \
&& pip cache purge
# https://stackoverflow.com/questions/57534295
WORKDIR /root
RUN npm config set registry https://registry.npmmirror.com \
&& npm install htmlhint -g \
&& npm install pug \
&& npm install pug-cli -g \
&& npm install @babel/eslint-parser -g \
&& npm cache clean -f
WORKDIR /
ARG USTREAMER_MIN_VERSION
ENV USTREAMER_MIN_VERSION $USTREAMER_MIN_VERSION
RUN echo $USTREAMER_MIN_VERSION
RUN git clone https://github.com/pikvm/ustreamer \
&& cd ustreamer \
&& make WITH_PYTHON=1 PREFIX=/usr DESTDIR=/ install \
&& cd - \
&& rm -rf ustreamer
RUN mkdir -p \
/etc/kvmd/{nginx,vnc} \
/var/lib/kvmd/msd \
/var/lib/kvmd/pst/data \
/opt/vc/bin
COPY testenv/fakes/vcgencmd /usr/bin/
COPY testenv/fakes/sys /fake_sysfs/sys
COPY testenv/fakes/proc /fake_procfs/proc
COPY testenv/fakes/etc /fake_etc/etc
CMD /bin/bash

View File

@ -1,26 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
# =====
ETC_PREFIX = "/fake_etc"
SYSFS_PREFIX = "/fake_sysfs"
PROCFS_PREFIX = "/fake_procfs"

View File

@ -1,2 +0,0 @@
LABEL=PIPST /var/lib/kvmd/pst ext4 nodev,nosuid,noexec,ro,errors=remount-ro,data=journal,X-kvmd.pst-user=kvmd-pst 0 0
LABEL=PIMSD /var/lib/kvmd/msd ext4 nodev,nosuid,noexec,ro,errors=remount-ro,data=journal,X-kvmd.otgmsd-user=kvmd 0 0

View File

@ -1 +0,0 @@
Virtual Raspberry Pi

View File

@ -1 +0,0 @@
0000000000000000

View File

@ -1 +0,0 @@
36511

View File

@ -1 +0,0 @@
../../../../bus/platform/drivers/dwc2

View File

@ -1 +0,0 @@
configured

View File

@ -1 +0,0 @@
../../functions/mass_storage.usb0

View File

@ -1,4 +0,0 @@
#!/bin/sh
case $1 in
get_throttled) echo "throttled=0x0";;
esac

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
[run]
data_file = testenv/.coverage
omit =
*/__main__.py,

View File

@ -1,57 +0,0 @@
const js = require("/usr/lib/node_modules/eslint/node_modules/@eslint/js/src/index.js");
const globals = require("/usr/lib/node_modules/eslint/node_modules/@eslint/eslintrc/node_modules/globals/index.js");
const parser = require("/usr/lib/node_modules/@babel/eslint-parser/lib/index.cjs");
module.exports = [
js.configs.recommended,
{
files: ["**/*.js"],
languageOptions: {
globals: globals.browser,
ecmaVersion: 2015,
parser: parser,
parserOptions: {
ecmaVersion: 2025,
sourceType: "module",
allowImportExportEverywhere: true,
requireConfigFile: false,
},
},
},
{
rules: {
indent: [
"error",
"tab",
{SwitchCase: 1},
],
"linebreak-style": [
"error",
"unix",
],
quotes: [
"error",
"double",
],
"quote-props": [
"error",
"always",
],
"semi": [
"error",
"always",
],
"comma-dangle": [
"error",
"always-multiline",
],
"no-unused-vars": [
"error",
{vars: "local", args: "after-used"},
],
},
},
];

View File

@ -1,10 +0,0 @@
[flake8]
inline-quotes = double
max-line-length = 160
ignore = W503, E221, E227, E241, E252, Q003
# W503 line break before binary operator
# E221 multiple spaces before operator
# E227 missing whitespace around bitwise or shift operator
# E241 multiple spaces after
# E252 missing whitespace around parameter equals
# Q003 Change outer quotes to avoid escaping inner quotes

View File

@ -1,3 +0,0 @@
{
"src-not-empty": false
}

View File

@ -1,5 +0,0 @@
[mypy]
python_version = 3.11
ignore_missing_imports = true
disallow_untyped_defs = true
strict_optional = true

View File

@ -1,74 +0,0 @@
[MASTER]
ignore = .git
extension-pkg-whitelist =
setproctitle,
gpiod,
spidev,
netifaces,
_ldap,
ustreamer,
hid,
[DESIGN]
min-public-methods = 0
max-args = 10
[TYPECHECK]
ignored-classes=
AioQueue,
[MESSAGES CONTROL]
disable =
file-ignored,
locally-disabled,
fixme,
missing-docstring,
superfluous-parens,
duplicate-code,
broad-except,
redundant-keyword-arg,
wrong-import-order,
too-many-ancestors,
no-else-return,
len-as-condition,
raise-missing-from,
consider-using-in,
unsubscriptable-object,
unused-private-member,
unspecified-encoding,
consider-using-f-string,
unnecessary-lambda-assignment,
too-many-positional-arguments,
# https://github.com/PyCQA/pylint/issues/3882
[CLASSES]
exclude-protected =
_unpack,
[REPORTS]
msg-template = {symbol} -- {path}:{line}({obj}): {msg}
[FORMAT]
max-line-length = 160
[BASIC]
# Good variable names which should always be accepted, separated by a comma
good-names = _, __, x, y, ws
# Regular expression matching correct method names
method-rgx = [a-z_][a-z0-9_]{2,50}$
# Regular expression matching correct function names
function-rgx = [a-z_][a-z0-9_]{2,50}$
# Regular expression which should only match correct module level names
const-rgx = ([a-zA-Z_][a-zA-Z0-9_]*)$
# Regular expression which should only match correct argument names
argument-rgx = [a-z_][a-z0-9_]{1,30}$
# Regular expression which should only match correct variable names
variable-rgx = [a-z_][a-z0-9_]{1,30}$
# Regular expression which should only match correct instance attribute names
attr-rgx = [a-z_][a-z0-9_]{1,30}$

View File

@ -1,59 +0,0 @@
InotifyMask.ACCESS
InotifyMask.ATTRIB
InotifyMask.CLOSE_WRITE
InotifyMask.CLOSE_NOWRITE
InotifyMask.CREATE
InotifyMask.DELETE
InotifyMask.DELETE_SELF
InotifyMask.MODIFY
InotifyMask.MOVE_SELF
InotifyMask.MOVED_FROM
InotifyMask.MOVED_TO
InotifyMask.OPEN
InotifyMask.IGNORED
InotifyMask.ISDIR
InotifyMask.Q_OVERFLOW
InotifyMask.UNMOUNT
IpmiServer.handle_raw_request
SpiDev.no_cs
SpiDev.cshigh
SpiDev.max_speed_hz
_DriveImage.complete
_AtxApiPart.switch_power
_UsbKey.arduino_modifier_code
_KeyMapping.web_name
_KeyMapping.mcu_code
_KeyMapping.usb_key
_KeyMapping.ps2_key
_KeyMapping.at1_code
_KeyMapping.x11_keys
_SharedParams.width
_SharedParams.height
_Netcfg.net_ip
_Netcfg.net_mask
_Netcfg.dhcp_option_3
_ScriptWriter.get_args
_pwm.period_ns
_Edid.set_mfc_id
_Edid.set_product_id
_Edid.set_serial
_Edid.set_monitor_name
_Edid.set_monitor_serial
_Edid.set_audio
Dumper.ignore_aliases
_auth_server_port_fixture
_test_user

View File

@ -1,3 +0,0 @@
PIKVM_MODEL=test_model
PIKVM_VIDEO=test_video
PIKVM_BOARD=test_board

View File

@ -1,9 +0,0 @@
python-periphery
pyserial-asyncio
pyghmi
spidev
pyrad
types-PyYAML
types-aiofiles
luma.oled
pyfatfs

View File

View File

@ -1,20 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #

View File

@ -1,20 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #

View File

@ -1,20 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #

View File

@ -1,169 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import hashlib
import tempfile
import builtins
import getpass
from typing import Generator
from typing import Any
import passlib.apache
import pytest
from kvmd.apps.htpasswd import main
# =====
def _make_passwd(user: str) -> str:
return hashlib.md5(user.encode()).hexdigest()
@pytest.fixture(name="htpasswd", params=[[], ["admin"], ["admin", "user"]])
def _htpasswd_fixture(request) -> Generator[passlib.apache.HtpasswdFile, None, None]: # type: ignore
(fd, path) = tempfile.mkstemp()
os.close(fd)
htpasswd = passlib.apache.HtpasswdFile(path)
for user in request.param:
htpasswd.set_password(user, _make_passwd(user))
htpasswd.save()
yield htpasswd
os.remove(path)
def _run_htpasswd(cmd: list[str], htpasswd_path: str, internal_type: str="htpasswd") -> None:
cmd = ["kvmd-htpasswd", *cmd, "--set-options"]
if internal_type != "htpasswd": # By default
cmd.append("kvmd/auth/internal/type=" + internal_type)
if htpasswd_path:
cmd.append("kvmd/auth/internal/file=" + htpasswd_path)
main(cmd)
# =====
def test_ok__list(htpasswd: passlib.apache.HtpasswdFile, capsys) -> None: # type: ignore
_run_htpasswd(["list"], htpasswd.path)
(out, err) = capsys.readouterr()
assert len(err) == 0
assert sorted(filter(None, out.split("\n"))) == sorted(htpasswd.users()) == sorted(set(htpasswd.users()))
# =====
def test_ok__set_change_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore
old_users = set(htpasswd.users())
if old_users:
assert htpasswd.check_password("admin", _make_passwd("admin"))
mocker.patch.object(builtins, "input", (lambda: " test "))
_run_htpasswd(["set", "admin", "--read-stdin"], htpasswd.path)
htpasswd.load(force=True)
assert htpasswd.check_password("admin", " test ")
assert old_users == set(htpasswd.users())
def test_ok__set_add_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore
old_users = set(htpasswd.users())
if old_users:
mocker.patch.object(builtins, "input", (lambda: " test "))
_run_htpasswd(["set", "new", "--read-stdin"], htpasswd.path)
htpasswd.load(force=True)
assert htpasswd.check_password("new", " test ")
assert old_users.union(["new"]) == set(htpasswd.users())
# =====
def test_ok__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore
old_users = set(htpasswd.users())
if old_users:
assert htpasswd.check_password("admin", _make_passwd("admin"))
mocker.patch.object(getpass, "getpass", (lambda *_, **__: " test "))
_run_htpasswd(["set", "admin"], htpasswd.path)
htpasswd.load(force=True)
assert htpasswd.check_password("admin", " test ")
assert old_users == set(htpasswd.users())
def test_fail__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore
old_users = set(htpasswd.users())
if old_users:
assert htpasswd.check_password("admin", _make_passwd("admin"))
count = 0
def fake_getpass(*_: Any, **__: Any) -> str:
nonlocal count
assert count <= 1
if count == 0:
passwd = " test "
else:
passwd = "test "
count += 1
return passwd
mocker.patch.object(getpass, "getpass", fake_getpass)
with pytest.raises(SystemExit, match="Sorry, passwords do not match"):
_run_htpasswd(["set", "admin"], htpasswd.path)
assert count == 2
htpasswd.load(force=True)
assert htpasswd.check_password("admin", _make_passwd("admin"))
assert old_users == set(htpasswd.users())
# =====
def test_ok__del(htpasswd: passlib.apache.HtpasswdFile) -> None:
old_users = set(htpasswd.users())
if old_users:
assert htpasswd.check_password("admin", _make_passwd("admin"))
_run_htpasswd(["del", "admin"], htpasswd.path)
htpasswd.load(force=True)
assert not htpasswd.check_password("admin", _make_passwd("admin"))
assert old_users.difference(["admin"]) == set(htpasswd.users())
# =====
def test_fail__not_htpasswd() -> None:
with pytest.raises(SystemExit, match="Error: KVMD internal auth not using 'htpasswd'"):
_run_htpasswd(["list"], "", internal_type="http")
def test_fail__unknown_plugin() -> None:
with pytest.raises(SystemExit, match="ConfigError: Unknown plugin 'auth/foobar'"):
_run_htpasswd(["list"], "", internal_type="foobar")
def test_fail__invalid_passwd(mocker, tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
open(path, "w").close() # pylint: disable=consider-using-with
mocker.patch.object(builtins, "input", (lambda: "\n"))
with pytest.raises(SystemExit, match="The argument is not a valid passwd characters"):
_run_htpasswd(["set", "admin", "--read-stdin"], path)

View File

@ -1,20 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #

View File

@ -1,223 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import contextlib
from typing import AsyncGenerator
import passlib.apache
import pytest
from kvmd.yamlconf import make_config
from kvmd.apps.kvmd.auth import AuthManager
from kvmd.plugins.auth import get_auth_service_class
from kvmd.htserver import HttpExposed
# =====
_E_AUTH = HttpExposed("GET", "/foo_auth", True, (lambda: None))
_E_UNAUTH = HttpExposed("GET", "/bar_unauth", True, (lambda: None))
_E_FREE = HttpExposed("GET", "/baz_free", False, (lambda: None))
def _make_service_kwargs(path: str) -> dict:
cls = get_auth_service_class("htpasswd")
scheme = cls.get_plugin_options()
return make_config({"file": path}, scheme)._unpack()
@contextlib.asynccontextmanager
async def _get_configured_manager(
unauth_paths: list[str],
internal_path: str,
external_path: str="",
force_internal_users: (list[str] | None)=None,
) -> AsyncGenerator[AuthManager, None]:
manager = AuthManager(
enabled=True,
unauth_paths=unauth_paths,
internal_type="htpasswd",
internal_kwargs=_make_service_kwargs(internal_path),
force_internal_users=(force_internal_users or []),
external_type=("htpasswd" if external_path else ""),
external_kwargs=(_make_service_kwargs(external_path) if external_path else {}),
totp_secret_path="",
)
try:
yield manager
finally:
await manager.cleanup()
# =====
@pytest.mark.asyncio
async def test_ok__internal(tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
htpasswd = passlib.apache.HtpasswdFile(path, new=True)
htpasswd.set_password("admin", "pass")
htpasswd.save()
async with _get_configured_manager([], path) as manager:
assert manager.is_auth_enabled()
assert manager.is_auth_required(_E_AUTH)
assert manager.is_auth_required(_E_UNAUTH)
assert not manager.is_auth_required(_E_FREE)
assert manager.check("xxx") is None
manager.logout("xxx")
assert (await manager.login("user", "foo")) is None
assert (await manager.login("admin", "foo")) is None
assert (await manager.login("user", "pass")) is None
token1 = await manager.login("admin", "pass")
assert isinstance(token1, str)
assert len(token1) == 64
token2 = await manager.login("admin", "pass")
assert isinstance(token2, str)
assert len(token2) == 64
assert token1 != token2
assert manager.check(token1) == "admin"
assert manager.check(token2) == "admin"
assert manager.check("foobar") is None
manager.logout(token1)
assert manager.check(token1) is None
assert manager.check(token2) is None
assert manager.check("foobar") is None
token3 = await manager.login("admin", "pass")
assert isinstance(token3, str)
assert len(token3) == 64
assert token1 != token3
assert token2 != token3
@pytest.mark.asyncio
async def test_ok__external(tmpdir) -> None: # type: ignore
path1 = os.path.abspath(str(tmpdir.join("htpasswd1")))
path2 = os.path.abspath(str(tmpdir.join("htpasswd2")))
htpasswd1 = passlib.apache.HtpasswdFile(path1, new=True)
htpasswd1.set_password("admin", "pass1")
htpasswd1.set_password("local", "foobar")
htpasswd1.save()
htpasswd2 = passlib.apache.HtpasswdFile(path2, new=True)
htpasswd2.set_password("admin", "pass2")
htpasswd2.set_password("user", "foobar")
htpasswd2.save()
async with _get_configured_manager([], path1, path2, ["admin"]) as manager:
assert manager.is_auth_enabled()
assert manager.is_auth_required(_E_AUTH)
assert manager.is_auth_required(_E_UNAUTH)
assert not manager.is_auth_required(_E_FREE)
assert (await manager.login("local", "foobar")) is None
assert (await manager.login("admin", "pass2")) is None
token = await manager.login("admin", "pass1")
assert token is not None
assert manager.check(token) == "admin"
manager.logout(token)
assert manager.check(token) is None
token = await manager.login("user", "foobar")
assert token is not None
assert manager.check(token) == "user"
manager.logout(token)
assert manager.check(token) is None
@pytest.mark.asyncio
async def test_ok__unauth(tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
htpasswd = passlib.apache.HtpasswdFile(path, new=True)
htpasswd.set_password("admin", "pass")
htpasswd.save()
async with _get_configured_manager([
"", " ",
"foo_auth", "/foo_auth ", " /foo_auth",
"/foo_authx", "/foo_auth/", "/foo_auth/x",
"/bar_unauth", # Only this one is matching
], path) as manager:
assert manager.is_auth_enabled()
assert manager.is_auth_required(_E_AUTH)
assert not manager.is_auth_required(_E_UNAUTH)
assert not manager.is_auth_required(_E_FREE)
@pytest.mark.asyncio
async def test_ok__disabled() -> None:
try:
manager = AuthManager(
enabled=False,
unauth_paths=[],
internal_type="foobar",
internal_kwargs={},
force_internal_users=[],
external_type="",
external_kwargs={},
totp_secret_path="",
)
assert not manager.is_auth_enabled()
assert not manager.is_auth_required(_E_AUTH)
assert not manager.is_auth_required(_E_UNAUTH)
assert not manager.is_auth_required(_E_FREE)
with pytest.raises(AssertionError):
await manager.authorize("admin", "admin")
with pytest.raises(AssertionError):
await manager.login("admin", "admin")
with pytest.raises(AssertionError):
manager.logout("xxx")
with pytest.raises(AssertionError):
manager.check("xxx")
finally:
await manager.cleanup()

View File

@ -1,20 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #

View File

@ -1,35 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import pytest
from kvmd.keyboard.mappings import KEYMAP
# =====
def test_ok__keymap() -> None:
assert KEYMAP["KeyA"].mcu.code == 1
def test_fail__keymap() -> None:
with pytest.raises(KeyError):
print(KEYMAP["keya"])

View File

@ -1,20 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #

View File

@ -1,43 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import contextlib
from typing import AsyncGenerator
from typing import Any
from kvmd.yamlconf import make_config
from kvmd.plugins.auth import BaseAuthService
from kvmd.plugins.auth import get_auth_service_class
# =====
@contextlib.asynccontextmanager
async def get_configured_auth_service(name: str, **kwargs: Any) -> AsyncGenerator[BaseAuthService, None]:
service_class = get_auth_service_class(name)
config = make_config(kwargs, service_class.get_plugin_options())
service = service_class(**config._unpack())
try:
yield service
finally:
await service.cleanup()

View File

@ -1,54 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import passlib.apache
import pytest
from . import get_configured_auth_service
# =====
@pytest.mark.asyncio
async def test_ok__htpasswd_service(tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
htpasswd = passlib.apache.HtpasswdFile(path, new=True)
htpasswd.set_password("admin", "pass")
htpasswd.save()
async with get_configured_auth_service("htpasswd", file=path) as service:
assert not (await service.authorize("user", "foo"))
assert not (await service.authorize("admin", "foo"))
assert not (await service.authorize("user", "pass"))
assert (await service.authorize("admin", "pass"))
htpasswd.set_password("admin", "bar")
htpasswd.set_password("user", "bar")
htpasswd.save()
assert (await service.authorize("admin", "bar"))
assert (await service.authorize("user", "bar"))
assert not (await service.authorize("admin", "foo"))
assert not (await service.authorize("user", "foo"))

View File

@ -1,79 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import AsyncGenerator
import aiohttp.web
import aiohttp_basicauth
import pytest
import pytest_asyncio
from . import get_configured_auth_service
# =====
async def _handle_auth(req: aiohttp.web.BaseRequest) -> aiohttp.web.Response:
status = 400
if req.method == "POST":
credentials = (await req.json())
if credentials["user"] == "admin" and credentials["passwd"] == "pass":
status = 200
return aiohttp.web.Response(text=str(status), status=status)
@pytest_asyncio.fixture(name="auth_server_port")
async def _auth_server_port_fixture(aiohttp_server) -> AsyncGenerator[int, None]: # type: ignore
auth = aiohttp_basicauth.BasicAuthMiddleware(
username="server-admin",
password="server-pass",
force=False,
)
app = aiohttp.web.Application(middlewares=[auth])
app.router.add_post("/auth", _handle_auth)
app.router.add_post("/auth_plus_basic", auth.required(_handle_auth))
server = await aiohttp_server(app)
try:
yield server.port
finally:
await server.close()
# =====
@pytest.mark.asyncio
@pytest.mark.parametrize("kwargs", [
{},
{"verify": False},
{"user": "server-admin", "passwd": "server-pass"},
])
async def test_ok(auth_server_port: int, kwargs: dict) -> None:
url = "http://localhost:%d/%s" % (
auth_server_port,
("auth_plus_basic" if kwargs.get("user") else "auth"),
)
async with get_configured_auth_service("http", url=url, **kwargs) as service:
assert not (await service.authorize("user", "foobar"))
assert not (await service.authorize("admin", "foobar"))
assert not (await service.authorize("user", "pass"))
assert (await service.authorize("admin", "pass"))

View File

@ -1,93 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import asyncio
import pwd
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from . import get_configured_auth_service
# =====
_UID = 1500
_USER = "foobar"
_PASSWD = "query"
# =====
async def _run_process(cmd: str, input: (str | None)=None) -> None: # pylint: disable=redefined-builtin
proc = await asyncio.create_subprocess_exec(
*cmd.split(" "),
stdin=(asyncio.subprocess.PIPE if input is not None else None),
preexec_fn=os.setpgrp,
)
await proc.communicate(input.encode() if input is not None else None)
assert proc.returncode == 0
@pytest_asyncio.fixture(name="test_user")
async def _test_user() -> AsyncGenerator[None, None]:
with pytest.raises(KeyError):
pwd.getpwnam(_USER)
await _run_process(f"useradd -u {_UID} -s /bin/bash {_USER}")
await _run_process("chpasswd", input=f"{_USER}:{_PASSWD}\n")
assert pwd.getpwnam(_USER).pw_uid == _UID
try:
yield
finally:
await _run_process(f"userdel -r {_USER}")
with pytest.raises(KeyError):
pwd.getpwnam(_USER)
# =====
@pytest.mark.asyncio
@pytest.mark.parametrize("kwargs", [
{},
{"allow_users": [_USER]},
{"allow_uids_at": _UID},
])
async def test_ok(test_user, kwargs: dict) -> None: # type: ignore
_ = test_user
async with get_configured_auth_service("pam", **kwargs) as service:
assert not (await service.authorize(_USER, "invalid_password"))
assert (await service.authorize(_USER, _PASSWD))
@pytest.mark.asyncio
@pytest.mark.parametrize("kwargs", [
{"allow_users": ["root"]},
{"deny_users": [_USER]},
{"allow_uids_at": _UID + 1},
])
async def test_fail(test_user, kwargs: dict) -> None: # type: ignore
_ = test_user
async with get_configured_auth_service("pam", **kwargs) as service:
assert not (await service.authorize(_USER, "invalid_password"))
assert not (await service.authorize(_USER, _PASSWD))

View File

@ -1,149 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import pytest
from kvmd.aiotools import AioExclusiveRegion
from kvmd.aiotools import shield_fg
# =====
class RegionIsBusyError(Exception):
pass
# =====
@pytest.mark.asyncio
async def test_ok__region__access_one() -> None:
region = AioExclusiveRegion(RegionIsBusyError)
async def func() -> None:
assert not region.is_busy()
with region:
assert region.is_busy()
assert not region.is_busy()
await func()
assert not region.is_busy()
region.exit()
assert not region.is_busy()
@pytest.mark.asyncio
async def test_fail__region__access_one() -> None:
region = AioExclusiveRegion(RegionIsBusyError)
async def func() -> None:
assert not region.is_busy()
with region:
assert region.is_busy()
region.enter()
assert not region.is_busy()
with pytest.raises(RegionIsBusyError):
await func()
assert not region.is_busy()
region.exit()
assert not region.is_busy()
# =====
@pytest.mark.asyncio
async def test_ok__region__access_two() -> None:
region = AioExclusiveRegion(RegionIsBusyError)
async def func1() -> None:
with region:
await asyncio.sleep(1)
print("done func1()")
async def func2() -> None:
await asyncio.sleep(2)
print("waiking up func2()")
with region:
await asyncio.sleep(1)
print("done func2()")
await asyncio.gather(func1(), func2())
assert not region.is_busy()
region.exit()
assert not region.is_busy()
@pytest.mark.asyncio
async def test_fail__region__access_two() -> None:
region = AioExclusiveRegion(RegionIsBusyError)
async def func1() -> None:
with region:
await asyncio.sleep(2)
print("done func1()")
async def func2() -> None:
await asyncio.sleep(1)
with region:
await asyncio.sleep(1)
print("done func2()")
results = await asyncio.gather(func1(), func2(), return_exceptions=True)
assert results[0] is None
assert type(results[1]) is RegionIsBusyError # pylint: disable=unidiomatic-typecheck
assert not region.is_busy()
region.exit()
assert not region.is_busy()
# =====
@pytest.mark.asyncio
async def test_ok__shield_fg() -> None:
ops: list[str] = []
async def foo(op: str, delay: float) -> None: # pylint: disable=disallowed-name
await asyncio.sleep(delay)
ops.append(op)
async def bar() -> None: # pylint: disable=disallowed-name
try:
try:
try:
raise RuntimeError()
finally:
await shield_fg(foo("foo1", 2.0))
ops.append("foo1-noexc")
finally:
await shield_fg(foo("foo2", 1.0))
ops.append("foo2-noexc")
finally:
ops.append("done")
task = asyncio.create_task(bar())
await asyncio.sleep(0.1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert ops == ["foo1", "foo2", "foo2-noexc", "done"]

View File

@ -1,35 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import pytest
from kvmd.logging import get_logger
# =====
@pytest.mark.parametrize("depth, name", [
(0, "tests.test_logging"),
(1, "_pytest.python"),
(2, "pluggy._callers"),
])
def test_ok__get_logger(depth: int, name: str) -> None:
assert get_logger(depth).name == name

View File

@ -1,42 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import pathlib
import textwrap
from kvmd.yamlconf.loader import load_yaml_file
# =====
def test_load_yaml_file__bools(tmp_path: pathlib.Path) -> None: # type: ignore
pobj = tmp_path / "test.yaml"
pobj.write_text(textwrap.dedent("""
a: true
b: false
c: yes
d: no
"""))
data = load_yaml_file(str(pobj))
assert data["a"] is True
assert data["b"] is False
assert data["c"] == "yes"
assert data["d"] == "no"

View File

@ -1,20 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #

View File

@ -1,130 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Any
import pytest
from kvmd.validators import ValidatorError
from kvmd.validators.auth import valid_user
from kvmd.validators.auth import valid_users_list
from kvmd.validators.auth import valid_passwd
from kvmd.validators.auth import valid_auth_token
# =====
@pytest.mark.parametrize("arg", [
"test-",
"glados",
"test",
"_",
"_foo_bar_",
" aix",
])
def test_ok__valid_user(arg: Any) -> None:
assert valid_user(arg) == arg.strip()
@pytest.mark.parametrize("arg", [
"тест",
"-molestia",
"te~st",
"-",
"-foo_bar",
"foo bar",
" ",
"",
None,
])
def test_fail__valid_user(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_user(arg))
# =====
@pytest.mark.parametrize("arg, retval", [
("foo, bar, ", ["foo", "bar"]),
("foo bar", ["foo", "bar"]),
(["foo", "bar"], ["foo", "bar"]),
("", []),
(" ", []),
(", ", []),
(", foo, ", ["foo"]),
([], []),
])
def test_ok__valid_users_list(arg: Any, retval: list) -> None:
assert valid_users_list(arg) == retval
@pytest.mark.parametrize("arg", [None, [None], [""], [" "], ["user,"]])
def test_fail__valid_users_list(arg: Any) -> None: # pylint: disable=invalid-name
with pytest.raises(ValidatorError):
print(valid_users_list(arg))
# =====
@pytest.mark.parametrize("arg", [
"glados",
"test",
"_",
"_foo_bar_",
" aix",
" ",
"",
" O(*#&@)FD*S)D(F ",
])
def test_ok__valid_passwd(arg: Any) -> None:
assert valid_passwd(arg) == arg
@pytest.mark.parametrize("arg", [
"тест",
"\n",
" \n",
"\n\n",
"\r",
None,
])
def test_fail__valid_passwd(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_passwd(arg))
# =====
@pytest.mark.parametrize("arg", [
("0" * 64) + " ",
("f" * 64) + " ",
])
def test_ok__valid_auth_token(arg: Any) -> None:
assert valid_auth_token(arg) == arg.strip()
@pytest.mark.parametrize("arg", [
("F" * 64),
"0" * 63,
"0" * 65,
"",
None,
])
def test_fail__valid_auth_token(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_auth_token(arg))

View File

@ -1,174 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Any
import pytest
from kvmd.validators import ValidatorError
from kvmd.validators.basic import valid_bool
from kvmd.validators.basic import valid_number
from kvmd.validators.basic import valid_int_f0
from kvmd.validators.basic import valid_int_f1
from kvmd.validators.basic import valid_float_f0
from kvmd.validators.basic import valid_float_f01
from kvmd.validators.basic import valid_string_list
# =====
@pytest.mark.parametrize("arg, retval", [
("1", True),
("true", True),
("TRUE", True),
("yes ", True),
(1, True),
(True, True),
("0", False),
("false", False),
("FALSE", False),
("no ", False),
(0, False),
(False, False),
])
def test_ok__valid_bool(arg: Any, retval: bool) -> None:
assert valid_bool(arg) == retval
@pytest.mark.parametrize("arg", ["test", "", None, -1, "x"])
def test_fail__valid_bool(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_bool(arg))
# =====
@pytest.mark.parametrize("arg", ["1 ", "-1", 1, -1, 0, 100500])
def test_ok__valid_number(arg: Any) -> None:
assert valid_number(arg) == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, "1x", 100500.0])
def test_fail__valid_number(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_number(arg))
@pytest.mark.parametrize("arg", [-5, 0, 5, "-5 ", "0 ", "5 "])
def test_ok__valid_number__min_max(arg: Any) -> None:
assert valid_number(arg, -5, 5) == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -6, 6, "-6 ", "6 "])
def test_fail__valid_number__min_max(arg: Any) -> None: # pylint: disable=invalid-name
with pytest.raises(ValidatorError):
print(valid_number(arg, -5, 5))
# =====
@pytest.mark.parametrize("arg", [0, 1, 5, "5 "])
def test_ok__valid_int_f0(arg: Any) -> None:
value = valid_int_f0(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6 ", "5.0"])
def test_fail__valid_int_f0(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_int_f0(arg))
# =====
@pytest.mark.parametrize("arg", [1, 5, "5 "])
def test_ok__valid_int_f1(arg: Any) -> None:
value = valid_int_f1(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6 ", 0, "0 ", "5.0"])
def test_fail__valid_int_f1(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_int_f1(arg))
# =====
@pytest.mark.parametrize("arg", [0, 1, 5, "5 ", "5.0 "])
def test_ok__valid_float_f0(arg: Any) -> None:
value = valid_float_f0(arg)
assert type(value) is float # pylint: disable=unidiomatic-typecheck
assert value == float(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6"])
def test_fail__valid_float_f0(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_float_f0(arg))
# =====
@pytest.mark.parametrize("arg", [0.1, 1, 5, "5 ", "5.0 "])
def test_ok__valid_float_f01(arg: Any) -> None:
value = valid_float_f01(arg)
assert type(value) is float # pylint: disable=unidiomatic-typecheck
assert value == float(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, 0.0, "0.0", -6, "-6", 0, "0"])
def test_fail__valid_float_f01(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_float_f01(arg))
# =====
@pytest.mark.parametrize("arg, retval", [
("a, b, c", ["a", "b", "c"]),
("a, b,, c", ["a", "b", "c"]),
("a b c", ["a", "b", "c"]),
(["a", "b", "c"], ["a", "b", "c"]),
("", []),
(" ", []),
(", ", []),
(", a, ", ["a"]),
([], []),
])
def test_ok__valid_string_list(arg: Any, retval: list) -> None:
assert valid_string_list(arg) == retval
@pytest.mark.parametrize("arg, retval", [
("1, 2, 3", [1, 2, 3]),
("1 2 3", [1, 2, 3]),
([1, 2, 3], [1, 2, 3]),
("", []),
(" ", []),
(", ", []),
(", 1, ", [1]),
([], []),
])
def test_ok__valid_string_list__subval(arg: Any, retval: list) -> None: # pylint: disable=invalid-name
assert valid_string_list(arg, subval=int) == retval
@pytest.mark.parametrize("arg", [None, [None]])
def test_fail__valid_string_list(arg: Any) -> None: # pylint: disable=invalid-name
with pytest.raises(ValidatorError):
print(valid_string_list(arg))

View File

@ -1,98 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Any
import pytest
from kvmd.keyboard.mappings import KEYMAP
from kvmd.validators import ValidatorError
from kvmd.validators.hid import valid_hid_key
from kvmd.validators.hid import valid_hid_mouse_move
from kvmd.validators.hid import valid_hid_mouse_button
from kvmd.validators.hid import valid_hid_mouse_delta
# =====
def test_ok__valid_hid_key() -> None:
for key in KEYMAP:
print(valid_hid_key(key))
print(valid_hid_key(key + " "))
@pytest.mark.parametrize("arg", ["test", "", None, "keya"])
def test_fail__valid_hid_key(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_hid_key(arg))
# =====
@pytest.mark.parametrize("arg", [-20000, "1 ", "-1", 1, -1, 0, "20000 "])
def test_ok__valid_hid_mouse_move(arg: Any) -> None:
assert valid_hid_mouse_move(arg) == int(str(arg).strip())
def test_ok__valid_hid_mouse_move__m50000() -> None:
assert valid_hid_mouse_move(-50000) == -32768
def test_ok__valid_hid_mouse_move__p50000() -> None:
assert valid_hid_mouse_move(50000) == 32767
@pytest.mark.parametrize("arg", ["test", "", None, 1.1])
def test_fail__valid_hid_mouse_move(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_hid_mouse_move(arg))
# =====
@pytest.mark.parametrize("arg", ["LEFT ", "RIGHT ", "Up ", " Down", " MiDdLe "])
def test_ok__valid_hid_mouse_button(arg: Any) -> None:
assert valid_hid_mouse_button(arg) == arg.strip().lower()
@pytest.mark.parametrize("arg", ["test", "", None])
def test_fail__valid_hid_mouse_button(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_hid_mouse_button(arg))
# =====
@pytest.mark.parametrize("arg", [-100, "1 ", "-1", 1, -1, 0, "100 "])
def test_ok__valid_hid_mouse_delta(arg: Any) -> None:
assert valid_hid_mouse_delta(arg) == int(str(arg).strip())
def test_ok__valid_hid_mouse_delta__m200() -> None:
assert valid_hid_mouse_delta(-200) == -127
def test_ok__valid_hid_mouse_delta__p200() -> None:
assert valid_hid_mouse_delta(200) == 127
@pytest.mark.parametrize("arg", ["test", "", None, 1.1])
def test_fail__valid_hid_mouse_delta(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_hid_mouse_delta(arg))

View File

@ -1,132 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Any
import pytest
from kvmd.validators import ValidatorError
from kvmd.validators.hw import valid_tty_speed
from kvmd.validators.hw import valid_gpio_pin
from kvmd.validators.hw import valid_gpio_pin_optional
from kvmd.validators.hw import valid_otg_gadget
from kvmd.validators.hw import valid_otg_id
from kvmd.validators.hw import valid_otg_ethernet
# =====
@pytest.mark.parametrize("arg", ["1200 ", 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200])
def test_ok__valid_tty_speed(arg: Any) -> None:
value = valid_tty_speed(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, 0, 1200.1])
def test_fail__valid_tty_speed(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_tty_speed(arg))
# =====
@pytest.mark.parametrize("arg", ["0 ", 0, 1, 13])
def test_ok__valid_gpio_pin(arg: Any) -> None:
value = valid_gpio_pin(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -1, -13, 1.1])
def test_fail__valid_gpio_pin(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_gpio_pin(arg))
# =====
@pytest.mark.parametrize("arg", ["0 ", -1, 0, 1, 13])
def test_ok__valid_gpio_pin_optional(arg: Any) -> None:
value = valid_gpio_pin_optional(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -2, -13, 1.1])
def test_fail__valid_gpio_pin_optional(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_gpio_pin_optional(arg))
# =====
@pytest.mark.parametrize("arg", [
"test-",
"glados",
"test",
"_",
"_foo_bar_",
" aix",
"a" * 255,
])
def test_ok__valid_otg_gadget(arg: Any) -> None:
assert valid_otg_gadget(arg) == arg.strip()
@pytest.mark.parametrize("arg", [
"тест",
"-molestia",
"te~st",
"-",
"-foo_bar",
"foo bar",
"a" * 256,
" ",
"",
None,
])
def test_fail__valid_otg_gadget(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_otg_gadget(arg))
# =====
@pytest.mark.parametrize("arg", ["0 ", 0, 1, 13, 65535])
def test_ok__valid_otg_id(arg: Any) -> None:
value = valid_otg_id(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -1, -13, 1.1, 65534.5, 65536])
def test_fail__valid_otg_id(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_otg_id(arg))
# =====
@pytest.mark.parametrize("arg", ["ECM ", "EeM ", "ncm ", " Rndis", "RNDIS5"])
def test_ok__valid_otg_ethernet(arg: Any) -> None:
assert valid_otg_ethernet(arg) == arg.strip().lower()
@pytest.mark.parametrize("arg", ["test", "", None])
def test_fail__valid_otg_ethernet(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_otg_ethernet(arg))

View File

@ -1,210 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Any
import pytest
from kvmd.validators import ValidatorError
from kvmd.validators.kvm import valid_atx_power_action
from kvmd.validators.kvm import valid_atx_button
from kvmd.validators.kvm import valid_msd_image_name
from kvmd.validators.kvm import valid_info_fields
from kvmd.validators.kvm import valid_log_seek
from kvmd.validators.kvm import valid_stream_quality
from kvmd.validators.kvm import valid_stream_fps
from kvmd.validators.kvm import valid_stream_resolution
from kvmd.validators.kvm import valid_stream_h264_bitrate
from kvmd.validators.kvm import valid_stream_h264_gop
# =====
@pytest.mark.parametrize("arg", ["ON ", "OFF ", "OFF_HARD ", "RESET_HARD "])
def test_ok__valid_atx_power_action(arg: Any) -> None:
assert valid_atx_power_action(arg) == arg.strip().lower()
@pytest.mark.parametrize("arg", ["test", "", None])
def test_fail__valid_atx_power_action(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_atx_power_action(arg))
# =====
@pytest.mark.parametrize("arg", ["POWER ", "POWER_LONG ", "RESET "])
def test_ok__valid_atx_button(arg: Any) -> None:
assert valid_atx_button(arg) == arg.strip().lower()
@pytest.mark.parametrize("arg", ["test", "", None])
def test_fail__valid_atx_button(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_atx_button(arg))
# =====
@pytest.mark.parametrize("arg, retval", [
("archlinux-2018.07.01-i686.iso", "archlinux-2018.07.01-i686.iso"),
("archlinux-2018.07.01-x86_64.iso", "archlinux-2018.07.01-x86_64.iso"),
("dsl-4.11.rc1.iso", "dsl-4.11.rc1.iso"),
("systemrescuecd-x86-5.3.1.iso", "systemrescuecd-x86-5.3.1.iso"),
("ubuntu-16.04.5-desktop-i386.iso", "ubuntu-16.04.5-desktop-i386.iso"),
(" тест(){}[ \t].iso\t", "тест(){}[ _].iso"),
("\n" + "x" * 1000, "x" * 255),
("test", "test"),
("test test [test] #test$", "test test [test] #test$"),
("test/", "test"),
("/test", "test"),
("foo/bar.iso", "foo/bar.iso"),
("//foo//bar.iso", "foo/bar.iso"),
("foo/lost-found/bar.iso", "foo/lost-found/bar.iso"),
("/bar.iso/", "bar.iso"),
])
def test_ok__valid_msd_image_name(arg: Any, retval: str) -> None:
assert valid_msd_image_name(arg) == retval
@pytest.mark.parametrize("arg", [
".",
"..",
" ..",
"../test",
"./.",
"../.",
"./..",
"../..",
"/ ..",
".. /",
"/.. /",
".test",
"foo/../bar.iso",
"foo/./foo.iso",
"foo/lost+found/bar.iso",
"../bar.iso",
"/../bar.iso",
"foo/.bar.iso",
"",
" ",
None,
])
def test_fail__valid_msd_image_name(arg: Any) -> None:
with pytest.raises(ValidatorError):
valid_msd_image_name(arg)
# =====
@pytest.mark.parametrize("arg", [" foo ", "bar", "foo, ,bar,", " ", " , ", ""])
def test_ok__valid_info_fields(arg: Any) -> None:
value = valid_info_fields(arg, set(["foo", "bar"]))
assert type(value) is set # pylint: disable=unidiomatic-typecheck
assert value == set(filter(None, map(str.strip, str(arg).split(","))))
@pytest.mark.parametrize("arg", ["xxx", "yyy", "foo,xxx", None])
def test_fail__valid_info_fields(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_info_fields(arg, set(["foo", "bar"])))
# =====
@pytest.mark.parametrize("arg", ["0 ", 0, 1, 13])
def test_ok__valid_log_seek(arg: Any) -> None:
value = valid_log_seek(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -1, -13, 1.1])
def test_fail__valid_log_seek(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_log_seek(arg))
# =====
@pytest.mark.parametrize("arg", ["1 ", 20, 100])
def test_ok__valid_stream_quality(arg: Any) -> None:
value = valid_stream_quality(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, 0, 101, 1.1])
def test_fail__valid_stream_quality(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_stream_quality(arg))
# =====
@pytest.mark.parametrize("arg", ["1 ", 120])
def test_ok__valid_stream_fps(arg: Any) -> None:
value = valid_stream_fps(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, 121, 1.1])
def test_fail__valid_stream_fps(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_stream_fps(arg))
# =====
@pytest.mark.parametrize("arg", ["1280x720 ", "1x1"])
def test_ok__valid_stream_resolution(arg: Any) -> None:
value = valid_stream_resolution(arg)
assert type(value) is str # pylint: disable=unidiomatic-typecheck
assert value == str(arg).strip()
@pytest.mark.parametrize("arg", ["x", None, "0x0", "0x1", "1x0", "1280", "1280x", "1280x720x"])
def test_fail__valid_stream_resolution(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_stream_resolution(arg))
# =====
@pytest.mark.parametrize("arg", ["25", " 20000 ", 5000])
def test_ok__valid_stream_h264_bitrate(arg: Any) -> None:
value = valid_stream_h264_bitrate(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["0", "-1", "100.0", 5000.1, None, ""])
def test_fail__valid_stream_h264_bitrate(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_stream_h264_bitrate(arg))
# =====
@pytest.mark.parametrize("arg", ["1 ", 0, 60])
def test_ok__valid_stream_h264_gop(arg: Any) -> None:
value = valid_stream_h264_gop(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, 61, 1.1])
def test_fail__valid_stream_h264_gop(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_stream_h264_gop(arg))

View File

@ -1,210 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from typing import Any
import pytest
from kvmd.validators import ValidatorError
from kvmd.validators.net import valid_ip_or_host
from kvmd.validators.net import valid_ip
from kvmd.validators.net import valid_net
from kvmd.validators.net import valid_rfc_host
from kvmd.validators.net import valid_port
from kvmd.validators.net import valid_ports_list
from kvmd.validators.net import valid_mac
from kvmd.validators.net import valid_ssl_ciphers
# =====
@pytest.mark.parametrize("arg", [
"yandex.ru ",
"foobar",
"foo-bar.ru",
"127.0.0.1",
"8.8.8.8",
"::",
"::1",
"2001:500:2f::f",
])
def test_ok__valid_ip_or_host(arg: Any) -> None:
assert valid_ip_or_host(arg) == arg.strip()
@pytest.mark.parametrize("arg", [
"foo_bar.ru",
"1.1.1.",
":",
"",
None,
])
def test_fail__valid_ip_or_host(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_ip_or_host(arg))
# =====
@pytest.mark.parametrize("arg", [
"127.0.0.1 ",
"8.8.8.8",
"::",
"::1",
"2001:500:2f::f",
])
def test_ok__valid_ip(arg: Any) -> None:
assert valid_ip(arg) == arg.strip()
@pytest.mark.parametrize("arg", [
"ya.ru",
"1",
"1.1.1",
"1.1.1.",
":",
"",
None,
])
def test_fail__valid_ip(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_ip(arg))
# =====
@pytest.mark.parametrize("arg", [
"127.0.0.0/24 ",
"8.8.8.8/31",
"::/16",
"::ffff:0:0:0/96",
"64:ff9b::/96",
])
def test_ok__valid_net(arg: Any) -> None:
assert valid_net(arg) == arg.strip()
@pytest.mark.parametrize("arg", [
"127.0.0.1/33",
"127.0.0.1/0",
"127.0.0.1/",
"127.0.0.1",
"8.8.8.8//31",
"ya.ru",
"1",
"1.1.1",
"1.1.1.",
":",
"",
None,
])
def test_fail__valid_net(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_net(arg))
# =====
@pytest.mark.parametrize("arg", [
"yandex.ru ",
"foobar",
"foo-bar.ru",
"z0r.de",
"11.ru",
"127.0.0.1",
])
def test_ok__valid_rfc_host(arg: Any) -> None:
assert valid_rfc_host(arg) == arg.strip()
@pytest.mark.parametrize("arg", [
"foobar.ru.",
"foo_bar.ru",
"",
None,
])
def test_fail__valid_rfc_host(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_rfc_host(arg))
# =====
@pytest.mark.parametrize("arg", ["0 ", 0, "22", 443, 65535])
def test_ok__valid_port(arg: Any) -> None:
value = valid_port(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, 1.1])
def test_fail__valid_port(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_port(arg))
# =====
@pytest.mark.parametrize("arg, retval", [
("", []),
(",, , ", []),
("0 ", [0]),
("1,", [1]),
("22,23", [22, 23]),
("80,443,443,", [80, 443, 443]),
(65535, [65535]),
])
def test_ok__valid_ports_list(arg: Any, retval: list[int]) -> None:
assert valid_ports_list(arg) == retval
@pytest.mark.parametrize("arg", ["test", "13,test", None, 1.1])
def test_fail__valid_ports_list(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_ports_list(arg))
# =====
@pytest.mark.parametrize("arg", [
" 00:00:00:00:00:00 ",
" 9f:00:00:00:00:00 ",
" FF:FF:FF:FF:FF:FF ",
])
def test_ok__valid_mac(arg: Any) -> None:
assert valid_mac(arg) == arg.strip().lower()
@pytest.mark.parametrize("arg", [
"00:00:00:00:00:0",
"9x:00:00:00:00:00",
"",
None,
])
def test_fail__valid_mac(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_mac(arg))
# =====
@pytest.mark.parametrize("arg", ["ALL", " ALL:@SECLEVEL=0 "])
def test_ok__valid_ssl_ciphers(arg: Any) -> None:
assert valid_ssl_ciphers(arg) == str(arg).strip()
@pytest.mark.parametrize("arg", ["test", "all", "", None])
def test_fail__valid_ssl_ciphers(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_ssl_ciphers(arg))

Some files were not shown because too many files have changed in this diff Show More