diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e6fd1b69f..9044818c2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 3.229 +current_version = 4.148 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/.dockerignore b/.dockerignore index e0435ada3..ae2e96754 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,8 +8,14 @@ /testenv/.tox/ /testenv/.mypy_cache/ /testenv/.ssl/ -/hid/.pio/ -/hid/.platformio/ +/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 diff --git a/.github/workflows/arduino-hid.yml b/.github/workflows/arduino-hid.yml new file mode 100644 index 000000000..c8cc1b152 --- /dev/null +++ b/.github/workflows/arduino-hid.yml @@ -0,0 +1,23 @@ +name: Arduino HID CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + container: + image: python + + steps: + - uses: actions/checkout@v3 + + - name: Prepare platformio + run: pip install platformio + + - name: Build all + run: make -C hid/arduino _build_all diff --git a/.github/workflows/hid.yml b/.github/workflows/hid.yml deleted file mode 100644 index 984f77dd8..000000000 --- a/.github/workflows/hid.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: HID CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - build: - runs-on: ubuntu-latest - - container: - image: python - - steps: - - uses: actions/checkout@v3 - - - name: Prepare platformio - run: pip install platformio - - - name: Build all - run: make -C hid _build_all diff --git a/.github/workflows/pico-hid-release.yml b/.github/workflows/pico-hid-release.yml new file mode 100644 index 000000000..d4524e5ff --- /dev/null +++ b/.github/workflows/pico-hid-release.yml @@ -0,0 +1,41 @@ +name: Pico HID Release + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Installing deps ... + run: sudo apt-get install cmake gcc-arm-none-eabi build-essential + + - name: Building ... + run: make -C hid/pico all + + - name: Releasing ... + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + + - name: Uploading firmware ... + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./hid/pico/hid.uf2 + asset_name: pico-hid.uf2 + asset_content_type: application/octet-stream diff --git a/.github/workflows/pico-hid.yml b/.github/workflows/pico-hid.yml new file mode 100644 index 000000000..4866a218f --- /dev/null +++ b/.github/workflows/pico-hid.yml @@ -0,0 +1,20 @@ +name: Pico HID CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Installing deps ... + run: sudo apt-get install cmake gcc-arm-none-eabi build-essential + + - name: Running tests ... + run: make -C hid/pico all diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index ed0528d4f..fdd513618 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -1,20 +1,20 @@ name: TOX CI on: - push: - branches: [master] - pull_request: - branches: [master] + push: + branches: [master] + pull_request: + branches: [master] jobs: - build: - runs-on: ubuntu-latest + build: + runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 + steps: + - uses: actions/checkout@v2 - - name: Building testenv ... - run: make testenv + - name: Building testenv ... + run: make testenv - - name: Running tests ... - run: make tox CMD="tox -c testenv/tox.ini" + - name: Running tests ... + run: make tox CMD="tox -c testenv/tox.ini" diff --git a/.gitignore b/.gitignore index 04b79f1c4..3127051e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -/hid/.platformio/ -/hid/.pio/ /pkg/ /src/ /site/ diff --git a/Makefile b/Makefile index 9a992babd..d61d59f04 100644 --- a/Makefile +++ b/Makefile @@ -3,23 +3,33 @@ TESTENV_IMAGE ?= kvmd-testenv TESTENV_HID ?= /dev/ttyS10 TESTENV_VIDEO ?= /dev/video0 -TESTENV_GPIO ?= /dev/gpiochip0 -TESTENV_RELAY ?= $(if $(shell ls /dev/hidraw0 2>/dev/null || true),/dev/hidraw0,) +TESTENV_RELAY ?= +# TESTENV_RELAY ?= $(if $(shell ls /dev/hidraw0 2>/dev/null || true),/dev/hidraw0,) -LIBGPIOD_VERSION ?= 1.6.3 +TESTENV_GPIO_MODULE = /sys/module/gpio_mockup +TESTENV_GPIO = /dev/$(call -basename,$(call -dirname,$(wildcard $(TESTENV_GPIO_MODULE)/drivers/*/*/*/dev))) USTREAMER_MIN_VERSION ?= $(shell grep -o 'ustreamer>=[^"]\+' PKGBUILD | sed 's/ustreamer>=//g') DEFAULT_PLATFORM ?= v2-hdmi-rpi4 DOCKER ?= docker +DOCKER_BUILD ?= build # ===== -define optbool +define -optbool $(filter $(shell echo $(1) | tr A-Z a-z),yes on 1) endef +define -dirname +$(patsubst %/,%,$(dir $(1))) +endef + +define -basename +$(notdir $(patsubst %/,%,$(1))) +endef + # ===== all: @@ -47,8 +57,8 @@ all: testenv: - $(DOCKER) build \ - $(if $(call optbool,$(NC)),--no-cache,) \ + $(DOCKER) $(DOCKER_BUILD) \ + $(if $(call -optbool,$(NC)),--no-cache,) \ --rm \ --tag $(TESTENV_IMAGE) \ --build-arg LIBGPIOD_VERSION=$(LIBGPIOD_VERSION) \ @@ -82,25 +92,31 @@ tox: testenv -t $(TESTENV_IMAGE) bash -c " \ cp -a /src/testenv/.ssl/nginx /etc/kvmd/nginx/ssl \ && cp -a /src/testenv/.ssl/vnc /etc/kvmd/vnc/ssl \ + && cp /src/testenv/platform /usr/lib/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ - && mkdir -p /etc/kvmd/override.d \ - && cp /src/testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ + && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /usr/lib/kvmd/main.yaml \ + && cp -r /src/testenv/override.d -T /etc/kvmd/override.d \ + && cp /src/testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.d/01-platform.yaml \ + && cp /usr/share/kvmd/configs.default/kvmd/override.yaml -T /etc/kvmd/override.yaml \ && cd /src \ && $(if $(CMD),$(CMD),tox -q -c testenv/tox.ini $(if $(E),-e $(E),-p auto)) \ " -$(TESTENV_GPIO): - test ! -e $(TESTENV_GPIO) - sudo modprobe gpio-mockup gpio_mockup_ranges=0,40 - test -c $(TESTENV_GPIO) +$(TESTENV_GPIO_MODULE): + sudo modprobe gpio_mockup gpio_mockup_ranges=0,40 + +gpio: $(TESTENV_GPIO_MODULE) + test -c "$(TESTENV_GPIO)" -run: testenv $(TESTENV_GPIO) +.NOTPARALLEL: run +run: testenv gpio - $(DOCKER) run --rm --name kvmd \ + --ipc=shareable \ --privileged \ --volume `pwd`/testenv/run:/run/kvmd:rw \ --volume `pwd`/testenv:/testenv:ro \ @@ -113,24 +129,27 @@ run: testenv $(TESTENV_GPIO) --device $(TESTENV_VIDEO):$(TESTENV_VIDEO) \ --device $(TESTENV_GPIO):$(TESTENV_GPIO) \ $(if $(TESTENV_RELAY),--device $(TESTENV_RELAY):$(TESTENV_RELAY),) \ - --publish 8080:80/tcp \ - --publish 4430:443/tcp \ + --publish 8080:8080/tcp \ + --publish 4430:4430/tcp \ -it $(TESTENV_IMAGE) /bin/bash -c " \ mkdir -p /tmp/kvmd-nginx \ && mount -t debugfs none /sys/kernel/debug \ - && test -d /sys/kernel/debug/gpio-mockup/`basename $(TESTENV_GPIO)`/ || (echo \"Missing GPIO mockup\" && exit 1) \ + && test -d /sys/kernel/debug/gpio-mockup/$(call -basename,$(TESTENV_GPIO))/ || (echo \"Missing GPIO mockup\" && exit 1) \ && (socat PTY,link=$(TESTENV_HID) PTY,link=/dev/ttyS11 &) \ && cp -r /usr/share/kvmd/configs.default/nginx/* /etc/kvmd/nginx \ - && cp testenv/redirect-to-https.conf /etc/kvmd/nginx \ && cp -a /testenv/.ssl/nginx /etc/kvmd/nginx/ssl \ && cp -a /testenv/.ssl/vnc /etc/kvmd/vnc/ssl \ + && cp /testenv/platform /usr/lib/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ + && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /usr/lib/kvmd/main.yaml \ && ln -s /testenv/web.css /etc/kvmd/web.css \ - && mkdir -p /etc/kvmd/override.d \ - && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ + && cp -r /testenv/override.d -T /etc/kvmd/override.d \ + && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.d/01-platform.yaml \ + && cp /usr/share/kvmd/configs.default/kvmd/override.yaml -T /etc/kvmd/override.yaml \ + && python -m kvmd.apps.ngxmkconf /etc/kvmd/nginx/nginx.conf.mako /etc/kvmd/nginx/nginx.conf \ && nginx -c /etc/kvmd/nginx/nginx.conf -g 'user http; error_log stderr;' \ && ln -s $(TESTENV_VIDEO) /dev/kvmd-video \ && ln -s $(TESTENV_GPIO) /dev/kvmd-gpio \ @@ -138,25 +157,9 @@ run: testenv $(TESTENV_GPIO) " -run-cfg: testenv - - $(DOCKER) run --rm --name kvmd-cfg \ - --volume `pwd`/testenv/run:/run/kvmd:rw \ - --volume `pwd`/testenv:/testenv:ro \ - --volume `pwd`/kvmd:/kvmd:ro \ - --volume `pwd`/extras:/usr/share/kvmd/extras:ro \ - --volume `pwd`/configs:/usr/share/kvmd/configs.default:ro \ - --volume `pwd`/contrib/keymaps:/usr/share/kvmd/keymaps:ro \ - -it $(TESTENV_IMAGE) /bin/bash -c " \ - cp -a /testenv/.ssl/nginx /etc/kvmd/nginx/ssl \ - && cp -a /testenv/.ssl/vnc /etc/kvmd/vnc/ssl \ - && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ - && mkdir -p /etc/kvmd/override.d \ - && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ - && $(if $(CMD),$(CMD),python -m kvmd.apps.kvmd -m) \ - " +.NOTPARALLEL: shell +shell: testenv gpio + $(MAKE) run CMD=bash run-ipmi: testenv @@ -171,18 +174,22 @@ run-ipmi: testenv -it $(TESTENV_IMAGE) /bin/bash -c " \ cp -a /testenv/.ssl/nginx /etc/kvmd/nginx/ssl \ && cp -a /testenv/.ssl/vnc /etc/kvmd/vnc/ssl \ + && cp /testenv/platform /usr/lib/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ - && mkdir -p /etc/kvmd/override.d \ - && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ + && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /usr/lib/kvmd/main.yaml \ + && cp -r /testenv/override.d -T /etc/kvmd/override.d \ + && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.d/01-platform.yaml \ + && cp /usr/share/kvmd/configs.default/kvmd/override.yaml -T /etc/kvmd/override.yaml \ && $(if $(CMD),$(CMD),python -m kvmd.apps.ipmi --run) \ " run-vnc: testenv - $(DOCKER) run --rm --name kvmd-vnc \ + --ipc=container:kvmd \ --volume `pwd`/testenv/run:/run/kvmd:rw \ --volume `pwd`/testenv:/testenv:ro \ --volume `pwd`/kvmd:/kvmd:ro \ @@ -193,12 +200,15 @@ run-vnc: testenv -it $(TESTENV_IMAGE) /bin/bash -c " \ cp -a /testenv/.ssl/nginx /etc/kvmd/nginx/ssl \ && cp -a /testenv/.ssl/vnc /etc/kvmd/vnc/ssl \ + && cp /testenv/platform /usr/lib/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ - && mkdir -p /etc/kvmd/override.d \ - && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ + && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /usr/lib/kvmd/main.yaml \ + && cp -r /testenv/override.d -T /etc/kvmd/override.d \ + && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.d/01-platform.yaml \ + && cp /usr/share/kvmd/configs.default/kvmd/override.yaml -T /etc/kvmd/override.yaml \ && $(if $(CMD),$(CMD),python -m kvmd.apps.vnc --run) \ " @@ -211,8 +221,9 @@ keymap: testenv --volume `pwd`:/src \ -it $(TESTENV_IMAGE) bash -c "cd src \ && ./genmap.py keymap.csv kvmd/keyboard/mappings.py.mako kvmd/keyboard/mappings.py \ - && ./genmap.py keymap.csv hid/lib/drivers/usb-keymap.h.mako hid/lib/drivers/usb-keymap.h \ - && ./genmap.py keymap.csv hid/lib/drivers-avr/ps2/keymap.h.mako hid/lib/drivers-avr/ps2/keymap.h \ + && ./genmap.py keymap.csv hid/arduino/lib/drivers/usb-keymap.h.mako hid/arduino/lib/drivers/usb-keymap.h \ + && ./genmap.py keymap.csv hid/arduino/lib/drivers-avr/ps2/keymap.h.mako hid/arduino/lib/drivers-avr/ps2/keymap.h \ + && ./genmap.py keymap.csv hid/pico/src/ph_usb_keymap.h.mako hid/pico/src/ph_usb_keymap.h \ " @@ -250,11 +261,13 @@ push: clean: rm -rf testenv/run/*.{pid,sock} build site dist pkg src v*.tar.gz *.pkg.tar.{xz,zst} *.egg-info kvmd-*.tar.gz find kvmd testenv/tests -name __pycache__ | xargs rm -rf - make -C hid clean + make -C hid/arduino clean + make -C hid/pico clean clean-all: testenv clean - make -C hid clean-all + make -C hid/arduino clean-all + make -C hid/pico clean-all - $(DOCKER) run --rm \ --volume `pwd`:/src \ -it $(TESTENV_IMAGE) bash -c "cd src && rm -rf testenv/{.ssl,.tox,.mypy_cache,.coverage}" diff --git a/PKGBUILD b/PKGBUILD index f1306e2c5..619090b4b 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -11,12 +11,19 @@ _variants=( 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 - v2-hdmiusb:generic v3-hdmi:rpi4 @@ -32,22 +39,24 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=3.229 +pkgver=4.148 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" license=(GPL) arch=(any) depends=( - "python>=3.11" - "python<3.12" + "python>=3.14" + "python<3.15" python-yaml + python-ruamel-yaml python-aiohttp python-aiofiles + python-async-lru python-passlib + # python-bcrypt python-pyotp python-qrcode - python-periphery python-pyserial python-pyserial-asyncio python-spidev @@ -58,7 +67,7 @@ depends=( python-dbus python-dbus-next python-pygments - python-pyghmi + "python-pyghmi>=1.6.0-2" python-pam python-pillow python-xlib @@ -68,24 +77,27 @@ depends=( python-pyrad python-ldap python-zstandard - libgpiod + python-mako + "python-luma-core>=2.5.2" + python-luma-oled + python-pyusb + python-pyudev + python-evdev + "libgpiod>=2.1" freetype2 "v4l-utils>=1.22.1-1" - nginx-mainline + "nginx-mainline>=1.25.1" openssl - platformio - avrdude-pikvm - make - patch sudo iptables iproute2 dnsmasq ipmitool - "janus-gateway-pikvm>=0.11.2-7" + "janus-gateway-pikvm>=1.3.0" certbot - platform-io-access - "ustreamer>=5.32" + "raspberrypi-io-access>=0.7" + raspberrypi-utils + "ustreamer>=6.47" # Systemd UDEV bug "systemd>=248.3-2" @@ -94,21 +106,25 @@ depends=( # https://archlinuxarm.org/forum/viewtopic.php?f=15&t=15725&start=40 "zstd>=1.5.1-2.1" - # Avoid dhcpcd stack trace - dhclient - netctl - - # Possible hotfix for the new os update - openssl-1.1 - # Bootconfig dos2unix parted e2fsprogs openssh - wpa_supplicant + # FIXME: + # - https://archlinuxarm.org/forum/viewtopic.php?f=15&t=17007&p=72789 + # - https://github.com/pikvm/pikvm/issues/1375 + # Update at 2025.11.10: Still not fixed. + # - https://github.com/pikvm/pikvm/issues/1604 + wpa_supplicant-pikvm run-parts + # fsck for /boot + dosfstools + + # pgrep for kvmd-udev-restart-pass, sysctl for kvmd-otgnet + procps-ng + # Misc hostapd ) @@ -118,46 +134,48 @@ optdepends=( conflicts=( python-pikvm python-aiohttp-pikvm + platformio + avrdude-pikvm + kvmd-oled + + # See kvmd/crypto.py + python-bcrypt +) +makedepends=( + python-setuptools + python-pip ) -makedepends=(python-setuptools) source=("$url/archive/v$pkgver.tar.gz") md5sums=(SKIP) backup=( - etc/kvmd/{override,logging,auth,meta}.yaml + etc/kvmd/{override,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/listen-http{,s}.conf etc/kvmd/nginx/loc-{login,nocache,proxy,websocket,nobuffering,bigpost}.conf - etc/kvmd/nginx/{mime-types,ssl,redirect-to-https,nginx}.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 ) -build() { - cd "$srcdir" - rm -rf $pkgname-build - cp -r kvmd-$pkgver $pkgname-build - cd $pkgname-build - python setup.py build -} - - package_kvmd() { - install=$pkgname.install + install=kvmd.install - cd "$srcdir/$pkgname-build" - python setup.py install --root="$pkgdir" + 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 -dm755 "$pkgdir/usr/lib/systemd/system" + cp -rd configs/os/services -T "$pkgdir/usr/lib/systemd/system" + 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" + cp -r {switch,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" @@ -173,8 +191,8 @@ package_kvmd() { 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,redirect-to-https,ssl,listen-http{,s}}.conf + 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" @@ -187,37 +205,46 @@ package_kvmd() { mkdir -p "$pkgdir/etc/kvmd/override.d" mkdir -p "$pkgdir/var/lib/kvmd/"{msd,pst} - - # Avoid dhcp problems - install -DTm755 configs/os/netctl-dhcp "$pkgdir/etc/netctl/hooks/pikvm-dhcp" + chmod 1775 "$pkgdir/var/lib/kvmd/pst" } for _variant in "${_variants[@]}"; do _platform=${_variant%:*} _board=${_variant#*:} + _base=${_platform%-*} + _video=${_platform#*-} eval "package_kvmd-platform-$_platform-$_board() { cd \"kvmd-\$pkgver\" + install=platform.install + + backup=() + pkgdesc=\"PiKVM platform configs - $_platform for $_board\" - depends=(kvmd=$pkgver-$pkgrel) - if [ $_board != generic ]; then - depends=(\"\${depends[@]}\" \"linux-rpi-pikvm>=5.15.25-16\") - fi + depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.12.56-5\" \"raspberrypi-bootloader-pikvm>=20251031-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/$_platform-$_board.rules \"\$pkgdir/etc/udev/rules.d/99-kvmd.rules\" - install -DTm444 configs/kvmd/main/$_platform-$_board.yaml \"\$pkgdir/etc/kvmd/main.yaml\" + install -DTm644 configs/os/sysctl.conf \"\$pkgdir/usr/lib/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/usr/lib/udev/rules.d/99-kvmd.rules\" + install -DTm644 configs/kvmd/main/$_platform-$_board.yaml \"\$pkgdir/usr/lib/kvmd/main.yaml\" + + if [ -f configs/os/modules-load/$_platform.conf ]; then + install -DTm644 configs/os/modules-load/$_platform.conf \"\$pkgdir/usr/lib/modules-load.d/kvmd.conf\" + fi if [ -f configs/kvmd/fan/$_platform.ini ]; then backup=(\"\${backup[@]}\" etc/kvmd/fan.ini) @@ -225,11 +252,6 @@ for _variant in "${_variants[@]}"; do 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\" @@ -237,8 +259,20 @@ for _variant in "${_variants[@]}"; do fi if [[ $_platform =~ ^.*-hdmi$ ]]; then - backup=(\"\${backup[@]}\" etc/kvmd/tc358743-edid.hex) - install -DTm444 configs/kvmd/edid/$_platform.hex \"\$pkgdir/etc/kvmd/tc358743-edid.hex\" + backup=(\"\${backup[@]}\" etc/kvmd/tc358743-edid.hex etc/kvmd/switch-edid.hex) + install -DTm444 configs/kvmd/edid/$_base.hex \"\$pkgdir/etc/kvmd/tc358743-edid.hex\" + ln -s tc358743-edid.hex \"\$pkgdir/etc/kvmd/switch-edid.hex\" + else + backup=(\"\${backup[@]}\" etc/kvmd/switch-edid.hex) + install -DTm444 configs/kvmd/edid/_no-1920x1200.hex \"\$pkgdir/etc/kvmd/switch-edid.hex\" fi + + mkdir -p \"\$pkgdir/usr/lib/kvmd\" + local _platform=\"\$pkgdir/usr/lib/kvmd/platform\" + rm -f \"\$_platform\" + echo PIKVM_MODEL=$_base > \"\$_platform\" + echo PIKVM_VIDEO=$_video >> \"\$_platform\" + echo PIKVM_BOARD=$_board >> \"\$_platform\" + chmod 444 \"\$_platform\" }" done diff --git a/configs/janus/janus.plugin.ustreamer.jcfg b/configs/janus/janus.plugin.ustreamer.jcfg index 6aadcf67c..49b5cfa7b 100644 --- a/configs/janus/janus.plugin.ustreamer.jcfg +++ b/configs/janus/janus.plugin.ustreamer.jcfg @@ -1,7 +1,10 @@ video: { sink = "kvmd::ustreamer::h264" } -audio: { - device = "hw:0,0" +acap: { + device = "hw:tc358743,0" tc358743 = "/dev/kvmd-video" } +aplay: { + device = "plughw:UAC2Gadget,0" +} diff --git a/configs/kvmd/auth.yaml b/configs/kvmd/auth.yaml deleted file mode 100644 index 0967ef424..000000000 --- a/configs/kvmd/auth.yaml +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/configs/kvmd/edid/_1080p-by-default.hex b/configs/kvmd/edid/_1080p-by-default.hex new file mode 100644 index 000000000..9998cb8bd --- /dev/null +++ b/configs/kvmd/edid/_1080p-by-default.hex @@ -0,0 +1,16 @@ +00FFFFFFFFFFFF0031D8717701010101 +231A010380351E780E0565A756529C27 +0F50543FED00B300A9C0950090408180 +814081C0714FEE2C80A070381A403020 +3500404421000002000000FF00434146 +45424142452020202020000000FD0032 +4B0F5211000A202020202020000000FC +0050694B564D0A202020202020200174 +020317314A049F13223E213D203C0167 +030C001000802DEE2C80A070381A4030 +203500404421000002011D007251D01E +206E2835000F282100001E0000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +000000000000000000000000000000CD diff --git a/configs/kvmd/edid/_no-1920x1200.hex b/configs/kvmd/edid/_no-1920x1200.hex new file mode 100644 index 000000000..00c745d34 --- /dev/null +++ b/configs/kvmd/edid/_no-1920x1200.hex @@ -0,0 +1,16 @@ +00FFFFFFFFFFFF0031D8737701010101 +231A010380351E780E0565A756529C27 +0F50543FED00B300A9C0950090408180 +814081C0714F023A801871382D40582C +45000F282100001E000000FF00434146 +45424142452020202020000000FD0032 +4B0F5211000A202020202020000000FC +0050694B564D0A2020202020202001B1 +020320714B90041F13223E213D203C01 +67030C001000802D23097F0783010000 +023A801871382D40582C45000F282100 +001E011D007251D01E206E2855000F28 +2100001E023A80D072382D40102C4580 +0F282100001E00000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000042 diff --git a/configs/kvmd/edid/v0-hdmi.hex b/configs/kvmd/edid/v0-hdmi.hex deleted file mode 100644 index d870783e4..000000000 --- a/configs/kvmd/edid/v0-hdmi.hex +++ /dev/null @@ -1,16 +0,0 @@ -00FFFFFFFFFFFF0031D8888800888888 -1C150103800000780AEE91A3544C9926 -0F50543FCD0001000101010101010101 -010101010101011D007251D01E206E28 -5500C48E2100001E8C0AD08A20E02D10 -103E9600138E2100001E000000FC0050 -694B564D0A20202020202020000000FD -003B3D0F2E0F1E0A202020202020013C -02031D034E041303021211012021A23C -3D3E1F66030C00300080E2007F8C0AD0 -8A20E02D10103E9600C48E210000188C -0AD08A20E02D10103E9600138E210000 -189729A0D051842230509816009A0111 -00001800000000000000000000000000 -00000000000000000000000000000000 -0000000000000000000000000000006E diff --git a/configs/kvmd/edid/v0.hex b/configs/kvmd/edid/v0.hex new file mode 100644 index 000000000..6c53d8d66 --- /dev/null +++ b/configs/kvmd/edid/v0.hex @@ -0,0 +1,16 @@ +00FFFFFFFFFFFF0031D8707701010101 +231A010380351E780E0565A756529C27 +0F50543FED00B300A9C0950090408180 +814081C0714F011D007251D01E206E28 +55000F282100001E000000FF00434146 +45424142452020202020000000FD0032 +4B0F5211000A202020202020000000FC +0050694B564D0A20202020202020018D +020317314A841F13223E213D203C0167 +030C001000802D011D007251D01E206E +2855000F282100001E00000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000015 diff --git a/configs/kvmd/edid/v1.hex b/configs/kvmd/edid/v1.hex new file mode 100644 index 000000000..6c53d8d66 --- /dev/null +++ b/configs/kvmd/edid/v1.hex @@ -0,0 +1,16 @@ +00FFFFFFFFFFFF0031D8707701010101 +231A010380351E780E0565A756529C27 +0F50543FED00B300A9C0950090408180 +814081C0714F011D007251D01E206E28 +55000F282100001E000000FF00434146 +45424142452020202020000000FD0032 +4B0F5211000A202020202020000000FC +0050694B564D0A20202020202020018D +020317314A841F13223E213D203C0167 +030C001000802D011D007251D01E206E +2855000F282100001E00000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000015 diff --git a/configs/kvmd/edid/v2-hdmi.hex b/configs/kvmd/edid/v2-hdmi.hex deleted file mode 100644 index d870783e4..000000000 --- a/configs/kvmd/edid/v2-hdmi.hex +++ /dev/null @@ -1,16 +0,0 @@ -00FFFFFFFFFFFF0031D8888800888888 -1C150103800000780AEE91A3544C9926 -0F50543FCD0001000101010101010101 -010101010101011D007251D01E206E28 -5500C48E2100001E8C0AD08A20E02D10 -103E9600138E2100001E000000FC0050 -694B564D0A20202020202020000000FD -003B3D0F2E0F1E0A202020202020013C -02031D034E041303021211012021A23C -3D3E1F66030C00300080E2007F8C0AD0 -8A20E02D10103E9600C48E210000188C -0AD08A20E02D10103E9600138E210000 -189729A0D051842230509816009A0111 -00001800000000000000000000000000 -00000000000000000000000000000000 -0000000000000000000000000000006E diff --git a/configs/kvmd/edid/v2.hex b/configs/kvmd/edid/v2.hex new file mode 100644 index 000000000..6c53d8d66 --- /dev/null +++ b/configs/kvmd/edid/v2.hex @@ -0,0 +1,16 @@ +00FFFFFFFFFFFF0031D8707701010101 +231A010380351E780E0565A756529C27 +0F50543FED00B300A9C0950090408180 +814081C0714F011D007251D01E206E28 +55000F282100001E000000FF00434146 +45424142452020202020000000FD0032 +4B0F5211000A202020202020000000FC +0050694B564D0A20202020202020018D +020317314A841F13223E213D203C0167 +030C001000802D011D007251D01E206E +2855000F282100001E00000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000015 diff --git a/configs/kvmd/edid/v3-hdmi.hex b/configs/kvmd/edid/v3-hdmi.hex deleted file mode 100644 index d870783e4..000000000 --- a/configs/kvmd/edid/v3-hdmi.hex +++ /dev/null @@ -1,16 +0,0 @@ -00FFFFFFFFFFFF0031D8888800888888 -1C150103800000780AEE91A3544C9926 -0F50543FCD0001000101010101010101 -010101010101011D007251D01E206E28 -5500C48E2100001E8C0AD08A20E02D10 -103E9600138E2100001E000000FC0050 -694B564D0A20202020202020000000FD -003B3D0F2E0F1E0A202020202020013C -02031D034E041303021211012021A23C -3D3E1F66030C00300080E2007F8C0AD0 -8A20E02D10103E9600C48E210000188C -0AD08A20E02D10103E9600138E210000 -189729A0D051842230509816009A0111 -00001800000000000000000000000000 -00000000000000000000000000000000 -0000000000000000000000000000006E diff --git a/configs/kvmd/edid/v3.hex b/configs/kvmd/edid/v3.hex new file mode 100644 index 000000000..aeb8dc29e --- /dev/null +++ b/configs/kvmd/edid/v3.hex @@ -0,0 +1,16 @@ +00FFFFFFFFFFFF0031D8717701010101 +231A010380351E780E0565A756529C27 +0F50543FED00B300A9C0950090408180 +814081C0714F011D007251D01E206E28 +55000F282100001E000000FF00434146 +45424142452020202020000000FD0032 +4B0F5211000A202020202020000000FC +0050694B564D2056330A202020200143 +020317314A841F13223E213D203C0167 +030C001000802D011D007251D01E206E +2855000F282100001E00000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000015 diff --git a/configs/kvmd/edid/v4mini-hdmi.hex b/configs/kvmd/edid/v4mini-hdmi.hex deleted file mode 100644 index 465cb31f2..000000000 --- a/configs/kvmd/edid/v4mini-hdmi.hex +++ /dev/null @@ -1,16 +0,0 @@ -00FFFFFFFFFFFF0031D8888800888888 -1C150103800000780AEE91A3544C9926 -0F50543FCF0001010101010101010101 -010101010101011D007251D01E206E28 -5500C48E2100001E8C0AD08A20E02D10 -103E9600138E2100001E000000FC0050 -694B564D0A20202020202020000000FD -003B3D0F2E0F1E0A2020202020200139 -02031E444F041303021211012021223C -3D3E901F66030C00300080E2007F8C0A -D08A20E02D10103E9600C48E21000018 -8C0AD08A20E02D10103E9600138E2100 -00188C0AA01451F01600267C4300138E -21000098000000000000000000000000 -00000000000000000000000000000000 -00000000000000000000000000000054 diff --git a/configs/kvmd/edid/v4mini.hex b/configs/kvmd/edid/v4mini.hex new file mode 100644 index 000000000..a38e70d28 --- /dev/null +++ b/configs/kvmd/edid/v4mini.hex @@ -0,0 +1,16 @@ +00FFFFFFFFFFFF0031D8727701010101 +231A010380351E780E0565A756529C27 +0F50543FED00B300A9C0950090408180 +814081C0714F023A801871382D40582C +45000F282100001E000000FF00434146 +45424142452020202020000000FD0032 +4B0F5211000A202020202020000000FC +0050694B564D205634204D696E690145 +020320714B90041F13223E213D203C01 +67030C001000802D23097F0783010000 +023A801871382D40582C45000F282100 +001E011D007251D01E206E2855000F28 +2100001E023A80D072382D40102C4580 +0F282100001E283C80A070B023403020 +360006442100001A0000000000000000 +00000000000000000000000000000030 diff --git a/configs/kvmd/edid/v4plus-hdmi.hex b/configs/kvmd/edid/v4plus-hdmi.hex deleted file mode 100644 index 465cb31f2..000000000 --- a/configs/kvmd/edid/v4plus-hdmi.hex +++ /dev/null @@ -1,16 +0,0 @@ -00FFFFFFFFFFFF0031D8888800888888 -1C150103800000780AEE91A3544C9926 -0F50543FCF0001010101010101010101 -010101010101011D007251D01E206E28 -5500C48E2100001E8C0AD08A20E02D10 -103E9600138E2100001E000000FC0050 -694B564D0A20202020202020000000FD -003B3D0F2E0F1E0A2020202020200139 -02031E444F041303021211012021223C -3D3E901F66030C00300080E2007F8C0A -D08A20E02D10103E9600C48E21000018 -8C0AD08A20E02D10103E9600138E2100 -00188C0AA01451F01600267C4300138E -21000098000000000000000000000000 -00000000000000000000000000000000 -00000000000000000000000000000054 diff --git a/configs/kvmd/edid/v4plus.hex b/configs/kvmd/edid/v4plus.hex new file mode 100644 index 000000000..9be030c2a --- /dev/null +++ b/configs/kvmd/edid/v4plus.hex @@ -0,0 +1,16 @@ +00FFFFFFFFFFFF0031D8737701010101 +231A010380351E780E0565A756529C27 +0F50543FED00B300A9C0950090408180 +814081C0714F023A801871382D40582C +45000F282100001E000000FF00434146 +45424142452020202020000000FD0032 +4B0F5211000A202020202020000000FC +0050694B564D20563420506C7573012D +020320714B90041F13223E213D203C01 +67030C001000802D23097F0783010000 +023A801871382D40582C45000F282100 +001E011D007251D01E206E2855000F28 +2100001E023A80D072382D40102C4580 +0F282100001E283C80A070B023403020 +360006442100001A0000000000000000 +00000000000000000000000000000030 diff --git a/configs/kvmd/htpasswd b/configs/kvmd/htpasswd index a6cbfca97..fce6127b6 100644 --- a/configs/kvmd/htpasswd +++ b/configs/kvmd/htpasswd @@ -1 +1 @@ -admin:$apr1$.6mu9N8n$xOuGesr4JZZkdiZo/j318. +admin:{SSHA512}3zSmw/L9zIkpQdX5bcy6HntTxltAzTuGNP6NjHRRgOcNZkA0K+Lsrj3QplO9Gr3BA5MYVVki9rAVnFNCcIdtYC6FkLJWCmHs diff --git a/configs/kvmd/ipmipasswd b/configs/kvmd/ipmipasswd index d95fdfe10..f358fa13c 100644 --- a/configs/kvmd/ipmipasswd +++ b/configs/kvmd/ipmipasswd @@ -1,14 +1,11 @@ -# This file describes the credentials for IPMI users. The first pair separated by colon -# is the login and password with which the user can access to IPMI. The second pair -# is the name and password with which the user can access to KVMD API. The arrow is used -# as a separator and shows the direction of user registration in the system. +# This file describes the credentials for IPMI users in format "login:password", +# one per line. The passwords are NOT encrypted. # # WARNING! IPMI protocol is completely unsafe by design. In short, the authentication # process for IPMI 2.0 mandates that the server send a salted SHA1 or MD5 hash of the -# requested user's password to the client, prior to the client authenticating. Never use -# the same passwords for KVMD and IPMI users. This default configuration is shown here -# for example only. +# requested user's password to the client, prior to the client authenticating. # -# And even better not to use IPMI. Instead, you can directly use KVMD API via curl. +# NEVER use the same passwords for KVMD and IPMI users. +# This default configuration is shown here just for the example only. -admin:admin -> admin:admin +admin:admin diff --git a/configs/kvmd/logging.yaml b/configs/kvmd/logging.yaml deleted file mode 100644 index 54f7c5aef..000000000 --- a/configs/kvmd/logging.yaml +++ /dev/null @@ -1,20 +0,0 @@ -version: 1 -disable_existing_loggers: false - -formatters: - console: - (): logging.Formatter - style: "{" - format: "{name:30.30} {levelname:>7} --- {message}" - -handlers: - console: - level: DEBUG - class: logging.StreamHandler - stream: ext://sys.stderr - formatter: console - -root: - level: INFO - handlers: - - console diff --git a/configs/kvmd/main/v0-hdmi-rpi2.yaml b/configs/kvmd/main/v0-hdmi-rpi2.yaml index 53366196d..e7a9db5f2 100644 --- a/configs/kvmd/main/v0-hdmi-rpi2.yaml +++ b/configs/kvmd/main/v0-hdmi-rpi2.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: serial @@ -25,12 +19,11 @@ kvmd: - "--persistent" - "--dv-timings" - "--format=uyvy" - - "--encoder=omx" + - "--encoder=m2m-image" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" diff --git a/configs/kvmd/main/v0-hdmi-rpi3.yaml b/configs/kvmd/main/v0-hdmi-rpi3.yaml index d054a6d23..4c14bb034 100644 --- a/configs/kvmd/main/v0-hdmi-rpi3.yaml +++ b/configs/kvmd/main/v0-hdmi-rpi3.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: serial @@ -25,12 +19,11 @@ kvmd: - "--persistent" - "--dv-timings" - "--format=uyvy" - - "--encoder=omx" + - "--encoder=m2m-image" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -38,8 +31,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" vnc: diff --git a/configs/kvmd/main/v0-hdmi-zero2w.yaml b/configs/kvmd/main/v0-hdmi-zero2w.yaml index 04416ee80..e95e19fb8 100644 --- a/configs/kvmd/main/v0-hdmi-zero2w.yaml +++ b/configs/kvmd/main/v0-hdmi-zero2w.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: serial @@ -27,12 +21,11 @@ kvmd: - "--persistent" - "--dv-timings" - "--format=uyvy" - - "--encoder=omx" + - "--encoder=m2m-image" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -40,14 +33,20 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" - "--h264-sink=kvmd::ustreamer::h264" - "--h264-sink-mode=0660" - "--h264-bitrate={h264_bitrate}" - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v0-hdmiusb-rpi2.yaml b/configs/kvmd/main/v0-hdmiusb-rpi2.yaml index 62ede331d..99a8b6741 100644 --- a/configs/kvmd/main/v0-hdmiusb-rpi2.yaml +++ b/configs/kvmd/main/v0-hdmiusb-rpi2.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: serial @@ -42,7 +36,6 @@ kvmd: - "--resolution={resolution}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" diff --git a/configs/kvmd/main/v0-hdmiusb-rpi3.yaml b/configs/kvmd/main/v0-hdmiusb-rpi3.yaml index 983ba6d29..3b016f67f 100644 --- a/configs/kvmd/main/v0-hdmiusb-rpi3.yaml +++ b/configs/kvmd/main/v0-hdmiusb-rpi3.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: serial @@ -42,7 +36,6 @@ kvmd: - "--resolution={resolution}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -50,8 +43,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" vnc: diff --git a/configs/kvmd/main/v0-hdmiusb-zero2w.yaml b/configs/kvmd/main/v0-hdmiusb-zero2w.yaml index 983ba6d29..3b016f67f 100644 --- a/configs/kvmd/main/v0-hdmiusb-zero2w.yaml +++ b/configs/kvmd/main/v0-hdmiusb-zero2w.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: serial @@ -42,7 +36,6 @@ kvmd: - "--resolution={resolution}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -50,8 +43,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" vnc: diff --git a/configs/kvmd/main/v1-hdmi-rpi2.yaml b/configs/kvmd/main/v1-hdmi-rpi2.yaml new file mode 100644 index 000000000..47acf1863 --- /dev/null +++ b/configs/kvmd/main/v1-hdmi-rpi2.yaml @@ -0,0 +1,42 @@ +# Don't touch this file otherwise your device may stop working. +# Use override.yaml to modify required settings. +# You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. + +kvmd: + hid: + type: spi + chip: 0 + bus: 0 + sw_cs_pin: 7 + sw_cs_per_byte: true + reset_pin: 25 + reset_inverted: true + reset_self: true + power_detect_pin: 16 + power_detect_pull_down: true + + atx: + type: gpio + + msd: + type: disabled + + streamer: + cmd: + - "/usr/bin/ustreamer" + - "--device=/dev/kvmd-video" + - "--persistent" + - "--dv-timings" + - "--format=uyvy" + - "--encoder=m2m-image" + - "--workers=3" + - "--quality={quality}" + - "--desired-fps={desired_fps}" + - "--drop-same-frames=30" + - "--unix={unix}" + - "--unix-rm" + - "--unix-mode=0660" + - "--exit-on-parent-death" + - "--process-name-prefix={process_name_prefix}" + - "--notify-parent" + - "--no-log-colors" diff --git a/configs/kvmd/main/v1-hdmi-rpi3.yaml b/configs/kvmd/main/v1-hdmi-rpi3.yaml new file mode 100644 index 000000000..66b7acc9b --- /dev/null +++ b/configs/kvmd/main/v1-hdmi-rpi3.yaml @@ -0,0 +1,64 @@ +# Don't touch this file otherwise your device may stop working. +# Use override.yaml to modify required settings. +# You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. + +kvmd: + hid: + type: spi + chip: 0 + bus: 0 + sw_cs_pin: 7 + sw_cs_per_byte: true + reset_pin: 25 + reset_inverted: true + reset_self: true + power_detect_pin: 16 + power_detect_pull_down: true + + atx: + type: gpio + + msd: + type: disabled + + streamer: + h264_bitrate: + default: 5000 + cmd: + - "/usr/bin/ustreamer" + - "--device=/dev/kvmd-video" + - "--persistent" + - "--dv-timings" + - "--format=uyvy" + - "--encoder=m2m-image" + - "--workers=3" + - "--quality={quality}" + - "--desired-fps={desired_fps}" + - "--drop-same-frames=30" + - "--unix={unix}" + - "--unix-rm" + - "--unix-mode=0660" + - "--exit-on-parent-death" + - "--process-name-prefix={process_name_prefix}" + - "--notify-parent" + - "--no-log-colors" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" + - "--h264-sink=kvmd::ustreamer::h264" + - "--h264-sink-mode=0660" + - "--h264-bitrate={h264_bitrate}" + - "--h264-gop={h264_gop}" + + +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + +vnc: + memsink: + jpeg: + sink: "kvmd::ustreamer::jpeg" + h264: + sink: "kvmd::ustreamer::h264" diff --git a/configs/kvmd/main/v1-hdmi-zero2w.yaml b/configs/kvmd/main/v1-hdmi-zero2w.yaml new file mode 100644 index 000000000..66b7acc9b --- /dev/null +++ b/configs/kvmd/main/v1-hdmi-zero2w.yaml @@ -0,0 +1,64 @@ +# Don't touch this file otherwise your device may stop working. +# Use override.yaml to modify required settings. +# You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. + +kvmd: + hid: + type: spi + chip: 0 + bus: 0 + sw_cs_pin: 7 + sw_cs_per_byte: true + reset_pin: 25 + reset_inverted: true + reset_self: true + power_detect_pin: 16 + power_detect_pull_down: true + + atx: + type: gpio + + msd: + type: disabled + + streamer: + h264_bitrate: + default: 5000 + cmd: + - "/usr/bin/ustreamer" + - "--device=/dev/kvmd-video" + - "--persistent" + - "--dv-timings" + - "--format=uyvy" + - "--encoder=m2m-image" + - "--workers=3" + - "--quality={quality}" + - "--desired-fps={desired_fps}" + - "--drop-same-frames=30" + - "--unix={unix}" + - "--unix-rm" + - "--unix-mode=0660" + - "--exit-on-parent-death" + - "--process-name-prefix={process_name_prefix}" + - "--notify-parent" + - "--no-log-colors" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" + - "--h264-sink=kvmd::ustreamer::h264" + - "--h264-sink-mode=0660" + - "--h264-bitrate={h264_bitrate}" + - "--h264-gop={h264_gop}" + + +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + +vnc: + memsink: + jpeg: + sink: "kvmd::ustreamer::jpeg" + h264: + sink: "kvmd::ustreamer::h264" diff --git a/configs/kvmd/main/v1-hdmiusb-rpi2.yaml b/configs/kvmd/main/v1-hdmiusb-rpi2.yaml new file mode 100644 index 000000000..16e2f9136 --- /dev/null +++ b/configs/kvmd/main/v1-hdmiusb-rpi2.yaml @@ -0,0 +1,54 @@ +# Don't touch this file otherwise your device may stop working. +# Use override.yaml to modify required settings. +# You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. + +kvmd: + hid: + type: spi + chip: 0 + bus: 0 + sw_cs_pin: 7 + sw_cs_per_byte: true + reset_pin: 25 + reset_inverted: true + reset_self: true + power_detect_pin: 16 + power_detect_pull_down: true + + atx: + type: gpio + + msd: + type: disabled + + streamer: + quality: 0 + resolution: + default: 1920x1080 + available: + - 1920x1080 + - 1600x1200 + - 1360x768 + - 1280x1024 + - 1280x960 + - 1280x720 + - 1024x768 + - 800x600 + - 720x576 + - 720x480 + - 640x480 + cmd: + - "/usr/bin/ustreamer" + - "--device=/dev/kvmd-video" + - "--persistent" + - "--format=mjpeg" + - "--resolution={resolution}" + - "--desired-fps={desired_fps}" + - "--drop-same-frames=30" + - "--unix={unix}" + - "--unix-rm" + - "--unix-mode=0660" + - "--exit-on-parent-death" + - "--process-name-prefix={process_name_prefix}" + - "--notify-parent" + - "--no-log-colors" diff --git a/configs/kvmd/main/v2-hdmiusb-generic.yaml b/configs/kvmd/main/v1-hdmiusb-rpi3.yaml similarity index 78% rename from configs/kvmd/main/v2-hdmiusb-generic.yaml rename to configs/kvmd/main/v1-hdmiusb-rpi3.yaml index 874ee2c05..c73da06a4 100644 --- a/configs/kvmd/main/v2-hdmiusb-generic.yaml +++ b/configs/kvmd/main/v1-hdmiusb-rpi3.yaml @@ -2,21 +2,24 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: - type: otg + type: spi + chip: 0 + bus: 0 + sw_cs_pin: 7 + sw_cs_per_byte: true + reset_pin: 25 + reset_inverted: true + reset_self: true + power_detect_pin: 16 + power_detect_pull_down: true atx: - type: disabled + type: gpio msd: - type: otg + type: disabled streamer: quality: 0 @@ -42,7 +45,6 @@ kvmd: - "--resolution={resolution}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -50,8 +52,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" vnc: diff --git a/configs/kvmd/main/v1-hdmiusb-zero2w.yaml b/configs/kvmd/main/v1-hdmiusb-zero2w.yaml new file mode 100644 index 000000000..c73da06a4 --- /dev/null +++ b/configs/kvmd/main/v1-hdmiusb-zero2w.yaml @@ -0,0 +1,62 @@ +# Don't touch this file otherwise your device may stop working. +# Use override.yaml to modify required settings. +# You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. + +kvmd: + hid: + type: spi + chip: 0 + bus: 0 + sw_cs_pin: 7 + sw_cs_per_byte: true + reset_pin: 25 + reset_inverted: true + reset_self: true + power_detect_pin: 16 + power_detect_pull_down: true + + atx: + type: gpio + + msd: + type: disabled + + streamer: + quality: 0 + resolution: + default: 1920x1080 + available: + - 1920x1080 + - 1600x1200 + - 1360x768 + - 1280x1024 + - 1280x960 + - 1280x720 + - 1024x768 + - 800x600 + - 720x576 + - 720x480 + - 640x480 + cmd: + - "/usr/bin/ustreamer" + - "--device=/dev/kvmd-video" + - "--persistent" + - "--format=mjpeg" + - "--resolution={resolution}" + - "--desired-fps={desired_fps}" + - "--drop-same-frames=30" + - "--unix={unix}" + - "--unix-rm" + - "--unix-mode=0660" + - "--exit-on-parent-death" + - "--process-name-prefix={process_name_prefix}" + - "--notify-parent" + - "--no-log-colors" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" + + +vnc: + memsink: + jpeg: + sink: "kvmd::ustreamer::jpeg" diff --git a/configs/kvmd/main/v2-hdmi-rpi3.yaml b/configs/kvmd/main/v2-hdmi-rpi3.yaml index 630247b9b..12a03d095 100644 --- a/configs/kvmd/main/v2-hdmi-rpi3.yaml +++ b/configs/kvmd/main/v2-hdmi-rpi3.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: otg @@ -19,18 +13,19 @@ kvmd: type: otg streamer: + h264_bitrate: + default: 5000 cmd: - "/usr/bin/ustreamer" - "--device=/dev/kvmd-video" - "--persistent" - "--dv-timings" - "--format=uyvy" - - "--encoder=omx" + - "--encoder=m2m-image" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -38,11 +33,23 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" + - "--h264-sink=kvmd::ustreamer::h264" + - "--h264-sink-mode=0660" + - "--h264-bitrate={h264_bitrate}" + - "--h264-gop={h264_gop}" + + +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" vnc: memsink: jpeg: sink: "kvmd::ustreamer::jpeg" + h264: + sink: "kvmd::ustreamer::h264" diff --git a/configs/kvmd/main/v2-hdmi-rpi4.yaml b/configs/kvmd/main/v2-hdmi-rpi4.yaml index acbba9735..f05865500 100644 --- a/configs/kvmd/main/v2-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v2-hdmi-rpi4.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: otg @@ -27,12 +21,12 @@ kvmd: - "--persistent" - "--dv-timings" - "--format=uyvy" - - "--encoder=omx" + - "--buffers=6" + - "--encoder=m2m-image" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -40,14 +34,20 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" - "--h264-sink=kvmd::ustreamer::h264" - "--h264-sink-mode=0660" - "--h264-bitrate={h264_bitrate}" - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v2-hdmi-zero2w.yaml b/configs/kvmd/main/v2-hdmi-zero2w.yaml index acbba9735..12a03d095 100644 --- a/configs/kvmd/main/v2-hdmi-zero2w.yaml +++ b/configs/kvmd/main/v2-hdmi-zero2w.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: otg @@ -27,12 +21,11 @@ kvmd: - "--persistent" - "--dv-timings" - "--format=uyvy" - - "--encoder=omx" + - "--encoder=m2m-image" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -40,14 +33,20 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" - "--h264-sink=kvmd::ustreamer::h264" - "--h264-sink-mode=0660" - "--h264-bitrate={h264_bitrate}" - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v2-hdmiusb-rpi4.yaml b/configs/kvmd/main/v2-hdmiusb-rpi4.yaml index dc25e5d0e..fd832d387 100644 --- a/configs/kvmd/main/v2-hdmiusb-rpi4.yaml +++ b/configs/kvmd/main/v2-hdmiusb-rpi4.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - hid: type: otg @@ -42,7 +36,6 @@ kvmd: - "--resolution={resolution}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -50,8 +43,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" vnc: diff --git a/configs/kvmd/main/v3-hdmi-rpi4.yaml b/configs/kvmd/main/v3-hdmi-rpi4.yaml index 10c9cba54..9a2050e7a 100644 --- a/configs/kvmd/main/v3-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v3-hdmi-rpi4.yaml @@ -2,13 +2,7 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - info: fan: unix: /run/kvmd/fan.sock @@ -31,12 +25,12 @@ kvmd: - "--persistent" - "--dv-timings" - "--format=uyvy" - - "--encoder=omx" + - "--buffers=6" + - "--encoder=m2m-image" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -44,8 +38,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" - "--h264-sink=kvmd::ustreamer::h264" - "--h264-sink-mode=0660" - "--h264-bitrate={h264_bitrate}" @@ -60,6 +54,12 @@ kvmd: pulse: false +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v4mini-hdmi-rpi4.yaml b/configs/kvmd/main/v4mini-hdmi-rpi4.yaml index 208f66039..e3bea3ba7 100644 --- a/configs/kvmd/main/v4mini-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v4mini-hdmi-rpi4.yaml @@ -2,21 +2,15 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - info: + hw: + ignore_past: true fan: unix: /run/kvmd/fan.sock hid: type: otg - mouse_alt: - device: /dev/kvmd-hid-mouse-alt atx: type: gpio @@ -37,12 +31,12 @@ kvmd: - "--persistent" - "--dv-timings" - "--format=uyvy" - - "--encoder=omx" + - "--buffers=6" + - "--encoder=m2m-image" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -50,8 +44,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" - "--h264-sink=kvmd::ustreamer::h264" - "--h264-sink-mode=0660" - "--h264-bitrate={h264_bitrate}" @@ -83,13 +77,15 @@ kvmd: pulse: false +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: sink: "kvmd::ustreamer::jpeg" h264: sink: "kvmd::ustreamer::h264" - - -otg: - remote_wakeup: true diff --git a/configs/kvmd/main/v4plus-hdmi-rpi4.yaml b/configs/kvmd/main/v4plus-hdmi-rpi4.yaml index 208f66039..4ac2f7fb5 100644 --- a/configs/kvmd/main/v4plus-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v4plus-hdmi-rpi4.yaml @@ -2,21 +2,15 @@ # Use override.yaml to modify required settings. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. -override: !include [override.d, override.yaml] - -logging: !include logging.yaml - kvmd: - auth: !include auth.yaml - info: + hw: + ignore_past: true fan: unix: /run/kvmd/fan.sock hid: type: otg - mouse_alt: - device: /dev/kvmd-hid-mouse-alt atx: type: gpio @@ -29,6 +23,7 @@ kvmd: type: otg streamer: + forever: true h264_bitrate: default: 5000 cmd: @@ -36,13 +31,14 @@ kvmd: - "--device=/dev/kvmd-video" - "--persistent" - "--dv-timings" - - "--format=uyvy" - - "--encoder=omx" + - "--format=rgb24" + - "--format-swap-rgb" + - "--buffers=8" + - "--encoder=cpu" - "--workers=3" - "--quality={quality}" - "--desired-fps={desired_fps}" - "--drop-same-frames=30" - - "--last-as-blank=0" - "--unix={unix}" - "--unix-rm" - "--unix-mode=0660" @@ -50,12 +46,13 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - "--sink=kvmd::ustreamer::jpeg" - - "--sink-mode=0660" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" - "--h264-sink=kvmd::ustreamer::h264" - "--h264-sink-mode=0660" - "--h264-bitrate={h264_bitrate}" - "--h264-gop={h264_gop}" + - "--v4p" gpio: drivers: @@ -83,6 +80,12 @@ kvmd: pulse: false +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: @@ -91,5 +94,6 @@ vnc: sink: "kvmd::ustreamer::h264" -otg: - remote_wakeup: true +oled: + height: 64 + rotate: 2 diff --git a/configs/kvmd/meta.yaml b/configs/kvmd/meta.yaml index a9de085e0..7e1dff7df 100644 --- a/configs/kvmd/meta.yaml +++ b/configs/kvmd/meta.yaml @@ -1,9 +1,6 @@ # You can write down any information and it will be available -# at the address /api/info (if you use default nginx config). -# If server.host (str) will be defined then this value -# will be displayed in the web interface. +# at the address /api/info?legacy=0 +# To change PiKVM's hostname, use "hostnamectl hostname ". server: - host: localhost.localdomain - -kvm: {} + host: "@auto" diff --git a/configs/kvmd/override.yaml b/configs/kvmd/override.yaml index b45f16211..083705d47 100644 --- a/configs/kvmd/override.yaml +++ b/configs/kvmd/override.yaml @@ -1,14 +1,15 @@ -#################################################################### -# # -# Override Pi-KVM system settings. This file uses the YAML syntax. # -# # -# https://github.com/pikvm/pikvm/blob/master/pages/config.md # -# # -# All overridden parameters will be applied AFTER other configs # -# and "!include" directives and BEFORE validation. # -# Not: Sections should be combined under shared keys. # -# # -#################################################################### +############################################################################### +# # +# Override KVMD settings. # +# # +# * https://docs.pikvm.org/config # +# # +# This file uses YAML syntax. All overridden parameters will be applied # +# AFTER all other configs and BEFORE validation. # +# # +# Values must be combined under common sections. # +# # +############################################################################### # ######### # Wrong # @@ -32,7 +33,7 @@ # Example # ########### #vnc: -# # See https://github.com/pikvm/pikvm/blob/master/pages/vnc.md +# # See https://docs.pikvm.org/vnc # keymap: /usr/share/kvmd/keymaps/ru # Set russian keymap # auth: # vncauth: diff --git a/configs/kvmd/vncpasswd b/configs/kvmd/vncpasswd index 28c2a19de..6c1967a0d 100644 --- a/configs/kvmd/vncpasswd +++ b/configs/kvmd/vncpasswd @@ -1,12 +1,9 @@ -# This file describes the credentials for VNCAuth. The left part before arrow is a passphrase -# for VNCAuth. The right part is username and password with which the user can access to KVMD API. -# The arrow is used as a separator and shows the relationship of user registrations on the system. +# This file contains passwords for the legacy VNCAuth, one per line. +# The passwords are NOT encrypted. # -# Never use the same passwords for VNC and IPMI users. This default configuration is shown here -# for example only. +# WARNING! The VNCAuth method is NOT secure and should not be used at all. +# But we support it for compatibility with some clients. # -# If this file does not contain any entries, VNCAuth will be disabled and you will only be able -# to login in using your KVMD username and password using VeNCrypt methods. +# NEVER use the same passwords for KVMD, IPMI and VNCAuth users. -# pa$$phr@se -> admin:password -admin -> admin:admin +admin diff --git a/configs/nginx/kvmd.ctx-server.conf b/configs/nginx/kvmd.ctx-server.conf index 335849bbc..cd3752180 100644 --- a/configs/nginx/kvmd.ctx-server.conf +++ b/configs/nginx/kvmd.ctx-server.conf @@ -24,6 +24,7 @@ location @login { location /login { root /usr/share/kvmd/web; + include /etc/kvmd/nginx/loc-nocache.conf; auth_request off; } @@ -65,6 +66,7 @@ location /api/hid/print { proxy_pass http://kvmd; include /etc/kvmd/nginx/loc-proxy.conf; include /etc/kvmd/nginx/loc-bigpost.conf; + proxy_read_timeout 7d; auth_request off; } diff --git a/configs/nginx/listen-http.conf b/configs/nginx/listen-http.conf deleted file mode 100644 index 76cb18d29..000000000 --- a/configs/nginx/listen-http.conf +++ /dev/null @@ -1,2 +0,0 @@ -listen 80; -listen [::]:80; diff --git a/configs/nginx/listen-https.conf b/configs/nginx/listen-https.conf deleted file mode 100644 index f6091bbdf..000000000 --- a/configs/nginx/listen-https.conf +++ /dev/null @@ -1,2 +0,0 @@ -listen 443 ssl http2; -listen [::]:443 ssl http2; diff --git a/configs/nginx/loc-bigpost.conf b/configs/nginx/loc-bigpost.conf index ebd37a6bc..7125ecc77 100644 --- a/configs/nginx/loc-bigpost.conf +++ b/configs/nginx/loc-bigpost.conf @@ -1,4 +1,2 @@ -limit_rate 6250k; -limit_rate_after 50k; client_max_body_size 0; proxy_request_buffering off; diff --git a/configs/nginx/nginx.conf b/configs/nginx/nginx.conf.mako similarity index 59% rename from configs/nginx/nginx.conf rename to configs/nginx/nginx.conf.mako index 16e8da3cc..ef76465f7 100644 --- a/configs/nginx/nginx.conf +++ b/configs/nginx/nginx.conf.mako @@ -36,16 +36,45 @@ http { include /etc/kvmd/nginx/kvmd.ctx-http.conf; include /usr/share/kvmd/extras/*/nginx.ctx-http.conf; + % if https_enabled: + server { - include /etc/kvmd/nginx/listen-http.conf; + listen ${http_ipv4}:${http_port}; + % if ipv6_enabled: + listen [${http_ipv6}]:${http_port}; + % endif include /etc/kvmd/nginx/certbot.ctx-server.conf; - include /etc/kvmd/nginx/redirect-to-https.conf; + location / { + % if https_port == 443: + return 301 https://$host$request_uri; + % else: + return 301 https://$host:${https_port}$request_uri; + % endif + } } server { - include /etc/kvmd/nginx/listen-https.conf; + listen ${https_ipv4}:${https_port} ssl; + % if ipv6_enabled: + listen [${https_ipv6}]:${https_port} ssl; + % endif + http2 on; include /etc/kvmd/nginx/ssl.conf; include /etc/kvmd/nginx/kvmd.ctx-server.conf; include /usr/share/kvmd/extras/*/nginx.ctx-server.conf; } + + % else: + + server { + listen ${http_ipv4}:${http_port}; + % if ipv6_enabled: + listen [${http_ipv6}]:${http_port}; + % endif + include /etc/kvmd/nginx/certbot.ctx-server.conf; + include /etc/kvmd/nginx/kvmd.ctx-server.conf; + include /usr/share/kvmd/extras/*/nginx.ctx-server.conf; + } + + % endif } diff --git a/configs/nginx/redirect-to-https.conf b/configs/nginx/redirect-to-https.conf deleted file mode 100644 index 385fb49aa..000000000 --- a/configs/nginx/redirect-to-https.conf +++ /dev/null @@ -1,3 +0,0 @@ -location / { - return 301 https://$host$request_uri; -} diff --git a/configs/os/boot-config/v0-hdmi-rpi2.txt b/configs/os/boot-config/v0-hdmi-rpi2.txt index 853673e9b..aa15bfcfd 100644 --- a/configs/os/boot-config/v0-hdmi-rpi2.txt +++ b/configs/os/boot-config/v0-hdmi-rpi2.txt @@ -1,6 +1,7 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-128 hdmi_force_hotplug=1 gpu_mem=128 enable_uart=1 diff --git a/configs/os/boot-config/v0-hdmi-rpi3.txt b/configs/os/boot-config/v0-hdmi-rpi3.txt index f9c43c3b3..8cd3a760c 100644 --- a/configs/os/boot-config/v0-hdmi-rpi3.txt +++ b/configs/os/boot-config/v0-hdmi-rpi3.txt @@ -1,6 +1,7 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-128 hdmi_force_hotplug=1 gpu_mem=128 enable_uart=1 diff --git a/configs/os/boot-config/v0-hdmi-zero2w.txt b/configs/os/boot-config/v0-hdmi-zero2w.txt index 544595668..396a1989c 100644 --- a/configs/os/boot-config/v0-hdmi-zero2w.txt +++ b/configs/os/boot-config/v0-hdmi-zero2w.txt @@ -1,6 +1,7 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-96 hdmi_force_hotplug=1 gpu_mem=96 enable_uart=1 diff --git a/configs/os/boot-config/v1-hdmi-rpi2.txt b/configs/os/boot-config/v1-hdmi-rpi2.txt new file mode 100644 index 000000000..8622396c2 --- /dev/null +++ b/configs/os/boot-config/v1-hdmi-rpi2.txt @@ -0,0 +1,9 @@ +# See /boot/overlays/README for all available options +initramfs initramfs-linux.img followkernel + +dtoverlay=cma,cma-128 +hdmi_force_hotplug=1 +gpu_mem=128 +enable_uart=1 +dtoverlay=tc358743,i2c_pins_28_29=1 +dtoverlay=spi0-1cs diff --git a/configs/os/boot-config/v1-hdmi-rpi3.txt b/configs/os/boot-config/v1-hdmi-rpi3.txt new file mode 100644 index 000000000..18e0e6f33 --- /dev/null +++ b/configs/os/boot-config/v1-hdmi-rpi3.txt @@ -0,0 +1,10 @@ +# See /boot/overlays/README for all available options +initramfs initramfs-linux.img followkernel + +dtoverlay=cma,cma-128 +hdmi_force_hotplug=1 +gpu_mem=128 +enable_uart=1 +dtoverlay=tc358743 +dtoverlay=disable-bt +dtoverlay=spi0-1cs diff --git a/configs/os/boot-config/v1-hdmi-zero2w.txt b/configs/os/boot-config/v1-hdmi-zero2w.txt new file mode 100644 index 000000000..6cfd82dab --- /dev/null +++ b/configs/os/boot-config/v1-hdmi-zero2w.txt @@ -0,0 +1,10 @@ +# See /boot/overlays/README for all available options +initramfs initramfs-linux.img followkernel + +dtoverlay=cma,cma-96 +hdmi_force_hotplug=1 +gpu_mem=96 +enable_uart=1 +dtoverlay=tc358743,i2c_pins_28_29=1 +dtoverlay=disable-bt +dtoverlay=spi0-1cs diff --git a/configs/os/boot-config/v1-hdmiusb-rpi2.txt b/configs/os/boot-config/v1-hdmiusb-rpi2.txt new file mode 100644 index 000000000..9957f1a4e --- /dev/null +++ b/configs/os/boot-config/v1-hdmiusb-rpi2.txt @@ -0,0 +1,7 @@ +# See /boot/overlays/README for all available options +initramfs initramfs-linux.img followkernel + +hdmi_force_hotplug=1 +gpu_mem=16 +enable_uart=1 +dtoverlay=spi0-1cs diff --git a/configs/os/boot-config/v1-hdmiusb-rpi3.txt b/configs/os/boot-config/v1-hdmiusb-rpi3.txt new file mode 100644 index 000000000..fe4ce032b --- /dev/null +++ b/configs/os/boot-config/v1-hdmiusb-rpi3.txt @@ -0,0 +1,8 @@ +# See /boot/overlays/README for all available options +initramfs initramfs-linux.img followkernel + +hdmi_force_hotplug=1 +gpu_mem=16 +enable_uart=1 +dtoverlay=disable-bt +dtoverlay=spi0-1cs diff --git a/configs/os/boot-config/v1-hdmiusb-zero2w.txt b/configs/os/boot-config/v1-hdmiusb-zero2w.txt new file mode 100644 index 000000000..fe4ce032b --- /dev/null +++ b/configs/os/boot-config/v1-hdmiusb-zero2w.txt @@ -0,0 +1,8 @@ +# See /boot/overlays/README for all available options +initramfs initramfs-linux.img followkernel + +hdmi_force_hotplug=1 +gpu_mem=16 +enable_uart=1 +dtoverlay=disable-bt +dtoverlay=spi0-1cs diff --git a/configs/os/boot-config/v2-hdmi-rpi3.txt b/configs/os/boot-config/v2-hdmi-rpi3.txt index 7a2f75b4c..12c3ce0c3 100644 --- a/configs/os/boot-config/v2-hdmi-rpi3.txt +++ b/configs/os/boot-config/v2-hdmi-rpi3.txt @@ -1,6 +1,7 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-128 hdmi_force_hotplug=1 gpu_mem=128 enable_uart=1 diff --git a/configs/os/boot-config/v2-hdmi-rpi4.txt b/configs/os/boot-config/v2-hdmi-rpi4.txt index 7a2f75b4c..12c3ce0c3 100644 --- a/configs/os/boot-config/v2-hdmi-rpi4.txt +++ b/configs/os/boot-config/v2-hdmi-rpi4.txt @@ -1,6 +1,7 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-128 hdmi_force_hotplug=1 gpu_mem=128 enable_uart=1 diff --git a/configs/os/boot-config/v2-hdmi-zero2w.txt b/configs/os/boot-config/v2-hdmi-zero2w.txt index 6ec3dc545..cf55d25b7 100644 --- a/configs/os/boot-config/v2-hdmi-zero2w.txt +++ b/configs/os/boot-config/v2-hdmi-zero2w.txt @@ -1,6 +1,7 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-96 hdmi_force_hotplug=1 gpu_mem=96 enable_uart=1 diff --git a/configs/os/boot-config/v3-hdmi-rpi4.txt b/configs/os/boot-config/v3-hdmi-rpi4.txt index f2078e79a..cc1faa5cf 100644 --- a/configs/os/boot-config/v3-hdmi-rpi4.txt +++ b/configs/os/boot-config/v3-hdmi-rpi4.txt @@ -1,6 +1,7 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-128 hdmi_force_hotplug=1 gpu_mem=128 enable_uart=1 @@ -19,4 +20,4 @@ dtoverlay=spi0-1cs dtparam=i2c_arm=on # Clock -dtoverlay=i2c-rtc,pcf8563 +dtoverlay=i2c-rtc,pcf8563,wakeup-source diff --git a/configs/os/boot-config/v4mini-hdmi-rpi4.txt b/configs/os/boot-config/v4mini-hdmi-rpi4.txt index abb8e0060..43c14dea1 100644 --- a/configs/os/boot-config/v4mini-hdmi-rpi4.txt +++ b/configs/os/boot-config/v4mini-hdmi-rpi4.txt @@ -2,6 +2,7 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-128 hdmi_force_hotplug=1 gpu_mem=128 enable_uart=1 @@ -18,4 +19,4 @@ dtoverlay=tc358743-audio dtparam=i2c_arm=on # Clock -dtoverlay=i2c-rtc,pcf8563 +dtoverlay=i2c-rtc,pcf8563,wakeup-source diff --git a/configs/os/boot-config/v4plus-hdmi-rpi4.txt b/configs/os/boot-config/v4plus-hdmi-rpi4.txt index abb8e0060..a25c84d44 100644 --- a/configs/os/boot-config/v4plus-hdmi-rpi4.txt +++ b/configs/os/boot-config/v4plus-hdmi-rpi4.txt @@ -2,8 +2,9 @@ # See /boot/overlays/README for all available options initramfs initramfs-linux.img followkernel +dtoverlay=cma,cma-192 hdmi_force_hotplug=1 -gpu_mem=128 +gpu_mem=192 enable_uart=1 dtoverlay=disable-bt @@ -18,4 +19,8 @@ dtoverlay=tc358743-audio dtparam=i2c_arm=on # Clock -dtoverlay=i2c-rtc,pcf8563 +dtoverlay=i2c-rtc,pcf8563,wakeup-source + +# Passthrough +dtoverlay=vc4-kms-v3d +disable_overscan=1 diff --git a/configs/os/cmdline/v0-hdmi-rpi2.sed b/configs/os/cmdline/v0-hdmi-rpi2.sed index 7d041e3f1..d2ac8b1e2 100644 --- a/configs/os/cmdline/v0-hdmi-rpi2.sed +++ b/configs/os/cmdline/v0-hdmi-rpi2.sed @@ -2,4 +2,3 @@ s/console=ttyAMA0\,115200//g s/kgdboc=ttyAMA0\,115200//g s/console=serial0\,115200//g s/kgdboc=serial0\,115200//g -s/rootwait/cma=128M rootwait/g diff --git a/configs/os/cmdline/v0-hdmi-rpi3.sed b/configs/os/cmdline/v0-hdmi-rpi3.sed index 7d041e3f1..d2ac8b1e2 100644 --- a/configs/os/cmdline/v0-hdmi-rpi3.sed +++ b/configs/os/cmdline/v0-hdmi-rpi3.sed @@ -2,4 +2,3 @@ s/console=ttyAMA0\,115200//g s/kgdboc=ttyAMA0\,115200//g s/console=serial0\,115200//g s/kgdboc=serial0\,115200//g -s/rootwait/cma=128M rootwait/g diff --git a/configs/os/cmdline/v0-hdmi-zero2w.sed b/configs/os/cmdline/v0-hdmi-zero2w.sed index 7ed427ec3..d2ac8b1e2 100644 --- a/configs/os/cmdline/v0-hdmi-zero2w.sed +++ b/configs/os/cmdline/v0-hdmi-zero2w.sed @@ -2,4 +2,3 @@ s/console=ttyAMA0\,115200//g s/kgdboc=ttyAMA0\,115200//g s/console=serial0\,115200//g s/kgdboc=serial0\,115200//g -s/rootwait/cma=96M rootwait/g diff --git a/hid/lib/.gitignore b/configs/os/cmdline/v1-hdmi-rpi2.sed similarity index 100% rename from hid/lib/.gitignore rename to configs/os/cmdline/v1-hdmi-rpi2.sed diff --git a/configs/os/cmdline/v1-hdmi-rpi3.sed b/configs/os/cmdline/v1-hdmi-rpi3.sed new file mode 100644 index 000000000..e69de29bb diff --git a/configs/os/cmdline/v1-hdmi-zero2w.sed b/configs/os/cmdline/v1-hdmi-zero2w.sed new file mode 100644 index 000000000..e69de29bb diff --git a/configs/os/cmdline/v1-hdmiusb-rpi2.sed b/configs/os/cmdline/v1-hdmiusb-rpi2.sed new file mode 100644 index 000000000..e69de29bb diff --git a/configs/os/cmdline/v1-hdmiusb-rpi3.sed b/configs/os/cmdline/v1-hdmiusb-rpi3.sed new file mode 100644 index 000000000..e69de29bb diff --git a/configs/os/cmdline/v1-hdmiusb-zero2w.sed b/configs/os/cmdline/v1-hdmiusb-zero2w.sed new file mode 100644 index 000000000..e69de29bb diff --git a/configs/os/cmdline/v2-hdmi-rpi3.sed b/configs/os/cmdline/v2-hdmi-rpi3.sed index 803b9b03b..e69de29bb 100644 --- a/configs/os/cmdline/v2-hdmi-rpi3.sed +++ b/configs/os/cmdline/v2-hdmi-rpi3.sed @@ -1 +0,0 @@ -s/rootwait/cma=128M rootwait/g diff --git a/configs/os/cmdline/v2-hdmi-rpi4.sed b/configs/os/cmdline/v2-hdmi-rpi4.sed index 803b9b03b..e69de29bb 100644 --- a/configs/os/cmdline/v2-hdmi-rpi4.sed +++ b/configs/os/cmdline/v2-hdmi-rpi4.sed @@ -1 +0,0 @@ -s/rootwait/cma=128M rootwait/g diff --git a/configs/os/cmdline/v2-hdmi-zero2w.sed b/configs/os/cmdline/v2-hdmi-zero2w.sed index f350c89c0..e69de29bb 100644 --- a/configs/os/cmdline/v2-hdmi-zero2w.sed +++ b/configs/os/cmdline/v2-hdmi-zero2w.sed @@ -1 +0,0 @@ -s/rootwait/cma=96M rootwait/g diff --git a/configs/os/cmdline/v3-hdmi-rpi4.sed b/configs/os/cmdline/v3-hdmi-rpi4.sed index 803b9b03b..e69de29bb 100644 --- a/configs/os/cmdline/v3-hdmi-rpi4.sed +++ b/configs/os/cmdline/v3-hdmi-rpi4.sed @@ -1 +0,0 @@ -s/rootwait/cma=128M rootwait/g diff --git a/configs/os/cmdline/v4mini-hdmi-rpi4.sed b/configs/os/cmdline/v4mini-hdmi-rpi4.sed index 803b9b03b..e69de29bb 100644 --- a/configs/os/cmdline/v4mini-hdmi-rpi4.sed +++ b/configs/os/cmdline/v4mini-hdmi-rpi4.sed @@ -1 +0,0 @@ -s/rootwait/cma=128M rootwait/g diff --git a/configs/os/cmdline/v4plus-hdmi-rpi4.sed b/configs/os/cmdline/v4plus-hdmi-rpi4.sed index 803b9b03b..e69de29bb 100644 --- a/configs/os/cmdline/v4plus-hdmi-rpi4.sed +++ b/configs/os/cmdline/v4plus-hdmi-rpi4.sed @@ -1 +0,0 @@ -s/rootwait/cma=128M rootwait/g diff --git a/configs/os/modules-load/v1-hdmi.conf b/configs/os/modules-load/v1-hdmi.conf new file mode 100644 index 000000000..5be254080 --- /dev/null +++ b/configs/os/modules-load/v1-hdmi.conf @@ -0,0 +1 @@ +tc358743 diff --git a/configs/os/modules-load/v2-hdmi.conf b/configs/os/modules-load/v2-hdmi.conf index af538dbab..76c8eeb04 100644 --- a/configs/os/modules-load/v2-hdmi.conf +++ b/configs/os/modules-load/v2-hdmi.conf @@ -1,3 +1,4 @@ dwc2 libcomposite tc358743 +nbd diff --git a/configs/os/modules-load/v2-hdmiusb.conf b/configs/os/modules-load/v2-hdmiusb.conf index 9c9626be4..8933495ea 100644 --- a/configs/os/modules-load/v2-hdmiusb.conf +++ b/configs/os/modules-load/v2-hdmiusb.conf @@ -1,2 +1,3 @@ dwc2 libcomposite +nbd diff --git a/configs/os/modules-load/v3-hdmi.conf b/configs/os/modules-load/v3-hdmi.conf index f1ede9d11..97bbb798c 100644 --- a/configs/os/modules-load/v3-hdmi.conf +++ b/configs/os/modules-load/v3-hdmi.conf @@ -2,3 +2,4 @@ dwc2 libcomposite tc358743 i2c-dev +nbd diff --git a/configs/os/modules-load/v4mini-hdmi.conf b/configs/os/modules-load/v4mini-hdmi.conf index f1ede9d11..97bbb798c 100644 --- a/configs/os/modules-load/v4mini-hdmi.conf +++ b/configs/os/modules-load/v4mini-hdmi.conf @@ -2,3 +2,4 @@ dwc2 libcomposite tc358743 i2c-dev +nbd diff --git a/configs/os/modules-load/v4plus-hdmi.conf b/configs/os/modules-load/v4plus-hdmi.conf index f1ede9d11..97bbb798c 100644 --- a/configs/os/modules-load/v4plus-hdmi.conf +++ b/configs/os/modules-load/v4plus-hdmi.conf @@ -2,3 +2,4 @@ dwc2 libcomposite tc358743 i2c-dev +nbd diff --git a/configs/os/netctl-dhcp b/configs/os/netctl-dhcp deleted file mode 100644 index ba172f197..000000000 --- a/configs/os/netctl-dhcp +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -DHCPClient='dhclient' diff --git a/configs/os/services/kvmd-bootconfig.service b/configs/os/services/kvmd-bootconfig.service index 81a73095d..ba5469b97 100644 --- a/configs/os/services/kvmd-bootconfig.service +++ b/configs/os/services/kvmd-bootconfig.service @@ -1,7 +1,23 @@ [Unit] Description=PiKVM - Boot configuration After=systemd-modules-load.service kvmd-oled.service -Before=network-pre.target kvmd-otg.service kvmd-nginx.service kvmd.service sshd.service pikvm-bootconfig.service +Before=\ + kvmd-webterm.service \ + kvmd-certbot.service \ + kvmd-ipmi.service \ + kvmd-janus-static.service \ + kvmd-janus.service \ + kvmd-nginx.service \ + kvmd-otg.service \ + kvmd-otgnet.service \ + kvmd-pst.service \ + kvmd-tc358743.service \ + kvmd-vnc.service \ + kvmd-watchdog.service \ + kvmd.service \ + pikvm-bootconfig.service \ + sshd.service \ + network-pre.target [Service] Type=oneshot diff --git a/configs/os/services/kvmd-localhid.service b/configs/os/services/kvmd-localhid.service new file mode 100644 index 000000000..792ea94ea --- /dev/null +++ b/configs/os/services/kvmd-localhid.service @@ -0,0 +1,16 @@ +[Unit] +Description=PiKVM - Local HID to KVMD proxy +After=kvmd.service systemd-udevd.service + +[Service] +User=kvmd-localhid +Group=kvmd-localhid +Type=simple +Restart=always +RestartSec=3 + +ExecStart=/usr/bin/kvmd-localhid --run +TimeoutStopSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/configs/os/services/kvmd-media.service b/configs/os/services/kvmd-media.service new file mode 100644 index 000000000..610d48598 --- /dev/null +++ b/configs/os/services/kvmd-media.service @@ -0,0 +1,16 @@ +[Unit] +Description=PiKVM - Media proxy server +After=kvmd.service + +[Service] +User=kvmd-media +Group=kvmd-media +Type=simple +Restart=always +RestartSec=3 + +ExecStart=/usr/bin/kvmd-media --run +TimeoutStopSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/configs/os/services/kvmd-nginx.service b/configs/os/services/kvmd-nginx.service index 51529dd5c..c0eff485f 100644 --- a/configs/os/services/kvmd-nginx.service +++ b/configs/os/services/kvmd-nginx.service @@ -10,8 +10,9 @@ SyslogLevel=err Restart=always RestartSec=3 -ExecStart=/usr/sbin/nginx -p /etc/kvmd/nginx -c /etc/kvmd/nginx/nginx.conf -g 'pid /run/kvmd/nginx.pid; user kvmd-nginx; error_log stderr;' -ExecReload=/usr/sbin/nginx -s reload -p /etc/kvmd/nginx -c /etc/kvmd/nginx/nginx.conf -g 'pid /run/kvmd/nginx.pid; user kvmd-nginx; error_log stderr;' +ExecStartPre=/usr/bin/kvmd-nginx-mkconf /etc/kvmd/nginx/nginx.conf.mako /run/kvmd/nginx.conf +ExecStart=/usr/sbin/nginx -p /etc/kvmd/nginx -c /run/kvmd/nginx.conf -g 'pid /run/kvmd/nginx.pid; user kvmd-nginx; error_log stderr;' +ExecReload=/usr/sbin/nginx -s reload -p /etc/kvmd/nginx -c /run/kvmd/nginx.conf -g 'pid /run/kvmd/nginx.pid; user kvmd-nginx; error_log stderr;' KillSignal=SIGQUIT KillMode=mixed TimeoutStopSec=3 diff --git a/configs/os/services/kvmd-oled-reboot.service b/configs/os/services/kvmd-oled-reboot.service new file mode 100644 index 000000000..0c6c727eb --- /dev/null +++ b/configs/os/services/kvmd-oled-reboot.service @@ -0,0 +1,14 @@ +[Unit] +Description=PiKVM - Display reboot message on the OLED +DefaultDependencies=no + +[Service] +User=kvmd-oled +Group=kvmd-oled +Type=oneshot +ExecStart=/bin/bash -c "kill -USR1 `systemctl show -P MainPID kvmd-oled`" +ExecStop=/bin/true +RemainAfterExit=yes + +[Install] +WantedBy=reboot.target diff --git a/configs/os/services/kvmd-oled-shutdown.service b/configs/os/services/kvmd-oled-shutdown.service new file mode 100644 index 000000000..6443950ca --- /dev/null +++ b/configs/os/services/kvmd-oled-shutdown.service @@ -0,0 +1,16 @@ +[Unit] +Description=PiKVM - Display shutdown message on the OLED +Conflicts=reboot.target +Before=shutdown.target poweroff.target halt.target +DefaultDependencies=no + +[Service] +User=kvmd-oled +Group=kvmd-oled +Type=oneshot +ExecStart=/bin/bash -c "kill -USR2 `systemctl show -P MainPID kvmd-oled`" +ExecStop=/bin/true +RemainAfterExit=yes + +[Install] +WantedBy=shutdown.target diff --git a/configs/os/services/kvmd-oled.service b/configs/os/services/kvmd-oled.service new file mode 100644 index 000000000..97fb7d1a3 --- /dev/null +++ b/configs/os/services/kvmd-oled.service @@ -0,0 +1,17 @@ +[Unit] +Description=PiKVM - A small OLED daemon +After=systemd-modules-load.service +ConditionPathExists=/dev/i2c-1 + +[Service] +User=kvmd-oled +Group=kvmd-oled +Type=simple +Restart=always +RestartSec=3 +ExecStartPre=/usr/bin/kvmd-oled --interval=3 --clear-on-exit --image=@hello.ppm +ExecStart=/usr/bin/kvmd-oled +TimeoutStopSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/configs/os/services/kvmd-tc358743.service b/configs/os/services/kvmd-tc358743.service index c4f133002..494da726c 100644 --- a/configs/os/services/kvmd-tc358743.service +++ b/configs/os/services/kvmd-tc358743.service @@ -6,7 +6,7 @@ Before=kvmd.service [Service] Type=oneshot -ExecStart=/usr/bin/v4l2-ctl --device=/dev/kvmd-video --set-edid=file=/etc/kvmd/tc358743-edid.hex --fix-edid-checksums --info-edid +ExecStart=/usr/bin/v4l2-ctl --device=/dev/kvmd-video --set-edid=file=/etc/kvmd/tc358743-edid.hex --info-edid ExecStop=/usr/bin/v4l2-ctl --device=/dev/kvmd-video --clear-edid RemainAfterExit=true diff --git a/configs/os/services/kvmd.service b/configs/os/services/kvmd.service index 56092f599..1f4d6550f 100644 --- a/configs/os/services/kvmd.service +++ b/configs/os/services/kvmd.service @@ -11,7 +11,6 @@ RestartSec=3 AmbientCapabilities=CAP_NET_RAW ExecStart=/usr/bin/kvmd --run -ExecStopPost=/usr/bin/kvmd-cleanup --run TimeoutStopSec=10 KillMode=mixed diff --git a/configs/os/services/systemd-networkd-wait-online.service.d/11-pikvm-wait-any.conf b/configs/os/services/systemd-networkd-wait-online.service.d/11-pikvm-wait-any.conf new file mode 100644 index 000000000..fae092558 --- /dev/null +++ b/configs/os/services/systemd-networkd-wait-online.service.d/11-pikvm-wait-any.conf @@ -0,0 +1,8 @@ +# Fix https://github.com/pikvm/pikvm/issues/1514: +# Wait for any single network interface, not all configured ones +# (Rationale: when user configures Wi-Fi via pikvm.txt or otherwise, +# we do not delete the Ethernet config, which means it will remain active +# regardless of whether the user ever intended to use Ethernet.) +[Service] +ExecStart= +ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any diff --git a/configs/os/sudoers/v1-hdmi b/configs/os/sudoers/v1-hdmi new file mode 100644 index 000000000..bb549dc8a --- /dev/null +++ b/configs/os/sudoers/v1-hdmi @@ -0,0 +1 @@ +kvmd-pst ALL=(ALL) NOPASSWD: /usr/bin/kvmd-helper-pst-remount diff --git a/configs/os/sudoers/v1-hdmiusb b/configs/os/sudoers/v1-hdmiusb new file mode 100644 index 000000000..bb549dc8a --- /dev/null +++ b/configs/os/sudoers/v1-hdmiusb @@ -0,0 +1 @@ +kvmd-pst ALL=(ALL) NOPASSWD: /usr/bin/kvmd-helper-pst-remount diff --git a/configs/os/sysusers.conf b/configs/os/sysusers.conf index 74ab9069a..b02d7b29c 100644 --- a/configs/os/sysusers.conf +++ b/configs/os/sysusers.conf @@ -1,37 +1,59 @@ g kvmd - - +g kvmd-selfauth - - +g kvmd-media - - g kvmd-pst - - g kvmd-ipmi - - g kvmd-vnc - - +g kvmd-localhid - - g kvmd-nginx - - g kvmd-janus - - g kvmd-certbot - - +g kvmd-oled - - u kvmd - "PiKVM - The main daemon" - +u kvmd-media - "PiKVM - The media proxy" u kvmd-pst - "PiKVM - Persistent storage" - u kvmd-ipmi - "PiKVM - IPMI to KVMD proxy" - u kvmd-vnc - "PiKVM - VNC to KVMD/Streamer proxy" - +u kvmd-localhid - "PiKVM - Local HID to KVMD proxy" - u kvmd-nginx - "PiKVM - HTTP entrypoint" - u kvmd-janus - "PiKVM - Janus WebRTC Gateway" - u kvmd-certbot - "PiKVM - Certbot-Renew for KVMD-Nginx" +u kvmd-oled - "PiKVM - OLED display" m kvmd video m kvmd gpio m kvmd uucp m kvmd spi m kvmd systemd-journal +m kvmd kvmd-media +m kvmd kvmd-pst + +m kvmd-media kvmd m kvmd-pst kvmd m kvmd-ipmi kvmd +m kvmd-ipmi kvmd-selfauth m kvmd-vnc kvmd +m kvmd-vnc kvmd-selfauth m kvmd-vnc kvmd-certbot +m kvmd-localhid input +m kvmd-localhid kvmd +m kvmd-localhid kvmd-selfauth + m kvmd-janus kvmd m kvmd-janus audio m kvmd-nginx kvmd +m kvmd-nginx kvmd-media m kvmd-nginx kvmd-janus m kvmd-nginx kvmd-certbot m kvmd-certbot kvmd-pst + +m kvmd-oled kvmd +m kvmd-oled kvmd-selfauth +m kvmd-oled i2c diff --git a/configs/os/udev/common.rules b/configs/os/udev/common.rules new file mode 100644 index 000000000..e1d60a4bd --- /dev/null +++ b/configs/os/udev/common.rules @@ -0,0 +1,16 @@ +# Here are described some bindings for PiKVM devices. +# Do not edit this file. + +ACTION!="remove", KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="eda3", SYMLINK+="kvmd-hid-bridge" +ACTION!="remove", KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="1080", SYMLINK+="kvmd-switch" +ACTION!="remove", KERNEL=="nbd15", SUBSYSTEM=="block", GROUP="kvmd", SYMLINK+="kvmd-nbd" + +# Disable USB autosuspend for critical devices +ACTION!="remove", SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="eda3", GOTO="kvmd-usb" +ACTION!="remove", SUBSYSTEM=="usb", ATTR{idVendor}=="2e8a", ATTR{idProduct}=="1080", GOTO="kvmd-usb" +GOTO="end" + +LABEL="kvmd-usb" +ATTR{power/control}="on", ATTR{power/autosuspend_delay_ms}="-1" + +LABEL="end" diff --git a/configs/os/udev/v1-hdmi-rpi2.rules b/configs/os/udev/v1-hdmi-rpi2.rules new file mode 100644 index 000000000..49ca9ce0b --- /dev/null +++ b/configs/os/udev/v1-hdmi-rpi2.rules @@ -0,0 +1,3 @@ +# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name +# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names +KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", KERNELS=="3f801000.csi|3f801000.csi1", ATTR{name}=="unicam-image", GROUP="kvmd", SYMLINK+="kvmd-video", TAG+="systemd" diff --git a/configs/os/udev/v1-hdmi-rpi3.rules b/configs/os/udev/v1-hdmi-rpi3.rules new file mode 100644 index 000000000..49ca9ce0b --- /dev/null +++ b/configs/os/udev/v1-hdmi-rpi3.rules @@ -0,0 +1,3 @@ +# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name +# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names +KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", KERNELS=="3f801000.csi|3f801000.csi1", ATTR{name}=="unicam-image", GROUP="kvmd", SYMLINK+="kvmd-video", TAG+="systemd" diff --git a/configs/os/udev/v1-hdmi-zero2w.rules b/configs/os/udev/v1-hdmi-zero2w.rules new file mode 100644 index 000000000..49ca9ce0b --- /dev/null +++ b/configs/os/udev/v1-hdmi-zero2w.rules @@ -0,0 +1,3 @@ +# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name +# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names +KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", KERNELS=="3f801000.csi|3f801000.csi1", ATTR{name}=="unicam-image", GROUP="kvmd", SYMLINK+="kvmd-video", TAG+="systemd" diff --git a/configs/os/udev/v1-hdmiusb-rpi2.rules b/configs/os/udev/v1-hdmiusb-rpi2.rules new file mode 100644 index 000000000..eafd8ef80 --- /dev/null +++ b/configs/os/udev/v1-hdmiusb-rpi2.rules @@ -0,0 +1,3 @@ +# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name +# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names +KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", PROGRAM="/usr/bin/kvmd-udev-hdmiusb-check rpi2 %b", ATTR{index}=="0", GROUP="kvmd", SYMLINK+="kvmd-video" diff --git a/configs/os/udev/v1-hdmiusb-rpi3.rules b/configs/os/udev/v1-hdmiusb-rpi3.rules new file mode 100644 index 000000000..f28bdfaef --- /dev/null +++ b/configs/os/udev/v1-hdmiusb-rpi3.rules @@ -0,0 +1,3 @@ +# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name +# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names +KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", PROGRAM="/usr/bin/kvmd-udev-hdmiusb-check rpi3 %b", ATTR{index}=="0", GROUP="kvmd", SYMLINK+="kvmd-video" diff --git a/configs/os/udev/v1-hdmiusb-zero2w.rules b/configs/os/udev/v1-hdmiusb-zero2w.rules new file mode 100644 index 000000000..1a2a6f7de --- /dev/null +++ b/configs/os/udev/v1-hdmiusb-zero2w.rules @@ -0,0 +1,3 @@ +# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name +# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names +KERNEL=="video0", SUBSYSTEM=="video4linux", GROUP="kvmd", SYMLINK+="kvmd-video" diff --git a/configs/os/udev/v2-hdmiusb-generic.rules b/configs/os/udev/v2-hdmiusb-generic.rules deleted file mode 100644 index 8ac0f80f3..000000000 --- a/configs/os/udev/v2-hdmiusb-generic.rules +++ /dev/null @@ -1,6 +0,0 @@ -# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name -# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names -KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", SUBSYSTEMS=="usb", ATTR{index}=="0", GROUP="kvmd", SYMLINK+="kvmd-video" -KERNEL=="hidg0", GROUP="kvmd", SYMLINK+="kvmd-hid-keyboard" -KERNEL=="hidg1", GROUP="kvmd", SYMLINK+="kvmd-hid-mouse" -KERNEL=="hidg2", GROUP="kvmd", SYMLINK+="kvmd-hid-mouse-alt" diff --git a/configs/os/udev/v4plus-hdmi-rpi4.rules b/configs/os/udev/v4plus-hdmi-rpi4.rules index 88ba66b12..87cfc700d 100644 --- a/configs/os/udev/v4plus-hdmi-rpi4.rules +++ b/configs/os/udev/v4plus-hdmi-rpi4.rules @@ -4,3 +4,4 @@ KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", KERNELS=="fe801000.csi|fe801000 KERNEL=="hidg0", GROUP="kvmd", SYMLINK+="kvmd-hid-keyboard" KERNEL=="hidg1", GROUP="kvmd", SYMLINK+="kvmd-hid-mouse" KERNEL=="hidg2", GROUP="kvmd", SYMLINK+="kvmd-hid-mouse-alt" +SUBSYSTEM=="drm", ACTION=="change", ENV{DEVLINKS}=="/dev/dri/by-path/platform-gpu-card", RUN+="/usr/bin/kvmd-udev-restart-pass %k %E{CONNECTOR}" diff --git a/contrib/keymaps/en-gb b/contrib/keymaps/en-gb index 6ff5033d8..8c993a0b8 100644 --- a/contrib/keymaps/en-gb +++ b/contrib/keymaps/en-gb @@ -71,31 +71,35 @@ fiveeighths 0x07 shift altgr # evdev 8 (0x8), QKeyCode "7", number 0x8 7 0x08 ampersand 0x08 shift -braceleft 0x08 altgr +# KVMD +#braceleft 0x08 altgr seveneighths 0x08 shift altgr # evdev 9 (0x9), QKeyCode "8", number 0x9 8 0x09 asterisk 0x09 shift -bracketleft 0x09 altgr +# KVMD +#bracketleft 0x09 altgr trademark 0x09 shift altgr # evdev 10 (0xa), QKeyCode "9", number 0xa 9 0x0a parenleft 0x0a shift -bracketright 0x0a altgr +# KVMD +#bracketright 0x0a altgr plusminus 0x0a shift altgr # evdev 11 (0xb), QKeyCode "0", number 0xb 0 0x0b parenright 0x0b shift -braceright 0x0b altgr +# KVMD +#braceright 0x0b altgr degree 0x0b shift altgr # evdev 12 (0xc), QKeyCode "minus", number 0xc minus 0x0c underscore 0x0c shift -backslash 0x0c altgr +#backslash 0x0c altgr questiondown 0x0c shift altgr # evdev 13 (0xd), QKeyCode "equal", number 0xd @@ -259,7 +263,8 @@ dead_caron 0x28 shift altgr # evdev 41 (0x29), QKeyCode "grave_accent", number 0x29 grave 0x29 notsign 0x29 shift -bar 0x29 altgr +# KVMD +#bar 0x29 altgr # evdev 42 (0x2a), QKeyCode "shift", number 0x2a Shift_L 0x2a @@ -274,13 +279,15 @@ dead_breve 0x2b shift altgr z 0x2c Z 0x2c shift guillemotleft 0x2c altgr -less 0x2c shift altgr +# KVMD +#less 0x2c shift altgr # evdev 45 (0x2d), QKeyCode "x", number 0x2d x 0x2d X 0x2d shift guillemotright 0x2d altgr -greater 0x2d shift altgr +# KVMD +#greater 0x2d shift altgr # evdev 46 (0x2e), QKeyCode "c", number 0x2e c 0x2e diff --git a/contrib/keymaps/en-us-colemak b/contrib/keymaps/en-us-colemak new file mode 100644 index 000000000..40b33d470 --- /dev/null +++ b/contrib/keymaps/en-us-colemak @@ -0,0 +1,1663 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# +# generated by qemu-keymap +# model : pc105 +# layout : us +# variant : colemak +# options : - + +# name: "English (Colemak)" + +# modifiers +# 0: Shift +# 1: Lock +# 2: Control +# 3: Mod1 +# 4: Mod2 +# 5: Mod3 +# 6: Mod4 +# 7: Mod5 +# 8: NumLock +# 9: Alt +# 10: LevelThree +# 11: Super +# 12: LevelFive +# 13: Meta +# 14: Hyper +# 15: ScrollLock + +# evdev 1 (0x1), QKeyCode "esc", number 0x1 +Escape 0x01 + +# evdev 2 (0x2), QKeyCode "1", number 0x2 +1 0x02 +exclam 0x02 shift + +# evdev 3 (0x3), QKeyCode "2", number 0x3 +2 0x03 +at 0x03 shift + +# evdev 4 (0x4), QKeyCode "3", number 0x4 +3 0x04 +numbersign 0x04 shift + +# evdev 5 (0x5), QKeyCode "4", number 0x5 +4 0x05 +dollar 0x05 shift + +# evdev 6 (0x6), QKeyCode "5", number 0x6 +5 0x06 +percent 0x06 shift + +# evdev 7 (0x7), QKeyCode "6", number 0x7 +6 0x07 +asciicircum 0x07 shift + +# evdev 8 (0x8), QKeyCode "7", number 0x8 +7 0x08 +ampersand 0x08 shift + +# evdev 9 (0x9), QKeyCode "8", number 0x9 +8 0x09 +asterisk 0x09 shift + +# evdev 10 (0xa), QKeyCode "9", number 0xa +9 0x0a +parenleft 0x0a shift + +# evdev 11 (0xb), QKeyCode "0", number 0xb +0 0x0b +parenright 0x0b shift + +# evdev 12 (0xc), QKeyCode "minus", number 0xc +minus 0x0c +underscore 0x0c shift + +# evdev 13 (0xd), QKeyCode "equal", number 0xd +equal 0x0d +plus 0x0d shift + +# evdev 14 (0xe), QKeyCode "backspace", number 0xe +BackSpace 0x0e + +# evdev 15 (0xf), QKeyCode "tab", number 0xf +Tab 0x0f +ISO_Left_Tab 0x0f shift + +# evdev 16 (0x10), QKeyCode "q", number 0x10 +q 0x10 +Q 0x10 shift + +# evdev 17 (0x11), QKeyCode "w", number 0x11 +w 0x11 +W 0x11 shift + +# evdev 18 (0x12), QKeyCode "e", number 0x12 +f 0x12 +F 0x12 shift + +# evdev 19 (0x13), QKeyCode "r", number 0x13 +p 0x13 +P 0x13 shift + +# evdev 20 (0x14), QKeyCode "t", number 0x14 +g 0x14 +G 0x14 shift + +# evdev 21 (0x15), QKeyCode "y", number 0x15 +j 0x15 +J 0x15 shift + +# evdev 22 (0x16), QKeyCode "u", number 0x16 +l 0x16 +L 0x16 shift + +# evdev 23 (0x17), QKeyCode "i", number 0x17 +u 0x17 +U 0x17 shift + +# evdev 24 (0x18), QKeyCode "o", number 0x18 +y 0x18 +Y 0x18 shift + +# evdev 25 (0x19), QKeyCode "p", number 0x19 +semicolon 0x19 +colon 0x19 shift + +# evdev 26 (0x1a), QKeyCode "bracket_left", number 0x1a +bracketleft 0x1a +braceleft 0x1a shift + +# evdev 27 (0x1b), QKeyCode "bracket_right", number 0x1b +bracketright 0x1b +braceright 0x1b shift + +# evdev 28 (0x1c), QKeyCode "ret", number 0x1c +Return 0x1c + +# evdev 29 (0x1d), QKeyCode "ctrl", number 0x1d +Control_L 0x1d + +# evdev 30 (0x1e), QKeyCode "a", number 0x1e +a 0x1e +A 0x1e shift + +# evdev 31 (0x1f), QKeyCode "s", number 0x1f +r 0x1f +R 0x1f shift + +# evdev 32 (0x20), QKeyCode "d", number 0x20 +s 0x20 +S 0x20 shift + +# evdev 33 (0x21), QKeyCode "f", number 0x21 +t 0x21 +T 0x21 shift + +# evdev 34 (0x22), QKeyCode "g", number 0x22 +d 0x22 +D 0x22 shift + +# evdev 35 (0x23), QKeyCode "h", number 0x23 +h 0x23 +H 0x23 shift + +# evdev 36 (0x24), QKeyCode "j", number 0x24 +n 0x24 +N 0x24 shift + +# evdev 37 (0x25), QKeyCode "k", number 0x25 +e 0x25 +E 0x25 shift + +# evdev 38 (0x26), QKeyCode "l", number 0x26 +i 0x26 +I 0x26 shift + +# evdev 39 (0x27), QKeyCode "semicolon", number 0x27 +o 0x27 +O 0x27 shift + +# evdev 40 (0x28), QKeyCode "apostrophe", number 0x28 +apostrophe 0x28 +quotedbl 0x28 shift + +# evdev 41 (0x29), QKeyCode "grave_accent", number 0x29 +grave 0x29 +asciitilde 0x29 shift + +# evdev 42 (0x2a), QKeyCode "shift", number 0x2a +Shift_L 0x2a + +# evdev 43 (0x2b), QKeyCode "backslash", number 0x2b +backslash 0x2b +bar 0x2b shift + +# evdev 44 (0x2c), QKeyCode "z", number 0x2c +z 0x2c +Z 0x2c shift + +# evdev 45 (0x2d), QKeyCode "x", number 0x2d +x 0x2d +X 0x2d shift + +# evdev 46 (0x2e), QKeyCode "c", number 0x2e +c 0x2e +C 0x2e shift + +# evdev 47 (0x2f), QKeyCode "v", number 0x2f +v 0x2f +V 0x2f shift + +# evdev 48 (0x30), QKeyCode "b", number 0x30 +b 0x30 +B 0x30 shift + +# evdev 49 (0x31), QKeyCode "n", number 0x31 +k 0x31 +K 0x31 shift + +# evdev 50 (0x32), QKeyCode "m", number 0x32 +m 0x32 +M 0x32 shift + +# evdev 51 (0x33), QKeyCode "comma", number 0x33 +comma 0x33 +less 0x33 shift + +# evdev 52 (0x34), QKeyCode "dot", number 0x34 +period 0x34 +greater 0x34 shift + +# evdev 53 (0x35), QKeyCode "slash", number 0x35 +slash 0x35 +question 0x35 shift + +# evdev 54 (0x36), QKeyCode "shift_r", number 0x36 +Shift_R 0x36 + +# evdev 55 (0x37), QKeyCode "kp_multiply", number 0x37 +KP_Multiply 0x37 + +# evdev 56 (0x38), QKeyCode "alt", number 0x38 +Alt_L 0x38 + +# evdev 57 (0x39), QKeyCode "spc", number 0x39 +space 0x39 + +# evdev 58 (0x3a), QKeyCode "caps_lock", number 0x3a +BackSpace 0x3a + +# evdev 59 (0x3b), QKeyCode "f1", number 0x3b +F1 0x3b + +# evdev 60 (0x3c), QKeyCode "f2", number 0x3c +F2 0x3c + +# evdev 61 (0x3d), QKeyCode "f3", number 0x3d +F3 0x3d + +# evdev 62 (0x3e), QKeyCode "f4", number 0x3e +F4 0x3e + +# evdev 63 (0x3f), QKeyCode "f5", number 0x3f +F5 0x3f + +# evdev 64 (0x40), QKeyCode "f6", number 0x40 +F6 0x40 + +# evdev 65 (0x41), QKeyCode "f7", number 0x41 +F7 0x41 + +# evdev 66 (0x42), QKeyCode "f8", number 0x42 +F8 0x42 + +# evdev 67 (0x43), QKeyCode "f9", number 0x43 +F9 0x43 + +# evdev 68 (0x44), QKeyCode "f10", number 0x44 +F10 0x44 + +# evdev 69 (0x45), QKeyCode "num_lock", number 0x45 +Num_Lock 0x45 + +# evdev 70 (0x46), QKeyCode "scroll_lock", number 0x46 +Scroll_Lock 0x46 + +# evdev 71 (0x47), QKeyCode "kp_7", number 0x47 +KP_Home 0x47 +KP_7 0x47 numlock + +# evdev 72 (0x48), QKeyCode "kp_8", number 0x48 +KP_Up 0x48 +KP_8 0x48 numlock + +# evdev 73 (0x49), QKeyCode "kp_9", number 0x49 +KP_Prior 0x49 +KP_9 0x49 numlock + +# evdev 74 (0x4a), QKeyCode "kp_subtract", number 0x4a +KP_Subtract 0x4a + +# evdev 75 (0x4b), QKeyCode "kp_4", number 0x4b +KP_Left 0x4b +KP_4 0x4b numlock + +# evdev 76 (0x4c), QKeyCode "kp_5", number 0x4c +KP_Begin 0x4c +KP_5 0x4c numlock + +# evdev 77 (0x4d), QKeyCode "kp_6", number 0x4d +KP_Right 0x4d +KP_6 0x4d numlock + +# evdev 78 (0x4e), QKeyCode "kp_add", number 0x4e +KP_Add 0x4e + +# evdev 79 (0x4f), QKeyCode "kp_1", number 0x4f +KP_End 0x4f +KP_1 0x4f numlock + +# evdev 80 (0x50), QKeyCode "kp_2", number 0x50 +KP_Down 0x50 +KP_2 0x50 numlock + +# evdev 81 (0x51), QKeyCode "kp_3", number 0x51 +KP_Next 0x51 +KP_3 0x51 numlock + +# evdev 82 (0x52), QKeyCode "kp_0", number 0x52 +KP_Insert 0x52 +KP_0 0x52 numlock + +# evdev 83 (0x53), QKeyCode "kp_decimal", number 0x53 +KP_Delete 0x53 +KP_Decimal 0x53 numlock + +# evdev 84 (0x54): no evdev -> QKeyCode mapping (xkb keysym ISO_Level3_Shift) + +# evdev 85 (0x55): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 86 (0x56), QKeyCode "less", number 0x56 +minus 0x56 +underscore 0x56 shift + +# evdev 87 (0x57), QKeyCode "f11", number 0x57 +F11 0x57 + +# evdev 88 (0x58), QKeyCode "f12", number 0x58 +F12 0x58 + +# evdev 89 (0x59), QKeyCode "ro", number 0x73 + +# evdev 90 (0x5a): no evdev -> QKeyCode mapping (xkb keysym Katakana) + +# evdev 91 (0x5b), QKeyCode "hiragana", number 0x77 +Hiragana 0x77 + +# evdev 92 (0x5c), QKeyCode "henkan", number 0x79 +Henkan_Mode 0x79 + +# evdev 93 (0x5d), QKeyCode "katakanahiragana", number 0x70 +Hiragana_Katakana 0x70 + +# evdev 94 (0x5e), QKeyCode "muhenkan", number 0x7b +Muhenkan 0x7b + +# evdev 95 (0x5f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 96 (0x60), QKeyCode "kp_enter", number 0x9c +KP_Enter 0x9c + +# evdev 97 (0x61), QKeyCode "ctrl_r", number 0x9d +Control_R 0x9d + +# evdev 98 (0x62), QKeyCode "kp_divide", number 0xb5 +KP_Divide 0xb5 + +# evdev 99 (0x63), QKeyCode "sysrq", number 0x54 +Print 0x54 + +# evdev 100 (0x64), QKeyCode "alt_r", number 0xb8 +ISO_Level3_Shift 0xb8 + +# evdev 101 (0x65), QKeyCode "lf", number 0x5b +Linefeed 0x5b + +# evdev 102 (0x66), QKeyCode "home", number 0xc7 +Home 0xc7 + +# evdev 103 (0x67), QKeyCode "up", number 0xc8 +Up 0xc8 + +# evdev 104 (0x68), QKeyCode "pgup", number 0xc9 +Prior 0xc9 + +# evdev 105 (0x69), QKeyCode "left", number 0xcb +Left 0xcb + +# evdev 106 (0x6a), QKeyCode "right", number 0xcd +Right 0xcd + +# evdev 107 (0x6b), QKeyCode "end", number 0xcf +End 0xcf + +# evdev 108 (0x6c), QKeyCode "down", number 0xd0 +Down 0xd0 + +# evdev 109 (0x6d), QKeyCode "pgdn", number 0xd1 +Next 0xd1 + +# evdev 110 (0x6e), QKeyCode "insert", number 0xd2 +Insert 0xd2 + +# evdev 111 (0x6f), QKeyCode "delete", number 0xd3 +Delete 0xd3 + +# evdev 112 (0x70): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 113 (0x71), QKeyCode "audiomute", number 0xa0 +XF86AudioMute 0xa0 + +# evdev 114 (0x72), QKeyCode "volumedown", number 0xae +XF86AudioLowerVolume 0xae + +# evdev 115 (0x73), QKeyCode "volumeup", number 0xb0 +XF86AudioRaiseVolume 0xb0 + +# evdev 116 (0x74), QKeyCode "power", number 0xde +XF86PowerOff 0xde + +# evdev 117 (0x75), QKeyCode "kp_equals", number 0x59 +KP_Equal 0x59 + +# evdev 118 (0x76): no evdev -> QKeyCode mapping (xkb keysym plusminus) + +# evdev 119 (0x77), QKeyCode "pause", number 0xc6 +Pause 0xc6 + +# evdev 120 (0x78): no evdev -> QKeyCode mapping (xkb keysym XF86LaunchA) + +# evdev 121 (0x79), QKeyCode "kp_comma", number 0x7e +KP_Decimal 0x7e + +# evdev 122 (0x7a), QKeyCode "lang1", number 0x72 +Hangul 0x72 + +# evdev 123 (0x7b), QKeyCode "lang2", number 0x71 +Hangul_Hanja 0x71 + +# evdev 124 (0x7c), QKeyCode "yen", number 0x7d + +# evdev 125 (0x7d), QKeyCode "meta_l", number 0xdb +Super_L 0xdb + +# evdev 126 (0x7e), QKeyCode "meta_r", number 0xdc +Super_R 0xdc + +# evdev 127 (0x7f), QKeyCode "compose", number 0xdd +Menu 0xdd + +# evdev 128 (0x80), QKeyCode "stop", number 0xe8 +Cancel 0xe8 + +# evdev 129 (0x81), QKeyCode "again", number 0x85 +Redo 0x85 + +# evdev 130 (0x82), QKeyCode "props", number 0x86 +SunProps 0x86 + +# evdev 131 (0x83), QKeyCode "undo", number 0x87 +Undo 0x87 + +# evdev 132 (0x84), QKeyCode "front", number 0x8c +SunFront 0x8c + +# evdev 133 (0x85), QKeyCode "copy", number 0xf8 +XF86Copy 0xf8 + +# evdev 134 (0x86), QKeyCode "open", number 0x64 +XF86Open 0x64 + +# evdev 135 (0x87), QKeyCode "paste", number 0x65 +XF86Paste 0x65 + +# evdev 136 (0x88), QKeyCode "find", number 0xc1 +Find 0xc1 + +# evdev 137 (0x89), QKeyCode "cut", number 0xbc +XF86Cut 0xbc + +# evdev 138 (0x8a), QKeyCode "help", number 0xf5 +Help 0xf5 + +# evdev 139 (0x8b), QKeyCode "menu", number 0x9e +XF86MenuKB 0x9e + +# evdev 140 (0x8c), QKeyCode "calculator", number 0xa1 +XF86Calculator 0xa1 + +# evdev 141 (0x8d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 142 (0x8e), QKeyCode "sleep", number 0xdf +XF86Sleep 0xdf + +# evdev 143 (0x8f), QKeyCode "wake", number 0xe3 +XF86WakeUp 0xe3 + +# evdev 144 (0x90): no evdev -> QKeyCode mapping (xkb keysym XF86Explorer) + +# evdev 145 (0x91): no evdev -> QKeyCode mapping (xkb keysym XF86Send) + +# evdev 146 (0x92): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 147 (0x93): no evdev -> QKeyCode mapping (xkb keysym XF86Xfer) + +# evdev 148 (0x94): no evdev -> QKeyCode mapping (xkb keysym XF86Launch1) + +# evdev 149 (0x95): no evdev -> QKeyCode mapping (xkb keysym XF86Launch2) + +# evdev 150 (0x96): no evdev -> QKeyCode mapping (xkb keysym XF86WWW) + +# evdev 151 (0x97): no evdev -> QKeyCode mapping (xkb keysym XF86DOS) + +# evdev 152 (0x98): no evdev -> QKeyCode mapping (xkb keysym XF86ScreenSaver) + +# evdev 153 (0x99): no evdev -> QKeyCode mapping (xkb keysym XF86RotateWindows) + +# evdev 154 (0x9a): no evdev -> QKeyCode mapping (xkb keysym XF86TaskPane) + +# evdev 155 (0x9b), QKeyCode "mail", number 0xec +XF86Mail 0xec + +# evdev 156 (0x9c), QKeyCode "ac_bookmarks", number 0xe6 +XF86Favorites 0xe6 + +# evdev 157 (0x9d), QKeyCode "computer", number 0xeb +XF86MyComputer 0xeb + +# evdev 158 (0x9e), QKeyCode "ac_back", number 0xea +XF86Back 0xea + +# evdev 159 (0x9f), QKeyCode "ac_forward", number 0xe9 +XF86Forward 0xe9 + +# evdev 160 (0xa0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 161 (0xa1): no evdev -> QKeyCode mapping (xkb keysym XF86Eject) + +# evdev 162 (0xa2): no evdev -> QKeyCode mapping (xkb keysym XF86Eject) + +# evdev 163 (0xa3), QKeyCode "audionext", number 0x99 +XF86AudioNext 0x99 + +# evdev 164 (0xa4), QKeyCode "audioplay", number 0xa2 +XF86AudioPlay 0xa2 +XF86AudioPause 0xa2 shift + +# evdev 165 (0xa5), QKeyCode "audioprev", number 0x90 +XF86AudioPrev 0x90 + +# evdev 166 (0xa6), QKeyCode "audiostop", number 0xa4 +XF86AudioStop 0xa4 +XF86Eject 0xa4 shift + +# evdev 167 (0xa7): no evdev -> QKeyCode mapping (xkb keysym XF86AudioRecord) + +# evdev 168 (0xa8): no evdev -> QKeyCode mapping (xkb keysym XF86AudioRewind) + +# evdev 169 (0xa9): no evdev -> QKeyCode mapping (xkb keysym XF86Phone) + +# evdev 170 (0xaa): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 171 (0xab): no evdev -> QKeyCode mapping (xkb keysym XF86Tools) + +# evdev 172 (0xac), QKeyCode "ac_home", number 0xb2 +XF86HomePage 0xb2 + +# evdev 173 (0xad), QKeyCode "ac_refresh", number 0xe7 +XF86Reload 0xe7 + +# evdev 174 (0xae): no evdev -> QKeyCode mapping (xkb keysym XF86Close) + +# evdev 175 (0xaf): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 176 (0xb0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 177 (0xb1): no evdev -> QKeyCode mapping (xkb keysym XF86ScrollUp) + +# evdev 178 (0xb2): no evdev -> QKeyCode mapping (xkb keysym XF86ScrollDown) + +# evdev 179 (0xb3): no evdev -> QKeyCode mapping (xkb keysym parenleft) + +# evdev 180 (0xb4): no evdev -> QKeyCode mapping (xkb keysym parenright) + +# evdev 181 (0xb5): no evdev -> QKeyCode mapping (xkb keysym XF86New) + +# evdev 182 (0xb6): no evdev -> QKeyCode mapping (xkb keysym Redo) + +# evdev 183 (0xb7), QKeyCode "f13", number 0x5d +XF86Tools 0x5d + +# evdev 184 (0xb8), QKeyCode "f14", number 0x5e +XF86Launch5 0x5e + +# evdev 185 (0xb9), QKeyCode "f15", number 0x5f +XF86Launch6 0x5f + +# evdev 186 (0xba), QKeyCode "f16", number 0x55 +XF86Launch7 0x55 + +# evdev 187 (0xbb), QKeyCode "f17", number 0x83 +XF86Launch8 0x83 + +# evdev 188 (0xbc), QKeyCode "f18", number 0xf7 +XF86Launch9 0xf7 + +# evdev 189 (0xbd), QKeyCode "f19", number 0x84 + +# evdev 190 (0xbe), QKeyCode "f20", number 0x5a +XF86AudioMicMute 0x5a + +# evdev 191 (0xbf), QKeyCode "f21", number 0x74 +XF86TouchpadToggle 0x74 + +# evdev 192 (0xc0), QKeyCode "f22", number 0xf9 +XF86TouchpadOn 0xf9 + +# evdev 193 (0xc1), QKeyCode "f23", number 0x6d +XF86TouchpadOff 0x6d + +# evdev 194 (0xc2), QKeyCode "f24", number 0x6f + +# evdev 195 (0xc3): no evdev -> QKeyCode mapping (xkb keysym ISO_Level5_Shift) + +# evdev 196 (0xc4): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 197 (0xc5): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 198 (0xc6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 199 (0xc7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 200 (0xc8): no evdev -> QKeyCode mapping (xkb keysym XF86AudioPlay) + +# evdev 201 (0xc9): no evdev -> QKeyCode mapping (xkb keysym XF86AudioPause) + +# evdev 202 (0xca): no evdev -> QKeyCode mapping (xkb keysym XF86Launch3) + +# evdev 203 (0xcb): no evdev -> QKeyCode mapping (xkb keysym XF86Launch4) + +# evdev 204 (0xcc): no evdev -> QKeyCode mapping (xkb keysym XF86LaunchB) + +# evdev 205 (0xcd): no evdev -> QKeyCode mapping (xkb keysym XF86Suspend) + +# evdev 206 (0xce): no evdev -> QKeyCode mapping (xkb keysym XF86Close) + +# evdev 207 (0xcf): no evdev -> QKeyCode mapping (xkb keysym XF86AudioPlay) + +# evdev 208 (0xd0): no evdev -> QKeyCode mapping (xkb keysym XF86AudioForward) + +# evdev 209 (0xd1): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 210 (0xd2): no evdev -> QKeyCode mapping (xkb keysym Print) + +# evdev 211 (0xd3): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 212 (0xd4): no evdev -> QKeyCode mapping (xkb keysym XF86WebCam) + +# evdev 213 (0xd5): no evdev -> QKeyCode mapping (xkb keysym XF86AudioPreset) + +# evdev 214 (0xd6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 215 (0xd7): no evdev -> QKeyCode mapping (xkb keysym XF86Mail) + +# evdev 216 (0xd8): no evdev -> QKeyCode mapping (xkb keysym XF86Messenger) + +# evdev 217 (0xd9): no evdev -> QKeyCode mapping (xkb keysym XF86Search) + +# evdev 218 (0xda): no evdev -> QKeyCode mapping (xkb keysym XF86Go) + +# evdev 219 (0xdb): no evdev -> QKeyCode mapping (xkb keysym XF86Finance) + +# evdev 220 (0xdc): no evdev -> QKeyCode mapping (xkb keysym XF86Game) + +# evdev 221 (0xdd): no evdev -> QKeyCode mapping (xkb keysym XF86Shop) + +# evdev 222 (0xde): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 223 (0xdf): no evdev -> QKeyCode mapping (xkb keysym Cancel) + +# evdev 224 (0xe0): no evdev -> QKeyCode mapping (xkb keysym XF86MonBrightnessDown) + +# evdev 225 (0xe1): no evdev -> QKeyCode mapping (xkb keysym XF86MonBrightnessUp) + +# evdev 226 (0xe2), QKeyCode "mediaselect", number 0xed +XF86AudioMedia 0xed + +# evdev 227 (0xe3): no evdev -> QKeyCode mapping (xkb keysym XF86Display) + +# evdev 228 (0xe4): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLightOnOff) + +# evdev 229 (0xe5): no evdev -> QKeyCode mapping (xkb keysym XF86KbdBrightnessDown) + +# evdev 230 (0xe6): no evdev -> QKeyCode mapping (xkb keysym XF86KbdBrightnessUp) + +# evdev 231 (0xe7): no evdev -> QKeyCode mapping (xkb keysym XF86Send) + +# evdev 232 (0xe8): no evdev -> QKeyCode mapping (xkb keysym XF86Reply) + +# evdev 233 (0xe9): no evdev -> QKeyCode mapping (xkb keysym XF86MailForward) + +# evdev 234 (0xea): no evdev -> QKeyCode mapping (xkb keysym XF86Save) + +# evdev 235 (0xeb): no evdev -> QKeyCode mapping (xkb keysym XF86Documents) + +# evdev 236 (0xec): no evdev -> QKeyCode mapping (xkb keysym XF86Battery) + +# evdev 237 (0xed): no evdev -> QKeyCode mapping (xkb keysym XF86Bluetooth) + +# evdev 238 (0xee): no evdev -> QKeyCode mapping (xkb keysym XF86WLAN) + +# evdev 239 (0xef): no evdev -> QKeyCode mapping (xkb keysym XF86UWB) + +# evdev 240 (0xf0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 241 (0xf1): no evdev -> QKeyCode mapping (xkb keysym XF86Next_VMode) + +# evdev 242 (0xf2): no evdev -> QKeyCode mapping (xkb keysym XF86Prev_VMode) + +# evdev 243 (0xf3): no evdev -> QKeyCode mapping (xkb keysym XF86MonBrightnessCycle) + +# evdev 244 (0xf4): no evdev -> QKeyCode mapping (xkb keysym XF86BrightnessAuto) + +# evdev 245 (0xf5): no evdev -> QKeyCode mapping (xkb keysym XF86DisplayOff) + +# evdev 246 (0xf6): no evdev -> QKeyCode mapping (xkb keysym XF86WWAN) + +# evdev 247 (0xf7): no evdev -> QKeyCode mapping (xkb keysym XF86RFKill) + +# evdev 248 (0xf8): no evdev -> QKeyCode mapping (xkb keysym XF86AudioMicMute) + +# evdev 249 (0xf9): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 250 (0xfa): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 251 (0xfb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 252 (0xfc): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 253 (0xfd): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 254 (0xfe): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 255 (0xff): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 256 (0x100): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 257 (0x101): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 258 (0x102): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 259 (0x103): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 260 (0x104): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 261 (0x105): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 262 (0x106): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 263 (0x107): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 264 (0x108): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 265 (0x109): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 266 (0x10a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 267 (0x10b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 268 (0x10c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 269 (0x10d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 270 (0x10e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 271 (0x10f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 272 (0x110): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 273 (0x111): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 274 (0x112): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 275 (0x113): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 276 (0x114): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 277 (0x115): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 278 (0x116): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 279 (0x117): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 280 (0x118): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 281 (0x119): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 282 (0x11a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 283 (0x11b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 284 (0x11c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 285 (0x11d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 286 (0x11e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 287 (0x11f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 288 (0x120): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 289 (0x121): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 290 (0x122): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 291 (0x123): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 292 (0x124): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 293 (0x125): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 294 (0x126): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 295 (0x127): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 296 (0x128): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 297 (0x129): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 298 (0x12a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 299 (0x12b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 300 (0x12c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 301 (0x12d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 302 (0x12e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 303 (0x12f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 304 (0x130): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 305 (0x131): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 306 (0x132): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 307 (0x133): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 308 (0x134): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 309 (0x135): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 310 (0x136): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 311 (0x137): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 312 (0x138): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 313 (0x139): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 314 (0x13a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 315 (0x13b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 316 (0x13c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 317 (0x13d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 318 (0x13e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 319 (0x13f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 320 (0x140): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 321 (0x141): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 322 (0x142): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 323 (0x143): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 324 (0x144): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 325 (0x145): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 326 (0x146): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 327 (0x147): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 328 (0x148): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 329 (0x149): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 330 (0x14a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 331 (0x14b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 332 (0x14c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 333 (0x14d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 334 (0x14e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 335 (0x14f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 336 (0x150): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 337 (0x151): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 338 (0x152): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 339 (0x153): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 340 (0x154): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 341 (0x155): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 342 (0x156): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 343 (0x157): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 344 (0x158): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 345 (0x159): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 346 (0x15a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 347 (0x15b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 348 (0x15c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 349 (0x15d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 350 (0x15e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 351 (0x15f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 352 (0x160): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 353 (0x161): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 354 (0x162): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 355 (0x163): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 356 (0x164): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 357 (0x165): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 358 (0x166): no evdev -> QKeyCode mapping (xkb keysym XF86Info) + +# evdev 359 (0x167): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 360 (0x168): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 361 (0x169): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 362 (0x16a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 363 (0x16b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 364 (0x16c): no evdev -> QKeyCode mapping (xkb keysym XF86Favorites) + +# evdev 365 (0x16d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 366 (0x16e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 367 (0x16f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 368 (0x170): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 369 (0x171): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 370 (0x172): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 371 (0x173): no evdev -> QKeyCode mapping (xkb keysym XF86CycleAngle) + +# evdev 372 (0x174): no evdev -> QKeyCode mapping (xkb keysym XF86FullScreen) + +# evdev 373 (0x175): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 374 (0x176): no evdev -> QKeyCode mapping (xkb keysym XF86Keyboard) + +# evdev 375 (0x177): no evdev -> QKeyCode mapping (xkb keysym XF86AspectRatio) + +# evdev 376 (0x178): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 377 (0x179): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 378 (0x17a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 379 (0x17b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 380 (0x17c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 381 (0x17d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 382 (0x17e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 383 (0x17f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 384 (0x180): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 385 (0x181): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 386 (0x182): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 387 (0x183): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 388 (0x184): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 389 (0x185): no evdev -> QKeyCode mapping (xkb keysym XF86DVD) + +# evdev 390 (0x186): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 391 (0x187): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 392 (0x188): no evdev -> QKeyCode mapping (xkb keysym XF86Audio) + +# evdev 393 (0x189): no evdev -> QKeyCode mapping (xkb keysym XF86Video) + +# evdev 394 (0x18a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 395 (0x18b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 396 (0x18c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 397 (0x18d): no evdev -> QKeyCode mapping (xkb keysym XF86Calendar) + +# evdev 398 (0x18e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 399 (0x18f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 400 (0x190): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 401 (0x191): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 402 (0x192): no evdev -> QKeyCode mapping (xkb keysym XF86ChannelUp) + +# evdev 403 (0x193): no evdev -> QKeyCode mapping (xkb keysym XF86ChannelDown) + +# evdev 404 (0x194): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 405 (0x195): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 406 (0x196): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 407 (0x197): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 408 (0x198): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 409 (0x199): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 410 (0x19a): no evdev -> QKeyCode mapping (xkb keysym XF86AudioRandomPlay) + +# evdev 411 (0x19b): no evdev -> QKeyCode mapping (xkb keysym XF86Break) + +# evdev 412 (0x19c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 413 (0x19d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 414 (0x19e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 415 (0x19f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 416 (0x1a0): no evdev -> QKeyCode mapping (xkb keysym XF86VideoPhone) + +# evdev 417 (0x1a1): no evdev -> QKeyCode mapping (xkb keysym XF86Game) + +# evdev 418 (0x1a2): no evdev -> QKeyCode mapping (xkb keysym XF86ZoomIn) + +# evdev 419 (0x1a3): no evdev -> QKeyCode mapping (xkb keysym XF86ZoomOut) + +# evdev 420 (0x1a4): no evdev -> QKeyCode mapping (xkb keysym XF86ZoomReset) + +# evdev 421 (0x1a5): no evdev -> QKeyCode mapping (xkb keysym XF86Word) + +# evdev 422 (0x1a6): no evdev -> QKeyCode mapping (xkb keysym XF86Editor) + +# evdev 423 (0x1a7): no evdev -> QKeyCode mapping (xkb keysym XF86Excel) + +# evdev 424 (0x1a8): no evdev -> QKeyCode mapping (xkb keysym XF86GraphicsEditor) + +# evdev 425 (0x1a9): no evdev -> QKeyCode mapping (xkb keysym XF86Presentation) + +# evdev 426 (0x1aa): no evdev -> QKeyCode mapping (xkb keysym XF86Database) + +# evdev 427 (0x1ab): no evdev -> QKeyCode mapping (xkb keysym XF86News) + +# evdev 428 (0x1ac): no evdev -> QKeyCode mapping (xkb keysym XF86Voicemail) + +# evdev 429 (0x1ad): no evdev -> QKeyCode mapping (xkb keysym XF86Addressbook) + +# evdev 430 (0x1ae): no evdev -> QKeyCode mapping (xkb keysym XF86Messenger) + +# evdev 431 (0x1af): no evdev -> QKeyCode mapping (xkb keysym XF86DisplayToggle) + +# evdev 432 (0x1b0): no evdev -> QKeyCode mapping (xkb keysym XF86SpellCheck) + +# evdev 433 (0x1b1): no evdev -> QKeyCode mapping (xkb keysym XF86LogOff) + +# evdev 434 (0x1b2): no evdev -> QKeyCode mapping (xkb keysym dollar) + +# evdev 435 (0x1b3): no evdev -> QKeyCode mapping (xkb keysym EuroSign) + +# evdev 436 (0x1b4): no evdev -> QKeyCode mapping (xkb keysym XF86FrameBack) + +# evdev 437 (0x1b5): no evdev -> QKeyCode mapping (xkb keysym XF86FrameForward) + +# evdev 438 (0x1b6): no evdev -> QKeyCode mapping (xkb keysym XF86ContextMenu) + +# evdev 439 (0x1b7): no evdev -> QKeyCode mapping (xkb keysym XF86MediaRepeat) + +# evdev 440 (0x1b8): no evdev -> QKeyCode mapping (xkb keysym XF8610ChannelsUp) + +# evdev 441 (0x1b9): no evdev -> QKeyCode mapping (xkb keysym XF8610ChannelsDown) + +# evdev 442 (0x1ba): no evdev -> QKeyCode mapping (xkb keysym XF86Images) + +# evdev 443 (0x1bb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 444 (0x1bc): no evdev -> QKeyCode mapping (xkb keysym XF86NotificationCenter) + +# evdev 445 (0x1bd): no evdev -> QKeyCode mapping (xkb keysym XF86PickupPhone) + +# evdev 446 (0x1be): no evdev -> QKeyCode mapping (xkb keysym XF86HangupPhone) + +# evdev 447 (0x1bf): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 448 (0x1c0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 449 (0x1c1): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 450 (0x1c2): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 451 (0x1c3): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 452 (0x1c4): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 453 (0x1c5): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 454 (0x1c6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 455 (0x1c7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 456 (0x1c8): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 457 (0x1c9): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 458 (0x1ca): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 459 (0x1cb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 460 (0x1cc): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 461 (0x1cd): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 462 (0x1ce): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 463 (0x1cf): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 464 (0x1d0): no evdev -> QKeyCode mapping (xkb keysym XF86Fn) + +# evdev 465 (0x1d1): no evdev -> QKeyCode mapping (xkb keysym XF86Fn_Esc) + +# evdev 466 (0x1d2): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 467 (0x1d3): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 468 (0x1d4): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 469 (0x1d5): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 470 (0x1d6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 471 (0x1d7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 472 (0x1d8): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 473 (0x1d9): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 474 (0x1da): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 475 (0x1db): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 476 (0x1dc): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 477 (0x1dd): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 478 (0x1de): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 479 (0x1df): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 480 (0x1e0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 481 (0x1e1): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 482 (0x1e2): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 483 (0x1e3): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 484 (0x1e4): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 485 (0x1e5): no evdev -> QKeyCode mapping (xkb keysym XF86FnRightShift) + +# evdev 486 (0x1e6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 487 (0x1e7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 488 (0x1e8): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 489 (0x1e9): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 490 (0x1ea): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 491 (0x1eb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 492 (0x1ec): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 493 (0x1ed): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 494 (0x1ee): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 495 (0x1ef): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 496 (0x1f0): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 497 (0x1f1): no evdev -> QKeyCode mapping (xkb keysym braille_dot_1) + +# evdev 498 (0x1f2): no evdev -> QKeyCode mapping (xkb keysym braille_dot_2) + +# evdev 499 (0x1f3): no evdev -> QKeyCode mapping (xkb keysym braille_dot_3) + +# evdev 500 (0x1f4): no evdev -> QKeyCode mapping (xkb keysym braille_dot_4) + +# evdev 501 (0x1f5): no evdev -> QKeyCode mapping (xkb keysym braille_dot_5) + +# evdev 502 (0x1f6): no evdev -> QKeyCode mapping (xkb keysym braille_dot_6) + +# evdev 503 (0x1f7): no evdev -> QKeyCode mapping (xkb keysym braille_dot_7) + +# evdev 504 (0x1f8): no evdev -> QKeyCode mapping (xkb keysym braille_dot_8) + +# evdev 505 (0x1f9): no evdev -> QKeyCode mapping (xkb keysym braille_dot_9) + +# evdev 506 (0x1fa): no evdev -> QKeyCode mapping (xkb keysym braille_dot_1) + +# evdev 507 (0x1fb): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 508 (0x1fc): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 509 (0x1fd): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 510 (0x1fe): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 511 (0x1ff): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 512 (0x200): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric0) + +# evdev 513 (0x201): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric1) + +# evdev 514 (0x202): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric2) + +# evdev 515 (0x203): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric3) + +# evdev 516 (0x204): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric4) + +# evdev 517 (0x205): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric5) + +# evdev 518 (0x206): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric6) + +# evdev 519 (0x207): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric7) + +# evdev 520 (0x208): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric8) + +# evdev 521 (0x209): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric9) + +# evdev 522 (0x20a): no evdev -> QKeyCode mapping (xkb keysym XF86NumericStar) + +# evdev 523 (0x20b): no evdev -> QKeyCode mapping (xkb keysym XF86NumericPound) + +# evdev 524 (0x20c): no evdev -> QKeyCode mapping (xkb keysym XF86NumericA) + +# evdev 525 (0x20d): no evdev -> QKeyCode mapping (xkb keysym XF86NumericB) + +# evdev 526 (0x20e): no evdev -> QKeyCode mapping (xkb keysym XF86NumericC) + +# evdev 527 (0x20f): no evdev -> QKeyCode mapping (xkb keysym XF86NumericD) + +# evdev 528 (0x210): no evdev -> QKeyCode mapping (xkb keysym XF86CameraFocus) + +# evdev 529 (0x211): no evdev -> QKeyCode mapping (xkb keysym XF86WPSButton) + +# evdev 530 (0x212): no evdev -> QKeyCode mapping (xkb keysym XF86TouchpadToggle) + +# evdev 531 (0x213): no evdev -> QKeyCode mapping (xkb keysym XF86TouchpadOn) + +# evdev 532 (0x214): no evdev -> QKeyCode mapping (xkb keysym XF86TouchpadOff) + +# evdev 533 (0x215): no evdev -> QKeyCode mapping (xkb keysym XF86CameraZoomIn) + +# evdev 534 (0x216): no evdev -> QKeyCode mapping (xkb keysym XF86CameraZoomOut) + +# evdev 535 (0x217): no evdev -> QKeyCode mapping (xkb keysym XF86CameraUp) + +# evdev 536 (0x218): no evdev -> QKeyCode mapping (xkb keysym XF86CameraDown) + +# evdev 537 (0x219): no evdev -> QKeyCode mapping (xkb keysym XF86CameraLeft) + +# evdev 538 (0x21a): no evdev -> QKeyCode mapping (xkb keysym XF86CameraRight) + +# evdev 539 (0x21b): no evdev -> QKeyCode mapping (xkb keysym XF86AttendantOn) + +# evdev 540 (0x21c): no evdev -> QKeyCode mapping (xkb keysym XF86AttendantOff) + +# evdev 541 (0x21d): no evdev -> QKeyCode mapping (xkb keysym XF86AttendantToggle) + +# evdev 542 (0x21e): no evdev -> QKeyCode mapping (xkb keysym XF86LightsToggle) + +# evdev 543 (0x21f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 544 (0x220): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 545 (0x221): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 546 (0x222): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 547 (0x223): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 548 (0x224): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 549 (0x225): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 550 (0x226): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 551 (0x227): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 552 (0x228): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 553 (0x229): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 554 (0x22a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 555 (0x22b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 556 (0x22c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 557 (0x22d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 558 (0x22e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 559 (0x22f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 560 (0x230): no evdev -> QKeyCode mapping (xkb keysym XF86ALSToggle) + +# evdev 561 (0x231): no evdev -> QKeyCode mapping (xkb keysym XF86RotationLockToggle) + +# evdev 562 (0x232): no evdev -> QKeyCode mapping (xkb keysym XF86RefreshRateToggle) + +# evdev 563 (0x233): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 564 (0x234): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 565 (0x235): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 566 (0x236): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 567 (0x237): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 568 (0x238): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 569 (0x239): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 570 (0x23a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 571 (0x23b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 572 (0x23c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 573 (0x23d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 574 (0x23e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 575 (0x23f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 576 (0x240): no evdev -> QKeyCode mapping (xkb keysym XF86Buttonconfig) + +# evdev 577 (0x241): no evdev -> QKeyCode mapping (xkb keysym XF86Taskmanager) + +# evdev 578 (0x242): no evdev -> QKeyCode mapping (xkb keysym XF86Journal) + +# evdev 579 (0x243): no evdev -> QKeyCode mapping (xkb keysym XF86ControlPanel) + +# evdev 580 (0x244): no evdev -> QKeyCode mapping (xkb keysym XF86AppSelect) + +# evdev 581 (0x245): no evdev -> QKeyCode mapping (xkb keysym XF86Screensaver) + +# evdev 582 (0x246): no evdev -> QKeyCode mapping (xkb keysym XF86VoiceCommand) + +# evdev 583 (0x247): no evdev -> QKeyCode mapping (xkb keysym XF86Assistant) + +# evdev 584 (0x248): no evdev -> QKeyCode mapping (xkb keysym ISO_Next_Group) + +# evdev 585 (0x249): no evdev -> QKeyCode mapping (xkb keysym XF86EmojiPicker) + +# evdev 586 (0x24a): no evdev -> QKeyCode mapping (xkb keysym XF86Dictate) + +# evdev 587 (0x24b): no evdev -> QKeyCode mapping (xkb keysym XF86CameraAccessEnable) + +# evdev 588 (0x24c): no evdev -> QKeyCode mapping (xkb keysym XF86CameraAccessDisable) + +# evdev 589 (0x24d): no evdev -> QKeyCode mapping (xkb keysym XF86CameraAccessToggle) + +# evdev 590 (0x24e): no evdev -> QKeyCode mapping (xkb keysym XF86Accessibility) + +# evdev 591 (0x24f): no evdev -> QKeyCode mapping (xkb keysym XF86DoNotDisturb) + +# evdev 592 (0x250): no evdev -> QKeyCode mapping (xkb keysym XF86BrightnessMin) + +# evdev 593 (0x251): no evdev -> QKeyCode mapping (xkb keysym XF86BrightnessMax) + +# evdev 594 (0x252): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 595 (0x253): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 596 (0x254): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 597 (0x255): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 598 (0x256): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 599 (0x257): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 600 (0x258): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 601 (0x259): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 602 (0x25a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 603 (0x25b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 604 (0x25c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 605 (0x25d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 606 (0x25e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 607 (0x25f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 608 (0x260): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistPrev) + +# evdev 609 (0x261): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistNext) + +# evdev 610 (0x262): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistPrevgroup) + +# evdev 611 (0x263): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistNextgroup) + +# evdev 612 (0x264): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistAccept) + +# evdev 613 (0x265): no evdev -> QKeyCode mapping (xkb keysym XF86KbdInputAssistCancel) + +# evdev 614 (0x266): no evdev -> QKeyCode mapping (xkb keysym XF86RightUp) + +# evdev 615 (0x267): no evdev -> QKeyCode mapping (xkb keysym XF86RightDown) + +# evdev 616 (0x268): no evdev -> QKeyCode mapping (xkb keysym XF86LeftUp) + +# evdev 617 (0x269): no evdev -> QKeyCode mapping (xkb keysym XF86LeftDown) + +# evdev 618 (0x26a): no evdev -> QKeyCode mapping (xkb keysym XF86RootMenu) + +# evdev 619 (0x26b): no evdev -> QKeyCode mapping (xkb keysym XF86MediaTopMenu) + +# evdev 620 (0x26c): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric11) + +# evdev 621 (0x26d): no evdev -> QKeyCode mapping (xkb keysym XF86Numeric12) + +# evdev 622 (0x26e): no evdev -> QKeyCode mapping (xkb keysym XF86AudioDesc) + +# evdev 623 (0x26f): no evdev -> QKeyCode mapping (xkb keysym XF863DMode) + +# evdev 624 (0x270): no evdev -> QKeyCode mapping (xkb keysym XF86NextFavorite) + +# evdev 625 (0x271): no evdev -> QKeyCode mapping (xkb keysym XF86StopRecord) + +# evdev 626 (0x272): no evdev -> QKeyCode mapping (xkb keysym XF86PauseRecord) + +# evdev 627 (0x273): no evdev -> QKeyCode mapping (xkb keysym XF86VOD) + +# evdev 628 (0x274): no evdev -> QKeyCode mapping (xkb keysym XF86Unmute) + +# evdev 629 (0x275): no evdev -> QKeyCode mapping (xkb keysym XF86FastReverse) + +# evdev 630 (0x276): no evdev -> QKeyCode mapping (xkb keysym XF86SlowReverse) + +# evdev 631 (0x277): no evdev -> QKeyCode mapping (xkb keysym XF86Data) + +# evdev 632 (0x278): no evdev -> QKeyCode mapping (xkb keysym XF86OnScreenKeyboard) + +# evdev 633 (0x279): no evdev -> QKeyCode mapping (xkb keysym XF86PrivacyScreenToggle) + +# evdev 634 (0x27a): no evdev -> QKeyCode mapping (xkb keysym XF86SelectiveScreenshot) + +# evdev 635 (0x27b): no evdev -> QKeyCode mapping (xkb keysym XF86NextElement) + +# evdev 636 (0x27c): no evdev -> QKeyCode mapping (xkb keysym XF86PreviousElement) + +# evdev 637 (0x27d): no evdev -> QKeyCode mapping (xkb keysym XF86AutopilotEngageToggle) + +# evdev 638 (0x27e): no evdev -> QKeyCode mapping (xkb keysym XF86MarkWaypoint) + +# evdev 639 (0x27f): no evdev -> QKeyCode mapping (xkb keysym XF86Sos) + +# evdev 640 (0x280): no evdev -> QKeyCode mapping (xkb keysym XF86NavChart) + +# evdev 641 (0x281): no evdev -> QKeyCode mapping (xkb keysym XF86FishingChart) + +# evdev 642 (0x282): no evdev -> QKeyCode mapping (xkb keysym XF86SingleRangeRadar) + +# evdev 643 (0x283): no evdev -> QKeyCode mapping (xkb keysym XF86DualRangeRadar) + +# evdev 644 (0x284): no evdev -> QKeyCode mapping (xkb keysym XF86RadarOverlay) + +# evdev 645 (0x285): no evdev -> QKeyCode mapping (xkb keysym XF86TraditionalSonar) + +# evdev 646 (0x286): no evdev -> QKeyCode mapping (xkb keysym XF86ClearvuSonar) + +# evdev 647 (0x287): no evdev -> QKeyCode mapping (xkb keysym XF86SidevuSonar) + +# evdev 648 (0x288): no evdev -> QKeyCode mapping (xkb keysym XF86NavInfo) + +# evdev 649 (0x289): no evdev -> QKeyCode mapping (xkb keysym XF86BrightnessAdjust) + +# evdev 650 (0x28a): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 651 (0x28b): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 652 (0x28c): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 653 (0x28d): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 654 (0x28e): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 655 (0x28f): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 656 (0x290): no evdev -> QKeyCode mapping (xkb keysym XF86Macro1) + +# evdev 657 (0x291): no evdev -> QKeyCode mapping (xkb keysym XF86Macro2) + +# evdev 658 (0x292): no evdev -> QKeyCode mapping (xkb keysym XF86Macro3) + +# evdev 659 (0x293): no evdev -> QKeyCode mapping (xkb keysym XF86Macro4) + +# evdev 660 (0x294): no evdev -> QKeyCode mapping (xkb keysym XF86Macro5) + +# evdev 661 (0x295): no evdev -> QKeyCode mapping (xkb keysym XF86Macro6) + +# evdev 662 (0x296): no evdev -> QKeyCode mapping (xkb keysym XF86Macro7) + +# evdev 663 (0x297): no evdev -> QKeyCode mapping (xkb keysym XF86Macro8) + +# evdev 664 (0x298): no evdev -> QKeyCode mapping (xkb keysym XF86Macro9) + +# evdev 665 (0x299): no evdev -> QKeyCode mapping (xkb keysym XF86Macro10) + +# evdev 666 (0x29a): no evdev -> QKeyCode mapping (xkb keysym XF86Macro11) + +# evdev 667 (0x29b): no evdev -> QKeyCode mapping (xkb keysym XF86Macro12) + +# evdev 668 (0x29c): no evdev -> QKeyCode mapping (xkb keysym XF86Macro13) + +# evdev 669 (0x29d): no evdev -> QKeyCode mapping (xkb keysym XF86Macro14) + +# evdev 670 (0x29e): no evdev -> QKeyCode mapping (xkb keysym XF86Macro15) + +# evdev 671 (0x29f): no evdev -> QKeyCode mapping (xkb keysym XF86Macro16) + +# evdev 672 (0x2a0): no evdev -> QKeyCode mapping (xkb keysym XF86Macro17) + +# evdev 673 (0x2a1): no evdev -> QKeyCode mapping (xkb keysym XF86Macro18) + +# evdev 674 (0x2a2): no evdev -> QKeyCode mapping (xkb keysym XF86Macro19) + +# evdev 675 (0x2a3): no evdev -> QKeyCode mapping (xkb keysym XF86Macro20) + +# evdev 676 (0x2a4): no evdev -> QKeyCode mapping (xkb keysym XF86Macro21) + +# evdev 677 (0x2a5): no evdev -> QKeyCode mapping (xkb keysym XF86Macro22) + +# evdev 678 (0x2a6): no evdev -> QKeyCode mapping (xkb keysym XF86Macro23) + +# evdev 679 (0x2a7): no evdev -> QKeyCode mapping (xkb keysym XF86Macro24) + +# evdev 680 (0x2a8): no evdev -> QKeyCode mapping (xkb keysym XF86Macro25) + +# evdev 681 (0x2a9): no evdev -> QKeyCode mapping (xkb keysym XF86Macro26) + +# evdev 682 (0x2aa): no evdev -> QKeyCode mapping (xkb keysym XF86Macro27) + +# evdev 683 (0x2ab): no evdev -> QKeyCode mapping (xkb keysym XF86Macro28) + +# evdev 684 (0x2ac): no evdev -> QKeyCode mapping (xkb keysym XF86Macro29) + +# evdev 685 (0x2ad): no evdev -> QKeyCode mapping (xkb keysym XF86Macro30) + +# evdev 686 (0x2ae): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 687 (0x2af): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 688 (0x2b0): no evdev -> QKeyCode mapping (xkb keysym XF86MacroRecordStart) + +# evdev 689 (0x2b1): no evdev -> QKeyCode mapping (xkb keysym XF86MacroRecordStop) + +# evdev 690 (0x2b2): no evdev -> QKeyCode mapping (xkb keysym XF86MacroPresetCycle) + +# evdev 691 (0x2b3): no evdev -> QKeyCode mapping (xkb keysym XF86MacroPreset1) + +# evdev 692 (0x2b4): no evdev -> QKeyCode mapping (xkb keysym XF86MacroPreset2) + +# evdev 693 (0x2b5): no evdev -> QKeyCode mapping (xkb keysym XF86MacroPreset3) + +# evdev 694 (0x2b6): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 695 (0x2b7): no evdev -> QKeyCode mapping (xkb keysym NoSymbol) + +# evdev 696 (0x2b8): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu1) + +# evdev 697 (0x2b9): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu2) + +# evdev 698 (0x2ba): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu3) + +# evdev 699 (0x2bb): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu4) + +# evdev 700 (0x2bc): no evdev -> QKeyCode mapping (xkb keysym XF86KbdLcdMenu5) + +# +# quirks section start +# +# Sometimes multiple keysyms map to the same keycodes. +# The keycode -> keysym lookup finds only one of the +# keysyms. So append them here. +# + +Print 0x54 +Sys_Req 0x54 +Execute 0x54 +KP_Decimal 0x53 numlock +KP_Separator 0x53 numlock +Alt_R 0xb8 +ISO_Level3_Shift 0xb8 +Mode_switch 0xb8 + +# quirks section end diff --git a/contrib/keymaps/fr b/contrib/keymaps/fr index 82ca812c7..00211f102 100644 --- a/contrib/keymaps/fr +++ b/contrib/keymaps/fr @@ -49,13 +49,15 @@ oneeighth 0x03 shift altgr quotedbl 0x04 3 0x04 shift numbersign 0x04 altgr -sterling 0x04 shift altgr +# KVMD +#sterling 0x04 shift altgr # evdev 5 (0x5), QKeyCode "4", number 0x5 apostrophe 0x05 4 0x05 shift braceleft 0x05 altgr -dollar 0x05 shift altgr +# KVMD +#dollar 0x05 shift altgr # evdev 6 (0x6), QKeyCode "5", number 0x6 parenleft 0x06 @@ -91,7 +93,8 @@ plusminus 0x0a shift altgr agrave 0x0b 0 0x0b shift at 0x0b altgr -degree 0x0b shift altgr +# KVMD +#degree 0x0b shift altgr # evdev 12 (0xc), QKeyCode "minus", number 0xc parenright 0x0c @@ -122,7 +125,8 @@ AE 0x10 shift altgr z 0x11 Z 0x11 shift guillemotleft 0x11 altgr -less 0x11 shift altgr +#KVMD +#less 0x11 shift altgr # evdev 18 (0x12), QKeyCode "e", number 0x12 e 0x12 @@ -200,7 +204,8 @@ Greek_OMEGA 0x1e shift altgr s 0x1f S 0x1f shift ssharp 0x1f altgr -section 0x1f shift altgr +# KVMD +#section 0x1f shift altgr # evdev 32 (0x20), QKeyCode "d", number 0x20 d 0x20 @@ -247,7 +252,8 @@ Lstroke 0x26 shift altgr # evdev 39 (0x27), QKeyCode "semicolon", number 0x27 m 0x27 M 0x27 shift -mu 0x27 altgr +# KVMD +#mu 0x27 altgr masculine 0x27 shift altgr # evdev 40 (0x28), QKeyCode "apostrophe", number 0x28 @@ -280,7 +286,8 @@ Lstroke 0x2c shift altgr x 0x2d X 0x2d shift guillemotright 0x2d altgr -greater 0x2d shift altgr +# KVMD +#greater 0x2d shift altgr # evdev 46 (0x2e), QKeyCode "c", number 0x2e c 0x2e diff --git a/extras/media/manifest.yaml b/extras/media/manifest.yaml new file mode 100644 index 000000000..f81c1bbfa --- /dev/null +++ b/extras/media/manifest.yaml @@ -0,0 +1,5 @@ +name: Media +description: KVMD Media Proxy +path: media +daemon: kvmd-media +place: -1 diff --git a/extras/media/nginx.ctx-http.conf b/extras/media/nginx.ctx-http.conf new file mode 100644 index 000000000..d4ff7ac39 --- /dev/null +++ b/extras/media/nginx.ctx-http.conf @@ -0,0 +1,3 @@ +upstream media { + server unix:/run/kvmd/media.sock fail_timeout=0s max_fails=0; +} diff --git a/extras/media/nginx.ctx-server.conf b/extras/media/nginx.ctx-server.conf new file mode 100644 index 000000000..c9564627f --- /dev/null +++ b/extras/media/nginx.ctx-server.conf @@ -0,0 +1,14 @@ +location /api/media/ws { + rewrite ^/api/media/ws$ /ws break; + rewrite ^/api/media/ws\?(.*)$ /ws?$1 break; + proxy_pass http://media; + include /etc/kvmd/nginx/loc-proxy.conf; + include /etc/kvmd/nginx/loc-websocket.conf; +} + +location /api/media { + rewrite ^/api/media$ / break; + rewrite ^/api/media\?(.*)$ /?$1 break; + proxy_pass http://media; + include /etc/kvmd/nginx/loc-proxy.conf; +} diff --git a/genmap.py b/genmap.py index 6a6b63bff..6857933ee 100755 --- a/genmap.py +++ b/genmap.py @@ -3,7 +3,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -38,12 +38,12 @@ @dataclasses.dataclass(frozen=True) class _UsbKey: code: int - is_modifier: bool + is_mod: bool @property - def arduino_modifier_code(self) -> int: + def arduino_mod_code(self) -> int: # https://github.com/NicoHood/HID/blob/4bf6cd6/src/HID-APIs/DefaultKeyboardAPI.hpp#L31 - assert self.is_modifier + assert self.is_mod code = self.code offset = 0 while not (code & 0x1): @@ -69,9 +69,10 @@ class _X11Key: @dataclasses.dataclass(frozen=True) class _KeyMapping: web_name: str + evdev_name: str mcu_code: int usb_key: _UsbKey - ps2_key: _Ps2Key + ps2_key: (_Ps2Key | None) at1_code: int x11_keys: set[_X11Key] @@ -102,12 +103,14 @@ def _parse_x11_names(names: str) -> set[_X11Key]: def _parse_usb_key(key: str) -> _UsbKey: - is_modifier = key.startswith("^") - code = int((key[1:] if is_modifier else key), 16) - return _UsbKey(code, is_modifier) + is_mod = key.startswith("^") + code = int((key[1:] if is_mod else key), 16) + return _UsbKey(code, is_mod) -def _parse_ps2_key(key: str) -> _Ps2Key: +def _parse_ps2_key(key: str) -> (_Ps2Key | None): + if ":" not in key: + return None (code_type, raw_code) = key.split(":") return _Ps2Key( code=int(raw_code, 16), @@ -122,6 +125,7 @@ def _read_keymap_csv(path: str) -> list[_KeyMapping]: if len(row) >= 6: keymap.append(_KeyMapping( web_name=row["web_name"], + evdev_name=row["evdev_name"], mcu_code=int(row["mcu_code"]), usb_key=_parse_usb_key(row["usb_key"]), ps2_key=_parse_ps2_key(row["ps2_key"]), @@ -150,6 +154,7 @@ def main() -> None: # Fields list: # - Web + # - Linux/evdev # - MCU code # - USB code (^ for the modifier mask) # - PS/2 key diff --git a/hid/.gitignore b/hid/arduino/.gitignore similarity index 78% rename from hid/.gitignore rename to hid/arduino/.gitignore index 816b29208..05b843bc8 100644 --- a/hid/.gitignore +++ b/hid/arduino/.gitignore @@ -1,3 +1,4 @@ +/.platformio/ /.pio/ /.current /.vscode/ diff --git a/hid/Makefile b/hid/arduino/Makefile similarity index 93% rename from hid/Makefile rename to hid/arduino/Makefile index 652ddd6e0..265a0c79e 100644 --- a/hid/Makefile +++ b/hid/arduino/Makefile @@ -24,8 +24,8 @@ upload: bash -ex -c " \ current=`cat .current`; \ if [ '$($@_CURRENT)' == 'spi' ] || [ '$($@_CURRENT)' == 'aum' ]; then \ - gpioset 0 25=1; \ - gpioset 0 25=0; \ + gpioset -c gpiochip0 -t 30ms,0 25=1; \ + gpioset -c gpiochip0 -t 30ms,0 25=0; \ fi \ " platformio run --environment '$($@_CURRENT)' --project-conf 'platformio-$($@_CONFIG).ini' --target upload diff --git a/hid/avrdude-rpi.conf b/hid/arduino/avrdude-rpi.conf similarity index 83% rename from hid/avrdude-rpi.conf rename to hid/arduino/avrdude-rpi.conf index 8a6f54604..210380a84 100644 --- a/hid/avrdude-rpi.conf +++ b/hid/arduino/avrdude-rpi.conf @@ -2,6 +2,7 @@ programmer id = "rpi"; desc = "RPi SPI programmer"; type = "linuxspi"; + prog_modes = PM_ISP; reset = 25; baudrate = 400000; ; diff --git a/hid/avrdude.py b/hid/arduino/avrdude.py similarity index 100% rename from hid/avrdude.py rename to hid/arduino/avrdude.py diff --git a/hid/arduino/lib/.gitignore b/hid/arduino/lib/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/hid/lib/drivers-avr/eeprom.h b/hid/arduino/lib/drivers-avr/eeprom.h similarity index 96% rename from hid/lib/drivers-avr/eeprom.h rename to hid/arduino/lib/drivers-avr/eeprom.h index bac642a7b..9e7277044 100644 --- a/hid/lib/drivers-avr/eeprom.h +++ b/hid/arduino/lib/drivers-avr/eeprom.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-avr/factory.cpp b/hid/arduino/lib/drivers-avr/factory.cpp similarity index 97% rename from hid/lib/drivers-avr/factory.cpp rename to hid/arduino/lib/drivers-avr/factory.cpp index 801e7a2f4..2fb541872 100644 --- a/hid/lib/drivers-avr/factory.cpp +++ b/hid/arduino/lib/drivers-avr/factory.cpp @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-avr/ps2/hid.h b/hid/arduino/lib/drivers-avr/ps2/hid.h similarity index 98% rename from hid/lib/drivers-avr/ps2/hid.h rename to hid/arduino/lib/drivers-avr/ps2/hid.h index ecd64d165..56cc321d4 100644 --- a/hid/lib/drivers-avr/ps2/hid.h +++ b/hid/arduino/lib/drivers-avr/ps2/hid.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-avr/ps2/keymap.h b/hid/arduino/lib/drivers-avr/ps2/keymap.h similarity index 96% rename from hid/lib/drivers-avr/ps2/keymap.h rename to hid/arduino/lib/drivers-avr/ps2/keymap.h index 65a5d7a1b..e21872084 100644 --- a/hid/lib/drivers-avr/ps2/keymap.h +++ b/hid/arduino/lib/drivers-avr/ps2/keymap.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -148,5 +148,8 @@ void keymapPs2(uint8_t code, Ps2KeyType *ps2_type, uint8_t *ps2_code) { case 109: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 19; return; // KanaMode case 110: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 100; return; // Convert case 111: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 103; return; // NonConvert + case 112: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 35; return; // AudioVolumeMute + case 113: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 50; return; // AudioVolumeUp + case 114: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 33; return; // AudioVolumeDown } } diff --git a/hid/lib/drivers-avr/ps2/keymap.h.mako b/hid/arduino/lib/drivers-avr/ps2/keymap.h.mako similarity index 95% rename from hid/lib/drivers-avr/ps2/keymap.h.mako rename to hid/arduino/lib/drivers-avr/ps2/keymap.h.mako index 75dfd8c2f..6a1cd5432 100644 --- a/hid/lib/drivers-avr/ps2/keymap.h.mako +++ b/hid/arduino/lib/drivers-avr/ps2/keymap.h.mako @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -38,7 +38,9 @@ void keymapPs2(uint8_t code, Ps2KeyType *ps2_type, uint8_t *ps2_code) { switch (code) { % for km in sorted(keymap, key=operator.attrgetter("mcu_code")): + % if km.ps2_key is not None: case ${km.mcu_code}: *ps2_type = PS2_KEY_TYPE_${km.ps2_key.type.upper()}; *ps2_code = ${km.ps2_key.code}; return; // ${km.web_name} + % endif % endfor } } diff --git a/hid/lib/drivers-avr/spi.cpp b/hid/arduino/lib/drivers-avr/spi.cpp similarity index 97% rename from hid/lib/drivers-avr/spi.cpp rename to hid/arduino/lib/drivers-avr/spi.cpp index f9cab4ace..9572c5131 100644 --- a/hid/lib/drivers-avr/spi.cpp +++ b/hid/arduino/lib/drivers-avr/spi.cpp @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-avr/spi.h b/hid/arduino/lib/drivers-avr/spi.h similarity index 96% rename from hid/lib/drivers-avr/spi.h rename to hid/arduino/lib/drivers-avr/spi.h index 368895c2f..742a808d4 100644 --- a/hid/lib/drivers-avr/spi.h +++ b/hid/arduino/lib/drivers-avr/spi.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-avr/usb/hid.h b/hid/arduino/lib/drivers-avr/usb/hid.h similarity index 98% rename from hid/lib/drivers-avr/usb/hid.h rename to hid/arduino/lib/drivers-avr/usb/hid.h index aa7b7ba06..ce15f427c 100644 --- a/hid/lib/drivers-avr/usb/hid.h +++ b/hid/arduino/lib/drivers-avr/usb/hid.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-stm32/README.md b/hid/arduino/lib/drivers-stm32/README.md similarity index 100% rename from hid/lib/drivers-stm32/README.md rename to hid/arduino/lib/drivers-stm32/README.md diff --git a/hid/lib/drivers-stm32/backup-register.h b/hid/arduino/lib/drivers-stm32/backup-register.h similarity index 97% rename from hid/lib/drivers-stm32/backup-register.h rename to hid/arduino/lib/drivers-stm32/backup-register.h index 196e4e1c6..6265dd04a 100644 --- a/hid/lib/drivers-stm32/backup-register.h +++ b/hid/arduino/lib/drivers-stm32/backup-register.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-stm32/bluepill_sch.png b/hid/arduino/lib/drivers-stm32/bluepill_sch.png similarity index 100% rename from hid/lib/drivers-stm32/bluepill_sch.png rename to hid/arduino/lib/drivers-stm32/bluepill_sch.png diff --git a/hid/lib/drivers-stm32/board-stm32.h b/hid/arduino/lib/drivers-stm32/board-stm32.h similarity index 98% rename from hid/lib/drivers-stm32/board-stm32.h rename to hid/arduino/lib/drivers-stm32/board-stm32.h index 1602edaab..97411cb28 100644 --- a/hid/lib/drivers-stm32/board-stm32.h +++ b/hid/arduino/lib/drivers-stm32/board-stm32.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-stm32/factory.cpp b/hid/arduino/lib/drivers-stm32/factory.cpp similarity index 97% rename from hid/lib/drivers-stm32/factory.cpp rename to hid/arduino/lib/drivers-stm32/factory.cpp index 8f20e0fff..9540bb7d7 100644 --- a/hid/lib/drivers-stm32/factory.cpp +++ b/hid/arduino/lib/drivers-stm32/factory.cpp @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-stm32/usb/hid-wrapper-stm32.h b/hid/arduino/lib/drivers-stm32/usb/hid-wrapper-stm32.h similarity index 97% rename from hid/lib/drivers-stm32/usb/hid-wrapper-stm32.h rename to hid/arduino/lib/drivers-stm32/usb/hid-wrapper-stm32.h index 62f58213f..3df0f4ea2 100644 --- a/hid/lib/drivers-stm32/usb/hid-wrapper-stm32.h +++ b/hid/arduino/lib/drivers-stm32/usb/hid-wrapper-stm32.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-stm32/usb/keyboard-stm32.h b/hid/arduino/lib/drivers-stm32/usb/keyboard-stm32.h similarity index 97% rename from hid/lib/drivers-stm32/usb/keyboard-stm32.h rename to hid/arduino/lib/drivers-stm32/usb/keyboard-stm32.h index 22dec04dc..578e07515 100644 --- a/hid/lib/drivers-stm32/usb/keyboard-stm32.h +++ b/hid/arduino/lib/drivers-stm32/usb/keyboard-stm32.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-stm32/usb/mouse-absolute-stm32.h b/hid/arduino/lib/drivers-stm32/usb/mouse-absolute-stm32.h similarity index 97% rename from hid/lib/drivers-stm32/usb/mouse-absolute-stm32.h rename to hid/arduino/lib/drivers-stm32/usb/mouse-absolute-stm32.h index 65195c780..4090cd4f0 100644 --- a/hid/lib/drivers-stm32/usb/mouse-absolute-stm32.h +++ b/hid/arduino/lib/drivers-stm32/usb/mouse-absolute-stm32.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers-stm32/usb/mouse-relative-stm32.h b/hid/arduino/lib/drivers-stm32/usb/mouse-relative-stm32.h similarity index 97% rename from hid/lib/drivers-stm32/usb/mouse-relative-stm32.h rename to hid/arduino/lib/drivers-stm32/usb/mouse-relative-stm32.h index 4524e20bb..943857efc 100644 --- a/hid/lib/drivers-stm32/usb/mouse-relative-stm32.h +++ b/hid/arduino/lib/drivers-stm32/usb/mouse-relative-stm32.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/aum.h b/hid/arduino/lib/drivers/aum.h similarity index 97% rename from hid/lib/drivers/aum.h rename to hid/arduino/lib/drivers/aum.h index 3d407d121..b211304a2 100644 --- a/hid/lib/drivers/aum.h +++ b/hid/arduino/lib/drivers/aum.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/board.h b/hid/arduino/lib/drivers/board.h similarity index 96% rename from hid/lib/drivers/board.h rename to hid/arduino/lib/drivers/board.h index cc431d62d..069bbb0eb 100644 --- a/hid/lib/drivers/board.h +++ b/hid/arduino/lib/drivers/board.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/connection.h b/hid/arduino/lib/drivers/connection.h similarity index 97% rename from hid/lib/drivers/connection.h rename to hid/arduino/lib/drivers/connection.h index 7a9beb7b7..f0e2ca0b3 100644 --- a/hid/lib/drivers/connection.h +++ b/hid/arduino/lib/drivers/connection.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/driver.h b/hid/arduino/lib/drivers/driver.h similarity index 96% rename from hid/lib/drivers/driver.h rename to hid/arduino/lib/drivers/driver.h index af60b112a..7fcb72dd0 100644 --- a/hid/lib/drivers/driver.h +++ b/hid/arduino/lib/drivers/driver.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/factory.h b/hid/arduino/lib/drivers/factory.h similarity index 96% rename from hid/lib/drivers/factory.h rename to hid/arduino/lib/drivers/factory.h index 116a6c84e..5eeba3941 100644 --- a/hid/lib/drivers/factory.h +++ b/hid/arduino/lib/drivers/factory.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/keyboard.h b/hid/arduino/lib/drivers/keyboard.h similarity index 97% rename from hid/lib/drivers/keyboard.h rename to hid/arduino/lib/drivers/keyboard.h index 1128def9c..b853ba67e 100644 --- a/hid/lib/drivers/keyboard.h +++ b/hid/arduino/lib/drivers/keyboard.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/mouse.h b/hid/arduino/lib/drivers/mouse.h similarity index 97% rename from hid/lib/drivers/mouse.h rename to hid/arduino/lib/drivers/mouse.h index 83216e295..1877bf1ef 100644 --- a/hid/lib/drivers/mouse.h +++ b/hid/arduino/lib/drivers/mouse.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/serial.h b/hid/arduino/lib/drivers/serial.h similarity index 97% rename from hid/lib/drivers/serial.h rename to hid/arduino/lib/drivers/serial.h index 32ec56133..60ade2233 100644 --- a/hid/lib/drivers/serial.h +++ b/hid/arduino/lib/drivers/serial.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/storage.h b/hid/arduino/lib/drivers/storage.h similarity index 96% rename from hid/lib/drivers/storage.h rename to hid/arduino/lib/drivers/storage.h index 3b6769055..19a5a7704 100644 --- a/hid/lib/drivers/storage.h +++ b/hid/arduino/lib/drivers/storage.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/tools.cpp b/hid/arduino/lib/drivers/tools.cpp similarity index 96% rename from hid/lib/drivers/tools.cpp rename to hid/arduino/lib/drivers/tools.cpp index a65852457..3dbd84707 100644 --- a/hid/lib/drivers/tools.cpp +++ b/hid/arduino/lib/drivers/tools.cpp @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/tools.h b/hid/arduino/lib/drivers/tools.h similarity index 96% rename from hid/lib/drivers/tools.h rename to hid/arduino/lib/drivers/tools.h index 34f880224..ea75e3781 100644 --- a/hid/lib/drivers/tools.h +++ b/hid/arduino/lib/drivers/tools.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/lib/drivers/usb-keymap.h b/hid/arduino/lib/drivers/usb-keymap.h similarity index 95% rename from hid/lib/drivers/usb-keymap.h rename to hid/arduino/lib/drivers/usb-keymap.h index 5a86337ff..8c1421cf9 100644 --- a/hid/lib/drivers/usb-keymap.h +++ b/hid/arduino/lib/drivers/usb-keymap.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -136,6 +136,10 @@ uint8_t keymapUsb(uint8_t code) { case 109: return 136; // KanaMode case 110: return 138; // Convert case 111: return 139; // NonConvert + case 112: return 127; // AudioVolumeMute + case 113: return 128; // AudioVolumeUp + case 114: return 129; // AudioVolumeDown + case 115: return 111; // F20 default: return 0; } } diff --git a/hid/lib/drivers/usb-keymap.h.mako b/hid/arduino/lib/drivers/usb-keymap.h.mako similarity index 90% rename from hid/lib/drivers/usb-keymap.h.mako rename to hid/arduino/lib/drivers/usb-keymap.h.mako index 5ae01421d..472122634 100644 --- a/hid/lib/drivers/usb-keymap.h.mako +++ b/hid/arduino/lib/drivers/usb-keymap.h.mako @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -26,8 +26,8 @@ uint8_t keymapUsb(uint8_t code) { switch (code) { % for km in sorted(keymap, key=operator.attrgetter("mcu_code")): - % if km.usb_key.is_modifier: - case ${km.mcu_code}: return ${km.usb_key.arduino_modifier_code}; // ${km.web_name} + % if km.usb_key.is_mod: + case ${km.mcu_code}: return ${km.usb_key.arduino_mod_code}; // ${km.web_name} % else: case ${km.mcu_code}: return ${km.usb_key.code}; // ${km.web_name} % endif diff --git a/hid/patch.py b/hid/arduino/patch.py similarity index 100% rename from hid/patch.py rename to hid/arduino/patch.py diff --git a/hid/patches/arduino-get-plugged-endpoint.patch b/hid/arduino/patches/arduino-get-plugged-endpoint.patch similarity index 100% rename from hid/patches/arduino-get-plugged-endpoint.patch rename to hid/arduino/patches/arduino-get-plugged-endpoint.patch diff --git a/hid/patches/arduino-main-no-usb.patch b/hid/arduino/patches/arduino-main-no-usb.patch similarity index 100% rename from hid/patches/arduino-main-no-usb.patch rename to hid/arduino/patches/arduino-main-no-usb.patch diff --git a/hid/patches/arduino-optional-cdc.patch b/hid/arduino/patches/arduino-optional-cdc.patch similarity index 100% rename from hid/patches/arduino-optional-cdc.patch rename to hid/arduino/patches/arduino-optional-cdc.patch diff --git a/hid/patches/hid-no-singletones.patch b/hid/arduino/patches/hid-no-singletones.patch similarity index 100% rename from hid/patches/hid-no-singletones.patch rename to hid/arduino/patches/hid-no-singletones.patch diff --git a/hid/patches/hid-shut-up.patch b/hid/arduino/patches/hid-shut-up.patch similarity index 100% rename from hid/patches/hid-shut-up.patch rename to hid/arduino/patches/hid-shut-up.patch diff --git a/hid/patches/hid-win98.patch b/hid/arduino/patches/hid-win98.patch similarity index 100% rename from hid/patches/hid-win98.patch rename to hid/arduino/patches/hid-win98.patch diff --git a/hid/patches/platformio-stm32f1-no-serial-usb.patch b/hid/arduino/patches/platformio-stm32f1-no-serial-usb.patch similarity index 100% rename from hid/patches/platformio-stm32f1-no-serial-usb.patch rename to hid/arduino/patches/platformio-stm32f1-no-serial-usb.patch diff --git a/hid/platformio-avr.ini b/hid/arduino/platformio-avr.ini similarity index 97% rename from hid/platformio-avr.ini rename to hid/arduino/platformio-avr.ini index 8484ef501..4c8c91441 100644 --- a/hid/platformio-avr.ini +++ b/hid/arduino/platformio-avr.ini @@ -82,8 +82,6 @@ build_flags = -DCDC_DISABLED upload_protocol = custom upload_flags = - -C - $PROJECT_PACKAGES_DIR/tool-avrdude/avrdude.conf -C +avrdude-rpi.conf -P diff --git a/hid/platformio-stm32.ini b/hid/arduino/platformio-stm32.ini similarity index 100% rename from hid/platformio-stm32.ini rename to hid/arduino/platformio-stm32.ini diff --git a/hid/src/main.cpp b/hid/arduino/src/main.cpp similarity index 99% rename from hid/src/main.cpp rename to hid/arduino/src/main.cpp index c1539106a..aa880b10f 100644 --- a/hid/src/main.cpp +++ b/hid/arduino/src/main.cpp @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/src/outputs.h b/hid/arduino/src/outputs.h similarity index 98% rename from hid/src/outputs.h rename to hid/arduino/src/outputs.h index dd9008f3d..b1992fc4f 100644 --- a/hid/src/outputs.h +++ b/hid/arduino/src/outputs.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/src/proto.h b/hid/arduino/src/proto.h similarity index 98% rename from hid/src/proto.h rename to hid/arduino/src/proto.h index 5fbdd49ca..a2e957226 100644 --- a/hid/src/proto.h +++ b/hid/arduino/src/proto.h @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/hid/pico/.gitignore b/hid/pico/.gitignore new file mode 100644 index 000000000..6d0c98832 --- /dev/null +++ b/hid/pico/.gitignore @@ -0,0 +1,5 @@ +/.pico-sdk* +/.tinyusb* +/.ps2x2pico* +/.build/ +/*.uf2 diff --git a/hid/pico/CMakeLists.txt b/hid/pico/CMakeLists.txt new file mode 100644 index 000000000..58a4c1a68 --- /dev/null +++ b/hid/pico/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.13) + +set(PICO_SDK_PATH ${CMAKE_CURRENT_LIST_DIR}/.pico-sdk) +set(PICO_TINYUSB_PATH ${CMAKE_CURRENT_LIST_DIR}/.tinyusb) +set(PS2_PATH ${CMAKE_CURRENT_LIST_DIR}/.ps2x2pico/src) + +# For TinyUSB +set(FAMILY rp2040) + +# Include pico_sdk_import.cmake from pico-sdk (instead of copying) +include(${PICO_SDK_PATH}/external/pico_sdk_import.cmake) + +# Generic setup +set(PROJECT hid) +project(${PROJECT}) + +# Initialize Pico-SDK +pico_sdk_init() + +# Set the path to the source code to build +set(SRC_TO_BUILD_PATH ${CMAKE_CURRENT_LIST_DIR}/src) +add_subdirectory(${SRC_TO_BUILD_PATH}) diff --git a/hid/pico/Makefile b/hid/pico/Makefile new file mode 100644 index 000000000..e76b6cef6 --- /dev/null +++ b/hid/pico/Makefile @@ -0,0 +1,41 @@ +all: deps + rm -f hid.uf2 + cmake -B .build + cmake --build .build --config Release -- -j + ln .build/src/hid.uf2 . + + +upload: install +install: all + sudo mount /dev/sda1 /mnt + sudo cp hid.uf2 /mnt + sudo umount /mnt + + +clean: + rm -rf .build hid.uf2 +clean-all: clean + rm -rf .pico-sdk* .tinyusb* .ps2x2pico + + +define libdep + rm -rf .$(1).tmp + git clone https://github.com/$(2) .$(1).tmp + cd .$(1).tmp \ + && git checkout $(3) \ + && (test ! -f .gitmodules || git submodule update --init) + mv .$(1).tmp .$(1) +endef +.pico-sdk: + $(call libdep,pico-sdk,raspberrypi/pico-sdk,6a7db34ff63345a7badec79ebea3aaef1712f374) +.pico-sdk.patches: .pico-sdk + patch -d .pico-sdk -p1 < patches/pico-sdk.patch + touch .pico-sdk.patches +.tinyusb: + $(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0) +.ps2x2pico: + $(call libdep,ps2x2pico,No0ne/ps2x2pico,26ce89d597e598bb0ac636622e064202d91a9efc) +deps: .pico-sdk .pico-sdk.patches .tinyusb .ps2x2pico + + +.PHONY: deps diff --git a/hid/pico/patches/pico-sdk.patch b/hid/pico/patches/pico-sdk.patch new file mode 100644 index 000000000..03502e1e8 --- /dev/null +++ b/hid/pico/patches/pico-sdk.patch @@ -0,0 +1,10 @@ +diff --git a/tools/pioasm/CMakeLists.txt b/tools/pioasm/CMakeLists.txt +index 322408a..fc8e4b8 100644 +--- a/tools/pioasm/CMakeLists.txt ++++ b/tools/pioasm/CMakeLists.txt +@@ -1,4 +1,4 @@ +-cmake_minimum_required(VERSION 3.4) ++cmake_minimum_required(VERSION 3.5) + project(pioasm CXX) + + set(CMAKE_CXX_STANDARD 11) diff --git a/hid/pico/src/CMakeLists.txt b/hid/pico/src/CMakeLists.txt new file mode 100644 index 000000000..0986741ef --- /dev/null +++ b/hid/pico/src/CMakeLists.txt @@ -0,0 +1,39 @@ +set(target_name hid) +add_executable(${target_name}) + +target_sources(${target_name} PRIVATE + main.c + ph_outputs.c + ph_usb.c + ph_usb_kbd.c + ph_usb_mouse.c + ph_ps2.c + ph_cmds.c + ph_com.c + ph_com_bridge.c + ph_com_spi.c + ph_com_uart.c + ph_debug.c + + ${PS2_PATH}/ps2out.c + ${PS2_PATH}/ps2in.c + ${PS2_PATH}/ps2kb.c + ${PS2_PATH}/ps2ms.c + ${PS2_PATH}/scancodes.c +) +target_link_options(${target_name} PRIVATE -Xlinker --print-memory-usage) +target_compile_options(${target_name} PRIVATE -Wall -Wextra) +target_include_directories(${target_name} PRIVATE ${CMAKE_CURRENT_LIST_DIR} ${PS2_PATH}) + +pico_generate_pio_header(${target_name} ${PS2_PATH}/ps2out.pio) +pico_generate_pio_header(${target_name} ${PS2_PATH}/ps2in.pio) + +target_link_libraries(${target_name} PRIVATE + pico_stdlib + pico_unique_id + hardware_pio + hardware_spi + hardware_watchdog + tinyusb_device +) +pico_add_extra_outputs(${target_name}) diff --git a/hid/pico/src/main.c b/hid/pico/src/main.c new file mode 100644 index 000000000..ee603db38 --- /dev/null +++ b/hid/pico/src/main.c @@ -0,0 +1,130 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#include "pico/stdlib.h" +#include "hardware/gpio.h" +#include "hardware/watchdog.h" + +#include "ph_types.h" +#include "ph_tools.h" +#include "ph_outputs.h" +#include "ph_usb.h" +#include "ph_ps2.h" +#include "ph_com.h" +#include "ph_proto.h" +#include "ph_cmds.h" +#include "ph_debug.h" + + +static bool _reset_required = false; + + +static u8 _handle_request(const u8 *data) { // 8 bytes + // FIXME: See kvmd/kvmd#80 + // Should input buffer be cleared in this case? + if (data[0] == PH_PROTO_MAGIC && ph_crc16(data, 6) == ph_merge8_u16(data[6], data[7])) { +# define HANDLE(x_handler, x_reset) { \ + x_handler(data + 2); \ + if (x_reset) { _reset_required = true; } \ + return PH_PROTO_PONG_OK; \ + } + switch (data[1]) { + case PH_PROTO_CMD_PING: return PH_PROTO_PONG_OK; + case PH_PROTO_CMD_SET_KBD: HANDLE(ph_cmd_set_kbd, true); + case PH_PROTO_CMD_SET_MOUSE: HANDLE(ph_cmd_set_mouse, true); + case PH_PROTO_CMD_SET_CONNECTED: return PH_PROTO_PONG_OK; // Arduino AUM + case PH_PROTO_CMD_CLEAR_HID: HANDLE(ph_cmd_send_clear, false); + case PH_PROTO_CMD_KBD_KEY: HANDLE(ph_cmd_kbd_send_key, false); + case PH_PROTO_CMD_MOUSE_BUTTON: HANDLE(ph_cmd_mouse_send_button, false); + case PH_PROTO_CMD_MOUSE_ABS: HANDLE(ph_cmd_mouse_send_abs, false); + case PH_PROTO_CMD_MOUSE_REL: HANDLE(ph_cmd_mouse_send_rel, false); + case PH_PROTO_CMD_MOUSE_WHEEL: HANDLE(ph_cmd_mouse_send_wheel, false); + case PH_PROTO_CMD_REPEAT: return 0; + } +# undef HANDLE + return PH_PROTO_RESP_INVALID_ERROR; + } + return PH_PROTO_RESP_CRC_ERROR; +} + +static void _send_response(u8 code) { + static u8 prev_code = PH_PROTO_RESP_NONE; + if (code == 0) { + code = prev_code; // Repeat the last code + } else { + prev_code = code; + } + + u8 resp[8] = {0}; + resp[0] = PH_PROTO_MAGIC_RESP; + + if (code & PH_PROTO_PONG_OK) { + resp[1] = PH_PROTO_PONG_OK; + if (_reset_required) { + resp[1] |= PH_PROTO_PONG_RESET_REQUIRED; + } + resp[2] = PH_PROTO_OUT1_DYNAMIC; + + resp[1] |= ph_cmd_get_offlines(); + resp[1] |= ph_cmd_kbd_get_leds(); + resp[2] |= ph_g_outputs_active; + resp[3] |= ph_g_outputs_avail; + } else { + resp[1] = code; + } + + ph_split16(ph_crc16(resp, 6), &resp[6], &resp[7]); + + ph_com_write(resp); + + if (_reset_required) { + watchdog_reboot(0, 0, 100); // Даем немного времени чтобы отправить ответ, а потом ребутимся + } +} + +static void _data_handler(const u8 *data) { + _send_response(_handle_request(data)); +} + +static void _timeout_handler(void) { + _send_response(PH_PROTO_RESP_TIMEOUT_ERROR); +} + + +int main(void) { + //ph_debug_act_init(); + //ph_debug_uart_init(); + ph_outputs_init(); + ph_ps2_init(); + ph_usb_init(); // Тут может быть инициализация USB-CDC для бриджа + ph_com_init(_data_handler, _timeout_handler); + + while (true) { + ph_usb_task(); + ph_ps2_task(); + if (!_reset_required) { + ph_com_task(); + //ph_debug_act_pulse(100); + } + } + return 0; +} diff --git a/hid/pico/src/ph_cmds.c b/hid/pico/src/ph_cmds.c new file mode 100644 index 000000000..52ccba167 --- /dev/null +++ b/hid/pico/src/ph_cmds.c @@ -0,0 +1,131 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#include "tusb.h" + +#include "ph_types.h" +#include "ph_tools.h" +#include "ph_proto.h" +#include "ph_outputs.h" +#include "ph_usb.h" +#include "ph_usb_keymap.h" +#include "ph_ps2.h" + + +u8 ph_cmd_kbd_get_leds(void) { + u8 leds = 0; + if (PH_O_IS_KBD_USB) { + leds = ph_g_usb_kbd_leds; + } else if (PH_O_IS_KBD_PS2) { + leds = ph_g_ps2_kbd_leds; + } +# define GET(x_mod) ((leds & KEYBOARD_LED_##x_mod##LOCK) ? PH_PROTO_PONG_##x_mod : 0) + return (GET(CAPS) | GET(SCROLL) | GET(NUM)); +# undef GET +} + +u8 ph_cmd_get_offlines(void) { + bool kbd_online = true; + if (PH_O_IS_KBD_USB) { + kbd_online = ph_g_usb_kbd_online; + } else if (PH_O_IS_KBD_PS2) { + kbd_online = ph_g_ps2_kbd_online; + } + bool mouse_online = true; + if (PH_O_IS_MOUSE_USB) { + mouse_online = ph_g_usb_mouse_online; + } else if (PH_O_IS_MOUSE_PS2) { + mouse_online = ph_g_ps2_mouse_online; + } + return ( + (kbd_online ? 0 : PH_PROTO_PONG_KBD_OFFLINE) + | (mouse_online ? 0 : PH_PROTO_PONG_MOUSE_OFFLINE) + ); +} + +void ph_cmd_set_kbd(const u8 *args) { // 1 byte + ph_outputs_write(PH_PROTO_OUT1_KBD_MASK, args[0], false); +} + +void ph_cmd_set_mouse(const u8 *args) { // 1 byte + ph_outputs_write(PH_PROTO_OUT1_MOUSE_MASK, args[0], false); +} + +void ph_cmd_send_clear(const u8 *args) { // 0 bytes + (void)args; + ph_usb_send_clear(); + ph_ps2_send_clear(); +} + +void ph_cmd_kbd_send_key(const u8 *args) { // 2 bytes + const u8 key = ph_usb_keymap(args[0]); + if (key > 0) { + if (PH_O_IS_KBD_USB) { + ph_usb_kbd_send_key(key, args[1]); + } else if (PH_O_IS_KBD_PS2) { + ph_ps2_kbd_send_key(key, args[1]); + } + } +} + +void ph_cmd_mouse_send_button(const u8 *args) { // 2 bytes +# define HANDLE(x_byte_n, x_button) { \ + if (args[x_byte_n] & PH_PROTO_CMD_MOUSE_##x_button##_SELECT) { \ + const bool m_state = !!(args[x_byte_n] & PH_PROTO_CMD_MOUSE_##x_button##_STATE); \ + if (PH_O_IS_MOUSE_USB) { \ + ph_usb_mouse_send_button(MOUSE_BUTTON_##x_button, m_state); \ + } else if (PH_O_IS_MOUSE_PS2) { \ + ph_ps2_mouse_send_button(MOUSE_BUTTON_##x_button, m_state); \ + } \ + } \ + } + HANDLE(0, LEFT); + HANDLE(0, RIGHT); + HANDLE(0, MIDDLE); + HANDLE(1, BACKWARD); + HANDLE(1, FORWARD); +# undef HANDLE +} + +void ph_cmd_mouse_send_abs(const u8 *args) { // 4 bytes + if (PH_O_IS_MOUSE_USB_ABS) { + const s16 x = ph_merge8_s16(args[0], args[1]); + const s16 y = ph_merge8_s16(args[2], args[3]); + ph_usb_mouse_send_abs(x, y); + } +} + +void ph_cmd_mouse_send_rel(const u8 *args) { // 2 bytes + if (PH_O_IS_MOUSE_USB_REL) { + ph_usb_mouse_send_rel(args[0], args[1]); + } else if (PH_O_IS_MOUSE_PS2) { + ph_ps2_mouse_send_rel(args[0], args[1]); + } +} + +void ph_cmd_mouse_send_wheel(const u8 *args) { // 2 bytes + if (PH_O_IS_MOUSE_USB) { + ph_usb_mouse_send_wheel(args[0], args[1]); + } else if (PH_O_IS_MOUSE_PS2) { + ph_ps2_mouse_send_wheel(args[0], args[1]); + } +} diff --git a/hid/pico/src/ph_cmds.h b/hid/pico/src/ph_cmds.h new file mode 100644 index 000000000..e0c4c9dbd --- /dev/null +++ b/hid/pico/src/ph_cmds.h @@ -0,0 +1,39 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#pragma once + +#include "ph_types.h" + + +u8 ph_cmd_kbd_get_leds(void); +u8 ph_cmd_get_offlines(void); + +void ph_cmd_set_kbd(const u8 *args); +void ph_cmd_set_mouse(const u8 *args); + +void ph_cmd_send_clear(const u8 *args); +void ph_cmd_kbd_send_key(const u8 *args); +void ph_cmd_mouse_send_button(const u8 *args); +void ph_cmd_mouse_send_abs(const u8 *args); +void ph_cmd_mouse_send_rel(const u8 *args); +void ph_cmd_mouse_send_wheel(const u8 *args); diff --git a/hid/pico/src/ph_com.c b/hid/pico/src/ph_com.c new file mode 100644 index 000000000..860b6202d --- /dev/null +++ b/hid/pico/src/ph_com.c @@ -0,0 +1,67 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#include "ph_com.h" + +#include "pico/stdlib.h" +#include "hardware/gpio.h" + +#include "ph_types.h" +#include "ph_outputs.h" +#include "ph_com_bridge.h" +#include "ph_com_spi.h" +#include "ph_com_uart.h" + + +#define _USE_SPI_PIN 22 + + +static bool _use_spi = true; + + +#define _COM(x_func, ...) { \ + if (ph_g_is_bridge) { \ + ph_com_bridge_##x_func(__VA_ARGS__); \ + } else if (_use_spi) { \ + ph_com_spi_##x_func(__VA_ARGS__); \ + } else { \ + ph_com_uart_##x_func(__VA_ARGS__); \ + } \ + } + + +void ph_com_init(void (*data_cb)(const u8 *), void (*timeout_cb)(void)) { + gpio_init(_USE_SPI_PIN); + gpio_set_dir(_USE_SPI_PIN, GPIO_IN); + gpio_pull_up(_USE_SPI_PIN); + sleep_ms(10); // Нужен небольшой слип для активации pull-up + _use_spi = gpio_get(_USE_SPI_PIN); + _COM(init, data_cb, timeout_cb); +} + +void ph_com_task(void) { + _COM(task); +} + +void ph_com_write(const u8 *data) { + _COM(write, data); +} diff --git a/hid/pico/src/ph_com.h b/hid/pico/src/ph_com.h new file mode 100644 index 000000000..12ca2d48b --- /dev/null +++ b/hid/pico/src/ph_com.h @@ -0,0 +1,30 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + + +void ph_com_init(void (*data_cb)(const u8 *), void (*timeout_cb)(void)); +void ph_com_task(void); +void ph_com_write(const u8 *data); diff --git a/hid/pico/src/ph_com_bridge.c b/hid/pico/src/ph_com_bridge.c new file mode 100644 index 000000000..d9fc4552d --- /dev/null +++ b/hid/pico/src/ph_com_bridge.c @@ -0,0 +1,84 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#include "ph_com_bridge.h" + +#include "pico/stdlib.h" + +#include "tusb.h" + +#include "ph_types.h" + + +#define _TIMEOUT_US 100000 + + +static u8 _buf[8] = {0}; +static u8 _index = 0; +static u64 _last_ts = 0; + +static void (*_data_cb)(const u8 *) = NULL; +static void (*_timeout_cb)(void) = NULL; + + +void ph_com_bridge_init(void (*data_cb)(const u8 *), void (*timeout_cb)(void)) { + _data_cb = data_cb; + _timeout_cb = timeout_cb; +} + +void ph_com_bridge_task(void) { + if (!tud_cdc_connected()) { + tud_cdc_write_clear(); + return; + } + + if (tud_cdc_available() > 0) { + const s32 ch = tud_cdc_read_char(); + if (ch < 0) { + goto no_data; + } + _buf[_index] = (u8)ch; + if (_index == 7) { + _data_cb(_buf); + _index = 0; + } else { + _last_ts = time_us_64(); + ++_index; + } + return; + } + + no_data: + if (_index > 0) { + if (_last_ts + _TIMEOUT_US < time_us_64()) { + _timeout_cb(); + _index = 0; + } + } +} + +void ph_com_bridge_write(const u8 *data) { + if (tud_cdc_connected()) { + tud_cdc_write(data, 8); + tud_cdc_write_flush(); + } +} diff --git a/hid/pico/src/ph_com_bridge.h b/hid/pico/src/ph_com_bridge.h new file mode 100644 index 000000000..7bc293e70 --- /dev/null +++ b/hid/pico/src/ph_com_bridge.h @@ -0,0 +1,30 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + + +void ph_com_bridge_init(void (*data_cb)(const u8 *), void (*timeout_cb)(void)); +void ph_com_bridge_task(void); +void ph_com_bridge_write(const u8 *data); diff --git a/hid/pico/src/ph_com_spi.c b/hid/pico/src/ph_com_spi.c new file mode 100644 index 000000000..e324e8dc4 --- /dev/null +++ b/hid/pico/src/ph_com_spi.c @@ -0,0 +1,121 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#include "ph_com_spi.h" + +#include "hardware/gpio.h" +#include "hardware/irq.h" +#include "hardware/spi.h" +#include "hardware/regs/spi.h" + +#include "ph_types.h" + + +#define _BUS spi0 +#define _IRQ SPI0_IRQ +#define _FREQ (2 * 1000 * 1000) +#define _CS_PIN 21 +#define _RX_PIN 20 +#define _TX_PIN 19 +#define _CLK_PIN 18 + + +static volatile u8 _in_buf[8] = {0}; +static volatile u8 _in_index = 0; + +static volatile u8 _out_buf[8] = {0}; +static volatile u8 _out_index = 0; + +static void (*_data_cb)(const u8 *) = NULL; + + +static void _xfer_isr(void); + + +void ph_com_spi_init(void (*data_cb)(const u8 *), void (*timeout_cb)(void)) { + _data_cb = data_cb; + (void)timeout_cb; + + spi_init(_BUS, _FREQ); + spi_set_slave(_BUS, true); + spi_set_format(_BUS, 8, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST); + + gpio_set_function(_CS_PIN, GPIO_FUNC_SPI); + gpio_set_function(_RX_PIN, GPIO_FUNC_SPI); + gpio_set_function(_TX_PIN, GPIO_FUNC_SPI); + gpio_set_function(_CLK_PIN, GPIO_FUNC_SPI); + + // https://github.com/raspberrypi/pico-sdk/blob/master/src/rp2040/hardware_regs/include/hardware/regs/spi.h + irq_set_exclusive_handler(_IRQ, _xfer_isr); + spi_get_hw(_BUS)->imsc = SPI_SSPIMSC_RXIM_BITS | SPI_SSPIMSC_TXIM_BITS; + irq_set_enabled(_IRQ, true); +} + +void ph_com_spi_task(void) { + if (!_out_buf[0] && _in_index == 8) { + _data_cb((const u8 *)_in_buf); + } +} + +void ph_com_spi_write(const u8 *data) { + // Меджик в нулевом байте разрешает начать ответ + for (s8 i = 7; i >= 0; --i) { + _out_buf[i] = data[i]; + } +} + +void __isr __not_in_flash_func(_xfer_isr)(void) { +# define SR (spi_get_hw(_BUS)->sr) +# define DR (spi_get_hw(_BUS)->dr) + + while (SR & SPI_SSPSR_TNF_BITS) { + if (_out_buf[0] && _out_index < 8) { + DR = (u32)_out_buf[_out_index]; + ++_out_index; + if (_out_index == 8) { + _out_index = 0; + _in_index = 0; + _out_buf[0] = 0; + } + } else { + DR = (u32)0; + } + } + + while (SR & SPI_SSPSR_RNE_BITS) { + static bool receiving = false; + const u8 in = DR; + if (!receiving && in != 0) { + receiving = true; + } + if (receiving && _in_index < 8) { + _in_buf[_in_index] = in; + ++_in_index; + } + if (_in_index == 8) { + receiving = false; + } + } + +# undef DR +# undef SR +} diff --git a/hid/pico/src/ph_com_spi.h b/hid/pico/src/ph_com_spi.h new file mode 100644 index 000000000..9a6d55b6d --- /dev/null +++ b/hid/pico/src/ph_com_spi.h @@ -0,0 +1,30 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + + +void ph_com_spi_init(void (*data_cb)(const u8 *), void (*timeout_cb)(void)); +void ph_com_spi_task(void); +void ph_com_spi_write(const u8 *data); diff --git a/hid/pico/src/ph_com_uart.c b/hid/pico/src/ph_com_uart.c new file mode 100644 index 000000000..8d64f56b8 --- /dev/null +++ b/hid/pico/src/ph_com_uart.c @@ -0,0 +1,75 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#include "ph_com_uart.h" + +#include "pico/stdlib.h" +#include "hardware/gpio.h" +#include "hardware/uart.h" + +#include "ph_types.h" + + +#define _BUS uart1 +#define _SPEED 115200 +#define _RX_PIN 21 +#define _TX_PIN 20 +#define _TIMEOUT_US 100000 + + +static u8 _buf[8] = {0}; +static u8 _index = 0; +static u64 _last_ts = 0; + +static void (*_data_cb)(const u8 *) = NULL; +static void (*_timeout_cb)(void) = NULL; + + +void ph_com_uart_init(void (*data_cb)(const u8 *), void (*timeout_cb)(void)) { + _data_cb = data_cb; + _timeout_cb = timeout_cb; + uart_init(_BUS, _SPEED); + gpio_set_function(_RX_PIN, GPIO_FUNC_UART); + gpio_set_function(_TX_PIN, GPIO_FUNC_UART); +} + +void ph_com_uart_task(void) { + if (uart_is_readable(_BUS)) { + _buf[_index] = (u8)uart_getc(_BUS); + if (_index == 7) { + _data_cb(_buf); + _index = 0; + } else { + _last_ts = time_us_64(); + ++_index; + } + } else if (_index > 0) { + if (_last_ts + _TIMEOUT_US < time_us_64()) { + _timeout_cb(); + _index = 0; + } + } +} + +void ph_com_uart_write(const u8 *data) { + uart_write_blocking(_BUS, data, 8); +} diff --git a/hid/pico/src/ph_com_uart.h b/hid/pico/src/ph_com_uart.h new file mode 100644 index 000000000..b423c6c80 --- /dev/null +++ b/hid/pico/src/ph_com_uart.h @@ -0,0 +1,30 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + + +void ph_com_uart_init(void (*data_cb)(const u8 *), void (*timeout_cb)(void)); +void ph_com_uart_task(void); +void ph_com_uart_write(const u8 *data); diff --git a/hid/pico/src/ph_debug.c b/hid/pico/src/ph_debug.c new file mode 100644 index 000000000..e08b9db1c --- /dev/null +++ b/hid/pico/src/ph_debug.c @@ -0,0 +1,58 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#include "pico/stdlib.h" +#include "hardware/gpio.h" + +#include "ph_types.h" + + +#define _UART uart0 +#define _SPEED 3000000 +#define _RX_PIN -1 // 1 - No stdin +#define _TX_PIN 0 +#define _ACT_PIN 25 + + +void ph_debug_uart_init(void) { + stdio_uart_init_full(_UART, _SPEED, _TX_PIN, _RX_PIN); +} + +void ph_debug_act_init(void) { + gpio_init(_ACT_PIN); + gpio_set_dir(_ACT_PIN, GPIO_OUT); +} + +void ph_debug_act(bool flag) { + gpio_put(_ACT_PIN, flag); +} + +void ph_debug_act_pulse(u64 delay_ms) { + static bool flag = false; + static u64 next_ts = 0; + const u64 now_ts = time_us_64(); + if (now_ts >= next_ts) { + ph_debug_act(flag); + flag = !flag; + next_ts = now_ts + (delay_ms * 1000); + } +} diff --git a/hid/pico/src/ph_debug.h b/hid/pico/src/ph_debug.h new file mode 100644 index 000000000..33d5eed84 --- /dev/null +++ b/hid/pico/src/ph_debug.h @@ -0,0 +1,31 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + + +void ph_debug_uart_init(); +void ph_debug_act_init(); +void ph_debug_act(bool flag); +void ph_debug_act_pulse(u64 delay_ms); diff --git a/hid/pico/src/ph_outputs.c b/hid/pico/src/ph_outputs.c new file mode 100644 index 000000000..4ec40de55 --- /dev/null +++ b/hid/pico/src/ph_outputs.c @@ -0,0 +1,146 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#include "ph_outputs.h" + +#include "pico/stdlib.h" +#include "hardware/gpio.h" +#include "hardware/structs/watchdog.h" + +#include "ph_types.h" +#include "ph_tools.h" +#include "ph_proto.h" + + +#define _PS2_ENABLED_PIN 2 +#define _PS2_SET_KBD_PIN 3 +#define _PS2_SET_MOUSE_PIN 4 + +#define _BRIDGE_MODE_PIN 5 + +#define _USB_DISABLED_PIN 6 +#define _USB_ENABLE_W98_PIN 7 +#define _USB_SET_MOUSE_REL_PIN 8 +#define _USB_SET_MOUSE_W98_PIN 9 + + +u8 ph_g_outputs_active = 0; +u8 ph_g_outputs_avail = 0; +bool ph_g_is_bridge = false; + + +static int _read_outputs(void); + + +void ph_outputs_init(void) { +# define INIT_SWITCH(x_pin) { gpio_init(x_pin); gpio_set_dir(x_pin, GPIO_IN); gpio_pull_up(x_pin); } + INIT_SWITCH(_PS2_ENABLED_PIN); + INIT_SWITCH(_PS2_SET_KBD_PIN); + INIT_SWITCH(_PS2_SET_MOUSE_PIN); + + INIT_SWITCH(_BRIDGE_MODE_PIN); + + INIT_SWITCH(_USB_DISABLED_PIN); + INIT_SWITCH(_USB_ENABLE_W98_PIN); + INIT_SWITCH(_USB_SET_MOUSE_REL_PIN); + INIT_SWITCH(_USB_SET_MOUSE_W98_PIN); +# undef INIT_SWITCH + sleep_ms(10); // Нужен небольшой слип для активации pull-up + + const bool o_ps2_enabled = !gpio_get(_PS2_ENABLED_PIN); // Note: all pins are pulled up! + const bool o_ps2_kbd = !gpio_get(_PS2_SET_KBD_PIN); + const bool o_ps2_mouse = !gpio_get(_PS2_SET_MOUSE_PIN); + + ph_g_is_bridge = !gpio_get(_BRIDGE_MODE_PIN); + + const bool o_usb_disabled = (ph_g_is_bridge || !gpio_get(_USB_DISABLED_PIN)); + const bool o_usb_enabled_w98 = !gpio_get(_USB_ENABLE_W98_PIN); + const bool o_usb_mouse_rel = !gpio_get(_USB_SET_MOUSE_REL_PIN); + const bool o_usb_mouse_w98 = !gpio_get(_USB_SET_MOUSE_W98_PIN); + + int outputs = _read_outputs(); + if (outputs < 0) { + outputs = 0; + + if (o_ps2_enabled && (o_ps2_kbd || o_usb_disabled)) { + outputs |= PH_PROTO_OUT1_KBD_PS2; + } else if (!o_usb_disabled) { + outputs |= PH_PROTO_OUT1_KBD_USB; + } + + if (o_ps2_enabled && (o_ps2_mouse || o_usb_disabled)) { + outputs |= PH_PROTO_OUT1_MOUSE_PS2; + } else if (!o_usb_disabled) { + if (o_usb_enabled_w98 && o_usb_mouse_w98) { + outputs |= PH_PROTO_OUT1_MOUSE_USB_W98; + } else if (o_usb_mouse_rel) { + outputs |= PH_PROTO_OUT1_MOUSE_USB_REL; + } else { + outputs |= PH_PROTO_OUT1_MOUSE_USB_ABS; + } + } + + ph_outputs_write(0xFF, outputs, true); + } + + if (!o_usb_disabled) { + ph_g_outputs_avail |= PH_PROTO_OUT2_HAS_USB; + if (o_usb_enabled_w98) { + ph_g_outputs_avail |= PH_PROTO_OUT2_HAS_USB_W98; + } + } + if (o_ps2_enabled) { + ph_g_outputs_avail |= PH_PROTO_OUT2_HAS_PS2; + } + + ph_g_outputs_active = outputs & 0xFF; +} + +void ph_outputs_write(u8 mask, u8 outputs, bool force) { + int old = 0; + if (!force) { + old = _read_outputs(); + if (old < 0) { + old = 0; + } + } + u8 data[4] = {0}; + data[0] = PH_PROTO_MAGIC; + data[1] = (old & ~mask) | outputs; + ph_split16(ph_crc16(data, 2), &data[2], &data[3]); + const u32 s0 = ((u32)data[0] << 24) | ((u32)data[1] << 16) | ((u32)data[2] << 8) | (u32)data[3]; + watchdog_hw->scratch[0] = s0; +} + +static int _read_outputs(void) { + const u32 s0 = watchdog_hw->scratch[0]; + const u8 data[4] = { + (s0 >> 24) & 0xFF, + (s0 >> 16) & 0xFF, + (s0 >> 8) & 0xFF, + s0 & 0xFF, + }; + if (data[0] != PH_PROTO_MAGIC || ph_crc16(data, 2) != ph_merge8_u16(data[2], data[3])) { + return -1; + } + return data[1]; +} diff --git a/hid/pico/src/ph_outputs.h b/hid/pico/src/ph_outputs.h new file mode 100644 index 000000000..bddf92080 --- /dev/null +++ b/hid/pico/src/ph_outputs.h @@ -0,0 +1,46 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#pragma once + +#include "ph_types.h" +#include "ph_proto.h" + + +#define PH_O_HAS_PS2 (!!(ph_g_outputs_avail & PH_PROTO_OUT2_HAS_PS2)) +#define PH_O_KBD(x_id) ((ph_g_outputs_active & PH_PROTO_OUT1_KBD_MASK) == PH_PROTO_OUT1_KBD_##x_id) +#define PH_O_MOUSE(x_id) ((ph_g_outputs_active & PH_PROTO_OUT1_MOUSE_MASK) == PH_PROTO_OUT1_MOUSE_##x_id) +#define PH_O_IS_KBD_USB PH_O_KBD(USB) +#define PH_O_IS_MOUSE_USB (PH_O_MOUSE(USB_ABS) || PH_O_MOUSE(USB_REL) || PH_O_MOUSE(USB_W98)) +#define PH_O_IS_MOUSE_USB_ABS (PH_O_MOUSE(USB_ABS) || PH_O_MOUSE(USB_W98)) +#define PH_O_IS_MOUSE_USB_REL PH_O_MOUSE(USB_REL) +#define PH_O_IS_KBD_PS2 PH_O_KBD(PS2) +#define PH_O_IS_MOUSE_PS2 PH_O_MOUSE(PS2) + + +extern bool ph_g_is_bridge; +extern u8 ph_g_outputs_active; +extern u8 ph_g_outputs_avail; + + +void ph_outputs_init(void); +void ph_outputs_write(u8 mask, u8 outputs, bool force); diff --git a/hid/pico/src/ph_proto.h b/hid/pico/src/ph_proto.h new file mode 100644 index 000000000..78b28d32d --- /dev/null +++ b/hid/pico/src/ph_proto.h @@ -0,0 +1,92 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + + +#define PH_PROTO_MAGIC ((u8)0x33) +#define PH_PROTO_MAGIC_RESP ((u8)0x34) + +//#define PH_PROTO_RESP_OK ((u8)0x20) // Legacy +#define PH_PROTO_RESP_NONE ((u8)0x24) +#define PH_PROTO_RESP_CRC_ERROR ((u8)0x40) +#define PH_PROTO_RESP_INVALID_ERROR ((u8)0x45) +#define PH_PROTO_RESP_TIMEOUT_ERROR ((u8)0x48) + +// Complex response flags +#define PH_PROTO_PONG_OK ((u8)0b10000000) +#define PH_PROTO_PONG_CAPS ((u8)0b00000001) +#define PH_PROTO_PONG_SCROLL ((u8)0b00000010) +#define PH_PROTO_PONG_NUM ((u8)0b00000100) +#define PH_PROTO_PONG_KBD_OFFLINE ((u8)0b00001000) +#define PH_PROTO_PONG_MOUSE_OFFLINE ((u8)0b00010000) +#define PH_PROTO_PONG_RESET_REQUIRED ((u8)0b01000000) + +// Complex request/response flags +#define PH_PROTO_OUT1_DYNAMIC ((u8)0b10000000) +#define PH_PROTO_OUT1_KBD_MASK ((u8)0b00000111) +#define PH_PROTO_OUT1_KBD_USB ((u8)0b00000001) +#define PH_PROTO_OUT1_KBD_PS2 ((u8)0b00000011) +// + +#define PH_PROTO_OUT1_MOUSE_MASK ((u8)0b00111000) +#define PH_PROTO_OUT1_MOUSE_USB_ABS ((u8)0b00001000) +#define PH_PROTO_OUT1_MOUSE_USB_REL ((u8)0b00010000) +#define PH_PROTO_OUT1_MOUSE_PS2 ((u8)0b00011000) +#define PH_PROTO_OUT1_MOUSE_USB_W98 ((u8)0b00100000) + +// Complex response +#define PH_PROTO_OUT2_CONNECTABLE ((u8)0b10000000) +#define PH_PROTO_OUT2_CONNECTED ((u8)0b01000000) +#define PH_PROTO_OUT2_HAS_USB ((u8)0b00000001) +#define PH_PROTO_OUT2_HAS_PS2 ((u8)0b00000010) +#define PH_PROTO_OUT2_HAS_USB_W98 ((u8)0b00000100) + +#define PH_PROTO_CMD_PING ((u8)0x01) +#define PH_PROTO_CMD_REPEAT ((u8)0x02) +#define PH_PROTO_CMD_SET_KBD ((u8)0x03) +#define PH_PROTO_CMD_SET_MOUSE ((u8)0x04) +#define PH_PROTO_CMD_SET_CONNECTED ((u8)0x05) +#define PH_PROTO_CMD_CLEAR_HID ((u8)0x10) +// + +#define PH_PROTO_CMD_KBD_KEY ((u8)0x11) +// + +#define PH_PROTO_CMD_MOUSE_ABS ((u8)0x12) +#define PH_PROTO_CMD_MOUSE_BUTTON ((u8)0x13) +#define PH_PROTO_CMD_MOUSE_WHEEL ((u8)0x14) +#define PH_PROTO_CMD_MOUSE_REL ((u8)0x15) +// + +#define PH_PROTO_CMD_MOUSE_LEFT_SELECT ((u8)0b10000000) +#define PH_PROTO_CMD_MOUSE_LEFT_STATE ((u8)0b00001000) +// + +#define PH_PROTO_CMD_MOUSE_RIGHT_SELECT ((u8)0b01000000) +#define PH_PROTO_CMD_MOUSE_RIGHT_STATE ((u8)0b00000100) +// + +#define PH_PROTO_CMD_MOUSE_MIDDLE_SELECT ((u8)0b00100000) +#define PH_PROTO_CMD_MOUSE_MIDDLE_STATE ((u8)0b00000010) +// + +#define PH_PROTO_CMD_MOUSE_BACKWARD_SELECT ((u8)0b10000000) // Previous/Up +#define PH_PROTO_CMD_MOUSE_BACKWARD_STATE ((u8)0b00001000) // Previous/Up +// + +#define PH_PROTO_CMD_MOUSE_FORWARD_SELECT ((u8)0b01000000) // Next/Down +#define PH_PROTO_CMD_MOUSE_FORWARD_STATE ((u8)0b00000100) // Next/Down diff --git a/hid/pico/src/ph_ps2.c b/hid/pico/src/ph_ps2.c new file mode 100644 index 000000000..fcb4dfbb1 --- /dev/null +++ b/hid/pico/src/ph_ps2.c @@ -0,0 +1,143 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#include "ph_ps2.h" + +#include "ph_types.h" +#include "ph_outputs.h" + +#include "hardware/gpio.h" + + +#define _LS_POWER_PIN 13 +#define _KBD_DATA_PIN 11 // CLK == 12 +#define _MOUSE_DATA_PIN 14 // CLK == 15 + +#define _KBD_IN_DATA_PIN 26 // passthru, CLK == 27 +#define _MOUSE_IN_DATA_PIN 16 // passthru, CLK == 17 + + +u8 ph_g_ps2_kbd_leds = 0; +bool ph_g_ps2_kbd_online = 0; +bool ph_g_ps2_mouse_online = 0; + +u8 ph_ps2_kbd_modifiers = 0; +u8 ph_ps2_mouse_buttons = 0; + + +void tuh_kb_set_leds(u8 leds) { + ph_g_ps2_kbd_leds = leds; +} + +void ph_ps2_init(void) { + if (PH_O_HAS_PS2) { + gpio_init(_LS_POWER_PIN); + gpio_set_dir(_LS_POWER_PIN, GPIO_OUT); + gpio_put(_LS_POWER_PIN, true); + } + +# define INIT_STUB(x_pin) { \ + gpio_init(x_pin); gpio_set_dir(x_pin, GPIO_IN); \ + gpio_init(x_pin + 1); gpio_set_dir(x_pin + 1, GPIO_IN); \ + } + + if (PH_O_IS_KBD_PS2) { + kb_init(_KBD_DATA_PIN, _KBD_IN_DATA_PIN); + } else { + INIT_STUB(_KBD_DATA_PIN); + } + + if (PH_O_IS_MOUSE_PS2) { + ms_init(_MOUSE_DATA_PIN, _MOUSE_IN_DATA_PIN); + } else { + INIT_STUB(_MOUSE_DATA_PIN); + } + +# undef INIT_STUB +} + +void ph_ps2_task(void) { + if (PH_O_IS_KBD_PS2) { + ph_g_ps2_kbd_online = kb_task(); + } + + if (PH_O_IS_MOUSE_PS2) { + ph_g_ps2_mouse_online = ms_task(); + } +} + +void ph_ps2_kbd_send_key(u8 key, bool state) { + if (PH_O_IS_KBD_PS2) { + if (key >= 0xe0 && key <= 0xe7) { + if (state) { + ph_ps2_kbd_modifiers = ph_ps2_kbd_modifiers | (1 << (key - 0xe0)); + } else { + ph_ps2_kbd_modifiers = ph_ps2_kbd_modifiers & ~(1 << (key - 0xe0)); + } + } + + kb_send_key(key, state, ph_ps2_kbd_modifiers); + } +} + +void ph_ps2_mouse_send_button(u8 button, bool state) { + if (PH_O_IS_MOUSE_PS2) { + button--; + + if (state) { + ph_ps2_mouse_buttons = ph_ps2_mouse_buttons | (1 << button); + } else { + ph_ps2_mouse_buttons = ph_ps2_mouse_buttons & ~(1 << button); + } + + ms_send_movement(ph_ps2_mouse_buttons, 0, 0, 0); + } +} + +void ph_ps2_mouse_send_rel(s8 x, s8 y) { + if (PH_O_IS_MOUSE_PS2) { + ms_send_movement(ph_ps2_mouse_buttons, x, y, 0); + } +} + +void ph_ps2_mouse_send_wheel(s8 h, s8 v) { + if (PH_O_IS_MOUSE_PS2) { + (void)h; // as far as I know there is no standard way for horizontal scrolling + ms_send_movement(ph_ps2_mouse_buttons, 0, 0, v); + } +} + +void ph_ps2_send_clear(void) { + if (PH_O_IS_KBD_PS2) { + //for(u8 key = 0xe0; key <= 0xe7; key++) { + // kb_send_key(key, false, 0); + //} + + //for(u8 key = 4; key <= 116; key++) { + // kb_send_key(key, false, 0); + //} + } + + if (PH_O_IS_MOUSE_PS2) { + ms_send_movement(0, 0, 0, 0); + } +} diff --git a/hid/pico/src/ph_ps2.h b/hid/pico/src/ph_ps2.h new file mode 100644 index 000000000..69f558660 --- /dev/null +++ b/hid/pico/src/ph_ps2.h @@ -0,0 +1,49 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#pragma once + +#include "ph_types.h" + + +extern u8 ph_g_ps2_kbd_leds; +extern bool ph_g_ps2_kbd_online; +extern bool ph_g_ps2_mouse_online; + + +void ph_ps2_init(void); +void ph_ps2_task(void); + +void tuh_kb_set_leds(u8 leds); +void kb_init(u8 gpio_out, u8 gpio_in); +bool kb_task(); +void kb_send_key(u8 key, bool state, u8 modifiers); +void ph_ps2_kbd_send_key(u8 key, bool state); + +void ms_init(u8 gpio_out, u8 gpio_in); +bool ms_task(); +void ms_send_movement(u8 buttons, s8 x, s8 y, s8 z); +void ph_ps2_mouse_send_button(u8 button, bool state); +void ph_ps2_mouse_send_rel(s8 x, s8 y); +void ph_ps2_mouse_send_wheel(s8 h, s8 v); + +void ph_ps2_send_clear(void); diff --git a/hid/pico/src/ph_tools.h b/hid/pico/src/ph_tools.h new file mode 100644 index 000000000..489302c43 --- /dev/null +++ b/hid/pico/src/ph_tools.h @@ -0,0 +1,57 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + + +inline u16 ph_crc16(const u8 *buf, uz len) { + const u16 polinom = 0xA001; + u16 crc = 0xFFFF; + + for (uz byte_count = 0; byte_count < len; ++byte_count) { + crc = crc ^ buf[byte_count]; + for (uz bit_count = 0; bit_count < 8; ++bit_count) { + if ((crc & 0x0001) == 0) { + crc = crc >> 1; + } else { + crc = crc >> 1; + crc = crc ^ polinom; + } + } + } + return crc; +} + +inline s16 ph_merge8_s16(u8 a, u8 b) { + return (((int)a << 8) | (int)b); +} + +inline u16 ph_merge8_u16(u8 a, u8 b) { + return (((u16)a << 8) | (u16)b); +} + +inline void ph_split16(u16 from, u8 *to_a, u8 *to_b) { + *to_a = (u8)(from >> 8); + *to_b = (u8)(from & 0xFF); +} diff --git a/hid/pico/src/ph_types.h b/hid/pico/src/ph_types.h new file mode 100644 index 000000000..97b89e907 --- /dev/null +++ b/hid/pico/src/ph_types.h @@ -0,0 +1,38 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#pragma once + +#include +#include +#include + + +typedef int8_t s8; +typedef int16_t s16; +typedef int32_t s32; + +typedef size_t uz; +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef uint64_t u64; diff --git a/hid/pico/src/ph_usb.c b/hid/pico/src/ph_usb.c new file mode 100644 index 000000000..b56e2cc58 --- /dev/null +++ b/hid/pico/src/ph_usb.c @@ -0,0 +1,437 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#include "ph_usb.h" + +#include +#include + +#include "pico/stdlib.h" +#include "pico/unique_id.h" + +#include "tusb.h" +#if TUD_OPT_HIGH_SPEED +# error "High-Speed is not supported" +#endif + +#include "ph_types.h" +#include "ph_outputs.h" +#include "ph_usb_kbd.h" +#include "ph_usb_mouse.h" + + +u8 ph_g_usb_kbd_leds = 0; +bool ph_g_usb_kbd_online = true; +bool ph_g_usb_mouse_online = true; + +static int _kbd_iface = -1; +static int _mouse_iface = -1; + +static u8 _kbd_mods = 0; +static u8 _kbd_keys[6] = {0}; +#define _KBD_CLEAR { _kbd_mods = 0; memset(_kbd_keys, 0, 6); } + +static u8 _mouse_buttons = 0; +static s16 _mouse_abs_x = 0; +static s16 _mouse_abs_y = 0; +#define _MOUSE_CLEAR { _mouse_buttons = 0; } + + +static void _kbd_sync_report(bool new); +static void _mouse_abs_send_report(s8 h, s8 v); +static void _mouse_rel_send_report(s8 x, s8 y, s8 h, s8 v); + + +void ph_usb_init(void) { + if (ph_g_is_bridge || PH_O_IS_KBD_USB || PH_O_IS_MOUSE_USB) { + tud_init(0); + } +} + +void ph_usb_task(void) { + if (ph_g_is_bridge || PH_O_IS_KBD_USB || PH_O_IS_MOUSE_USB) { + tud_task(); + + static u64 next_ts = 0; + const u64 now_ts = time_us_64(); + if (next_ts == 0 || now_ts >= next_ts) { +# define CHECK_IFACE(x_dev) \ + static u64 offline_ts = 0; \ + static bool prev_online = true; \ + const bool online = (tud_ready() && tud_hid_n_ready(_##x_dev##_iface)); \ + bool force = false; \ + if (online) { \ + if (!ph_g_usb_##x_dev##_online) { \ + force = true; /* Если был переход из долгого оффлайна в онлайн */ \ + } \ + ph_g_usb_##x_dev##_online = true; \ + offline_ts = 0; \ + } else if (prev_online && !online) { \ + offline_ts = now_ts; /* Начинаем отсчет для долгого оффлайна */ \ + } else if (!prev_online && !online && offline_ts + 50000 < now_ts) { \ + ph_g_usb_##x_dev##_online = false; /* Долгий оффлайн найден */ \ + } \ + prev_online = online; + + if (_kbd_iface >= 0) { + CHECK_IFACE(kbd); + _kbd_sync_report(force); + } + + if (_mouse_iface >= 0) { + CHECK_IFACE(mouse); + (void)force; + } + +# undef CHECK_IFACE + next_ts = time_us_64() + 1000; // Every 1 ms + } + } +} + +void ph_usb_kbd_send_key(u8 key, bool state) { + if (_kbd_iface < 0) { + return; // Допускаем планирование нажатия, пока устройство не готово + } + + if (key >= HID_KEY_CONTROL_LEFT && key <= HID_KEY_GUI_RIGHT) { // 0xE0...0xE7 - Modifiers + key = 1 << (key & 0x07); // Номер означает сдвиг + if (state) { + _kbd_mods |= key; + } else { + _kbd_mods &= ~key; + } + + } else { // Regular keys + if (state) { + s8 pos = -1; + for (u8 i = 0; i < 6; ++i) { + if (_kbd_keys[i] == key) { + goto already_pressed; + } else if (_kbd_keys[i] == 0) { + pos = i; + } + } + _kbd_keys[pos >= 0 ? pos : 0] = key; + // already_pressed: + } else { + for (u8 i = 0; i < 6; ++i) { + if (_kbd_keys[i] == key) { + _kbd_keys[i] = 0; + break; + } + } + } + } + already_pressed: // Old GCC doesn't like ^ that label in the end of block + + _kbd_sync_report(true); +} + +void ph_usb_mouse_send_button(u8 button, bool state) { + if (!PH_O_IS_MOUSE_USB) { + return; + } + if (state) { + _mouse_buttons |= button; + } else { + _mouse_buttons &= ~button; + } + if (PH_O_IS_MOUSE_USB_ABS) { + _mouse_abs_send_report(0, 0); + } else { // PH_O_IS_MOUSE_USB_REL + _mouse_rel_send_report(0, 0, 0, 0); + } +} + +void ph_usb_mouse_send_abs(s16 x, s16 y) { + if (PH_O_IS_MOUSE_USB_ABS) { + _mouse_abs_x = x; + _mouse_abs_y = y; + _mouse_abs_send_report(0, 0); + } +} + +void ph_usb_mouse_send_rel(s8 x, s8 y) { + if (PH_O_IS_MOUSE_USB_REL) { + _mouse_rel_send_report(x, y, 0, 0); + } +} + +void ph_usb_mouse_send_wheel(s8 h, s8 v) { + if (PH_O_IS_MOUSE_USB_ABS) { + _mouse_abs_send_report(h, v); + } else { // PH_O_IS_MOUSE_USB_REL + _mouse_rel_send_report(0, 0, h, v); + } +} + +void ph_usb_send_clear(void) { + if (PH_O_IS_KBD_USB) { + _KBD_CLEAR; + _kbd_sync_report(true); + } + if (PH_O_IS_MOUSE_USB) { + _MOUSE_CLEAR; + if (PH_O_IS_MOUSE_USB_ABS) { + _mouse_abs_send_report(_mouse_abs_x, _mouse_abs_y); + } else { // PH_O_IS_MOUSE_USB_REL + _mouse_rel_send_report(0, 0, 0, 0); + } + } +} + +//-------------------------------------------------------------------- +// RAW report senders +//-------------------------------------------------------------------- + +static void _kbd_sync_report(bool new) { + static bool sent = true; + if (_kbd_iface < 0 || !PH_O_IS_KBD_USB) { + _KBD_CLEAR; + sent = true; + return; + } + if (new) { + sent = false; + } + if (!sent) { + if (tud_suspended()) { + tud_remote_wakeup(); + //_KBD_CLEAR; + //sent = true; + } else { + sent = tud_hid_n_keyboard_report(_kbd_iface, 0, _kbd_mods, _kbd_keys); + } + } +} + +#define _CHECK_MOUSE(x_mode) { \ + if (_mouse_iface < 0 || !PH_O_IS_MOUSE_USB_##x_mode) { _MOUSE_CLEAR; return; } \ + if (tud_suspended()) { tud_remote_wakeup(); _MOUSE_CLEAR; return; } \ + } + + +static void _mouse_abs_send_report(s8 h, s8 v) { + (void)h; // Horizontal scrolling is not supported due BIOS/UEFI compatibility reasons + _CHECK_MOUSE(ABS); + u16 x = ((s32)_mouse_abs_x + 32768) / 2; + u16 y = ((s32)_mouse_abs_y + 32768) / 2; + if (PH_O_MOUSE(USB_W98)) { + x <<= 1; + y <<= 1; + } + struct TU_ATTR_PACKED { + u8 buttons; + u16 x; + u16 y; + s8 v; + } report = {_mouse_buttons, x, y, v}; + tud_hid_n_report(_mouse_iface, 0, &report, sizeof(report)); +} + +static void _mouse_rel_send_report(s8 x, s8 y, s8 h, s8 v) { + (void)h; // Horizontal scrolling is not supported due BIOS/UEFI compatibility reasons + _CHECK_MOUSE(REL); + struct TU_ATTR_PACKED { + u8 buttons; + s8 x; + s8 y; + s8 v; + } report = {_mouse_buttons, x, y, v}; + tud_hid_n_report(_mouse_iface, 0, &report, sizeof(report)); +} + +#undef _CHECK_MOUSE + + +//-------------------------------------------------------------------- +// Device callbacks +//-------------------------------------------------------------------- + +u16 tud_hid_get_report_cb(u8 iface, u8 report_id, hid_report_type_t report_type, u8 *buf, u16 len) { + // Invoked when received GET_REPORT control request, return 0 == STALL + (void)iface; + (void)report_id; + (void)report_type; + (void)buf; + (void)len; + return 0; +} + +void tud_hid_set_report_cb(u8 iface, u8 report_id, hid_report_type_t report_type, const u8 *buf, u16 len) { + // Invoked when received SET_REPORT control request + // or received data on OUT endpoint (ReportID=0, Type=0) + (void)report_id; + if (iface == _kbd_iface && report_type == HID_REPORT_TYPE_OUTPUT && len >= 1) { + ph_g_usb_kbd_leds = buf[0]; + } +} + +const u8 *tud_hid_descriptor_report_cb(u8 iface) { + if ((int)iface == _mouse_iface) { + if (PH_O_IS_MOUSE_USB_ABS) { + return PH_USB_MOUSE_ABS_DESC; + } else { // PH_O_IS_MOUSE_USB_REL + return PH_USB_MOUSE_REL_DESC; + } + } + return PH_USB_KBD_DESC; // _kbd_iface, PH_O_IS_KBD_USB +} + +const u8 *_bridge_tud_descriptor_configuration_cb(void) { + enum {num_cdc = 0, num_cdc_data, num_total}; + static const u8 desc[] = { + TUD_CONFIG_DESCRIPTOR( + 1, // Config number + num_total,// Interface count + 0, // String index + (TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN), // Total length + 0, // Attribute + 100 // Power in mA + ), + TUD_CDC_DESCRIPTOR( + num_cdc,// Interface number + 4, // String index + 0x81, // EPNUM_CDC_NOTIF - EP notification address + 8, // EP notification size + 0x02, // EPNUM_CDC_OUT - EP OUT data address + 0x82, // EPNUM_CDC_IN - EP IN data address + 64 // EP size + ), + }; + return desc; +} + +const u8 *_hid_tud_descriptor_configuration_cb(void) { + static u8 desc[TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN * 2] = {0}; + static bool filled = false; + + if (!filled) { + uz offset = TUD_CONFIG_DESC_LEN; + u8 iface = 0; + u8 ep = 0x81; + +# define APPEND_DESC(x_proto, x_desc, x_iface_to) { \ + const u8 part[] = {TUD_HID_DESCRIPTOR( \ + (x_iface_to = iface), /* Interface number */ \ + 0, x_proto, x_desc##_LEN, /* String index, protocol, report descriptor len */ \ + ep, CFG_TUD_HID_EP_BUFSIZE, 1)}; /* EP In address, size, polling interval */ \ + memcpy(desc + offset, part, TUD_HID_DESC_LEN); \ + offset += TUD_HID_DESC_LEN; ++iface; ++ep; \ + } + + if (PH_O_IS_KBD_USB) { + APPEND_DESC(HID_ITF_PROTOCOL_KEYBOARD, PH_USB_KBD_DESC, _kbd_iface); + } + if (PH_O_IS_MOUSE_USB_ABS) { + APPEND_DESC(HID_ITF_PROTOCOL_NONE, PH_USB_MOUSE_ABS_DESC, _mouse_iface); + } else if (PH_O_IS_MOUSE_USB_REL) { + APPEND_DESC(HID_ITF_PROTOCOL_MOUSE, PH_USB_MOUSE_REL_DESC, _mouse_iface); + } + +# undef APPEND_DESC + + // Config number, interface count, string index, total length, attribute, power in mA + const u8 part[] = {TUD_CONFIG_DESCRIPTOR(1, iface, 0, offset, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100)}; + memcpy(desc, part, TUD_CONFIG_DESC_LEN); + filled = true; + } + return desc; +} + +const u8 *tud_descriptor_configuration_cb(u8 index) { + // Invoked when received GET CONFIGURATION DESCRIPTOR + (void)index; + if (ph_g_is_bridge) { + return _bridge_tud_descriptor_configuration_cb(); + } + return _hid_tud_descriptor_configuration_cb(); +} + +const u8 *tud_descriptor_device_cb(void) { + // Invoked when received GET DEVICE DESCRIPTOR + static tusb_desc_device_t desc = { + .bLength = sizeof(tusb_desc_device_t), + .bDescriptorType = TUSB_DESC_DEVICE, + .bcdUSB = 0x0200, + + .bDeviceClass = 0, + .bDeviceSubClass = 0, + .bDeviceProtocol = 0, + + .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE, + + .idVendor = 0x1209, // https://pid.codes/org/Pi-KVM + .idProduct = 0xEDA2, + .bcdDevice = 0x0100, + + .iManufacturer = 1, + .iProduct = 2, + .iSerialNumber = 3, + + .bNumConfigurations = 1, + }; + if (ph_g_is_bridge) { + desc.bDeviceClass = TUSB_CLASS_MISC; + desc.bDeviceSubClass = MISC_SUBCLASS_COMMON; + desc.bDeviceProtocol = MISC_PROTOCOL_IAD; + desc.idProduct = 0xEDA3; + } + return (const u8 *)&desc; +} + +const u16 *tud_descriptor_string_cb(u8 index, u16 lang_id) { + // Invoked when received GET STRING DESCRIPTOR request. + (void)lang_id; + + static u16 desc_str[32]; + uz desc_str_len; + + if (index == 0) { + desc_str[1] = 0x0409; // Supported language is English (0x0409) + desc_str_len = 1; + } else { + char str[32]; + switch (index) { + case 1: strcpy(str, "PiKVM"); break; // Manufacturer + case 2: strcpy(str, (ph_g_is_bridge ? "PiKVM HID Bridge" : "PiKVM HID")); break; // Product + case 3: pico_get_unique_board_id_string(str, 32); break; // Serial + case 4: { + if (ph_g_is_bridge) { + strcpy(str, "PiKVM HID Bridge CDC"); + } else { + return NULL; + } + }; break; + default: return NULL; + } + desc_str_len = strlen(str); + for (uz i = 0; i < desc_str_len; ++i) { + desc_str[i + 1] = str[i]; // Convert ASCII string into UTF-16 + } + } + + // First byte is length (including header), second byte is string type + desc_str[0] = (TUSB_DESC_STRING << 8) | (2 * desc_str_len + 2); + return desc_str; +} diff --git a/hid/pico/src/ph_usb.h b/hid/pico/src/ph_usb.h new file mode 100644 index 000000000..357f578d6 --- /dev/null +++ b/hid/pico/src/ph_usb.h @@ -0,0 +1,43 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#pragma once + +#include "ph_types.h" + + +extern u8 ph_g_usb_kbd_leds; +extern bool ph_g_usb_kbd_online; +extern bool ph_g_usb_mouse_online; + + +void ph_usb_init(void); +void ph_usb_task(void); + +void ph_usb_kbd_send_key(u8 key, bool state); + +void ph_usb_mouse_send_button(u8 button, bool state); +void ph_usb_mouse_send_abs(s16 x, s16 y); +void ph_usb_mouse_send_rel(s8 x, s8 y); +void ph_usb_mouse_send_wheel(s8 h, s8 v); + +void ph_usb_send_clear(void); diff --git a/hid/pico/src/ph_usb_kbd.c b/hid/pico/src/ph_usb_kbd.c new file mode 100644 index 000000000..b4613c279 --- /dev/null +++ b/hid/pico/src/ph_usb_kbd.c @@ -0,0 +1,78 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#include "ph_usb_kbd.h" + +#include "ph_types.h" + + +const u8 PH_USB_KBD_DESC[] = { + // 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) + + // 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 +}; + +const uz PH_USB_KBD_DESC_LEN = sizeof(PH_USB_KBD_DESC); diff --git a/hid/pico/src/ph_usb_kbd.h b/hid/pico/src/ph_usb_kbd.h new file mode 100644 index 000000000..8bbdcd05e --- /dev/null +++ b/hid/pico/src/ph_usb_kbd.h @@ -0,0 +1,29 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#pragma once + +#include "ph_types.h" + + +extern const u8 PH_USB_KBD_DESC[]; +extern const uz PH_USB_KBD_DESC_LEN; diff --git a/hid/pico/src/ph_usb_keymap.h b/hid/pico/src/ph_usb_keymap.h new file mode 100644 index 000000000..c7d98ef83 --- /dev/null +++ b/hid/pico/src/ph_usb_keymap.h @@ -0,0 +1,147 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + + +inline u8 ph_usb_keymap(u8 key) { + switch (key) { + case 1: return 4; // KeyA + case 2: return 5; // KeyB + case 3: return 6; // KeyC + case 4: return 7; // KeyD + case 5: return 8; // KeyE + case 6: return 9; // KeyF + case 7: return 10; // KeyG + case 8: return 11; // KeyH + case 9: return 12; // KeyI + case 10: return 13; // KeyJ + case 11: return 14; // KeyK + case 12: return 15; // KeyL + case 13: return 16; // KeyM + case 14: return 17; // KeyN + case 15: return 18; // KeyO + case 16: return 19; // KeyP + case 17: return 20; // KeyQ + case 18: return 21; // KeyR + case 19: return 22; // KeyS + case 20: return 23; // KeyT + case 21: return 24; // KeyU + case 22: return 25; // KeyV + case 23: return 26; // KeyW + case 24: return 27; // KeyX + case 25: return 28; // KeyY + case 26: return 29; // KeyZ + case 27: return 30; // Digit1 + case 28: return 31; // Digit2 + case 29: return 32; // Digit3 + case 30: return 33; // Digit4 + case 31: return 34; // Digit5 + case 32: return 35; // Digit6 + case 33: return 36; // Digit7 + case 34: return 37; // Digit8 + case 35: return 38; // Digit9 + case 36: return 39; // Digit0 + case 37: return 40; // Enter + case 38: return 41; // Escape + case 39: return 42; // Backspace + case 40: return 43; // Tab + case 41: return 44; // Space + case 42: return 45; // Minus + case 43: return 46; // Equal + case 44: return 47; // BracketLeft + case 45: return 48; // BracketRight + case 46: return 49; // Backslash + case 47: return 51; // Semicolon + case 48: return 52; // Quote + case 49: return 53; // Backquote + case 50: return 54; // Comma + case 51: return 55; // Period + case 52: return 56; // Slash + case 53: return 57; // CapsLock + case 54: return 58; // F1 + case 55: return 59; // F2 + case 56: return 60; // F3 + case 57: return 61; // F4 + case 58: return 62; // F5 + case 59: return 63; // F6 + case 60: return 64; // F7 + case 61: return 65; // F8 + case 62: return 66; // F9 + case 63: return 67; // F10 + case 64: return 68; // F11 + case 65: return 69; // F12 + case 66: return 70; // PrintScreen + case 67: return 73; // Insert + case 68: return 74; // Home + case 69: return 75; // PageUp + case 70: return 76; // Delete + case 71: return 77; // End + case 72: return 78; // PageDown + case 73: return 79; // ArrowRight + case 74: return 80; // ArrowLeft + case 75: return 81; // ArrowDown + case 76: return 82; // ArrowUp + case 77: return 224; // ControlLeft + case 78: return 225; // ShiftLeft + case 79: return 226; // AltLeft + case 80: return 227; // MetaLeft + case 81: return 228; // ControlRight + case 82: return 229; // ShiftRight + case 83: return 230; // AltRight + case 84: return 231; // MetaRight + case 85: return 72; // Pause + case 86: return 71; // ScrollLock + case 87: return 83; // NumLock + case 88: return 101; // ContextMenu + case 89: return 84; // NumpadDivide + case 90: return 85; // NumpadMultiply + case 91: return 86; // NumpadSubtract + case 92: return 87; // NumpadAdd + case 93: return 88; // NumpadEnter + case 94: return 89; // Numpad1 + case 95: return 90; // Numpad2 + case 96: return 91; // Numpad3 + case 97: return 92; // Numpad4 + case 98: return 93; // Numpad5 + case 99: return 94; // Numpad6 + case 100: return 95; // Numpad7 + case 101: return 96; // Numpad8 + case 102: return 97; // Numpad9 + case 103: return 98; // Numpad0 + case 104: return 99; // NumpadDecimal + case 105: return 102; // Power + case 106: return 100; // IntlBackslash + case 107: return 137; // IntlYen + case 108: return 135; // IntlRo + case 109: return 136; // KanaMode + case 110: return 138; // Convert + case 111: return 139; // NonConvert + case 112: return 127; // AudioVolumeMute + case 113: return 128; // AudioVolumeUp + case 114: return 129; // AudioVolumeDown + case 115: return 111; // F20 + } + return 0; +} diff --git a/hid/pico/src/ph_usb_keymap.h.mako b/hid/pico/src/ph_usb_keymap.h.mako new file mode 100644 index 000000000..5a1f30382 --- /dev/null +++ b/hid/pico/src/ph_usb_keymap.h.mako @@ -0,0 +1,39 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +*****************************************************************************/ + + +#pragma once + +#include "ph_types.h" + +<%! import operator %> +inline u8 ph_usb_keymap(u8 key) { + switch (key) { +% for km in sorted(keymap, key=operator.attrgetter("mcu_code")): + % if km.usb_key.is_mod: + case ${km.mcu_code}: return ${km.usb_key.arduino_mod_code}; // ${km.web_name} + % else: + case ${km.mcu_code}: return ${km.usb_key.code}; // ${km.web_name} + % endif +% endfor + } + return 0; +} diff --git a/hid/pico/src/ph_usb_mouse.c b/hid/pico/src/ph_usb_mouse.c new file mode 100644 index 000000000..33f354107 --- /dev/null +++ b/hid/pico/src/ph_usb_mouse.c @@ -0,0 +1,120 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#include "ph_usb_mouse.h" + +#include "ph_types.h" + + +const u8 PH_USB_MOUSE_ABS_DESC[] = { + // 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) + + // 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) + + // End + 0xC0, // END_COLLECTION (Physical) + 0xC0, // END_COLLECTION +}; + +const uz PH_USB_MOUSE_ABS_DESC_LEN = sizeof(PH_USB_MOUSE_ABS_DESC); + +const u8 PH_USB_MOUSE_REL_DESC[] = { + // 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) + + // 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) + + // End + 0xC0, // END_COLLECTION (Physical) + 0xC0, // END_COLLECTION +}; + +const uz PH_USB_MOUSE_REL_DESC_LEN = sizeof(PH_USB_MOUSE_REL_DESC); diff --git a/hid/pico/src/ph_usb_mouse.h b/hid/pico/src/ph_usb_mouse.h new file mode 100644 index 000000000..1d13d73db --- /dev/null +++ b/hid/pico/src/ph_usb_mouse.h @@ -0,0 +1,32 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#pragma once + +#include "ph_types.h" + + +extern const u8 PH_USB_MOUSE_ABS_DESC[]; +extern const uz PH_USB_MOUSE_ABS_DESC_LEN; + +extern const u8 PH_USB_MOUSE_REL_DESC[]; +extern const uz PH_USB_MOUSE_REL_DESC_LEN; diff --git a/hid/pico/src/tusb_config.h b/hid/pico/src/tusb_config.h new file mode 100644 index 000000000..a3986ee19 --- /dev/null +++ b/hid/pico/src/tusb_config.h @@ -0,0 +1,79 @@ +/* ========================================================================= # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================= */ + + +#pragma once + + +//-------------------------------------------------------------------- +// Common config +//-------------------------------------------------------------------- + +//#define CFG_TUSB_DEBUG 100 + +#define CFG_TUSB_OS OPT_OS_PICO + +// Enable device stack +#define CFG_TUD_ENABLED 1 + +// CFG_TUSB_DEBUG is defined by compiler in DEBUG build +//#define CFG_TUSB_DEBUG 100 + +// USB DMA on some MCUs can only access a specific SRAM region with restriction on alignment. +// Tinyusb use follows macros to declare transferring memory so that they can be put +// into those specific section. +// - CFG_TUSB_MEM SECTION : __attribute__ (( section(".usb_ram") )) +// - CFG_TUSB_MEM_ALIGN : __attribute__ ((aligned(4))) +#ifndef CFG_TUSB_MEM_SECTION +# define CFG_TUSB_MEM_SECTION +#endif + +#ifndef CFG_TUSB_MEM_ALIGN +# define CFG_TUSB_MEM_ALIGN __attribute__((aligned(4))) +#endif + + +//-------------------------------------------------------------------- +// Device config +//-------------------------------------------------------------------- + +#ifndef CFG_TUD_ENDPOINT0_SIZE +# define CFG_TUD_ENDPOINT0_SIZE 64 +#endif + +// HID: Keyboard + Mouse +#define CFG_TUD_HID 2 + +// HID buffer size Should be sufficient to hold ID (if any) + Data +#ifndef CFG_TUD_HID_EP_BUFSIZE +# define CFG_TUD_HID_EP_BUFSIZE 16 +#endif + + +// CDC for the bridge mode +#define CFG_TUD_CDC 1 + +// CDC FIFO size of TX and RX +#define CFG_TUD_CDC_RX_BUFSIZE 4096 +#define CFG_TUD_CDC_TX_BUFSIZE 4096 + +// CDC Endpoint transfer buffer size, more is faster +#define CFG_TUD_CDC_EP_BUFSIZE 64 diff --git a/keymap.csv b/keymap.csv index daf33c245..93454eb7f 100644 --- a/keymap.csv +++ b/keymap.csv @@ -1,112 +1,116 @@ -web_name,mcu_code,usb_key,ps2_key,at1_code,x11_names -KeyA,1,0x04,reg:0x1c,0x1e,"^XK_A,XK_a" -KeyB,2,0x05,reg:0x32,0x30,"^XK_B,XK_b" -KeyC,3,0x06,reg:0x21,0x2e,"^XK_C,XK_c" -KeyD,4,0x07,reg:0x23,0x20,"^XK_D,XK_d" -KeyE,5,0x08,reg:0x24,0x12,"^XK_E,XK_e" -KeyF,6,0x09,reg:0x2b,0x21,"^XK_F,XK_f" -KeyG,7,0x0a,reg:0x34,0x22,"^XK_G,XK_g" -KeyH,8,0x0b,reg:0x33,0x23,"^XK_H,XK_h" -KeyI,9,0x0c,reg:0x43,0x17,"^XK_I,XK_i" -KeyJ,10,0x0d,reg:0x3b,0x24,"^XK_J,XK_j" -KeyK,11,0x0e,reg:0x42,0x25,"^XK_K,XK_k" -KeyL,12,0x0f,reg:0x4b,0x26,"^XK_L,XK_l" -KeyM,13,0x10,reg:0x3a,0x32,"^XK_M,XK_m" -KeyN,14,0x11,reg:0x31,0x31,"^XK_N,XK_n" -KeyO,15,0x12,reg:0x44,0x18,"^XK_O,XK_o" -KeyP,16,0x13,reg:0x4d,0x19,"^XK_P,XK_p" -KeyQ,17,0x14,reg:0x15,0x10,"^XK_Q,XK_q" -KeyR,18,0x15,reg:0x2d,0x13,"^XK_R,XK_r" -KeyS,19,0x16,reg:0x1b,0x1f,"^XK_S,XK_s" -KeyT,20,0x17,reg:0x2c,0x14,"^XK_T,XK_t" -KeyU,21,0x18,reg:0x3c,0x16,"^XK_U,XK_u" -KeyV,22,0x19,reg:0x2a,0x2f,"^XK_V,XK_v" -KeyW,23,0x1a,reg:0x1d,0x11,"^XK_W,XK_w" -KeyX,24,0x1b,reg:0x22,0x2d,"^XK_X,XK_x" -KeyY,25,0x1c,reg:0x35,0x15,"^XK_Y,XK_y" -KeyZ,26,0x1d,reg:0x1a,0x2c,"^XK_Z,XK_z" -Digit1,27,0x1e,reg:0x16,0x02,"XK_1,^XK_exclam" -Digit2,28,0x1f,reg:0x1e,0x03,"XK_2,^XK_at" -Digit3,29,0x20,reg:0x26,0x04,"XK_3,^XK_numbersign" -Digit4,30,0x21,reg:0x25,0x05,"XK_4,^XK_dollar" -Digit5,31,0x22,reg:0x2e,0x06,"XK_5,^XK_percent" -Digit6,32,0x23,reg:0x36,0x07,"XK_6,^XK_asciicircum" -Digit7,33,0x24,reg:0x3d,0x08,"XK_7,^XK_ampersand" -Digit8,34,0x25,reg:0x3e,0x09,"XK_8,^XK_asterisk" -Digit9,35,0x26,reg:0x46,0x0a,"XK_9,^XK_parenleft" -Digit0,36,0x27,reg:0x45,0x0b,"XK_0,^XK_parenright" -Enter,37,0x28,reg:0x5a,0x1c,XK_Return -Escape,38,0x29,reg:0x76,0x01,XK_Escape -Backspace,39,0x2a,reg:0x66,0x0e,XK_BackSpace -Tab,40,0x2b,reg:0x0d,0x0f,XK_Tab -Space,41,0x2c,reg:0x29,0x39,XK_space -Minus,42,0x2d,reg:0x4e,0x0c,"XK_minus,^XK_underscore" -Equal,43,0x2e,reg:0x55,0x0d,"XK_equal,^XK_plus" -BracketLeft,44,0x2f,reg:0x54,0x1a,"XK_bracketleft,^XK_braceleft" -BracketRight,45,0x30,reg:0x5b,0x1b,"XK_bracketright,^XK_braceright" -Backslash,46,0x31,reg:0x5d,0x2b,"XK_backslash,^XK_bar" -Semicolon,47,0x33,reg:0x4c,0x27,"XK_semicolon,^XK_colon" -Quote,48,0x34,reg:0x52,0x28,"XK_apostrophe,^XK_quotedbl" -Backquote,49,0x35,reg:0x0e,0x29,"XK_grave,^XK_asciitilde" -Comma,50,0x36,reg:0x41,0x33,"XK_comma,^XK_less" -Period,51,0x37,reg:0x49,0x34,"XK_period,^XK_greater" -Slash,52,0x38,reg:0x4a,0x35,"XK_slash,^XK_question" -CapsLock,53,0x39,reg:0x58,0x3a,XK_Caps_Lock -F1,54,0x3a,reg:0x05,0x3b,XK_F1 -F2,55,0x3b,reg:0x06,0x3c,XK_F2 -F3,56,0x3c,reg:0x04,0x3d,XK_F3 -F4,57,0x3d,reg:0x0c,0x3e,XK_F4 -F5,58,0x3e,reg:0x03,0x3f,XK_F5 -F6,59,0x3f,reg:0x0b,0x40,XK_F6 -F7,60,0x40,reg:0x83,0x41,XK_F7 -F8,61,0x41,reg:0x0a,0x42,XK_F8 -F9,62,0x42,reg:0x01,0x43,XK_F9 -F10,63,0x43,reg:0x09,0x44,XK_F10 -F11,64,0x44,reg:0x78,0x57,XK_F11 -F12,65,0x45,reg:0x07,0x58,XK_F12 -PrintScreen,66,0x46,print:0xff,0x54,XK_Sys_Req -Insert,67,0x49,spec:0x70,0xe052,XK_Insert -Home,68,0x4a,spec:0x6c,0xe047,XK_Home -PageUp,69,0x4b,spec:0x7d,0xe049,XK_Page_Up -Delete,70,0x4c,spec:0x71,0xe053,XK_Delete -End,71,0x4d,spec:0x69,0xe04f,XK_End -PageDown,72,0x4e,spec:0x7a,0xe051,XK_Page_Down -ArrowRight,73,0x4f,spec:0x74,0xe04d,XK_Right -ArrowLeft,74,0x50,spec:0x6b,0xe04b,XK_Left -ArrowDown,75,0x51,spec:0x72,0xe050,XK_Down -ArrowUp,76,0x52,spec:0x75,0xe048,XK_Up -ControlLeft,77,^0x01,reg:0x14,0x1d,XK_Control_L -ShiftLeft,78,^0x02,reg:0x12,0x2a,XK_Shift_L -AltLeft,79,^0x04,reg:0x11,0x38,XK_Alt_L -MetaLeft,80,^0x08,spec:0x1f,0xe05b,"XK_Meta_L,XK_Super_L" -ControlRight,81,^0x10,spec:0x14,0xe01d,XK_Control_R -ShiftRight,82,^0x20,reg:0x59,0x36,XK_Shift_R -AltRight,83,^0x40,spec:0x11,0xe038,"XK_Alt_R,XK_ISO_Level3_Shift" -MetaRight,84,^0x80,spec:0x27,0xe05c,"XK_Meta_R,XK_Super_R" -Pause,85,0x48,pause:0xff,0xe046,XK_Pause -ScrollLock,86,0x47,reg:0x7e,0x46,XK_Scroll_Lock -NumLock,87,0x53,reg:0x77,0x45,XK_Num_Lock -ContextMenu,88,0x65,spec:0x2f,0xe05d,XK_Menu -NumpadDivide,89,0x54,spec:0x4a,0xe035,XK_KP_Divide -NumpadMultiply,90,0x55,reg:0x7c,0x37,XK_multiply -NumpadSubtract,91,0x56,reg:0x7b,0x4a,XK_KP_Subtract -NumpadAdd,92,0x57,reg:0x79,0x4e,XK_KP_Add -NumpadEnter,93,0x58,spec:0x5a,0xe01c,XK_KP_Enter -Numpad1,94,0x59,reg:0x69,0x4f,XK_KP_1 -Numpad2,95,0x5a,reg:0x72,0x50,XK_KP_2 -Numpad3,96,0x5b,reg:0x7a,0x51,XK_KP_3 -Numpad4,97,0x5c,reg:0x6b,0x4b,XK_KP_4 -Numpad5,98,0x5d,reg:0x73,0x4c,XK_KP_5 -Numpad6,99,0x5e,reg:0x74,0x4d,XK_KP_6 -Numpad7,100,0x5f,reg:0x6c,0x47,XK_KP_7 -Numpad8,101,0x60,reg:0x75,0x48,XK_KP_8 -Numpad9,102,0x61,reg:0x7d,0x49,XK_KP_9 -Numpad0,103,0x62,reg:0x70,0x52,XK_KP_0 -NumpadDecimal,104,0x63,reg:0x71,0x53,XK_KP_Decimal -Power,105,0x66,spec:0x5e,0xe05e,XK_XF86_Sleep -IntlBackslash,106,0x64,reg:0x61,0x56,"" -IntlYen,107,0x89,reg:0x6a,0x7d,"" -IntlRo,108,0x87,reg:0x51,0x73,"" -KanaMode,109,0x88,reg:0x13,0x70,"" -Convert,110,0x8a,reg:0x64,0x79,"" -NonConvert,111,0x8b,reg:0x67,0x7b,"" +web_name,evdev_name,mcu_code,usb_key,ps2_key,at1_code,x11_names +KeyA,KEY_A,1,0x04,reg:0x1c,0x1e,"^XK_A,XK_a" +KeyB,KEY_B,2,0x05,reg:0x32,0x30,"^XK_B,XK_b" +KeyC,KEY_C,3,0x06,reg:0x21,0x2e,"^XK_C,XK_c" +KeyD,KEY_D,4,0x07,reg:0x23,0x20,"^XK_D,XK_d" +KeyE,KEY_E,5,0x08,reg:0x24,0x12,"^XK_E,XK_e" +KeyF,KEY_F,6,0x09,reg:0x2b,0x21,"^XK_F,XK_f" +KeyG,KEY_G,7,0x0a,reg:0x34,0x22,"^XK_G,XK_g" +KeyH,KEY_H,8,0x0b,reg:0x33,0x23,"^XK_H,XK_h" +KeyI,KEY_I,9,0x0c,reg:0x43,0x17,"^XK_I,XK_i" +KeyJ,KEY_J,10,0x0d,reg:0x3b,0x24,"^XK_J,XK_j" +KeyK,KEY_K,11,0x0e,reg:0x42,0x25,"^XK_K,XK_k" +KeyL,KEY_L,12,0x0f,reg:0x4b,0x26,"^XK_L,XK_l" +KeyM,KEY_M,13,0x10,reg:0x3a,0x32,"^XK_M,XK_m" +KeyN,KEY_N,14,0x11,reg:0x31,0x31,"^XK_N,XK_n" +KeyO,KEY_O,15,0x12,reg:0x44,0x18,"^XK_O,XK_o" +KeyP,KEY_P,16,0x13,reg:0x4d,0x19,"^XK_P,XK_p" +KeyQ,KEY_Q,17,0x14,reg:0x15,0x10,"^XK_Q,XK_q" +KeyR,KEY_R,18,0x15,reg:0x2d,0x13,"^XK_R,XK_r" +KeyS,KEY_S,19,0x16,reg:0x1b,0x1f,"^XK_S,XK_s" +KeyT,KEY_T,20,0x17,reg:0x2c,0x14,"^XK_T,XK_t" +KeyU,KEY_U,21,0x18,reg:0x3c,0x16,"^XK_U,XK_u" +KeyV,KEY_V,22,0x19,reg:0x2a,0x2f,"^XK_V,XK_v" +KeyW,KEY_W,23,0x1a,reg:0x1d,0x11,"^XK_W,XK_w" +KeyX,KEY_X,24,0x1b,reg:0x22,0x2d,"^XK_X,XK_x" +KeyY,KEY_Y,25,0x1c,reg:0x35,0x15,"^XK_Y,XK_y" +KeyZ,KEY_Z,26,0x1d,reg:0x1a,0x2c,"^XK_Z,XK_z" +Digit1,KEY_1,27,0x1e,reg:0x16,0x02,"XK_1,^XK_exclam" +Digit2,KEY_2,28,0x1f,reg:0x1e,0x03,"XK_2,^XK_at" +Digit3,KEY_3,29,0x20,reg:0x26,0x04,"XK_3,^XK_numbersign" +Digit4,KEY_4,30,0x21,reg:0x25,0x05,"XK_4,^XK_dollar" +Digit5,KEY_5,31,0x22,reg:0x2e,0x06,"XK_5,^XK_percent" +Digit6,KEY_6,32,0x23,reg:0x36,0x07,"XK_6,^XK_asciicircum" +Digit7,KEY_7,33,0x24,reg:0x3d,0x08,"XK_7,^XK_ampersand" +Digit8,KEY_8,34,0x25,reg:0x3e,0x09,"XK_8,^XK_asterisk" +Digit9,KEY_9,35,0x26,reg:0x46,0x0a,"XK_9,^XK_parenleft" +Digit0,KEY_0,36,0x27,reg:0x45,0x0b,"XK_0,^XK_parenright" +Enter,KEY_ENTER,37,0x28,reg:0x5a,0x1c,XK_Return +Escape,KEY_ESC,38,0x29,reg:0x76,0x01,XK_Escape +Backspace,KEY_BACKSPACE,39,0x2a,reg:0x66,0x0e,XK_BackSpace +Tab,KEY_TAB,40,0x2b,reg:0x0d,0x0f,XK_Tab +Space,KEY_SPACE,41,0x2c,reg:0x29,0x39,XK_space +Minus,KEY_MINUS,42,0x2d,reg:0x4e,0x0c,"XK_minus,^XK_underscore" +Equal,KEY_EQUAL,43,0x2e,reg:0x55,0x0d,"XK_equal,^XK_plus" +BracketLeft,KEY_LEFTBRACE,44,0x2f,reg:0x54,0x1a,"XK_bracketleft,^XK_braceleft" +BracketRight,KEY_RIGHTBRACE,45,0x30,reg:0x5b,0x1b,"XK_bracketright,^XK_braceright" +Backslash,KEY_BACKSLASH,46,0x31,reg:0x5d,0x2b,"XK_backslash,^XK_bar" +Semicolon,KEY_SEMICOLON,47,0x33,reg:0x4c,0x27,"XK_semicolon,^XK_colon" +Quote,KEY_APOSTROPHE,48,0x34,reg:0x52,0x28,"XK_apostrophe,^XK_quotedbl" +Backquote,KEY_GRAVE,49,0x35,reg:0x0e,0x29,"XK_grave,^XK_asciitilde" +Comma,KEY_COMMA,50,0x36,reg:0x41,0x33,"XK_comma,^XK_less" +Period,KEY_DOT,51,0x37,reg:0x49,0x34,"XK_period,^XK_greater" +Slash,KEY_SLASH,52,0x38,reg:0x4a,0x35,"XK_slash,^XK_question" +CapsLock,KEY_CAPSLOCK,53,0x39,reg:0x58,0x3a,XK_Caps_Lock +F1,KEY_F1,54,0x3a,reg:0x05,0x3b,XK_F1 +F2,KEY_F2,55,0x3b,reg:0x06,0x3c,XK_F2 +F3,KEY_F3,56,0x3c,reg:0x04,0x3d,XK_F3 +F4,KEY_F4,57,0x3d,reg:0x0c,0x3e,XK_F4 +F5,KEY_F5,58,0x3e,reg:0x03,0x3f,XK_F5 +F6,KEY_F6,59,0x3f,reg:0x0b,0x40,XK_F6 +F7,KEY_F7,60,0x40,reg:0x83,0x41,XK_F7 +F8,KEY_F8,61,0x41,reg:0x0a,0x42,XK_F8 +F9,KEY_F9,62,0x42,reg:0x01,0x43,XK_F9 +F10,KEY_F10,63,0x43,reg:0x09,0x44,XK_F10 +F11,KEY_F11,64,0x44,reg:0x78,0x57,XK_F11 +F12,KEY_F12,65,0x45,reg:0x07,0x58,XK_F12 +PrintScreen,KEY_SYSRQ,66,0x46,print:0xff,0x54,XK_Sys_Req +Insert,KEY_INSERT,67,0x49,spec:0x70,0xe052,XK_Insert +Home,KEY_HOME,68,0x4a,spec:0x6c,0xe047,XK_Home +PageUp,KEY_PAGEUP,69,0x4b,spec:0x7d,0xe049,XK_Page_Up +Delete,KEY_DELETE,70,0x4c,spec:0x71,0xe053,XK_Delete +End,KEY_END,71,0x4d,spec:0x69,0xe04f,XK_End +PageDown,KEY_PAGEDOWN,72,0x4e,spec:0x7a,0xe051,XK_Page_Down +ArrowRight,KEY_RIGHT,73,0x4f,spec:0x74,0xe04d,XK_Right +ArrowLeft,KEY_LEFT,74,0x50,spec:0x6b,0xe04b,XK_Left +ArrowDown,KEY_DOWN,75,0x51,spec:0x72,0xe050,XK_Down +ArrowUp,KEY_UP,76,0x52,spec:0x75,0xe048,XK_Up +ControlLeft,KEY_LEFTCTRL,77,^0x01,reg:0x14,0x1d,XK_Control_L +ShiftLeft,KEY_LEFTSHIFT,78,^0x02,reg:0x12,0x2a,XK_Shift_L +AltLeft,KEY_LEFTALT,79,^0x04,reg:0x11,0x38,XK_Alt_L +MetaLeft,KEY_LEFTMETA,80,^0x08,spec:0x1f,0xe05b,"XK_Meta_L,XK_Super_L" +ControlRight,KEY_RIGHTCTRL,81,^0x10,spec:0x14,0xe01d,XK_Control_R +ShiftRight,KEY_RIGHTSHIFT,82,^0x20,reg:0x59,0x36,XK_Shift_R +AltRight,KEY_RIGHTALT,83,^0x40,spec:0x11,0xe038,"XK_Alt_R,XK_ISO_Level3_Shift" +MetaRight,KEY_RIGHTMETA,84,^0x80,spec:0x27,0xe05c,"XK_Meta_R,XK_Super_R" +Pause,KEY_PAUSE,85,0x48,pause:0xff,0xe046,XK_Pause +ScrollLock,KEY_SCROLLLOCK,86,0x47,reg:0x7e,0x46,XK_Scroll_Lock +NumLock,KEY_NUMLOCK,87,0x53,reg:0x77,0x45,XK_Num_Lock +ContextMenu,KEY_CONTEXT_MENU,88,0x65,spec:0x2f,0xe05d,XK_Menu +NumpadDivide,KEY_KPSLASH,89,0x54,spec:0x4a,0xe035,XK_KP_Divide +NumpadMultiply,KEY_KPASTERISK,90,0x55,reg:0x7c,0x37,XK_multiply +NumpadSubtract,KEY_KPMINUS,91,0x56,reg:0x7b,0x4a,XK_KP_Subtract +NumpadAdd,KEY_KPPLUS,92,0x57,reg:0x79,0x4e,XK_KP_Add +NumpadEnter,KEY_KPENTER,93,0x58,spec:0x5a,0xe01c,XK_KP_Enter +Numpad1,KEY_KP1,94,0x59,reg:0x69,0x4f,XK_KP_1 +Numpad2,KEY_KP2,95,0x5a,reg:0x72,0x50,XK_KP_2 +Numpad3,KEY_KP3,96,0x5b,reg:0x7a,0x51,XK_KP_3 +Numpad4,KEY_KP4,97,0x5c,reg:0x6b,0x4b,XK_KP_4 +Numpad5,KEY_KP5,98,0x5d,reg:0x73,0x4c,XK_KP_5 +Numpad6,KEY_KP6,99,0x5e,reg:0x74,0x4d,XK_KP_6 +Numpad7,KEY_KP7,100,0x5f,reg:0x6c,0x47,XK_KP_7 +Numpad8,KEY_KP8,101,0x60,reg:0x75,0x48,XK_KP_8 +Numpad9,KEY_KP9,102,0x61,reg:0x7d,0x49,XK_KP_9 +Numpad0,KEY_KP0,103,0x62,reg:0x70,0x52,XK_KP_0 +NumpadDecimal,KEY_KPDOT,104,0x63,reg:0x71,0x53,XK_KP_Decimal +Power,KEY_POWER,105,0x66,spec:0x5e,0xe05e,XK_XF86_Sleep +IntlBackslash,KEY_102ND,106,0x64,reg:0x61,0x56, +IntlYen,KEY_YEN,107,0x89,reg:0x6a,0x7d, +IntlRo,KEY_RO,108,0x87,reg:0x51,0x73, +KanaMode,KEY_KATAKANA,109,0x88,reg:0x13,0x70, +Convert,KEY_HENKAN,110,0x8a,reg:0x64,0x79, +NonConvert,KEY_MUHENKAN,111,0x8b,reg:0x67,0x7b, +AudioVolumeMute,KEY_MUTE,112,0x7f,spec:0x23,0xe020, +AudioVolumeUp,KEY_VOLUMEUP,113,0x80,spec:0x32,0xe030, +AudioVolumeDown,KEY_VOLUMEDOWN,114,0x81,spec:0x21,0xe02e, +F20,KEY_F20,115,0x6f,,0x5a, diff --git a/kvmd.install b/kvmd.install index 73bd1ac76..a78446cb9 100644 --- a/kvmd.install +++ b/kvmd.install @@ -1,9 +1,12 @@ # shellcheck disable=SC2148 +# arg 1: the new package version post_install() { - post_upgrade + 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 @@ -19,12 +22,13 @@ post_upgrade() { 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 redirect-to-https ssl listen-http listen-https; do - chmod 644 "/etc/kvmd/nginx/$target.conf" || 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 /var/lib/kvmd/pst 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 ..." @@ -52,14 +56,72 @@ post_upgrade() { done echo "==> Patching configs ..." - [ ! -f /etc/fstab ] || (sed -i -e "s|,data=journal||g" /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 '/\/ s/^#*/#/' /etc/pam.d/system-login - [ ! -f /etc/pam.d/system-auth ] || sed -i -e '/\/ s/^#*/#/' /etc/pam.d/system-auth - [ -e /etc/systemd/network/99-default.link ] || ln -s /dev/null /etc/systemd/network/99-default.link + + 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 '/\/ s/^#*/#/' /etc/pam.d/system-login + [ ! -f /etc/pam.d/system-auth ] || sed -i -e '/\/ 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 + + if [[ "$(vercmp "$2" 4.31)" -lt 0 ]]; then + if [[ "$(systemctl is-enabled kvmd-janus || true)" = enabled || "$(systemctl is-enabled kvmd-janus-static || true)" = enabled ]]; then + systemctl enable kvmd-media || true + fi + fi + + if [[ "$(vercmp "$2" 4.47)" -lt 0 ]]; then + cp /usr/share/kvmd/configs.default/janus/janus.plugin.ustreamer.jcfg /etc/kvmd/janus || true + fi + + if [[ "$(vercmp "$2" 4.60)" -lt 0 ]]; then + if grep -q "^dtoverlay=vc4-kms-v3d" /boot/config.txt; then + sed -i -e "s/cma=128M/cma=192M/g" /boot/cmdline.txt || true + sed -i -e "s/^gpu_mem=128/gpu_mem=192/g" /boot/config.txt || true + fi + fi + + if [[ "$(vercmp "$2" 4.102)" -lt 0 ]]; then + rm -f /etc/kvmd/logging.yaml* + fi # Some update deletes /etc/motd, WTF # shellcheck disable=SC2015,SC2166 diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 572969c7a..2c40ac8f9 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,4 +20,9 @@ # ========================================================================== # -__version__ = "3.229" +__version__ = "4.148" + + +import multiprocessing + +multiprocessing.set_start_method("fork") # FIXME diff --git a/kvmd/aiogp.py b/kvmd/aiogp.py index 7965666bf..ce8d93949 100644 --- a/kvmd/aiogp.py +++ b/kvmd/aiogp.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -23,22 +23,13 @@ import asyncio import threading import dataclasses +import typing import gpiod from . import aiotools -# ===== -async def pulse(line: gpiod.Line, delay: float, final: float, inverted: bool=False) -> None: - try: - line.set_value(int(not inverted)) - await asyncio.sleep(delay) - finally: - line.set_value(int(inverted)) - await asyncio.sleep(final) - - # ===== @dataclasses.dataclass(frozen=True) class AioReaderPinParams: @@ -57,7 +48,7 @@ def __init__( self.__path = path self.__consumer = consumer - self.__pins = pins + self.__pins = dict(pins) self.__notifier = notifier self.__values: (dict[int, _DebouncedValue] | None) = None @@ -79,53 +70,57 @@ async def poll(self) -> None: self.__loop = asyncio.get_running_loop() self.__thread.start() try: - await aiotools.run_async(self.__thread.join) + await asyncio.to_thread(self.__thread.join) finally: self.__stop_event.set() - await aiotools.run_async(self.__thread.join) + await asyncio.to_thread(self.__thread.join) def __run(self) -> None: assert self.__values is None assert self.__loop - with gpiod.Chip(self.__path) as chip: - pins = sorted(self.__pins) - lines = chip.get_lines(pins) - lines.request(self.__consumer, gpiod.LINE_REQ_EV_BOTH_EDGES) - lines.event_wait(nsec=1) + pins = sorted(self.__pins) + with gpiod.request_lines( + self.__path, + consumer=self.__consumer, + config={tuple(pins): gpiod.LineSettings(edge_detection=gpiod.line.Edge.BOTH)}, + ) as line_req: + + line_req.wait_edge_events(0.1) self.__values = { pin: _DebouncedValue( - initial=bool(value), + initial=bool(value.value), debounce=self.__pins[pin].debounce, notifier=self.__notifier, loop=self.__loop, ) - for (pin, value) in zip(pins, lines.get_values()) + for (pin, value) in zip(pins, line_req.get_values(pins)) } self.__loop.call_soon_threadsafe(self.__notifier.notify) while not self.__stop_event.is_set(): - ev_lines = lines.event_wait(1) - if ev_lines: - for ev_line in ev_lines: - events = ev_line.event_read_multiple() - if events: - (pin, value) = self.__parse_event(events[-1]) - self.__values[pin].set(bool(value)) + if line_req.wait_edge_events(1): + new: dict[int, bool] = {} + for event in line_req.read_edge_events(): + (pin, state) = self.__parse_event(event) + new[pin] = state + for (pin, state) in new.items(): + self.__values[pin].set(state) else: # Timeout + # XXX: Лимит был актуален для 1.6. Надо проверить, поменялось ли это в 2.x. # Размер буфера ядра - 16 эвентов на линии. При превышении этого числа, # новые эвенты потеряются. Это не баг, это фича, как мне объяснили в LKML. # Штош. Будем с этим жить и синхронизировать состояния при таймауте. - for (pin, value) in zip(pins, lines.get_values()): - self.__values[pin].set(bool(value)) + for (pin, value) in zip(pins, line_req.get_values(pins)): + self.__values[pin].set(bool(value.value)) # type: ignore - def __parse_event(self, event: gpiod.LineEvent) -> tuple[int, int]: - pin = event.source.offset() - if event.type == gpiod.LineEvent.RISING_EDGE: - return (pin, 1) - elif event.type == gpiod.LineEvent.FALLING_EDGE: - return (pin, 0) - raise RuntimeError(f"Invalid event {event} type: {event.type}") + def __parse_event(self, event: gpiod.EdgeEvent) -> tuple[int, bool]: + match event.event_type: + case event.Type.RISING_EDGE: + return (event.line_offset, True) + case event.Type.FALLING_EDGE: + return (event.line_offset, False) + typing.assert_never(event.event_type) class _DebouncedValue: @@ -143,7 +138,7 @@ def __init__( self.__loop = loop self.__queue: "asyncio.Queue[bool]" = asyncio.Queue() # type: ignore - self.__task = loop.create_task(self.__consumer_task_loop()) + self.__task = asyncio.run_coroutine_threadsafe(self.__consumer_task_loop(), loop) def set(self, value: bool) -> None: if self.__loop.is_running(): diff --git a/kvmd/aiohelpers.py b/kvmd/aiohelpers.py index 07d458a5a..70fac32d3 100644 --- a/kvmd/aiohelpers.py +++ b/kvmd/aiohelpers.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -42,7 +42,7 @@ async def remount(name: str, base_cmd: list[str], rw: bool) -> bool: if proc.returncode != 0: assert proc.returncode is not None raise subprocess.CalledProcessError(proc.returncode, cmd) - except Exception as err: - logger.error("Can't remount %s storage: %s", name, tools.efmt(err)) + except Exception as ex: + logger.error("Can't remount %s storage: %s", name, tools.efmt(ex)) return False return True diff --git a/kvmd/aiomulti.py b/kvmd/aiomulti.py index 5e7032d32..1afa09f68 100644 --- a/kvmd/aiomulti.py +++ b/kvmd/aiomulti.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,53 +20,205 @@ # ========================================================================== # +import os +import signal +import asyncio import multiprocessing +import multiprocessing.queues +import multiprocessing.connection import queue +import logging +from typing import Callable from typing import Type from typing import TypeVar from typing import Generic +from typing import Any -from . import aiotools +import setproctitle # ===== -_QueueItemT = TypeVar("_QueueItemT") +def rename_process(suffix: str) -> None: + setproctitle.setproctitle(f"kvmd/{suffix}: {setproctitle.getproctitle()}") -async def queue_get_last( # pylint: disable=invalid-name - q: "multiprocessing.Queue[_QueueItemT]", - timeout: float, -) -> tuple[bool, (_QueueItemT | None)]: +# ===== +class AioMpProcess: + def __init__( + self, + name: str, + target: Callable[..., None], + args: tuple[Any, ...]=(), + ) -> None: + + self.__name = name + self.__target = target + + self.__proc = multiprocessing.Process( + target=self.__target_wrapper, + args=args, + daemon=True, + name=name, + ) + + def __target_wrapper(self, *args: Any, **kwargs: Any) -> None: + logger = logging.getLogger(self.__target.__module__) + logger.info("Started process kvmd/%s: pid=%s", self.__name, os.getpid()) + os.setpgrp() + rename_process(self.__name) + self.__target(*args, **kwargs) + + def is_alive(self) -> bool: + return self.__proc.is_alive() + + @property + def exitcode(self) -> (int | None): + return self.__proc.exitcode + + def start(self) -> None: + self.__proc.start() + + def send_sigterm(self) -> None: + if self.__proc.pid is None: + return + try: + os.kill(self.__proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + + def sendpg_sigkill(self) -> None: + if self.__proc.pid is None: + return + try: + own = os.getpgid(os.getpid()) + target = os.getpgid(self.__proc.pid) + if own != target: + os.killpg(target, signal.SIGKILL) + else: + os.kill(self.__proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + async def async_join(self, timeout: float=0.0) -> bool: + if self.__proc.pid is None: + return False - return (await aiotools.run_async(queue_get_last_sync, q, timeout)) + prev = self.__proc.is_alive() + loop = asyncio.get_running_loop() + fut = asyncio.Future() # type: ignore + try: + fd = os.pidfd_open(self.__proc.pid, os.PIDFD_NONBLOCK) + except ProcessLookupError: + pass + else: + try: + loop.add_reader(fd, fut.set_result, None) + fut.add_done_callback(lambda _: loop.remove_reader(fd)) + if timeout > 0: + await asyncio.wait_for(fut, timeout) + else: + await fut + except TimeoutError: + pass + finally: + try: + loop.remove_reader(fd) + finally: + os.close(fd) -def queue_get_last_sync( # pylint: disable=invalid-name - q: "multiprocessing.Queue[_QueueItemT]", - timeout: float, -) -> tuple[bool, (_QueueItemT | None)]: + # Crank the internal MP machinery and return a status code. + # It should be non-blocking. + new = self.__proc.is_alive() + if prev != new: + self.__get_logger().info("Stopped kvmd/%s: exitcode=%s", self.__name, self.exitcode) + return new - try: - item = q.get(timeout=timeout) - while not q.empty(): - item = q.get() - return (True, item) - except queue.Empty: - return (False, None) + def __get_logger(self) -> logging.Logger: + return logging.getLogger(self.__target.__module__) # ===== -class AioProcessNotifier: +class AioMpQueue[T](multiprocessing.queues.Queue[T]): + def __init__(self, maxsize: int=0) -> None: + super().__init__(maxsize=maxsize, ctx=multiprocessing.get_context()) + + def get_reader(self) -> multiprocessing.connection.Connection: + return self._reader # type: ignore # pylint: disable=protected-access + + def get_reader_fd(self) -> int: + return self.get_reader().fileno() + + async def async_fetch(self, timeout: float=0.0) -> tuple[bool, (T | None)]: + return (await self.__async_get(timeout, False)) + + async def async_fetch_last(self, timeout: float=0.0) -> tuple[bool, (T | None)]: + return (await self.__async_get(timeout, True)) + + async def __async_get(self, timeout: float, last_only: bool) -> tuple[bool, (T | None)]: + loop = asyncio.get_running_loop() + fut = asyncio.Future() # type: ignore + fd = self.get_reader_fd() + + try: + loop.add_reader(fd, fut.set_result, None) + fut.add_done_callback(lambda _: loop.remove_reader(fd)) + if timeout > 0: + await asyncio.wait_for(fut, timeout) + else: + await fut + + if not last_only: + return (True, self.get(False)) + + got = False + item: (T | None) = None + while not self.empty(): + got = True + item = self.get(False) + await asyncio.sleep(0) # Switch task to prevent hanging in a loop + return (got, item) + except (TimeoutError, queue.Empty): + return (False, None) + finally: + loop.remove_reader(fd) + + def fetch_last(self, timeout: float=0.0) -> tuple[bool, (T | None)]: + try: + item = self.get(timeout=timeout) + while not self.empty(): + item = self.get() + return (True, item) + except queue.Empty: + return (False, None) + + def clear_current(self) -> None: + for _ in range(self.qsize()): + try: + self.get_nowait() + except queue.Empty: + break + + +# ===== +class AioMpNotifier: def __init__(self) -> None: - self.__queue: "multiprocessing.Queue[None]" = multiprocessing.Queue() + self.__queue: AioMpQueue[int] = AioMpQueue() - def notify(self) -> None: - self.__queue.put_nowait(None) + def notify(self, mask: int=0) -> None: + self.__queue.put_nowait(mask) - async def wait(self) -> None: - while not (await queue_get_last(self.__queue, 0.1))[0]: - pass + async def wait(self, timeout: float=0) -> int: + (got, mask) = await self.__queue.async_fetch(timeout) + if not got: # Timeout + return -1 + assert mask is not None + if got: + while not self.__queue.empty(): + mask |= self.__queue.get() + await asyncio.sleep(0) + return mask # ===== @@ -77,7 +229,7 @@ class AioSharedFlags(Generic[_SharedFlagT]): def __init__( self, initial: dict[str, _SharedFlagT], - notifier: AioProcessNotifier, + notifier: AioMpNotifier, type: Type[_SharedFlagT]=bool, # pylint: disable=redefined-builtin ) -> None: @@ -103,7 +255,7 @@ def update(self, **kwargs: _SharedFlagT) -> None: self.__notifier.notify() async def get(self) -> dict[str, _SharedFlagT]: - return (await aiotools.run_async(self.__inner_get)) + return (await asyncio.to_thread(self.__inner_get)) def __inner_get(self) -> dict[str, _SharedFlagT]: with self.__lock: diff --git a/kvmd/aioproc.py b/kvmd/aioproc.py index 81864b7ff..aa19160d6 100644 --- a/kvmd/aioproc.py +++ b/kvmd/aioproc.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -26,10 +26,6 @@ import asyncio.subprocess import logging -import setproctitle - -from .logging import get_logger - # ===== async def run_process( @@ -75,7 +71,11 @@ async def log_process( return proc -async def log_stdout_infinite(proc: asyncio.subprocess.Process, logger: logging.Logger) -> None: # pylint: disable=no-member +async def log_stdout_infinite( + proc: asyncio.subprocess.Process, # pylint: disable=no-member + logger: logging.Logger, +) -> None: + empty = 0 async for line_bytes in proc.stdout: # type: ignore line = line_bytes.decode(errors="ignore").strip() @@ -88,35 +88,31 @@ async def log_stdout_infinite(proc: asyncio.subprocess.Process, logger: logging. raise RuntimeError("Asyncio process: too many empty lines") -async def kill_process(proc: asyncio.subprocess.Process, wait: float, logger: logging.Logger) -> None: # pylint: disable=no-member +async def kill_process( + proc: asyncio.subprocess.Process, # pylint: disable=no-member + wait: float, + logger: logging.Logger, +) -> None: + if proc.returncode is None: try: - proc.terminate() - await asyncio.sleep(wait) - if proc.returncode is None: + try: + proc.terminate() try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except Exception: - if proc.returncode is not None: - raise - await proc.wait() - logger.info("Process killed: retcode=%d", proc.returncode) - except asyncio.CancelledError: - pass + await asyncio.wait_for(proc.wait(), timeout=wait) + except TimeoutError: + pass + finally: + if proc.returncode is None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + await asyncio.wait_for(proc.wait(), timeout=wait) + except Exception: + if proc.returncode is not None: + raise except Exception: if proc.returncode is None: - logger.exception("Can't kill process pid=%d", proc.pid) - else: - logger.info("Process killed: retcode=%d", proc.returncode) - - -def rename_process(suffix: str, prefix: str="kvmd") -> None: - setproctitle.setproctitle(f"{prefix}/{suffix}: {setproctitle.getproctitle()}") - - -def settle(name: str, suffix: str, prefix: str="kvmd") -> logging.Logger: - logger = get_logger(1) - logger.info("Started %s pid=%d", name, os.getpid()) - os.setpgrp() - rename_process(suffix, prefix) - return logger + logger.exception("Can't kill process pid=%s", proc.pid) + finally: + if proc.returncode is not None: + logger.info("Process killed: retcode=%s", proc.returncode) diff --git a/kvmd/aiotools.py b/kvmd/aiotools.py index 8449419ef..20ce5b783 100644 --- a/kvmd/aiotools.py +++ b/kvmd/aiotools.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -45,6 +45,11 @@ async def read_file(path: str) -> str: return (await file.read()) +async def write_file(path: str, text: str) -> None: + async with aiofiles.open(path, "w") as file: + await file.write(text) + + # ===== def run(coro: Coroutine, final: (Coroutine | None)=None) -> None: # https://github.com/aio-libs/aiohttp/blob/a1d4dac1d/aiohttp/web.py#L515 @@ -55,7 +60,9 @@ def sigint_handler() -> None: def sigterm_handler() -> None: raise SystemExit() - loop = asyncio.get_event_loop() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.add_signal_handler(signal.SIGINT, sigint_handler) loop.add_signal_handler(signal.SIGTERM, sigterm_handler) @@ -112,9 +119,9 @@ def inner_done(_) -> None: # type: ignore if inner.cancelled(): outer.forced_cancel() else: - err = inner.exception() - if err is not None: - outer.set_exception(err) + ex = inner.exception() + if ex is not None: + outer.set_exception(ex) else: outer.set_result(inner.result()) @@ -166,7 +173,7 @@ async def wrapper() -> None: except asyncio.CancelledError: pass except Exception: - logger.exception("Unhandled exception in deadly task, killing myself ...") + logger.exception("Unhandled exception in deadly task %r, killing myself ...", name) pid = os.getpid() if pid == 1: os._exit(1) # Docker workaround # pylint: disable=protected-access @@ -188,22 +195,35 @@ async def stop_all_deadly_tasks() -> None: # ===== -async def run_async(func: Callable[..., _RetvalT], *args: Any) -> _RetvalT: - return (await asyncio.get_running_loop().run_in_executor(None, func, *args)) +async def wait_infinite() -> None: + while True: + await asyncio.sleep(3600) -def run_sync(coro: Coroutine[Any, Any, _RetvalT]) -> _RetvalT: - return asyncio.get_event_loop().run_until_complete(coro) +async def wait_first(*aws: asyncio.Task) -> tuple[set[asyncio.Task], set[asyncio.Task]]: + return (await asyncio.wait(list(aws), return_when=asyncio.FIRST_COMPLETED)) # ===== -async def wait_infinite() -> None: - while True: - await asyncio.sleep(3600) +async def spawn_and_follow( + *coros: Coroutine, + wait: float=0.0, + tasks: (list[asyncio.Task] | None)=None, +) -> None: + if tasks is None: + tasks = [] -async def wait_first(*aws: (asyncio.Future | asyncio.Task)) -> tuple[set[asyncio.Task], set[asyncio.Task]]: - return (await asyncio.wait(list(aws), return_when=asyncio.FIRST_COMPLETED)) + try: + for coro in coros: + tasks.append(asyncio.create_task(coro)) + await wait_first(*tasks) + if wait > 0: + await asyncio.sleep(wait) + finally: + for task in tasks: + task.cancel() + await shield_fg(asyncio.gather(*tasks, return_exceptions=True)) # ===== @@ -232,25 +252,26 @@ async def close_writer(writer: asyncio.StreamWriter) -> bool: # ===== class AioNotifier: def __init__(self) -> None: - self.__queue: "asyncio.Queue[None]" = asyncio.Queue() + self.__queue: "asyncio.Queue[int]" = asyncio.Queue() - def notify(self) -> None: - self.__queue.put_nowait(None) + def notify(self, mask: int=0) -> None: + self.__queue.put_nowait(mask) - async def wait(self, timeout: (float | None)=None) -> None: + async def wait(self, timeout: (float | None)=None) -> int: + mask = 0 if timeout is None: - await self.__queue.get() + mask = await self.__queue.get() else: try: - await asyncio.wait_for( + mask = await asyncio.wait_for( asyncio.ensure_future(self.__queue.get()), timeout=timeout, ) except asyncio.TimeoutError: - return # False + return -1 while not self.__queue.empty(): - await self.__queue.get() - # return True + mask |= await self.__queue.get() + return mask # ===== @@ -296,7 +317,7 @@ def get_exc_type(self) -> type[Exception]: def is_busy(self) -> bool: return self.__busy - async def enter(self) -> None: + def enter(self) -> None: if not self.__busy: self.__busy = True try: @@ -308,22 +329,22 @@ async def enter(self) -> None: return raise self.__exc_type() - async def exit(self) -> None: + def exit(self) -> None: self.__busy = False if self.__notifier: self.__notifier.notify() - async def __aenter__(self) -> None: - await self.enter() + def __enter__(self) -> None: + self.enter() - async def __aexit__( + def __exit__( self, _exc_type: type[BaseException], _exc: BaseException, _tb: types.TracebackType, ) -> None: - await self.exit() + self.exit() async def run_region_task( @@ -338,7 +359,7 @@ async def run_region_task( async def wrapper() -> None: try: - async with region: + with region: entered.set_result(None) await func(*args, **kwargs) except region.get_exc_type(): diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 1a8acad93..b9ef6f2b5 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,139 +22,120 @@ import sys import os -import functools +import copy +import dataclasses +import contextlib import argparse -import logging -import logging.config -import pygments -import pygments.lexers.data -import pygments.formatters +from typing import Generator +from typing import Any from .. import tools -from ..mouse import MouseRange - from ..plugins import UnknownPluginError -from ..plugins.auth import get_auth_service_class -from ..plugins.hid import get_hid_class -from ..plugins.atx import get_atx_class -from ..plugins.msd import get_msd_class - -from ..plugins.ugpio import UserGpioModes -from ..plugins.ugpio import BaseUserGpioDriver -from ..plugins.ugpio import get_ugpio_driver_class from ..yamlconf import ConfigError -from ..yamlconf import manual_validated -from ..yamlconf import make_config from ..yamlconf import Section -from ..yamlconf import Option -from ..yamlconf import build_raw_from_options -from ..yamlconf.dumper import make_config_dump +from ..yamlconf import make_config +from ..yamlconf.loader import listed_yaml_dir from ..yamlconf.loader import load_yaml_file from ..yamlconf.merger import yaml_merge - -from ..validators.basic import valid_stripped_string -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_int_f0 -from ..validators.basic import valid_int_f1 -from ..validators.basic import valid_float_f0 -from ..validators.basic import valid_float_f01 -from ..validators.basic import valid_string_list - -from ..validators.auth import valid_user -from ..validators.auth import valid_users_list +from ..yamlconf.dumper import dump_yaml +from ..yamlconf.dumper import override_yaml_file from ..validators.os import valid_abs_path from ..validators.os import valid_abs_file from ..validators.os import valid_abs_dir -from ..validators.os import valid_unix_mode -from ..validators.os import valid_options -from ..validators.os import valid_command - -from ..validators.net import valid_ip_or_host -from ..validators.net import valid_net -from ..validators.net import valid_port -from ..validators.net import valid_ports_list -from ..validators.net import valid_mac -from ..validators.net import valid_ssl_ciphers - -from ..validators.hid import valid_hid_key -from ..validators.hid import valid_hid_mouse_output -from ..validators.hid import valid_hid_mouse_move - -from ..validators.kvm import valid_stream_quality -from ..validators.kvm import valid_stream_fps -from ..validators.kvm import valid_stream_resolution -from ..validators.kvm import valid_stream_h264_bitrate -from ..validators.kvm import valid_stream_h264_gop - -from ..validators.ugpio import valid_ugpio_driver -from ..validators.ugpio import valid_ugpio_channel -from ..validators.ugpio import valid_ugpio_mode -from ..validators.ugpio import valid_ugpio_view_title -from ..validators.ugpio import valid_ugpio_view_table - -from ..validators.hw import valid_tty_speed -from ..validators.hw import valid_otg_gadget -from ..validators.hw import valid_otg_id -from ..validators.hw import valid_otg_ethernet + +from ._logging import init_logging +from ._scheme import make_config_scheme +from ._scheme import patch_dynamic +from ._scheme import patch_raw # ===== +@dataclasses.dataclass(frozen=True) +class ConfigPaths: + main: str + legacy_auth: str + override_dir: str + override: str + + +@dataclasses.dataclass(frozen=True) +class InitAttrs: + parser: argparse.ArgumentParser + args: list[str] + config: Section + cps: ConfigPaths + + def init( prog: (str | None)=None, description: (str | None)=None, add_help: bool=True, check_run: bool=False, cli_logging: bool=False, - argv: (list[str] | None)=None, + test_args: (list[str] | None)=None, + test_override: (dict | None)=None, **load: bool, -) -> tuple[argparse.ArgumentParser, list[str], Section]: +) -> InitAttrs: - argv = (argv or sys.argv) - assert len(argv) > 0 + init_logging(cli_logging) + + prog = (prog or sys.argv[0]) + args = (test_args or sys.argv[1:]) # Remove app name from sys.argv parser = argparse.ArgumentParser( - prog=(prog or argv[0]), + prog=prog, description=description, add_help=add_help, formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - parser.add_argument("-c", "--config", default="/etc/kvmd/main.yaml", type=valid_abs_file, - help="Set config file path", metavar="") - parser.add_argument("-o", "--set-options", default=[], nargs="+", - help="Override config options list (like sec/sub/opt=value)", metavar="",) + parser.add_argument("--main-config", default="/usr/lib/kvmd/main.yaml", type=valid_abs_file, + help="Set the main default config", metavar="") + parser.add_argument("--legacy-auth-config", default="/etc/kvmd/auth.yaml", type=valid_abs_path, + help="Set the auth config, which is applied before override (don't use it)", metavar="") + parser.add_argument("--override-dir", default="/etc/kvmd/override.d", type=valid_abs_dir, + help="Set the override.d directory", metavar="") + parser.add_argument("--override-config", default="/etc/kvmd/override.yaml", type=valid_abs_file, + help="Set the override config", metavar="") parser.add_argument("-m", "--dump-config", action="store_true", help="View current configuration (include all overrides)") + parser.add_argument("-M", "--dump-config-changes", action="store_true", + help="Similar to --dump-config, but shows only changed fields") if check_run: parser.add_argument("--run", dest="run", action="store_true", help="Run the service") - (options, remaining) = parser.parse_known_args(argv) - - if options.dump_config: - _dump_config(_init_config( - config_path=options.config, - override_options=options.set_options, - load_auth=True, - load_hid=True, - load_atx=True, - load_msd=True, - load_gpio=True, + + # Replace args for child parser + (options, args) = parser.parse_known_args(list(args)) + cps = ConfigPaths( + main=options.main_config, + legacy_auth=options.legacy_auth_config, + override_dir=options.override_dir, + override=options.override_config, + ) + + dump_only = (options.dump_config or options.dump_config_changes) + + try: + config = _init_config( + cps=cps, + test_override=test_override, + load_all=dump_only, + **load, + ) + except ConfigError as ex: + raise SystemExit(tools.efmt(ex)) + + if dump_only: + print(dump_yaml( + data=config, + only_changed=options.dump_config_changes, + colored=sys.stdout.isatty(), )) raise SystemExit() - config = _init_config(options.config, options.set_options, **load) - - logging.captureWarnings(True) - logging.config.dictConfig(config.logging) - if cli_logging: - logging.getLogger().handlers[0].setFormatter(logging.Formatter( - "-- {levelname:>7} -- {message}", - style="{", - )) if check_run and not options.run: raise SystemExit( @@ -163,600 +144,81 @@ def init( "Make sure you understand exactly what you are doing!" ) - return (parser, remaining, config) + return InitAttrs( + parser=parser, + args=list(args), + config=config, + cps=cps, + ) -# ===== -def _init_config(config_path: str, override_options: list[str], **load_flags: bool) -> Section: - config_path = os.path.expanduser(config_path) +@contextlib.contextmanager +def override_checked(cps: ConfigPaths) -> Generator[Any]: + def validator(path: str) -> None: + _init_config( + cps=ConfigPaths( + main=cps.main, + legacy_auth=cps.legacy_auth, + override_dir=cps.override_dir, + override=path, + ), + test_override={}, + load_all=True, + ) + try: - raw_config: dict = load_yaml_file(config_path) - except Exception as err: - raise SystemExit(f"ConfigError: Can't read config file {config_path!r}:\n{tools.efmt(err)}") - if not isinstance(raw_config, dict): - raise SystemExit(f"ConfigError: Top-level of the file {config_path!r} must be a dictionary") + with override_yaml_file(cps.override, validator) as doc: + yield doc + except ConfigError as ex: + raise ConfigError(f"The resulting override turns invalid and will be discarded:\n{tools.efmt(ex)}") + - scheme = _get_config_scheme() +# ===== +def _checkload_yaml_file(path: str) -> dict: try: - yaml_merge(raw_config, (raw_config.pop("override", {}) or {})) - yaml_merge(raw_config, build_raw_from_options(override_options), "raw CLI options") - _patch_raw(raw_config) - config = make_config(raw_config, scheme) + raw: dict = load_yaml_file(path) + except Exception as ex: + raise ConfigError(f"Can't read config file {path!r}:\n{tools.efmt(ex)}") + if raw is None: + return {} + elif not isinstance(raw, dict): + raise ConfigError(f"Top-level of the file {path!r} must be a dictionary") + return raw + - if _patch_dynamic(raw_config, config, scheme, **load_flags): - config = make_config(raw_config, scheme) +def _init_config( + cps: ConfigPaths, + test_override: (dict | None), + **load: bool, # Pass load_all=True to test full configuration +) -> Section: + # Stage 1: Top-priority, considered as default + main: dict = _checkload_yaml_file(cps.main) + override: dict = {} + + # Stage 1.5: Legacy auth.yaml config, it shouln't be used anymore + if os.path.isfile(cps.legacy_auth) or os.path.islink(cps.legacy_auth): + yaml_merge(override, {"kvmd": {"auth": _checkload_yaml_file(cps.legacy_auth)}}) + + # Stage 2: Directory for partial overrides + for path in listed_yaml_dir(cps.override_dir): + yaml_merge(override, _checkload_yaml_file(path)) + + # Stage 3: Manual overrides + yaml_merge(override, _checkload_yaml_file(cps.override)) + + # Stage 4: Test overrides + if test_override is not None: + yaml_merge(override, copy.deepcopy(test_override)) + + patch_raw(main) + patch_raw(override) + + scheme = make_config_scheme() + try: + config = make_config(main, override, scheme) + if patch_dynamic(main, override, config, scheme, **load): + config = make_config(main, override, scheme) return config - except (ConfigError, UnknownPluginError) as err: - raise SystemExit(f"ConfigError: {err}") - - -def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches - if isinstance(raw_config.get("otg"), dict): - for (old, new) in [ - ("msd", "msd"), - ("acm", "serial"), - ("drives", "drives"), - ]: - if old in raw_config["otg"]: - if not isinstance(raw_config["otg"].get("devices"), dict): - raw_config["otg"]["devices"] = {} - raw_config["otg"]["devices"][new] = raw_config["otg"].pop(old) - - if isinstance(raw_config.get("kvmd"), dict) and isinstance(raw_config["kvmd"].get("wol"), dict): - if not isinstance(raw_config["kvmd"].get("gpio"), dict): - raw_config["kvmd"]["gpio"] = {} - for section in ["drivers", "scheme"]: - if not isinstance(raw_config["kvmd"]["gpio"].get(section), dict): - raw_config["kvmd"]["gpio"][section] = {} - raw_config["kvmd"]["gpio"]["drivers"]["__wol__"] = { - "type": "wol", - **raw_config["kvmd"].pop("wol"), - } - raw_config["kvmd"]["gpio"]["scheme"]["__wol__"] = { - "driver": "__wol__", - "pin": 0, - "mode": "output", - "switch": False, - } - - if isinstance(raw_config.get("kvmd"), dict) and isinstance(raw_config["kvmd"].get("streamer"), dict): - streamer_config = raw_config["kvmd"]["streamer"] - - desired_fps = streamer_config.get("desired_fps") - if desired_fps is not None and not isinstance(desired_fps, dict): - streamer_config["desired_fps"] = {"default": desired_fps} - - max_fps = streamer_config.get("max_fps") - if max_fps is not None: - if not isinstance(streamer_config.get("desired_fps"), dict): - streamer_config["desired_fps"] = {} - streamer_config["desired_fps"]["max"] = max_fps - del streamer_config["max_fps"] - - resolution = streamer_config.get("resolution") - if resolution is not None and not isinstance(resolution, dict): - streamer_config["resolution"] = {"default": resolution} - - available_resolutions = streamer_config.get("available_resolutions") - if available_resolutions is not None: - if not isinstance(streamer_config.get("resolution"), dict): - streamer_config["resolution"] = {} - streamer_config["resolution"]["available"] = available_resolutions - del streamer_config["available_resolutions"] - - -def _patch_dynamic( # pylint: disable=too-many-locals - raw_config: dict, - config: Section, - scheme: dict, - load_auth: bool=False, - load_hid: bool=False, - load_atx: bool=False, - load_msd: bool=False, - load_gpio: bool=False, -) -> bool: - - rebuild = False - - if load_auth: - scheme["kvmd"]["auth"]["internal"].update(get_auth_service_class(config.kvmd.auth.internal.type).get_plugin_options()) - if config.kvmd.auth.external.type: - scheme["kvmd"]["auth"]["external"].update(get_auth_service_class(config.kvmd.auth.external.type).get_plugin_options()) - rebuild = True - - for (load, section, get_class) in [ - (load_hid, "hid", get_hid_class), - (load_atx, "atx", get_atx_class), - (load_msd, "msd", get_msd_class), - ]: - if load: - scheme["kvmd"][section].update(get_class(getattr(config.kvmd, section).type).get_plugin_options()) - rebuild = True - - if load_gpio: - driver: str - drivers: dict[str, type[BaseUserGpioDriver]] = {} # Name to drivers - for (driver, params) in { # type: ignore - "__gpio__": {}, - **tools.rget(raw_config, "kvmd", "gpio", "drivers"), - }.items(): - with manual_validated(driver, "kvmd", "gpio", "drivers", ""): - driver = valid_ugpio_driver(driver) - - driver_type = valid_stripped_string_not_empty(params.get("type", "gpio")) - driver_class = get_ugpio_driver_class(driver_type) - drivers[driver] = driver_class - scheme["kvmd"]["gpio"]["drivers"][driver] = { - "type": Option(driver_type, type=valid_stripped_string_not_empty), - **driver_class.get_plugin_options() - } - - path = ("kvmd", "gpio", "scheme") - for (channel, params) in tools.rget(raw_config, *path).items(): - with manual_validated(channel, *path, ""): - channel = valid_ugpio_channel(channel) - - driver = params.get("driver", "__gpio__") - with manual_validated(driver, *path, channel, "driver"): - driver = valid_ugpio_driver(driver, set(drivers)) - - mode: str = params.get("mode", "") - with manual_validated(mode, *path, channel, "mode"): - mode = valid_ugpio_mode(mode, drivers[driver].get_modes()) - - if params.get("pulse") == False: # noqa: E712 # pylint: disable=singleton-comparison - params["pulse"] = {"delay": 0} - - scheme["kvmd"]["gpio"]["scheme"][channel] = { - "driver": Option("__gpio__", type=functools.partial(valid_ugpio_driver, variants=set(drivers))), - "pin": Option(None, type=drivers[driver].get_pin_validator()), - "mode": Option("", type=functools.partial(valid_ugpio_mode, variants=drivers[driver].get_modes())), - "inverted": Option(False, type=valid_bool), - **({ - "busy_delay": Option(0.2, type=valid_float_f01), - "initial": Option(False, type=(lambda arg: (valid_bool(arg) if arg is not None else None))), - "switch": Option(True, type=valid_bool), - "pulse": { # type: ignore - "delay": Option(0.1, type=valid_float_f0), - "min_delay": Option(0.1, type=valid_float_f01), - "max_delay": Option(0.1, type=valid_float_f01), - }, - } if mode == UserGpioModes.OUTPUT else { # input - "debounce": Option(0.1, type=valid_float_f0), - }) - } - - rebuild = True - - return rebuild - - -def _dump_config(config: Section) -> None: - dump = make_config_dump(config) - if sys.stdout.isatty(): - dump = pygments.highlight( - dump, - pygments.lexers.data.YamlLexer(), - pygments.formatters.TerminalFormatter(bg="dark"), # pylint: disable=no-member - ) - print(dump) - - -def _get_config_scheme() -> dict: - return { - "logging": Option({}), - - "kvmd": { - "server": { - "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), - "unix_rm": Option(True, type=valid_bool), - "unix_mode": Option(0o660, type=valid_unix_mode), - "heartbeat": Option(15.0, type=valid_float_f01), - "access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---" - " referer='%{Referer}i'; user_agent='%{User-Agent}i'"), - }, - - "auth": { - "enabled": Option(True, type=valid_bool), - - "internal": { - "type": Option("htpasswd"), - "force_users": Option([], type=valid_users_list), - # Dynamic content - }, - - "external": { - "type": Option("", type=valid_stripped_string), - # Dynamic content - }, - - "totp": { - "secret": { - "file": Option("/etc/kvmd/totp.secret", type=valid_abs_path, if_empty=""), - }, - }, - }, - - "info": { # Accessed via global config, see kvmd/info for details - "meta": Option("/etc/kvmd/meta.yaml", type=valid_abs_file), - "extras": Option("/usr/share/kvmd/extras", type=valid_abs_dir), - "hw": { - "vcgencmd_cmd": Option(["/opt/vc/bin/vcgencmd"], type=valid_command), - "state_poll": Option(10.0, type=valid_float_f01), - }, - "fan": { - "daemon": Option("kvmd-fan", type=valid_stripped_string), - "unix": Option("", type=valid_abs_path, if_empty="", unpack_as="unix_path"), - "timeout": Option(5.0, type=valid_float_f01), - "state_poll": Option(5.0, type=valid_float_f01), - }, - }, - - "log_reader": { - "enabled": Option(True, type=valid_bool), - }, - - "hid": { - "type": Option("", type=valid_stripped_string_not_empty), - - "keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file), - "ignore_keys": Option([], type=functools.partial(valid_string_list, subval=valid_hid_key)), - - "mouse_x_range": { - "min": Option(MouseRange.MIN, type=valid_hid_mouse_move), - "max": Option(MouseRange.MAX, type=valid_hid_mouse_move), - }, - "mouse_y_range": { - "min": Option(MouseRange.MIN, type=valid_hid_mouse_move), - "max": Option(MouseRange.MAX, type=valid_hid_mouse_move), - }, - - # Dynamic content - }, - - "atx": { - "type": Option("", type=valid_stripped_string_not_empty), - # Dynamic content - }, - - "msd": { - "type": Option("", type=valid_stripped_string_not_empty), - # Dynamic content - }, - - "streamer": { - "forever": Option(False, type=valid_bool), - - "reset_delay": Option(1.0, type=valid_float_f0), - "shutdown_delay": Option(10.0, type=valid_float_f01), - "state_poll": Option(1.0, type=valid_float_f01), - - "quality": Option(80, type=valid_stream_quality, if_empty=0), - - "resolution": { - "default": Option("", type=valid_stream_resolution, if_empty="", unpack_as="resolution"), - "available": Option( - [], - type=functools.partial(valid_string_list, subval=valid_stream_resolution), - unpack_as="available_resolutions", - ), - }, - - "desired_fps": { - "default": Option(40, type=valid_stream_fps, unpack_as="desired_fps"), - "min": Option(0, type=valid_stream_fps, unpack_as="desired_fps_min"), - "max": Option(70, type=valid_stream_fps, unpack_as="desired_fps_max"), - }, - - "h264_bitrate": { - "default": Option(0, type=valid_stream_h264_bitrate, if_empty=0, unpack_as="h264_bitrate"), - "min": Option(25, type=valid_stream_h264_bitrate, unpack_as="h264_bitrate_min"), - "max": Option(20000, type=valid_stream_h264_bitrate, unpack_as="h264_bitrate_max"), - }, - - "h264_gop": { - "default": Option(30, type=valid_stream_h264_gop, unpack_as="h264_gop"), - "min": Option(0, type=valid_stream_h264_gop, unpack_as="h264_gop_min"), - "max": Option(60, type=valid_stream_h264_gop, unpack_as="h264_gop_max"), - }, - - "unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"), - "timeout": Option(2.0, type=valid_float_f01), - - "process_name_prefix": Option("kvmd/streamer"), - - "cmd": Option(["/bin/true"], type=valid_command), - "cmd_remove": Option([], type=valid_options), - "cmd_append": Option([], type=valid_options), - }, - - "ocr": { - "langs": Option(["eng"], type=valid_string_list, unpack_as="default_langs"), - "tessdata": Option("/usr/share/tessdata", type=valid_stripped_string_not_empty, unpack_as="data_dir_path") - }, - - "snapshot": { - "idle_interval": Option(0.0, type=valid_float_f0), - "live_interval": Option(0.0, type=valid_float_f0), - - "wakeup_key": Option("", type=valid_hid_key, if_empty=""), - "wakeup_move": Option(0, type=valid_hid_mouse_move), - - "online_delay": Option(5.0, type=valid_float_f0), - "retries": Option(10, type=valid_int_f1), - "retries_delay": Option(3.0, type=valid_float_f01), - }, - - "gpio": { - "state_poll": Option(0.1, type=valid_float_f01), - "drivers": {}, # Dynamic content - "scheme": {}, # Dymanic content - "view": { - "header": { - "title": Option("GPIO", type=valid_ugpio_view_title), - }, - "table": Option([], type=valid_ugpio_view_table), - }, - }, - }, - - "pst": { - "server": { - "unix": Option("/run/kvmd/pst.sock", type=valid_abs_path, unpack_as="unix_path"), - "unix_rm": Option(True, type=valid_bool), - "unix_mode": Option(0o660, type=valid_unix_mode), - "heartbeat": Option(15.0, type=valid_float_f01), - "access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---" - " referer='%{Referer}i'; user_agent='%{User-Agent}i'"), - }, - - "ro_retries_delay": Option(10.0, type=valid_float_f01), - "ro_cleanup_delay": Option(3.0, type=valid_float_f01), - - "remount_cmd": Option([ - "/usr/bin/sudo", "--non-interactive", - "/usr/bin/kvmd-helper-pst-remount", "{mode}", - ], type=valid_command), - }, - - "otg": { - "vendor_id": Option(0x1D6B, type=valid_otg_id), # Linux Foundation - "product_id": Option(0x0104, type=valid_otg_id), # Multifunction Composite Gadget - "manufacturer": Option("PiKVM", type=valid_stripped_string), - "product": Option("Composite KVM Device", type=valid_stripped_string), - "serial": Option("CAFEBABE", type=valid_stripped_string, if_none=None), - "device_version": Option(-1, type=functools.partial(valid_number, min=-1, max=0xFFFF)), - "usb_version": Option(0x0200, type=valid_otg_id), - "max_power": Option(250, type=functools.partial(valid_number, min=50, max=500)), - "remote_wakeup": Option(False, type=valid_bool), - - "gadget": Option("kvmd", type=valid_otg_gadget), - "config": Option("PiKVM device", type=valid_stripped_string_not_empty), - "udc": Option("", type=valid_stripped_string), - "init_delay": Option(3.0, type=valid_float_f01), - - "user": Option("kvmd", type=valid_user), - "meta": Option("/run/kvmd/otg", type=valid_abs_path), - - "devices": { - "hid": { - "keyboard": { - "start": Option(True, type=valid_bool), - }, - "mouse": { - "start": Option(True, type=valid_bool), - }, - }, - - "msd": { - "start": Option(True, type=valid_bool), - "default": { - "stall": Option(False, type=valid_bool), - "cdrom": Option(True, type=valid_bool), - "rw": Option(False, type=valid_bool), - "removable": Option(True, type=valid_bool), - "fua": Option(True, type=valid_bool), - }, - }, - - "serial": { - "enabled": Option(False, type=valid_bool), - "start": Option(True, type=valid_bool), - }, - - "ethernet": { - "enabled": Option(False, type=valid_bool), - "start": Option(True, type=valid_bool), - "driver": Option("ecm", type=valid_otg_ethernet), - "host_mac": Option("", type=valid_mac, if_empty=""), - "kvm_mac": Option("", type=valid_mac, if_empty=""), - }, - - "drives": { - "enabled": Option(False, type=valid_bool), - "start": Option(True, type=valid_bool), - "count": Option(1, type=valid_int_f1), - "default": { - "stall": Option(False, type=valid_bool), - "cdrom": Option(False, type=valid_bool), - "rw": Option(True, type=valid_bool), - "removable": Option(True, type=valid_bool), - "fua": Option(True, type=valid_bool), - }, - }, - }, - }, - - "otgnet": { - "iface": { - "net": Option("169.254.0.0/28", type=functools.partial(valid_net, v6=False)), - "ip_cmd": Option(["/usr/bin/ip"], type=valid_command), - }, - - "firewall": { - "allow_icmp": Option(True, type=valid_bool), - "allow_tcp": Option([], type=valid_ports_list), - "allow_udp": Option([67], type=valid_ports_list), - "forward_iface": Option("", type=valid_stripped_string), - "iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command), - }, - - "commands": { - "pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command), - "pre_start_cmd_remove": Option([], type=valid_options), - "pre_start_cmd_append": Option([], type=valid_options), - - "post_start_cmd": Option([ - "/usr/bin/systemd-run", - "--unit=kvmd-otgnet-dnsmasq", - "/usr/sbin/dnsmasq", - "--conf-file=/dev/null", - "--pid-file", - "--user=dnsmasq", - "--interface={iface}", - "--port=0", - "--dhcp-range={dhcp_ip_begin},{dhcp_ip_end},24h", - "--dhcp-leasefile=/run/kvmd/dnsmasq.lease", - "--dhcp-option={dhcp_option_3}", - "--dhcp-option=6", - "--keep-in-foreground", - ], type=valid_command), - "post_start_cmd_remove": Option([], type=valid_options), - "post_start_cmd_append": Option([], type=valid_options), - - "pre_stop_cmd": Option([ - "/usr/bin/systemctl", - "stop", - "kvmd-otgnet-dnsmasq", - ], type=valid_command), - "pre_stop_cmd_remove": Option([], type=valid_options), - "pre_stop_cmd_append": Option([], type=valid_options), - - "post_stop_cmd": Option(["/bin/true", "post-stop"], type=valid_command), - "post_stop_cmd_remove": Option([], type=valid_options), - "post_stop_cmd_append": Option([], type=valid_options), - }, - }, - - "ipmi": { - "server": { - "host": Option("::", type=valid_ip_or_host), - "port": Option(623, type=valid_port), - "timeout": Option(10.0, type=valid_float_f01), - }, - - "kvmd": { - "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), - "timeout": Option(5.0, type=valid_float_f01), - }, - - "auth": { - "file": Option("/etc/kvmd/ipmipasswd", type=valid_abs_file, unpack_as="path"), - }, - - "sol": { - "device": Option("", type=valid_abs_path, if_empty="", unpack_as="sol_device_path"), - "speed": Option(115200, type=valid_tty_speed, unpack_as="sol_speed"), - "select_timeout": Option(0.1, type=valid_float_f01, unpack_as="sol_select_timeout"), - "proxy_port": Option(0, type=valid_port, unpack_as="sol_proxy_port"), - }, - }, - - "vnc": { - "desired_fps": Option(30, type=valid_stream_fps), - "mouse_output": Option("usb", type=valid_hid_mouse_output), - "keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file), - - "server": { - "host": Option("::", type=valid_ip_or_host), - "port": Option(5900, type=valid_port), - "max_clients": Option(10, type=valid_int_f1), - - "no_delay": Option(True, type=valid_bool), - "keepalive": { - "enabled": Option(True, type=valid_bool, unpack_as="keepalive_enabled"), - "idle": Option(10, type=functools.partial(valid_number, min=1, max=3600), unpack_as="keepalive_idle"), - "interval": Option(3, type=functools.partial(valid_number, min=1, max=60), unpack_as="keepalive_interval"), - "count": Option(3, type=functools.partial(valid_number, min=1, max=10), unpack_as="keepalive_count"), - }, - - "tls": { - "ciphers": Option("ALL:@SECLEVEL=0", type=valid_ssl_ciphers, if_empty=""), - "timeout": Option(30.0, type=valid_float_f01), - "x509": { - "cert": Option("/etc/kvmd/vnc/ssl/server.crt", type=valid_abs_file, if_empty=""), - "key": Option("/etc/kvmd/vnc/ssl/server.key", type=valid_abs_file, if_empty=""), - }, - }, - }, - - "kvmd": { - "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), - "timeout": Option(5.0, type=valid_float_f01), - }, - - "streamer": { - "unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"), - "timeout": Option(5.0, type=valid_float_f01), - }, - - "memsink": { - "jpeg": { - "sink": Option("", unpack_as="obj"), - "lock_timeout": Option(1.0, type=valid_float_f01), - "wait_timeout": Option(1.0, type=valid_float_f01), - "drop_same_frames": Option(1.0, type=valid_float_f0), - }, - "h264": { - "sink": Option("", unpack_as="obj"), - "lock_timeout": Option(1.0, type=valid_float_f01), - "wait_timeout": Option(1.0, type=valid_float_f01), - "drop_same_frames": Option(0.0, type=valid_float_f0), - }, - }, - - "auth": { - "vncauth": { - "enabled": Option(False, type=valid_bool), - "file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="path"), - }, - "vencrypt": { - "enabled": Option(True, type=valid_bool, unpack_as="vencrypt_enabled"), - }, - }, - }, - - "janus": { - "stun": { - "host": Option("stun.l.google.com", type=valid_ip_or_host, unpack_as="stun_host"), - "port": Option(19302, type=valid_port, unpack_as="stun_port"), - "timeout": Option(5.0, type=valid_float_f01, unpack_as="stun_timeout"), - "retries": Option(5, type=valid_int_f1, unpack_as="stun_retries"), - "retries_delay": Option(5.0, type=valid_float_f01, unpack_as="stun_retries_delay"), - }, - - "check": { - "interval": Option(10.0, type=valid_float_f01, unpack_as="check_interval"), - "retries": Option(5, type=valid_int_f1, unpack_as="check_retries"), - "retries_delay": Option(5.0, type=valid_float_f01, unpack_as="check_retries_delay"), - }, - - "cmd": Option([ - "/usr/bin/janus", - "--disable-colors", - "--plugins-folder=/usr/lib/ustreamer/janus", - "--configs-folder=/etc/kvmd/janus", - "--interface={src_ip}", - "{o_stun_server}", - ], type=valid_command), - "cmd_remove": Option([], type=valid_options), - "cmd_append": Option([], type=valid_options), - }, - - "watchdog": { - "rtc": Option(0, type=valid_int_f0), - "timeout": Option(300, type=valid_int_f1), - "interval": Option(30, type=valid_int_f1), - }, - } + except UnknownPluginError as ex: + raise ConfigError(str(ex)) # We don't want to know too much about exception diff --git a/kvmd/apps/_logging.py b/kvmd/apps/_logging.py new file mode 100644 index 000000000..f9fc9e9da --- /dev/null +++ b/kvmd/apps/_logging.py @@ -0,0 +1,57 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import logging +import logging.config + + +# ===== +def init_logging(cli: bool) -> None: + logging.captureWarnings(True) + logging.config.dictConfig({ + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "console": { + "()": "logging.Formatter", + "style": "{", + "format": "{name:30.30} {levelname:>7} --- {message}", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + "formatter": "console", + }, + }, + "root": { + "level": "INFO", + "handlers": ["console"], + }, + }) + if cli: + logging.getLogger().handlers[0].setFormatter(logging.Formatter( + "-- {levelname:>7} -- {message}", + style="{", + )) diff --git a/kvmd/apps/_scheme.py b/kvmd/apps/_scheme.py new file mode 100644 index 000000000..9d27f4c0a --- /dev/null +++ b/kvmd/apps/_scheme.py @@ -0,0 +1,798 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import copy + +from ..tools import walk_dict +from ..tools import is_dict + +from ..plugins.auth import get_auth_service_class +from ..plugins.hid import get_hid_class +from ..plugins.atx import get_atx_class +from ..plugins.msd import get_msd_class + +from ..plugins.ugpio import UserGpioModes +from ..plugins.ugpio import BaseUserGpioDriver +from ..plugins.ugpio import get_ugpio_driver_class + +from ..yamlconf import Hint +from ..yamlconf import Option +from ..yamlconf import Section +from ..yamlconf import manual_validated +from ..yamlconf.merger import yaml_merge + +from ..validators.basic import valid_stripped_string +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_int_f0 +from ..validators.basic import valid_int_f1 +from ..validators.basic import valid_float_f0 +from ..validators.basic import valid_float_f01 +from ..validators.basic import valid_string_list + +from ..validators.auth import valid_user +from ..validators.auth import valid_users_list +from ..validators.auth import valid_expire + +from ..validators.os import valid_abs_path +from ..validators.os import valid_unix_mode +from ..validators.os import valid_options +from ..validators.os import valid_command + +from ..validators.net import valid_ip +from ..validators.net import valid_ip_or_host +from ..validators.net import valid_net +from ..validators.net import valid_port +from ..validators.net import valid_ports_list +from ..validators.net import valid_mac +from ..validators.net import valid_ssl_ciphers + +from ..validators.hid import valid_hid_key +from ..validators.hid import valid_hid_mouse_output +from ..validators.hid import valid_hid_mouse_move + +from ..validators.kvm import valid_stream_quality +from ..validators.kvm import valid_stream_fps +from ..validators.kvm import valid_stream_resolution +from ..validators.kvm import valid_stream_h264_bitrate +from ..validators.kvm import valid_stream_h264_gop + +from ..validators.ugpio import valid_ugpio_view_title +from ..validators.ugpio import valid_ugpio_view_table +from ..validators.ugpio import valid_ugpio_driver +from ..validators.ugpio import valid_ugpio_channel +from ..validators.ugpio import valid_ugpio_mode + +from ..validators.hw import valid_tty_speed +from ..validators.hw import valid_otg_gadget +from ..validators.hw import valid_otg_id +from ..validators.hw import valid_otg_ethernet + + +# ===== +def patch_raw(raw: dict) -> None: # pylint: disable=too-many-branches + for params in walk_dict(raw, "kvmd", "gpio", "scheme").values(): + if is_dict(params): + if params.get("pulse") == False: # noqa: E712 # pylint: disable=singleton-comparison + params["pulse"] = {"delay": 0} + + # === Legacy === + + if is_dict(raw, "otgnet"): + for (sub, cmd) in [("iface", "ip_cmd"), ("firewall", "iptables_cmd")]: + if is_dict(raw["otgnet"], sub): + if raw["otgnet"][sub].get(cmd): + raw["otgnet"].setdefault("commands", {}) + raw["otgnet"]["commands"][cmd] = raw["otgnet"][sub][cmd] + del raw["otgnet"][sub][cmd] + + if is_dict(raw, "otg"): + for (old, new) in [ + ("msd", "msd"), + ("acm", "serial"), + ("drives", "drives"), + ]: + if old in raw["otg"]: + if not is_dict(raw["otg"], "devices"): + raw["otg"]["devices"] = {} + raw["otg"]["devices"][new] = raw["otg"].pop(old) + + if is_dict(raw, "kvmd", "wol"): + if not is_dict(raw["kvmd"], "gpio"): + raw["kvmd"]["gpio"] = {} + for section in ["drivers", "scheme"]: + if not is_dict(raw["kvmd"]["gpio"], section): + raw["kvmd"]["gpio"][section] = {} + raw["kvmd"]["gpio"]["drivers"]["__wol__"] = { + "type": "wol", + **raw["kvmd"].pop("wol"), + } + raw["kvmd"]["gpio"]["scheme"]["__wol__"] = { + "driver": "__wol__", + "pin": 0, + "mode": "output", + "switch": False, + } + + if is_dict(raw, "kvmd", "streamer"): + streamer = raw["kvmd"]["streamer"] + + desired_fps = streamer.get("desired_fps") + if desired_fps is not None and not is_dict(desired_fps): + streamer["desired_fps"] = {"default": desired_fps} + + max_fps = streamer.get("max_fps") + if max_fps is not None: + if not is_dict(streamer, "desired_fps"): + streamer["desired_fps"] = {} + streamer["desired_fps"]["max"] = max_fps + del streamer["max_fps"] + + resolution = streamer.get("resolution") + if resolution is not None and not is_dict(resolution): + streamer["resolution"] = {"default": resolution} + + available_resolutions = streamer.get("available_resolutions") + if available_resolutions is not None: + if not is_dict(streamer, "resolution"): + streamer["resolution"] = {} + streamer["resolution"]["available"] = available_resolutions + del streamer["available_resolutions"] + + +def patch_dynamic( # pylint: disable=too-many-locals + main: dict, + override: dict, + config: Section, + scheme: dict, + load_auth: bool=False, + load_hid: bool=False, + load_atx: bool=False, + load_msd: bool=False, + load_gpio: bool=False, + load_all: bool=False, +) -> bool: + + if load_all: + load_auth = load_hid = load_atx = load_msd = load_gpio = True + + rebuild = False + + if load_auth: + scheme["kvmd"]["auth"]["internal"].update(get_auth_service_class(config.kvmd.auth.internal.type).get_plugin_options()) + if config.kvmd.auth.external.type: + scheme["kvmd"]["auth"]["external"].update(get_auth_service_class(config.kvmd.auth.external.type).get_plugin_options()) + rebuild = True + + for (load, section, get_class) in [ + (load_hid, "hid", get_hid_class), + (load_atx, "atx", get_atx_class), + (load_msd, "msd", get_msd_class), + ]: + if load: + scheme["kvmd"][section].update(get_class(getattr(config.kvmd, section).type).get_plugin_options()) + rebuild = True + + if load_gpio: + raw = copy.deepcopy(main) + yaml_merge(raw, override) + + driver: str + drivers: dict[str, type[BaseUserGpioDriver]] = {} # Name to drivers + for (driver, params) in { # type: ignore + "__gpio__": {}, + **walk_dict(raw, "kvmd", "gpio", "drivers"), + }.items(): + with manual_validated(driver, "kvmd", "gpio", "drivers", ""): + driver = valid_ugpio_driver(driver) + + driver_type = valid_stripped_string_not_empty(params.get("type", "gpio")) + driver_class = get_ugpio_driver_class(driver_type) + drivers[driver] = driver_class + + # Пустая строка нужна, чтобы увидеть добавленные драйверы в -M. + # Значение все равно будет перезаписано из raw при make_config(), + # поэтому нет никакой проблемы, что пустая строка является + # невалидным дефолтом. + driver_type_default = ("gpio" if driver == "__gpio__" else "") + + scheme["kvmd"]["gpio"]["drivers"][driver] = { + "type": Option(driver_type_default, type=valid_stripped_string_not_empty), + **driver_class.get_plugin_options() + } + + path = ("kvmd", "gpio", "scheme") + for (channel, params) in walk_dict(raw, *path).items(): + with manual_validated(channel, *path, ""): + channel = valid_ugpio_channel(channel) + + driver = params.get("driver", "__gpio__") + with manual_validated(driver, *path, channel, "driver"): + driver = valid_ugpio_driver(driver, set(drivers)) + + mode: str = params.get("mode", "") + with manual_validated(mode, *path, channel, "mode"): + mode = valid_ugpio_mode(mode, drivers[driver].get_modes()) + + if params.get("pulse") == False: # noqa: E712 # pylint: disable=singleton-comparison + params["pulse"] = {"delay": 0} + + scheme["kvmd"]["gpio"]["scheme"][channel] = { + "driver": Option("__gpio__", type=valid_ugpio_driver.mk(variants=set(drivers))), + "pin": Option(None, type=drivers[driver].get_pin_validator()), + "mode": Option("", type=valid_ugpio_mode.mk(variants=drivers[driver].get_modes())), + "inverted": Option(False, type=valid_bool), + **({ + "busy_delay": Option(0.2, type=valid_float_f01), + "initial": Option(False, type=valid_bool, if_none=None), + "switch": Option(True, type=valid_bool), + "pulse": { # type: ignore + "delay": Option(0.1, type=valid_float_f0), + "min_delay": Option(0.1, type=valid_float_f01), + "max_delay": Option(0.1, type=valid_float_f01), + }, + } if mode == UserGpioModes.OUTPUT else { # input + "debounce": Option(0.1, type=valid_float_f0), + }) + } + + rebuild = True + + return rebuild + + +def make_config_scheme() -> dict: + return { + "kvmd": { + "server": { + "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), + "unix_rm": Option(True, type=valid_bool), + "unix_mode": Option(0o660, type=valid_unix_mode, hint=Hint.OCT), + "heartbeat": Option(15.0, type=valid_float_f01), + "access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---" + " referer='%{Referer}i'; user_agent='%{User-Agent}i'"), + }, + + "auth": { + "enabled": Option(True, type=valid_bool), + "expire": Option(0, type=valid_expire), + "extend": Option(False, type=valid_bool), + + "usc": { + "users": Option([], type=valid_users_list), # PiKVM username has a same regex as a UNIX username + "groups": Option([], type=valid_users_list), # groupname has a same regex as a username + "kvmd_users": Option([], type=valid_users_list), # Internal users + "kvmd_groups": Option(["kvmd-selfauth"], type=valid_users_list), # Internal groups + }, + + "internal": { + "type": Option("htpasswd"), + "force_users": Option([], type=valid_users_list), + # Dynamic content + }, + + "external": { + "type": Option("", type=valid_stripped_string), + # Dynamic content + }, + + "totp": { + "secret": { + "file": Option("/etc/kvmd/totp.secret", type=valid_abs_path, if_empty=""), + }, + }, + }, + + "info": { # Accessed via global config, see kvmd/info for details + "meta": Option("/etc/kvmd/meta.yaml", type=valid_abs_path), + "extras": Option("/usr/share/kvmd/extras", type=valid_abs_path), + "hw": { + "platform": Option("/usr/lib/kvmd/platform", type=valid_abs_path, unpack_as="platform_path"), + "vcgencmd_cmd": Option(["/usr/bin/vcgencmd"], type=valid_command), + "ignore_past": Option(False, type=valid_bool), + "state_poll": Option(5.0, type=valid_float_f01), + }, + "fan": { + "daemon": Option("kvmd-fan", type=valid_stripped_string), + "unix": Option("", type=valid_abs_path, if_empty="", unpack_as="unix_path"), + "timeout": Option(5.0, type=valid_float_f01), + "state_poll": Option(5.0, type=valid_float_f01), + }, + }, + + "log_reader": { + "enabled": Option(True, type=valid_bool), + }, + + "prometheus": { + "auth": { + "enabled": Option(True, type=valid_bool), + }, + }, + + "hid": { + "type": Option("", type=valid_stripped_string_not_empty), + "keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_path), + # Dynamic content + }, + + "atx": { + "type": Option("", type=valid_stripped_string_not_empty), + # Dynamic content + }, + + "msd": { + "type": Option("", type=valid_stripped_string_not_empty), + # Dynamic content + }, + + "streamer": { + "forever": Option(False, type=valid_bool), + + "reset_delay": Option(1.0, type=valid_float_f0), + "shutdown_delay": Option(10.0, type=valid_float_f01), + "state_poll": Option(1.0, type=valid_float_f01), + + "quality": Option(80, type=valid_stream_quality, if_empty=0), + + "resolution": { + "default": Option("", type=valid_stream_resolution, if_empty="", unpack_as="resolution"), + "available": Option( + [], + type=valid_string_list.mk(subval=valid_stream_resolution), + unpack_as="available_resolutions", + ), + }, + + "desired_fps": { + "default": Option(30, type=valid_stream_fps, unpack_as="desired_fps"), + "min": Option(0, type=valid_stream_fps, unpack_as="desired_fps_min"), + "max": Option(90, type=valid_stream_fps, unpack_as="desired_fps_max"), + }, + + "h264_bitrate": { + "default": Option(0, type=valid_stream_h264_bitrate, if_empty=0, unpack_as="h264_bitrate"), + "min": Option(25, type=valid_stream_h264_bitrate, unpack_as="h264_bitrate_min"), + "max": Option(20000, type=valid_stream_h264_bitrate, unpack_as="h264_bitrate_max"), + }, + + "h264_gop": { + "default": Option(30, type=valid_stream_h264_gop, unpack_as="h264_gop"), + "min": Option(0, type=valid_stream_h264_gop, unpack_as="h264_gop_min"), + "max": Option(60, type=valid_stream_h264_gop, unpack_as="h264_gop_max"), + }, + + "unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"), + "timeout": Option(2.0, type=valid_float_f01), + "snapshot_timeout": Option(5.0, type=valid_float_f01), # error_delay * 3 + 1 + + "process_name_prefix": Option("kvmd/streamer"), + + "pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command), + "pre_start_cmd_remove": Option([], type=valid_options), + "pre_start_cmd_append": Option([], type=valid_options), + + "cmd": Option(["/bin/true"], type=valid_command), + "cmd_remove": Option([], type=valid_options), + "cmd_append": Option([], type=valid_options), + + "post_stop_cmd": Option(["/bin/true", "post-stop"], type=valid_command), + "post_stop_cmd_remove": Option([], type=valid_options), + "post_stop_cmd_append": Option([], type=valid_options), + }, + + "ocr": { + "langs": Option(["eng"], type=valid_string_list, unpack_as="default_langs"), + "tessdata": Option("/usr/share/tessdata", type=valid_stripped_string_not_empty, unpack_as="data_dir_path") + }, + + "snapshot": { + "idle_interval": Option(0.0, type=valid_float_f0), + "live_interval": Option(0.0, type=valid_float_f0), + + "wakeup_key": Option("", type=valid_hid_key, if_empty=""), + "wakeup_move": Option(0, type=valid_hid_mouse_move), + + "online_delay": Option(5.0, type=valid_float_f0), + "retries": Option(10, type=valid_int_f1), + "retries_delay": Option(3.0, type=valid_float_f01), + }, + + "gpio": { + "state_poll": Option(0.1, type=valid_float_f01), + "drivers": {}, # Dynamic content + "scheme": {}, # Dymanic content + "view": { + "header": { + "title": Option("GPIO", type=valid_ugpio_view_title), + }, + "table": Option([], type=valid_ugpio_view_table, hint=Hint.INLINED_ITEMS), + }, + }, + + "switch": { + "device": Option("/dev/kvmd-switch", type=valid_abs_path, unpack_as="device_path"), + "default_edid": Option("/etc/kvmd/switch-edid.hex", type=valid_abs_path, unpack_as="default_edid_path"), + "ignore_hpd_on_top": Option(False, type=valid_bool), + }, + }, + + "media": { + "server": { + "unix": Option("/run/kvmd/media.sock", type=valid_abs_path, unpack_as="unix_path"), + "unix_rm": Option(True, type=valid_bool), + "unix_mode": Option(0o660, type=valid_unix_mode, hint=Hint.OCT), + "heartbeat": Option(15.0, type=valid_float_f01), + "access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---" + " referer='%{Referer}i'; user_agent='%{User-Agent}i'"), + }, + + "memsink": { + "jpeg": { + "sink": Option("", unpack_as="obj"), + "lock_timeout": Option(1.0, type=valid_float_f01), + "wait_timeout": Option(1.0, type=valid_float_f01), + "drop_same_frames": Option(0.0, type=valid_float_f0), + }, + "h264": { + "sink": Option("", unpack_as="obj"), + "lock_timeout": Option(1.0, type=valid_float_f01), + "wait_timeout": Option(1.0, type=valid_float_f01), + "drop_same_frames": Option(0.0, type=valid_float_f0), + }, + }, + }, + + "pst": { + "server": { + "unix": Option("/run/kvmd/pst.sock", type=valid_abs_path, unpack_as="unix_path"), + "unix_rm": Option(True, type=valid_bool), + "unix_mode": Option(0o660, type=valid_unix_mode, hint=Hint.OCT), + "heartbeat": Option(15.0, type=valid_float_f01), + "access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---" + " referer='%{Referer}i'; user_agent='%{User-Agent}i'"), + }, + + "ro_retries_delay": Option(10.0, type=valid_float_f01), + "ro_cleanup_delay": Option(3.0, type=valid_float_f01), + + "remount_cmd": Option([ + "/usr/bin/sudo", "--non-interactive", + "/usr/bin/kvmd-helper-pst-remount", "{mode}", + ], type=valid_command), + }, + + "otg": { + "vendor_id": Option(0x1D6B, type=valid_otg_id, hint=Hint.HEX), # Linux Foundation + "product_id": Option(0x0104, type=valid_otg_id, hint=Hint.HEX), # Multifunction Composite Gadget + "manufacturer": Option("PiKVM", type=valid_stripped_string), + "product": Option("PiKVM Composite Device", type=valid_stripped_string), + "serial": Option("CAFEBABE", type=valid_stripped_string, if_none=None), + "config": Option(None, type=valid_stripped_string, if_none=None), + "device_version": Option(-1, type=valid_number.mk(min=-1, max=0xFFFF), hint=Hint.HEX), + "usb_version": Option(0x0200, type=valid_otg_id, hint=Hint.HEX), + "max_power": Option(250, type=valid_number.mk(min=50, max=500)), + "remote_wakeup": Option(True, type=valid_bool), + + "gadget": Option("kvmd", type=valid_otg_gadget), + "udc": Option("", type=valid_stripped_string), + "endpoints": Option(9, type=valid_int_f0), + "init_delay": Option(3.0, type=valid_float_f01), + + "user": Option("kvmd", type=valid_user), + "meta": Option("/run/kvmd/otg", type=valid_abs_path), + + "devices": { + "hid": { + "keyboard": { + "start": Option(True, type=valid_bool), + }, + "mouse": { + "start": Option(True, type=valid_bool), + }, + "mouse_alt": { + "start": Option(True, type=valid_bool), + }, + }, + + "msd": { + "start": Option(True, type=valid_bool), + "default": { + "stall": Option(False, type=valid_bool), + "cdrom": Option(True, type=valid_bool), + "rw": Option(False, type=valid_bool), + "removable": Option(True, type=valid_bool), + "fua": Option(True, type=valid_bool), + "inquiry_string": { + "cdrom": { + "vendor": Option(None, type=valid_stripped_string, if_none=None), + "product": Option("Optical Drive", type=valid_stripped_string), + "revision": Option("1.00", type=valid_stripped_string), + }, + "flash": { + "vendor": Option(None, type=valid_stripped_string, if_none=None), + "product": Option("Flash Drive", type=valid_stripped_string), + "revision": Option("1.00", type=valid_stripped_string), + }, + }, + }, + }, + + "serial": { + "enabled": Option(False, type=valid_bool), + "start": Option(True, type=valid_bool), + }, + + "ethernet": { + "enabled": Option(False, type=valid_bool), + "start": Option(True, type=valid_bool), + "driver": Option("ecm", type=valid_otg_ethernet), + "host_mac": Option("", type=valid_mac, if_empty=""), + "kvm_mac": Option("", type=valid_mac, if_empty=""), + }, + + "audio": { + "enabled": Option(False, type=valid_bool), + "start": Option(True, type=valid_bool), + }, + + "drives": { + "enabled": Option(False, type=valid_bool), + "start": Option(True, type=valid_bool), + "count": Option(1, type=valid_int_f1), + "default": { + "stall": Option(False, type=valid_bool), + "cdrom": Option(False, type=valid_bool), + "rw": Option(True, type=valid_bool), + "removable": Option(True, type=valid_bool), + "fua": Option(True, type=valid_bool), + "inquiry_string": { + "cdrom": { + "vendor": Option(None, type=valid_stripped_string, if_none=None), + "product": Option("Optical Drive", type=valid_stripped_string), + "revision": Option("1.00", type=valid_stripped_string), + }, + "flash": { + "vendor": Option(None, type=valid_stripped_string, if_none=None), + "product": Option("Flash Drive", type=valid_stripped_string), + "revision": Option("1.00", type=valid_stripped_string), + }, + }, + }, + }, + }, + }, + + "otgnet": { + "iface": { + "net": Option("172.30.30.0/24", type=valid_net.mk(v6=False)), + }, + + "firewall": { + "allow_icmp": Option(True, type=valid_bool), + "allow_tcp": Option([], type=valid_ports_list), + "allow_udp": Option([67], type=valid_ports_list), + "forward_iface": Option("", type=valid_stripped_string), + }, + + "commands": { + "ip_cmd": Option(["/usr/bin/ip"], type=valid_command), + "iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command), + "sysctl_cmd": Option(["/usr/sbin/sysctl"], type=valid_command), + + "pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command), + "pre_start_cmd_remove": Option([], type=valid_options), + "pre_start_cmd_append": Option([], type=valid_options), + + "post_start_cmd": Option([ + "/usr/bin/systemd-run", + "--unit=kvmd-otgnet-dnsmasq", + "/usr/sbin/dnsmasq", + "--conf-file=/dev/null", + "--pid-file", + "--user=dnsmasq", + "--interface={iface}", + "--port=0", + "--dhcp-range={dhcp_ip_begin},{dhcp_ip_end},24h", + "--dhcp-leasefile=/run/kvmd/dnsmasq.lease", + "--dhcp-option={dhcp_option_3}", + "--dhcp-option=6", + "--keep-in-foreground", + ], type=valid_command), + "post_start_cmd_remove": Option([], type=valid_options), + "post_start_cmd_append": Option([], type=valid_options), + + "pre_stop_cmd": Option([ + "/usr/bin/systemctl", + "stop", + "kvmd-otgnet-dnsmasq", + ], type=valid_command), + "pre_stop_cmd_remove": Option([], type=valid_options), + "pre_stop_cmd_append": Option([], type=valid_options), + + "post_stop_cmd": Option(["/bin/true", "post-stop"], type=valid_command), + "post_stop_cmd_remove": Option([], type=valid_options), + "post_stop_cmd_append": Option([], type=valid_options), + }, + }, + + "ipmi": { + "server": { + "host": Option("", type=valid_ip_or_host, if_empty=""), + "port": Option(623, type=valid_port), + "timeout": Option(10.0, type=valid_float_f01), + }, + + "kvmd": { + "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), + "timeout": Option(5.0, type=valid_float_f01), + }, + + "auth": { + "file": Option("/etc/kvmd/ipmipasswd", type=valid_abs_path, unpack_as="path"), + }, + + "sol": { + "device": Option("", type=valid_abs_path, if_empty="", unpack_as="sol_device_path"), + "speed": Option(115200, type=valid_tty_speed, unpack_as="sol_speed"), + "select_timeout": Option(0.1, type=valid_float_f01, unpack_as="sol_select_timeout"), + "proxy_port": Option(0, type=valid_port, unpack_as="sol_proxy_port"), + }, + }, + + "vnc": { + "desired_fps": Option(30, type=valid_stream_fps), + "mouse_output": Option("usb", type=valid_hid_mouse_output), + "keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_path), + "scroll_rate": Option(4, type=valid_number.mk(min=1, max=30)), + + "server": { + "host": Option("", type=valid_ip_or_host, if_empty=""), + "port": Option(5900, type=valid_port), + "max_clients": Option(10, type=valid_int_f1), + + "no_delay": Option(True, type=valid_bool), + "keepalive": { + "enabled": Option(True, type=valid_bool, unpack_as="keepalive_enabled"), + "idle": Option(10, type=valid_number.mk(min=1, max=3600), unpack_as="keepalive_idle"), + "interval": Option(3, type=valid_number.mk(min=1, max=60), unpack_as="keepalive_interval"), + "count": Option(3, type=valid_number.mk(min=1, max=10), unpack_as="keepalive_count"), + }, + + "tls": { + "ciphers": Option("ALL:@SECLEVEL=0", type=valid_ssl_ciphers, if_empty=""), + "timeout": Option(30.0, type=valid_float_f01), + "x509": { + "cert": Option("/etc/kvmd/vnc/ssl/server.crt", type=valid_abs_path, if_empty=""), + "key": Option("/etc/kvmd/vnc/ssl/server.key", type=valid_abs_path, if_empty=""), + }, + }, + }, + + "kvmd": { + "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), + "timeout": Option(5.0, type=valid_float_f01), + }, + + "streamer": { + "unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"), + "timeout": Option(5.0, type=valid_float_f01), + }, + + "memsink": { + "jpeg": { + "sink": Option("", unpack_as="obj"), + "lock_timeout": Option(1.0, type=valid_float_f01), + "wait_timeout": Option(1.0, type=valid_float_f01), + "drop_same_frames": Option(1.0, type=valid_float_f0), + }, + "h264": { + "sink": Option("", unpack_as="obj"), + "lock_timeout": Option(1.0, type=valid_float_f01), + "wait_timeout": Option(1.0, type=valid_float_f01), + "drop_same_frames": Option(0.0, type=valid_float_f0), + }, + }, + + "auth": { + "vncauth": { + "enabled": Option(False, type=valid_bool, unpack_as="vncpass_enabled"), + "file": Option("/etc/kvmd/vncpasswd", type=valid_abs_path, unpack_as="vncpass_path"), + }, + "vencrypt": { + "enabled": Option(True, type=valid_bool, unpack_as="vencrypt_enabled"), + }, + }, + }, + + "localhid": { + "kvmd": { + "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), + "timeout": Option(5.0, type=valid_float_f01), + }, + }, + + "nginx": { + "http": { + "ipv4": Option("0.0.0.0", type=valid_ip.mk(v6=False)), + "ipv6": Option("::", type=valid_ip.mk(v4=False)), + "port": Option(80, type=valid_port), + }, + "https": { + "enabled": Option(True, type=valid_bool), + "ipv4": Option("0.0.0.0", type=valid_ip.mk(v6=False)), + "ipv6": Option("::", type=valid_ip.mk(v4=False)), + "port": Option(443, type=valid_port), + }, + }, + + "janus": { + "stun": { + "host": Option("stun.l.google.com", type=valid_ip_or_host, unpack_as="stun_host"), + "port": Option(19302, type=valid_port, unpack_as="stun_port"), + "timeout": Option(5.0, type=valid_float_f01, unpack_as="stun_timeout"), + "retries": Option(5, type=valid_int_f1, unpack_as="stun_retries"), + "retries_delay": Option(5.0, type=valid_float_f01, unpack_as="stun_retries_delay"), + }, + + "check": { + "interval": Option(10.0, type=valid_float_f01, unpack_as="check_interval"), + "retries": Option(5, type=valid_int_f1, unpack_as="check_retries"), + "retries_delay": Option(5.0, type=valid_float_f01, unpack_as="check_retries_delay"), + }, + + "cmd": Option([ + "/usr/bin/janus", + "--disable-colors", + "--plugins-folder=/usr/lib/ustreamer/janus", + "--configs-folder=/etc/kvmd/janus", + "--interface={src_ip}", + "{o_stun_server}", + ], type=valid_command), + "cmd_remove": Option([], type=valid_options), + "cmd_append": Option([], type=valid_options), + }, + + "watchdog": { + "rtc": Option(0, type=valid_int_f0), + "timeout": Option(300, type=valid_int_f1), + "interval": Option(30, type=valid_int_f1), + }, + + "oled": { + "width": Option(128, type=valid_int_f1), + "height": Option(32, type=valid_int_f1), + "rotate": Option(0, type=valid_number.mk(min=0, max=3)), + "fahrenheit": Option(False, type=valid_bool), + + "contrast": { + "low": Option(1, type=valid_number.mk(min=0, max=255)), + "normal": Option(64, type=valid_number.mk(min=0, max=255)), + }, + + "kvmd": { + "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), + "timeout": Option(5.0, type=valid_float_f01), + }, + }, + } diff --git a/kvmd/apps/edidconf/__init__.py b/kvmd/apps/edidconf/__init__.py index c42f4c91e..1b792baf2 100644 --- a/kvmd/apps/edidconf/__init__.py +++ b/kvmd/apps/edidconf/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,174 +22,89 @@ import sys import os -import re import subprocess -import contextlib import argparse import time -from typing import IO -from typing import Generator from typing import Callable from ...validators.basic import valid_bool from ...validators.basic import valid_int_f0 +from ...edid import EdidNoBlockError +from ...edid import Edid + # from .. import init # ===== -@contextlib.contextmanager -def _smart_open(path: str, mode: str) -> Generator[IO, None, None]: - fd = (0 if "r" in mode else 1) - with (os.fdopen(fd, mode, closefd=False) if path == "-" else open(path, mode)) as file: - yield file - if "w" in mode: - file.flush() - - -class _Edid: - # https://en.wikipedia.org/wiki/Extended_Display_Identification_Data - - def __init__(self, path: str) -> None: - with _smart_open(path, "rb") as file: - data = file.read() - if data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"): - self.__data = list(data) - else: - text = re.sub(r"\s", "", data.decode()) - self.__data = [ - int(text[index:index + 2], 16) - for index in range(0, len(text), 2) - ] - assert len(self.__data) == 256, f"Invalid EDID length: {len(self.__data)}, should be 256 bytes" - assert self.__data[126] == 1, "Zero extensions number" - assert (self.__data[128], self.__data[129]) == (0x02, 0x03), "Can't find CEA-861" - - # ===== - - def write_hex(self, path: str) -> None: - self.__update_checksums() - text = "\n".join( - "".join( - f"{item:0{2}X}" - for item in self.__data[index:index + 16] - ) - for index in range(0, len(self.__data), 16) - ) + "\n" - with _smart_open(path, "w") as file: - file.write(text) - - def write_bin(self, path: str) -> None: - self.__update_checksums() - with _smart_open(path, "wb") as file: - file.write(bytes(self.__data)) - - def __update_checksums(self) -> None: - self.__data[127] = 256 - (sum(self.__data[:127]) % 256) - self.__data[255] = 256 - (sum(self.__data[128:255]) % 256) - - # ===== - - def get_mfc_id(self) -> str: - raw = self.__data[8] << 8 | self.__data[9] - return bytes([ - ((raw >> 10) & 0b11111) + 0x40, - ((raw >> 5) & 0b11111) + 0x40, - (raw & 0b11111) + 0x40, - ]).decode("ascii") - - def set_mfc_id(self, mfc_id: str) -> None: - assert len(mfc_id) == 3, "Mfc ID must be 3 characters long" - data = mfc_id.upper().encode("ascii") - for byte in data: - assert 0x41 <= byte <= 0x5A, "Mfc ID must contain only A-Z characters" - raw = ( - (data[2] - 0x40) - | ((data[1] - 0x40) << 5) - | ((data[0] - 0x40) << 10) - ) - self.__data[8] = (raw >> 8) & 0xFF - self.__data[9] = raw & 0xFF - - # ===== - - def get_product_id(self) -> int: - return (self.__data[10] | self.__data[11] << 8) - - def set_product_id(self, product_id: int) -> None: - assert 0 <= product_id <= 0xFFFF, f"Product ID should be from 0 to {0xFFFF}" - self.__data[10] = product_id & 0xFF - self.__data[11] = (product_id >> 8) & 0xFF - - # ===== - - def get_serial(self) -> int: - return ( - self.__data[12] - | self.__data[13] << 8 - | self.__data[14] << 16 - | self.__data[15] << 24 - ) - - def set_serial(self, serial: int) -> None: - assert 0 <= serial <= 0xFFFFFFFF, f"Serial should be from 0 to {0xFFFFFFFF}" - self.__data[12] = serial & 0xFF - self.__data[13] = (serial >> 8) & 0xFF - self.__data[14] = (serial >> 16) & 0xFF - self.__data[15] = (serial >> 24) & 0xFF - - # ===== - - def get_monitor_name(self) -> str: - index = self.__find_mnd_text() - return bytes(self.__data[index:index + 13]).decode("cp437").strip() - - def set_monitor_name(self, name: str) -> None: - index = self.__find_mnd_text() - encoded = (name[:13] + "\n" + " " * 12)[:13].encode("cp437") - for (offset, byte) in enumerate(encoded): - self.__data[index + offset] = byte +def _format_bool(value: bool) -> str: + return ("yes" if value else "no") - def __find_mnd_text(self) -> int: - for index in [54, 72, 90, 108]: - if self.__data[index + 3] == 0xFC: - return index + 5 - raise AssertionError("Can't find DTD Monitor name") - # ===== +def _make_format_hex(size: int) -> Callable[[int], str]: + return (lambda value: ("0x{:0%dX} ({})" % (size * 2)).format(value, value)) - def get_audio(self) -> bool: - return bool(self.__data[131] & 0b01000000) - def set_audio(self, enabled: bool) -> None: - if enabled: - self.__data[131] |= 0b01000000 - else: - self.__data[131] &= (0xFF - 0b01000000) # ~X +def _print_edid(edid: Edid) -> None: + for (key, get, fmt) in [ + ("Manufacturer ID:", edid.get_mfc_id, str), + ("Product ID: ", edid.get_product_id, _make_format_hex(2)), + ("Serial number: ", edid.get_serial, _make_format_hex(4)), + ("Monitor name: ", edid.get_monitor_name, str), + ("Monitor serial: ", edid.get_monitor_serial, str), + ("Audio: ", edid.get_audio, _format_bool), + ]: + try: + print(key, fmt(get()), file=sys.stderr) # type: ignore + except EdidNoBlockError: + pass -def _format_bool(value: bool) -> str: - return ("yes" if value else "no") +def _find_out2_edid_path() -> str: + card = os.path.basename(os.readlink("/dev/dri/by-path/platform-gpu-card")) + path = f"/sys/devices/platform/gpu/drm/{card}/{card}-HDMI-A-2" + with open(os.path.join(path, "status")) as file: + if file.read().startswith("d"): + raise SystemExit("No display found") + return os.path.join(path, "edid") -def _make_format_hex(size: int) -> Callable[[int], str]: - return (lambda value: ("0x{:0%dX} ({})" % (size * 2)).format(value, value)) +def _adopt_out2_ids(dest: Edid) -> None: + src = Edid.from_file(_find_out2_edid_path()) + dest.set_monitor_name(src.get_monitor_name()) + try: + dest.get_monitor_serial() + except EdidNoBlockError: + pass + else: + try: + ser = src.get_monitor_serial() + except EdidNoBlockError: + ser = "{:08X}".format(src.get_serial()) + dest.set_monitor_serial(ser) + dest.set_mfc_id(src.get_mfc_id()) + dest.set_product_id(src.get_product_id()) + dest.set_serial(src.get_serial()) # ===== -def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-branches - # (parent_parser, argv, _) = init( +def main() -> None: # pylint: disable=too-many-branches,too-many-statements + # ia = init( # add_help=False, - # argv=argv, + # cli_logging=True, # ) - if argv is None: - argv = sys.argv parser = argparse.ArgumentParser( prog="kvmd-edidconf", description="A simple and primitive KVMD EDID editor", - # parents=[parent_parser], + # parents=[ia.parser], ) + args = sys.argv[1:] # ia.args + + lane2 = ["v0", "v1", "v2", "v3"] + lane4 = ["v4mini", "v4plus"] + presets = lane2 + lane4 + [f"{name}.1080p-by-default" for name in lane2] + [f"{name}.no-1920x1200" for name in lane4] + parser.add_argument("-f", "--edid", dest="edid_path", default="/etc/kvmd/tc358743-edid.hex", help="The hex/bin EDID file path", metavar="") parser.add_argument("--export-hex", @@ -198,8 +113,14 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra help="Export [--edid] file to the new file as a bin data", metavar="") parser.add_argument("--import", dest="imp", help="Import the specified bin/hex EDID to the [--edid] file as a hex text", metavar="") + parser.add_argument("--import-preset", choices=presets, + help="Restore default EDID or choose the preset", metavar=f"{{ {' | '.join(presets)} }}",) + parser.add_argument("--import-display-ids", action="store_true", + help="On PiKVM V4, import and adopt IDs from a physical display connected to the OUT2 port") + parser.add_argument("--import-display", action="store_true", + help="On PiKVM V4, import full EDID from a physical display connected to the OUT2 port") parser.add_argument("--set-audio", type=valid_bool, - help="Enable or disable basic audio", metavar="") + help="Enable or disable audio", metavar="") parser.add_argument("--set-mfc-id", help="Set manufacturer ID (https://uefi.org/pnp_id_list)", metavar="") parser.add_argument("--set-product-id", type=valid_int_f0, @@ -207,25 +128,51 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra parser.add_argument("--set-serial", type=valid_int_f0, help="Set serial number (decimal)", metavar="") parser.add_argument("--set-monitor-name", - help="Set monitor name in DTD/MND (ASCII, max 13 characters)", metavar="") + help="Set monitor name in DTD block (ASCII, max 13 characters)", metavar="") + parser.add_argument("--set-monitor-serial", + help="Set monitor serial in DTD block if exists (ASCII, max 13 characters)", metavar="") parser.add_argument("--clear", action="store_true", help="Clear the EDID in the [--device]") parser.add_argument("--apply", action="store_true", help="Apply [--edid] on the [--device]") parser.add_argument("--device", dest="device_path", default="/dev/kvmd-video", help="The video device", metavar="") - options = parser.parse_args(argv[1:]) - + parser.add_argument("--presets", dest="presets_path", default="/usr/share/kvmd/configs.default/kvmd/edid", + help="Presets directory", metavar="") + options = parser.parse_args(args) + + base: (Edid | None) = None + if options.import_preset: + imp = options.import_preset + if "." in imp: + (base_name, imp) = imp.split(".", 1) # v3.1080p-by-default + base = Edid.from_file(os.path.join(options.presets_path, f"{base_name}.hex")) + imp = f"_{imp}" + options.imp = os.path.join(options.presets_path, f"{imp}.hex") + + if options.import_display: + options.imp = _find_out2_edid_path() + + orig_edid_path = options.edid_path if options.imp: options.export_hex = options.edid_path options.edid_path = options.imp - edid = _Edid(options.edid_path) + edid = Edid.from_file(options.edid_path) changed = False - for cmd in dir(_Edid): + if options.import_display_ids: + _adopt_out2_ids(edid) + changed = True + + for cmd in dir(Edid): if cmd.startswith("set_"): value = getattr(options, cmd) + if value is None and base is not None: + try: + value = getattr(base, cmd.replace("set_", "get_"))() + except EdidNoBlockError: + pass if value is not None: getattr(edid, cmd)(value) changed = True @@ -237,14 +184,7 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra elif changed: edid.write_hex(options.edid_path) - for (key, get, fmt) in [ - ("Manufacturer ID:", edid.get_mfc_id, str), - ("Product ID: ", edid.get_product_id, _make_format_hex(2)), - ("Serial number: ", edid.get_serial, _make_format_hex(4)), - ("Monitor name: ", edid.get_monitor_name, str), - ("Basic audio: ", edid.get_audio, _format_bool), - ]: - print(key, fmt(get()), file=sys.stderr) # type: ignore + _print_edid(edid) try: if options.clear: @@ -259,9 +199,8 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra subprocess.run([ "/usr/bin/v4l2-ctl", f"--device={options.device_path}", - f"--set-edid=file={options.edid_path}", - "--fix-edid-checksums", + f"--set-edid=file={orig_edid_path}", "--info-edid", ], stdout=sys.stderr, check=True) - except subprocess.CalledProcessError as err: - raise SystemExit(str(err)) + except subprocess.CalledProcessError as ex: + raise SystemExit(str(ex)) diff --git a/kvmd/apps/edidconf/__main__.py b/kvmd/apps/edidconf/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/edidconf/__main__.py +++ b/kvmd/apps/edidconf/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/htpasswd/__init__.py b/kvmd/apps/htpasswd/__init__.py index 3d6554846..8d6215341 100644 --- a/kvmd/apps/htpasswd/__init__.py +++ b/kvmd/apps/htpasswd/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,57 +21,41 @@ import sys -import os import getpass -import tempfile import contextlib import textwrap import argparse from typing import Generator -import passlib.apache - from ...yamlconf import Section from ...validators import ValidatorError from ...validators.auth import valid_user from ...validators.auth import valid_passwd +from ...crypto import KvmdHtpasswdFile + +from ... import tools + from .. import init # ===== def _get_htpasswd_path(config: Section) -> str: if config.kvmd.auth.internal.type != "htpasswd": - raise SystemExit(f"Error: KVMD internal auth not using 'htpasswd'" + raise SystemExit(f"Error: KVMD internal auth does not use 'htpasswd'" f" (now configured {config.kvmd.auth.internal.type!r})") return config.kvmd.auth.internal.file @contextlib.contextmanager -def _get_htpasswd_for_write(config: Section) -> Generator[passlib.apache.HtpasswdFile, None, None]: +def _get_htpasswd_for_write(config: Section) -> Generator[KvmdHtpasswdFile, None, None]: path = _get_htpasswd_path(config) - (tmp_fd, tmp_path) = tempfile.mkstemp( - prefix=f".{os.path.basename(path)}.", - dir=os.path.dirname(path), - ) - try: - try: - st = os.stat(path) - with open(path, "rb") as file: - os.write(tmp_fd, file.read()) - os.fchown(tmp_fd, st.st_uid, st.st_gid) - os.fchmod(tmp_fd, st.st_mode) - finally: - os.close(tmp_fd) - htpasswd = passlib.apache.HtpasswdFile(tmp_path) + with tools.atomic_file_edit(path) as tmp_path: + htpasswd = KvmdHtpasswdFile(tmp_path) yield htpasswd htpasswd.save() - os.rename(tmp_path, path) - finally: - if os.path.exists(tmp_path): - os.remove(tmp_path) def _print_invalidate_tip(prepend_nl: bool) -> None: @@ -96,64 +80,98 @@ def _print_invalidate_tip(prepend_nl: bool) -> None: # ==== def _cmd_list(config: Section, _: argparse.Namespace) -> None: - for user in sorted(passlib.apache.HtpasswdFile(_get_htpasswd_path(config)).users()): + for user in sorted(KvmdHtpasswdFile(_get_htpasswd_path(config)).users()): print(user) -def _cmd_set(config: Section, options: argparse.Namespace) -> None: +def _change_user(config: Section, options: argparse.Namespace, create: bool) -> None: with _get_htpasswd_for_write(config) as htpasswd: + assert options.user == options.user.strip() + assert options.user + has_user = (options.user in htpasswd.users()) + if create: + if has_user: + raise SystemExit(f"The user {options.user!r} is already exists") + else: + if not has_user: + raise SystemExit(f"The user {options.user!r} is not exist") + if options.read_stdin: passwd = valid_passwd(input()) else: passwd = valid_passwd(getpass.getpass("Password: ", stream=sys.stderr)) if valid_passwd(getpass.getpass("Repeat: ", stream=sys.stderr)) != passwd: raise SystemExit("Sorry, passwords do not match") + htpasswd.set_password(options.user, passwd) + if has_user and not options.quiet: _print_invalidate_tip(True) +def _cmd_add(config: Section, options: argparse.Namespace) -> None: + _change_user(config, options, create=True) + + +def _cmd_set(config: Section, options: argparse.Namespace) -> None: + _change_user(config, options, create=False) + + def _cmd_delete(config: Section, options: argparse.Namespace) -> None: with _get_htpasswd_for_write(config) as htpasswd: + assert options.user == options.user.strip() + assert options.user + has_user = (options.user in htpasswd.users()) + if not has_user: + raise SystemExit(f"The user {options.user!r} is not exist") + htpasswd.delete(options.user) + if has_user and not options.quiet: _print_invalidate_tip(False) # ===== -def main(argv: (list[str] | None)=None) -> None: - (parent_parser, argv, config) = init( +def main(test_args: (list[str] | None)=None, test_override: (dict | None)=None) -> None: + ia = init( add_help=False, cli_logging=True, - argv=argv, + test_args=test_args, + test_override=test_override, load_auth=True, ) parser = argparse.ArgumentParser( prog="kvmd-htpasswd", description="Manage KVMD users (htpasswd auth only)", - parents=[parent_parser], + parents=[ia.parser], ) parser.set_defaults(cmd=(lambda *_: parser.print_help())) subparsers = parser.add_subparsers() - cmd_list_parser = subparsers.add_parser("list", help="List users") - cmd_list_parser.set_defaults(cmd=_cmd_list) + sub = subparsers.add_parser("list", help="List users") + sub.set_defaults(cmd=_cmd_list) + + sub = subparsers.add_parser("add", help="Add user") + sub.add_argument("user", type=valid_user) + sub.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin") + sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note") + sub.set_defaults(cmd=_cmd_add) - cmd_set_parser = subparsers.add_parser("set", help="Create user or change password") - cmd_set_parser.add_argument("user", type=valid_user) - cmd_set_parser.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin") - cmd_set_parser.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note") - cmd_set_parser.set_defaults(cmd=_cmd_set) + sub = subparsers.add_parser("set", help="Change user's password") + sub.add_argument("user", type=valid_user) + sub.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin") + sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note") + sub.set_defaults(cmd=_cmd_set) - cmd_delete_parser = subparsers.add_parser("del", help="Delete user") - cmd_delete_parser.add_argument("user", type=valid_user) - cmd_delete_parser.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note") - cmd_delete_parser.set_defaults(cmd=_cmd_delete) + sub = subparsers.add_parser("del", help="Delete user") + sub.add_argument("user", type=valid_user) + sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note") + sub.set_defaults(cmd=_cmd_delete) - options = parser.parse_args(argv[1:]) + options = parser.parse_args(ia.args) try: - options.cmd(config, options) - except ValidatorError as err: - raise SystemExit(str(err)) + options.cmd(ia.config, options) + except ValidatorError as ex: + raise SystemExit(str(ex)) diff --git a/kvmd/apps/htpasswd/__main__.py b/kvmd/apps/htpasswd/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/htpasswd/__main__.py +++ b/kvmd/apps/htpasswd/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/ipmi/__init__.py b/kvmd/apps/ipmi/__init__.py index 609822758..24e7085f5 100644 --- a/kvmd/apps/ipmi/__init__.py +++ b/kvmd/apps/ipmi/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -31,16 +31,15 @@ # ===== -def main(argv: (list[str] | None)=None) -> None: +def main() -> None: config = init( prog="kvmd-ipmi", description="IPMI to KVMD proxy", check_run=True, - argv=argv, - )[2].ipmi + ).config.ipmi IpmiServer( - auth_manager=IpmiAuthManager(**config.auth._unpack()), + auth=IpmiAuthManager(**config.auth._unpack()), kvmd=KvmdClient( user_agent=htclient.make_user_agent("KVMD-IPMI"), **config.kvmd._unpack(), diff --git a/kvmd/apps/ipmi/__main__.py b/kvmd/apps/ipmi/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/ipmi/__main__.py +++ b/kvmd/apps/ipmi/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/ipmi/auth.py b/kvmd/apps/ipmi/auth.py index c559a67a3..01d1ea0c7 100644 --- a/kvmd/apps/ipmi/auth.py +++ b/kvmd/apps/ipmi/auth.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,7 +20,13 @@ # ========================================================================== # -import dataclasses +import threading +import functools +import time + +from ...logging import get_logger + +from ... import tools # ===== @@ -29,60 +35,42 @@ def __init__(self, path: str, lineno: int, msg: str) -> None: super().__init__(f"Syntax error at {path}:{lineno}: {msg}") -@dataclasses.dataclass(frozen=True) -class IpmiUserCredentials: - ipmi_user: str - ipmi_passwd: str - kvmd_user: str - kvmd_passwd: str - - class IpmiAuthManager: def __init__(self, path: str) -> None: self.__path = path - with open(path) as file: - self.__credentials = self.__parse_passwd_file(file.read().split("\n")) - - def __contains__(self, ipmi_user: str) -> bool: - return (ipmi_user in self.__credentials) - - def __getitem__(self, ipmi_user: str) -> str: - return self.__credentials[ipmi_user].ipmi_passwd - - def get_credentials(self, ipmi_user: str) -> IpmiUserCredentials: - return self.__credentials[ipmi_user] - - def __parse_passwd_file(self, lines: list[str]) -> dict[str, IpmiUserCredentials]: - credentials: dict[str, IpmiUserCredentials] = {} - for (lineno, line) in enumerate(lines): - if len(line.strip()) == 0 or line.lstrip().startswith("#"): - continue - - if " -> " not in line: - raise IpmiPasswdError(self.__path, lineno, "Missing ' -> ' operator") - - (left, right) = map(str.lstrip, line.split(" -> ", 1)) - for (name, pair) in [("left", left), ("right", right)]: - if ":" not in pair: - raise IpmiPasswdError(self.__path, lineno, f"Missing ':' operator in {name} credentials") - - (ipmi_user, ipmi_passwd) = left.split(":") - ipmi_user = ipmi_user.strip() - if len(ipmi_user) == 0: - raise IpmiPasswdError(self.__path, lineno, "Empty IPMI user (left)") - - (kvmd_user, kvmd_passwd) = right.split(":") - kvmd_user = kvmd_user.strip() - if len(kvmd_user) == 0: - raise IpmiPasswdError(self.__path, lineno, "Empty KVMD user (left)") - - if ipmi_user in credentials: - raise IpmiPasswdError(self.__path, lineno, f"Found duplicating user {ipmi_user!r} (left)") - - credentials[ipmi_user] = IpmiUserCredentials( - ipmi_user=ipmi_user, - ipmi_passwd=ipmi_passwd, - kvmd_user=kvmd_user, - kvmd_passwd=kvmd_passwd, - ) - return credentials + self.__lock = threading.Lock() + + def get(self, user: str) -> (str | None): + creds = self.__get_credentials(int(time.time())) + return creds.get(user) + + @functools.lru_cache(maxsize=1) + def __get_credentials(self, ts: int) -> dict[str, str]: + _ = ts + with self.__lock: + try: + return self.__read_credentials() + except Exception as ex: + get_logger().error("%s", tools.efmt(ex)) + return {} + + def __read_credentials(self) -> dict[str, str]: + with open(self.__path) as file: + creds: dict[str, str] = {} + for (lineno, line) in tools.passwds_splitted(file.read()): + if " -> " in line: # Compatibility with old ipmipasswd file format + line = line.split(" -> ", 1)[0] + + if ":" not in line: + raise IpmiPasswdError(self.__path, lineno, "Missing ':' operator") + + (user, passwd) = line.split(":", 1) + user = user.strip() + if len(user) == 0: + raise IpmiPasswdError(self.__path, lineno, "Empty IPMI user") + + if user in creds: + raise IpmiPasswdError(self.__path, lineno, f"Found duplicating user {user!r}") + + creds[user] = passwd + return creds diff --git a/kvmd/apps/ipmi/server.py b/kvmd/apps/ipmi/server.py index 3679ce106..a613be213 100644 --- a/kvmd/apps/ipmi/server.py +++ b/kvmd/apps/ipmi/server.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -24,7 +24,6 @@ import select import asyncio import threading -import multiprocessing import functools import queue @@ -40,7 +39,8 @@ from ...clients.kvmd import KvmdClient -from ... import aiotools +from ... import aiomulti +from ... import network from .auth import IpmiAuthManager @@ -52,7 +52,7 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute def __init__( self, - auth_manager: IpmiAuthManager, + auth: IpmiAuthManager, kvmd: KvmdClient, host: str, @@ -65,9 +65,10 @@ def __init__( sol_proxy_port: int, ) -> None: - super().__init__(authdata=auth_manager, address=host, port=port) + host = network.get_listen_host(host) + + super().__init__(authdata=auth, address=host, port=port) - self.__auth_manager = auth_manager self.__kvmd = kvmd self.__host = host @@ -98,6 +99,7 @@ def run(self) -> None: # ===== def handle_raw_request(self, request: dict, session: IpmiServerSession) -> None: + # Parameter 'request' has been renamed to 'req' in overriding method handler = { (6, 1): (lambda _, session: self.send_device_id(session)), # Get device ID (6, 7): self.__get_power_state_handler, # Power state @@ -142,13 +144,13 @@ def __get_chassis_status_handler(self, _: dict, session: IpmiServerSession) -> N data = [int(result["leds"]["power"]), 0, 0] session.send_ipmi_response(data=data) - def __chassis_control_handler(self, request: dict, session: IpmiServerSession) -> None: + def __chassis_control_handler(self, req: dict, session: IpmiServerSession) -> None: action = { 0: "off_hard", 1: "on", 3: "reset_hard", 5: "off", - }.get(request["data"][0], "") + }.get(req["data"][0], "") if action: if not self.__make_request(session, f"atx.switch_power({action})", "atx.switch_power", action=action): code = 0xC0 # Try again later @@ -161,18 +163,17 @@ def __chassis_control_handler(self, request: dict, session: IpmiServerSession) - def __make_request(self, session: IpmiServerSession, name: str, func_path: str, **kwargs): # type: ignore async def runner(): # type: ignore logger = get_logger(0) - credentials = self.__auth_manager.get_credentials(session.username.decode()) - logger.info("[%s]: Performing request %s from user %r (IPMI) as %r (KVMD)", - session.sockaddr[0], name, credentials.ipmi_user, credentials.kvmd_user) + logger.info("[%s]: Performing request %s from IPMI user %r ...", + session.sockaddr[0], name, session.username.decode()) try: - async with self.__kvmd.make_session(credentials.kvmd_user, credentials.kvmd_passwd) as kvmd_session: + async with self.__kvmd.make_session() as kvmd_session: func = functools.reduce(getattr, func_path.split("."), kvmd_session) return (await func(**kwargs)) - except (aiohttp.ClientError, asyncio.TimeoutError) as err: - logger.error("[%s]: Can't perform request %s: %s", session.sockaddr[0], name, err) + except (aiohttp.ClientError, asyncio.TimeoutError) as ex: + logger.error("[%s]: Can't perform request %s: %s", session.sockaddr[0], name, ex) raise - return aiotools.run_sync(runner()) + return asyncio.run(runner()) # ===== @@ -211,9 +212,9 @@ def __is_sol_activated(self) -> bool: def __start_sol_worker(self, session: IpmiServerSession) -> None: assert self.__sol_console is None assert self.__sol_thread is None - user_queue: "multiprocessing.Queue[bytes]" = multiprocessing.Queue() # Only for select() - self.__sol_console = IpmiConsole(session, user_queue.put_nowait) - self.__sol_thread = threading.Thread(target=self.__sol_worker, args=(user_queue,), daemon=True) + user_q: aiomulti.AioMpQueue[bytes] = aiomulti.AioMpQueue() # Only for select() + self.__sol_console = IpmiConsole(session, user_q.put_nowait) + self.__sol_thread = threading.Thread(target=self.__sol_worker, args=(user_q,), daemon=True) self.__sol_thread.start() def __stop_sol_worker(self) -> None: @@ -231,22 +232,22 @@ def __close_sol_console(self) -> None: self.__sol_console = None get_logger(0).info("SOL closed") - def __sol_worker(self, user_queue: "multiprocessing.Queue[bytes]") -> None: + def __sol_worker(self, user_q: aiomulti.AioMpQueue[bytes]) -> None: logger = get_logger(0) logger.info("Starting SOL worker ...") try: assert self.__sol_console is not None with serial.Serial(self.__sol_device_path, self.__sol_speed) as tty: logger.info("Opened SOL port %s at speed=%d", self.__sol_device_path, self.__sol_speed) - qr = user_queue._reader # type: ignore # pylint: disable=protected-access + qr = user_q.get_reader() try: while not self.__sol_stop: ready = select.select([qr, tty], [], [], self.__sol_select_timeout)[0] if qr in ready: data = b"" - for _ in range(user_queue.qsize()): # Don't hold on this with [not empty()] + for _ in range(user_q.qsize()): # Don't hold on this with [not empty()] try: - data += user_queue.get_nowait() + data += user_q.get_nowait() except queue.Empty: break if data: diff --git a/kvmd/apps/janus/__init__.py b/kvmd/apps/janus/__init__.py index 92f6e26df..1c6b3165b 100644 --- a/kvmd/apps/janus/__init__.py +++ b/kvmd/apps/janus/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -26,13 +26,12 @@ # ===== -def main(argv: (list[str] | None)=None) -> None: +def main() -> None: config = init( prog="kvmd-Janus", description="Janus WebRTC Gateway Runner", check_run=True, - argv=argv, - )[2].janus + ).config.janus JanusRunner( **config.stun._unpack(), diff --git a/kvmd/apps/janus/__main__.py b/kvmd/apps/janus/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/janus/__main__.py +++ b/kvmd/apps/janus/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/janus/runner.py b/kvmd/apps/janus/runner.py index e08fade05..01ddefb7d 100644 --- a/kvmd/apps/janus/runner.py +++ b/kvmd/apps/janus/runner.py @@ -1,26 +1,26 @@ import asyncio import asyncio.subprocess -import socket import dataclasses -import netifaces - from ... import tools from ... import aiotools from ... import aioproc +from ... import network from ...logging import get_logger +from .stun import StunNatType from .stun import Stun # ===== @dataclasses.dataclass(frozen=True) class _Netcfg: - nat_type: str = dataclasses.field(default="") - src_ip: str = dataclasses.field(default="") - ext_ip: str = dataclasses.field(default="") + nat_type: StunNatType = dataclasses.field(default=StunNatType.ERROR) + src_ip: str = dataclasses.field(default="") + ext_ip: str = dataclasses.field(default="") stun_host: str = dataclasses.field(default="") + stun_ip: str = dataclasses.field(default="") stun_port: int = dataclasses.field(default=0) @@ -91,36 +91,14 @@ async def __run(self) -> None: await asyncio.sleep(self.__check_interval) async def __get_netcfg(self) -> _Netcfg: - src_ip = (self.__get_default_ip() or "0.0.0.0") - (stun, (nat_type, ext_ip)) = await self.__get_stun_info(src_ip) - return _Netcfg(nat_type, src_ip, ext_ip, stun.host, stun.port) - - def __get_default_ip(self) -> str: - try: - gws = netifaces.gateways() - if "default" in gws: - for proto in [socket.AF_INET, socket.AF_INET6]: - if proto in gws["default"]: - iface = gws["default"][proto][1] - addrs = netifaces.ifaddresses(iface) - return addrs[proto][0]["addr"] - - for iface in netifaces.interfaces(): - if not iface.startswith(("lo", "docker")): - addrs = netifaces.ifaddresses(iface) - for proto in [socket.AF_INET, socket.AF_INET6]: - if proto in addrs: - return addrs[proto][0]["addr"] - except Exception as err: - get_logger().error("Can't get default IP: %s", tools.efmt(err)) - return "" - - async def __get_stun_info(self, src_ip: str) -> tuple[Stun, tuple[str, str]]: try: - return (self.__stun, (await self.__stun.get_info(src_ip, 0))) - except Exception as err: - get_logger().error("Can't get STUN info: %s", tools.efmt(err)) - return (self.__stun, ("", "")) + src_ip = network.get_first_iface().ip + except Exception as ex: + get_logger().error("Can't get default IP: %s", tools.efmt(ex)) + src_ip = "0.0.0.0" + info = await self.__stun.get_info(src_ip, 0) + # В текущей реализации _Netcfg() это копия StunInfo() + return _Netcfg(**dataclasses.asdict(info)) # ===== @@ -162,7 +140,7 @@ async def __janus_task_loop(self, netcfg: _Netcfg) -> None: # pylint: disable=t async def __start_janus_proc(self, netcfg: _Netcfg) -> None: assert self.__janus_proc is None placeholders = { - "o_stun_server": f"--stun-server={netcfg.stun_host}:{netcfg.stun_port}", + "o_stun_server": f"--stun-server={netcfg.stun_ip}:{netcfg.stun_port}", **{ key: str(value) for (key, value) in dataclasses.asdict(netcfg).items() @@ -177,7 +155,10 @@ async def __start_janus_proc(self, netcfg: _Netcfg) -> None: part.format(**placeholders) for part in cmd ] - self.__janus_proc = await aioproc.run_process(cmd) + self.__janus_proc = await aioproc.run_process( + cmd=cmd, + env={"JANUS_USTREAMER_WEB_ICE_URL": f"stun:{netcfg.stun_host}:{netcfg.stun_port}"}, + ) get_logger(0).info("Started Janus pid=%d: %s", self.__janus_proc.pid, tools.cmdfmt(cmd)) async def __kill_janus_proc(self) -> None: diff --git a/kvmd/apps/janus/stun.py b/kvmd/apps/janus/stun.py index 5fea9da66..e93965abd 100644 --- a/kvmd/apps/janus/stun.py +++ b/kvmd/apps/janus/stun.py @@ -4,37 +4,48 @@ import struct import secrets import dataclasses +import enum from ... import tools -from ... import aiotools from ...logging import get_logger # ===== +class StunNatType(enum.Enum): + ERROR = "" + BLOCKED = "Blocked" + OPEN_INTERNET = "Open Internet" + SYMMETRIC_UDP_FW = "Symmetric UDP Firewall" + FULL_CONE_NAT = "Full Cone NAT" + RESTRICTED_NAT = "Restricted NAT" + RESTRICTED_PORT_NAT = "Restricted Port NAT" + SYMMETRIC_NAT = "Symmetric NAT" + CHANGED_ADDR_ERROR = "Error when testing on Changed-IP and Port" + + +@dataclasses.dataclass(frozen=True) +class StunInfo: + nat_type: StunNatType + src_ip: str + ext_ip: str + stun_host: str + stun_ip: str + stun_port: int + + @dataclasses.dataclass(frozen=True) -class StunAddress: - ip: str +class _StunAddress: + ip: str port: int @dataclasses.dataclass(frozen=True) -class StunResponse: - ok: bool - ext: (StunAddress | None) = dataclasses.field(default=None) - src: (StunAddress | None) = dataclasses.field(default=None) - changed: (StunAddress | None) = dataclasses.field(default=None) - - -class StunNatType: - BLOCKED = "Blocked" - OPEN_INTERNET = "Open Internet" - SYMMETRIC_UDP_FW = "Symmetric UDP Firewall" - FULL_CONE_NAT = "Full Cone NAT" - RESTRICTED_NAT = "Restricted NAT" - RESTRICTED_PORT_NAT = "Restricted Port NAT" - SYMMETRIC_NAT = "Symmetric NAT" - CHANGED_ADDR_ERROR = "Error when testing on Changed-IP and Port" +class _StunResponse: + ok: bool + ext: (_StunAddress | None) = dataclasses.field(default=None) + src: (_StunAddress | None) = dataclasses.field(default=None) + changed: (_StunAddress | None) = dataclasses.field(default=None) # ===== @@ -50,58 +61,108 @@ def __init__( retries_delay: float, ) -> None: - self.host = host - self.port = port + self.__host = host + self.__port = port self.__timeout = timeout self.__retries = retries self.__retries_delay = retries_delay - self.__sock: (socket.socket | None) = None + self.__stun_ip = "" + + async def get_info(self, src_ip: str, src_port: int) -> StunInfo: + stun_ip = self.__stun_ip + nat_type = StunNatType.ERROR + ext_ip = "" - async def get_info(self, src_ip: str, src_port: int) -> tuple[str, str]: - (family, _, _, _, addr) = socket.getaddrinfo(src_ip, src_port, type=socket.SOCK_DGRAM)[0] try: - with socket.socket(family, socket.SOCK_DGRAM) as self.__sock: - self.__sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.__sock.settimeout(self.__timeout) - self.__sock.bind(addr) - (nat_type, response) = await self.__get_nat_type(src_ip) - return (nat_type, (response.ext.ip if response.ext is not None else "")) - finally: - self.__sock = None - - async def __get_nat_type(self, src_ip: str) -> tuple[str, StunResponse]: # pylint: disable=too-many-return-statements - first = await self.__make_request("First probe") + (src_fam, _, _, _, src_addr) = (await self.__retried_getaddrinfo_udp(src_ip, src_port))[0] + + stun_ips = [ + stun_addr[0] + for (stun_fam, _, _, _, stun_addr) in (await self.__retried_getaddrinfo_udp(self.__host, self.__port)) + if stun_fam == src_fam + ] + if not stun_ips: + raise RuntimeError(f"Can't resolve {src_fam.name} address for STUN") + if not self.__stun_ip or self.__stun_ip not in stun_ips: + # On new IP, changed family, etc. + self.__stun_ip = stun_ips[0] + stun_ip = self.__stun_ip + + with socket.socket(src_fam, socket.SOCK_DGRAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.settimeout(self.__timeout) + sock.bind(src_addr) + (nat_type, resp) = await self.__get_nat_type(sock, stun_ip, src_ip) + ext_ip = (resp.ext.ip if resp.ext is not None else "") + except Exception as ex: + get_logger(0).error("Can't get STUN info: %s", tools.efmt(ex)) + + return StunInfo( + nat_type=nat_type, + src_ip=src_ip, + ext_ip=ext_ip, + stun_host=self.__host, + stun_ip=stun_ip, + stun_port=self.__port, + ) + + async def __retried_getaddrinfo_udp(self, host: str, port: int) -> list: + retries = self.__retries + while True: + try: + return (await asyncio.to_thread(socket.getaddrinfo, host, port, type=socket.SOCK_DGRAM)) + except Exception: + retries -= 1 + if retries == 0: + raise + await asyncio.sleep(self.__retries_delay) + + async def __get_nat_type( # pylint: disable=too-many-return-statements + self, + sock: socket.SocketType, + stun_ip: str, + src_ip: str, + ) -> tuple[StunNatType, _StunResponse]: + + first = await self.__make_request(sock, "First probe", stun_ip, b"") if not first.ok: return (StunNatType.BLOCKED, first) - request = struct.pack(">HHI", 0x0003, 0x0004, 0x00000006) # Change-Request - response = await self.__make_request("Change request [ext_ip == src_ip]", request) + req = struct.pack(">HHI", 0x0003, 0x0004, 0x00000006) # Change-Request + resp = await self.__make_request(sock, "Change request [ext_ip == src_ip]", stun_ip, req) if first.ext is not None and first.ext.ip == src_ip: - if response.ok: - return (StunNatType.OPEN_INTERNET, response) - return (StunNatType.SYMMETRIC_UDP_FW, response) + if resp.ok: + return (StunNatType.OPEN_INTERNET, resp) + return (StunNatType.SYMMETRIC_UDP_FW, resp) - if response.ok: - return (StunNatType.FULL_CONE_NAT, response) + if resp.ok: + return (StunNatType.FULL_CONE_NAT, resp) if first.changed is None: raise RuntimeError(f"Changed addr is None: {first}") - response = await self.__make_request("Change request [ext_ip != src_ip]", addr=first.changed) - if not response.ok: - return (StunNatType.CHANGED_ADDR_ERROR, response) + resp = await self.__make_request(sock, "Change request [ext_ip != src_ip]", first.changed, b"") + if not resp.ok: + return (StunNatType.CHANGED_ADDR_ERROR, resp) - if response.ext == first.ext: - request = struct.pack(">HHI", 0x0003, 0x0004, 0x00000002) - response = await self.__make_request("Change port", request, addr=first.changed.ip) - if response.ok: - return (StunNatType.RESTRICTED_NAT, response) - return (StunNatType.RESTRICTED_PORT_NAT, response) + if resp.ext == first.ext: + req = struct.pack(">HHI", 0x0003, 0x0004, 0x00000002) + resp = await self.__make_request(sock, "Change port", first.changed.ip, req) + if resp.ok: + return (StunNatType.RESTRICTED_NAT, resp) + return (StunNatType.RESTRICTED_PORT_NAT, resp) - return (StunNatType.SYMMETRIC_NAT, response) + return (StunNatType.SYMMETRIC_NAT, resp) + + async def __make_request( + self, + sock: socket.SocketType, + ctx: str, + addr: (_StunAddress | str), + req: bytes, + ) -> _StunResponse: - async def __make_request(self, ctx: str, request: bytes=b"", addr: (StunAddress | str | None)=None) -> StunResponse: # TODO: Support IPv6 and RFC 5389 # The first 4 bytes of the response are the Type (2) and Length (2) # The 5th byte is Reserved @@ -111,32 +172,29 @@ async def __make_request(self, ctx: str, request: bytes=b"", addr: (StunAddress # More info at: https://tools.ietf.org/html/rfc3489#section-11.2.1 # And at: https://tools.ietf.org/html/rfc5389#section-15.1 - if isinstance(addr, StunAddress): + if isinstance(addr, _StunAddress): addr_t = (addr.ip, addr.port) - elif isinstance(addr, str): - addr_t = (addr, self.port) - else: - assert addr is None - addr_t = (self.host, self.port) + else: # str + addr_t = (addr, self.__port) # https://datatracker.ietf.org/doc/html/rfc5389#section-6 trans_id = b"\x21\x12\xA4\x42" + secrets.token_bytes(12) - (response, error) = (b"", "") + (resp, error) = (b"", "") for _ in range(self.__retries): - (response, error) = await self.__inner_make_request(trans_id, request, addr_t) + (resp, error) = await self.__inner_make_request(sock, trans_id, req, addr_t) if not error: break await asyncio.sleep(self.__retries_delay) if error: get_logger(0).error("%s: Can't perform STUN request after %d retries; last error: %s", ctx, self.__retries, error) - return StunResponse(ok=False) + return _StunResponse(ok=False) - parsed: dict[str, StunAddress] = {} + parsed: dict[str, _StunAddress] = {} offset = 0 - remaining = len(response) + remaining = len(resp) while remaining > 0: - (attr_type, attr_len) = struct.unpack(">HH", response[offset : offset + 4]) # noqa: E203 + (attr_type, attr_len) = struct.unpack(">HH", resp[offset : offset + 4]) # noqa: E203 offset += 4 field = { 0x0001: "ext", # MAPPED-ADDRESS @@ -145,40 +203,45 @@ async def __make_request(self, ctx: str, request: bytes=b"", addr: (StunAddress 0x0005: "changed", # CHANGED-ADDRESS }.get(attr_type) if field is not None: - parsed[field] = self.__parse_address(response[offset:], (trans_id if attr_type == 0x0020 else b"")) + parsed[field] = self.__parse_address(resp[offset:], (trans_id if attr_type == 0x0020 else b"")) offset += attr_len remaining -= (4 + attr_len) - return StunResponse(ok=True, **parsed) + return _StunResponse(ok=True, **parsed) - async def __inner_make_request(self, trans_id: bytes, request: bytes, addr: tuple[str, int]) -> tuple[bytes, str]: - assert self.__sock is not None + async def __inner_make_request( + self, + sock: socket.SocketType, + trans_id: bytes, + req: bytes, + addr: tuple[str, int], + ) -> tuple[bytes, str]: - request = struct.pack(">HH", 0x0001, len(request)) + trans_id + request # Bind Request + req = struct.pack(">HH", 0x0001, len(req)) + trans_id + req # Bind Request try: - await aiotools.run_async(self.__sock.sendto, request, addr) - except Exception as err: - return (b"", f"Send error: {tools.efmt(err)}") + await asyncio.to_thread(sock.sendto, req, addr) + except Exception as ex: + return (b"", f"Send error: {tools.efmt(ex)}") try: - response = (await aiotools.run_async(self.__sock.recvfrom, 2048))[0] - except Exception as err: - return (b"", f"Recv error: {tools.efmt(err)}") - - (response_type, payload_len) = struct.unpack(">HH", response[:4]) - if response_type != 0x0101: - return (b"", f"Invalid response type: {response_type:#06x}") - if trans_id != response[4:20]: + resp = (await asyncio.to_thread(sock.recvfrom, 2048))[0] + except Exception as ex: + return (b"", f"Recv error: {tools.efmt(ex)}") + + (resp_type, payload_len) = struct.unpack(">HH", resp[:4]) + if resp_type != 0x0101: + return (b"", f"Invalid response type: {resp_type:#06x}") + if trans_id != resp[4:20]: return (b"", "Transaction ID mismatch") - return (response[20 : 20 + payload_len], "") # noqa: E203 + return (resp[20 : 20 + payload_len], "") # noqa: E203 - def __parse_address(self, data: bytes, trans_id: bytes) -> StunAddress: + def __parse_address(self, data: bytes, trans_id: bytes) -> _StunAddress: family = data[1] port = struct.unpack(">H", self.__trans_xor(data[2:4], trans_id))[0] if family == 0x01: - return StunAddress(str(ipaddress.IPv4Address(self.__trans_xor(data[4:8], trans_id))), port) + return _StunAddress(str(ipaddress.IPv4Address(self.__trans_xor(data[4:8], trans_id))), port) elif family == 0x02: - return StunAddress(str(ipaddress.IPv6Address(self.__trans_xor(data[4:20], trans_id))), port) + return _StunAddress(str(ipaddress.IPv6Address(self.__trans_xor(data[4:20], trans_id))), port) raise RuntimeError(f"Unknown family; received: {family}") def __trans_xor(self, data: bytes, trans_id: bytes) -> bytes: diff --git a/kvmd/apps/kvmd/__init__.py b/kvmd/apps/kvmd/__init__.py index f02085e1d..a730c3f29 100644 --- a/kvmd/apps/kvmd/__init__.py +++ b/kvmd/apps/kvmd/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -35,28 +35,28 @@ from .streamer import Streamer from .snapshoter import Snapshoter from .ocr import Ocr +from .switch import Switch from .server import KvmdServer # ===== -def main(argv: (list[str] | None)=None) -> None: +def main() -> None: config = init( prog="kvmd", description="The main PiKVM daemon", - argv=argv, check_run=True, load_auth=True, load_hid=True, load_atx=True, load_msd=True, load_gpio=True, - )[2] + ).config msd_kwargs = config.kvmd.msd._unpack(ignore=["type"]) if config.kvmd.msd.type == "otg": msd_kwargs["gadget"] = config.otg.gadget # XXX: Small crutch to pass gadget name to the plugin - hid_kwargs = config.kvmd.hid._unpack(ignore=["type", "keymap", "ignore_keys", "mouse_x_range", "mouse_y_range"]) + hid_kwargs = config.kvmd.hid._unpack(ignore=["type", "keymap"]) if config.kvmd.hid.type == "otg": hid_kwargs["udc"] = config.otg.udc # XXX: Small crutch to pass UDC to the plugin @@ -73,22 +73,31 @@ def main(argv: (list[str] | None)=None) -> None: ) KvmdServer( - auth_manager=AuthManager( + auth=AuthManager( enabled=config.auth.enabled, + expire=config.auth.expire, + extend=config.auth.extend, + usc_users=(config.auth.usc.kvmd_users + config.auth.usc.users), + usc_groups=(config.auth.usc.kvmd_groups + config.auth.usc.groups), + unauth_paths=([] if config.prometheus.auth.enabled else ["/export/prometheus/metrics"]), - internal_type=config.auth.internal.type, - internal_kwargs=config.auth.internal._unpack(ignore=["type", "force_users"]), - force_internal_users=config.auth.internal.force_users, + int_type=config.auth.internal.type, + int_kwargs=config.auth.internal._unpack(ignore=["type", "force_users"]), + force_int_users=config.auth.internal.force_users, - external_type=config.auth.external.type, - external_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}), + ext_type=config.auth.external.type, + ext_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}), totp_secret_path=config.auth.totp.secret.file, ), - info_manager=InfoManager(global_config), + im=InfoManager(global_config), log_reader=(LogReader() if config.log_reader.enabled else None), - user_gpio=UserGpio(config.gpio, global_config.otg), + ugpio=UserGpio(config.gpio, global_config.otg), ocr=Ocr(**config.ocr._unpack()), + switch=Switch( + pst_unix_path=global_config.pst.server.unix, + **config.switch._unpack(), + ), hid=hid, atx=get_atx_class(config.atx.type)(**config.atx._unpack(ignore=["type"])), @@ -102,9 +111,6 @@ def main(argv: (list[str] | None)=None) -> None: ), keymap_path=config.hid.keymap, - ignore_keys=config.hid.ignore_keys, - mouse_x_range=(config.hid.mouse_x_range.min, config.hid.mouse_x_range.max), - mouse_y_range=(config.hid.mouse_y_range.min, config.hid.mouse_y_range.max), stream_forever=config.streamer.forever, ).run(**config.server._unpack()) diff --git a/kvmd/apps/kvmd/__main__.py b/kvmd/apps/kvmd/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/kvmd/__main__.py +++ b/kvmd/apps/kvmd/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/kvmd/api/__init__.py b/kvmd/apps/kvmd/api/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/kvmd/apps/kvmd/api/__init__.py +++ b/kvmd/apps/kvmd/api/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/kvmd/api/atx.py b/kvmd/apps/kvmd/api/atx.py index b4d5dab56..df952b7bb 100644 --- a/kvmd/apps/kvmd/api/atx.py +++ b/kvmd/apps/kvmd/api/atx.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -45,9 +45,9 @@ async def __state_handler(self, _: Request) -> Response: return make_json_response(await self.__atx.get_state()) @exposed_http("POST", "/atx/power") - async def __power_handler(self, request: Request) -> Response: - action = valid_atx_power_action(request.query.get("action")) - wait = valid_bool(request.query.get("wait", False)) + async def __power_handler(self, req: Request) -> Response: + action = valid_atx_power_action(req.query.get("action")) + wait = valid_bool(req.query.get("wait", False)) await ({ "on": self.__atx.power_on, "off": self.__atx.power_off, @@ -57,9 +57,9 @@ async def __power_handler(self, request: Request) -> Response: return make_json_response() @exposed_http("POST", "/atx/click") - async def __click_handler(self, request: Request) -> Response: - button = valid_atx_button(request.query.get("button")) - wait = valid_bool(request.query.get("wait", False)) + async def __click_handler(self, req: Request) -> Response: + button = valid_atx_button(req.query.get("button")) + wait = valid_bool(req.query.get("wait", False)) await ({ "power": self.__atx.click_power, "power_long": self.__atx.click_power_long, diff --git a/kvmd/apps/kvmd/api/auth.py b/kvmd/apps/kvmd/api/auth.py index 7d2fad361..bd79e4f9a 100644 --- a/kvmd/apps/kvmd/api/auth.py +++ b/kvmd/apps/kvmd/api/auth.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -24,6 +24,7 @@ from aiohttp.web import Request from aiohttp.web import Response +from aiohttp.web import HTTPFound from ....htserver import UnauthorizedError from ....htserver import ForbiddenError @@ -31,10 +32,13 @@ from ....htserver import exposed_http from ....htserver import make_json_response from ....htserver import set_request_auth_info +from ....htserver import get_request_unix_credentials from ....validators.auth import valid_user from ....validators.auth import valid_passwd +from ....validators.auth import valid_expire from ....validators.auth import valid_auth_token +from ....validators.auth import valid_login_redirect from ..auth import AuthManager @@ -43,67 +47,99 @@ _COOKIE_AUTH_TOKEN = "auth_token" -async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, request: Request) -> None: - if exposed.auth_required and auth_manager.is_auth_enabled(): - user = request.headers.get("X-KVMD-User", "") +async def _check_xhdr(auth: AuthManager, _: HttpExposed, req: Request) -> bool: + user = req.headers.get("X-KVMD-User", "") + if user: + user = valid_user(user) + passwd = req.headers.get("X-KVMD-Passwd", "") + set_request_auth_info(req, f"{user} (xhdr)") + if (await auth.authorize(user, valid_passwd(passwd))): + return True + raise ForbiddenError() + return False + + +async def _check_token(auth: AuthManager, _: HttpExposed, req: Request) -> bool: + token = req.cookies.get(_COOKIE_AUTH_TOKEN, "") + if token: + user = auth.check(valid_auth_token(token)) if user: - user = valid_user(user) - passwd = request.headers.get("X-KVMD-Passwd", "") - set_request_auth_info(request, f"{user} (xhdr)") - if not (await auth_manager.authorize(user, valid_passwd(passwd))): - raise ForbiddenError() - return + set_request_auth_info(req, f"{user} (token)", token=token) + return True + set_request_auth_info(req, "- (token)") + raise ForbiddenError() + return False + + +async def _check_basic(auth: AuthManager, _: HttpExposed, req: Request) -> bool: + basic_auth = req.headers.get("Authorization", "") + if basic_auth and basic_auth[:6].lower() == "basic ": + try: + (user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":") + except Exception: + raise UnauthorizedError() + user = valid_user(user) + set_request_auth_info(req, f"{user} (basic)") + if (await auth.authorize(user, valid_passwd(passwd))): + return True + raise ForbiddenError() + return False + + +async def _check_usc(auth: AuthManager, exposed: HttpExposed, req: Request) -> bool: + if exposed.allow_usc: + creds = get_request_unix_credentials(req) + if creds is not None: + user = auth.check_unix_credentials(creds) + if user: + set_request_auth_info(req, f"{user}[{creds.uid}] (unix)") + return True + raise UnauthorizedError() + return False - token = request.cookies.get(_COOKIE_AUTH_TOKEN, "") - if token: - user = auth_manager.check(valid_auth_token(token)) # type: ignore - if not user: - set_request_auth_info(request, "- (token)") - raise ForbiddenError() - set_request_auth_info(request, f"{user} (token)") - return - basic_auth = request.headers.get("Authorization", "") - if basic_auth and basic_auth[:6].lower() == "basic ": - try: - (user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":") - except Exception: - raise UnauthorizedError() - user = valid_user(user) - set_request_auth_info(request, f"{user} (basic)") - if not (await auth_manager.authorize(user, valid_passwd(passwd))): - raise ForbiddenError() +async def check_request_auth(auth: AuthManager, exposed: HttpExposed, req: Request) -> None: + if not auth.is_auth_required(exposed): + return + for checker in [_check_xhdr, _check_token, _check_basic, _check_usc]: + if (await checker(auth, exposed, req)): return - - raise UnauthorizedError() + raise UnauthorizedError() class AuthApi: - def __init__(self, auth_manager: AuthManager) -> None: - self.__auth_manager = auth_manager + def __init__(self, auth: AuthManager) -> None: + self.__auth = auth # ===== - @exposed_http("POST", "/auth/login", auth_required=False) - async def __login_handler(self, request: Request) -> Response: - if self.__auth_manager.is_auth_enabled(): - credentials = await request.post() - token = await self.__auth_manager.login( - user=valid_user(credentials.get("user", "")), - passwd=valid_passwd(credentials.get("passwd", "")), + @exposed_http("POST", "/auth/login", auth_required=False, allow_usc=False) + async def __login_handler(self, req: Request) -> Response: + if self.__auth.is_auth_enabled(): + params = await req.post() + redirect = valid_login_redirect(params.get("redirect", "")) + token = await self.__auth.login( + user=valid_user(params.get("user", "")), + passwd=valid_passwd(params.get("passwd", "")), + expire=valid_expire(params.get("expire", "0")), ) if token: + if redirect: + ex = HTTPFound(location=redirect) + ex.set_cookie(_COOKIE_AUTH_TOKEN, token) + raise ex return make_json_response(set_cookies={_COOKIE_AUTH_TOKEN: token}) raise ForbiddenError() return make_json_response() - @exposed_http("POST", "/auth/logout") - async def __logout_handler(self, request: Request) -> Response: - if self.__auth_manager.is_auth_enabled(): - token = valid_auth_token(request.cookies.get(_COOKIE_AUTH_TOKEN, "")) - self.__auth_manager.logout(token) + @exposed_http("POST", "/auth/logout", allow_usc=False) + async def __logout_handler(self, req: Request) -> Response: + if self.__auth.is_auth_enabled(): + token = valid_auth_token(req.cookies.get(_COOKIE_AUTH_TOKEN, "")) + self.__auth.logout(token) return make_json_response() - @exposed_http("GET", "/auth/check") + # XXX: This handle is used for access control so it should NEVER allow access by socket credentials + @exposed_http("GET", "/auth/check", allow_usc=False) async def __check_handler(self, _: Request) -> Response: return make_json_response() diff --git a/kvmd/apps/kvmd/api/export.py b/kvmd/apps/kvmd/api/export.py index cfcbde414..51c64d5e8 100644 --- a/kvmd/apps/kvmd/api/export.py +++ b/kvmd/apps/kvmd/api/export.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,9 +21,12 @@ import asyncio +import re from typing import Any +import async_lru + from aiohttp.web import Request from aiohttp.web import Response @@ -40,35 +43,40 @@ # ===== class ExportApi: - def __init__(self, info_manager: InfoManager, atx: BaseAtx, user_gpio: UserGpio) -> None: - self.__info_manager = info_manager + def __init__(self, im: InfoManager, atx: BaseAtx, ugpio: UserGpio) -> None: + self.__im = im self.__atx = atx - self.__user_gpio = user_gpio + self.__ugpio = ugpio # ===== @exposed_http("GET", "/export/prometheus/metrics") async def __prometheus_metrics_handler(self, _: Request) -> Response: - (atx_state, hw_state, fan_state, gpio_state) = await asyncio.gather(*[ + return Response(text=(await self.__get_prometheus_metrics())) + + @async_lru.alru_cache(maxsize=1, ttl=5) + async def __get_prometheus_metrics(self) -> str: + (atx_state, info_state, gpio_state) = await asyncio.gather(*[ self.__atx.get_state(), - self.__info_manager.get_submanager("hw").get_state(), - self.__info_manager.get_submanager("fan").get_state(), - self.__user_gpio.get_state(), + self.__im.get_state(["health", "fan"]), + self.__ugpio.get_state(), ]) rows: list[str] = [] - self.__append_prometheus_rows(rows, atx_state["enabled"], "pikvm_atx_enabled") - self.__append_prometheus_rows(rows, atx_state["leds"]["power"], "pikvm_atx_power") + self.__append_prometheus_rows(rows, atx_state["enabled"], "pikvm_atx_enabled") # type: ignore + self.__append_prometheus_rows(rows, atx_state["leds"]["power"], "pikvm_atx_power") # type: ignore for mode in sorted(UserGpioModes.ALL): - for (channel, ch_state) in gpio_state[f"{mode}s"].items(): - for key in ["online", "state"]: - self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}") + for (channel, ch_state) in gpio_state["state"][f"{mode}s"].items(): # type: ignore + if not channel.startswith("__"): # Hide special GPIOs + channel = re.sub(r"[^\w]", "_", channel) + for key in ["online", "state"]: + self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}") - self.__append_prometheus_rows(rows, hw_state["health"], "pikvm_hw") - self.__append_prometheus_rows(rows, fan_state, "pikvm_fan") + self.__append_prometheus_rows(rows, info_state["health"], "pikvm_hw") # type: ignore + self.__append_prometheus_rows(rows, info_state["fan"], "pikvm_fan") - return Response(text="\n".join(rows)) + return "\n".join(rows) def __append_prometheus_rows(self, rows: list[str], value: Any, path: str) -> None: if isinstance(value, bool): diff --git a/kvmd/apps/kvmd/api/hid.py b/kvmd/apps/kvmd/api/hid.py index a2ea5d4cd..d78a2b139 100644 --- a/kvmd/apps/kvmd/api/hid.py +++ b/kvmd/apps/kvmd/api/hid.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -23,17 +23,22 @@ import os import stat import functools +import itertools import struct +from typing import Iterable from typing import Callable from aiohttp.web import Request from aiohttp.web import Response -from ....mouse import MouseRange +from ....logging import get_logger +from ....keyboard.mappings import WEB_TO_EVDEV from ....keyboard.keysym import build_symmap -from ....keyboard.printer import text_to_web_keys +from ....keyboard.printer import text_to_evdev_keys + +from ....mouse import MOUSE_TO_EVDEV from ....htserver import exposed_http from ....htserver import exposed_ws @@ -44,7 +49,9 @@ from ....validators import raise_error from ....validators.basic import valid_bool +from ....validators.basic import valid_number from ....validators.basic import valid_int_f0 +from ....validators.basic import valid_string_list from ....validators.os import valid_printable_filename from ....validators.hid import valid_hid_keyboard_output from ....validators.hid import valid_hid_mouse_output @@ -59,24 +66,13 @@ class HidApi: def __init__( self, hid: BaseHid, - keymap_path: str, - ignore_keys: list[str], - - mouse_x_range: tuple[int, int], - mouse_y_range: tuple[int, int], ) -> None: self.__hid = hid self.__keymaps_dir_path = os.path.dirname(keymap_path) - self.__default_keymap_name = os.path.basename(keymap_path) - self.__ensure_symmap(self.__default_keymap_name) - - self.__ignore_keys = ignore_keys - - self.__mouse_x_range = mouse_x_range - self.__mouse_y_range = mouse_y_range + self.__default_kn = os.path.basename(keymap_path) # ===== @@ -85,21 +81,22 @@ async def __state_handler(self, _: Request) -> Response: return make_json_response(await self.__hid.get_state()) @exposed_http("POST", "/hid/set_params") - async def __set_params_handler(self, request: Request) -> Response: + async def __set_params_handler(self, req: Request) -> Response: params = { - key: validator(request.query.get(key)) + key: validator(req.query.get(key)) for (key, validator) in [ ("keyboard_output", valid_hid_keyboard_output), ("mouse_output", valid_hid_mouse_output), + ("jiggler", valid_bool), ] - if request.query.get(key) is not None + if req.query.get(key) is not None } self.__hid.set_params(**params) # type: ignore return make_json_response() @exposed_http("POST", "/hid/set_connected") - async def __set_connected_handler(self, request: Request) -> Response: - self.__hid.set_connected(valid_bool(request.query.get("connected"))) + async def __set_connected_handler(self, req: Request) -> Response: + self.__hid.set_connected(valid_bool(req.query.get("connected"))) return make_json_response() @exposed_http("POST", "/hid/reset") @@ -107,17 +104,26 @@ async def __reset_handler(self, _: Request) -> Response: await self.__hid.reset() return make_json_response() + @exposed_http("GET", "/hid/inactivity") + async def __inactivity_handler(self, _: Request) -> Response: + secs = self.__hid.get_inactivity_seconds() + return make_json_response({"inactivity": secs}) + # ===== async def get_keymaps(self) -> dict: # Ugly hack to generate hid_keymaps_state (see server.py) keymaps: set[str] = set() - for keymap_name in os.listdir(self.__keymaps_dir_path): - path = os.path.join(self.__keymaps_dir_path, keymap_name) - if os.access(path, os.R_OK) and stat.S_ISREG(os.stat(path).st_mode): - keymaps.add(keymap_name) + try: + for keymap_name in os.listdir(self.__keymaps_dir_path): + path = os.path.join(self.__keymaps_dir_path, keymap_name) + if os.access(path, os.R_OK) and stat.S_ISREG(os.stat(path).st_mode): + keymaps.add(keymap_name) + except Exception: + keymaps = set() + get_logger(0).exception("Can't load keymaps list") return { "keymaps": { - "default": self.__default_keymap_name, + "default": self.__default_kn, "available": sorted(keymaps), }, } @@ -127,16 +133,28 @@ async def __keymaps_handler(self, _: Request) -> Response: return make_json_response(await self.get_keymaps()) @exposed_http("POST", "/hid/print") - async def __print_handler(self, request: Request) -> Response: - text = await request.text() - limit = int(valid_int_f0(request.query.get("limit", 1024))) + async def __print_handler(self, req: Request) -> Response: + text = await req.text() + limit = valid_int_f0(req.query.get("limit", 1024)) if limit > 0: text = text[:limit] - symmap = self.__ensure_symmap(request.query.get("keymap", self.__default_keymap_name)) - self.__hid.send_key_events(text_to_web_keys(text, symmap)) + symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_kn)) + slow = valid_bool(req.query.get("slow", False)) + delay = float(valid_number( + arg=req.query.get("delay", (0.02 if slow else 0)), + min=0, + max=5, + type=float, + name="keys delay", + )) + await self.__hid.send_key_events( + keys=text_to_evdev_keys(text, symmap), + no_ignore_keys=True, + delay=delay, + ) return make_json_response() - def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]: + def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, int]]: keymap_name = valid_printable_filename(keymap_name, "keymap") path = os.path.join(self.__keymaps_dir_path, keymap_name) try: @@ -148,7 +166,7 @@ def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]: return self.__inner_ensure_symmap(path, st.st_mtime) @functools.lru_cache(maxsize=10) - def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, str]]: + def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, int]]: _ = mod_ts # For LRU return build_symmap(path) @@ -157,18 +175,25 @@ def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, s @exposed_ws(1) async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None: try: - key = valid_hid_key(data[1:].decode("ascii")) - state = valid_bool(data[0]) + state = bool(data[0] & 0b01) + finish = bool(data[0] & 0b10) + if data[0] & 0b10000000: + key = struct.unpack(">H", data[1:])[0] + else: + key = WEB_TO_EVDEV[valid_hid_key(data[1:33].decode("ascii"))] except Exception: return - if key not in self.__ignore_keys: - self.__hid.send_key_events([(key, state)]) + self.__hid.send_key_event(key, state, finish) @exposed_ws(2) async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None: try: - button = valid_hid_mouse_button(data[1:].decode("ascii")) - state = valid_bool(data[0]) + state = bool(data[0] & 0b01) + if data[0] & 0b10000000: + button = struct.unpack(">H", data[1:])[0] + else: + button = MOUSE_TO_EVDEV[valid_hid_mouse_button(data[1:33].decode("ascii"))] + state = bool(data[0] & 0b01) except Exception: return self.__hid.send_mouse_button_event(button, state) @@ -181,19 +206,19 @@ async def __ws_bin_mouse_move_handler(self, _: WsSession, data: bytes) -> None: to_y = valid_hid_mouse_move(to_y) except Exception: return - self.__send_mouse_move_event(to_x, to_y) + self.__hid.send_mouse_move_event(to_x, to_y) @exposed_ws(4) async def __ws_bin_mouse_relative_handler(self, _: WsSession, data: bytes) -> None: - self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_relative_event) + self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_relative_events) @exposed_ws(5) async def __ws_bin_mouse_wheel_handler(self, _: WsSession, data: bytes) -> None: - self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_wheel_event) + self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_wheel_events) - def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[int, int], None]) -> None: + def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None: try: - squash = valid_bool(data[0]) + squash = bool(data[0] & 0b01) data = data[1:] deltas: list[tuple[int, int]] = [] for index in range(0, len(data), 2): @@ -201,24 +226,24 @@ def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[int, in deltas.append((valid_hid_mouse_delta(delta_x), valid_hid_mouse_delta(delta_y))) except Exception: return - self.__send_mouse_delta_event(deltas, squash, handler) + handler(deltas, squash) # ===== @exposed_ws("key") async def __ws_key_handler(self, _: WsSession, event: dict) -> None: try: - key = valid_hid_key(event["key"]) + key = WEB_TO_EVDEV[valid_hid_key(event["key"])] state = valid_bool(event["state"]) + finish = valid_bool(event.get("finish", False)) except Exception: return - if key not in self.__ignore_keys: - self.__hid.send_key_events([(key, state)]) + self.__hid.send_key_event(key, state, finish) @exposed_ws("mouse_button") async def __ws_mouse_button_handler(self, _: WsSession, event: dict) -> None: try: - button = valid_hid_mouse_button(event["button"]) + button = MOUSE_TO_EVDEV[valid_hid_mouse_button(event["button"])] state = valid_bool(event["state"]) except Exception: return @@ -231,17 +256,17 @@ async def __ws_mouse_move_handler(self, _: WsSession, event: dict) -> None: to_y = valid_hid_mouse_move(event["to"]["y"]) except Exception: return - self.__send_mouse_move_event(to_x, to_y) + self.__hid.send_mouse_move_event(to_x, to_y) @exposed_ws("mouse_relative") async def __ws_mouse_relative_handler(self, _: WsSession, event: dict) -> None: - self.__process_ws_delta_event(event, self.__hid.send_mouse_relative_event) + self.__process_ws_delta_event(event, self.__hid.send_mouse_relative_events) @exposed_ws("mouse_wheel") async def __ws_mouse_wheel_handler(self, _: WsSession, event: dict) -> None: - self.__process_ws_delta_event(event, self.__hid.send_mouse_wheel_event) + self.__process_ws_delta_event(event, self.__hid.send_mouse_wheel_events) - def __process_ws_delta_event(self, event: dict, handler: Callable[[int, int], None]) -> None: + def __process_ws_delta_event(self, event: dict, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None: try: raw_delta = event["delta"] deltas = [ @@ -251,26 +276,39 @@ def __process_ws_delta_event(self, event: dict, handler: Callable[[int, int], No squash = valid_bool(event.get("squash", False)) except Exception: return - self.__send_mouse_delta_event(deltas, squash, handler) + handler(deltas, squash) # ===== + @exposed_http("POST", "/hid/events/send_shortcut") + async def __events_send_shortcut_handler(self, req: Request) -> Response: + shortcut = valid_string_list(req.query.get("keys"), subval=valid_hid_key) + if shortcut: + press = [WEB_TO_EVDEV[key] for key in shortcut] + release = list(reversed(press)) + seq = [ + *zip(press, itertools.repeat(True)), + *zip(release, itertools.repeat(False)), + ] + await self.__hid.send_key_events(seq, no_ignore_keys=True, delay=0.05) + return make_json_response() + @exposed_http("POST", "/hid/events/send_key") - async def __events_send_key_handler(self, request: Request) -> Response: - key = valid_hid_key(request.query.get("key")) - if key not in self.__ignore_keys: - if "state" in request.query: - state = valid_bool(request.query["state"]) - self.__hid.send_key_events([(key, state)]) - else: - self.__hid.send_key_events([(key, True), (key, False)]) + async def __events_send_key_handler(self, req: Request) -> Response: + key = WEB_TO_EVDEV[valid_hid_key(req.query.get("key"))] + if "state" in req.query: + state = valid_bool(req.query["state"]) + finish = valid_bool(req.query.get("finish", False)) + self.__hid.send_key_event(key, state, finish) + else: + self.__hid.send_key_event(key, True, True) return make_json_response() @exposed_http("POST", "/hid/events/send_mouse_button") - async def __events_send_mouse_button_handler(self, request: Request) -> Response: - button = valid_hid_mouse_button(request.query.get("button")) - if "state" in request.query: - state = valid_bool(request.query["state"]) + async def __events_send_mouse_button_handler(self, req: Request) -> Response: + button = MOUSE_TO_EVDEV[valid_hid_mouse_button(req.query.get("button"))] + if "state" in req.query: + state = valid_bool(req.query["state"]) self.__hid.send_mouse_button_event(button, state) else: self.__hid.send_mouse_button_event(button, True) @@ -278,52 +316,22 @@ async def __events_send_mouse_button_handler(self, request: Request) -> Response return make_json_response() @exposed_http("POST", "/hid/events/send_mouse_move") - async def __events_send_mouse_move_handler(self, request: Request) -> Response: - to_x = valid_hid_mouse_move(request.query.get("to_x")) - to_y = valid_hid_mouse_move(request.query.get("to_y")) - self.__send_mouse_move_event(to_x, to_y) + async def __events_send_mouse_move_handler(self, req: Request) -> Response: + to_x = valid_hid_mouse_move(req.query.get("to_x")) + to_y = valid_hid_mouse_move(req.query.get("to_y")) + self.__hid.send_mouse_move_event(to_x, to_y) return make_json_response() @exposed_http("POST", "/hid/events/send_mouse_relative") - async def __events_send_mouse_relative_handler(self, request: Request) -> Response: - return self.__process_http_delta_event(request, self.__hid.send_mouse_relative_event) + async def __events_send_mouse_relative_handler(self, req: Request) -> Response: + return self.__process_http_delta_event(req, self.__hid.send_mouse_relative_event) @exposed_http("POST", "/hid/events/send_mouse_wheel") - async def __events_send_mouse_wheel_handler(self, request: Request) -> Response: - return self.__process_http_delta_event(request, self.__hid.send_mouse_wheel_event) + async def __events_send_mouse_wheel_handler(self, req: Request) -> Response: + return self.__process_http_delta_event(req, self.__hid.send_mouse_wheel_event) - def __process_http_delta_event(self, request: Request, handler: Callable[[int, int], None]) -> Response: - delta_x = valid_hid_mouse_delta(request.query.get("delta_x")) - delta_y = valid_hid_mouse_delta(request.query.get("delta_y")) + def __process_http_delta_event(self, req: Request, handler: Callable[[int, int], None]) -> Response: + delta_x = valid_hid_mouse_delta(req.query.get("delta_x")) + delta_y = valid_hid_mouse_delta(req.query.get("delta_y")) handler(delta_x, delta_y) return make_json_response() - - # ===== - - def __send_mouse_move_event(self, to_x: int, to_y: int) -> None: - if self.__mouse_x_range != MouseRange.RANGE: - to_x = MouseRange.remap(to_x, *self.__mouse_x_range) - if self.__mouse_y_range != MouseRange.RANGE: - to_y = MouseRange.remap(to_y, *self.__mouse_y_range) - self.__hid.send_mouse_move_event(to_x, to_y) - - def __send_mouse_delta_event( - self, - deltas: list[tuple[int, int]], - squash: bool, - handler: Callable[[int, int], None], - ) -> None: - - if squash: - prev = (0, 0) - for cur in deltas: - if abs(prev[0] + cur[0]) > 127 or abs(prev[1] + cur[1]) > 127: - handler(*prev) - prev = cur - else: - prev = (prev[0] + cur[0], prev[1] + cur[1]) - if prev[0] or prev[1]: - handler(*prev) - else: - for xy in deltas: - handler(*xy) diff --git a/kvmd/apps/kvmd/api/info.py b/kvmd/apps/kvmd/api/info.py index 5d1c133d0..89195d372 100644 --- a/kvmd/apps/kvmd/api/info.py +++ b/kvmd/apps/kvmd/api/info.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,14 +20,13 @@ # ========================================================================== # -import asyncio - from aiohttp.web import Request from aiohttp.web import Response from ....htserver import exposed_http from ....htserver import make_json_response +from ....validators.basic import valid_bool from ....validators.kvm import valid_info_fields from ..info import InfoManager @@ -35,23 +34,29 @@ # ===== class InfoApi: - def __init__(self, info_manager: InfoManager) -> None: - self.__info_manager = info_manager + def __init__(self, im: InfoManager) -> None: + self.__im = im # ===== @exposed_http("GET", "/info") - async def __common_state_handler(self, request: Request) -> Response: - fields = self.__valid_info_fields(request) - results = dict(zip(fields, await asyncio.gather(*[ - self.__info_manager.get_submanager(field).get_state() - for field in fields - ]))) - return make_json_response(results) - - def __valid_info_fields(self, request: Request) -> list[str]: - subs = self.__info_manager.get_subs() - return sorted(valid_info_fields( - arg=request.query.get("fields", ",".join(subs)), - variants=subs, - ) or subs) + async def __state_handler(self, req: Request) -> Response: + legacy = valid_bool(req.query.get("legacy", True)) + + available = self.__im.get_subs() + if legacy: + available.add("hw") + default = set(available) + if legacy: + default.remove("health") + + fields = sorted(valid_info_fields( + arg=req.query.get("fields", ",".join(default)), + variants=available, + ) or available) + + if legacy: + handler = self.__im.get_state_legacy + else: + handler = self.__im.get_state + return make_json_response(await handler(fields)) diff --git a/kvmd/apps/kvmd/api/log.py b/kvmd/apps/kvmd/api/log.py index 7a73693b9..0c0e27d24 100644 --- a/kvmd/apps/kvmd/api/log.py +++ b/kvmd/apps/kvmd/api/log.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -47,16 +47,16 @@ def __init__(self, log_reader: (LogReader | None)) -> None: # ===== @exposed_http("GET", "/log") - async def __log_handler(self, request: Request) -> StreamResponse: + async def __log_handler(self, req: Request) -> StreamResponse: if self.__log_reader is None: raise LogReaderDisabledError() - seek = valid_log_seek(request.query.get("seek", 0)) - follow = valid_bool(request.query.get("follow", False)) - response = await start_streaming(request, "text/plain") + seek = valid_log_seek(req.query.get("seek", 0)) + follow = valid_bool(req.query.get("follow", False)) + resp = await start_streaming(req, "text/plain") async for record in self.__log_reader.poll_log(seek, follow): - await response.write(("[%s %s] --- %s" % ( + await resp.write(("[%s %s] --- %s" % ( record["dt"].strftime("%Y-%m-%d %H:%M:%S"), record["service"], record["msg"], )).encode("utf-8") + b"\r\n") - return response + return resp diff --git a/kvmd/apps/kvmd/api/msd.py b/kvmd/apps/kvmd/api/msd.py index 94b082999..5195d4f19 100644 --- a/kvmd/apps/kvmd/api/msd.py +++ b/kvmd/apps/kvmd/api/msd.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,7 @@ # ========================================================================== # +import asyncio import lzma import time @@ -34,7 +35,6 @@ from ....logging import get_logger -from .... import aiotools from .... import htclient from ....htserver import exposed_http @@ -66,29 +66,29 @@ async def __state_handler(self, _: Request) -> Response: return make_json_response(await self.__msd.get_state()) @exposed_http("POST", "/msd/set_params") - async def __set_params_handler(self, request: Request) -> Response: + async def __set_params_handler(self, req: Request) -> Response: params = { - key: validator(request.query.get(param)) + key: validator(req.query.get(param)) # type: ignore for (param, key, validator) in [ - ("image", "name", (lambda arg: str(arg).strip() and valid_msd_image_name(arg))), + ("image", "name", valid_msd_image_name.mk(allow_eject=True)), ("cdrom", "cdrom", valid_bool), - ("rw", "rw", valid_bool), + ("rw", "rw", valid_bool), ] - if request.query.get(param) is not None + if req.query.get(param) is not None } await self.__msd.set_params(**params) # type: ignore return make_json_response() @exposed_http("POST", "/msd/set_connected") - async def __set_connected_handler(self, request: Request) -> Response: - await self.__msd.set_connected(valid_bool(request.query.get("connected"))) + async def __set_connected_handler(self, req: Request) -> Response: + await self.__msd.set_connected(valid_bool(req.query.get("connected"))) return make_json_response() # ===== @exposed_http("GET", "/msd/read") - async def __read_handler(self, request: Request) -> StreamResponse: - name = valid_msd_image_name(request.query.get("image")) + async def __read_handler(self, req: Request) -> StreamResponse: + name = valid_msd_image_name(req.query.get("image")) compressors = { "": ("", None), "none": ("", None), @@ -96,7 +96,7 @@ async def __read_handler(self, request: Request) -> StreamResponse: "zstd": (".zst", (lambda: zstandard.ZstdCompressor().compressobj())), # pylint: disable=unnecessary-lambda } (suffix, make_compressor) = compressors[check_string_in_list( - arg=request.query.get("compress", ""), + arg=req.query.get("compress", ""), name="Compression mode", variants=set(compressors), )] @@ -114,57 +114,57 @@ async def compressed() -> AsyncGenerator[bytes, None]: buf = b"" try: async for chunk in reader.read_chunked(): - buf += await aiotools.run_async(compressor.compress, chunk) + buf += await asyncio.to_thread(compressor.compress, chunk) if len(buf) >= limit: yield buf buf = b"" finally: # Закрыть в любом случае - buf += await aiotools.run_async(compressor.flush) + buf += await asyncio.to_thread(compressor.flush) if len(buf) > 0: yield buf src = compressed() size = -1 - response = await start_streaming(request, "application/octet-stream", size, name + suffix) + resp = await start_streaming(req, "application/octet-stream", size, name + suffix) async for chunk in src: - await response.write(chunk) - return response + await resp.write(chunk) + return resp # ===== @exposed_http("POST", "/msd/write") - async def __write_handler(self, request: Request) -> Response: - unsafe_prefix = request.query.get("prefix", "") + "/" - name = valid_msd_image_name(unsafe_prefix + request.query.get("image", "")) - size = valid_int_f0(request.content_length) - remove_incomplete = self.__get_remove_incomplete(request) + async def __write_handler(self, req: Request) -> Response: + unsafe_prefix = req.query.get("prefix", "") + "/" + name = valid_msd_image_name(unsafe_prefix + req.query.get("image", "")) + size = valid_int_f0(req.content_length) + remove_incomplete = self.__get_remove_incomplete(req) written = 0 async with self.__msd.write_image(name, size, remove_incomplete) as writer: chunk_size = writer.get_chunk_size() while True: - chunk = await request.content.read(chunk_size) + chunk = await req.content.read(chunk_size) if not chunk: break written = await writer.write_chunk(chunk) return make_json_response(self.__make_write_info(name, size, written)) @exposed_http("POST", "/msd/write_remote") - async def __write_remote_handler(self, request: Request) -> (Response | StreamResponse): # pylint: disable=too-many-locals - unsafe_prefix = request.query.get("prefix", "") + "/" - url = valid_url(request.query.get("url")) - insecure = valid_bool(request.query.get("insecure", False)) - timeout = valid_float_f01(request.query.get("timeout", 10.0)) - remove_incomplete = self.__get_remove_incomplete(request) + async def __write_remote_handler(self, req: Request) -> (Response | StreamResponse): # pylint: disable=too-many-locals + unsafe_prefix = req.query.get("prefix", "") + "/" + url = valid_url(req.query.get("url")) + insecure = valid_bool(req.query.get("insecure", False)) + timeout = valid_float_f01(req.query.get("timeout", 10.0)) + remove_incomplete = self.__get_remove_incomplete(req) name = "" size = written = 0 - response: (StreamResponse | None) = None + resp: (StreamResponse | None) = None async def stream_write_info() -> None: - assert response is not None - await stream_json(response, self.__make_write_info(name, size, written)) + assert resp is not None + await stream_json(resp, self.__make_write_info(name, size, written)) try: async with htclient.download( @@ -174,7 +174,7 @@ async def stream_write_info() -> None: read_timeout=(7 * 24 * 3600), ) as remote: - name = str(request.query.get("image", "")).strip() + name = str(req.query.get("image", "")).strip() if len(name) == 0: name = htclient.get_filename(remote) name = valid_msd_image_name(unsafe_prefix + name) @@ -184,7 +184,7 @@ async def stream_write_info() -> None: get_logger(0).info("Downloading image %r as %r to MSD ...", url, name) async with self.__msd.write_image(name, size, remove_incomplete) as writer: chunk_size = writer.get_chunk_size() - response = await start_streaming(request, "application/x-ndjson") + resp = await start_streaming(req, "application/x-ndjson") await stream_write_info() last_report_ts = 0 async for chunk in remote.content.iter_chunked(chunk_size): @@ -195,18 +195,18 @@ async def stream_write_info() -> None: last_report_ts = now await stream_write_info() - return response + return resp - except Exception as err: - if response is not None: + except Exception as ex: + if resp is not None: await stream_write_info() - await stream_json_exception(response, err) - elif isinstance(err, aiohttp.ClientError): - return make_json_exception(err, 400) + await stream_json_exception(resp, ex) + elif isinstance(ex, aiohttp.ClientError): + return make_json_exception(ex, 400) raise - def __get_remove_incomplete(self, request: Request) -> (bool | None): - flag: (str | None) = request.query.get("remove_incomplete") + def __get_remove_incomplete(self, req: Request) -> (bool | None): + flag: (str | None) = req.query.get("remove_incomplete") return (valid_bool(flag) if flag is not None else None) def __make_write_info(self, name: str, size: int, written: int) -> dict: @@ -215,8 +215,8 @@ def __make_write_info(self, name: str, size: int, written: int) -> dict: # ===== @exposed_http("POST", "/msd/remove") - async def __remove_handler(self, request: Request) -> Response: - await self.__msd.remove(valid_msd_image_name(request.query.get("image"))) + async def __remove_handler(self, req: Request) -> Response: + await self.__msd.remove(valid_msd_image_name(req.query.get("image"))) return make_json_response() @exposed_http("POST", "/msd/reset") diff --git a/kvmd/apps/kvmd/api/redfish.py b/kvmd/apps/kvmd/api/redfish.py deleted file mode 100644 index 6f157b7b8..000000000 --- a/kvmd/apps/kvmd/api/redfish.py +++ /dev/null @@ -1,127 +0,0 @@ -# ========================================================================== # -# # -# KVMD - The main PiKVM daemon. # -# # -# Copyright (C) 2018-2023 Maxim Devaev # -# # -# 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 . # -# # -# ========================================================================== # - - -import asyncio - -from aiohttp.web import Request -from aiohttp.web import Response - -from ....htserver import HttpError -from ....htserver import exposed_http -from ....htserver import make_json_response - -from ....plugins.atx import BaseAtx - -from ....validators import ValidatorError -from ....validators import check_string_in_list - -from ..info import InfoManager - - -# ===== -class RedfishApi: - # https://github.com/DMTF/Redfishtool - # https://github.com/DMTF/Redfish-Mockup-Server - # https://redfish.dmtf.org/redfish/v1 - # https://www.dmtf.org/documents/redfish-spmf/redfish-mockup-bundle-20191 - # https://www.dmtf.org/sites/default/files/Redfish_School-Sessions.pdf - # https://www.ibm.com/support/knowledgecenter/POWER9/p9ej4/p9ej4_kickoff.htm - # - # Quick examples: - # redfishtool -S Never -u admin -p admin -r localhost:8080 Systems - # redfishtool -S Never -u admin -p admin -r localhost:8080 Systems reset ForceOff - - def __init__(self, info_manager: InfoManager, atx: BaseAtx) -> None: - self.__info_manager = info_manager - self.__atx = atx - - self.__actions = { - "On": self.__atx.power_on, - "ForceOff": self.__atx.power_off_hard, - "GracefulShutdown": self.__atx.power_off, - "ForceRestart": self.__atx.power_reset_hard, - "ForceOn": self.__atx.power_on, - "PushPowerButton": self.__atx.click_power, - } - - # ===== - - @exposed_http("GET", "/redfish/v1", auth_required=False) - async def __root_handler(self, _: Request) -> Response: - return make_json_response({ - "@odata.id": "/redfish/v1", - "@odata.type": "#ServiceRoot.v1_6_0.ServiceRoot", - "Id": "RootService", - "Name": "Root Service", - "RedfishVersion": "1.6.0", - "Systems": {"@odata.id": "/redfish/v1/Systems"}, - }, wrap_result=False) - - @exposed_http("GET", "/redfish/v1/Systems") - async def __systems_handler(self, _: Request) -> Response: - return make_json_response({ - "@odata.id": "/redfish/v1/Systems", - "@odata.type": "#ComputerSystemCollection.ComputerSystemCollection", - "Members": [{"@odata.id": "/redfish/v1/Systems/0"}], - "Members@odata.count": 1, - "Name": "Computer System Collection", - }, wrap_result=False) - - @exposed_http("GET", "/redfish/v1/Systems/0") - async def __server_handler(self, _: Request) -> Response: - (atx_state, meta_state) = await asyncio.gather(*[ - self.__atx.get_state(), - self.__info_manager.get_submanager("meta").get_state(), - ]) - try: - host = meta_state.get("server", {})["host"] - except Exception: - host = "" - return make_json_response({ - "@odata.id": "/redfish/v1/Systems/0", - "@odata.type": "#ComputerSystem.v1_10_0.ComputerSystem", - "Actions": { - "#ComputerSystem.Reset": { - "ResetType@Redfish.AllowableValues": list(self.__actions), - "target": "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset" - }, - }, - "Id": "0", - "HostName": host, - "PowerState": ("On" if atx_state["leds"]["power"] else "Off"), - }, wrap_result=False) - - @exposed_http("POST", "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset") - async def __power_handler(self, request: Request) -> Response: - try: - action = check_string_in_list( - arg=(await request.json())["ResetType"], - name="Redfish ResetType", - variants=set(self.__actions), - lower=False, - ) - except ValidatorError: - raise - except Exception: - raise HttpError("Missing Redfish ResetType", 400) - await self.__actions[action](False) - return Response(body=None, status=204) diff --git a/testenv/tests/apps/cleanup/__init__.py b/kvmd/apps/kvmd/api/redfish/__init__.py similarity index 95% rename from testenv/tests/apps/cleanup/__init__.py rename to kvmd/apps/kvmd/api/redfish/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/testenv/tests/apps/cleanup/__init__.py +++ b/kvmd/apps/kvmd/api/redfish/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/kvmd/api/redfish/atx.py b/kvmd/apps/kvmd/api/redfish/atx.py new file mode 100644 index 000000000..e9f953b6b --- /dev/null +++ b/kvmd/apps/kvmd/api/redfish/atx.py @@ -0,0 +1,180 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import re + +from aiohttp.web import Request +from aiohttp.web import Response + +from .....htserver import HttpError +from .....htserver import exposed_http +from .....htserver import make_json_response + +from .....plugins.atx import BaseAtx + +from .....validators import check_string_in_list +from .....validators.basic import valid_int_f0 + +from ...info import InfoManager +from ...switch import Switch + + +# ===== +class RedfishAtxApi: + __SWITCH_PREFIX = "SwitchPort" + + def __init__(self, im: InfoManager, atx: BaseAtx, switch: Switch) -> None: + self.__im = im + + self.__atx = atx + self.__atx_actions = { + "On": self.__atx.power_on, + "ForceOff": self.__atx.power_off_hard, + "GracefulShutdown": self.__atx.power_off, + "ForceRestart": self.__atx.power_reset_hard, + "ForceOn": self.__atx.power_on, + "PushPowerButton": self.__atx.click_power, + } + + self.__switch = switch + self.__switch_actions = { + "On": self.__switch.atx_power_on, + "ForceOff": self.__switch.atx_power_off_hard, + "GracefulShutdown": self.__switch.atx_power_off, + "ForceRestart": self.__switch.atx_power_reset_hard, + "ForceOn": self.__switch.atx_power_on, + "PushPowerButton": self.__switch.atx_click_power, + } + + assert set(self.__atx_actions) == set(self.__switch_actions) + + # ===== + + @exposed_http("GET", "/redfish/v1/Systems") + async def __systems_handler(self, _: Request) -> Response: + (atx_state, switch_state) = await asyncio.gather(*[ + self.__atx.get_state(), + self.__switch.get_state(), + ]) + + members: list[str] = [] + if atx_state["enabled"]: + members.append("0") + + members.extend( + f"{self.__SWITCH_PREFIX}{port}" + for port in range(len(switch_state["model"]["ports"])) + ) + + return make_json_response({ + "@odata.id": "/redfish/v1/Systems", + "@odata.type": "#ComputerSystemCollection.ComputerSystemCollection", + "Name": "Computer System Collection", + "Members": [ + {"@odata.id": f"/redfish/v1/Systems/{member}"} + for member in members + ], + "Members@odata.count": len(members), + }, wrap_result=False) + + @exposed_http("GET", "/redfish/v1/Systems/{sid}") + async def __systems_server_handler(self, req: Request) -> Response: + (sid, port) = self.__valid_server_id(req) + host: str + power: bool + if port < 0: + (atx_state, host) = await asyncio.gather(*[ # type: ignore + self.__atx.get_state(), + self.__im.get_meta_server_host(), + ]) + power = atx_state["leds"]["power"] # type: ignore + + else: + switch_state = await self.__switch.get_state() + if port >= len(switch_state["model"]["ports"]): + raise HttpError("Non-existent Switch Port ID", 400) + host = (switch_state["model"]["ports"][port]["name"] or sid) # type: ignore + power = switch_state["atx"]["leds"]["power"][port] + + host = re.sub(r"[^a-zA-Z0-9_\.]", "_", host) + return make_json_response({ + "@odata.id": f"/redfish/v1/Systems/{sid}", + "@odata.type": "#ComputerSystem.v1_10_0.ComputerSystem", + "Id": sid, + "HostName": host, + "PowerState": ("On" if power else "Off"), + "Actions": { + "#ComputerSystem.Reset": { # XXX: Same actions list for ATX and Switch + "ResetType@Redfish.AllowableValues": list(self.__atx_actions), + "target": f"/redfish/v1/Systems/{sid}/Actions/ComputerSystem.Reset", + }, + "#ComputerSystem.SetDefaultBootOrder": { # https://github.com/pikvm/pikvm/issues/1525 + "target": f"/redfish/v1/Systems/{sid}/Actions/ComputerSystem.SetDefaultBootOrder", + }, + }, + "Boot": { + "BootSourceOverrideEnabled": "Disabled", + "BootSourceOverrideTarget": None, + }, + }, wrap_result=False) + + @exposed_http("PATCH", "/redfish/v1/Systems/{sid}") + async def __systems_server_patch_handler(self, _: Request) -> Response: + # https://github.com/pikvm/pikvm/issues/1525 + # XXX: We don't care about sid validation here, because nothing to do + return Response(body=None, status=204) + + @exposed_http("POST", "/redfish/v1/Systems/{sid}/Actions/ComputerSystem.Reset") + async def __systems_server_power_handler(self, req: Request) -> Response: + (_, port) = self.__valid_server_id(req) + try: + # XXX: Same actions list for ATX and Switch + action = check_string_in_list( + arg=(await req.json()).get("ResetType"), + variants=set(self.__atx_actions), + name="Redfish ResetType", + lower=False, + ) + except Exception: + raise HttpError("Missing or invalid ResetType", 400) + if port < 0: + if (await self.__atx.get_state())["enabled"]: + await self.__atx_actions[action](False) + else: + await self.__switch_actions[action](port) + return Response(body=None, status=204) + + # ===== + + def __valid_server_id(self, req: Request) -> tuple[str, int]: + try: + sid = req.match_info["sid"].strip() + if sid == "0": # Legacy name for PiKVM itself + return ("0", -1) + if sid.startswith(self.__SWITCH_PREFIX): + sid = sid[len(self.__SWITCH_PREFIX):] + port = valid_int_f0(sid) + return (f"{self.__SWITCH_PREFIX}{port}", port) + except Exception: + pass + raise HttpError("Missing or invalid Server ID", 400) diff --git a/kvmd/apps/kvmd/api/redfish/msd.py b/kvmd/apps/kvmd/api/redfish/msd.py new file mode 100644 index 000000000..9e45b639f --- /dev/null +++ b/kvmd/apps/kvmd/api/redfish/msd.py @@ -0,0 +1,150 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os + +from aiohttp.web import Request +from aiohttp.web import Response + +from .....htserver import HttpError +from .....htserver import exposed_http +from .....htserver import make_json_response + +from .....plugins.msd import BaseMsd + +from .....validators.basic import valid_bool +from .....validators.kvm import valid_msd_image_name + + +# ===== +class RedfishMsdApi: + # https://pubs.lenovo.com/tsm/get_virtual_media_collection + # https://developer.avermedia.com/oob/fw-1.0.3.1/user-guide/13-virtualmedia + + def __init__(self, msd: BaseMsd) -> None: + self.__msd = msd + + # ===== + + @exposed_http("GET", "/redfish/v1/Managers") + async def __managers_handler(self, _: Request) -> Response: + return make_json_response({ + "@odata.id": "/redfish/v1/Managers", + "@odata.type": "#ManagerCollection.ManagerCollection", + "Name": "Manager Collection", + "Members": [{"@odata.id": "/redfish/v1/Managers/BMC"}], + "Members@odata.count": 1, + }, wrap_result=False) + + @exposed_http("GET", "/redfish/v1/Managers/BMC") + async def __managers_bmc_handler(self, _: Request) -> Response: + return make_json_response({ + "@odata.id": "/redfish/v1/Managers/BMC", + "@odata.type": "#Manager.v1_15_0.Manager", + "Id": "BMC", + "Name": "PiKVM Manager", + "Description": "PiKVM Baseboard Management Controller", + "ManagerType": "BMC", + "VirtualMedia": {"@odata.id": "/redfish/v1/Managers/BMC/VirtualMedia"}, + }, wrap_result=False) + + @exposed_http("GET", "/redfish/v1/Managers/BMC/VirtualMedia") + async def __managers_bmc_vm_handler(self, _: Request) -> Response: + return make_json_response({ + "@odata.id": "/redfish/v1/Managers/BMC/VirtualMedia", + "@odata.type": "#VirtualMediaCollection.VirtualMediaCollection", + "Name": "Virtual Media Collection", + "Members": [{"@odata.id": "/redfish/v1/Managers/BMC/VirtualMedia/MSD"}], + "Members@odata.count": 1, + }, wrap_result=False) + + # ===== + + @exposed_http("GET", "/redfish/v1/Managers/BMC/VirtualMedia/MSD") + async def __msd_handler(self, _: Request) -> Response: + state = (await self.__msd.get_state()) + + drive: (dict | None) = None + path: (str | None) = None + if state["online"]: + drive = state["drive"] + path = (drive and drive["image"] and drive["image"]["name"]) # type: ignore + + return make_json_response({ + "@odata.id": "/redfish/v1/Managers/BMC/VirtualMedia/MSD", + "@odata.type": "#VirtualMedia.v1_4_0.VirtualMedia", + "Id": "MSD", + "Name": "Virtual CD/DVD/Flash Drive", + "Description": "PiKVM Virtual CD/DVD/Flash Drive", + "MediaTypes": ["USBStick", "CD", "DVD"], + "Image": path, + "ImageName": (path and os.path.basename(path)), + "ConnectedVia": (drive and ("Oem" if drive["image"] else "NotConnected")), + "Inserted": (drive and drive["connected"]), + "WriteProtected": (drive and drive["rw"]), + "Oem": { + "PiKVM": { + "@odata.context": "/redfish/v1/$metadata#PiKVMVirtualMedia.PiKVMVirtualMedia", + "@odata.type": "#PiKVMVirtualMedia.v1_0_0.PiKVMVirtualMedia", + "MsdEnabled": state["enabled"], + "MsdOnline": state["online"], + "MsdBusy": state["busy"], + "DriveOptical": (drive and drive["cdrom"]), + }, + }, + "Actions": { + "#VirtualMedia.InsertMedia": { + "target": "/redfish/v1/Managers/BMC/VirtualMedia/MSD/Actions/VirtualMedia.InsertMedia", + "Image@Redfish.AllowableValues": ["URI"], + }, + "#VirtualMedia.EjectMedia": { + "target": "/redfish/v1/Managers/BMC/VirtualMedia/MSD/Actions/VirtualMedia.EjectMedia", + }, + }, + }, wrap_result=False) + + @exposed_http("POST", "/redfish/v1/Managers/BMC/VirtualMedia/MSD/Actions/VirtualMedia.InsertMedia") + async def __msd_insert_handler(self, req: Request) -> Response: + try: + params = await req.json() + except Exception: + raise HttpError("Invalid body", 400) + name = valid_msd_image_name(params.get("Image")) + cdrom = name.lower().startswith(".iso") + connect = valid_bool(params.get("Inserted", True)) + rw = (not valid_bool(params.get("WriteProtected", True))) + + state = await self.__msd.get_state() + if state.get("drive", {}).get("connected"): + await self.__msd.set_connected(False) + await self.__msd.set_params(name="") + + await self.__msd.set_params(name=name, cdrom=cdrom, rw=rw) + if connect: + await self.__msd.set_connected(True) + return Response(body=None, status=204) + + @exposed_http("POST", "/redfish/v1/Managers/BMC/VirtualMedia/MSD/Actions/VirtualMedia.EjectMedia") + async def __msd_eject_handler(self, _: Request) -> Response: + await self.__msd.set_connected(False) + await self.__msd.set_params(name="") + return Response(body=None, status=204) diff --git a/kvmd/apps/kvmd/api/redfish/root.py b/kvmd/apps/kvmd/api/redfish/root.py new file mode 100644 index 000000000..0f3077fe3 --- /dev/null +++ b/kvmd/apps/kvmd/api/redfish/root.py @@ -0,0 +1,54 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from aiohttp.web import Request +from aiohttp.web import Response + +from .....htserver import exposed_http +from .....htserver import make_json_response + + +# ===== +class RedfishRootApi: + # https://github.com/DMTF/Redfishtool + # https://github.com/DMTF/Redfish-Mockup-Server + # https://redfish.dmtf.org/redfish/v1 + # https://www.dmtf.org/documents/redfish-spmf/redfish-mockup-bundle-20191 + # https://www.dmtf.org/sites/default/files/Redfish_School-Sessions.pdf + # https://www.ibm.com/support/knowledgecenter/POWER9/p9ej4/p9ej4_kickoff.htm + # https://www.dmtf.org/sites/default/files/standards/documents/DSP2046_2025.1.pdf + # + # Quick examples: + # redfishtool -S Never -u admin -p admin -r localhost:8080 Systems + # redfishtool -S Never -u admin -p admin -r localhost:8080 Systems reset ForceOff + + @exposed_http("GET", "/redfish/v1", auth_required=False) + async def __root_handler(self, _: Request) -> Response: + return make_json_response({ + "@odata.id": "/redfish/v1", + "@odata.type": "#ServiceRoot.v1_6_0.ServiceRoot", + "Id": "RootService", + "Name": "Root Service", + "RedfishVersion": "1.6.0", + "Systems": {"@odata.id": "/redfish/v1/Systems"}, # ATX + "Managers": {"@odata.id": "/redfish/v1/Managers"}, # MSD + }, wrap_result=False) diff --git a/kvmd/apps/kvmd/api/streamer.py b/kvmd/apps/kvmd/api/streamer.py index a757eed5f..81f255d02 100644 --- a/kvmd/apps/kvmd/api/streamer.py +++ b/kvmd/apps/kvmd/api/streamer.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -52,36 +52,36 @@ async def __state_handler(self, _: Request) -> Response: return make_json_response(await self.__streamer.get_state()) @exposed_http("GET", "/streamer/snapshot") - async def __take_snapshot_handler(self, request: Request) -> Response: + async def __take_snapshot_handler(self, req: Request) -> Response: snapshot = await self.__streamer.take_snapshot( - save=valid_bool(request.query.get("save", False)), - load=valid_bool(request.query.get("load", False)), - allow_offline=valid_bool(request.query.get("allow_offline", False)), + save=valid_bool(req.query.get("save", False)), + load=valid_bool(req.query.get("load", False)), + allow_offline=valid_bool(req.query.get("allow_offline", False)), ) if snapshot: - if valid_bool(request.query.get("ocr", False)): + if valid_bool(req.query.get("ocr", False)): langs = self.__ocr.get_available_langs() return Response( body=(await self.__ocr.recognize( data=snapshot.data, langs=valid_string_list( - arg=str(request.query.get("ocr_langs", "")).strip(), + arg=str(req.query.get("ocr_langs", "")).strip(), subval=(lambda lang: check_string_in_list(lang, "OCR lang", langs)), name="OCR langs list", ), - left=int(valid_number(request.query.get("ocr_left", -1))), - top=int(valid_number(request.query.get("ocr_top", -1))), - right=int(valid_number(request.query.get("ocr_right", -1))), - bottom=int(valid_number(request.query.get("ocr_bottom", -1))), + left=int(valid_number(req.query.get("ocr_left", -1))), + top=int(valid_number(req.query.get("ocr_top", -1))), + right=int(valid_number(req.query.get("ocr_right", -1))), + bottom=int(valid_number(req.query.get("ocr_bottom", -1))), )), headers=dict(snapshot.headers), content_type="text/plain", ) - elif valid_bool(request.query.get("preview", False)): + elif valid_bool(req.query.get("preview", False)): data = await snapshot.make_preview( - max_width=valid_int_f0(request.query.get("preview_max_width", 0)), - max_height=valid_int_f0(request.query.get("preview_max_height", 0)), - quality=valid_stream_quality(request.query.get("preview_quality", 80)), + max_width=valid_int_f0(req.query.get("preview_max_width", 0)), + max_height=valid_int_f0(req.query.get("preview_max_height", 0)), + quality=valid_stream_quality(req.query.get("preview_quality", 80)), ) else: data = snapshot.data @@ -97,25 +97,6 @@ async def __remove_snapshot_handler(self, _: Request) -> Response: self.__streamer.remove_snapshot() return make_json_response() - # ===== - - async def get_ocr(self) -> dict: # XXX: Ugly hack - enabled = self.__ocr.is_available() - default: list[str] = [] - available: list[str] = [] - if enabled: - default = self.__ocr.get_default_langs() - available = self.__ocr.get_available_langs() - return { - "ocr": { - "enabled": enabled, - "langs": { - "default": default, - "available": available, - }, - }, - } - @exposed_http("GET", "/streamer/ocr") async def __ocr_handler(self, _: Request) -> Response: - return make_json_response(await self.get_ocr()) + return make_json_response({"ocr": (await self.__ocr.get_state())}) diff --git a/kvmd/apps/kvmd/api/switch.py b/kvmd/apps/kvmd/api/switch.py new file mode 100644 index 000000000..f1addea3b --- /dev/null +++ b/kvmd/apps/kvmd/api/switch.py @@ -0,0 +1,176 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from aiohttp.web import Request +from aiohttp.web import Response + +from ....htserver import exposed_http +from ....htserver import make_json_response + +from ....validators.basic import valid_bool +from ....validators.basic import valid_int_f0 +from ....validators.basic import valid_float_f0 +from ....validators.basic import valid_stripped_string_not_empty +from ....validators.kvm import valid_atx_power_action +from ....validators.kvm import valid_atx_button +from ....validators.switch import valid_switch_port_name +from ....validators.switch import valid_switch_edid_id +from ....validators.switch import valid_switch_edid_data +from ....validators.switch import valid_switch_color +from ....validators.switch import valid_switch_atx_click_delay + +from ..switch import Switch +from ..switch import Colors + + +# ===== +class SwitchApi: + def __init__(self, switch: Switch) -> None: + self.__switch = switch + + # ===== + + @exposed_http("GET", "/switch") + async def __state_handler(self, _: Request) -> Response: + return make_json_response(await self.__switch.get_state()) + + @exposed_http("POST", "/switch/set_active_prev") + async def __set_active_prev_handler(self, _: Request) -> Response: + await self.__switch.set_active_prev() + return make_json_response() + + @exposed_http("POST", "/switch/set_active_next") + async def __set_active_next_handler(self, _: Request) -> Response: + await self.__switch.set_active_next() + return make_json_response() + + @exposed_http("POST", "/switch/set_active") + async def __set_active_port_handler(self, req: Request) -> Response: + port = valid_float_f0(req.query.get("port")) + await self.__switch.set_active_port(port) + return make_json_response() + + @exposed_http("POST", "/switch/set_beacon") + async def __set_beacon_handler(self, req: Request) -> Response: + on = valid_bool(req.query.get("state")) + if "port" in req.query: + port = valid_float_f0(req.query.get("port")) + await self.__switch.set_port_beacon(port, on) + elif "uplink" in req.query: + unit = valid_int_f0(req.query.get("uplink")) + await self.__switch.set_uplink_beacon(unit, on) + else: # Downlink + unit = valid_int_f0(req.query.get("downlink")) + await self.__switch.set_downlink_beacon(unit, on) + return make_json_response() + + @exposed_http("POST", "/switch/set_port_params") + async def __set_port_params(self, req: Request) -> Response: + port = valid_float_f0(req.query.get("port")) + params = { + param: validator(req.query.get(param)) # type: ignore + for (param, validator) in [ + ("edid_id", valid_switch_edid_id.mk(allow_default=True)), + ("dummy", valid_bool), + ("name", valid_switch_port_name), + ("atx_click_power_delay", valid_switch_atx_click_delay), + ("atx_click_power_long_delay", valid_switch_atx_click_delay), + ("atx_click_reset_delay", valid_switch_atx_click_delay), + ] + if req.query.get(param) is not None + } + await self.__switch.set_port_params(port, **params) # type: ignore + return make_json_response() + + @exposed_http("POST", "/switch/set_colors") + async def __set_colors(self, req: Request) -> Response: + params = { + param: valid_switch_color(req.query.get(param), allow_default=True) + for param in Colors.ROLES + if req.query.get(param) is not None + } + await self.__switch.set_colors(**params) + return make_json_response() + + # ===== + + @exposed_http("POST", "/switch/reset") + async def __reset(self, req: Request) -> Response: + unit = valid_int_f0(req.query.get("unit")) + bootloader = valid_bool(req.query.get("bootloader", False)) + await self.__switch.reboot_unit(unit, bootloader) + return make_json_response() + + # ===== + + @exposed_http("POST", "/switch/edids/create") + async def __create_edid(self, req: Request) -> Response: + name = valid_stripped_string_not_empty(req.query.get("name")) + data_hex = valid_switch_edid_data(req.query.get("data")) + edid_id = await self.__switch.create_edid(name, data_hex) + return make_json_response({"id": edid_id}) + + @exposed_http("POST", "/switch/edids/change") + async def __change_edid(self, req: Request) -> Response: + edid_id = valid_switch_edid_id(req.query.get("id"), allow_default=False) + params = { + param: validator(req.query.get(param)) + for (param, validator) in [ + ("name", valid_switch_port_name), + ("data", valid_switch_edid_data), + ] + if req.query.get(param) is not None + } + if params: + await self.__switch.change_edid(edid_id, **params) + return make_json_response() + + @exposed_http("POST", "/switch/edids/remove") + async def __remove_edid(self, req: Request) -> Response: + edid_id = valid_switch_edid_id(req.query.get("id"), allow_default=False) + await self.__switch.remove_edid(edid_id) + return make_json_response() + + # ===== + + @exposed_http("POST", "/switch/atx/power") + async def __power_handler(self, req: Request) -> Response: + port = valid_float_f0(req.query.get("port")) + action = valid_atx_power_action(req.query.get("action")) + await ({ + "on": self.__switch.atx_power_on, + "off": self.__switch.atx_power_off, + "off_hard": self.__switch.atx_power_off_hard, + "reset_hard": self.__switch.atx_power_reset_hard, + }[action])(port) + return make_json_response() + + @exposed_http("POST", "/switch/atx/click") + async def __click_handler(self, req: Request) -> Response: + port = valid_float_f0(req.query.get("port")) + button = valid_atx_button(req.query.get("button")) + await ({ + "power": self.__switch.atx_click_power, + "power_long": self.__switch.atx_click_power_long, + "reset": self.__switch.atx_click_reset, + }[button])(port) + return make_json_response() diff --git a/kvmd/apps/kvmd/api/ugpio.py b/kvmd/apps/kvmd/api/ugpio.py index d8f939ae5..d564cf905 100644 --- a/kvmd/apps/kvmd/api/ugpio.py +++ b/kvmd/apps/kvmd/api/ugpio.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -35,30 +35,27 @@ # ===== class UserGpioApi: - def __init__(self, user_gpio: UserGpio) -> None: - self.__user_gpio = user_gpio + def __init__(self, ugpio: UserGpio) -> None: + self.__ugpio = ugpio # ===== @exposed_http("GET", "/gpio") async def __state_handler(self, _: Request) -> Response: - return make_json_response({ - "model": (await self.__user_gpio.get_model()), - "state": (await self.__user_gpio.get_state()), - }) + return make_json_response(await self.__ugpio.get_state()) @exposed_http("POST", "/gpio/switch") - async def __switch_handler(self, request: Request) -> Response: - channel = valid_ugpio_channel(request.query.get("channel")) - state = valid_bool(request.query.get("state")) - wait = valid_bool(request.query.get("wait", False)) - await self.__user_gpio.switch(channel, state, wait) + async def __switch_handler(self, req: Request) -> Response: + channel = valid_ugpio_channel(req.query.get("channel")) + state = valid_bool(req.query.get("state")) + wait = valid_bool(req.query.get("wait", False)) + await self.__ugpio.switch(channel, state, wait) return make_json_response() @exposed_http("POST", "/gpio/pulse") - async def __pulse_handler(self, request: Request) -> Response: - channel = valid_ugpio_channel(request.query.get("channel")) - delay = valid_float_f0(request.query.get("delay", 0.0)) - wait = valid_bool(request.query.get("wait", False)) - await self.__user_gpio.pulse(channel, delay, wait) + async def __pulse_handler(self, req: Request) -> Response: + channel = valid_ugpio_channel(req.query.get("channel")) + delay = valid_float_f0(req.query.get("delay", 0.0)) + wait = valid_bool(req.query.get("wait", False)) + await self.__ugpio.pulse(channel, delay, wait) return make_json_response() diff --git a/kvmd/apps/kvmd/auth.py b/kvmd/apps/kvmd/auth.py index 21af1a93f..a62378883 100644 --- a/kvmd/apps/kvmd/auth.py +++ b/kvmd/apps/kvmd/auth.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,12 @@ # ========================================================================== # +import pwd +import grp +import dataclasses +import time +import datetime + import secrets import pyotp @@ -30,103 +36,281 @@ from ...plugins.auth import BaseAuthService from ...plugins.auth import get_auth_service_class +from ...htserver import HttpExposed +from ...htserver import RequestUnixCredentials + # ===== -class AuthManager: +@dataclasses.dataclass(frozen=True) +class _Session: + user: str + expire_req: int + expire_ts: int + ws_started: int + + def __post_init__(self) -> None: + assert self.user == self.user.strip() + assert self.user + assert self.expire_req >= 0 + assert self.expire_ts >= 0 + + +class AuthManager: # pylint: disable=too-many-arguments,too-many-instance-attributes def __init__( self, enabled: bool, + expire: int, + extend: bool, + usc_users: list[str], + usc_groups: list[str], + unauth_paths: list[str], - internal_type: str, - internal_kwargs: dict, - force_internal_users: list[str], + int_type: str, + int_kwargs: dict, + force_int_users: list[str], - external_type: str, - external_kwargs: dict, + ext_type: str, + ext_kwargs: dict, totp_secret_path: str, ) -> None: + logger = get_logger(0) + self.__enabled = enabled if not enabled: - get_logger().warning("AUTHORIZATION IS DISABLED") + logger.warning("AUTHORIZATION IS DISABLED") + + assert expire >= 0 + self.__expire = expire + if expire > 0: + logger.info("Maximum user session time is limited: %s", + self.__format_seconds(expire)) + + self.__extend = extend + if extend: + logger.info("Enabled WS-based session extending") + + self.__usc_uids = self.__load_usc_uids(usc_users, usc_groups) + if self.__usc_uids: + logger.info("Selfauth UNIX socket access is allowed for users: %s", + list(self.__usc_uids.values())) - self.__internal_service: (BaseAuthService | None) = None + self.__unauth_paths = frozenset(unauth_paths) # To speed up + if self.__unauth_paths: + logger.info("Authorization is disabled for APIs: %s", + list(self.__unauth_paths)) + + self.__int_service: (BaseAuthService | None) = None if enabled: - self.__internal_service = get_auth_service_class(internal_type)(**internal_kwargs) - get_logger().info("Using internal auth service %r", self.__internal_service.get_plugin_name()) + self.__int_service = get_auth_service_class(int_type)(**int_kwargs) + logger.info("Using internal auth service %r", + self.__int_service.get_plugin_name()) - self.__force_internal_users = force_internal_users + self.__force_int_users = force_int_users - self.__external_service: (BaseAuthService | None) = None - if enabled and external_type: - self.__external_service = get_auth_service_class(external_type)(**external_kwargs) - get_logger().info("Using external auth service %r", self.__external_service.get_plugin_name()) + self.__ext_service: (BaseAuthService | None) = None + if enabled and ext_type: + self.__ext_service = get_auth_service_class(ext_type)(**ext_kwargs) + logger.info("Using external auth service %r", + self.__ext_service.get_plugin_name()) self.__totp_secret_path = totp_secret_path - self.__tokens: dict[str, str] = {} # {token: user} + self.__sessions: dict[str, _Session] = {} # {token: session} def is_auth_enabled(self) -> bool: return self.__enabled + def is_auth_required(self, exposed: HttpExposed) -> bool: + return ( + self.is_auth_enabled() + and exposed.auth_required + and exposed.path not in self.__unauth_paths + ) + async def authorize(self, user: str, passwd: str) -> bool: assert user == user.strip() assert user assert self.__enabled - assert self.__internal_service + assert self.__int_service + logger = get_logger(0) if self.__totp_secret_path: with open(self.__totp_secret_path) as file: secret = file.read().strip() if secret: code = passwd[-6:] - if not pyotp.TOTP(secret).verify(code): - get_logger().error("Got access denied for user %r by TOTP", user) + if not pyotp.TOTP(secret).verify(code, valid_window=1): + logger.error("Got access denied for user %r by TOTP", user) return False passwd = passwd[:-6] - if user not in self.__force_internal_users and self.__external_service: - service = self.__external_service + if user not in self.__force_int_users and self.__ext_service: + service = self.__ext_service else: - service = self.__internal_service + service = self.__int_service + pname = service.get_plugin_name() ok = (await service.authorize(user, passwd)) if ok: - get_logger().info("Authorized user %r via auth service %r", user, service.get_plugin_name()) + logger.info("Authorized user %r via auth service %r", user, pname) else: - get_logger().error("Got access denied for user %r from auth service %r", user, service.get_plugin_name()) + logger.error("Got access denied for user %r from auth service %r", user, pname) return ok - async def login(self, user: str, passwd: str) -> (str | None): + async def login(self, user: str, passwd: str, expire: int) -> (str | None): assert user == user.strip() assert user + assert expire >= 0 assert self.__enabled + if (await self.authorize(user, passwd)): - for (token, token_user) in self.__tokens.items(): - if user == token_user: - return token - token = secrets.token_hex(32) - self.__tokens[token] = user - get_logger().info("Logged in user %r", user) + token = self.__make_new_token() + session = _Session( + user=user, + expire_req=expire, + expire_ts=self.__make_expire_ts(expire), + ws_started=0, + ) + self.__sessions[token] = session + get_logger(0).info("Logged in user %r; expire=%s, sessions_now=%d", + session.user, + self.__format_expire_ts(session.expire_ts), + self.__get_sessions_number(session.user)) return token + + return None + + def __make_new_token(self) -> str: + for _ in range(10): + token = secrets.token_hex(32) + if token not in self.__sessions: + return token + raise RuntimeError("Can't generate new unique token") + + def __make_expire_ts(self, expire: int) -> int: + assert expire >= 0 + assert self.__expire >= 0 + + if expire == 0: + # The user requested infinite session: apply global expire. + # It will allow this (0) or set a limit. + expire = self.__expire else: - return None + # The user wants a limited session + if self.__expire > 0: + # If we have a global limit, override the user limit + assert expire > 0 + expire = min(expire, self.__expire) + + if expire > 0: + return (self.__get_now_ts() + expire) + + assert expire == 0 + return 0 + + def __get_now_ts(self) -> int: + return int(time.monotonic()) + + def __format_expire_ts(self, expire_ts: int) -> str: + if expire_ts > 0: + seconds = expire_ts - self.__get_now_ts() + return f"[{self.__format_seconds(seconds)}]" + return "INF" + + def __format_seconds(self, seconds: int) -> str: + return str(datetime.timedelta(seconds=seconds)) + + def __get_sessions_number(self, user: str) -> int: + return sum( + 1 + for session in self.__sessions.values() + if session.user == user + ) def logout(self, token: str) -> None: assert self.__enabled - user = self.__tokens.pop(token, "") - if user: - get_logger().info("Logged out user %r", user) + if token in self.__sessions: + user = self.__sessions[token].user + count = 0 + for (key_t, session) in list(self.__sessions.items()): + if session.user == user: + count += 1 + del self.__sessions[key_t] + get_logger(0).info("Logged out user %r; sessions_closed=%d", user, count) def check(self, token: str) -> (str | None): assert self.__enabled - return self.__tokens.get(token) + session = self.__sessions.get(token) + if session is not None: + if session.expire_ts <= 0: + # Infinite session + return session.user + else: + # Limited session + if self.__get_now_ts() < session.expire_ts: + return session.user + else: + del self.__sessions[token] + get_logger(0).info("The session of user %r is expired; sessions_left=%d", + session.user, + self.__get_sessions_number(session.user)) + return None + + def start_ws_session(self, token: str) -> None: + self.__renew_ws_session(token, True) # Infinite until stop_ws_session() + + def stop_ws_session(self, token: str) -> None: + self.__renew_ws_session(token, False) # Invalidate if needed + + def __renew_ws_session(self, token: str, start: bool) -> None: + if self.__enabled and self.__extend: + session = self.__sessions.get(token) + if session is not None: + ws_started = session.ws_started + (1 if start else -1) + + expire_ts = 0 + if ws_started <= 0: + expire_ts = self.__make_expire_ts(session.expire_req) + + self.__sessions[token] = _Session( + user=session.user, + expire_req=session.expire_req, + expire_ts=expire_ts, + ws_started=ws_started, + ) @aiotools.atomic_fg async def cleanup(self) -> None: if self.__enabled: - assert self.__internal_service - await self.__internal_service.cleanup() - if self.__external_service: - await self.__external_service.cleanup() + assert self.__int_service + await self.__int_service.cleanup() + if self.__ext_service: + await self.__ext_service.cleanup() + + # ===== + + def __load_usc_uids(self, users: list[str], groups: list[str]) -> dict[int, str]: + uids: dict[int, str] = {} + + pwds: dict[str, int] = {} + for pw in pwd.getpwall(): + assert pw.pw_name == pw.pw_name.strip() + assert pw.pw_name + pwds[pw.pw_name] = pw.pw_uid + if pw.pw_name in users: + uids[pw.pw_uid] = pw.pw_name + + for gr in grp.getgrall(): + if gr.gr_name in groups: + for member in gr.gr_mem: + if member in pwds: + uid = pwds[member] + uids[uid] = member + + return uids + + def check_unix_credentials(self, creds: RequestUnixCredentials) -> (str | None): + assert self.__enabled + return self.__usc_uids.get(creds.uid) diff --git a/kvmd/apps/kvmd/info/__init__.py b/kvmd/apps/kvmd/info/__init__.py index 60ef34447..43bee90a9 100644 --- a/kvmd/apps/kvmd/info/__init__.py +++ b/kvmd/apps/kvmd/info/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,10 @@ # ========================================================================== # +import asyncio + +from typing import AsyncGenerator + from ....yamlconf import Section from .base import BaseInfoSubmanager @@ -27,24 +31,99 @@ from .system import SystemInfoSubmanager from .meta import MetaInfoSubmanager from .extras import ExtrasInfoSubmanager -from .hw import HwInfoSubmanager +from .health import HealthInfoSubmanager +from .uptime import UptimeInfoSubmanager +from .node import NodeInfoSubmanager from .fan import FanInfoSubmanager # ===== class InfoManager: def __init__(self, config: Section) -> None: - self.__subs = { - "system": SystemInfoSubmanager(config.kvmd.streamer.cmd), - "auth": AuthInfoSubmanager(config.kvmd.auth.enabled), - "meta": MetaInfoSubmanager(config.kvmd.info.meta), + self.__subs: dict[str, BaseInfoSubmanager] = { + "system": SystemInfoSubmanager(config.kvmd.info.hw.platform, config.kvmd.streamer.cmd), + "auth": AuthInfoSubmanager(config.kvmd.auth.enabled), + "meta": MetaInfoSubmanager(config.kvmd.info.meta), "extras": ExtrasInfoSubmanager(config), - "hw": HwInfoSubmanager(**config.kvmd.info.hw._unpack()), - "fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()), + "health": HealthInfoSubmanager(**config.kvmd.info.hw._unpack(ignore="platform")), + "uptime": UptimeInfoSubmanager(), + "node": NodeInfoSubmanager(), + "fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()), } + self.__queue: "asyncio.Queue[tuple[str, (dict | None)]]" = asyncio.Queue() + + async def get_meta_server_host(self) -> str: + return (await self.__subs["meta"].get_server_host()) # type: ignore def get_subs(self) -> set[str]: return set(self.__subs) - def get_submanager(self, name: str) -> BaseInfoSubmanager: - return self.__subs[name] + async def get_state(self, fields: (list[str] | None)=None) -> dict: + fields = sorted(set(fields or list(self.__subs))) + return dict(zip(fields, await asyncio.gather(*[ + self.__subs[field].get_state() + for field in fields + ]))) + + async def get_state_legacy(self, fields: (list[str] | None)=None) -> dict: + fields_set = set(fields or list(self.__subs)) + hw = ("hw" in fields_set) # Old for compatible + system = ("system" in fields_set) + if hw: + fields_set.remove("hw") + fields_set.add("health") + fields_set.add("system") + + fields = sorted(fields_set) + state = dict(zip(fields, await asyncio.gather(*[ + self.__subs[field].get_state() + for field in fields + ]))) + + if hw: + state["hw"] = { + "health": state.pop("health"), + "platform": (state["system"] or {}).pop("platform"), # {} makes mypy happy + } + if not system: + state.pop("system") + return state + + async def trigger_state(self) -> None: + await asyncio.gather(*[ + sub.trigger_state() + for sub in self.__subs.values() + ]) + + async def poll_state(self) -> AsyncGenerator[dict, None]: + # ==== Granularity table ==== + # - system -- Partial + # - auth -- Partial + # - meta -- Partial, nullable + # - extras -- Partial, nullable + # - health -- Partial + # - uptime -- Partial + # - node -- Partial + # - fan -- Partial + # =========================== + + while True: + (field, value) = await self.__queue.get() + yield {field: value} + + async def systask(self) -> None: + tasks = [ + asyncio.create_task(self.__poller(field)) + for field in self.__subs + ] + try: + await asyncio.gather(*tasks) + except Exception: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + + async def __poller(self, field: str) -> None: + async for state in self.__subs[field].poll_state(): + self.__queue.put_nowait((field, state)) diff --git a/kvmd/apps/kvmd/info/auth.py b/kvmd/apps/kvmd/info/auth.py index 47b3fef7e..301cffe37 100644 --- a/kvmd/apps/kvmd/info/auth.py +++ b/kvmd/apps/kvmd/info/auth.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,10 @@ # ========================================================================== # +from typing import AsyncGenerator + +from .... import aiotools + from .base import BaseInfoSubmanager @@ -27,6 +31,15 @@ class AuthInfoSubmanager(BaseInfoSubmanager): def __init__(self, enabled: bool) -> None: self.__enabled = enabled + self.__notifier = aiotools.AioNotifier() async def get_state(self) -> dict: return {"enabled": self.__enabled} + + async def trigger_state(self) -> None: + self.__notifier.notify() + + async def poll_state(self) -> AsyncGenerator[(dict | None), None]: + while True: + await self.__notifier.wait() + yield (await self.get_state()) diff --git a/kvmd/apps/kvmd/info/base.py b/kvmd/apps/kvmd/info/base.py index a73565d25..d090ed34d 100644 --- a/kvmd/apps/kvmd/info/base.py +++ b/kvmd/apps/kvmd/info/base.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,7 +20,17 @@ # ========================================================================== # +from typing import AsyncGenerator + + # ===== class BaseInfoSubmanager: async def get_state(self) -> (dict | None): raise NotImplementedError + + async def trigger_state(self) -> None: + raise NotImplementedError + + async def poll_state(self) -> AsyncGenerator[(dict | None), None]: + yield None + raise NotImplementedError diff --git a/kvmd/apps/kvmd/info/extras.py b/kvmd/apps/kvmd/info/extras.py index 922526fe5..417ba245e 100644 --- a/kvmd/apps/kvmd/info/extras.py +++ b/kvmd/apps/kvmd/info/extras.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -24,6 +24,8 @@ import re import asyncio +from typing import AsyncGenerator + from ....logging import get_logger from ....yamlconf import Section @@ -41,13 +43,14 @@ class ExtrasInfoSubmanager(BaseInfoSubmanager): def __init__(self, global_config: Section) -> None: self.__global_config = global_config + self.__notifier = aiotools.AioNotifier() async def get_state(self) -> (dict | None): try: sui = sysunit.SystemdUnitInfo() await sui.open() - except Exception as err: - get_logger(0).error("Can't open systemd bus to get extras state: %s", tools.efmt(err)) + except Exception as ex: + get_logger(0).error("Can't open systemd bus to get extras state: %s", tools.efmt(ex)) sui = None try: extras: dict[str, dict] = {} @@ -65,12 +68,20 @@ async def get_state(self) -> (dict | None): if sui is not None: await aiotools.shield_fg(sui.close()) + async def trigger_state(self) -> None: + self.__notifier.notify() + + async def poll_state(self) -> AsyncGenerator[(dict | None), None]: + while True: + await self.__notifier.wait() + yield (await self.get_state()) + def __get_extras_path(self, *parts: str) -> str: return os.path.join(self.__global_config.kvmd.info.extras, *parts) async def __read_extra(self, sui: (sysunit.SystemdUnitInfo | None), name: str) -> dict: try: - extra = await aiotools.run_async(load_yaml_file, self.__get_extras_path(name, "manifest.yaml")) + extra = await asyncio.to_thread(load_yaml_file, self.__get_extras_path(name, "manifest.yaml")) await self.__rewrite_app_daemon(sui, extra) self.__rewrite_app_port(extra) return {re.sub(r"[^a-zA-Z0-9_]+", "_", name): extra} @@ -85,8 +96,8 @@ async def __rewrite_app_daemon(self, sui: (sysunit.SystemdUnitInfo | None), extr if sui is not None: try: (extra["enabled"], extra["started"]) = await sui.get_status(daemon) - except Exception as err: - get_logger(0).error("Can't get info about the service %r: %s", daemon, tools.efmt(err)) + except Exception as ex: + get_logger(0).error("Can't get info about the service %r: %s", daemon, tools.efmt(ex)) def __rewrite_app_port(self, extra: dict) -> None: port_path = extra.get("port", "") diff --git a/kvmd/apps/kvmd/info/fan.py b/kvmd/apps/kvmd/info/fan.py index 39572059c..91ff258df 100644 --- a/kvmd/apps/kvmd/info/fan.py +++ b/kvmd/apps/kvmd/info/fan.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,7 +21,6 @@ import copy -import asyncio from typing import AsyncGenerator @@ -53,6 +52,8 @@ def __init__( self.__timeout = timeout self.__state_poll = state_poll + self.__notifier = aiotools.AioNotifier() + async def get_state(self) -> dict: monitored = await self.__get_monitored() return { @@ -60,24 +61,27 @@ async def get_state(self) -> dict: "state": ((await self.__get_fan_state() if monitored else None)), } - async def poll_state(self) -> AsyncGenerator[dict, None]: - prev_state: dict = {} + async def trigger_state(self) -> None: + self.__notifier.notify(1) + + async def poll_state(self) -> AsyncGenerator[(dict | None), None]: + prev: dict = {} while True: if self.__unix_path: - pure = state = await self.get_state() - if pure["state"] is not None: - try: - pure = copy.deepcopy(state) - pure["state"]["service"]["now_ts"] = 0 - except Exception: - pass - if pure != prev_state: - yield state - prev_state = pure - await asyncio.sleep(self.__state_poll) + if (await self.__notifier.wait(timeout=self.__state_poll)) > 0: + prev = {} + new = await self.get_state() # Can be None + pure = copy.deepcopy(new) + try: + pure["state"]["service"]["now_ts"] = 0 + except Exception: + pass + if pure != prev: + prev = pure + yield new else: + await self.__notifier.wait() yield (await self.get_state()) - await aiotools.wait_infinite() # ===== @@ -87,26 +91,24 @@ async def __get_monitored(self) -> bool: async with sysunit.SystemdUnitInfo() as sui: status = await sui.get_status(self.__daemon) return (status[0] or status[1]) - except Exception as err: - get_logger(0).error("Can't get info about the service %r: %s", self.__daemon, tools.efmt(err)) + except Exception as ex: + get_logger(0).error("Can't get info about the service %r: %s", + self.__daemon, tools.efmt(ex)) return False async def __get_fan_state(self) -> (dict | None): try: async with self.__make_http_session() as session: - async with session.get("http://localhost/state") as response: - htclient.raise_not_200(response) - return (await response.json())["result"] - except Exception as err: - get_logger(0).error("Can't read fan state: %s", err) + async with session.get("http://localhost/state") as resp: + htclient.raise_not_200(resp) + return (await resp.json())["result"] + except Exception as ex: + get_logger(0).error("Can't read fan state: %s", ex) return None def __make_http_session(self) -> aiohttp.ClientSession: - kwargs: dict = { - "headers": { - "User-Agent": htclient.make_user_agent("KVMD"), - }, - "timeout": aiohttp.ClientTimeout(total=self.__timeout), - "connector": aiohttp.UnixConnector(path=self.__unix_path) - } - return aiohttp.ClientSession(**kwargs) + return aiohttp.ClientSession( + headers={"User-Agent": htclient.make_user_agent("KVMD")}, + connector=aiohttp.UnixConnector(path=self.__unix_path), + timeout=aiohttp.ClientTimeout(total=self.__timeout), + ) diff --git a/kvmd/apps/kvmd/info/hw.py b/kvmd/apps/kvmd/info/health.py similarity index 63% rename from kvmd/apps/kvmd/info/hw.py rename to kvmd/apps/kvmd/info/health.py index fe0008abf..db38409ab 100644 --- a/kvmd/apps/kvmd/info/hw.py +++ b/kvmd/apps/kvmd/info/health.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,13 +20,15 @@ # ========================================================================== # -import os import asyncio +import copy from typing import Callable from typing import AsyncGenerator from typing import TypeVar +import psutil + from ....logging import get_logger from .... import env @@ -42,68 +44,101 @@ # ===== -class HwInfoSubmanager(BaseInfoSubmanager): +class HealthInfoSubmanager(BaseInfoSubmanager): def __init__( self, vcgencmd_cmd: list[str], + ignore_past: bool, state_poll: float, ) -> None: self.__vcgencmd_cmd = vcgencmd_cmd + self.__ignore_past = ignore_past self.__state_poll = state_poll - self.__dt_cache: dict[str, str] = {} + self.__notifier = aiotools.AioNotifier() async def get_state(self) -> dict: - (model, serial, cpu_temp, throttling) = await asyncio.gather( - self.__read_dt_file("model"), - self.__read_dt_file("serial-number"), - self.__get_cpu_temp(), + ( + throttling, + cpu_percent, + cpu_temp, + mem, + ) = await asyncio.gather( self.__get_throttling(), + self.__get_cpu_percent(), + self.__get_cpu_temp(), + self.__get_mem(), ) return { - "platform": { - "type": "rpi", - "base": model, - "serial": serial, + "temp": { + "cpu": cpu_temp, }, - "health": { - "temp": { - "cpu": cpu_temp, - }, - "throttling": throttling, + "cpu": { + "percent": cpu_percent, }, + "mem": mem, + "throttling": throttling, } + async def trigger_state(self) -> None: + self.__notifier.notify(1) + async def poll_state(self) -> AsyncGenerator[dict, None]: - prev_state: dict = {} + prev: dict = {} while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - await asyncio.sleep(self.__state_poll) + if (await self.__notifier.wait(timeout=self.__state_poll)) > 0: + prev = {} + new = await self.get_state() + if new != prev: + prev = copy.deepcopy(new) + yield new # ===== - async def __read_dt_file(self, name: str) -> (str | None): - if name not in self.__dt_cache: - path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name) - try: - self.__dt_cache[name] = (await aiotools.read_file(path)).strip(" \t\r\n\0") - except Exception as err: - get_logger(0).error("Can't read DT %s from %s: %s", name, path, err) - return None - return self.__dt_cache[name] - async def __get_cpu_temp(self) -> (float | None): temp_path = f"{env.SYSFS_PREFIX}/sys/class/thermal/thermal_zone0/temp" try: return int((await aiotools.read_file(temp_path)).strip()) / 1000 - except Exception as err: - get_logger(0).error("Can't read CPU temp from %s: %s", temp_path, err) + except Exception as ex: + get_logger(0).error("Can't read CPU temp from %s: %s", temp_path, ex) return None + async def __get_cpu_percent(self) -> (float | None): + try: + st = psutil.cpu_times_percent() + user = st.user - st.guest + nice = st.nice - st.guest_nice + idle_all = st.idle + st.iowait + system_all = st.system + st.irq + st.softirq + virtual = st.guest + st.guest_nice + total = max(1, user + nice + system_all + idle_all + st.steal + virtual) + return int( + st.nice / total * 100 + + st.user / total * 100 + + system_all / total * 100 + + (st.steal + st.guest) / total * 100 + ) + except Exception as ex: + get_logger(0).error("Can't get CPU percent: %s", ex) + return None + + async def __get_mem(self) -> dict: + try: + st = psutil.virtual_memory() + return { + "percent": st.percent, + "total": st.total, + "available": st.available, + } + except Exception as ex: + get_logger(0).error("Can't get memory info: %s", ex) + return { + "percent": None, + "total": None, + "available": None, + } + async def __get_throttling(self) -> (dict | None): # https://www.raspberrypi.org/forums/viewtopic.php?f=63&t=147781&start=50#p972790 flags = await self.__parse_vcgencmd( @@ -127,6 +162,7 @@ async def __get_throttling(self) -> (dict | None): "past": bool(flags & (1 << 18)), }, }, + "ignore_past": self.__ignore_past, } return None @@ -139,6 +175,6 @@ async def __parse_vcgencmd(self, arg: str, parser: Callable[[str], _RetvalT]) -> return None try: return parser(text) - except Exception as err: - get_logger(0).error("Can't parse [ %s ] output: %r: %s", tools.cmdfmt(cmd), text, tools.efmt(err)) + except Exception as ex: + get_logger(0).error("Can't parse [ %s ] output: %r: %s", tools.cmdfmt(cmd), text, tools.efmt(ex)) return None diff --git a/kvmd/apps/kvmd/info/meta.py b/kvmd/apps/kvmd/info/meta.py index 0da96a359..f72488d9a 100644 --- a/kvmd/apps/kvmd/info/meta.py +++ b/kvmd/apps/kvmd/info/meta.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,11 @@ # ========================================================================== # +import asyncio +import socket + +from typing import AsyncGenerator + from ....logging import get_logger from ....yamlconf.loader import load_yaml_file @@ -33,10 +38,30 @@ class MetaInfoSubmanager(BaseInfoSubmanager): def __init__(self, meta_path: str) -> None: self.__meta_path = meta_path + self.__notifier = aiotools.AioNotifier() + + async def get_server_host(self) -> str: + meta = await self.get_state() + try: + host = str(meta.get("server", {})["host"]).strip() # type: ignore + except Exception: + host = "" + return (host or socket.gethostname()) async def get_state(self) -> (dict | None): try: - return ((await aiotools.run_async(load_yaml_file, self.__meta_path)) or {}) + meta = ((await asyncio.to_thread(load_yaml_file, self.__meta_path)) or {}) + if meta["server"]["host"] == "@auto": + meta["server"]["host"] = socket.gethostname() + return meta except Exception: get_logger(0).exception("Can't parse meta") return None + + async def trigger_state(self) -> None: + self.__notifier.notify() + + async def poll_state(self) -> AsyncGenerator[(dict | None), None]: + while True: + await self.__notifier.wait() + yield (await self.get_state()) diff --git a/kvmd/apps/kvmd/info/node.py b/kvmd/apps/kvmd/info/node.py new file mode 100644 index 000000000..3231d798f --- /dev/null +++ b/kvmd/apps/kvmd/info/node.py @@ -0,0 +1,53 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import socket +import copy + +from typing import AsyncGenerator + +from .... import aiotools + +from .base import BaseInfoSubmanager + + +# ===== +class NodeInfoSubmanager(BaseInfoSubmanager): + def __init__(self) -> None: + self.__notifier = aiotools.AioNotifier() + + async def get_state(self) -> dict: + return {"host": socket.gethostname()} + + async def trigger_state(self) -> None: + self.__notifier.notify(1) + + async def poll_state(self) -> AsyncGenerator[(dict | None), None]: + prev: dict = {} + while True: + if (await self.__notifier.wait(timeout=1)) > 0: + prev = {} + new = await self.get_state() + pure = copy.deepcopy(new) + if pure != prev: + prev = pure + yield new diff --git a/kvmd/apps/kvmd/info/system.py b/kvmd/apps/kvmd/info/system.py index 4c2b4e30e..85b466730 100644 --- a/kvmd/apps/kvmd/info/system.py +++ b/kvmd/apps/kvmd/info/system.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -24,8 +24,12 @@ import asyncio import platform +from typing import AsyncGenerator + from ....logging import get_logger +from .... import env +from .... import aiotools from .... import aioproc from .... import __version__ @@ -35,11 +39,30 @@ # ===== class SystemInfoSubmanager(BaseInfoSubmanager): - def __init__(self, streamer_cmd: list[str]) -> None: + def __init__( + self, + platform_path: str, + streamer_cmd: list[str], + ) -> None: + + self.__platform_path = platform_path self.__streamer_cmd = streamer_cmd + self.__dt_cache: dict[str, str] = {} + self.__notifier = aiotools.AioNotifier() + async def get_state(self) -> dict: - streamer_info = await self.__get_streamer_info() + ( + base, + serial, + pl, + streamer_info, + ) = await asyncio.gather( + self.__read_dt_file("model", upper=False), + self.__read_dt_file("serial-number", upper=True), + self.__read_platform_file(), + self.__get_streamer_info(), + ) uname_info = platform.uname() # Uname using the internal cache return { "kvmd": {"version": __version__}, @@ -48,10 +71,53 @@ async def get_state(self) -> dict: field: getattr(uname_info, field) for field in ["system", "release", "version", "machine"] }, + "platform": { + "type": "rpi", + "base": base, + "serial": serial, + **pl, # type: ignore + }, } + async def trigger_state(self) -> None: + self.__notifier.notify() + + async def poll_state(self) -> AsyncGenerator[(dict | None), None]: + while True: + await self.__notifier.wait() + yield (await self.get_state()) + # ===== + async def __read_dt_file(self, name: str, upper: bool) -> (str | None): + if name not in self.__dt_cache: + path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name) + try: + value = (await aiotools.read_file(path)).strip(" \t\r\n\0") + self.__dt_cache[name] = (value.upper() if upper else value) + except Exception as ex: + get_logger(0).error("Can't read DT %s from %s: %s", name, path, ex) + return None + return self.__dt_cache[name] + + async def __read_platform_file(self) -> dict: + try: + text = await aiotools.read_file(self.__platform_path) + parsed: dict[str, str] = {} + for row in text.split("\n"): + row = row.strip() + if row: + (key, value) = row.split("=", 1) + parsed[key.strip()] = value.strip() + return { + "model": parsed["PIKVM_MODEL"], + "video": parsed["PIKVM_VIDEO"], + "board": parsed["PIKVM_BOARD"], + } + except Exception: + get_logger(0).exception("Can't read device model") + return {"model": None, "video": None, "board": None} + async def __get_streamer_info(self) -> dict: version = "" features: dict[str, bool] = {} diff --git a/kvmd/apps/kvmd/info/uptime.py b/kvmd/apps/kvmd/info/uptime.py new file mode 100644 index 000000000..fbd8d5f5b --- /dev/null +++ b/kvmd/apps/kvmd/info/uptime.py @@ -0,0 +1,75 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os +import datetime + +from typing import AsyncGenerator + +from ....logging import get_logger + +from .... import env +from .... import aiotools + +from .base import BaseInfoSubmanager + + +# ===== +class UptimeInfoSubmanager(BaseInfoSubmanager): + __RESOLUTION = 5 # Seconds + + def __init__(self) -> None: + self.__notifier = aiotools.AioNotifier() + + async def get_state(self) -> dict: + total = await self.__read_uptime_file() + uptime = datetime.timedelta(seconds=total) + days = uptime.days + (hours, rem) = divmod(uptime.seconds, 3600) + (mins, secs) = divmod(rem, 60) + return { + "total": total, + "parts": { + "days": days, + "hours": hours, + "minutes": mins, + "seconds": secs, + }, + } + + async def trigger_state(self) -> None: + self.__notifier.notify() + + async def poll_state(self) -> AsyncGenerator[(dict | None), None]: + while True: + await self.__notifier.wait(timeout=self.__RESOLUTION) + yield (await self.get_state()) + + # ===== + + async def __read_uptime_file(self) -> int: + path = os.path.join(f"{env.PROCFS_PREFIX}/proc/uptime") + try: + return int(float((await aiotools.read_file(path)).split()[0])) + except Exception as ex: + get_logger(0).error("Can't read system uptime: %s", ex) + return 0 diff --git a/kvmd/apps/kvmd/logreader.py b/kvmd/apps/kvmd/logreader.py index 34d1143a8..6de2819be 100644 --- a/kvmd/apps/kvmd/logreader.py +++ b/kvmd/apps/kvmd/logreader.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -34,7 +34,8 @@ class LogReader: async def poll_log(self, seek: int, follow: bool) -> AsyncGenerator[dict, None]: reader = systemd.journal.Reader() reader.this_boot() - reader.this_machine() + # XXX: Из-за смены ID машины в bootconfig это не работает при первой загрузке. + # reader.this_machine() reader.log_level(systemd.journal.LOG_DEBUG) services = set( diff --git a/kvmd/apps/kvmd/ocr.py b/kvmd/apps/kvmd/ocr.py index fbe02b49f..91cd137a3 100644 --- a/kvmd/apps/kvmd/ocr.py +++ b/kvmd/apps/kvmd/ocr.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -23,6 +23,7 @@ import os import stat import io +import asyncio import ctypes import ctypes.util import contextlib @@ -37,6 +38,7 @@ from ctypes import c_char from typing import Generator +from typing import AsyncGenerator from PIL import ImageOps from PIL import Image as PilImage @@ -76,8 +78,8 @@ def _load_libtesseract() -> (ctypes.CDLL | None): setattr(func, "restype", restype) setattr(func, "argtypes", argtypes) return lib - except Exception as err: - warnings.warn(f"Can't load libtesseract: {err}", RuntimeWarning) + except Exception as ex: + warnings.warn(f"Can't load libtesseract: {ex}", RuntimeWarning) return None @@ -107,9 +109,37 @@ class Ocr: def __init__(self, data_dir_path: str, default_langs: list[str]) -> None: self.__data_dir_path = data_dir_path self.__default_langs = default_langs - - def is_available(self) -> bool: - return bool(_libtess) + self.__notifier = aiotools.AioNotifier() + + async def get_state(self) -> dict: + enabled = bool(_libtess) + default: list[str] = [] + available: list[str] = [] + if enabled: + default = self.get_default_langs() + available = self.get_available_langs() + return { + "enabled": enabled, + "langs": { + "default": default, + "available": available, + }, + } + + async def trigger_state(self) -> None: + self.__notifier.notify() + + async def poll_state(self) -> AsyncGenerator[dict, None]: + # ===== Granularity table ===== + # - enabled -- Full + # - langs -- Partial + # ============================= + + while True: + await self.__notifier.wait() + yield (await self.get_state()) + + # ===== def get_default_langs(self) -> list[str]: return list(self.__default_langs) @@ -129,7 +159,7 @@ def get_available_langs(self) -> list[str]: async def recognize(self, data: bytes, langs: list[str], left: int, top: int, right: int, bottom: int) -> str: if not langs: langs = self.__default_langs - return (await aiotools.run_async(self.__inner_recognize, data, langs, left, top, right, bottom)) + return (await asyncio.to_thread(self.__inner_recognize, data, langs, left, top, right, bottom)) def __inner_recognize(self, data: bytes, langs: list[str], left: int, top: int, right: int, bottom: int) -> str: with _tess_api(self.__data_dir_path, langs) as api: @@ -145,12 +175,12 @@ def __inner_recognize(self, data: bytes, langs: list[str], left: int, top: int, if left < right and top < bottom: image_cropped = image.crop((left, top, right, bottom)) image.close() - image = image_cropped + image = image_cropped # type: ignore ImageOps.grayscale(image) - image_resized = image.resize((int(image.size[0] * 2), int(image.size[1] * 2)), PilImage.BICUBIC) + image_resized = image.resize((int(image.size[0] * 2), int(image.size[1] * 2)), PilImage.Resampling.BICUBIC) image.close() - image = image_resized + image = image_resized # type: ignore _libtess.TessBaseAPISetImage(api, image.tobytes("raw", "RGB"), image.width, image.height, 3, image.width * 3) text_ptr = None diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index 8f696fb78..99373c514 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,29 +20,25 @@ # ========================================================================== # -import asyncio -import operator import dataclasses -from typing import Tuple -from typing import List -from typing import Dict from typing import Callable from typing import Coroutine from typing import AsyncGenerator -from typing import Optional from typing import Any from aiohttp.web import Request from aiohttp.web import Response from aiohttp.web import WebSocketResponse +from ... import __version__ + from ...logging import get_logger from ...errors import OperationError from ... import aiotools -from ... import aioproc +from ... import aiomulti from ...htserver import HttpExposed from ...htserver import exposed_http @@ -70,6 +66,7 @@ from .streamer import Streamer from .snapshoter import Snapshoter from .ocr import Ocr +from .switch import Switch from .api.auth import AuthApi from .api.auth import check_request_auth @@ -81,8 +78,11 @@ from .api.atx import AtxApi from .api.msd import MsdApi from .api.streamer import StreamerApi +from .api.switch import SwitchApi from .api.export import ExportApi -from .api.redfish import RedfishApi +from .api.redfish.root import RedfishRootApi +from .api.redfish.atx import RedfishAtxApi +from .api.redfish.msd import RedfishMsdApi # ===== @@ -102,35 +102,56 @@ def __init__(self) -> None: # ===== -@dataclasses.dataclass(frozen=True) -class _Component: # pylint: disable=too-many-instance-attributes - name: str - event_type: str - obj: object - sysprep: Optional[Callable[[], None]] = None - systask: Optional[Callable[[], Coroutine[Any, Any, None]]] = None - get_state: Optional[Callable[[], Coroutine[Any, Any, Dict]]] = None - poll_state: Optional[Callable[[], AsyncGenerator[Dict, None]]] = None - cleanup: Optional[Callable[[], Coroutine[Any, Any, Dict]]] = None +@dataclasses.dataclass +class _Subsystem: + name: str + event_type: str + sysprep: (Callable[[], Coroutine[Any, Any, None]] | None) + systask: (Callable[[], Coroutine[Any, Any, None]] | None) + cleanup: (Callable[[], Coroutine[Any, Any, dict]] | None) + trigger_state: (Callable[[], Coroutine[Any, Any, None]] | None) = None + poll_state: (Callable[[], AsyncGenerator[dict, None]] | None) = None def __post_init__(self) -> None: - if isinstance(self.obj, BasePlugin): - object.__setattr__(self, "name", f"{self.name} ({self.obj.get_plugin_name()})") - - for field in ["sysprep", "systask", "get_state", "poll_state", "cleanup"]: - object.__setattr__(self, field, getattr(self.obj, field, None)) - if self.get_state or self.poll_state: - assert self.event_type, self + if self.event_type: + assert self.trigger_state + assert self.poll_state + + @classmethod + def make(cls, obj: object, name: str, event_type: str="") -> "_Subsystem": + if isinstance(obj, BasePlugin): + name = f"{name} ({obj.get_plugin_name()})" + return _Subsystem( + name=name, + event_type=event_type, + sysprep=getattr(obj, "sysprep", None), + systask=getattr(obj, "systask", None), + cleanup=getattr(obj, "cleanup", None), + trigger_state=getattr(obj, "trigger_state", None), + poll_state=getattr(obj, "poll_state", None), + ) class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-instance-attributes + __EV_GPIO_STATE = "gpio" + __EV_HID_STATE = "hid" + __EV_HID_KEYMAPS_STATE = "hid_keymaps" # FIXME + __EV_ATX_STATE = "atx" + __EV_MSD_STATE = "msd" + __EV_STREAMER_STATE = "streamer" + __EV_OCR_STATE = "ocr" + __EV_INFO_STATE = "info" + __EV_SWITCH_STATE = "switch" + __EV_CLIENTS_STATE = "clients" # FIXME + def __init__( # pylint: disable=too-many-arguments,too-many-locals self, - auth_manager: AuthManager, - info_manager: InfoManager, + auth: AuthManager, + im: InfoManager, log_reader: (LogReader | None), - user_gpio: UserGpio, + ugpio: UserGpio, ocr: Ocr, + switch: Switch, hid: BaseHid, atx: BaseAtx, @@ -139,73 +160,65 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals snapshoter: Snapshoter, keymap_path: str, - ignore_keys: List[str], - mouse_x_range: Tuple[int, int], - mouse_y_range: Tuple[int, int], stream_forever: bool, ) -> None: super().__init__() - self.__auth_manager = auth_manager + self.__auth = auth self.__hid = hid self.__streamer = streamer self.__snapshoter = snapshoter # Not a component: No state or cleanup - self.__user_gpio = user_gpio # Has extra state "gpio_scheme_state" self.__stream_forever = stream_forever - self.__components = [ - *[ - _Component("Auth manager", "", auth_manager), - ], - *[ - _Component(f"Info manager ({sub})", f"info_{sub}_state", info_manager.get_submanager(sub)) - for sub in sorted(info_manager.get_subs()) - ], - *[ - _Component("User-GPIO", "gpio_state", user_gpio), - _Component("HID", "hid_state", hid), - _Component("ATX", "atx_state", atx), - _Component("MSD", "msd_state", msd), - _Component("Streamer", "streamer_state", streamer), - ], - ] - - self.__hid_api = HidApi(hid, keymap_path, ignore_keys, mouse_x_range, mouse_y_range) # Ugly hack to get keymaps state - self.__streamer_api = StreamerApi(streamer, ocr) # Same hack to get ocr langs state - self.__apis: List[object] = [ + self.__hid_api = HidApi(hid, keymap_path) # Ugly hack to get keymaps state + self.__apis: list[object] = [ self, - AuthApi(auth_manager), - InfoApi(info_manager), + AuthApi(auth), + InfoApi(im), LogApi(log_reader), - UserGpioApi(user_gpio), + UserGpioApi(ugpio), self.__hid_api, AtxApi(atx), MsdApi(msd), - self.__streamer_api, - ExportApi(info_manager, atx, user_gpio), - RedfishApi(info_manager, atx), + StreamerApi(streamer, ocr), + SwitchApi(switch), + ExportApi(im, atx, ugpio), + RedfishRootApi(), + RedfishAtxApi(im, atx, switch), + RedfishMsdApi(msd), + ] + self.__subsystems = [ + _Subsystem.make(auth, "Auth"), + _Subsystem.make(ugpio, "GPIO", self.__EV_GPIO_STATE), + _Subsystem.make(hid, "HID", self.__EV_HID_STATE), + _Subsystem.make(atx, "ATX", self.__EV_ATX_STATE), + _Subsystem.make(msd, "MSD", self.__EV_MSD_STATE), + _Subsystem.make(streamer, "Streamer", self.__EV_STREAMER_STATE), + _Subsystem.make(ocr, "OCR", self.__EV_OCR_STATE), + _Subsystem.make(im, "Info", self.__EV_INFO_STATE), + _Subsystem.make(switch, "Switch", self.__EV_SWITCH_STATE), ] self.__streamer_notifier = aiotools.AioNotifier() self.__reset_streamer = False - self.__new_streamer_params: Dict = {} + self.__new_streamer_params: dict = {} # ===== STREAMER CONTROLLER @exposed_http("POST", "/streamer/set_params") - async def __streamer_set_params_handler(self, request: Request) -> Response: + async def __streamer_set_params_handler(self, req: Request) -> Response: current_params = self.__streamer.get_params() for (name, validator, exc_cls) in [ - ("quality", valid_stream_quality, StreamerQualityNotSupported), - ("desired_fps", valid_stream_fps, None), - ("resolution", valid_stream_resolution, StreamerResolutionNotSupported), + ("quality", valid_stream_quality, StreamerQualityNotSupported), + ("desired_fps", valid_stream_fps, None), + ("resolution", valid_stream_resolution, StreamerResolutionNotSupported), ("h264_bitrate", valid_stream_h264_bitrate, StreamerH264NotSupported), - ("h264_gop", valid_stream_h264_gop, StreamerH264NotSupported), + ("h264_gop", valid_stream_h264_gop, StreamerH264NotSupported), ]: - value = request.query.get(name) + value = req.query.get(name) if value: if name not in current_params: assert exc_cls is not None, name @@ -225,55 +238,54 @@ async def __streamer_reset_handler(self, _: Request) -> Response: # ===== WEBSOCKET @exposed_http("GET", "/ws") - async def __ws_handler(self, request: Request) -> WebSocketResponse: - stream = valid_bool(request.query.get("stream", True)) - async with self._ws_session(request, stream=stream) as ws: - stage1 = [ - ("gpio_model_state", self.__user_gpio.get_model()), - ("hid_keymaps_state", self.__hid_api.get_keymaps()), - ("streamer_ocr_state", self.__streamer_api.get_ocr()), - ] - stage2 = [ - (comp.event_type, comp.get_state()) - for comp in self.__components - if comp.get_state - ] - stages = stage1 + stage2 - events = dict(zip( - map(operator.itemgetter(0), stages), - await asyncio.gather(*map(operator.itemgetter(1), stages)), - )) - for stage in [stage1, stage2]: - await asyncio.gather(*[ - ws.send_event(event_type, events.pop(event_type)) - for (event_type, _) in stage - ]) - await ws.send_event("loop", {}) + async def __ws_handler(self, req: Request) -> WebSocketResponse: + stream = valid_bool(req.query.get("stream", True)) + async with self._ws_session(req, stream=stream) as ws: + (major, minor) = __version__.split(".") + await ws.send_event("loop", { + "version": { + "major": int(major), + "minor": int(minor), + }, + }) + for sub in self.__subsystems: + if sub.event_type: + assert sub.trigger_state + await sub.trigger_state() + await self._broadcast_ws_event(self.__EV_HID_KEYMAPS_STATE, await self.__hid_api.get_keymaps()) # FIXME + await self._broadcast_ws_event(self.__EV_CLIENTS_STATE, {"count": self.__get_stream_clients()}) # FIXME return (await self._ws_loop(ws)) @exposed_ws("ping") - async def __ws_ping_handler(self, ws: WsSession, _: Dict) -> None: + async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None: await ws.send_event("pong", {}) + @exposed_ws(0) + async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None: + await ws.send_bin(255, b"") # Ping-pong + # ===== SYSTEM STUFF def run(self, **kwargs: Any) -> None: # type: ignore # pylint: disable=arguments-differ - for comp in self.__components: - if comp.sysprep: - comp.sysprep() - aioproc.rename_process("main") + aiomulti.rename_process("main") super().run(**kwargs) - async def _check_request_auth(self, exposed: HttpExposed, request: Request) -> None: - await check_request_auth(self.__auth_manager, exposed, request) + async def _check_request_auth(self, exposed: HttpExposed, req: Request) -> None: + await check_request_auth(self.__auth, exposed, req) + + async def _before_app(self) -> None: + for sub in self.__subsystems: + if sub.sysprep: + await sub.sysprep() async def _init_app(self) -> None: aiotools.create_deadly_task("Stream controller", self.__stream_controller()) - for comp in self.__components: - if comp.systask: - aiotools.create_deadly_task(comp.name, comp.systask()) - if comp.poll_state: - aiotools.create_deadly_task(f"{comp.name} [poller]", self.__poll_state(comp.event_type, comp.poll_state())) + for sub in self.__subsystems: + if sub.systask: + aiotools.create_deadly_task(sub.name, sub.systask()) + if sub.event_type: + assert sub.poll_state + aiotools.create_deadly_task(f"{sub.name} [poller]", self.__poll_state(sub.event_type, sub.poll_state())) aiotools.create_deadly_task("Stream snapshoter", self.__stream_snapshoter()) self._add_exposed(*self.__apis) @@ -289,58 +301,69 @@ async def _on_shutdown(self) -> None: async def _on_cleanup(self) -> None: logger = get_logger(0) - for comp in self.__components: - if comp.cleanup: - logger.info("Cleaning up %s ...", comp.name) + for sub in self.__subsystems: + if sub.cleanup: + logger.info("Cleaning up %s ...", sub.name) try: - await comp.cleanup() # type: ignore + await sub.cleanup() # type: ignore except Exception: - logger.exception("Cleanup error on %s", comp.name) + logger.exception("Cleanup error on %s", sub.name) logger.info("On-Cleanup complete") - async def _on_ws_opened(self) -> None: + def _on_ws_added(self, ws: WsSession) -> None: + self.__auth.start_ws_session(ws.token) + self.__hid.clear_events() self.__streamer_notifier.notify() - async def _on_ws_closed(self) -> None: + def _on_ws_removed(self, ws: WsSession) -> None: + self.__auth.stop_ws_session(ws.token) self.__hid.clear_events() self.__streamer_notifier.notify() - def __has_stream_clients(self) -> bool: - return bool(sum(map( + def __get_stream_clients(self) -> int: + return sum(map( (lambda ws: ws.kwargs["stream"]), self._get_wss(), - ))) + )) # ===== SYSTEM TASKS async def __stream_controller(self) -> None: prev = False while True: - cur = (self.__has_stream_clients() or self.__snapshoter.snapshoting() or self.__stream_forever) + clients = self.__get_stream_clients() + await self._broadcast_ws_event(self.__EV_CLIENTS_STATE, {"count": clients}) # FIXME + + cur = ( + bool(clients) + or self.__snapshoter.snapshoting() + or self.__stream_forever + ) if not prev and cur: - await self.__streamer.ensure_start(reset=False) + await self.__streamer.ensure_start() elif prev and not cur: - await self.__streamer.ensure_stop(immediately=False) - - if self.__reset_streamer or self.__new_streamer_params: - start = self.__streamer.is_working() - await self.__streamer.ensure_stop(immediately=True) - if self.__new_streamer_params: - self.__streamer.set_params(self.__new_streamer_params) - self.__new_streamer_params = {} - if start: - await self.__streamer.ensure_start(reset=self.__reset_streamer) + await self.__streamer.ensure_stop() + prev = cur + + if self.__new_streamer_params: + if (await self.__streamer_notifier.wait(timeout=1)) >= 0: + continue + self.__streamer.set_params(self.__new_streamer_params) + self.__new_streamer_params = {} + self.__reset_streamer = True + + if self.__reset_streamer: + await self.__streamer.ensure_restart() self.__reset_streamer = False - prev = cur await self.__streamer_notifier.wait() - async def __poll_state(self, event_type: str, poller: AsyncGenerator[Dict, None]) -> None: - async for state in poller: - await self._broadcast_ws_event(event_type, state) - async def __stream_snapshoter(self) -> None: await self.__snapshoter.run( - is_live=self.__has_stream_clients, + is_live=(lambda: bool(self.__get_stream_clients())), notifier=self.__streamer_notifier, ) + + async def __poll_state(self, event_type: str, poller: AsyncGenerator[dict, None]) -> None: + async for state in poller: + await self._broadcast_ws_event(event_type, state) diff --git a/kvmd/apps/kvmd/snapshoter.py b/kvmd/apps/kvmd/snapshoter.py index 8e6ad5631..3799b2818 100644 --- a/kvmd/apps/kvmd/snapshoter.py +++ b/kvmd/apps/kvmd/snapshoter.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -31,6 +31,8 @@ from ...plugins.hid import BaseHid +from ...keyboard.mappings import WEB_TO_EVDEV + from .streamer import Streamer @@ -63,7 +65,7 @@ def __init__( else: self.__idle_interval = self.__live_interval = 0.0 - self.__wakeup_key = wakeup_key + self.__wakeup_key = WEB_TO_EVDEV.get(wakeup_key, 0) self.__wakeup_move = wakeup_move self.__online_delay = online_delay @@ -121,12 +123,12 @@ async def __take_snapshot(self, live: bool, notifier: aiotools.AioNotifier) -> N async def __wakeup(self) -> None: logger = get_logger(0) - if self.__wakeup_key: - logger.info("Waking up using key %r ...", self.__wakeup_key) - self.__hid.send_key_events([ - (self.__wakeup_key, True), - (self.__wakeup_key, False), - ]) + if self.__wakeup_key > 0: + logger.info("Waking up using keyboard ...") + await self.__hid.send_key_events( + keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)], + no_ignore_keys=True, + ) if self.__wakeup_move: logger.info("Waking up using mouse move for %d units ...", self.__wakeup_move) diff --git a/kvmd/apps/kvmd/streamer.py b/kvmd/apps/kvmd/streamer.py deleted file mode 100644 index 933498fd3..000000000 --- a/kvmd/apps/kvmd/streamer.py +++ /dev/null @@ -1,458 +0,0 @@ -# ========================================================================== # -# # -# KVMD - The main PiKVM daemon. # -# # -# Copyright (C) 2018-2023 Maxim Devaev # -# # -# 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 . # -# # -# ========================================================================== # - - -import io -import signal -import asyncio -import asyncio.subprocess -import dataclasses -import functools - -from typing import AsyncGenerator -from typing import Any - -import aiohttp - -from PIL import Image as PilImage - -from ...logging import get_logger - -from ... import tools -from ... import aiotools -from ... import aioproc -from ... import htclient - - -# ===== -@dataclasses.dataclass(frozen=True) -class StreamerSnapshot: - online: bool - width: int - height: int - headers: tuple[tuple[str, str], ...] - data: bytes - - async def make_preview(self, max_width: int, max_height: int, quality: int) -> bytes: - assert max_width >= 0 - assert max_height >= 0 - assert quality > 0 - - if max_width == 0 and max_height == 0: - max_width = self.width // 5 - max_height = self.height // 5 - else: - max_width = min((max_width or self.width), self.width) - max_height = min((max_height or self.height), self.height) - - if (max_width, max_height) == (self.width, self.height): - return self.data - return (await aiotools.run_async(self.__inner_make_preview, max_width, max_height, quality)) - - @functools.lru_cache(maxsize=1) - def __inner_make_preview(self, max_width: int, max_height: int, quality: int) -> bytes: - with io.BytesIO(self.data) as snapshot_bio: - with io.BytesIO() as preview_bio: - with PilImage.open(snapshot_bio) as image: - image.thumbnail((max_width, max_height), PilImage.ANTIALIAS) - image.save(preview_bio, format="jpeg", quality=quality) - return preview_bio.getvalue() - - -class _StreamerParams: - __DESIRED_FPS = "desired_fps" - - __QUALITY = "quality" - - __RESOLUTION = "resolution" - __AVAILABLE_RESOLUTIONS = "available_resolutions" - - __H264_BITRATE = "h264_bitrate" - __H264_GOP = "h264_gop" - - def __init__( # pylint: disable=too-many-arguments - self, - quality: int, - - resolution: str, - available_resolutions: list[str], - - desired_fps: int, - desired_fps_min: int, - desired_fps_max: int, - - h264_bitrate: int, - h264_bitrate_min: int, - h264_bitrate_max: int, - - h264_gop: int, - h264_gop_min: int, - h264_gop_max: int, - ) -> None: - - self.__has_quality = bool(quality) - self.__has_resolution = bool(resolution) - self.__has_h264 = bool(h264_bitrate) - - self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)} - self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}} - - if self.__has_quality: - self.__params[self.__QUALITY] = quality - - if self.__has_resolution: - self.__params[self.__RESOLUTION] = resolution - self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions - - if self.__has_h264: - self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max) - self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max} - self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max) - self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max} - - def get_features(self) -> dict: - return { - self.__QUALITY: self.__has_quality, - self.__RESOLUTION: self.__has_resolution, - "h264": self.__has_h264, - } - - def get_limits(self) -> dict: - limits = dict(self.__limits) - if self.__has_resolution: - limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS]) - return limits - - def get_params(self) -> dict: - return dict(self.__params) - - def set_params(self, params: dict) -> None: - new_params = dict(self.__params) - - if self.__QUALITY in params and self.__has_quality: - new_params[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100) - - if self.__RESOLUTION in params and self.__has_resolution: - if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]: - new_params[self.__RESOLUTION] = params[self.__RESOLUTION] - - for (key, enabled) in [ - (self.__DESIRED_FPS, True), - (self.__H264_BITRATE, self.__has_h264), - (self.__H264_GOP, self.__has_h264), - ]: - if key in params and enabled: - if self.__check_limits_min_max(key, params[key]): - new_params[key] = params[key] - - self.__params = new_params - - def __check_limits_min_max(self, key: str, value: int) -> bool: - return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"]) - - -class Streamer: # pylint: disable=too-many-instance-attributes - def __init__( # pylint: disable=too-many-arguments,too-many-locals - self, - - reset_delay: float, - shutdown_delay: float, - state_poll: float, - - unix_path: str, - timeout: float, - - process_name_prefix: str, - - cmd: list[str], - cmd_remove: list[str], - cmd_append: list[str], - - **params_kwargs: Any, - ) -> None: - - self.__reset_delay = reset_delay - self.__shutdown_delay = shutdown_delay - self.__state_poll = state_poll - - self.__unix_path = unix_path - self.__timeout = timeout - - self.__process_name_prefix = process_name_prefix - - self.__cmd = tools.build_cmd(cmd, cmd_remove, cmd_append) - - self.__params = _StreamerParams(**params_kwargs) - - self.__stop_task: (asyncio.Task | None) = None - self.__stop_wip = False - - self.__streamer_task: (asyncio.Task | None) = None - self.__streamer_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member - - self.__http_session: (aiohttp.ClientSession | None) = None - - self.__snapshot: (StreamerSnapshot | None) = None - - self.__notifier = aiotools.AioNotifier() - - # ===== - - @aiotools.atomic_fg - async def ensure_start(self, reset: bool) -> None: - if not self.__streamer_task or self.__stop_task: - logger = get_logger(0) - - if self.__stop_task: - if not self.__stop_wip: - self.__stop_task.cancel() - await asyncio.gather(self.__stop_task, return_exceptions=True) - logger.info("Streamer stop cancelled") - return - else: - await asyncio.gather(self.__stop_task, return_exceptions=True) - - if reset and self.__reset_delay > 0: - logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay) - await asyncio.sleep(self.__reset_delay) - logger.info("Starting streamer ...") - await self.__inner_start() - - @aiotools.atomic_fg - async def ensure_stop(self, immediately: bool) -> None: - if self.__streamer_task: - logger = get_logger(0) - - if immediately: - if self.__stop_task: - if not self.__stop_wip: - self.__stop_task.cancel() - await asyncio.gather(self.__stop_task, return_exceptions=True) - logger.info("Stopping streamer immediately ...") - await self.__inner_stop() - else: - await asyncio.gather(self.__stop_task, return_exceptions=True) - else: - logger.info("Stopping streamer immediately ...") - await self.__inner_stop() - - elif not self.__stop_task: - - async def delayed_stop() -> None: - try: - await asyncio.sleep(self.__shutdown_delay) - self.__stop_wip = True - logger.info("Stopping streamer after delay ...") - await self.__inner_stop() - finally: - self.__stop_task = None - self.__stop_wip = False - - logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay) - self.__stop_task = asyncio.create_task(delayed_stop()) - - def is_working(self) -> bool: - # Запущено и не планирует останавливаться - return bool(self.__streamer_task and not self.__stop_task) - - # ===== - - def set_params(self, params: dict) -> None: - assert not self.__streamer_task - return self.__params.set_params(params) - - def get_params(self) -> dict: - return self.__params.get_params() - - # ===== - - async def get_state(self) -> dict: - streamer_state = None - if self.__streamer_task: - session = self.__ensure_http_session() - try: - async with session.get(self.__make_url("state")) as response: - htclient.raise_not_200(response) - streamer_state = (await response.json())["result"] - except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError): - pass - except Exception: - get_logger().exception("Invalid streamer response from /state") - - snapshot: (dict | None) = None - if self.__snapshot: - snapshot = dataclasses.asdict(self.__snapshot) - del snapshot["headers"] - del snapshot["data"] - - return { - "limits": self.__params.get_limits(), - "params": self.__params.get_params(), - "snapshot": {"saved": snapshot}, - "streamer": streamer_state, - "features": self.__params.get_features(), - } - - async def poll_state(self) -> AsyncGenerator[dict, None]: - def signal_handler(*_: Any) -> None: - get_logger(0).info("Got SIGUSR2, checking the stream state ...") - self.__notifier.notify() - - get_logger(0).info("Installing SIGUSR2 streamer handler ...") - asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler) - - waiter_task: (asyncio.Task | None) = None - prev_state: dict = {} - while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - - if waiter_task is None: - waiter_task = asyncio.create_task(self.__notifier.wait()) - if waiter_task in (await aiotools.wait_first( - asyncio.ensure_future(asyncio.sleep(self.__state_poll)), - waiter_task, - ))[0]: - waiter_task = None - - # ===== - - async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None): - if load: - return self.__snapshot - else: - logger = get_logger() - session = self.__ensure_http_session() - try: - async with session.get(self.__make_url("snapshot")) as response: - htclient.raise_not_200(response) - online = (response.headers["X-UStreamer-Online"] == "true") - if online or allow_offline: - snapshot = StreamerSnapshot( - online=online, - width=int(response.headers["X-UStreamer-Width"]), - height=int(response.headers["X-UStreamer-Height"]), - headers=tuple( - (key, value) - for (key, value) in tools.sorted_kvs(dict(response.headers)) - if key.lower().startswith("x-ustreamer-") or key.lower() in [ - "x-timestamp", - "access-control-allow-origin", - "cache-control", - "pragma", - "expires", - ] - ), - data=bytes(await response.read()), - ) - if save: - self.__snapshot = snapshot - self.__notifier.notify() - return snapshot - logger.error("Stream is offline, no signal or so") - except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as err: - logger.error("Can't connect to streamer: %s", tools.efmt(err)) - except Exception: - logger.exception("Invalid streamer response from /snapshot") - return None - - def remove_snapshot(self) -> None: - self.__snapshot = None - - # ===== - - @aiotools.atomic_fg - async def cleanup(self) -> None: - await self.ensure_stop(immediately=True) - if self.__http_session: - await self.__http_session.close() - self.__http_session = None - - # ===== - - def __ensure_http_session(self) -> aiohttp.ClientSession: - if not self.__http_session: - kwargs: dict = { - "headers": {"User-Agent": htclient.make_user_agent("KVMD")}, - "connector": aiohttp.UnixConnector(path=self.__unix_path), - "timeout": aiohttp.ClientTimeout(total=self.__timeout), - } - self.__http_session = aiohttp.ClientSession(**kwargs) - return self.__http_session - - def __make_url(self, handle: str) -> str: - assert not handle.startswith("/"), handle - return f"http://localhost:0/{handle}" - - # ===== - - @aiotools.atomic_fg - async def __inner_start(self) -> None: - assert not self.__streamer_task - self.__streamer_task = asyncio.create_task(self.__streamer_task_loop()) - - @aiotools.atomic_fg - async def __inner_stop(self) -> None: - assert self.__streamer_task - self.__streamer_task.cancel() - await asyncio.gather(self.__streamer_task, return_exceptions=True) - await self.__kill_streamer_proc() - self.__streamer_task = None - - # ===== - - async def __streamer_task_loop(self) -> None: # pylint: disable=too-many-branches - logger = get_logger(0) - while True: # pylint: disable=too-many-nested-blocks - try: - await self.__start_streamer_proc() - assert self.__streamer_proc is not None - await aioproc.log_stdout_infinite(self.__streamer_proc, logger) - raise RuntimeError("Streamer unexpectedly died") - except asyncio.CancelledError: - break - except Exception: - if self.__streamer_proc: - logger.exception("Unexpected streamer error: pid=%d", self.__streamer_proc.pid) - else: - logger.exception("Can't start streamer") - await self.__kill_streamer_proc() - await asyncio.sleep(1) - - async def __start_streamer_proc(self) -> None: - assert self.__streamer_proc is None - cmd = [ - part.format( - unix=self.__unix_path, - process_name_prefix=self.__process_name_prefix, - **self.__params.get_params(), - ) - for part in self.__cmd - ] - self.__streamer_proc = await aioproc.run_process(cmd) - get_logger(0).info("Started streamer pid=%d: %s", self.__streamer_proc.pid, tools.cmdfmt(cmd)) - - async def __kill_streamer_proc(self) -> None: - if self.__streamer_proc: - await aioproc.kill_process(self.__streamer_proc, 1, get_logger(0)) - self.__streamer_proc = None diff --git a/kvmd/apps/kvmd/streamer/__init__.py b/kvmd/apps/kvmd/streamer/__init__.py new file mode 100644 index 000000000..3c4472c1f --- /dev/null +++ b/kvmd/apps/kvmd/streamer/__init__.py @@ -0,0 +1,262 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import signal +import asyncio +import dataclasses +import copy + +from typing import AsyncGenerator +from typing import Any + +import aiohttp + +from ....logging import get_logger + +from ....clients.streamer import StreamerSnapshot +from ....clients.streamer import HttpStreamerClient +from ....clients.streamer import HttpStreamerClientSession + +from .... import tools +from .... import aiotools +from .... import htclient + +from .params import Params +from .runner import Runner + + +# ===== +class Streamer: # pylint: disable=too-many-instance-attributes + __ST_FULL = 0xFF + __ST_PARAMS = 0x01 + __ST_STREAMER = 0x02 + __ST_SNAPSHOT = 0x04 + + def __init__( # pylint: disable=too-many-arguments,too-many-locals + self, + + reset_delay: float, + shutdown_delay: float, + state_poll: float, + + unix_path: str, + timeout: float, + snapshot_timeout: float, + + process_name_prefix: str, + + pre_start_cmd: list[str], + pre_start_cmd_remove: list[str], + pre_start_cmd_append: list[str], + + cmd: list[str], + cmd_remove: list[str], + cmd_append: list[str], + + post_stop_cmd: list[str], + post_stop_cmd_remove: list[str], + post_stop_cmd_append: list[str], + + **params_kwargs: Any, + ) -> None: + + self.__state_poll = state_poll + + self.__unix_path = unix_path + self.__snapshot_timeout = snapshot_timeout + self.__process_name_prefix = process_name_prefix + + self.__params = Params(**params_kwargs) + + self.__runner = Runner( + reset_delay=reset_delay, + shutdown_delay=shutdown_delay, + pre_start_cmd=tools.build_cmd(pre_start_cmd, pre_start_cmd_remove, pre_start_cmd_append), + cmd=tools.build_cmd(cmd, cmd_remove, cmd_append), + post_stop_cmd=tools.build_cmd(post_stop_cmd, post_stop_cmd_remove, post_stop_cmd_append), + ) + + self.__client = HttpStreamerClient( + name="jpeg", + unix_path=self.__unix_path, + timeout=timeout, + user_agent=htclient.make_user_agent("KVMD"), + ) + self.__client_session: (HttpStreamerClientSession | None) = None + + self.__snapshot: (StreamerSnapshot | None) = None + + self.__notifier = aiotools.AioNotifier() + + # ===== + + @aiotools.atomic_fg + async def ensure_start(self) -> None: + await self.__runner.ensure_start(self.__make_params()) + + @aiotools.atomic_fg + async def ensure_restart(self) -> None: + await self.__runner.ensure_restart(self.__make_params()) + + def __make_params(self) -> dict: + return { + "unix": self.__unix_path, + "process_name_prefix": self.__process_name_prefix, + **self.__params.get_params(), + } + + @aiotools.atomic_fg + async def ensure_stop(self) -> None: + await self.__runner.ensure_stop(immediately=False) + + # ===== + + def set_params(self, params: dict) -> None: + self.__notifier.notify(self.__ST_PARAMS) + return self.__params.set_params(params) + + def get_params(self) -> dict: + return self.__params.get_params() + + # ===== + + async def get_state(self) -> dict: + ss = await self.__get_streamer_state() + return { + "features": self.__params.get_features(), + "limits": self.__params.get_limits(), + "params": self.__params.get_params(), + "applied": self.__params.get_applied(ss), + "streamer": ss, + "snapshot": self.__get_snapshot_state(), + } + + async def trigger_state(self) -> None: + self.__notifier.notify(self.__ST_FULL) + + async def poll_state(self) -> AsyncGenerator[dict, None]: + # ==== Granularity table ==== + # - features -- Full + # - limits -- Partial + # - params -- Partial, paired with limits + # - applied -- Partial, paired with limits + # - streamer -- Partial, nullable + # - snapshot -- Partial + # =========================== + + def signal_handler(*_: Any) -> None: + get_logger(0).info("Got SIGUSR2, checking the stream state ...") + self.__notifier.notify(self.__ST_STREAMER) + + get_logger(0).info("Installing SIGUSR2 streamer handler ...") + asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler) + + prev: dict = {} + while True: + new: dict = {} + + mask = await self.__notifier.wait(timeout=self.__state_poll) + if mask == self.__ST_FULL: + new = await self.get_state() + prev = copy.deepcopy(new) + yield new + continue + + if mask < 0: + mask = self.__ST_STREAMER + + def check_update(key: str, value: (dict | None)) -> None: + if prev.get(key) != value: + new[key] = value + + if mask & self.__ST_PARAMS: + check_update("params", self.__params.get_params()) + if mask & self.__ST_STREAMER: + ss = await self.__get_streamer_state() + check_update("streamer", ss) + check_update("applied", self.__params.get_applied(ss)) + if mask & self.__ST_SNAPSHOT: + check_update("snapshot", self.__get_snapshot_state()) + + if "params" in new or "applied" in new: + new["limits"] = self.__params.get_limits() + + if new and prev != new: + prev.update(copy.deepcopy(new)) + yield new + + async def __get_streamer_state(self) -> (dict | None): + if self.__runner.is_running(): + session = self.__ensure_client_session() + try: + return (await session.get_state()) + except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError): + pass + except Exception: + get_logger().exception("Invalid streamer response from /state") + return None + + def __get_snapshot_state(self) -> dict: + if self.__snapshot: + snapshot = dataclasses.asdict(self.__snapshot) + del snapshot["headers"] + del snapshot["data"] + return {"saved": snapshot} + return {"saved": None} + + # ===== + + async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None): + if load: + return self.__snapshot + logger = get_logger() + session = self.__ensure_client_session() + try: + snapshot = await session.take_snapshot(self.__snapshot_timeout) + if snapshot.online or allow_offline: + if save: + self.__snapshot = snapshot + self.__notifier.notify(self.__ST_SNAPSHOT) + return snapshot + logger.error("Stream is offline, no signal or so") + except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as ex: + logger.error("Can't connect to streamer: %s", tools.efmt(ex)) + except Exception: + logger.exception("Invalid streamer response from /snapshot") + return None + + def remove_snapshot(self) -> None: + self.__snapshot = None + + # ===== + + @aiotools.atomic_fg + async def cleanup(self) -> None: + await self.__runner.ensure_stop(immediately=True) + if self.__client_session: + await self.__client_session.close() + self.__client_session = None + + def __ensure_client_session(self) -> HttpStreamerClientSession: + if not self.__client_session: + self.__client_session = self.__client.make_session() + return self.__client_session diff --git a/kvmd/apps/kvmd/streamer/params.py b/kvmd/apps/kvmd/streamer/params.py new file mode 100644 index 000000000..0a20534bc --- /dev/null +++ b/kvmd/apps/kvmd/streamer/params.py @@ -0,0 +1,135 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import copy + +from ....logging import get_logger + + +# ===== +class Params: + __DESIRED_FPS = "desired_fps" + + __QUALITY = "quality" + + __RESOLUTION = "resolution" + __AVAILABLE_RESOLUTIONS = "available_resolutions" + + __H264 = "h264" + __H264_BITRATE = "h264_bitrate" + __H264_GOP = "h264_gop" + + def __init__( # pylint: disable=too-many-arguments + self, + quality: int, + + resolution: str, + available_resolutions: list[str], + + desired_fps: int, + desired_fps_min: int, + desired_fps_max: int, + + h264_bitrate: int, + h264_bitrate_min: int, + h264_bitrate_max: int, + + h264_gop: int, + h264_gop_min: int, + h264_gop_max: int, + ) -> None: + + self.__has_quality = bool(quality) + self.__has_resolution = bool(resolution) + self.__has_h264 = bool(h264_bitrate) + + self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)} + self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}} + + if self.__has_quality: + self.__params[self.__QUALITY] = quality + + if self.__has_resolution: + self.__params[self.__RESOLUTION] = resolution + self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions + + if self.__has_h264: + self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max) + self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max} + self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max) + self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max} + + def get_applied(self, ss: (dict | None)) -> dict: + applied = self.get_params() + try: + if ss: + applied[self.__DESIRED_FPS] = ss["source"]["desired_fps"] + if self.__has_resolution: + applied[self.__RESOLUTION] = "{width}x{height}".format(**ss["source"]["resolution"]) + if self.__has_quality: + applied[self.__QUALITY] = ss["encoder"]["quality"] + if self.__has_h264 and "h264" in ss: + applied[self.__H264_BITRATE] = ss["h264"]["bitrate"] + applied[self.__H264_GOP] = ss["h264"]["gop"] + except Exception: + get_logger().exception("Invalid streamer response: %s", ss) + return applied + + def get_features(self) -> dict: + return { + self.__QUALITY: self.__has_quality, + self.__RESOLUTION: self.__has_resolution, + self.__H264: self.__has_h264, + } + + def get_limits(self) -> dict: + limits = copy.deepcopy(self.__limits) + if self.__has_resolution: + limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS]) + return limits + + def get_params(self) -> dict: + return dict(self.__params) + + def set_params(self, params: dict) -> None: + new = dict(self.__params) + + if self.__QUALITY in params and self.__has_quality: + new[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100) + + if self.__RESOLUTION in params and self.__has_resolution: + if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]: + new[self.__RESOLUTION] = params[self.__RESOLUTION] + + for (key, enabled) in [ + (self.__DESIRED_FPS, True), + (self.__H264_BITRATE, self.__has_h264), + (self.__H264_GOP, self.__has_h264), + ]: + if key in params and enabled: + if self.__check_limits_min_max(key, params[key]): + new[key] = params[key] + + self.__params = new + + def __check_limits_min_max(self, key: str, value: int) -> bool: + return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"]) diff --git a/kvmd/apps/kvmd/streamer/runner.py b/kvmd/apps/kvmd/streamer/runner.py new file mode 100644 index 000000000..fef5a5e18 --- /dev/null +++ b/kvmd/apps/kvmd/streamer/runner.py @@ -0,0 +1,182 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import asyncio.subprocess + +from ....logging import get_logger + +from .... import tools +from .... import aiotools +from .... import aioproc + + +# ===== +class Runner: # pylint: disable=too-many-instance-attributes + def __init__( + self, + reset_delay: float, + shutdown_delay: float, + + pre_start_cmd: list[str], + cmd: list[str], + post_stop_cmd: list[str], + ) -> None: + + self.__reset_delay = reset_delay + self.__shutdown_delay = shutdown_delay + + self.__pre_start_cmd: list[str] = pre_start_cmd + self.__cmd: list[str] = cmd + self.__post_stop_cmd: list[str] = post_stop_cmd + + self.__proc_params: dict = {} + self.__proc_task: (asyncio.Task | None) = None + self.__proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member + + self.__stopper_task: (asyncio.Task | None) = None + self.__stopper_wip = False + + @aiotools.atomic_fg + async def ensure_start(self, params: dict) -> None: + if not self.__proc_task or self.__stopper_task: + logger = get_logger(0) + + if self.__stopper_task: + if not self.__stopper_wip: + self.__stopper_task.cancel() + await asyncio.gather(self.__stopper_task, return_exceptions=True) + logger.info("Streamer stop cancelled") + return + else: + await asyncio.gather(self.__stopper_task, return_exceptions=True) + + logger.info("Starting streamer ...") + await self.__inner_start(params) + + @aiotools.atomic_fg + async def ensure_restart(self, params: dict) -> None: + logger = get_logger(0) + start = bool(self.__proc_task and not self.__stopper_task) # Если запущено и не планирует останавливаться + await self.ensure_stop(immediately=True) + if self.__reset_delay > 0: + logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay) + await asyncio.sleep(self.__reset_delay) + if start: + await self.ensure_start(params) + + @aiotools.atomic_fg + async def ensure_stop(self, immediately: bool) -> None: + if self.__proc_task: + logger = get_logger(0) + + if immediately: + if self.__stopper_task: + if not self.__stopper_wip: + self.__stopper_task.cancel() + await asyncio.gather(self.__stopper_task, return_exceptions=True) + logger.info("Stopping streamer immediately ...") + await self.__inner_stop() + else: + await asyncio.gather(self.__stopper_task, return_exceptions=True) + else: + logger.info("Stopping streamer immediately ...") + await self.__inner_stop() + + elif not self.__stopper_task: + + async def delayed_stop() -> None: + try: + await asyncio.sleep(self.__shutdown_delay) + self.__stopper_wip = True + logger.info("Stopping streamer after delay ...") + await self.__inner_stop() + finally: + self.__stopper_task = None + self.__stopper_wip = False + + logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay) + self.__stopper_task = asyncio.create_task(delayed_stop()) + + def is_running(self) -> bool: + return bool(self.__proc_task) + + # ===== + + @aiotools.atomic_fg + async def __inner_start(self, params: dict) -> None: + assert not self.__proc_task + self.__proc_params = params + await self.__run_hook("PRE-START-CMD", self.__pre_start_cmd) + self.__proc_task = asyncio.create_task(self.__process_task_loop()) + + @aiotools.atomic_fg + async def __inner_stop(self) -> None: + assert self.__proc_task + self.__proc_task.cancel() + await asyncio.gather(self.__proc_task, return_exceptions=True) + await self.__kill_process() + await self.__run_hook("POST-STOP-CMD", self.__post_stop_cmd) + self.__proc_task = None + + # ===== + + async def __process_task_loop(self) -> None: # pylint: disable=too-many-branches + logger = get_logger(0) + while True: # pylint: disable=too-many-nested-blocks + try: + await self.__start_process() + assert self.__proc is not None + await aioproc.log_stdout_infinite(self.__proc, logger) + raise RuntimeError("Streamer unexpectedly died") + except asyncio.CancelledError: + break + except Exception: + if self.__proc: + logger.exception("Unexpected streamer error: pid=%d", self.__proc.pid) + else: + logger.exception("Can't start streamer") + await self.__kill_process() + await asyncio.sleep(1) + + def __make_cmd(self, cmd: list[str]) -> list[str]: + return [part.format(**self.__proc_params) for part in cmd] + + async def __run_hook(self, name: str, cmd: list[str]) -> None: + logger = get_logger() + cmd = self.__make_cmd(cmd) + logger.info("%s: %s", name, tools.cmdfmt(cmd)) + try: + await aioproc.log_process(cmd, logger, prefix=name) + except Exception: + logger.exception("Can't execute %s hook: %s", name, tools.cmdfmt(cmd)) + + async def __start_process(self) -> None: + assert self.__proc is None + cmd = self.__make_cmd(self.__cmd) + self.__proc = await aioproc.run_process(cmd) + get_logger(0).info("Started streamer pid=%d: %s", self.__proc.pid, tools.cmdfmt(cmd)) + + async def __kill_process(self) -> None: + if self.__proc: + await aioproc.kill_process(self.__proc, 1, get_logger(0)) + self.__proc = None diff --git a/kvmd/apps/kvmd/switch/__init__.py b/kvmd/apps/kvmd/switch/__init__.py new file mode 100644 index 000000000..ff50e1477 --- /dev/null +++ b/kvmd/apps/kvmd/switch/__init__.py @@ -0,0 +1,413 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os +import asyncio + +from typing import AsyncGenerator + +from .lib import OperationError +from .lib import get_logger +from .lib import aiotools +from .lib import Inotify + +from .types import Edid +from .types import Edids +from .types import Dummies +from .types import Color +from .types import Colors +from .types import PortNames +from .types import AtxClickPowerDelays +from .types import AtxClickPowerLongDelays +from .types import AtxClickResetDelays + +from .chain import DeviceFoundEvent +from .chain import ChainTruncatedEvent +from .chain import PortActivatedEvent +from .chain import UnitStateEvent +from .chain import UnitAtxLedsEvent +from .chain import Chain + +from .state import StateCache + +from .storage import Storage + + +# ===== +class SwitchError(Exception): + pass + + +class SwitchOperationError(OperationError, SwitchError): + pass + + +class SwitchUnknownEdidError(SwitchOperationError): + def __init__(self) -> None: + super().__init__("No specified EDID ID found") + + +# ===== +class Switch: # pylint: disable=too-many-public-methods + __X_EDIDS = "edids" + __X_DUMMIES = "dummies" + __X_COLORS = "colors" + __X_PORT_NAMES = "port_names" + __X_ATX_CP_DELAYS = "atx_cp_delays" + __X_ATX_CPL_DELAYS = "atx_cpl_delays" + __X_ATX_CR_DELAYS = "atx_cr_delays" + + __X_ALL = frozenset([ + __X_EDIDS, __X_DUMMIES, __X_COLORS, __X_PORT_NAMES, + __X_ATX_CP_DELAYS, __X_ATX_CPL_DELAYS, __X_ATX_CR_DELAYS, + ]) + + def __init__( + self, + device_path: str, + default_edid_path: str, + pst_unix_path: str, + ignore_hpd_on_top: bool, + ) -> None: + + self.__default_edid_path = default_edid_path + + self.__chain = Chain(device_path, ignore_hpd_on_top) + self.__cache = StateCache() + self.__storage = Storage(pst_unix_path) + + self.__lock = asyncio.Lock() + + self.__save_notifier = aiotools.AioNotifier() + + # ===== + + def __x_set_edids(self, edids: Edids, save: bool=True) -> None: + self.__chain.set_edids(edids) + self.__cache.set_edids(edids) + if save: + self.__save_notifier.notify() + + def __x_set_dummies(self, dummies: Dummies, save: bool=True) -> None: + self.__chain.set_dummies(dummies) + self.__cache.set_dummies(dummies) + if save: + self.__save_notifier.notify() + + def __x_set_colors(self, colors: Colors, save: bool=True) -> None: + self.__chain.set_colors(colors) + self.__cache.set_colors(colors) + if save: + self.__save_notifier.notify() + + def __x_set_port_names(self, port_names: PortNames, save: bool=True) -> None: + self.__cache.set_port_names(port_names) + if save: + self.__save_notifier.notify() + + def __x_set_atx_cp_delays(self, delays: AtxClickPowerDelays, save: bool=True) -> None: + self.__cache.set_atx_cp_delays(delays) + if save: + self.__save_notifier.notify() + + def __x_set_atx_cpl_delays(self, delays: AtxClickPowerLongDelays, save: bool=True) -> None: + self.__cache.set_atx_cpl_delays(delays) + if save: + self.__save_notifier.notify() + + def __x_set_atx_cr_delays(self, delays: AtxClickResetDelays, save: bool=True) -> None: + self.__cache.set_atx_cr_delays(delays) + if save: + self.__save_notifier.notify() + + # ===== + + async def set_active_prev(self) -> None: + self.__chain.set_active_prev() + + async def set_active_next(self) -> None: + self.__chain.set_active_next() + + async def set_active_port(self, port: float) -> None: + self.__chain.set_active_port(self.__chain.translate_port(port)) + + # ===== + + async def set_port_beacon(self, port: float, on: bool) -> None: + self.__chain.set_port_beacon(self.__chain.translate_port(port), on) + + async def set_uplink_beacon(self, unit: int, on: bool) -> None: + self.__chain.set_uplink_beacon(unit, on) + + async def set_downlink_beacon(self, unit: int, on: bool) -> None: + self.__chain.set_downlink_beacon(unit, on) + + # ===== + + async def atx_power_on(self, port: float) -> None: + self.__inner_atx_cp(port, False, self.__X_ATX_CP_DELAYS) + + async def atx_power_off(self, port: float) -> None: + self.__inner_atx_cp(port, True, self.__X_ATX_CP_DELAYS) + + async def atx_power_off_hard(self, port: float) -> None: + self.__inner_atx_cp(port, True, self.__X_ATX_CPL_DELAYS) + + async def atx_power_reset_hard(self, port: float) -> None: + self.__inner_atx_cr(port, True) + + async def atx_click_power(self, port: float) -> None: + self.__inner_atx_cp(port, None, self.__X_ATX_CP_DELAYS) + + async def atx_click_power_long(self, port: float) -> None: + self.__inner_atx_cp(port, None, self.__X_ATX_CPL_DELAYS) + + async def atx_click_reset(self, port: float) -> None: + self.__inner_atx_cr(port, None) + + def __inner_atx_cp(self, port: float, if_powered: (bool | None), x_delay: str) -> None: + assert x_delay in [self.__X_ATX_CP_DELAYS, self.__X_ATX_CPL_DELAYS] + port = self.__chain.translate_port(port) + delay = getattr(self.__cache, f"get_{x_delay}")()[port] + self.__chain.click_power(port, delay, if_powered) + + def __inner_atx_cr(self, port: float, if_powered: (bool | None)) -> None: + port = self.__chain.translate_port(port) + delay = self.__cache.get_atx_cr_delays()[port] + self.__chain.click_reset(port, delay, if_powered) + + # ===== + + async def create_edid(self, name: str, data_hex: str) -> str: + async with self.__lock: + edids = self.__cache.get_edids() + edid_id = edids.add(Edid.from_data(name, data_hex)) + self.__x_set_edids(edids) + return edid_id + + async def change_edid( + self, + edid_id: str, + name: (str | None)=None, + data_hex: (str | None)=None, + ) -> None: + + assert edid_id != Edids.DEFAULT_ID + async with self.__lock: + edids = self.__cache.get_edids() + if not edids.has_id(edid_id): + raise SwitchUnknownEdidError() + old = edids.get(edid_id) + name = (name or old.name) + data_hex = (data_hex or old.as_text()) + edids.set(edid_id, Edid.from_data(name, data_hex)) + self.__x_set_edids(edids) + + async def remove_edid(self, edid_id: str) -> None: + assert edid_id != Edids.DEFAULT_ID + async with self.__lock: + edids = self.__cache.get_edids() + if not edids.has_id(edid_id): + raise SwitchUnknownEdidError() + edids.remove(edid_id) + self.__x_set_edids(edids) + + # ===== + + async def set_colors(self, **values: str) -> None: + async with self.__lock: + old = self.__cache.get_colors() + new = {} + for role in Colors.ROLES: + if role in values: + if values[role] != "default": + new[role] = Color.from_text(values[role]) + # else reset to default + else: + new[role] = getattr(old, role) + self.__x_set_colors(Colors(**new)) # type: ignore + + # ===== + + async def set_port_params( + self, + port: int, + edid_id: (str | None)=None, + dummy: (bool | None)=None, + name: (str | None)=None, + atx_click_power_delay: (float | None)=None, + atx_click_power_long_delay: (float | None)=None, + atx_click_reset_delay: (float | None)=None, + ) -> None: + + port = self.__chain.translate_port(port) + async with self.__lock: + if edid_id is not None: + edids = self.__cache.get_edids() + if not edids.has_id(edid_id): + raise SwitchUnknownEdidError() + edids.assign(port, edid_id) + self.__x_set_edids(edids) + + for (reset, key, value) in [ + (None, self.__X_DUMMIES, dummy), # None can't be used now + ("", self.__X_PORT_NAMES, name), + (0, self.__X_ATX_CP_DELAYS, atx_click_power_delay), + (0, self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay), + (0, self.__X_ATX_CR_DELAYS, atx_click_reset_delay), + ]: + if value is not None: + new = getattr(self.__cache, f"get_{key}")() + new[port] = (None if value == reset else value) # Value or reset default + getattr(self, f"_Switch__x_set_{key}")(new) + + # ===== + + async def reboot_unit(self, unit: int, bootloader: bool) -> None: + self.__chain.reboot_unit(unit, bootloader) + + # ===== + + async def get_state(self) -> dict: + return self.__cache.get_state() + + async def trigger_state(self) -> None: + await self.__cache.trigger_state() + + async def poll_state(self) -> AsyncGenerator[dict, None]: + async for state in self.__cache.poll_state(): + yield state + + # ===== + + async def systask(self) -> None: + await aiotools.spawn_and_follow( + self.__systask_events(), + self.__systask_default_edid(), + self.__systask_storage(), + ) + + async def __systask_events(self) -> None: + async for event in self.__chain.poll_events(): + match event: + case DeviceFoundEvent(): + await self.__load_configs() + case ChainTruncatedEvent(): + self.__cache.truncate(event.units) + case PortActivatedEvent(): + self.__cache.update_active_port(event.port) + case UnitStateEvent(): + self.__cache.update_unit_state(event.unit, event.state) + case UnitAtxLedsEvent(): + self.__cache.update_unit_atx_leds(event.unit, event.atx_leds) + + async def __load_configs(self) -> None: + async with self.__lock: + try: + async with self.__storage.readable() as ctx: + values = { + key: await getattr(ctx, f"read_{key}")() + for key in self.__X_ALL + } + data_hex = await aiotools.read_file(self.__default_edid_path) + values["edids"].set_default(data_hex) + except Exception: + get_logger(0).exception("Can't load configs") + else: + for (key, value) in values.items(): + func = getattr(self, f"_Switch__x_set_{key}") + if isinstance(value, tuple): + func(*value, save=False) + else: + func(value, save=False) + self.__chain.set_actual(True) + + async def __systask_default_edid(self) -> None: + logger = get_logger(0) + async for _ in self.__poll_default_edid(): + async with self.__lock: + edids = self.__cache.get_edids() + try: + data_hex = await aiotools.read_file(self.__default_edid_path) + edids.set_default(data_hex) + except Exception: + logger.exception("Can't read default EDID, ignoring ...") + else: + self.__x_set_edids(edids, save=False) + + async def __poll_default_edid(self) -> AsyncGenerator[None, None]: + logger = get_logger(0) + while True: + while not os.path.exists(self.__default_edid_path): + await asyncio.sleep(5) + try: + with Inotify() as inotify: + await inotify.watch_all_changes(self.__default_edid_path) + if os.path.islink(self.__default_edid_path): + await inotify.watch_all_changes(os.path.realpath(self.__default_edid_path)) + yield None + 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 ...", event) + need_restart = True + break + if need_restart: + break + if need_notify: + yield None + except Exception: + logger.exception("Unexpected watcher error") + await asyncio.sleep(1) + + async def __systask_storage(self) -> None: + # При остановке KVMD можем не успеть записать, ну да пофиг + prevs = dict.fromkeys(self.__X_ALL) + while True: + await self.__save_notifier.wait() + while not (await self.__save_notifier.wait(5)): + pass + while True: + try: + async with self.__lock: + write = { + key: new + for (key, old) in prevs.items() + if (new := getattr(self.__cache, f"get_{key}")()) != old + } + if write: + async with self.__storage.writable() as ctx: + for (key, new) in write.items(): + func = getattr(ctx, f"write_{key}") + if isinstance(new, tuple): + await func(*new) + else: + await func(new) + prevs[key] = new + except Exception: + get_logger(0).exception("Unexpected storage error") + await asyncio.sleep(5) + else: + break diff --git a/kvmd/apps/kvmd/switch/chain.py b/kvmd/apps/kvmd/switch/chain.py new file mode 100644 index 000000000..113b25f0a --- /dev/null +++ b/kvmd/apps/kvmd/switch/chain.py @@ -0,0 +1,530 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import multiprocessing +import select +import dataclasses +import time + +from typing import AsyncGenerator + +from .lib import get_logger +from .lib import tools +from .lib import aiomulti + +from .types import Edids +from .types import Dummies +from .types import Colors + +from .proto import Response +from .proto import UnitState +from .proto import UnitAtxLeds + +from .device import Device +from .device import DeviceError + + +# ===== +class _BaseCmd: + pass + + +@dataclasses.dataclass(frozen=True) +class _CmdSetActual(_BaseCmd): + actual: bool + + +class _CmdSetActivePrev(_BaseCmd): + pass + + +class _CmdSetActiveNext(_BaseCmd): + pass + + +@dataclasses.dataclass(frozen=True) +class _CmdSetActivePort(_BaseCmd): + port: int + + def __post_init__(self) -> None: + assert self.port >= 0 + + +@dataclasses.dataclass(frozen=True) +class _CmdSetPortBeacon(_BaseCmd): + port: int + on: bool + + +@dataclasses.dataclass(frozen=True) +class _CmdSetUnitBeacon(_BaseCmd): + unit: int + on: bool + downlink: bool + + +@dataclasses.dataclass(frozen=True) +class _CmdSetEdids(_BaseCmd): + edids: Edids + + +@dataclasses.dataclass(frozen=True) +class _CmdSetDummies(_BaseCmd): + dummies: Dummies + + +@dataclasses.dataclass(frozen=True) +class _CmdSetColors(_BaseCmd): + colors: Colors + + +@dataclasses.dataclass(frozen=True) +class _CmdAtxClick(_BaseCmd): + port: int + delay: float + reset: bool + if_powered: (bool | None) + + def __post_init__(self) -> None: + assert self.port >= 0 + assert 0.001 <= self.delay <= (0xFFFF / 1000) + + +@dataclasses.dataclass(frozen=True) +class _CmdRebootUnit(_BaseCmd): + unit: int + bootloader: bool + + def __post_init__(self) -> None: + assert self.unit >= 0 + + +class _UnitContext: + __TIMEOUT = 5.0 + + def __init__(self) -> None: + self.state: (UnitState | None) = None + self.atx_leds: (UnitAtxLeds | None) = None + self.__rid = -1 + self.__deadline_ts = -1.0 + + def can_be_changed(self) -> bool: + return ( + self.state is not None + and not self.state.flags.changing_busy + and self.changing_rid < 0 + ) + + # ===== + + @property + def changing_rid(self) -> int: + if self.__deadline_ts >= 0 and self.__deadline_ts < time.monotonic(): + self.__rid = -1 + self.__deadline_ts = -1 + return self.__rid + + @changing_rid.setter + def changing_rid(self, rid: int) -> None: + self.__rid = rid + self.__deadline_ts = ((time.monotonic() + self.__TIMEOUT) if rid >= 0 else -1) + + # ===== + + def is_atx_allowed(self, ch: int) -> tuple[bool, bool]: # (allowed, power_led) + if self.state is None or self.atx_leds is None: + return (False, False) + return ((not self.state.atx_busy[ch]), self.atx_leds.power[ch]) + + +# ===== +class BaseEvent: + pass + + +class DeviceFoundEvent(BaseEvent): + pass + + +@dataclasses.dataclass(frozen=True) +class ChainTruncatedEvent(BaseEvent): + units: int + + +@dataclasses.dataclass(frozen=True) +class PortActivatedEvent(BaseEvent): + port: int + + +@dataclasses.dataclass(frozen=True) +class UnitStateEvent(BaseEvent): + unit: int + state: UnitState + + +@dataclasses.dataclass(frozen=True) +class UnitAtxLedsEvent(BaseEvent): + unit: int + atx_leds: UnitAtxLeds + + +# ===== +class Chain: # pylint: disable=too-many-instance-attributes + def __init__( + self, + device_path: str, + ignore_hpd_on_top: bool, + ) -> None: + + self.__device = Device(device_path) + self.__ignore_hpd_on_top = ignore_hpd_on_top + + self.__actual = False + + self.__edids = Edids() + self.__dummies = Dummies({}) + self.__colors = Colors() + + self.__units: list[_UnitContext] = [] + self.__active_port = -1 + + self.__cmd_q: aiomulti.AioMpQueue[_BaseCmd] = aiomulti.AioMpQueue() + self.__events_q: aiomulti.AioMpQueue[BaseEvent] = aiomulti.AioMpQueue() + + self.__stop_event = multiprocessing.Event() + + def set_actual(self, actual: bool) -> None: + # Флаг разрешения синхронизации EDID и прочих чувствительных вещей + self.__queue_cmd(_CmdSetActual(actual)) + + # ===== + + def translate_port(self, port: float) -> int: + assert port >= 0 + if int(port) == port: + return int(port) + (unit, ch) = map(int, str(port).split(".")) + unit = min(max(unit, 1), 5) + ch = min(max(ch, 1), 4) + port = min((unit - 1) * 4 + (ch - 1), 19) + return port + + # ===== + + def set_active_prev(self) -> None: + self.__queue_cmd(_CmdSetActivePrev()) + + def set_active_next(self) -> None: + self.__queue_cmd(_CmdSetActiveNext()) + + def set_active_port(self, port: int) -> None: + self.__queue_cmd(_CmdSetActivePort(port)) + + # ===== + + def set_port_beacon(self, port: int, on: bool) -> None: + self.__queue_cmd(_CmdSetPortBeacon(port, on)) + + def set_uplink_beacon(self, unit: int, on: bool) -> None: + self.__queue_cmd(_CmdSetUnitBeacon(unit, on, downlink=False)) + + def set_downlink_beacon(self, unit: int, on: bool) -> None: + self.__queue_cmd(_CmdSetUnitBeacon(unit, on, downlink=True)) + + # ===== + + def set_edids(self, edids: Edids) -> None: + self.__queue_cmd(_CmdSetEdids(edids)) # Will be copied because of multiprocessing.Queue() + + def set_dummies(self, dummies: Dummies) -> None: + self.__queue_cmd(_CmdSetDummies(dummies)) + + def set_colors(self, colors: Colors) -> None: + self.__queue_cmd(_CmdSetColors(colors)) + + # ===== + + def click_power(self, port: int, delay: float, if_powered: (bool | None)) -> None: + self.__queue_cmd(_CmdAtxClick(port, delay, reset=False, if_powered=if_powered)) + + def click_reset(self, port: int, delay: float, if_powered: (bool | None)) -> None: + self.__queue_cmd(_CmdAtxClick(port, delay, reset=True, if_powered=if_powered)) + + # ===== + + def reboot_unit(self, unit: int, bootloader: bool) -> None: + self.__queue_cmd(_CmdRebootUnit(unit, bootloader)) + + # ===== + + async def poll_events(self) -> AsyncGenerator[BaseEvent, None]: + proc = aiomulti.AioMpProcess("switch", self.__subprocess) + proc.start() + try: + while True: + (_, event) = await self.__events_q.async_fetch() + assert event is not None + yield event + finally: + self.__stop_event.set() + await proc.async_join() + + # ===== + + def __queue_cmd(self, cmd: _BaseCmd) -> None: + if not self.__stop_event.is_set(): + self.__cmd_q.put_nowait(cmd) + + def __queue_event(self, event: BaseEvent) -> None: + if not self.__stop_event.is_set(): + self.__events_q.put_nowait(event) + + def __subprocess(self) -> None: + logger = get_logger(0) + no_device_reported = False + while True: + try: + if self.__device.has_device(): + no_device_reported = False + with self.__device: + logger.info("Switch found") + self.__queue_event(DeviceFoundEvent()) + self.__main_loop() + elif not no_device_reported: + self.__queue_event(ChainTruncatedEvent(0)) + logger.info("Switch is missing") + no_device_reported = True + except DeviceError as ex: + logger.error("%s", tools.efmt(ex)) + except Exception: + logger.exception("Unexpected error in the Switch loop") + self.__cmd_q.clear_current() + if self.__stop_event.is_set(): + break + time.sleep(1) + + def __main_loop(self) -> None: + self.__device.request_state() + self.__device.request_atx_leds() + while not self.__stop_event.is_set(): + count = 0 + if self.__select(): + count = 0 + for resp in self.__device.read_all(): + self.__update_units(resp) + self.__adjust_quirks() + self.__adjust_start_port() + self.__finish_changing_request(resp) + self.__consume_commands() + else: + count += 1 + if count >= 5: + # Heartbeat + self.__device.request_state() + count = 0 + self.__ensure_config() + + def __select(self) -> bool: + try: + return bool(select.select([ + self.__device.get_fd(), + self.__cmd_q.get_reader_fd(), + ], [], [], 1)[0]) + except Exception as ex: + raise DeviceError(ex) + + def __consume_commands(self) -> None: # pylint: disable=too-many-branches + while not self.__cmd_q.empty(): + cmd = self.__cmd_q.get() + match cmd: + case _CmdSetActual(): + self.__actual = cmd.actual + + case _CmdSetActivePrev(): + if len(self.__units) > 0: + port = self.__active_port + port -= 1 + if port >= 0: + self.__active_port = port + self.__queue_event(PortActivatedEvent(self.__active_port)) + + case _CmdSetActiveNext(): + port = self.__active_port + if port < 0: + port = 0 + else: + port += 1 + if port < len(self.__units) * 4: + self.__active_port = port + self.__queue_event(PortActivatedEvent(self.__active_port)) + + case _CmdSetActivePort(): + # Может быть вызвано изнутри при синхронизации + if cmd.port < len(self.__units) * 4: + self.__active_port = cmd.port + self.__queue_event(PortActivatedEvent(self.__active_port)) + + case _CmdSetPortBeacon(): + (unit, ch) = self.get_real_unit_channel(cmd.port) + self.__device.request_beacon(unit, ch, cmd.on) + + case _CmdSetUnitBeacon(): + ch = (4 if cmd.downlink else 5) + self.__device.request_beacon(cmd.unit, ch, cmd.on) + + case _CmdAtxClick(): + (unit, ch) = self.get_real_unit_channel(cmd.port) + if unit < len(self.__units): + (allowed, powered) = self.__units[unit].is_atx_allowed(ch) + if allowed and (cmd.if_powered is None or cmd.if_powered == powered): + delay_ms = min(int(cmd.delay * 1000), 0xFFFF) + if cmd.reset: + self.__device.request_atx_cr(unit, ch, delay_ms) + else: + self.__device.request_atx_cp(unit, ch, delay_ms) + + case _CmdSetEdids(): + self.__edids = cmd.edids + + case _CmdSetDummies(): + self.__dummies = cmd.dummies + + case _CmdSetColors(): + self.__colors = cmd.colors + + case _CmdRebootUnit(): + self.__device.request_reboot(cmd.unit, cmd.bootloader) + + def __update_units(self, resp: Response) -> None: + units = resp.header.unit + 1 + while len(self.__units) < units: + self.__units.append(_UnitContext()) + + match resp.body: + case UnitState(): + if not resp.body.flags.has_downlink and len(self.__units) > units: + del self.__units[units:] + self.__queue_event(ChainTruncatedEvent(units)) + self.__units[resp.header.unit].state = resp.body + self.__queue_event(UnitStateEvent(resp.header.unit, resp.body)) + + case UnitAtxLeds(): + self.__units[resp.header.unit].atx_leds = resp.body + self.__queue_event(UnitAtxLedsEvent(resp.header.unit, resp.body)) + + def __adjust_quirks(self) -> None: + for (unit, ctx) in enumerate(self.__units): + if ctx.state is not None and ctx.state.version.is_fresh(7): + ignore_hpd = (unit == 0 and self.__ignore_hpd_on_top) + if ctx.state.quirks.ignore_hpd != ignore_hpd: + get_logger().info("Applying quirk ignore_hpd=%s to [%d] ...", + ignore_hpd, unit) + self.__device.request_set_quirks(unit, ignore_hpd) + + def __adjust_start_port(self) -> None: + if self.__active_port < 0: + for (unit, ctx) in enumerate(self.__units): + if ctx.state is not None and ctx.state.ch < 4: + # Trigger queue select() + port = self.get_virtual_port(unit, ctx.state.ch) + get_logger().info("Found an active port %d on [%d:%d]: Syncing ...", + port, unit, ctx.state.ch) + self.set_active_port(port) + break + + def __finish_changing_request(self, resp: Response) -> None: + if self.__units[resp.header.unit].changing_rid == resp.header.rid: + self.__units[resp.header.unit].changing_rid = -1 + + # ===== + + def __ensure_config(self) -> None: + for (unit, ctx) in enumerate(self.__units): + if ctx.state is not None: + self.__ensure_config_port(unit, ctx) + if self.__actual: + self.__ensure_config_edids(unit, ctx) + self.__ensure_config_dummies(unit, ctx) + self.__ensure_config_colors(unit, ctx) + + def __ensure_config_port(self, unit: int, ctx: _UnitContext) -> None: + assert ctx.state is not None + if self.__active_port >= 0 and ctx.can_be_changed(): + ch = self.get_unit_target_channel(unit, self.__active_port) + if ctx.state.ch != ch: + get_logger().info("Switching for active port %d: [%d:%d] -> [%d:%d] ...", + self.__active_port, unit, ctx.state.ch, unit, ch) + ctx.changing_rid = self.__device.request_switch(unit, ch) + + def __ensure_config_edids(self, unit: int, ctx: _UnitContext) -> None: + assert self.__actual + assert ctx.state is not None + if ctx.can_be_changed(): + for ch in range(4): + port = self.get_virtual_port(unit, ch) + edid = self.__edids.get_edid_for_port(port) + if not ctx.state.compare_edid(ch, edid): + get_logger().info("Changing EDID on port %d on [%d:%d]: %d/%d -> %d/%d (%s) ...", + port, unit, ch, + ctx.state.video_crc[ch], ctx.state.video_edid[ch], + edid.crc, edid.valid, edid.name) + ctx.changing_rid = self.__device.request_set_edid(unit, ch, edid) + break # Busy globally + + def __ensure_config_dummies(self, unit: int, ctx: _UnitContext) -> None: + assert ctx.state is not None + if ctx.state.version.is_fresh(8) and ctx.can_be_changed(): + for ch in range(4): + port = self.get_virtual_port(unit, ch) + dummy = self.__dummies[port] + if ctx.state.video_dummies[ch] != dummy: + get_logger().info("Changing dummy flag on port %d on [%d:%d]: %d -> %d ...", + port, unit, ch, + ctx.state.video_dummies[ch], dummy) + ctx.changing_rid = self.__device.request_set_dummy(unit, ch, dummy) + break # Busy globally (actually not but it can be changed in the firmware) + + def __ensure_config_colors(self, unit: int, ctx: _UnitContext) -> None: + assert self.__actual + assert ctx.state is not None + for np in range(6): + if self.__colors.crc != ctx.state.np_crc[np]: + # get_logger().info("Changing colors on NP [%d:%d]: %d -> %d ...", + # unit, np, ctx.state.np_crc[np], self.__colors.crc) + self.__device.request_set_colors(unit, np, self.__colors) + + # ===== + + @classmethod + def get_real_unit_channel(cls, port: int) -> tuple[int, int]: + return (port // 4, port % 4) + + @classmethod + def get_unit_target_channel(cls, unit: int, port: int) -> int: + (t_unit, t_ch) = cls.get_real_unit_channel(port) + if unit != t_unit: + t_ch = 4 + return t_ch + + @classmethod + def get_virtual_port(cls, unit: int, ch: int) -> int: + return (unit * 4) + ch diff --git a/kvmd/apps/kvmd/switch/device.py b/kvmd/apps/kvmd/switch/device.py new file mode 100644 index 000000000..df532804f --- /dev/null +++ b/kvmd/apps/kvmd/switch/device.py @@ -0,0 +1,204 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os +import random +import types + +import serial + +from .lib import tools + +from .types import Edid +from .types import Colors + +from .proto import Packable +from .proto import Request +from .proto import Response +from .proto import Header + +from .proto import BodySwitch +from .proto import BodySetBeacon +from .proto import BodyAtxClick +from .proto import BodySetEdid +from .proto import BodyClearEdid +from .proto import BodySetDummy +from .proto import BodySetColors +from .proto import BodySetQuirks + + +# ===== +class DeviceError(Exception): + def __init__(self, ex: Exception): + super().__init__(tools.efmt(ex)) + + +class Device: + __SPEED = 115200 + __TIMEOUT = 5.0 + + def __init__(self, device_path: str) -> None: + self.__device_path = device_path + self.__rid = random.randint(1, 0xFFFF) + self.__tty: (serial.Serial | None) = None + self.__buf: bytes = b"" + + def __enter__(self) -> "Device": + try: + self.__tty = serial.Serial( + self.__device_path, + baudrate=self.__SPEED, + timeout=self.__TIMEOUT, + ) + except Exception as ex: + raise DeviceError(ex) + return self + + def __exit__( + self, + _exc_type: type[BaseException], + _exc: BaseException, + _tb: types.TracebackType, + ) -> None: + + if self.__tty is not None: + try: + self.__tty.close() + except Exception: + pass + self.__tty = None + + def has_device(self) -> bool: + return os.path.exists(self.__device_path) + + def get_fd(self) -> int: + assert self.__tty is not None + return self.__tty.fd + + def read_all(self) -> list[Response]: + assert self.__tty is not None + try: + if not self.__tty.in_waiting: + return [] + self.__buf += self.__tty.read_all() + except Exception as ex: + raise DeviceError(ex) + + results: list[Response] = [] + while True: + try: + begin = self.__buf.index(0xF1) + except ValueError: + break + try: + end = self.__buf.index(0xF2, begin) + except ValueError: + break + msg = self.__buf[begin + 1:end] + if 0xF1 in msg: + # raise RuntimeError(f"Found 0xF1 inside the message: {msg!r}") + break + self.__buf = self.__buf[end + 1:] + msg = self.__unescape(msg) + resp = Response.unpack(msg) + if resp is not None: + results.append(resp) + return results + + def __unescape(self, msg: bytes) -> bytes: + if 0xF0 not in msg: + return msg + unesc: list[int] = [] + esc = False + for ch in msg: + if ch == 0xF0: + esc = True + else: + if esc: + ch ^= 0xFF + esc = False + unesc.append(ch) + return bytes(unesc) + + def request_reboot(self, unit: int, bootloader: bool) -> int: + return self.__send_request((Header.BOOTLOADER if bootloader else Header.REBOOT), unit, None) + + def request_state(self) -> int: + return self.__send_request(Header.STATE, 0xFF, None) + + def request_switch(self, unit: int, ch: int) -> int: + return self.__send_request(Header.SWITCH, unit, BodySwitch(ch)) + + def request_beacon(self, unit: int, ch: int, on: bool) -> int: + return self.__send_request(Header.BEACON, unit, BodySetBeacon(ch, on)) + + def request_atx_leds(self) -> int: + return self.__send_request(Header.ATX_LEDS, 0xFF, None) + + def request_atx_cp(self, unit: int, ch: int, delay_ms: int) -> int: + return self.__send_request(Header.ATX_CLICK, unit, BodyAtxClick(ch, BodyAtxClick.POWER, delay_ms)) + + def request_atx_cr(self, unit: int, ch: int, delay_ms: int) -> int: + return self.__send_request(Header.ATX_CLICK, unit, BodyAtxClick(ch, BodyAtxClick.RESET, delay_ms)) + + def request_set_edid(self, unit: int, ch: int, edid: Edid) -> int: + if edid.valid: + return self.__send_request(Header.SET_EDID, unit, BodySetEdid(ch, edid)) + return self.__send_request(Header.CLEAR_EDID, unit, BodyClearEdid(ch)) + + def request_set_dummy(self, unit: int, ch: int, on: bool) -> int: + return self.__send_request(Header.SET_DUMMY, unit, BodySetDummy(ch, on)) + + def request_set_colors(self, unit: int, ch: int, colors: Colors) -> int: + return self.__send_request(Header.SET_COLORS, unit, BodySetColors(ch, colors)) + + def request_set_quirks(self, unit: int, ignore_hpd: bool) -> int: + return self.__send_request(Header.SET_QUIRKS, unit, BodySetQuirks(ignore_hpd)) + + def __send_request(self, op: int, unit: int, body: (Packable | None)) -> int: + assert self.__tty is not None + req = Request(Header( + proto=1, + rid=self.__get_next_rid(), + op=op, + unit=unit, + ), body) + data: list[int] = [0xF1] + for ch in req.pack(): + if 0xF0 <= ch <= 0xF2: + data.append(0xF0) + ch ^= 0xFF + data.append(ch) + data.append(0xF2) + try: + self.__tty.write(bytes(data)) + self.__tty.flush() + except Exception as ex: + raise DeviceError(ex) + return req.header.rid + + def __get_next_rid(self) -> int: + rid = self.__rid + self.__rid += 1 + if self.__rid > 0xFFFF: + self.__rid = 1 + return rid diff --git a/kvmd/apps/kvmd/switch/lib.py b/kvmd/apps/kvmd/switch/lib.py new file mode 100644 index 000000000..48e2ef962 --- /dev/null +++ b/kvmd/apps/kvmd/switch/lib.py @@ -0,0 +1,36 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +# pylint: disable=unused-import + +from ....logging import get_logger # noqa: F401 + +from .... import tools # noqa: F401 +from .... import aiotools # noqa: F401 +from .... import aioproc # noqa: F401 +from .... import aiomulti # noqa: F401 +from .... import bitbang # noqa: F401 +from .... import htclient # noqa: F401 +from ....inotify import Inotify # noqa: F401 +from ....errors import OperationError # noqa: F401 +from ....edid import EdidNoBlockError as ParsedEdidNoBlockError # noqa: F401 +from ....edid import Edid as ParsedEdid # noqa: F401 diff --git a/kvmd/apps/kvmd/switch/proto.py b/kvmd/apps/kvmd/switch/proto.py new file mode 100644 index 000000000..3c39d2385 --- /dev/null +++ b/kvmd/apps/kvmd/switch/proto.py @@ -0,0 +1,340 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import struct +import dataclasses + +from typing import Optional + +from .types import Edid +from .types import Colors + + +# ===== +class Packable: + def pack(self) -> bytes: + raise NotImplementedError() + + +class Unpackable: + @classmethod + def unpack(cls, data: bytes, offset: int=0) -> "Unpackable": + raise NotImplementedError() + + +# ===== +@dataclasses.dataclass(frozen=True) +class Header(Packable, Unpackable): + proto: int + rid: int + op: int + unit: int + + NAK = 0 + BOOTLOADER = 2 + REBOOT = 3 + STATE = 4 + SWITCH = 5 + BEACON = 6 + ATX_LEDS = 7 + ATX_CLICK = 8 + SET_EDID = 9 + CLEAR_EDID = 10 + SET_COLORS = 12 + SET_QUIRKS = 13 + SET_DUMMY = 14 + + __struct = struct.Struct(" bytes: + return self.__struct.pack(self.proto, self.rid, self.op, self.unit) + + @classmethod + def unpack(cls, data: bytes, offset: int=0) -> "Header": + return Header(*cls.__struct.unpack_from(data, offset=offset)) + + +@dataclasses.dataclass(frozen=True) +class Nak(Unpackable): + reason: int + + INVALID_COMMAND = 0 + BUSY = 1 + NO_DOWNLINK = 2 + DOWNLINK_OVERFLOW = 3 + + __struct = struct.Struct(" "Nak": + return Nak(*cls.__struct.unpack_from(data, offset=offset)) + + +@dataclasses.dataclass(frozen=True) +class UnitVersion: + hw: int + sw: int + sw_dev: bool + + def is_fresh(self, version: int) -> bool: + return (self.sw_dev or (self.sw >= version)) + + +@dataclasses.dataclass(frozen=True) +class UnitFlags: + changing_busy: bool + flashing_busy: bool + has_downlink: bool + has_hpd: bool + + +@dataclasses.dataclass(frozen=True) +class UnitQuirks: + ignore_hpd: bool + + +@dataclasses.dataclass(frozen=True) +class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes + version: UnitVersion + flags: UnitFlags + ch: int + beacons: tuple[bool, bool, bool, bool, bool, bool] + np_crc: tuple[int, int, int, int, int, int] + video_5v_sens: tuple[bool, bool, bool, bool, bool] + video_hpd: tuple[bool, bool, bool, bool, bool] + video_edid: tuple[bool, bool, bool, bool] + video_crc: tuple[int, int, int, int] + video_dummies: tuple[bool, bool, bool, bool] + usb_5v_sens: tuple[bool, bool, bool, bool] + atx_busy: tuple[bool, bool, bool, bool] + quirks: UnitQuirks + + __struct = struct.Struct(" bool: + if edid is None: + # Сойдет любой невалидный EDID + return (not self.video_edid[ch]) + return ( + self.video_edid[ch] == edid.valid + and self.video_crc[ch] == edid.crc + ) + + @classmethod + def unpack(cls, data: bytes, offset: int=0) -> "UnitState": # pylint: disable=too-many-locals + ( + sw_version, hw_version, flags, ch, + beacons, nc0, nc1, nc2, nc3, nc4, nc5, + video_5v_sens, video_hpd, video_edid, vc0, vc1, vc2, vc3, + usb_5v_sens, atx_busy, quirks, video_dummies, + ) = cls.__struct.unpack_from(data, offset=offset) + return UnitState( + version=UnitVersion( + hw=hw_version, + sw=(sw_version & 0x7FFF), + sw_dev=bool(sw_version & 0x8000), + ), + flags=UnitFlags( + changing_busy=bool(flags & 0x80), + flashing_busy=bool(flags & 0x40), + has_downlink=bool(flags & 0x02), + has_hpd=bool(flags & 0x04), + ), + ch=ch, + beacons=cls.__make_flags6(beacons), + np_crc=(nc0, nc1, nc2, nc3, nc4, nc5), + video_5v_sens=cls.__make_flags5(video_5v_sens), + video_hpd=cls.__make_flags5(video_hpd), + video_edid=cls.__make_flags4(video_edid), + video_crc=(vc0, vc1, vc2, vc3), + video_dummies=cls.__make_flags4(video_dummies), + usb_5v_sens=cls.__make_flags4(usb_5v_sens), + atx_busy=cls.__make_flags4(atx_busy), + quirks=UnitQuirks(ignore_hpd=bool(quirks & 0x01)), + ) + + @classmethod + def __make_flags6(cls, mask: int) -> tuple[bool, bool, bool, bool, bool, bool]: + return ( + bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), + bool(mask & 0x08), bool(mask & 0x10), bool(mask & 0x20), + ) + + @classmethod + def __make_flags5(cls, mask: int) -> tuple[bool, bool, bool, bool, bool]: + return ( + bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), + bool(mask & 0x08), bool(mask & 0x10), + ) + + @classmethod + def __make_flags4(cls, mask: int) -> tuple[bool, bool, bool, bool]: + return (bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), bool(mask & 0x08)) + + +@dataclasses.dataclass(frozen=True) +class UnitAtxLeds(Unpackable): + power: tuple[bool, bool, bool, bool] + hdd: tuple[bool, bool, bool, bool] + + __struct = struct.Struct(" "UnitAtxLeds": + (mask,) = cls.__struct.unpack_from(data, offset=offset) + return UnitAtxLeds( + power=(bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), bool(mask & 0x08)), + hdd=(bool(mask & 0x10), bool(mask & 0x20), bool(mask & 0x40), bool(mask & 0x80)), + ) + + +# ===== +@dataclasses.dataclass(frozen=True) +class BodySwitch(Packable): + ch: int + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 4 + + def pack(self) -> bytes: + return self.ch.to_bytes() + + +@dataclasses.dataclass(frozen=True) +class BodySetBeacon(Packable): + ch: int + on: bool + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 5 + + def pack(self) -> bytes: + return self.ch.to_bytes() + self.on.to_bytes() + + +@dataclasses.dataclass(frozen=True) +class BodyAtxClick(Packable): + ch: int + action: int + delay_ms: int + + POWER = 0 + RESET = 1 + + __struct = struct.Struct(" None: + assert 0 <= self.ch <= 3 + assert self.action in [self.POWER, self.RESET] + assert 1 <= self.delay_ms <= 0xFFFF + + def pack(self) -> bytes: + return self.__struct.pack(self.ch, self.action, self.delay_ms) + + +@dataclasses.dataclass(frozen=True) +class BodySetEdid(Packable): + ch: int + edid: Edid + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 3 + + def pack(self) -> bytes: + return self.ch.to_bytes() + self.edid.pack() + + +@dataclasses.dataclass(frozen=True) +class BodyClearEdid(Packable): + ch: int + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 3 + + def pack(self) -> bytes: + return self.ch.to_bytes() + + +@dataclasses.dataclass(frozen=True) +class BodySetDummy(Packable): + ch: int + on: bool + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 3 + + def pack(self) -> bytes: + return self.ch.to_bytes() + self.on.to_bytes() + + +@dataclasses.dataclass(frozen=True) +class BodySetColors(Packable): + ch: int + colors: Colors + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 5 + + def pack(self) -> bytes: + return self.ch.to_bytes() + self.colors.pack() + + +@dataclasses.dataclass(frozen=True) +class BodySetQuirks(Packable): + ignore_hpd: bool + + def pack(self) -> bytes: + return self.ignore_hpd.to_bytes() + + +# ===== +@dataclasses.dataclass(frozen=True) +class Request: + header: Header + body: (Packable | None) = dataclasses.field(default=None) + + def pack(self) -> bytes: + msg = self.header.pack() + if self.body is not None: + msg += self.body.pack() + return msg + + +@dataclasses.dataclass(frozen=True) +class Response: + header: Header + body: Unpackable + + @classmethod + def unpack(cls, msg: bytes) -> Optional["Response"]: + header = Header.unpack(msg) + match header.op: + case Header.NAK: + return Response(header, Nak.unpack(msg, Header.SIZE)) + case Header.STATE: + return Response(header, UnitState.unpack(msg, Header.SIZE)) + case Header.ATX_LEDS: + return Response(header, UnitAtxLeds.unpack(msg, Header.SIZE)) + # raise RuntimeError(f"Unknown OP in the header: {header!r}") + return None diff --git a/kvmd/apps/kvmd/switch/state.py b/kvmd/apps/kvmd/switch/state.py new file mode 100644 index 000000000..ba00e722e --- /dev/null +++ b/kvmd/apps/kvmd/switch/state.py @@ -0,0 +1,386 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import dataclasses +import time + +from typing import AsyncGenerator + +from .types import Edids +from .types import Dummies +from .types import Color +from .types import Colors +from .types import PortNames +from .types import AtxClickPowerDelays +from .types import AtxClickPowerLongDelays +from .types import AtxClickResetDelays + +from .proto import UnitState +from .proto import UnitAtxLeds + +from .chain import Chain + + +# ===== +@dataclasses.dataclass +class _UnitInfo: + state: (UnitState | None) = dataclasses.field(default=None) + atx_leds: (UnitAtxLeds | None) = dataclasses.field(default=None) + + +# ===== +class StateCache: # pylint: disable=too-many-instance-attributes,too-many-public-methods + __FW_VERSION = 9 + + __FULL = 0xFFFF + __SUMMARY = 0x01 + __EDIDS = 0x02 + __COLORS = 0x04 + __VIDEO = 0x08 + __USB = 0x10 + __BEACONS = 0x20 + __ATX = 0x40 + + def __init__(self) -> None: + self.__edids = Edids() + self.__dummies = Dummies({}) + self.__colors = Colors() + self.__port_names = PortNames({}) + self.__atx_cp_delays = AtxClickPowerDelays({}) + self.__atx_cpl_delays = AtxClickPowerLongDelays({}) + self.__atx_cr_delays = AtxClickResetDelays({}) + + self.__units: list[_UnitInfo] = [] + self.__active_port = -1 + self.__synced = True + + self.__queue: "asyncio.Queue[int]" = asyncio.Queue() + + def get_edids(self) -> Edids: + return self.__edids.copy() + + def get_dummies(self) -> Dummies: + return self.__dummies.copy() + + def get_colors(self) -> Colors: + return self.__colors + + def get_port_names(self) -> PortNames: + return self.__port_names.copy() + + def get_atx_cp_delays(self) -> AtxClickPowerDelays: + return self.__atx_cp_delays.copy() + + def get_atx_cpl_delays(self) -> AtxClickPowerLongDelays: + return self.__atx_cpl_delays.copy() + + def get_atx_cr_delays(self) -> AtxClickResetDelays: + return self.__atx_cr_delays.copy() + + # ===== + + def get_state(self) -> dict: + return self.__inner_get_state(self.__FULL) + + async def trigger_state(self) -> None: + self.__bump_state(self.__FULL) + + async def poll_state(self) -> AsyncGenerator[dict, None]: + atx_ts: float = 0 + while True: + try: + mask = await asyncio.wait_for(self.__queue.get(), timeout=0.1) + except TimeoutError: + mask = 0 + + if mask == self.__ATX: + # Откладываем единичное новое событие ATX, чтобы аккумулировать с нескольких свичей + if atx_ts == 0: + atx_ts = time.monotonic() + 0.2 + continue + elif atx_ts >= time.monotonic(): + continue + # ... Ну или разрешаем отправить, если оно уже достаточно мариновалось + elif mask == 0 and atx_ts > time.monotonic(): + # Разрешаем отправить отложенное + mask = self.__ATX + atx_ts = 0 + elif mask & self.__ATX: + # Комплексное событие всегда должно обрабатываться сразу + atx_ts = 0 + + if mask != 0: + yield self.__inner_get_state(mask) + + def __inner_get_state(self, mask: int) -> dict: # pylint: disable=too-many-branches,too-many-statements,too-many-locals + assert mask != 0 + x_model = (mask == self.__FULL) + x_summary = (mask & self.__SUMMARY) + x_edids = (mask & self.__EDIDS) + x_colors = (mask & self.__COLORS) + x_video = (mask & self.__VIDEO) + x_usb = (mask & self.__USB) + x_beacons = (mask & self.__BEACONS) + x_atx = (mask & self.__ATX) + + state: dict = {} + if x_model: + state["model"] = { + "firmware": {"version": self.__FW_VERSION}, + "units": [], + "ports": [], + "limits": { + "atx": { + "click_delays": { + key: {"default": value, "min": 0, "max": 10} + for (key, value) in [ + ("power", self.__atx_cp_delays.default), + ("power_long", self.__atx_cpl_delays.default), + ("reset", self.__atx_cr_delays.default), + ] + }, + }, + }, + } + if x_summary: + state["summary"] = { + "active_port": self.__active_port, + "active_id": ( + "" if self.__active_port < 0 else ( + f"{self.__active_port // 4 + 1}.{self.__active_port % 4 + 1}" + if len(self.__units) > 1 else + f"{self.__active_port + 1}" + ) + ), + "synced": self.__synced, + } + if x_edids: + state["edids"] = { + "all": { + edid_id: { + "name": edid.name, + "data": edid.as_text(), + "parsed": (dataclasses.asdict(edid.info) if edid.info is not None else None), + } + for (edid_id, edid) in self.__edids.all.items() + }, + "used": [], + } + if x_colors: + state["colors"] = { + role: { + comp: getattr(getattr(self.__colors, role), comp) + for comp in Color.COMPONENTS + } + for role in Colors.ROLES + } + if x_video: + state["video"] = {"links": []} + if x_usb: + state["usb"] = {"links": []} + if x_beacons: + state["beacons"] = {"uplinks": [], "downlinks": [], "ports": []} + if x_atx: + state["atx"] = {"busy": [], "leds": {"power": [], "hdd": []}} + + if not self.__is_units_ready(): + return state + + for (unit, ui) in enumerate(self.__units): + assert ui.state is not None + assert ui.atx_leds is not None + if x_model: + state["model"]["units"].append({"firmware": { + "version": ui.state.version.sw, + "devbuild": ui.state.version.sw_dev, + }}) + if x_video: + state["video"]["links"].extend(ui.state.video_5v_sens[:4]) + if x_usb: + state["usb"]["links"].extend(ui.state.usb_5v_sens) + if x_beacons: + state["beacons"]["uplinks"].append(ui.state.beacons[5]) + state["beacons"]["downlinks"].append(ui.state.beacons[4]) + state["beacons"]["ports"].extend(ui.state.beacons[:4]) + if x_atx: + state["atx"]["busy"].extend(ui.state.atx_busy) + state["atx"]["leds"]["power"].extend(ui.atx_leds.power) + state["atx"]["leds"]["hdd"].extend(ui.atx_leds.hdd) + if x_model or x_edids: + for ch in range(4): + port = Chain.get_virtual_port(unit, ch) + if x_model: + state["model"]["ports"].append({ + "unit": unit, + "channel": ch, + "name": self.__port_names[port], + "id": (f"{unit + 1}.{ch + 1}" if len(self.__units) > 1 else f"{ch + 1}"), + "atx": { + "click_delays": { + "power": self.__atx_cp_delays[port], + "power_long": self.__atx_cpl_delays[port], + "reset": self.__atx_cr_delays[port], + }, + }, + "video": { + "dummy": self.__dummies[port], + }, + }) + if x_edids: + state["edids"]["used"].append(self.__edids.get_id_for_port(port)) + return state + + def __inner_check_synced(self) -> bool: + for (unit, ui) in enumerate(self.__units): + if ui.state is None or ui.state.flags.changing_busy: + return False + if ( + self.__active_port >= 0 + and ui.state.ch != Chain.get_unit_target_channel(unit, self.__active_port) + ): + return False + for ch in range(4): + port = Chain.get_virtual_port(unit, ch) + edid = self.__edids.get_edid_for_port(port) + if not ui.state.compare_edid(ch, edid): + return False + for ch in range(6): + if ui.state.np_crc[ch] != self.__colors.crc: + return False + return True + + def __recache_synced(self) -> bool: + synced = self.__inner_check_synced() + if self.__synced != synced: + self.__synced = synced + return True + return False + + def truncate(self, units: int) -> None: + if len(self.__units) > units: + del self.__units[units:] + self.__bump_state(self.__FULL) + + def update_active_port(self, port: int) -> None: + changed = (self.__active_port != port) + self.__active_port = port + changed = (self.__recache_synced() or changed) + if changed: + self.__bump_state(self.__SUMMARY) + + def update_unit_state(self, unit: int, new: UnitState) -> None: + ui = self.__ensure_unit(unit) + (prev, ui.state) = (ui.state, new) + if not self.__is_units_ready(): + return + mask = 0 + if prev is None: + mask = self.__FULL + else: + if self.__recache_synced(): + mask |= self.__SUMMARY + if prev.video_5v_sens != new.video_5v_sens: + mask |= self.__VIDEO + if prev.usb_5v_sens != new.usb_5v_sens: + mask |= self.__USB + if prev.beacons != new.beacons: + mask |= self.__BEACONS + if prev.atx_busy != new.atx_busy: + mask |= self.__ATX + if mask: + self.__bump_state(mask) + + def update_unit_atx_leds(self, unit: int, new: UnitAtxLeds) -> None: + ui = self.__ensure_unit(unit) + (prev, ui.atx_leds) = (ui.atx_leds, new) + if not self.__is_units_ready(): + return + if prev is None: + self.__bump_state(self.__FULL) + elif prev != new: + self.__bump_state(self.__ATX) + + def __is_units_ready(self) -> bool: + for ui in self.__units: + if ui.state is None or ui.atx_leds is None: + return False + return True + + def __ensure_unit(self, unit: int) -> _UnitInfo: + while len(self.__units) < unit + 1: + self.__units.append(_UnitInfo()) + return self.__units[unit] + + def __bump_state(self, mask: int) -> None: + assert mask != 0 + self.__queue.put_nowait(mask) + + # ===== + + def set_edids(self, edids: Edids) -> None: + changed = ( + self.__edids.all != edids.all + or not self.__edids.compare_on_ports(edids, self.__get_ports()) + ) + self.__edids = edids.copy() + if changed: + self.__bump_state(self.__EDIDS) + + def set_dummies(self, dummies: Dummies) -> None: + changed = (not self.__dummies.compare_on_ports(dummies, self.__get_ports())) + self.__dummies = dummies.copy() + if changed: + self.__bump_state(self.__FULL) + + def set_colors(self, colors: Colors) -> None: + changed = (self.__colors != colors) + self.__colors = colors + if changed: + self.__bump_state(self.__COLORS) + + def set_port_names(self, port_names: PortNames) -> None: + changed = (not self.__port_names.compare_on_ports(port_names, self.__get_ports())) + self.__port_names = port_names.copy() + if changed: + self.__bump_state(self.__FULL) + + def set_atx_cp_delays(self, delays: AtxClickPowerDelays) -> None: + changed = (not self.__atx_cp_delays.compare_on_ports(delays, self.__get_ports())) + self.__atx_cp_delays = delays.copy() + if changed: + self.__bump_state(self.__FULL) + + def set_atx_cpl_delays(self, delays: AtxClickPowerLongDelays) -> None: + changed = (not self.__atx_cpl_delays.compare_on_ports(delays, self.__get_ports())) + self.__atx_cpl_delays = delays.copy() + if changed: + self.__bump_state(self.__FULL) + + def set_atx_cr_delays(self, delays: AtxClickResetDelays) -> None: + changed = (not self.__atx_cr_delays.compare_on_ports(delays, self.__get_ports())) + self.__atx_cr_delays = delays.copy() + if changed: + self.__bump_state(self.__FULL) + + def __get_ports(self) -> int: + return (len(self.__units) * 4) diff --git a/kvmd/apps/kvmd/switch/storage.py b/kvmd/apps/kvmd/switch/storage.py new file mode 100644 index 000000000..f83480099 --- /dev/null +++ b/kvmd/apps/kvmd/switch/storage.py @@ -0,0 +1,196 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os +import asyncio +import json +import contextlib + +from typing import AsyncGenerator + +try: + from ....clients.pst import PstClient +except ImportError: + PstClient = None # type: ignore + +# from .lib import get_logger +from .lib import aiotools +from .lib import htclient +from .lib import get_logger + +from .types import Edid +from .types import Edids +from .types import Dummies +from .types import Color +from .types import Colors +from .types import PortNames +from .types import AtxClickPowerDelays +from .types import AtxClickPowerLongDelays +from .types import AtxClickResetDelays + + +# ===== +class StorageContext: + __F_EDIDS_ALL = "edids_all.json" + __F_EDIDS_PORT = "edids_port.json" + + __F_DUMMIES = "dummies.json" + + __F_COLORS = "colors.json" + + __F_PORT_NAMES = "port_names.json" + + __F_ATX_CP_DELAYS = "atx_click_power_delays.json" + __F_ATX_CPL_DELAYS = "atx_click_power_long_delays.json" + __F_ATX_CR_DELAYS = "atx_click_reset_delays.json" + + def __init__(self, path: str, rw: bool) -> None: + self.__path = path + self.__rw = rw + + # ===== + + async def write_edids(self, edids: Edids) -> None: + await self.__write_json_keyvals(self.__F_EDIDS_ALL, { + edid_id.lower(): {"name": edid.name, "data": edid.as_text()} + for (edid_id, edid) in edids.all.items() + if edid_id != Edids.DEFAULT_ID + }) + await self.__write_json_keyvals(self.__F_EDIDS_PORT, edids.port) + + async def write_dummies(self, dummies: Dummies) -> None: + await self.__write_json_keyvals(self.__F_DUMMIES, dummies.kvs) + + async def write_colors(self, colors: Colors) -> None: + await self.__write_json_keyvals(self.__F_COLORS, { + role: { + comp: getattr(getattr(colors, role), comp) + for comp in Color.COMPONENTS + } + for role in Colors.ROLES + }) + + async def write_port_names(self, port_names: PortNames) -> None: + await self.__write_json_keyvals(self.__F_PORT_NAMES, port_names.kvs) + + async def write_atx_cp_delays(self, delays: AtxClickPowerDelays) -> None: + await self.__write_json_keyvals(self.__F_ATX_CP_DELAYS, delays.kvs) + + async def write_atx_cpl_delays(self, delays: AtxClickPowerLongDelays) -> None: + await self.__write_json_keyvals(self.__F_ATX_CPL_DELAYS, delays.kvs) + + async def write_atx_cr_delays(self, delays: AtxClickResetDelays) -> None: + await self.__write_json_keyvals(self.__F_ATX_CR_DELAYS, delays.kvs) + + async def __write_json_keyvals(self, name: str, kvs: dict) -> None: + if len(self.__path) == 0: + return + assert self.__rw + kvs = {str(key): value for (key, value) in kvs.items()} + if (await self.__read_json_keyvals(name)) == kvs: + return # Don't write the same data + path = os.path.join(self.__path, name) + get_logger(0).info("Writing '%s' ...", name) + await aiotools.write_file(path, json.dumps(kvs)) + + # ===== + + async def read_edids(self) -> Edids: + all_edids = { + edid_id.lower(): Edid.from_data(edid["name"], edid["data"]) + for (edid_id, edid) in (await self.__read_json_keyvals(self.__F_EDIDS_ALL)).items() + } + port_edids = await self.__read_json_keyvals_int(self.__F_EDIDS_PORT) + return Edids(all_edids, port_edids) + + async def read_dummies(self) -> Dummies: + kvs = await self.__read_json_keyvals_int(self.__F_DUMMIES) + return Dummies({key: bool(value) for (key, value) in kvs.items()}) + + async def read_colors(self) -> Colors: + raw = await self.__read_json_keyvals(self.__F_COLORS) + return Colors(**{ # type: ignore + role: Color(**{comp: raw[role][comp] for comp in Color.COMPONENTS}) + for role in Colors.ROLES + if role in raw + }) + + async def read_port_names(self) -> PortNames: + return PortNames(await self.__read_json_keyvals_int(self.__F_PORT_NAMES)) + + async def read_atx_cp_delays(self) -> AtxClickPowerDelays: + return AtxClickPowerDelays(await self.__read_json_keyvals_int(self.__F_ATX_CP_DELAYS)) + + async def read_atx_cpl_delays(self) -> AtxClickPowerLongDelays: + return AtxClickPowerLongDelays(await self.__read_json_keyvals_int(self.__F_ATX_CPL_DELAYS)) + + async def read_atx_cr_delays(self) -> AtxClickResetDelays: + return AtxClickResetDelays(await self.__read_json_keyvals_int(self.__F_ATX_CR_DELAYS)) + + async def __read_json_keyvals_int(self, name: str) -> dict: + return (await self.__read_json_keyvals(name, int_keys=True)) + + async def __read_json_keyvals(self, name: str, int_keys: bool=False) -> dict: + if len(self.__path) == 0: + return {} + path = os.path.join(self.__path, name) + try: + kvs: dict = json.loads(await aiotools.read_file(path)) + except FileNotFoundError: + kvs = {} + if int_keys: + kvs = {int(key): value for (key, value) in kvs.items()} + return kvs + + +class Storage: + __SUBDIR = "__switch__" + __TIMEOUT = 5.0 + + def __init__(self, unix_path: str) -> None: + self.__pst: (PstClient | None) = None + if len(unix_path) > 0 and PstClient is not None: + self.__pst = PstClient( + subdir=self.__SUBDIR, + unix_path=unix_path, + timeout=self.__TIMEOUT, + user_agent=htclient.make_user_agent("KVMD"), + ) + self.__lock = asyncio.Lock() + + @contextlib.asynccontextmanager + async def readable(self) -> AsyncGenerator[StorageContext, None]: + async with self.__lock: + if self.__pst is None: + yield StorageContext("", False) + else: + path = await self.__pst.get_path() + yield StorageContext(path, False) + + @contextlib.asynccontextmanager + async def writable(self) -> AsyncGenerator[StorageContext, None]: + async with self.__lock: + if self.__pst is None: + yield StorageContext("", True) + else: + async with self.__pst.writable() as path: + yield StorageContext(path, True) diff --git a/kvmd/apps/kvmd/switch/types.py b/kvmd/apps/kvmd/switch/types.py new file mode 100644 index 000000000..3d948eb35 --- /dev/null +++ b/kvmd/apps/kvmd/switch/types.py @@ -0,0 +1,327 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import re +import struct +import uuid +import dataclasses + +from typing import TypeVar +from typing import Generic + +from .lib import bitbang +from .lib import ParsedEdidNoBlockError +from .lib import ParsedEdid + + +# ===== +@dataclasses.dataclass(frozen=True) +class EdidInfo: + mfc_id: str + product_id: int + serial: int + monitor_name: (str | None) + monitor_serial: (str | None) + audio: bool + + @classmethod + def from_data(cls, data: bytes) -> "EdidInfo": + parsed = ParsedEdid(data) + + monitor_name: (str | None) = None + try: + monitor_name = parsed.get_monitor_name() + except ParsedEdidNoBlockError: + pass + + monitor_serial: (str | None) = None + try: + monitor_serial = parsed.get_monitor_serial() + except ParsedEdidNoBlockError: + pass + + audio: bool = False + try: + audio = parsed.get_audio() + except ParsedEdidNoBlockError: + pass + + return EdidInfo( + mfc_id=parsed.get_mfc_id(), + product_id=parsed.get_product_id(), + serial=parsed.get_serial(), + monitor_name=monitor_name, + monitor_serial=monitor_serial, + audio=audio, + ) + + +@dataclasses.dataclass(frozen=True) +class Edid: + name: str + data: bytes + crc: int = dataclasses.field(default=0) + valid: bool = dataclasses.field(default=False) + info: (EdidInfo | None) = dataclasses.field(default=None) + _packed: bytes = dataclasses.field(default=b"") + + def __post_init__(self) -> None: + assert len(self.name) > 0 + assert len(self.data) in [128, 256] + object.__setattr__(self, "_packed", (self.data + (b"\x00" * 128))[:256]) + object.__setattr__(self, "crc", bitbang.make_crc16(self._packed)) # Calculate CRC for filled data + object.__setattr__(self, "valid", ParsedEdid.is_header_valid(self.data)) + try: + object.__setattr__(self, "info", EdidInfo.from_data(self.data)) + except Exception: + pass + + def as_text(self) -> str: + return "".join(f"{item:0{2}X}" for item in self.data) + + def pack(self) -> bytes: + return self._packed + + @classmethod + def from_data(cls, name: str, data: (str | bytes | None)) -> "Edid": + if data is None: # Пустой едид + return Edid(name, b"\x00" * 256) + + if isinstance(data, bytes): + if ParsedEdid.is_header_valid(cls.data): + return Edid(name, data) # Бинарный едид + data_hex = data.decode() # Текстовый едид, прочитанный как бинарный из файла + else: # isinstance(data, str) + data_hex = str(data) # Текстовый едид + + data_hex = re.sub(r"\s", "", data_hex) + assert len(data_hex) in [256, 512] + data = bytes([ + int(data_hex[index:index + 2], 16) + for index in range(0, len(data_hex), 2) + ]) + return Edid(name, data) + + +@dataclasses.dataclass +class Edids: + DEFAULT_NAME = "Default" + DEFAULT_ID = "default" + + all: dict[str, Edid] = dataclasses.field(default_factory=dict) + port: dict[int, str] = dataclasses.field(default_factory=dict) + + def __post_init__(self) -> None: + if self.DEFAULT_ID not in self.all: + self.set_default(None) + + def set_default(self, data: (str | bytes | None)) -> None: + self.all[self.DEFAULT_ID] = Edid.from_data(self.DEFAULT_NAME, data) + + def copy(self) -> "Edids": + return Edids(dict(self.all), dict(self.port)) + + def compare_on_ports(self, other: "Edids", ports: int) -> bool: + for port in range(ports): + if self.get_id_for_port(port) != other.get_id_for_port(port): + return False + return True + + def add(self, edid: Edid) -> str: + edid_id = str(uuid.uuid4()).lower() + self.all[edid_id] = edid + return edid_id + + def set(self, edid_id: str, edid: Edid) -> None: + assert edid_id in self.all + self.all[edid_id] = edid + + def get(self, edid_id: str) -> Edid: + return self.all[edid_id] + + def remove(self, edid_id: str) -> None: + assert edid_id in self.all + self.all.pop(edid_id) + for port in list(self.port): + if self.port[port] == edid_id: + self.port.pop(port) + + def has_id(self, edid_id: str) -> bool: + return (edid_id in self.all) + + def assign(self, port: int, edid_id: str) -> None: + assert edid_id in self.all + if edid_id == Edids.DEFAULT_ID: + self.port.pop(port, None) + else: + self.port[port] = edid_id + + def get_id_for_port(self, port: int) -> str: + return self.port.get(port, self.DEFAULT_ID) + + def get_edid_for_port(self, port: int) -> Edid: + return self.all[self.get_id_for_port(port)] + + +# ===== +@dataclasses.dataclass(frozen=True) +class Color: + COMPONENTS = frozenset(["red", "green", "blue", "brightness", "blink_ms"]) + + red: int + green: int + blue: int + brightness: int + blink_ms: int + crc: int = dataclasses.field(default=0) + _packed: bytes = dataclasses.field(default=b"") + + __struct = struct.Struct(" None: + assert 0 <= self.red <= 0xFF + assert 0 <= self.green <= 0xFF + assert 0 <= self.blue <= 0xFF + assert 0 <= self.brightness <= 0xFF + assert 0 <= self.blink_ms <= 0xFFFF + data = self.__struct.pack(self.red, self.green, self.blue, self.brightness, self.blink_ms) + object.__setattr__(self, "crc", bitbang.make_crc16(data)) + object.__setattr__(self, "_packed", data) + + def pack(self) -> bytes: + return self._packed + + @classmethod + def from_text(cls, text: str) -> "Color": + match = cls.__rx.match(text) + assert match is not None, text + return Color( + red=int(match.group(1), 16), + green=int(match.group(2), 16), + blue=int(match.group(3), 16), + brightness=int(match.group(4), 16), + blink_ms=int(match.group(5), 16), + ) + + +@dataclasses.dataclass(frozen=True) +class Colors: + ROLES = frozenset(["inactive", "active", "flashing", "beacon", "bootloader"]) + + inactive: Color = dataclasses.field(default=Color(255, 0, 0, 64, 0)) + active: Color = dataclasses.field(default=Color(0, 255, 0, 128, 0)) + flashing: Color = dataclasses.field(default=Color(0, 170, 255, 128, 0)) + beacon: Color = dataclasses.field(default=Color(228, 44, 156, 255, 250)) + bootloader: Color = dataclasses.field(default=Color(255, 170, 0, 128, 0)) + crc: int = dataclasses.field(default=0) + _packed: bytes = dataclasses.field(default=b"") + + __crc_struct = struct.Struct(" None: + crcs: list[int] = [] + packed: bytes = b"" + for color in [self.inactive, self.active, self.flashing, self.beacon, self.bootloader]: + crcs.append(color.crc) + packed += color.pack() + object.__setattr__(self, "crc", bitbang.make_crc16(self.__crc_struct.pack(*crcs))) + object.__setattr__(self, "_packed", packed) + + def pack(self) -> bytes: + return self._packed + + +# ===== +_T = TypeVar("_T") + + +class _PortsDict(Generic[_T]): + def __init__(self, default: _T, kvs: dict[int, _T]) -> None: + self.default = default + self.kvs = { + port: value + for (port, value) in kvs.items() + if value != default + } + + def compare_on_ports(self, other: "_PortsDict[_T]", ports: int) -> bool: + for port in range(ports): + if self[port] != other[port]: + return False + return True + + def __getitem__(self, port: int) -> _T: + return self.kvs.get(port, self.default) + + def __setitem__(self, port: int, value: (_T | None)) -> None: + if value is None: + value = self.default + if value == self.default: + self.kvs.pop(port, None) + else: + self.kvs[port] = value + + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return False + return (self.kvs == other.kvs) + + +class Dummies(_PortsDict[bool]): + def __init__(self, kvs: dict[int, bool]) -> None: + super().__init__(True, kvs) + + def copy(self) -> "Dummies": + return Dummies(self.kvs) + + +class PortNames(_PortsDict[str]): + def __init__(self, kvs: dict[int, str]) -> None: + super().__init__("", kvs) + + def copy(self) -> "PortNames": + return PortNames(self.kvs) + + +class AtxClickPowerDelays(_PortsDict[float]): + def __init__(self, kvs: dict[int, float]) -> None: + super().__init__(0.5, kvs) + + def copy(self) -> "AtxClickPowerDelays": + return AtxClickPowerDelays(self.kvs) + + +class AtxClickPowerLongDelays(_PortsDict[float]): + def __init__(self, kvs: dict[int, float]) -> None: + super().__init__(5.5, kvs) + + def copy(self) -> "AtxClickPowerLongDelays": + return AtxClickPowerLongDelays(self.kvs) + + +class AtxClickResetDelays(_PortsDict[float]): + def __init__(self, kvs: dict[int, float]) -> None: + super().__init__(0.5, kvs) + + def copy(self) -> "AtxClickResetDelays": + return AtxClickResetDelays(self.kvs) diff --git a/kvmd/apps/kvmd/sysunit.py b/kvmd/apps/kvmd/sysunit.py index 5a5555532..bcb41c346 100644 --- a/kvmd/apps/kvmd/sysunit.py +++ b/kvmd/apps/kvmd/sysunit.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -35,6 +35,7 @@ def __init__(self) -> None: self.__bus: (dbus_next.aio.MessageBus | None) = None self.__intr: (dbus_next.introspection.Node | None) = None self.__manager: (dbus_next.aio.proxy_object.ProxyInterface | None) = None + self.__requested = False async def get_status(self, name: str) -> tuple[bool, bool]: assert self.__bus is not None @@ -49,8 +50,9 @@ async def get_status(self, name: str) -> tuple[bool, bool]: unit = self.__bus.get_proxy_object("org.freedesktop.systemd1", unit_p, self.__intr) unit_props = unit.get_interface("org.freedesktop.DBus.Properties") started = ((await unit_props.call_get("org.freedesktop.systemd1.Unit", "ActiveState")).value == "active") # type: ignore - except dbus_next.errors.DBusError as err: - if err.type != "org.freedesktop.systemd1.NoSuchUnit": + self.__requested = True + except dbus_next.errors.DBusError as ex: + if ex.type != "org.freedesktop.systemd1.NoSuchUnit": raise started = False enabled = ((await self.__manager.call_get_unit_file_state(name)) in [ # type: ignore @@ -75,8 +77,13 @@ async def __aenter__(self) -> "SystemdUnitInfo": async def close(self) -> None: try: if self.__bus is not None: - self.__bus.disconnect() - await self.__bus.wait_for_disconnect() + try: + # XXX: Workaround for dbus_next bug: https://github.com/pikvm/kvmd/pull/182 + if not self.__requested: + await self.__manager.call_get_default_target() # type: ignore + finally: + self.__bus.disconnect() + await self.__bus.wait_for_disconnect() except Exception: pass self.__manager = None diff --git a/kvmd/apps/kvmd/ugpio.py b/kvmd/apps/kvmd/ugpio.py index bda9cef6c..8b09a0143 100644 --- a/kvmd/apps/kvmd/ugpio.py +++ b/kvmd/apps/kvmd/ugpio.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,6 +21,7 @@ import asyncio +import copy from typing import AsyncGenerator from typing import Callable @@ -68,12 +69,12 @@ def __init__(self) -> None: class _GpioInput: def __init__( self, - channel: str, + ch: str, config: Section, driver: BaseUserGpioDriver, ) -> None: - self.__channel = channel + self.__ch = ch self.__pin: str = str(config.pin) self.__inverted: bool = config.inverted @@ -100,7 +101,7 @@ async def get_state(self) -> dict: } def __str__(self) -> str: - return f"Input({self.__channel}, driver={self.__driver}, pin={self.__pin})" + return f"Input({self.__ch}, driver={self.__driver}, pin={self.__pin})" __repr__ = __str__ @@ -108,13 +109,13 @@ def __str__(self) -> str: class _GpioOutput: # pylint: disable=too-many-instance-attributes def __init__( self, - channel: str, + ch: str, config: Section, driver: BaseUserGpioDriver, notifier: aiotools.AioNotifier, ) -> None: - self.__channel = channel + self.__ch = ch self.__pin: str = str(config.pin) self.__inverted: bool = config.inverted @@ -184,7 +185,7 @@ async def pulse(self, delay: float, wait: bool) -> None: @aiotools.atomic_fg async def __run_action(self, wait: bool, name: str, func: Callable, *args: Any) -> None: if wait: - async with self.__region: + with self.__region: await func(*args) else: await aiotools.run_region_task( @@ -224,7 +225,7 @@ async def __write(self, state: bool) -> None: await self.__driver.write(self.__pin, (state ^ self.__inverted)) def __str__(self) -> str: - return f"Output({self.__channel}, driver={self.__driver}, pin={self.__pin})" + return f"Output({self.__ch}, driver={self.__driver}, pin={self.__pin})" __repr__ = __str__ @@ -232,8 +233,6 @@ def __str__(self) -> str: # ===== class UserGpio: def __init__(self, config: Section, otg_config: Section) -> None: - self.__view = config.view - self.__notifier = aiotools.AioNotifier() self.__drivers = { @@ -249,49 +248,71 @@ def __init__(self, config: Section, otg_config: Section) -> None: self.__inputs: dict[str, _GpioInput] = {} self.__outputs: dict[str, _GpioOutput] = {} - for (channel, ch_config) in tools.sorted_kvs(config.scheme): + for (ch, ch_config) in tools.sorted_kvs(config.scheme): driver = self.__drivers[ch_config.driver] if ch_config.mode == UserGpioModes.INPUT: - self.__inputs[channel] = _GpioInput(channel, ch_config, driver) + self.__inputs[ch] = _GpioInput(ch, ch_config, driver) else: # output: - self.__outputs[channel] = _GpioOutput(channel, ch_config, driver, self.__notifier) + self.__outputs[ch] = _GpioOutput(ch, ch_config, driver, self.__notifier) + + self.__scheme = self.__make_scheme() + self.__view = self.__make_view(config.view) - async def get_model(self) -> dict: + async def get_state(self) -> dict: return { - "scheme": { - "inputs": {channel: gin.get_scheme() for (channel, gin) in self.__inputs.items()}, - "outputs": { - channel: gout.get_scheme() - for (channel, gout) in self.__outputs.items() - if not gout.is_const() - }, + "model": { + "scheme": copy.deepcopy(self.__scheme), + "view": copy.deepcopy(self.__view), }, - "view": self.__make_view(), + "state": (await self.__get_io_state()), } - async def get_state(self) -> dict: + async def trigger_state(self) -> None: + self.__notifier.notify(1) + + async def poll_state(self) -> AsyncGenerator[dict, None]: + # ==== Granularity table ==== + # - model -- Full + # - state.inputs -- Partial + # - state.outputs -- Partial + # =========================== + + prev: dict = {"inputs": {}, "outputs": {}} + while True: # pylint: disable=too-many-nested-blocks + if (await self.__notifier.wait()) > 0: + full = await self.get_state() + prev = copy.deepcopy(full["state"]) + yield full + else: + new = await self.__get_io_state() + diff: dict = {} + for sub in ["inputs", "outputs"]: + for ch in new[sub]: + if new[sub][ch] != prev[sub].get(ch): + if sub not in diff: + diff[sub] = {} + diff[sub][ch] = new[sub][ch] + if diff: + prev = copy.deepcopy(new) + yield {"state": diff} + + async def __get_io_state(self) -> dict: return { - "inputs": {channel: await gin.get_state() for (channel, gin) in self.__inputs.items()}, + "inputs": { + ch: (await gin.get_state()) + for (ch, gin) in self.__inputs.items() + }, "outputs": { - channel: await gout.get_state() - for (channel, gout) in self.__outputs.items() + ch: (await gout.get_state()) + for (ch, gout) in self.__outputs.items() if not gout.is_const() }, } - async def poll_state(self) -> AsyncGenerator[dict, None]: - prev_state: dict = {} - while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - await self.__notifier.wait() - - def sysprep(self) -> None: + async def sysprep(self) -> None: get_logger(0).info("Preparing User-GPIO drivers ...") for (_, driver) in tools.sorted_kvs(self.__drivers): - driver.prepare() + await driver.prepare() async def systask(self) -> None: get_logger(0).info("Running User-GPIO drivers ...") @@ -307,28 +328,43 @@ async def cleanup(self) -> None: except Exception: get_logger().exception("Can't cleanup driver %s", driver) - async def switch(self, channel: str, state: bool, wait: bool) -> None: - gout = self.__outputs.get(channel) + async def switch(self, ch: str, state: bool, wait: bool) -> None: + gout = self.__outputs.get(ch) if gout is None: raise GpioChannelNotFoundError() await gout.switch(state, wait) - async def pulse(self, channel: str, delay: float, wait: bool) -> None: - gout = self.__outputs.get(channel) + async def pulse(self, ch: str, delay: float, wait: bool) -> None: + gout = self.__outputs.get(ch) if gout is None: raise GpioChannelNotFoundError() await gout.pulse(delay, wait) # ===== - def __make_view(self) -> dict: + def __make_scheme(self) -> dict: + return { + "inputs": { + ch: gin.get_scheme() + for (ch, gin) in self.__inputs.items() + }, + "outputs": { + ch: gout.get_scheme() + for (ch, gout) in self.__outputs.items() + if not gout.is_const() + }, + } + + # ===== + + def __make_view(self, view: dict) -> dict: return { - "header": {"title": self.__make_view_title()}, - "table": self.__make_view_table(), + "header": {"title": self.__make_view_title(view)}, + "table": self.__make_view_table(view), } - def __make_view_title(self) -> list[dict]: - raw_title = self.__view["header"]["title"] + def __make_view_title(self, view: dict) -> list[dict]: + raw_title = view["header"]["title"] title: list[dict] = [] if isinstance(raw_title, list): for item in raw_title: @@ -342,9 +378,9 @@ def __make_view_title(self) -> list[dict]: title.append(self.__make_item_label(f"#{raw_title}")) return title - def __make_view_table(self) -> list[list[dict] | None]: + def __make_view_table(self, view: dict) -> list[list[dict] | None]: table: list[list[dict] | None] = [] - for row in self.__view["table"]: + for row in view["table"]: if len(row) == 0: table.append(None) continue @@ -372,7 +408,7 @@ def __make_item_label(self, item: str) -> dict: def __make_item_input(self, parts: list[str]) -> dict: assert len(parts) >= 1 color = (parts[1] if len(parts) > 1 else None) - if color not in ["green", "yellow", "red"]: + if color not in ["green", "yellow", "red", "blue", "cyan", "magenta", "pink", "white"]: color = "green" return { "type": UserGpioModes.INPUT, @@ -383,15 +419,19 @@ def __make_item_input(self, parts: list[str]) -> dict: def __make_item_output(self, parts: list[str]) -> dict: assert len(parts) >= 1 confirm = False + hide = False text = "Click" if len(parts) == 2: text = parts[1] elif len(parts) == 3: - confirm = (parts[1] == "confirm") + opts = parts[1].split(",") + confirm = ("confirm" in opts) + hide = ("hide" in opts) text = parts[2] return { "type": UserGpioModes.OUTPUT, "channel": parts[0], "confirm": confirm, + "hide": hide, "text": text, } diff --git a/kvmd/apps/localhid/__init__.py b/kvmd/apps/localhid/__init__.py new file mode 100644 index 000000000..895610b29 --- /dev/null +++ b/kvmd/apps/localhid/__init__.py @@ -0,0 +1,44 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from ...clients.kvmd import KvmdClient + +from ... import htclient + +from .. import init + +from .server import LocalHidServer + + +# ===== +def main() -> None: + config = init( + prog="kvmd-localhid", + description=" Local HID to KVMD proxy", + check_run=True, + ).config.localhid + + user_agent = htclient.make_user_agent("KVMD-LocalHID") + + LocalHidServer( + kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()), + ).run() diff --git a/kvmd/apps/cleanup/__main__.py b/kvmd/apps/localhid/__main__.py similarity index 96% rename from kvmd/apps/cleanup/__main__.py rename to kvmd/apps/localhid/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/cleanup/__main__.py +++ b/kvmd/apps/localhid/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/localhid/hid.py b/kvmd/apps/localhid/hid.py new file mode 100644 index 000000000..0f977db25 --- /dev/null +++ b/kvmd/apps/localhid/hid.py @@ -0,0 +1,152 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio + +from typing import Final +from typing import Generator + +import evdev +from evdev import ecodes + + +# ===== +class Hid: # pylint: disable=too-many-instance-attributes + KEY: Final[int] = 0 + MOUSE_BUTTON: Final[int] = 1 + MOUSE_REL: Final[int] = 2 + MOUSE_WHEEL: Final[int] = 3 + + def __init__(self, path: str) -> None: + self.__device = evdev.InputDevice(path) + + caps = self.__device.capabilities(absinfo=False) + + syns = caps.get(ecodes.EV_SYN, []) + self.__has_syn = (ecodes.SYN_REPORT in syns) + + leds = caps.get(ecodes.EV_LED, []) + self.__has_caps = (ecodes.LED_CAPSL in leds) + self.__has_scroll = (ecodes.LED_SCROLLL in leds) + self.__has_num = (ecodes.LED_NUML in leds) + + keys = caps.get(ecodes.EV_KEY, []) + self.__has_keyboard = ( + ecodes.KEY_LEFTCTRL in keys + or ecodes.KEY_RIGHTCTRL in keys + or ecodes.KEY_LEFTSHIFT in keys + or ecodes.KEY_RIGHTSHIFT in keys + ) + + rels = caps.get(ecodes.EV_REL, []) + self.__has_mouse_rel = ( + ecodes.BTN_LEFT in keys + and ecodes.REL_X in rels + ) + + self.__grabbed = False + + def is_suitable(self) -> bool: + return (self.__has_keyboard or self.__has_mouse_rel) + + def set_leds(self, caps: bool, scroll: bool, num: bool) -> None: + if self.__grabbed: + if self.__has_caps: + self.__device.set_led(ecodes.LED_CAPSL, caps) + if self.__has_scroll: + self.__device.set_led(ecodes.LED_SCROLLL, scroll) + if self.__has_num: + self.__device.set_led(ecodes.LED_NUML, num) + + def set_grabbed(self, grabbed: bool) -> None: + if self.__grabbed != grabbed: + getattr(self.__device, ("grab" if grabbed else "ungrab"))() + self.__grabbed = grabbed + + def close(self) -> None: + try: + self.__device.close() + except Exception: + pass + + async def poll_to_queue(self, queue: asyncio.Queue[tuple[int, tuple]]) -> None: # pylint: disable=too-many-branches + def put(event: int, args: tuple) -> None: + queue.put_nowait((event, args)) + + move_x = move_y = 0 + wheel_x = wheel_y = 0 + async for event in self.__device.async_read_loop(): + if not self.__grabbed: + # Клавиши перехватываются всегда для обработки хоткеев, + # всё остальное пропускается для экономии ресурсов. + if event.type == ecodes.EV_KEY and event.value != 2 and (event.code in ecodes.KEY): + put(self.KEY, (event.code, bool(event.value))) + continue + + if event.type == ecodes.EV_REL: + match event.code: + case ecodes.REL_X: + move_x += event.value + case ecodes.REL_Y: + move_y += event.value + case ecodes.REL_HWHEEL: + wheel_x += event.value + case ecodes.REL_WHEEL: + wheel_y += event.value + + if not self.__has_syn or event.type == ecodes.SYN_REPORT: + if move_x or move_y: + for xy in self.__splitted_deltas(move_x, move_y): + put(self.MOUSE_REL, xy) + move_x = move_y = 0 + if wheel_x or wheel_y: + for xy in self.__splitted_deltas(wheel_x, wheel_y): + put(self.MOUSE_WHEEL, xy) + wheel_x = wheel_y = 0 + + elif event.type == ecodes.EV_KEY and event.value != 2: + if event.code in ecodes.KEY: + put(self.KEY, (event.code, bool(event.value))) + elif event.code in ecodes.BTN: + put(self.MOUSE_BUTTON, (event.code, bool(event.value))) + + def __splitted_deltas(self, delta_x: int, delta_y: int) -> Generator[tuple[int, int], None, None]: + sign_x = (-1 if delta_x < 0 else 1) + sign_y = (-1 if delta_y < 0 else 1) + delta_x = abs(delta_x) + delta_y = abs(delta_y) + while delta_x > 0 or delta_y > 0: + dx = sign_x * max(min(delta_x, 127), 0) + dy = sign_y * max(min(delta_y, 127), 0) + yield (dx, dy) + delta_x -= 127 + delta_y -= 127 + + def __str__(self) -> str: + info: list[str] = [] + if self.__has_syn: + info.append("syn") + if self.__has_keyboard: + info.append("keyboard") + if self.__has_mouse_rel: + info.append("mouse_rel") + return f"Hid({self.__device.path!r}, {self.__device.name!r}, {self.__device.phys!r}, {', '.join(info)})" diff --git a/kvmd/apps/localhid/multi.py b/kvmd/apps/localhid/multi.py new file mode 100644 index 000000000..18ef4722d --- /dev/null +++ b/kvmd/apps/localhid/multi.py @@ -0,0 +1,178 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import dataclasses +import errno + +from typing import AsyncGenerator + +import pyudev + +from ...logging import get_logger + +from ... import aiotools + +from .hid import Hid + + +# ===== +def _udev_check(device: pyudev.Device) -> str: + props = device.properties + if props.get("ID_INPUT") == "1": + path = props.get("DEVNAME") + if isinstance(path, str) and path.startswith("/dev/input/event"): + return path + return "" + + +async def _follow_udev_hids() -> AsyncGenerator[tuple[bool, str], None]: + ctx = pyudev.Context() + + monitor = pyudev.Monitor.from_netlink(pyudev.Context()) + monitor.filter_by(subsystem="input") + monitor.start() + fd = monitor.fileno() + + read_event = asyncio.Event() + loop = asyncio.get_event_loop() + loop.add_reader(fd, read_event.set) + + try: + for device in ctx.list_devices(subsystem="input"): + path = _udev_check(device) + if path: + yield (True, path) + + while True: + await read_event.wait() + while True: + device = monitor.poll(0) + if device is None: + read_event.clear() + break + path = _udev_check(device) + if path: + if device.action == "add": + yield (True, path) + elif device.action == "remove": + yield (False, path) + finally: + loop.remove_reader(fd) + + +@dataclasses.dataclass +class _Worker: + task: asyncio.Task + hid: (Hid | None) + + +class MultiHid: + def __init__(self, queue: asyncio.Queue[tuple[int, tuple]]) -> None: + self.__queue = queue + self.__workers: dict[str, _Worker] = {} + self.__grabbed = True + self.__leds = (False, False, False) + + async def run(self) -> None: + logger = get_logger(0) + logger.info("Starting UDEV loop ...") + try: + async for (added, path) in _follow_udev_hids(): + if added: + await self.__add_worker(path) + else: + await self.__remove_worker(path) + finally: + logger.info("Cleanup ...") + await aiotools.shield_fg(self.__cleanup()) + + async def __cleanup(self) -> None: + for path in list(self.__workers): + await self.__remove_worker(path) + + async def __add_worker(self, path: str) -> None: + if path in self.__workers: + await self.__remove_worker(path) + self.__workers[path] = _Worker(asyncio.create_task(self.__worker_task_loop(path)), None) + + async def __remove_worker(self, path: str) -> None: + if path not in self.__workers: + return + try: + worker = self.__workers[path] + worker.task.cancel() + await asyncio.gather(worker.task, return_exceptions=True) + except Exception: + pass + finally: + self.__workers.pop(path, None) + + async def __worker_task_loop(self, path: str) -> None: + logger = get_logger(0) + while True: + hid: (Hid | None) = None + try: + hid = Hid(path) + if not hid.is_suitable(): + break + logger.info("Opened: %s", hid) + if self.__grabbed: + hid.set_grabbed(True) + hid.set_leds(*self.__leds) + self.__workers[path].hid = hid + await hid.poll_to_queue(self.__queue) + except Exception as ex: + if isinstance(ex, OSError) and ex.errno == errno.ENODEV: # pylint: disable=no-member + logger.info("Closed: %s", hid) + break + logger.exception("Unhandled exception while polling %s", hid) + await asyncio.sleep(5) + finally: + self.__workers[path].hid = None + if hid: + hid.close() + + def is_grabbed(self) -> bool: + return self.__grabbed + + async def set_grabbed(self, grabbed: bool) -> None: + await asyncio.to_thread(self.__inner_set_grabbed, grabbed) + + def __inner_set_grabbed(self, grabbed: bool) -> None: + if self.__grabbed != grabbed: + get_logger(0).info("Grabbing ..." if grabbed else "Ungrabbing ...") + self.__grabbed = grabbed + for worker in self.__workers.values(): + if worker.hid: + worker.hid.set_grabbed(grabbed) + self.__inner_set_leds(*self.__leds) + + async def set_leds(self, caps: bool, scroll: bool, num: bool) -> None: + await asyncio.to_thread(self.__inner_set_leds, caps, scroll, num) + + def __inner_set_leds(self, caps: bool, scroll: bool, num: bool) -> None: + self.__leds = (caps, scroll, num) + if self.__grabbed: + for worker in self.__workers.values(): + if worker.hid: + worker.hid.set_leds(*self.__leds) diff --git a/kvmd/apps/localhid/server.py b/kvmd/apps/localhid/server.py new file mode 100644 index 000000000..da7196c46 --- /dev/null +++ b/kvmd/apps/localhid/server.py @@ -0,0 +1,192 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import errno + +from typing import Callable +from typing import Coroutine + +import aiohttp +import async_lru + +from evdev import ecodes + +from ...logging import get_logger + +from ... import tools +from ... import aiotools + +from ...keyboard.magic import MagicHandler + +from ...clients.kvmd import KvmdClient +from ...clients.kvmd import KvmdClientSession +from ...clients.kvmd import KvmdClientWs + +from .hid import Hid +from .multi import MultiHid + + +# ===== +class LocalHidServer: # pylint: disable=too-many-instance-attributes + def __init__(self, kvmd: KvmdClient) -> None: + self.__kvmd = kvmd + + self.__kvmd_session: (KvmdClientSession | None) = None + self.__kvmd_ws: (KvmdClientWs | None) = None + + self.__queue: asyncio.Queue[tuple[int, tuple]] = asyncio.Queue() + self.__hid = MultiHid(self.__queue) + + self.__info_switch_units = 0 + self.__info_switch_active = "" + self.__info_mouse_absolute = True + self.__info_mouse_outputs: list[str] = [] + + self.__magic = MagicHandler( + proxy_handler=self.__on_magic_key_proxy, + key_handlers={ + ecodes.KEY_H: self.__on_magic_grab, + ecodes.KEY_K: self.__on_magic_ungrab, + ecodes.KEY_UP: self.__on_magic_switch_prev, + ecodes.KEY_LEFT: self.__on_magic_switch_prev, + ecodes.KEY_DOWN: self.__on_magic_switch_next, + ecodes.KEY_RIGHT: self.__on_magic_switch_next, + }, + numeric_handler=self.__on_magic_switch_port, + ) + + def run(self) -> None: + try: + aiotools.run(self.__inner_run()) + finally: + get_logger(0).info("Bye-bye") + + async def __inner_run(self) -> None: + await aiotools.spawn_and_follow( + self.__create_loop(self.__hid.run), + self.__create_loop(self.__queue_worker), + self.__create_loop(self.__api_worker), + ) + + async def __create_loop(self, func: Callable[[], Coroutine]) -> None: + while True: + try: + await func() + except Exception as ex: + if isinstance(ex, OSError) and ex.errno == errno.ENODEV: # pylint: disable=no-member + pass # Device disconnected + elif isinstance(ex, aiohttp.ClientError): + get_logger(0).error("KVMD client error: %s", tools.efmt(ex)) + else: + get_logger(0).exception("Unhandled exception in the loop: %s", func) + await asyncio.sleep(5) + + async def __queue_worker(self) -> None: + while True: + (event, args) = await self.__queue.get() + if event == Hid.KEY: + await self.__magic.handle_key(*args) + continue + elif self.__hid.is_grabbed() and self.__kvmd_session and self.__kvmd_ws: + match event: + case Hid.MOUSE_BUTTON: + await self.__kvmd_ws.send_mouse_button_event(*args) + case Hid.MOUSE_REL: + await self.__ensure_mouse_relative() + await self.__kvmd_ws.send_mouse_relative_event(*args) + case Hid.MOUSE_WHEEL: + await self.__kvmd_ws.send_mouse_wheel_event(*args) + + async def __api_worker(self) -> None: + logger = get_logger(0) + async with self.__kvmd.make_session() as session: + async with session.ws(stream=False) as ws: + logger.info("KVMD session opened") + self.__kvmd_session = session + self.__kvmd_ws = ws + try: + async for (event_type, event) in ws.communicate(): + if event_type == "hid": + if "leds" in event.get("keyboard", {}): + await self.__hid.set_leds(**event["keyboard"]["leds"]) + if "absolute" in event.get("mouse", {}): + self.__info_mouse_outputs = event["mouse"]["outputs"]["available"] + self.__info_mouse_absolute = event["mouse"]["absolute"] + elif event_type == "switch": + if "model" in event: + self.__info_switch_units = len(event["model"]["units"]) + if "summary" in event: + self.__info_switch_active = event["summary"]["active_id"] + finally: + logger.info("KVMD session closed") + self.__kvmd_session = None + self.__kvmd_ws = None + + # ===== + + async def __ensure_mouse_relative(self) -> None: + if self.__info_mouse_absolute: + # Avoid unnecessary LRU checks, just to speed up a bit + await self.__inner_ensure_mouse_relative() + + @async_lru.alru_cache(maxsize=1, ttl=1) + async def __inner_ensure_mouse_relative(self) -> None: + if self.__kvmd_session and self.__info_mouse_absolute: + for output in ["usb_rel", "ps2"]: + if output in self.__info_mouse_outputs: + await self.__kvmd_session.hid.set_params(mouse_output=output) + + async def __on_magic_key_proxy(self, key: int, state: bool) -> None: + if self.__hid.is_grabbed() and self.__kvmd_ws: + await self.__kvmd_ws.send_key_event(key, state) + + async def __on_magic_grab(self) -> None: + await self.__hid.set_grabbed(True) + + async def __on_magic_ungrab(self) -> None: + await self.__hid.set_grabbed(False) + + async def __on_magic_switch_prev(self) -> None: + if self.__kvmd_session and self.__info_switch_units > 0: + get_logger(0).info("Switching port to the previous one ...") + await self.__kvmd_session.switch.set_active_prev() + + async def __on_magic_switch_next(self) -> None: + if self.__kvmd_session and self.__info_switch_units > 0: + get_logger(0).info("Switching port to the next one ...") + await self.__kvmd_session.switch.set_active_next() + + async def __on_magic_switch_port(self, codes: list[int]) -> bool: + assert len(codes) > 0 + if self.__info_switch_units <= 0: + return True + elif 1 <= self.__info_switch_units <= 2: + port = float(codes[0]) + else: # self.__info_switch_units > 2: + if len(codes) == 1: + return False # Wait for the second key + port = (codes[0] + 1) + (codes[1] + 1) / 10 + if self.__kvmd_session: + get_logger(0).info("Switching port to %s ...", port) + await self.__kvmd_session.switch.set_active(port) + return True diff --git a/kvmd/apps/media/__init__.py b/kvmd/apps/media/__init__.py new file mode 100644 index 000000000..0080cbbd9 --- /dev/null +++ b/kvmd/apps/media/__init__.py @@ -0,0 +1,47 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from ...clients.streamer import StreamerFormats +from ...clients.streamer import MemsinkStreamerClient + +from .. import init + +from .server import MediaServer + + +# ===== +def main() -> None: + config = init( + prog="kvmd-media", + description="The media proxy", + check_run=True, + ).config.media + + def make_streamer(name: str, fmt: int) -> (MemsinkStreamerClient | None): + if getattr(config.memsink, name).sink: + return MemsinkStreamerClient(name.upper(), fmt, **getattr(config.memsink, name)._unpack()) + return None + + MediaServer( + h264_streamer=make_streamer("h264", StreamerFormats.H264), + jpeg_streamer=make_streamer("jpeg", StreamerFormats.JPEG), + ).run(**config.server._unpack()) diff --git a/kvmd/apps/media/__main__.py b/kvmd/apps/media/__main__.py new file mode 100644 index 000000000..ab578e063 --- /dev/null +++ b/kvmd/apps/media/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/media/server.py b/kvmd/apps/media/server.py new file mode 100644 index 000000000..515459057 --- /dev/null +++ b/kvmd/apps/media/server.py @@ -0,0 +1,246 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import dataclasses + +from aiohttp.web import Request +from aiohttp.web import Response +from aiohttp.web import WebSocketResponse + +from ...logging import get_logger + +from ... import tools +from ... import aiotools + +from ...htserver import exposed_http +from ...htserver import exposed_ws +from ...htserver import make_json_response +from ...htserver import WsSession +from ...htserver import HttpServer + +from ...clients.streamer import StreamerError +from ...clients.streamer import StreamerPermError +from ...clients.streamer import StreamerFormats +from ...clients.streamer import BaseStreamerClient + +from ...validators import ValidatorError +from ...validators.basic import valid_stripped_string + + +# ===== +@dataclasses.dataclass +class _Source: + streamer: BaseStreamerClient + meta: dict = dataclasses.field(default_factory=dict) + clients: dict[WsSession, "_Client"] = dataclasses.field(default_factory=dict) + key_required: bool = dataclasses.field(default=False) + + def is_diff(self) -> bool: + return StreamerFormats.is_diff(self.streamer.get_format()) + + +@dataclasses.dataclass +class _Client: + ws: WsSession + src: _Source + sender: asyncio.Task + _queue: asyncio.Queue[dict] = dataclasses.field(default_factory=(lambda: asyncio.Queue(32))) + + async def get_frame(self) -> dict: + return (await self._queue.get()) + + async def put_frame(self, frame: dict) -> bool: # Overflow/wipe flag + try: + self._queue.put_nowait(frame) + except asyncio.QueueFull: + # Если какой-то из клиентов не справляется, очищаем ему очередь и запрашиваем кейфрейм. + # Я вижу у такой логики кучу минусов, хз как себя покажет, но лучше пока ничего не придумал. + for _ in range(self._queue.qsize()): + try: + self._queue.get_nowait() + except asyncio.QueueEmpty: + break + return True + except Exception: + pass + return False + + +class MediaServer(HttpServer): + __EV_MEDIA = "media" + + __T_VIDEO = "video" + + __F_H264 = "h264" + __F_JPEG = "jpeg" + + def __init__( + self, + h264_streamer: (BaseStreamerClient | None), + jpeg_streamer: (BaseStreamerClient | None), + ) -> None: + + super().__init__() + + self.__media: dict[str, dict[str, _Source]] = {self.__T_VIDEO: {}} + if h264_streamer: + self.__media[self.__T_VIDEO][self.__F_H264] = _Source(h264_streamer, {"profile_level_id": "42E01F"}) + if jpeg_streamer: + self.__media[self.__T_VIDEO][self.__F_JPEG] = _Source(jpeg_streamer) + + # ===== + + @exposed_http("GET", "/") + async def __state_handler(self, _: Request) -> Response: + return make_json_response({ + self.__EV_MEDIA: self.__get_media_info(), + }) + + @exposed_http("GET", "/ws") + async def __ws_handler(self, req: Request) -> WebSocketResponse: + v_fmt = valid_stripped_string(req.query.get(self.__T_VIDEO, "")) + if v_fmt not in ["", *self.__media[self.__T_VIDEO]]: + raise ValidatorError("Unsupported video type") + + async with self._ws_session(req, pure=bool(v_fmt)) as ws: + if v_fmt: # Pure request for simplified API without any info + if not self.__start_stream(ws, self.__T_VIDEO, v_fmt): + raise RuntimeError("We shouldn't be here") + else: + await ws.send_event(self.__EV_MEDIA, self.__get_media_info()) + return (await self._ws_loop(ws)) + + @exposed_ws(0) + async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None: + if not ws.kwargs["pure"]: # Don't spoil pure data + await ws.send_bin(255, b"") # Ping-pong + + @exposed_ws(1) + async def __ws_bin_key_handler(self, ws: WsSession, _: bytes) -> None: + for srcs in self.__media.values(): + for src in srcs.values(): + if ws in src.clients: + if src.is_diff(): + src.key_required = True + break + + @exposed_ws("start") + async def __ws_start_handler(self, ws: WsSession, event: dict) -> None: + try: + m_type = str(event.get("type")) + m_fmt = str(event.get("format")) + except Exception: + return + self.__start_stream(ws, m_type, m_fmt) # TODO: Handle discard + + def __get_media_info(self) -> dict: + info: dict = {} + for (m_type, srcs) in self.__media.items(): + info[m_type] = {} + for (m_fmt, src) in srcs.items(): + info[m_type][m_fmt] = src.meta + return info + + def __start_stream(self, ws: WsSession, m_type: str, m_fmt: str) -> bool: + src: (_Source | None) = self.__media.get(m_type, {}).get(m_fmt) + if src is None: + return False + client = _Client(ws, src, None) # type: ignore + client.sender = aiotools.create_deadly_task(str(ws), self.__sender(client)) + src.clients[ws] = client + get_logger(0).info("Streaming %s to %s ...", src.streamer, ws) + return True + + # ===== + + async def _init_app(self) -> None: + logger = get_logger(0) + for srcs in self.__media.values(): + for src in srcs.values(): + logger.info("Starting streamer %s ...", src.streamer) + aiotools.create_deadly_task(str(src.streamer), self.__streamer(src)) + self._add_exposed(self) + + async def _on_shutdown(self) -> None: + logger = get_logger(0) + logger.info("Stopping system tasks ...") + await aiotools.stop_all_deadly_tasks() + logger.info("Disconnecting clients ...") + await self._close_all_wss() + logger.info("On-Shutdown complete") + + def _on_ws_removed(self, ws: WsSession) -> None: + for srcs in self.__media.values(): + for src in srcs.values(): + client = src.clients.pop(ws, None) + if client and client.sender: + get_logger(0).info("Closed stream for %s", ws) + client.sender.cancel() + return + + # ===== + + async def __sender(self, client: _Client) -> None: + need_key = client.src.is_diff() + if need_key: + client.src.key_required = True + + has_key = False + while True: + frame = await client.get_frame() + has_key = (not need_key or has_key or frame["key"]) + if has_key: + try: + if client.ws.kwargs["pure"]: # Simplified interface for a scripting + await client.ws.send_bin_raw(frame["data"]) + else: # Regular interface for the Web UI + await client.ws.send_bin(1, frame["key"].to_bytes() + frame["data"]) + except Exception: + pass + + async def __streamer(self, src: _Source) -> None: + logger = get_logger(0) + while True: + if len(src.clients) == 0: + await asyncio.sleep(1) + continue + + try: + async with src.streamer.reading() as read_frame: + while len(src.clients) > 0: + frame = await read_frame(src.key_required) + if frame["key"]: + src.key_required = False + for client in src.clients.values(): + if (await client.put_frame(frame)): + # Overflowed and cleaned up, need a keyframe + src.key_required = True + + except StreamerError as ex: + if isinstance(ex, StreamerPermError): + logger.exception("Streamer failed: %s", src.streamer) + else: + logger.error("Streamer error: %s: %s", src.streamer, tools.efmt(ex)) + except Exception: + get_logger(0).exception("Unexpected streamer error: %s", src.streamer) + await asyncio.sleep(1) diff --git a/kvmd/apps/nbd/__init__.py b/kvmd/apps/nbd/__init__.py new file mode 100644 index 000000000..5b52009e9 --- /dev/null +++ b/kvmd/apps/nbd/__init__.py @@ -0,0 +1,66 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import signal +import argparse + +from ...logging import get_logger + +from ... import aiotools + +from ...nbd import NbdServer +from ...nbd.types import NbdStopEvent + +from .._logging import init_logging + + +# ===== +async def _async_main(device_path: str, url: str) -> None: + server = NbdServer(device_path) + + async def poller() -> None: + logger = get_logger(0) + async for event in server.poll(): + logger.info("NBD-EVENT: %s", event) + if isinstance(event, NbdStopEvent): + break + + task = asyncio.create_task(poller()) + + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGINT, server.unbind) + loop.add_signal_handler(signal.SIGTERM, server.unbind) + + await server.bind(url) + await task + + +def main() -> None: + init_logging(False) + + parser = argparse.ArgumentParser() + parser.add_argument("-d", "--device", default="/dev/nbd0") + parser.add_argument("-u", "--url", required=True) + opts = parser.parse_args() + + aiotools.run(_async_main(opts.device, opts.url)) diff --git a/kvmd/apps/nbd/__main__.py b/kvmd/apps/nbd/__main__.py new file mode 100644 index 000000000..4827fc498 --- /dev/null +++ b/kvmd/apps/nbd/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/cleanup/__init__.py b/kvmd/apps/ngxmkconf/__init__.py similarity index 52% rename from kvmd/apps/cleanup/__init__.py rename to kvmd/apps/ngxmkconf/__init__.py index c4855efbe..196b69eed 100644 --- a/kvmd/apps/cleanup/__init__.py +++ b/kvmd/apps/ngxmkconf/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,56 +20,50 @@ # ========================================================================== # -import signal -import time +import os +import argparse -import psutil +import mako.template -from ...logging import get_logger - -from ...yamlconf import Section +from ... import network from .. import init # ===== -def _kill_streamer(config: Section) -> None: - logger = get_logger(0) - - if config.streamer.process_name_prefix: - prefix = config.streamer.process_name_prefix + ":" - logger.info("Trying to find and kill the streamer %r ...", prefix + " ") +def main() -> None: + ia = init(add_help=False) + parser = argparse.ArgumentParser( + prog="kvmd-nginx-mkconf", + description="Generate KVMD-Nginx config", + parents=[ia.parser], + ) + parser.add_argument("-p", "--print", action="store_true", help="Print the result to stdout besides the output file") + parser.add_argument("input", help="Input Mako template") + parser.add_argument("output", help="Output Nginx config") + options = parser.parse_args(ia.args) - for proc in psutil.process_iter(): - attrs = proc.as_dict(attrs=["name"]) - if attrs.get("name", "").startswith(prefix): - try: - proc.send_signal(signal.SIGTERM) - except Exception: - logger.exception("Can't send SIGTERM to streamer with pid=%d", proc.pid) - time.sleep(3) - if proc.is_running(): - try: - proc.send_signal(signal.SIGKILL) - except Exception: - logger.exception("Can't send SIGKILL to streamer with pid=%d", proc.pid) + with open(options.input, "r") as in_file: + template = in_file.read() + rendered = mako.template.Template(template).render( + http_ipv4=ia.config.nginx.http.ipv4, + http_ipv6=ia.config.nginx.http.ipv6, + http_port=ia.config.nginx.http.port, + https_enabled=ia.config.nginx.https.enabled, + https_ipv4=ia.config.nginx.https.ipv4, + https_ipv6=ia.config.nginx.https.ipv6, + https_port=ia.config.nginx.https.port, + ipv6_enabled=network.is_ipv6_enabled(), + ) -# ===== -def main(argv: (list[str] | None)=None) -> None: - config = init( - prog="kvmd-cleanup", - description="Kill KVMD and clear resources", - check_run=True, - argv=argv, - )[2].kvmd - - logger = get_logger(0) - logger.info("Cleaning up ...") + if options.print: + print(rendered) try: - _kill_streamer(config) - except Exception: + os.remove(options.output) + except FileNotFoundError: pass - logger.info("Bye-bye") + with open(options.output, "w") as out_file: + out_file.write(rendered) diff --git a/kvmd/apps/ngxmkconf/__main__.py b/kvmd/apps/ngxmkconf/__main__.py new file mode 100644 index 000000000..4827fc498 --- /dev/null +++ b/kvmd/apps/ngxmkconf/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/oled/__init__.py b/kvmd/apps/oled/__init__.py new file mode 100644 index 000000000..0b75dec85 --- /dev/null +++ b/kvmd/apps/oled/__init__.py @@ -0,0 +1,203 @@ +# ========================================================================== # +# # +# KVMD-OLED - A small OLED daemon for PiKVM. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import sys +import os +import signal +import argparse +import time + +from luma.core import cmdline as luma_cmdline + +from PIL import ImageFont + +from ...logging import get_logger + +from ... import htclient + +from ...clients.kvmd import KvmdClient + +from .. import init + +from .screen import Screen +from .sensors import Sensors + + +# ===== +def _get_data_path(subdir: str, name: str) -> str: + if not name.startswith("@"): + return name # Just a regular system path + name = name[1:] + module_path = sys.modules[__name__].__file__ + assert module_path is not None + return os.path.join(os.path.dirname(module_path), subdir, name) + + +async def _run(options: argparse.Namespace) -> None: # pylint: disable=too-many-branches,too-many-statements + logger = get_logger(0) + + device = luma_cmdline.create_device(options) + device.cleanup = (lambda _: None) + screen = Screen( + device=device, + font=ImageFont.truetype(options.font, options.font_size), + font_spacing=options.font_spacing, + offset=(options.offset_x, options.offset_y), + ) + + if options.display not in luma_cmdline.get_display_types()["emulator"]: + logger.info("Iface: %s", options.interface) + logger.info("Display: %s", options.display) + logger.info("Size: %dx%d", device.width, device.height) + + try: + await screen.set_contrast(options.contrast) + + if options.image: + await screen.draw_image(options.image) + await asyncio.sleep(options.interval) + + elif options.text: + await screen.draw_text(options.text.replace("\\n", "\n")) + await asyncio.sleep(options.interval) + + elif options.pipe: + text = "" + for line in sys.stdin: + text += line + if "\0" in text: + await screen.draw_text(text.replace("\0", "")) + text = "" + await asyncio.sleep(options.interval) + + elif options.fill: + await screen.draw_white() + + else: + stop_reason: (str | None) = None + + def sigusr_handler(signum: int, _) -> None: # type: ignore + nonlocal stop_reason + if signum in (signal.SIGINT, signal.SIGTERM): + stop_reason = "" + elif signum == signal.SIGUSR1: + stop_reason = "Rebooting...\nPlease wait" + elif signum == signal.SIGUSR2: + stop_reason = "Halted" + + for signum in [signal.SIGTERM, signal.SIGINT, signal.SIGUSR1, signal.SIGUSR2]: + signal.signal(signum, sigusr_handler) + + async with Sensors( + kvmd=( + KvmdClient( + unix_path=options.kvmd_unix, + timeout=options.kvmd_timeout, + user_agent=htclient.make_user_agent("KVMD-OLED"), + ) if options.kvmd_unix else None + ), + fahrenheit=options.fahrenheit, + ) as sensors: + + await screen.set_swimming(60, 3) + + async def draw_and_sleep(text: str) -> None: + await screen.set_contrast(options.low_contrast if sensors.has_clients() else options.contrast) + await screen.draw_text(sensors.render(text)) + await asyncio.sleep(options.interval) + + if device.height >= 64: + while stop_reason is None: + text = "{fqdn}\n{ip}\niface: {iface}\ntemp: {temp}\ncpu: {cpu} mem: {mem}\n({hb} {clients}) {uptime}" + await draw_and_sleep(text) + else: + summary = True + while stop_reason is None: + if summary: + text = "{fqdn}\n({hb} {clients}) {uptime}\ntemp: {temp}" + else: + text = "{ip}\n({hb}) iface: {iface}\ncpu: {cpu} mem: {mem}" + await draw_and_sleep(text) + summary = bool(time.monotonic() // 6 % 2) + + if stop_reason is not None: + if len(stop_reason) > 0: + options.clear_on_exit = False + await screen.set_swimming(0, 0) + await screen.draw_text(stop_reason) + while len(stop_reason) > 0: + await asyncio.sleep(0.1) + + except (SystemExit, KeyboardInterrupt): + pass + + if options.clear_on_exit: + await screen.draw_text("") + + +# ===== +def main() -> None: + ia = init(add_help=False) + parser = argparse.ArgumentParser( + prog="kvmd-oled", + description="Display some info on PiKVM OLED display", + parents=[ia.parser], + ) + luma_cmdline.create_parser("", parser=parser) + parser.add_argument("--font", default="@ProggySquare.ttf", type=(lambda arg: _get_data_path("fonts", arg)), help="Font path") + parser.add_argument("--font-size", default=16, type=int, help="Font size") + parser.add_argument("--font-spacing", default=2, type=int, help="Font line spacing") + parser.add_argument("--offset-x", default=0, type=int, help="Horizontal offset") + parser.add_argument("--offset-y", default=0, type=int, help="Vertical offset") + parser.add_argument("--interval", default=0.5, type=float, help="Screens interval") + parser.add_argument("--image", default="", type=(lambda arg: _get_data_path("pics", arg)), help="Display some image, wait a single interval and exit") + parser.add_argument("--text", default="", help="Display some text, wait a single interval and exit") + parser.add_argument("--pipe", action="store_true", help="Read and display lines from stdin until EOF, wait a single interval and exit") + parser.add_argument("--fill", action="store_true", help="Fill the display with 0xFF") + parser.add_argument("--clear-on-exit", action="store_true", help="Clear display on exit") + # Compatibility options below + parser.add_argument("--contrast", type=int, help="Set OLED contrast, values from 0 to 255") + parser.add_argument("--low-contrast", type=int, help="Set OLED contrast when device is used") + parser.add_argument("--fahrenheit", action="store_true", help="Display temperature in Fahrenheit instead of Celsius") + # parser.add_argument("--kvmd-unix", help="Ask some info from KVMD like a clients count") + # parser.add_argument("--kvmd-timeout", type=float, help="Timeout for KVMD requests") + parser.set_defaults( + width=ia.config.oled.width, + height=ia.config.oled.height, + rotate=ia.config.oled.rotate, + fahrenheit=ia.config.oled.fahrenheit, + low_contrast=ia.config.oled.contrast.low, + contrast=ia.config.oled.contrast.normal, + kvmd_unix=ia.config.oled.kvmd.unix, + kvmd_timeout=ia.config.oled.kvmd.timeout, + ) + options = parser.parse_args(ia.args) + if options.config: + options = parser.parse_args( + luma_cmdline.load_config(options.config) + + ia.args + ) + options.contrast = min(max(options.contrast, 0), 255) + options.low_contrast = min(max(options.low_contrast, 0), 255) + + asyncio.run(_run(options)) diff --git a/kvmd/apps/oled/__main__.py b/kvmd/apps/oled/__main__.py new file mode 100644 index 000000000..4827fc498 --- /dev/null +++ b/kvmd/apps/oled/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/oled/fonts/ProggySquare.ttf b/kvmd/apps/oled/fonts/ProggySquare.ttf new file mode 100644 index 000000000..9118ece5b Binary files /dev/null and b/kvmd/apps/oled/fonts/ProggySquare.ttf differ diff --git a/kvmd/apps/oled/pics/hello.ppm b/kvmd/apps/oled/pics/hello.ppm new file mode 100644 index 000000000..df97a64a9 Binary files /dev/null and b/kvmd/apps/oled/pics/hello.ppm differ diff --git a/kvmd/apps/oled/pics/pikvm.ppm b/kvmd/apps/oled/pics/pikvm.ppm new file mode 100644 index 000000000..fafaa5114 Binary files /dev/null and b/kvmd/apps/oled/pics/pikvm.ppm differ diff --git a/kvmd/apps/oled/screen.py b/kvmd/apps/oled/screen.py new file mode 100644 index 000000000..cff7edc37 --- /dev/null +++ b/kvmd/apps/oled/screen.py @@ -0,0 +1,91 @@ +# ========================================================================== # +# # +# KVMD-OLED - A small OLED daemon for PiKVM. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import time + +import async_lru + +from luma.core.device import device as luma_device +from luma.core.render import canvas as luma_canvas + +from PIL import Image +from PIL import ImageFont + + +# ===== +class Screen: # pylint: disable=too-many-instance-attributes + def __init__( + self, + device: luma_device, + font: ImageFont.FreeTypeFont, + font_spacing: int, + offset: tuple[int, int], + ) -> None: + + self.__device = device + self.__font = font + self.__font_spacing = font_spacing + self.__offset = offset + + self.__swim_interval = 0.0 + self.__swim_offset_x = 0 + self.__swim_after_ts = time.monotonic() + self.__swim_interval + self.__swim_state = True + + async def set_swimming(self, interval: float, offset_x: int) -> None: + self.__swim_interval = interval + self.__swim_offset_x = offset_x + + @async_lru.alru_cache(maxsize=1) + async def set_contrast(self, contrast: int) -> None: + await asyncio.to_thread(self.__device.contrast, contrast) + + async def draw_text(self, text: str) -> None: + await asyncio.to_thread(self.__inner_draw_text, text) + + async def draw_image(self, image_path: str) -> None: + await asyncio.to_thread(self.__inner_draw_image, image_path) + + async def draw_white(self) -> None: + await asyncio.to_thread(self.__inner_draw_white) + + def __inner_draw_text(self, text: str) -> None: + with luma_canvas(self.__device) as draw: + draw.multiline_text(self.__get_offset(), text, font=self.__font, spacing=self.__font_spacing, fill="white") + + def __inner_draw_image(self, image_path: str) -> None: + with luma_canvas(self.__device) as draw: + draw.bitmap(self.__get_offset(), Image.open(image_path).convert("1"), fill="white") + + def __inner_draw_white(self) -> None: + with luma_canvas(self.__device) as draw: + draw.rectangle((0, 0, self.__device.width, self.__device.height), fill="white") + + def __get_offset(self) -> tuple[int, int]: + if self.__swim_interval >= 0: + now_ts = time.monotonic() + if now_ts >= self.__swim_after_ts: + self.__swim_state = (not self.__swim_state) + self.__swim_after_ts = now_ts + self.__swim_interval + return (self.__offset[0] + (self.__swim_state * self.__swim_offset_x), self.__offset[1]) + return self.__offset diff --git a/kvmd/apps/oled/sensors.py b/kvmd/apps/oled/sensors.py new file mode 100644 index 000000000..2ff2705e9 --- /dev/null +++ b/kvmd/apps/oled/sensors.py @@ -0,0 +1,160 @@ +# ========================================================================== # +# # +# KVMD-OLED - A small OLED daemon for PiKVM. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import socket +import itertools +import types + +from typing import Self + +from ...logging import get_logger + +from ... import tools +from ... import network + +from ...clients.kvmd import KvmdClient + + +# ===== +class Sensors: # pylint: disable=too-many-instance-attributes + def __init__( + self, + kvmd: (KvmdClient | None), + fahrenheit: bool, + ) -> None: + + self.__kvmd = kvmd + self.__fahrenheit = fahrenheit + + self.__fqdn_task: (asyncio.Task | None) = None + self.__iface_task: (asyncio.Task | None) = None + self.__kvmd_task: (asyncio.Task | None) = None + + self.__clients_count = -1 + self.__s_fqdn = "" + self.__s_iface = "" + self.__s_ip = "" + self.__s_uptime = "" + self.__s_temp = "" + self.__s_cpu = "" + self.__s_mem = "" + + hb = itertools.cycle(r"/-\|") + self.__sensors = { + "hb": (lambda: next(hb)), + "fqdn": (lambda: (self.__s_fqdn or "")), + "iface": (lambda: (self.__s_iface or "")), + "ip": (lambda: (self.__s_ip or "")), + "uptime": (lambda: (self.__s_uptime or "?d ?h ?m")), + "temp": (lambda: (self.__s_temp or "?")), + "cpu": (lambda: (self.__s_cpu or "?")), + "mem": (lambda: (self.__s_mem or "?")), + "clients": (lambda: ("?" if self.__clients_count < 0 else str(self.__clients_count))), + } + + def has_clients(self) -> int: + return (self.__clients_count > 0) + + def render(self, text: str) -> str: + return text.format_map(self) + + def __getitem__(self, key: str) -> str: + return self.__sensors[key]() # type: ignore + + async def __aenter__(self) -> Self: + assert self.__fqdn_task is None + self.__fqdn_task = asyncio.create_task(self.__fqdn_task_loop()) + self.__iface_task = asyncio.create_task(self.__iface_task_loop()) + if self.__kvmd: + self.__kvmd_task = asyncio.create_task(self.__kvmd_task_loop()) + return self + + async def __aexit__( + self, + _exc_type: type[BaseException], + _exc: BaseException, + _tb: types.TracebackType, + ) -> None: + + for task in [self.__fqdn_task, self.__iface_task, self.__kvmd_task]: + if task: + task.cancel() + + async def __fqdn_task_loop(self) -> None: + while True: + try: + self.__s_fqdn = socket.gethostname() + except Exception: + self.__s_fqdn = "" + await asyncio.sleep(3) + + async def __iface_task_loop(self) -> None: + while True: + try: + fi = await asyncio.to_thread(network.get_first_iface) + self.__s_iface = fi.name + self.__s_ip = fi.ip + except Exception: + self.__s_iface = "" + self.__s_ip = "" + await asyncio.sleep(3) + + async def __kvmd_task_loop(self) -> None: + logger = get_logger(0) + assert self.__kvmd + ok = True + while True: + try: + async with self.__kvmd.make_session() as session: + async with session.ws(stream=False) as ws: + logger.info("Polling KVMD ...") + async for (event_type, event) in ws.communicate(): + self.__parse_kvmd_event(event_type, event) + ok = True + except Exception as ex: + self.__clients_count = -1 + self.__s_uptime = "" + self.__s_temp = "" + self.__s_cpu = "" + self.__s_mem = "" + if ok: + logger.error("Can't poll KVMD: %s", tools.efmt(ex)) + ok = False + await asyncio.sleep(1) + + def __parse_kvmd_event(self, event_type: str, event: dict) -> None: + if event_type == "clients": + self.__clients_count = int(event["count"]) + + elif event_type == "info": + if "health" in event: + (deg, temp) = ("C", float(event["health"]["temp"]["cpu"])) + if self.__fahrenheit: + (deg, temp) = ("F", temp * 9 / 5 + 32) + self.__s_temp = f"{temp:.1f}\u00b0{deg}" + + self.__s_cpu = f"{event["health"]["cpu"]["percent"]}%" + self.__s_mem = f"{event["health"]["mem"]["percent"]}%" + + if "uptime" in event: + self.__s_uptime = "{days}d {hours}h {minutes}m".format(**event["uptime"]["parts"]) diff --git a/kvmd/apps/otg/__init__.py b/kvmd/apps/otg/__init__.py index 43dd3e5a9..8aeb253db 100644 --- a/kvmd/apps/otg/__init__.py +++ b/kvmd/apps/otg/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -62,7 +62,8 @@ def _symlink(src: str, dest: str) -> None: def _rmdir(path: str) -> None: get_logger().info("RMDIR --- %s", path) - os.rmdir(path) + if os.path.isdir(path): + os.rmdir(path) def _unlink(path: str, optional: bool=False) -> None: @@ -102,31 +103,45 @@ def _check_config(config: Section) -> None: # ===== class _GadgetConfig: - def __init__(self, gadget_path: str, profile_path: str, meta_path: str) -> None: + def __init__(self, gadget_path: str, profile_path: str, meta_path: str, eps: int) -> None: self.__gadget_path = gadget_path self.__profile_path = profile_path self.__meta_path = meta_path + self.__eps_max = eps + self.__eps_used = 0 self.__hid_instance = 0 self.__msd_instance = 0 _mkdir(meta_path) - def add_serial(self, start: bool) -> None: + def add_audio_mic(self, starter: list[str], start: bool) -> None: + eps = 2 + func = "uac2.usb0" + func_path = self.__create_function(func) + _write(join(func_path, "c_chmask"), 0) + _write(join(func_path, "p_chmask"), 0b11) + _write(join(func_path, "p_srate"), 48000) + _write(join(func_path, "p_ssize"), 2) + if start: + self.__start_function(func, eps) + self.__create_meta(func, "Microphone", eps, starter) + + def add_serial(self, starter: list[str], start: bool) -> None: + eps = 3 func = "acm.usb0" - func_path = join(self.__gadget_path, "functions", func) - _mkdir(func_path) + self.__create_function(func) if start: - _symlink(func_path, join(self.__profile_path, func)) - self.__create_meta(func, "Serial Port") + self.__start_function(func, eps) + self.__create_meta(func, "Serial Port", eps, starter) - def add_ethernet(self, start: bool, driver: str, host_mac: str, kvm_mac: str) -> None: + def add_ethernet(self, starter: list[str], start: bool, driver: str, host_mac: str, kvm_mac: str) -> None: + eps = 3 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) + func_path = self.__create_function(func) if host_mac: _write(join(func_path, "host_addr"), host_mac) if kvm_mac: @@ -146,20 +161,20 @@ def add_ethernet(self, start: bool, driver: str, host_mac: str, kvm_mac: str) -> _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") + self.__start_function(func, eps) + self.__create_meta(func, "Ethernet", eps, starter) - def add_keyboard(self, start: bool, remote_wakeup: bool) -> None: - self.__add_hid("Keyboard", start, remote_wakeup, make_keyboard_hid()) + def add_keyboard(self, starter: list[str], start: bool, remote_wakeup: bool) -> None: + self.__add_hid("Keyboard", starter, 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_mouse(self, starter: list[str], start: bool, remote_wakeup: bool, absolute: bool, horizontal_wheel: bool) -> None: + desc = ("Absolute" if absolute else "Relative") + " Mouse" + self.__add_hid(desc, starter, start, remote_wakeup, make_mouse_hid(absolute, horizontal_wheel)) - def __add_hid(self, name: str, start: bool, remote_wakeup: bool, hid: Hid) -> None: + def __add_hid(self, desc: str, starter: list[str], start: bool, remote_wakeup: bool, hid: Hid) -> None: + eps = 1 func = f"hid.usb{self.__hid_instance}" - func_path = join(self.__gadget_path, "functions", func) - _mkdir(func_path) + func_path = self.__create_function(func) _write(join(func_path, "no_out_endpoint"), "1", optional=True) if remote_wakeup: _write(join(func_path, "wakeup_on_write"), "1", optional=True) @@ -168,32 +183,68 @@ def __add_hid(self, name: str, start: bool, remote_wakeup: bool, hid: Hid) -> No _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.__start_function(func, eps) + self.__create_meta(func, desc, eps, starter) self.__hid_instance += 1 - def add_msd(self, start: bool, user: str, stall: bool, cdrom: bool, rw: bool, removable: bool, fua: bool) -> None: + def add_msd( # pylint: disable=too-many-arguments + self, + starter: list[str], + start: bool, + user: str, + stall: bool, + cdrom: bool, + rw: bool, + removable: bool, + fua: bool, + inquiry_string_cdrom: str, + inquiry_string_flash: str, + ) -> None: + + # Endpoints number depends on transport_type but we can consider that this is 2 + # because transport_type is always USB_PR_BULK by default if CONFIG_USB_FILE_STORAGE_TEST + # is not defined. See drivers/usb/gadget/function/storage_common.c + eps = 2 func = f"mass_storage.usb{self.__msd_instance}" - func_path = join(self.__gadget_path, "functions", func) - _mkdir(func_path) + func_path = self.__create_function(func) _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)) + _write(join(func_path, "lun.0/inquiry_string_cdrom"), inquiry_string_cdrom) + _write(join(func_path, "lun.0/inquiry_string"), inquiry_string_flash) 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) 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.__start_function(func, eps) + desc = ("Mass Storage Drive" if self.__msd_instance == 0 else f"Extra Drive #{self.__msd_instance}") + self.__create_meta(func, desc, eps, starter) 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 __create_function(self, func: str) -> str: + func_path = join(self.__gadget_path, "functions", func) + _mkdir(func_path) + return func_path + + def __start_function(self, func: str, eps: int) -> None: + func_path = join(self.__gadget_path, "functions", func) + if self.__eps_max - self.__eps_used >= eps: + _symlink(func_path, join(self.__profile_path, func)) + self.__eps_used += eps + else: + get_logger().info("Will not be started: No available endpoints") + + def __create_meta(self, func: str, desc: str, eps: int, starter: list[str]) -> None: + _write(join(self.__meta_path, f"{func}@meta.json"), json.dumps({ + "function": func, + "description": desc, + "endpoints": eps, + "starter": ["otg", "devices", *starter, "start"], + })) def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,too-many-branches @@ -237,40 +288,68 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements, 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}") + if config.otg.config is not None: + _mkdir(join(profile_path, "strings/0x409")) + _write(join(profile_path, "strings/0x409/configuration"), 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) + gc = _GadgetConfig(gadget_path, profile_path, config.otg.meta, config.otg.endpoints) 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) + gc.add_keyboard(["hid", "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) + ckhm = config.kvmd.hid.mouse + gc.add_mouse(["hid", "mouse"], cod.hid.mouse.start, + config.otg.remote_wakeup, ckhm.absolute, ckhm.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) + gc.add_mouse(["hid", "mouse_alt"], cod.hid.mouse_alt.start, + config.otg.remote_wakeup, (not ckhm.absolute), ckhm.horizontal_wheel) + + def make_inquiry_string(isc: Section) -> str: + kwargs = isc._unpack() + if kwargs["vendor"] is None: + kwargs["vendor"] = config.otg.manufacturer + return usb.make_inquiry_string(**kwargs) if config.kvmd.msd.type == "otg": logger.info("===== MSD =====") - gc.add_msd(cod.msd.start, config.otg.user, **cod.msd.default._unpack()) + gc.add_msd( + starter=["msd"], + start=cod.msd.start, + user=config.otg.user, + inquiry_string_cdrom=make_inquiry_string(cod.msd.default.inquiry_string.cdrom), + inquiry_string_flash=make_inquiry_string(cod.msd.default.inquiry_string.flash), + **cod.msd.default._unpack(ignore="inquiry_string"), + ) 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()) + gc.add_msd( + starter=["drives"], + start=cod.drives.start, + user="root", + inquiry_string_cdrom=make_inquiry_string(cod.drives.default.inquiry_string.cdrom), + inquiry_string_flash=make_inquiry_string(cod.drives.default.inquiry_string.flash), + **cod.drives.default._unpack(ignore="inquiry_string"), + ) + + if cod.ethernet.enabled: + logger.info("===== Ethernet =====") + gc.add_ethernet(["ethernet"], **cod.ethernet._unpack(ignore=["enabled"])) + + if cod.serial.enabled: + logger.info("===== Serial =====") + gc.add_serial(["serial"], cod.serial.start) + + if cod.audio.enabled: + logger.info("===== Microphone =====") + gc.add_audio_mic(["audio"], cod.audio.start) logger.info("===== Preparing complete =====") @@ -321,10 +400,9 @@ def _cmd_stop(config: Section) -> None: # ===== -def main(argv: (list[str] | None)=None) -> None: - (parent_parser, argv, config) = init( +def main() -> None: + ia = init( add_help=False, - argv=argv, load_hid=True, load_atx=True, load_msd=True, @@ -332,7 +410,7 @@ def main(argv: (list[str] | None)=None) -> None: parser = argparse.ArgumentParser( prog="kvmd-otg", description="Control KVMD OTG device", - parents=[parent_parser], + parents=[ia.parser], ) parser.set_defaults(cmd=(lambda *_: parser.print_help())) subparsers = parser.add_subparsers() @@ -343,8 +421,8 @@ def main(argv: (list[str] | None)=None) -> None: cmd_stop_parser = subparsers.add_parser("stop", help="Stop OTG") cmd_stop_parser.set_defaults(cmd=_cmd_stop) - options = parser.parse_args(argv[1:]) + options = parser.parse_args(ia.args) try: - options.cmd(config) - except ValidatorError as err: - raise SystemExit(str(err)) + options.cmd(ia.config) + except ValidatorError as ex: + raise SystemExit(str(ex)) diff --git a/kvmd/apps/otg/__main__.py b/kvmd/apps/otg/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/otg/__main__.py +++ b/kvmd/apps/otg/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/otg/hid/__init__.py b/kvmd/apps/otg/hid/__init__.py index 14e7e9c4c..2be5d2d63 100644 --- a/kvmd/apps/otg/hid/__init__.py +++ b/kvmd/apps/otg/hid/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/otg/hid/keyboard.py b/kvmd/apps/otg/hid/keyboard.py index a49a22a03..e3232afaa 100644 --- a/kvmd/apps/otg/hid/keyboard.py +++ b/kvmd/apps/otg/hid/keyboard.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/otg/hid/mouse.py b/kvmd/apps/otg/hid/mouse.py index 99f538334..2fcb578c2 100644 --- a/kvmd/apps/otg/hid/mouse.py +++ b/kvmd/apps/otg/hid/mouse.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/otgconf/__init__.py b/kvmd/apps/otgconf/__init__.py index 9d672ce89..69f4a8a7a 100644 --- a/kvmd/apps/otgconf/__init__.py +++ b/kvmd/apps/otgconf/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,29 +20,61 @@ # ========================================================================== # +import sys import os import json import contextlib +import dataclasses import argparse import time from typing import Generator -import yaml +import pyudev +import usb.core +import usb.util + +from ... import tools + +from ...yamlconf import ConfigError +from ...yamlconf.dumper import YamlHexInt +from ...yamlconf.dumper import YamlInlinedItemsList +from ...yamlconf.dumper import dump_yaml +from ...yamlconf.merger import yaml_merge from ...validators.basic import valid_stripped_string_not_empty from ... import usb +from ... import env from .. import init +from .. import override_checked # ===== +@dataclasses.dataclass(frozen=True) +class _Function: + name: str + desc: str + eps: int + enabled: bool + starter: list[str] + + class _GadgetControl: - def __init__(self, meta_path: str, gadget: str, udc: str, init_delay: float) -> None: + def __init__( + self, + meta_path: str, + gadget: str, + udc: str, + eps: int, + init_delay: float, + ) -> None: + self.__meta_path = meta_path self.__gadget = gadget self.__udc = udc + self.__eps = eps self.__init_delay = init_delay @contextlib.contextmanager @@ -57,50 +89,90 @@ def __udc_stopped(self) -> Generator[None, None, None]: try: yield finally: + self.__clear_profile(recreate=True) time.sleep(self.__init_delay) with open(udc_path, "w") as file: file.write(udc) - 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 __clear_profile(self, recreate: bool) -> 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) + if recreate: + os.symlink(self.__get_fsrc_path(func), path) + except (FileNotFoundError, FileExistsError): + pass - def enable_function(self, func: str) -> None: - with self.__udc_stopped(): - os.symlink( - usb.get_gadget_path(self.__gadget, usb.G_FUNCTIONS, func), - usb.get_gadget_path(self.__gadget, usb.G_PROFILE, func), - ) + def __read_metas(self) -> Generator[_Function, None, None]: + for name in sorted(os.listdir(self.__meta_path)): + with open(os.path.join(self.__meta_path, name)) as file: + meta = json.loads(file.read()) + enabled = os.path.exists(self.__get_fdest_path(meta["function"])) + yield _Function( + name=meta["function"], + desc=meta["description"], + eps=meta["endpoints"], + enabled=enabled, + starter=meta["starter"], + ) + + def __get_fsrc_path(self, func: str) -> str: + return usb.get_gadget_path(self.__gadget, usb.G_FUNCTIONS, func) - def disable_function(self, func: str) -> None: + 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 change_functions(self, enable: set[str], disable: set[str]) -> None: + funcs = list(self.__read_metas()) + new: set[str] = set(func.name for func in funcs if func.enabled) + new = (new - disable) | enable + eps_req = sum(func.eps for func in funcs if func.name in new) + if eps_req > self.__eps: + raise SystemExit(f"No available endpoints for this config: {eps_req} required, {self.__eps} is maximum") with self.__udc_stopped(): - os.unlink(usb.get_gadget_path(self.__gadget, usb.G_PROFILE, func)) + self.__clear_profile(recreate=False) + for func in new: + try: + os.symlink(self.__get_fsrc_path(func), self.__get_fdest_path(func)) + except FileExistsError: + pass def list_functions(self) -> None: - for meta in self.__read_metas(): - enabled = os.path.exists(usb.get_gadget_path(self.__gadget, usb.G_PROFILE, meta["func"])) - print(f"{'+' if enabled else '-'} {meta['func']} # {meta['name']}") + funcs = list(self.__read_metas()) + eps_used = sum(func.eps for func in funcs if func.enabled) + print(f"# Endpoints used: {eps_used} of {self.__eps}") + print(f"# Endpoints free: {self.__eps - eps_used}") + for func in funcs: + print(f"{'+' if func.enabled else '-'} {func.name}" + f" # [{func.eps}] {func.desc} # {'/'.join(func.starter)}") def make_gpio_config(self) -> None: config = { "drivers": {"otgconf": {"type": "otgconf"}}, "scheme": {}, - "view": {"table": []}, + "view": {"table": YamlInlinedItemsList()}, } - for meta in self.__read_metas(): - config["scheme"][meta["func"]] = { # type: ignore + for func in self.__read_metas(): + config["scheme"][func.name] = { # type: ignore "driver": "otgconf", - "pin": meta["func"], + "pin": func.name, "mode": "output", - "pulse": {"delay": 0}, + "pulse": False, } config["view"]["table"].append([ # type: ignore - "#" + meta["name"], - "#" + meta["func"], - meta["func"], + "#" + func.desc, + "#" + func.name, + func.name, ]) - print(yaml.dump({"kvmd": {"gpio": config}}, indent=4)) + print(dump_yaml({"kvmd": {"gpio": config}}, colored=sys.stdout.isatty())) def reset(self) -> None: with self.__udc_stopped(): @@ -108,37 +180,154 @@ def reset(self) -> None: # ===== -def main(argv: (list[str] | None)=None) -> None: - (parent_parser, argv, config) = init( +@dataclasses.dataclass(frozen=True) +class _Donor: + vendor_id: int + product_id: int + manufacturer: str + product: str + serial: (str | None) + config: (str | None) + device_version: int + + +def _find_inputs() -> set[str]: + found: set[str] = set() + ctx = pyudev.Context() + for device in ctx.list_devices(subsystem="input"): + props = device.properties + if props.get("ID_INPUT") == "1" and props.get("ID_BUS") == "usb": + parent = device.find_parent("usb", "usb_device") + if parent is not None: + path = parent.properties.get("DEVPATH") + if path: + found.add(f"{env.SYSFS_PREFIX}/sys{path}") + return found + + +def _parse_hex(arg: str) -> int: + return int(arg.strip(), 16) + + +def _parse_str(arg: str) -> str: + return arg.strip() + + +def _find_donor() -> (_Donor | None): + for path in _find_inputs(): + kvs: dict = {} + for (key, name, parser, nullable) in [ + ("vendor_id", "idVendor", _parse_hex, False), + ("product_id", "idProduct", _parse_hex, False), + ("manufacturer", "manufacturer", _parse_str, False), + ("product", "product", _parse_str, False), + ("serial", "serial", _parse_str, True), # See _Donor definition + ("config", "configuration", _parse_str, True), + ("device_version", "bcdDevice", _parse_hex, False), + ]: + try: + with open(os.path.join(path, name)) as file: + kvs[key] = parser(file.read()) + except Exception as ex: + if isinstance(ex, FileNotFoundError) and nullable: + kvs[key] = None + else: + kvs = {} + break + if kvs: + return _Donor(**kvs) + return None + + +def _print_donor_info(donor: _Donor) -> None: + print(f"VendorID: 0x{donor.vendor_id:04X}") + print(f"ProductID: 0x{donor.product_id:04X}") + print(f"Manufacturer: {donor.manufacturer}") + print(f"Product: {donor.product}") + if donor.serial is not None: # See _Donor definition + print(f"Serial: {donor.serial}") + if donor.config is not None: + print(f"Config: {donor.config}") + print(f"DeviceVersion: 0x{donor.device_version:04X}") + + +def _make_donor_config(donor: _Donor) -> dict: + inq = { + "vendor": None, + "product": "GENERIC", + "revision": "1.00", + } + config = { + "vendor_id": YamlHexInt(donor.vendor_id), + "product_id": YamlHexInt(donor.product_id), + "manufacturer": donor.manufacturer, + "product": donor.product, + "serial": donor.serial, + "config": donor.config, + "device_version": (donor.device_version or YamlHexInt(donor.device_version)), + "devices": { + "msd": {"default": {"inquiry_string": { + "cdrom": inq, + "flash": inq, + }}}, + "drives": {"default": {"inquiry_string": { + "cdrom": inq, + "flash": inq, + }}}, + }, + } + return {"otg": config} + + +# ===== +def main() -> None: + ia = 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], + parents=[ia.parser], ) parser.add_argument("-l", "--list-functions", action="store_true", help="List functions") - parser.add_argument("-e", "--enable-function", type=valid_stripped_string_not_empty, - metavar="", help="Enable function") - parser.add_argument("-d", "--disable-function", type=valid_stripped_string_not_empty, - metavar="", help="Disable function") + parser.add_argument("-e", "--enable-function", nargs="+", default=[], metavar="", help="Enable function(s)") + parser.add_argument("-d", "--disable-function", nargs="+", default=[], metavar="", help="Disable function(s)") parser.add_argument("-r", "--reset-gadget", action="store_true", help="Reset gadget") + parser.add_argument("--import-usb-ids", action="store_true", + help="Find a local USB HID device and take its IDs and write it to [--usb-ids] file") + parser.add_argument("--override", dest="override_path", default="/etc/kvmd/override.yaml", + help="A place for config generated by [--import-usb-ids]", metavar="file") parser.add_argument("--make-gpio-config", action="store_true") - options = parser.parse_args(argv[1:]) + options = parser.parse_args(ia.args) - gc = _GadgetControl(config.otg.meta, config.otg.gadget, config.otg.udc, config.otg.init_delay) + if options.import_usb_ids: + donor = _find_donor() + if donor is None: + raise SystemExit("Can't find any appropriate USB device connected to PiKVM like keyboard or mouse") + _print_donor_info(donor) + try: + with override_checked(ia.cps) as doc: + yaml_merge(doc, _make_donor_config(donor)) + except ConfigError as ex: + raise SystemExit("\n" + tools.efmt(ex)) + return - if options.list_functions: - gc.list_functions() + gc = _GadgetControl( + meta_path=ia.config.otg.meta, + gadget=ia.config.otg.gadget, + udc=ia.config.otg.udc, + eps=ia.config.otg.endpoints, + init_delay=ia.config.otg.init_delay, + ) - elif options.enable_function: - gc.enable_function(options.enable_function) + if options.list_functions: gc.list_functions() - elif options.disable_function: - gc.disable_function(options.disable_function) + elif options.enable_function or options.disable_function: + enable = set(map(valid_stripped_string_not_empty, options.enable_function)) + disable = set(map(valid_stripped_string_not_empty, options.disable_function)) + gc.change_functions(enable, disable) gc.list_functions() elif options.reset_gadget: diff --git a/kvmd/apps/otgconf/__main__.py b/kvmd/apps/otgconf/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/otgconf/__main__.py +++ b/kvmd/apps/otgconf/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/otgmsd/__init__.py b/kvmd/apps/otgmsd/__init__.py index 33e586990..ce5ca0823 100644 --- a/kvmd/apps/otgmsd/__init__.py +++ b/kvmd/apps/otgmsd/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,13 +20,12 @@ # ========================================================================== # -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 ...validators.os import valid_abs_path from ... import usb @@ -47,45 +46,44 @@ 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 err: - if err.errno == errno.EBUSY: - raise SystemExit(f"Can't change {param!r} value because device is locked: {err}") + 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( +def main() -> None: + ia = 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], + parents=[ia.parser], ) parser.add_argument("-i", "--instance", default=0, type=valid_int_f0, metavar="", 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") + metavar="<1|0|yes|no>", help="Set CD/DVD 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, + parser.add_argument("--set-image", default=None, type=valid_abs_path, metavar="", 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:]) + options = parser.parse_args(ia.args) - if config.kvmd.msd.type != "otg": + if ia.config.kvmd.msd.type != "otg": raise SystemExit(f"Error: KVMD MSD not using 'otg'" - f" (now configured {config.kvmd.msd.type!r})") + f" (now configured {ia.config.kvmd.msd.type!r})") - 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)) + set_param = (lambda param, value: _set_param(ia.config.otg.gadget, options.instance, param, value)) + get_param = (lambda param: _get_param(ia.config.otg.gadget, options.instance, param)) if options.eject: set_param("forced_eject", "") @@ -97,10 +95,10 @@ def main(argv: (list[str] | None)=None) -> 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}") + # 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 "")) - print("CD-ROM flag:", ("yes" if int(get_param("cdrom")) else "no")) + print("CD/DVD flag:", ("yes" if int(get_param("cdrom")) else "no")) print("RW flag: ", ("no" if int(get_param("ro")) else "yes")) diff --git a/kvmd/apps/otgmsd/__main__.py b/kvmd/apps/otgmsd/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/otgmsd/__main__.py +++ b/kvmd/apps/otgmsd/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/otgnet/__init__.py b/kvmd/apps/otgnet/__init__.py index 8ea1c5096..e78024768 100644 --- a/kvmd/apps/otgnet/__init__.py +++ b/kvmd/apps/otgnet/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -45,6 +45,7 @@ from .netctl import IptablesAllowPortCtl from .netctl import IptablesForwardOut from .netctl import IptablesForwardIn +from .netctl import SysctlIpv4ForwardCtl from .netctl import CustomCtl @@ -63,14 +64,16 @@ class _Netcfg: # pylint: disable=too-many-instance-attributes class _Service: # pylint: disable=too-many-instance-attributes def __init__(self, config: Section) -> None: + self.__ip_cmd: list[str] = config.otgnet.commands.ip_cmd + self.__iptables_cmd: list[str] = config.otgnet.commands.iptables_cmd + self.__sysctl_cmd: list[str] = config.otgnet.commands.sysctl_cmd + 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( @@ -115,6 +118,7 @@ async def __run(self, direct: bool) -> None: *([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}"), + *([SysctlIpv4ForwardCtl(self.__sysctl_cmd)] if self.__forward_iface else []), CustomCtl(self.__post_start_cmd, self.__pre_stop_cmd, placeholders), ] if direct: @@ -130,11 +134,13 @@ async def __run(self, direct: bool) -> None: async def __run_ctl(self, ctl: BaseCtl, direct: bool) -> bool: logger = get_logger() cmd = ctl.get_command(direct) + if not cmd: + return True logger.info("CMD: %s", tools.cmdfmt(cmd)) try: return (not (await aioproc.log_process(cmd, logger)).returncode) - except Exception as err: - logger.exception("Can't execute command: %s", err) + except Exception as ex: + logger.exception("Can't execute command: %s", ex) return False # ===== @@ -184,20 +190,17 @@ def __find_iface(self) -> str: # ===== -def main(argv: (list[str] | None)=None) -> None: - (parent_parser, argv, config) = init( - add_help=False, - argv=argv, - ) +def main() -> None: + ia = init(add_help=False) parser = argparse.ArgumentParser( prog="kvmd-otgnet", description="Control KVMD OTG network", - parents=[parent_parser], + parents=[ia.parser], ) parser.set_defaults(cmd=(lambda *_: parser.print_help())) subparsers = parser.add_subparsers() - service = _Service(config) + service = _Service(ia.config) cmd_start_parser = subparsers.add_parser("start", help="Start OTG network") cmd_start_parser.set_defaults(cmd=service.start) @@ -205,5 +208,5 @@ def main(argv: (list[str] | None)=None) -> None: 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 = parser.parse_args(ia.args) options.cmd() diff --git a/kvmd/apps/otgnet/__main__.py b/kvmd/apps/otgnet/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/otgnet/__main__.py +++ b/kvmd/apps/otgnet/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/otgnet/netctl.py b/kvmd/apps/otgnet/netctl.py index cdb4092a4..127dc5ee4 100644 --- a/kvmd/apps/otgnet/netctl.py +++ b/kvmd/apps/otgnet/netctl.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -121,6 +121,16 @@ def get_command(self, direct: bool) -> list[str]: ] +class SysctlIpv4ForwardCtl(BaseCtl): + def __init__(self, base_cmd: list[str]) -> None: + self.__base_cmd = base_cmd + + def get_command(self, direct: bool) -> list[str]: + if direct: + return [*self.__base_cmd, "net.ipv4.ip_forward=1"] + return [] # Don't revert the command because some services can require it too + + class CustomCtl(BaseCtl): def __init__( self, diff --git a/kvmd/apps/override/__init__.py b/kvmd/apps/override/__init__.py new file mode 100644 index 000000000..0c4febb10 --- /dev/null +++ b/kvmd/apps/override/__init__.py @@ -0,0 +1,97 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import json +import argparse + +from typing import Any + +from ... import tools + +from ...yamlconf import ConfigError +from ...yamlconf.merger import yaml_merge + +from .. import init +from .. import override_checked + + +# ===== +def _parse_value(value: str) -> Any: + value = value.strip() + if ( + not value.isdigit() + and value not in ["true", "false", "null"] + and not value.startswith(("{", "[", "\"")) + ): + value = f"\"{value}\"" + return json.loads(value) + + +def _build_raw(options: list[str]) -> dict: + raw: dict = {} + for option in options: + key: str + (key, value) = (option.split("=", 1) + [None])[:2] # type: ignore + if len(key.strip()) == 0: + raise ConfigError(f"Empty option key (required 'key=value' instead of {option!r})") + if value is None: + raise ConfigError(f"No value for key {key!r}") + + path = list(filter(None, map(str.strip, key.split("/")))) + if len(path) == 0: + raise ConfigError("Writing to the root is not supported") + + sub = raw + for key in path[:-1]: + sub.setdefault(key, {}) + sub = sub[key] + sub[path[-1]] = _parse_value(value) + return raw + + +# ===== +def main() -> None: + ia = init( + add_help=False, + cli_logging=True, + ) + parser = argparse.ArgumentParser( + prog="kvmd-override", + description="Writes some override configuration and validates the result", + parents=[ia.parser], + ) + parser.add_argument("-s", "--set", default=[], nargs="+", + help="Validate and write override values (list like sec/sub/opt=value ...)", metavar="") + options = parser.parse_args(ia.args) + + if not options.set: + return + + try: + raw = _build_raw(options.set) + except ConfigError as ex: + raise SystemExit(tools.efmt(ex)) + try: + with override_checked(ia.cps) as doc: + yaml_merge(doc, raw) + except ConfigError as ex: + raise SystemExit(tools.efmt(ex)) diff --git a/kvmd/apps/override/__main__.py b/kvmd/apps/override/__main__.py new file mode 100644 index 000000000..4827fc498 --- /dev/null +++ b/kvmd/apps/override/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/pst/__init__.py b/kvmd/apps/pst/__init__.py index 35930ae74..cb9efb912 100644 --- a/kvmd/apps/pst/__init__.py +++ b/kvmd/apps/pst/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -28,13 +28,12 @@ # ===== -def main(argv: (list[str] | None)=None) -> None: +def main() -> None: config = init( prog="kvmd-pst", description="The KVMD persistent storage manager", - argv=argv, check_run=True, - )[2] + ).config PstServer( **config.pst._unpack(ignore="server"), diff --git a/kvmd/apps/pst/__main__.py b/kvmd/apps/pst/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/pst/__main__.py +++ b/kvmd/apps/pst/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/pst/server.py b/kvmd/apps/pst/server.py index 1a183080b..17470aee6 100644 --- a/kvmd/apps/pst/server.py +++ b/kvmd/apps/pst/server.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -24,6 +24,7 @@ import asyncio from aiohttp.web import Request +from aiohttp.web import Response from aiohttp.web import WebSocketResponse from ...logging import get_logger @@ -35,6 +36,7 @@ from ...htserver import exposed_http from ...htserver import exposed_ws +from ...htserver import make_json_response from ...htserver import WsSession from ...htserver import HttpServer @@ -50,7 +52,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals super().__init__() - self.__data_path = os.path.join(fstab.find_pst().root_path, "data") + self.__data_path = fstab.find_pst().root_path self.__ro_retries_delay = ro_retries_delay self.__ro_cleanup_delay = ro_cleanup_delay self.__remount_cmd = remount_cmd @@ -60,11 +62,21 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals # ===== WEBSOCKET @exposed_http("GET", "/ws") - async def __ws_handler(self, request: Request) -> WebSocketResponse: - async with self._ws_session(request) as ws: + async def __ws_handler(self, req: Request) -> WebSocketResponse: + async with self._ws_session(req) as ws: await ws.send_event("loop", {}) return (await self._ws_loop(ws)) + @exposed_http("GET", "/state") + async def __state_handler(self, _: Request) -> Response: + return make_json_response({ + "clients": len(self._get_wss()), + "data": { + "path": self.__data_path, + "write_allowed": self.__is_write_available(), + }, + }) + @exposed_ws("ping") async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None: await ws.send_event("pong", {}) @@ -92,10 +104,10 @@ async def _on_cleanup(self) -> None: await self.__remount_storage(rw=False) logger.info("On-Cleanup complete") - async def _on_ws_opened(self) -> None: + def _on_ws_added(self, _: WsSession) -> None: self.__notifier.notify() - async def _on_ws_closed(self) -> None: + def _on_ws_removed(self, _: WsSession) -> None: self.__notifier.notify() # ===== SYSTEM TASKS @@ -117,7 +129,7 @@ async def __controller(self) -> None: await self.__notifier.wait() async def __broadcast_storage_state(self, clients: int, write_allowed: bool) -> None: - await self._broadcast_ws_event("storage_state", { + await self._broadcast_ws_event("storage", { "clients": clients, "data": { "path": self.__data_path, @@ -128,9 +140,9 @@ async def __broadcast_storage_state(self, clients: int, write_allowed: bool) -> def __is_write_available(self) -> bool: try: return (not (os.statvfs(self.__data_path).f_flag & os.ST_RDONLY)) - except Exception as err: + except Exception as ex: get_logger(0).info("Can't get filesystem state of PST (%s): %s", - self.__data_path, tools.efmt(err)) + self.__data_path, tools.efmt(ex)) return False async def __remount_storage(self, rw: bool) -> bool: diff --git a/kvmd/apps/pstrun/__init__.py b/kvmd/apps/pstrun/__init__.py index be6a900fd..d2db07313 100644 --- a/kvmd/apps/pstrun/__init__.py +++ b/kvmd/apps/pstrun/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -46,8 +46,8 @@ def _preexec() -> None: if os.isatty(0): try: os.tcsetpgrp(0, os.getpgid(0)) - except Exception as err: - get_logger(0).info("Can't perform tcsetpgrp(0): %s", tools.efmt(err)) + except Exception as ex: + get_logger(0).info("Can't perform tcsetpgrp(0): %s", tools.efmt(ex)) async def _run_process(cmd: list[str], data_path: str) -> asyncio.subprocess.Process: # pylint: disable=no-member @@ -66,25 +66,25 @@ async def _run_process(cmd: list[str], data_path: str) -> asyncio.subprocess.Pro async def _run_cmd_ws(cmd: list[str], ws: aiohttp.ClientWebSocketResponse) -> int: # pylint: disable=too-many-branches logger = get_logger(0) - receive_task: (asyncio.Task | None) = None + recv_task: (asyncio.Task | None) = None proc_task: (asyncio.Task | None) = None proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member try: # pylint: disable=too-many-nested-blocks while True: - if receive_task is None: - receive_task = asyncio.create_task(ws.receive()) + if recv_task is None: + recv_task = asyncio.create_task(ws.receive()) if proc_task is None and proc is not None: proc_task = asyncio.create_task(proc.wait()) - tasks = list(filter(None, [receive_task, proc_task])) + tasks = list(filter(None, [recv_task, proc_task])) done = (await aiotools.wait_first(*tasks))[0] - if receive_task in done: - msg = receive_task.result() + if recv_task in done: + msg = recv_task.result() if msg.type == aiohttp.WSMsgType.TEXT: (event_type, event) = htserver.parse_ws_event(msg.data) - if event_type == "storage_state": + if event_type == "storage": if event["data"]["write_allowed"] and proc is None: logger.info("PST write is allowed: %s", event["data"]["path"]) logger.info("Running the process ...") @@ -98,15 +98,15 @@ async def _run_cmd_ws(cmd: list[str], ws: aiohttp.ClientWebSocketResponse) -> in else: logger.error("Unknown PST message type: %r", msg) break - receive_task = None + recv_task = None if proc_task in done: break except Exception: logger.exception("Unhandled exception") - if receive_task is not None: - receive_task.cancel() + if recv_task is not None: + recv_task.cancel() if proc_task is not None: proc_task.cancel() if proc is not None: @@ -129,17 +129,16 @@ async def _run_cmd(cmd: list[str], unix_path: str) -> None: # ===== -def main(argv: (list[str] | None)=None) -> None: - (parent_parser, argv, config) = init( +def main() -> None: + ia = init( add_help=False, cli_logging=True, - argv=argv, ) parser = argparse.ArgumentParser( prog="kvmd-pstrun", description="Request the access to KVMD persistent storage and run the script", - parents=[parent_parser], + parents=[ia.parser], ) parser.add_argument("cmd", nargs="+", help="Script with arguments to run") - options = parser.parse_args(argv[1:]) - aiotools.run(_run_cmd(options.cmd, config.pst.server.unix)) + options = parser.parse_args(ia.args) + aiotools.run(_run_cmd(options.cmd, ia.config.pst.server.unix)) diff --git a/kvmd/apps/pstrun/__main__.py b/kvmd/apps/pstrun/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/pstrun/__main__.py +++ b/kvmd/apps/pstrun/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/swctl/__init__.py b/kvmd/apps/swctl/__init__.py new file mode 100644 index 000000000..441a0dd5f --- /dev/null +++ b/kvmd/apps/swctl/__init__.py @@ -0,0 +1,167 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os +import argparse +import pprint +import time + +import pyudev + +from ..kvmd.switch.device import Device +from ..kvmd.switch.proto import Edid + + +# ===== +def _find_serial_device() -> str: + ctx = pyudev.Context() + for device in ctx.list_devices(subsystem="tty"): + if ( + str(device.properties.get("ID_VENDOR_ID")).upper() == "2E8A" + and str(device.properties.get("ID_MODEL_ID")).upper() == "1080" + ): + path = device.properties["DEVNAME"] + assert path.startswith("/dev/") + return path + return "" + + +def _wait_boot_device() -> str: + stop_ts = time.time() + 5 + ctx = pyudev.Context() + while time.time() < stop_ts: + for device in ctx.list_devices(subsystem="block", DEVTYPE="partition"): + if ( + str(device.properties.get("ID_VENDOR_ID")).upper() == "2E8A" + and str(device.properties.get("ID_MODEL_ID")).upper() == "0003" + ): + path = device.properties["DEVNAME"] + assert path.startswith("/dev/") + return path + time.sleep(0.2) + return "" + + +def _create_edid(arg: str) -> Edid: + if arg == "@": + return Edid.from_data("Empty", None) + with open(arg) as file: + return Edid.from_data(os.path.basename(arg), file.read()) + + +# ===== +def main() -> None: # pylint: disable=too-many-statements,too-many-branches + parser = argparse.ArgumentParser() + parser.add_argument("-d", "--device", default="") + parser.set_defaults(cmd="") + subs = parser.add_subparsers() + + def add_command(name: str) -> argparse.ArgumentParser: + cmd = subs.add_parser(name) + cmd.set_defaults(cmd=name) + return cmd + + add_command("poll") + + add_command("state") + + cmd = add_command("bootloader") + cmd.add_argument("unit", type=int) + + cmd = add_command("reboot") + cmd.add_argument("unit", type=int) + + cmd = add_command("switch") + cmd.add_argument("unit", type=int) + cmd.add_argument("port", type=int, choices=list(range(5))) + + cmd = add_command("beacon") + cmd.add_argument("unit", type=int) + cmd.add_argument("port", type=int, choices=list(range(6))) + cmd.add_argument("on", choices=["on", "off"]) + + add_command("leds") + + cmd = add_command("click") + cmd.add_argument("button", choices=["power", "reset"]) + cmd.add_argument("unit", type=int) + cmd.add_argument("port", type=int, choices=list(range(4))) + cmd.add_argument("delay_ms", type=int) + + cmd = add_command("set-edid") + cmd.add_argument("unit", type=int) + cmd.add_argument("port", type=int, choices=list(range(4))) + cmd.add_argument("edid", type=_create_edid) + + opts = parser.parse_args() + + if not opts.device: + opts.device = _find_serial_device() + + if opts.cmd == "bootloader" and opts.unit == 0: + if opts.device: + with Device(opts.device) as device: + device.request_reboot(opts.unit, bootloader=True) + found = _wait_boot_device() + if found: + print(found) + raise SystemExit() + raise SystemExit("Error: No switch found") + + if not opts.device: + raise SystemExit("Error: No switch found") + + with Device(opts.device) as device: + wait_rid: (int | None) = None + match opts.cmd: + case "poll": + device.request_state() + device.request_atx_leds() + case "state": + wait_rid = device.request_state() + case "bootloader" | "reboot": + device.request_reboot(opts.unit, (opts.cmd == "bootloader")) + raise SystemExit() + case "switch": + wait_rid = device.request_switch(opts.unit, opts.port) + case "leds": + wait_rid = device.request_atx_leds() + case "click": + match opts.button: + case "power": + wait_rid = device.request_atx_cp(opts.unit, opts.port, opts.delay_ms) + case "reset": + wait_rid = device.request_atx_cr(opts.unit, opts.port, opts.delay_ms) + case "beacon": + wait_rid = device.request_beacon(opts.unit, opts.port, (opts.on == "on")) + case "set-edid": + wait_rid = device.request_set_edid(opts.unit, opts.port, opts.edid) + + error_ts = time.monotonic() + 1 + while True: + for resp in device.read_all(): + pprint.pprint((int(time.time()), resp)) + print() + if resp.header.rid == wait_rid: + raise SystemExit() + if wait_rid is not None and time.monotonic() > error_ts: + raise SystemExit("No answer from unit") diff --git a/kvmd/apps/swctl/__main__.py b/kvmd/apps/swctl/__main__.py new file mode 100644 index 000000000..4827fc498 --- /dev/null +++ b/kvmd/apps/swctl/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/totp/__init__.py b/kvmd/apps/totp/__init__.py index 329a8b522..ba9dbd642 100644 --- a/kvmd/apps/totp/__init__.py +++ b/kvmd/apps/totp/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -59,7 +59,7 @@ def _cmd_show(config: Section, options: argparse.Namespace) -> None: if len(secret) == 0: raise SystemExit("Error: TOTP secret is not configured") uri = pyotp.totp.TOTP(secret).provisioning_uri( - name=(options.name or socket.getfqdn()), + name=(options.name or socket.gethostname()), issuer_name="PiKVM", ) qr = qrcode.QRCode() @@ -77,16 +77,15 @@ def _cmd_delete(config: Section, _: argparse.Namespace) -> None: # ===== -def main(argv: (list[str] | None)=None) -> None: - (parent_parser, argv, config) = init( +def main() -> None: + ia = init( add_help=False, cli_logging=True, - argv=argv, ) parser = argparse.ArgumentParser( prog="kvmd-totp", description="Manage KVMD TOTP secret", - parents=[parent_parser], + parents=[ia.parser], ) parser.set_defaults(cmd=(lambda *_: parser.print_help())) subparsers = parser.add_subparsers() @@ -103,5 +102,5 @@ def main(argv: (list[str] | None)=None) -> None: cmd_delete_parser = subparsers.add_parser("del", help="Remove TOTP secret and disable 2FA auth") cmd_delete_parser.set_defaults(cmd=_cmd_delete) - options = parser.parse_args(argv[1:]) - options.cmd(config, options) + options = parser.parse_args(ia.args) + options.cmd(ia.config, options) diff --git a/kvmd/apps/totp/__main__.py b/kvmd/apps/totp/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/totp/__main__.py +++ b/kvmd/apps/totp/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/apps/vnc/__init__.py b/kvmd/apps/vnc/__init__.py index a5d3c7815..52d657a9a 100644 --- a/kvmd/apps/vnc/__init__.py +++ b/kvmd/apps/vnc/__init__.py @@ -21,7 +21,7 @@ from ...clients.kvmd import KvmdClient -from ...clients.streamer import StreamFormats +from ...clients.streamer import StreamerFormats from ...clients.streamer import BaseStreamerClient from ...clients.streamer import HttpStreamerClient from ...clients.streamer import MemsinkStreamerClient @@ -30,18 +30,16 @@ from .. import init -from .vncauth import VncAuthManager from .server import VncServer # ===== -def main(argv: (list[str] | None)=None) -> None: +def main() -> None: config = init( prog="kvmd-vnc", description="VNC to KVMD proxy", check_run=True, - argv=argv, - )[2].vnc + ).config.vnc user_agent = htclient.make_user_agent("KVMD-VNC") @@ -51,8 +49,8 @@ def make_memsink_streamer(name: str, fmt: int) -> (MemsinkStreamerClient | None) return None streamers: list[BaseStreamerClient] = list(filter(None, [ - make_memsink_streamer("h264", StreamFormats.H264), - make_memsink_streamer("jpeg", StreamFormats.JPEG), + make_memsink_streamer("h264", StreamerFormats.H264), + make_memsink_streamer("jpeg", StreamerFormats.JPEG), HttpStreamerClient(name="JPEG", user_agent=user_agent, **config.streamer._unpack()), ])) @@ -71,11 +69,12 @@ def make_memsink_streamer(name: str, fmt: int) -> (MemsinkStreamerClient | None) desired_fps=config.desired_fps, mouse_output=config.mouse_output, keymap_path=config.keymap, + scroll_rate=config.scroll_rate, kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()), streamers=streamers, - vnc_auth_manager=VncAuthManager(**config.auth.vncauth._unpack()), **config.server.keepalive._unpack(), + **config.auth.vncauth._unpack(), **config.auth.vencrypt._unpack(), ).run() diff --git a/kvmd/apps/vnc/render.py b/kvmd/apps/vnc/render.py index a443e34e9..11265fcdc 100644 --- a/kvmd/apps/vnc/render.py +++ b/kvmd/apps/vnc/render.py @@ -23,18 +23,17 @@ import sys import os import io +import asyncio import functools from PIL import Image as PilImage from PIL import ImageDraw as PilImageDraw from PIL import ImageFont as PilImageFont -from ... import aiotools - # ===== async def make_text_jpeg(width: int, height: int, quality: int, text: str) -> bytes: - return (await aiotools.run_async(_inner_make_text_jpeg, width, height, quality, text)) + return (await asyncio.to_thread(_inner_make_text_jpeg, width, height, quality, text)) @functools.lru_cache(maxsize=10) diff --git a/kvmd/apps/vnc/rfb/__init__.py b/kvmd/apps/vnc/rfb/__init__.py index d6cdf6d82..8d6288b5d 100644 --- a/kvmd/apps/vnc/rfb/__init__.py +++ b/kvmd/apps/vnc/rfb/__init__.py @@ -27,11 +27,17 @@ from typing import Coroutine from typing import AsyncGenerator +from evdev import ecodes + from ....logging import get_logger from .... import tools from .... import aiotools +from ....keyboard.keysym import SymmapModifiers +from ....keyboard.mappings import EvdevModifiers +from ....keyboard.mappings import X11Modifiers +from ....keyboard.mappings import AT1_TO_EVDEV from ....mouse import MouseRange from .errors import RfbError @@ -46,6 +52,11 @@ from .stream import RfbClientStream +# ===== +class _SecurityError(Exception): + pass + + # ===== class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attributes # https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst @@ -64,7 +75,9 @@ def __init__( # pylint: disable=too-many-arguments width: int, height: int, name: str, - vnc_passwds: list[str], + scroll_rate: int, + + vncpasses: set[str], vencrypt: bool, none_auth_only: bool, ) -> None: @@ -79,10 +92,14 @@ def __init__( # pylint: disable=too-many-arguments self._width = width self._height = height self.__name = name - self.__vnc_passwds = vnc_passwds + self.__scroll_rate = scroll_rate + + self.__vncpasses = vncpasses self.__vencrypt = vencrypt self.__none_auth_only = none_auth_only + self.__symmap: (dict[int, dict[int, int]] | None) = None + self.__rfb_version = 0 self._encodings = RfbClientEncodings(frozenset()) @@ -90,8 +107,16 @@ def __init__( # pylint: disable=too-many-arguments self.__fb_cont_updates = False self.__fb_reset_h264 = False + self.__authorized = False + self.__lock = asyncio.Lock() + # Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события. + # Все это нужно только чтобы не посылать лишние события в сокет KVMD + self.__mods = 0 + self.__mouse_buttons: dict[int, bool] = {} + self.__mouse_move = (-1, -1, -1, -1) # (width, height, X, Y) + # ===== async def _run(self, **coros: Coroutine) -> None: @@ -120,16 +145,18 @@ async def __wrapper(self, name: str, coro: Coroutine) -> None: except asyncio.CancelledError: logger.info("%s [%s]: Cancelling subtask ...", self._remote, name) raise - except RfbConnectionError as err: - logger.info("%s [%s]: Gone: %s", self._remote, name, err) - except (RfbError, ssl.SSLError) as err: - logger.error("%s [%s]: Error: %s", self._remote, name, err) + except RfbConnectionError as ex: + logger.info("%s [%s]: Gone: %s", self._remote, name, ex) + except (RfbError, ssl.SSLError) as ex: + logger.error("%s [%s]: Error: %s", self._remote, name, ex) except Exception: logger.exception("%s [%s]: Unhandled exception", self._remote, name) async def __main_task_loop(self) -> None: await self.__handshake_version() await self.__handshake_security() + if not self.__authorized: + raise _SecurityError() await self.__handshake_init() await self.__main_loop() @@ -138,21 +165,24 @@ async def __main_task_loop(self) -> None: async def _authorize_userpass(self, user: str, passwd: str) -> bool: raise NotImplementedError - async def _on_authorized_vnc_passwd(self, passwd: str) -> str: + async def _on_authorized_vncpass(self) -> None: raise NotImplementedError - async def _on_authorized_none(self) -> bool: + async def _authorize_none(self) -> bool: raise NotImplementedError # ===== - async def _on_key_event(self, code: int, state: bool) -> None: + async def _on_key_event(self, key: int, state: bool) -> None: + raise NotImplementedError + + async def _on_mouse_button_event(self, button: int, state: bool) -> None: raise NotImplementedError - async def _on_ext_key_event(self, code: int, state: bool) -> None: + async def _on_mouse_move_event(self, to_x: int, to_y: int) -> None: raise NotImplementedError - async def _on_pointer_event(self, buttons: dict[str, bool], wheel: dict[str, int], move: dict[str, int]) -> None: + async def _on_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: raise NotImplementedError async def _on_cut_event(self, text: str) -> None: @@ -163,6 +193,16 @@ async def _on_set_encodings(self) -> None: # ===== + async def _load_symmap_cache(self) -> dict[int, dict[int, int]]: + raise NotImplementedError + + async def __get_symmap(self) -> dict[int, dict[int, int]]: + if self.__symmap is None: + self.__symmap = await self._load_symmap_cache() + return self.__symmap + + # ===== + async def _send_fb_allowed(self) -> AsyncGenerator[None, None]: while True: await self.__fb_notifier.wait() @@ -230,18 +270,18 @@ async def __handshake_version(self) -> None: await self._write_struct("handshake server version", "", b"RFB 003.008\n") - response = await self._read_text("handshake client version", 12) + resp = await self._read_text("handshake client version", 12) if ( - not response.startswith("RFB 003.00") - or not response.endswith("\n") - or response[-2] not in ["3", "5", "7", "8"] + not resp.startswith("RFB 003.00") + or not resp.endswith("\n") + or resp[-2] not in ["3", "5", "7", "8"] ): - raise RfbError(f"Invalid version response: {response!r}") + raise RfbError(f"Invalid version response: {resp!r}") try: - version = int(response[-2]) + version = int(resp[-2]) except ValueError: - raise RfbError(f"Invalid version response: {response!r}") + raise RfbError(f"Invalid version response: {resp!r}") self.__rfb_version = (3 if version == 5 else version) get_logger(0).info("%s [main]: Using RFB version 3.%d", self._remote, self.__rfb_version) @@ -253,19 +293,23 @@ async def __handshake_security(self) -> None: sec_types[19] = ("VeNCrypt", self.__handshake_security_vencrypt) if self.__none_auth_only: sec_types[1] = ("None", self.__handshake_security_none) - elif self.__vnc_passwds: + elif self.__vncpasses: sec_types[2] = ("VNCAuth", self.__handshake_security_vnc_auth) + if not sec_types: msg = "The client uses a very old protocol 3.3 and VNCAuth or NoneAuth is disabled" await self._write_struct("refusing security type flag", "L", 0, drain=False) await self._write_reason("refusing security type reason", msg) raise RfbError(msg) - await self._write_struct("security types", "B" + "B" * len(sec_types), len(sec_types), *sec_types) # Keep dict priority - - sec_type = await self._read_number("selected security type", "B") - if sec_type not in sec_types: - raise RfbError(f"Invalid security type: {sec_type}") + if self.__rfb_version > 3: + await self._write_struct("security types", "B" + "B" * len(sec_types), len(sec_types), *sec_types) # Keep dict priority + sec_type = await self._read_number("selected security type", "B") + if sec_type not in sec_types: + raise RfbError(f"Invalid security type: {sec_type}") + else: + sec_type = list(sec_types.keys())[0] # First auth type from the list, None or VNCAuth + await self._write_struct("selected security type", "L", sec_type) (sec_name, handler) = sec_types[sec_type] get_logger(0).info("%s [main]: Using %s security type", self._remote, sec_name) @@ -295,7 +339,7 @@ async def __handshake_security_vencrypt(self) -> None: # pylint: disable=too-ma if self.__x509_cert_path: auth_types[262] = ("VeNCrypt/X509Plain", 2, self.__handshake_security_vencrypt_userpass) auth_types[259] = ("VeNCrypt/TLSPlain", 1, self.__handshake_security_vencrypt_userpass) - if self.__vnc_passwds: + if self.__vncpasses: # Некоторые клиенты не умеют работать с нешифрованными соединениями внутри VeNCrypt: # - https://github.com/LibVNC/libvncserver/issues/458 # - https://bugzilla.redhat.com/show_bug.cgi?id=692048 @@ -345,7 +389,7 @@ async def __handshake_security_vencrypt_userpass(self) -> None: ) async def __handshake_security_none(self) -> None: - allow = await self._on_authorized_none() + allow = await self._authorize_none() await self.__handshake_security_send_result( allow=allow, allow_msg="NoneAuth access granted", @@ -357,20 +401,19 @@ async def __handshake_security_vnc_auth(self) -> None: challenge = rfb_make_challenge() await self._write_struct("VNCAuth challenge request", "", challenge) - user = "" + allow = False response = (await self._read_struct("VNCAuth challenge response", "16s"))[0] - for passwd in self.__vnc_passwds: + for passwd in self.__vncpasses: passwd_bytes = passwd.encode("utf-8", errors="ignore") if rfb_encrypt_challenge(challenge, passwd_bytes) == response: - user = await self._on_authorized_vnc_passwd(passwd) - if user: - assert user == user.strip() + await self._on_authorized_vncpass() + allow = True break await self.__handshake_security_send_result( - allow=bool(user), - allow_msg=f"VNCAuth access granted for user {user!r}", - deny_msg="VNCAuth access denied (user not found)", + allow=allow, + allow_msg="VNCAuth access granted", + deny_msg="VNCAuth access denied (passwd not found)", deny_reason="Invalid password", ) @@ -378,6 +421,7 @@ async def __handshake_security_send_result(self, allow: bool, allow_msg: str, de if allow: get_logger(0).info("%s [main]: %s", self._remote, allow_msg) await self._write_struct("access OK", "L", 0) + self.__authorized = True else: await self._write_struct("access denial flag", "L", 1, drain=(self.__rfb_version < 8)) if self.__rfb_version >= 8: @@ -387,6 +431,9 @@ async def __handshake_security_send_result(self, allow: bool, allow_msg: str, de # ===== async def __handshake_init(self) -> None: + if not self.__authorized: + raise _SecurityError() + await self._read_number("initial shared flag", "B") # Shared flag, ignored await self._write_struct("initial FB size", "HH", self._width, self._height, drain=False) @@ -410,6 +457,8 @@ async def __handshake_init(self) -> None: # ===== async def __main_loop(self) -> None: + if not self.__authorized: + raise _SecurityError() handlers = { 0: self.__handle_set_pixel_format, 2: self.__handle_set_encodings, @@ -454,6 +503,12 @@ async def __handle_set_encodings(self) -> None: if self._encodings.has_ext_keys: # Preferred method await self._write_fb_update("ExtKeys FBUR", 0, 0, RfbEncodings.EXT_KEYS, drain=True) + else: # Load symmap for regular key events + await self.__get_symmap() + + if self._encodings.has_ext_mouse: # Preferred too + await self._write_fb_update("ExtMouse FBUR", 0, 0, RfbEncodings.EXT_MOUSE, drain=True) + await self._on_set_encodings() async def __handle_fb_update_request(self) -> None: @@ -472,25 +527,96 @@ def __check_encodings(self) -> None: async def __handle_key_event(self) -> None: (state, code) = await self._read_struct("key event", "? xx L") - await self._on_key_event(code, state) # type: ignore + state = bool(state) + + is_mod = self.__switch_modifiers_x11(code, state) + variants = (await self.__get_symmap()).get(code) + fake_shift = False + + if variants: + if is_mod: + key = variants.get(0) + else: + key = variants.get(self.__mods) + if key is None: + key = variants.get(0) + + if key is None and self.__mods == 0 and SymmapModifiers.SHIFT in variants: + # JUMP doesn't send shift events: + # - https://github.com/pikvm/pikvm/issues/820 + key = variants[SymmapModifiers.SHIFT] + fake_shift = True + + if key: + if fake_shift: + await self._on_key_event(EvdevModifiers.SHIFT_LEFT, True) + await self._on_key_event(key, state) + if fake_shift: + await self._on_key_event(EvdevModifiers.SHIFT_LEFT, False) + + def __switch_modifiers_x11(self, code: int, state: bool) -> bool: + mod = 0 + if code in X11Modifiers.SHIFTS: + mod = SymmapModifiers.SHIFT + elif code == X11Modifiers.ALTGR: + mod = SymmapModifiers.ALTGR + elif code in X11Modifiers.CTRLS: + mod = SymmapModifiers.CTRL + if mod == 0: + return False + if state: + self.__mods |= mod + else: + self.__mods &= ~mod + return True + + def __switch_modifiers_evdev(self, key: int, state: bool) -> bool: + mod = 0 + if key in EvdevModifiers.SHIFTS: + mod = SymmapModifiers.SHIFT + elif key == EvdevModifiers.ALT_RIGHT: + mod = SymmapModifiers.ALTGR + elif key in EvdevModifiers.CTRLS: + mod = SymmapModifiers.CTRL + if mod == 0: + return False + if state: + self.__mods |= mod + else: + self.__mods &= ~mod + return True async def __handle_pointer_event(self) -> None: (buttons, to_x, to_y) = await self._read_struct("pointer event", "B HH") - await self._on_pointer_event( - buttons={ - "left": bool(buttons & 0x1), - "right": bool(buttons & 0x4), - "middle": bool(buttons & 0x2), - }, - wheel={ - "x": (-4 if buttons & 0x40 else (4 if buttons & 0x20 else 0)), - "y": (-4 if buttons & 0x10 else (4 if buttons & 0x8 else 0)), - }, - move={ - "x": tools.remap(to_x, 0, self._width, *MouseRange.RANGE), - "y": tools.remap(to_y, 0, self._height, *MouseRange.RANGE), - }, - ) + ext_buttons = 0 + if self._encodings.has_ext_mouse and (buttons & 0x80): # Marker bit 7 for ext event + ext_buttons = await self._read_number("ext pointer event buttons", "B") + + if buttons & (0x40 | 0x20 | 0x10 | 0x08): + sr = self.__scroll_rate + await self._on_mouse_wheel_event( + (-sr if buttons & 0x40 else (sr if buttons & 0x20 else 0)), + (-sr if buttons & 0x10 else (sr if buttons & 0x08 else 0)), + ) + + move = (self._width, self._height, to_x, to_y) + if self.__mouse_move != move: + await self._on_mouse_move_event( + tools.remap(to_x, 0, self._width - 1, *MouseRange.RANGE), + tools.remap(to_y, 0, self._height - 1, *MouseRange.RANGE), + ) + self.__mouse_move = move + + for (code, state) in [ + (ecodes.BTN_LEFT, bool(buttons & 0x1)), + (ecodes.BTN_RIGHT, bool(buttons & 0x4)), + (ecodes.BTN_MIDDLE, bool(buttons & 0x2)), + (ecodes.BTN_BACK, bool(ext_buttons & 0x2)), + (ecodes.BTN_FORWARD, bool(ext_buttons & 0x1)), + ]: + if self.__mouse_buttons.get(code) != state: + await self._on_mouse_button_event(code, state) + self.__mouse_buttons[code] = state async def __handle_client_cut_text(self) -> None: length = (await self._read_struct("cut text length", "xxx L"))[0] @@ -508,6 +634,7 @@ async def __handle_enable_cont_updates(self) -> None: async def __handle_qemu_event(self) -> None: (sub_type, state, code) = await self._read_struct("QEMU event (key?)", "B H xxxx L") + state = bool(state) if sub_type != 0: raise RfbError(f"Invalid QEMU sub-message type: {sub_type}") if code == 0xB7: @@ -515,4 +642,7 @@ async def __handle_qemu_event(self) -> None: code = 0x54 if code & 0x80: code = (0xE0 << 8) | (code & ~0x80) - await self._on_ext_key_event(code, bool(state)) + key = AT1_TO_EVDEV.get(code, 0) + if key: + self.__switch_modifiers_evdev(key, state) # Предполагаем, что модификаторы всегда известны + await self._on_key_event(key, state) diff --git a/kvmd/apps/vnc/rfb/encodings.py b/kvmd/apps/vnc/rfb/encodings.py index 597e3a922..940a383f4 100644 --- a/kvmd/apps/vnc/rfb/encodings.py +++ b/kvmd/apps/vnc/rfb/encodings.py @@ -31,6 +31,7 @@ class RfbEncodings: RENAME = -307 # DesktopName Pseudo-encoding LEDS_STATE = -261 # QEMU LED State Pseudo-encoding EXT_KEYS = -258 # QEMU Extended Key Events Pseudo-encoding + EXT_MOUSE = -316 # ExtendedMouseButtons Pseudo-encoding CONT_UPDATES = -313 # ContinuousUpdates Pseudo-encoding TIGHT = 7 @@ -50,16 +51,17 @@ def _make_meta(variants: (int | frozenset[int])) -> dict: class RfbClientEncodings: # pylint: disable=too-many-instance-attributes encodings: frozenset[int] - has_resize: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RESIZE)) # noqa: E224 - has_rename: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RENAME)) # noqa: E224 - has_leds_state: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.LEDS_STATE)) # noqa: E224 - has_ext_keys: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_KEYS)) # noqa: E224 - has_cont_updates: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.CONT_UPDATES)) # noqa: E224 + has_resize: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RESIZE)) # noqa: E224 + has_rename: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RENAME)) # noqa: E224 + has_leds_state: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.LEDS_STATE)) # noqa: E224 + has_ext_keys: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_KEYS)) # noqa: E224 + has_ext_mouse: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_MOUSE)) # noqa: E224 + has_cont_updates: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.CONT_UPDATES)) # noqa: E224 - has_tight: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.TIGHT)) # noqa: E224 - tight_jpeg_quality: int = dataclasses.field(default=0, metadata=_make_meta(frozenset(RfbEncodings.TIGHT_JPEG_QUALITIES))) # noqa: E224 + has_tight: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.TIGHT)) # noqa: E224 + tight_jpeg_quality: int = dataclasses.field(default=0, metadata=_make_meta(frozenset(RfbEncodings.TIGHT_JPEG_QUALITIES))) # noqa: E224 - has_h264: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.H264)) # noqa: E224 + has_h264: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.H264)) # noqa: E224 def get_summary(self) -> list[str]: summary: list[str] = [f"encodings -- {sorted(self.encodings)}"] diff --git a/kvmd/apps/vnc/rfb/errors.py b/kvmd/apps/vnc/rfb/errors.py index 1cf688187..caa4d0853 100644 --- a/kvmd/apps/vnc/rfb/errors.py +++ b/kvmd/apps/vnc/rfb/errors.py @@ -29,5 +29,5 @@ class RfbError(Exception): class RfbConnectionError(RfbError): - def __init__(self, msg: str, err: Exception) -> None: - super().__init__(f"{msg}: {tools.efmt(err)}") + def __init__(self, msg: str, ex: Exception) -> None: + super().__init__(f"{msg}: {tools.efmt(ex)}") diff --git a/kvmd/apps/vnc/rfb/stream.py b/kvmd/apps/vnc/rfb/stream.py index dfa6cb138..49f53f3c2 100644 --- a/kvmd/apps/vnc/rfb/stream.py +++ b/kvmd/apps/vnc/rfb/stream.py @@ -51,22 +51,22 @@ async def _read_number(self, msg: str, fmt: str) -> int: else: fmt = f">{fmt}" return struct.unpack(fmt, await self.__reader.readexactly(struct.calcsize(fmt)))[0] - except (ConnectionError, asyncio.IncompleteReadError) as err: - raise RfbConnectionError(f"Can't read {msg}", err) + except (ConnectionError, asyncio.IncompleteReadError) as ex: + raise RfbConnectionError(f"Can't read {msg}", ex) async def _read_struct(self, msg: str, fmt: str) -> tuple[int, ...]: assert len(fmt) > 1 try: fmt = f">{fmt}" return struct.unpack(fmt, (await self.__reader.readexactly(struct.calcsize(fmt)))) - except (ConnectionError, asyncio.IncompleteReadError) as err: - raise RfbConnectionError(f"Can't read {msg}", err) + except (ConnectionError, asyncio.IncompleteReadError) as ex: + raise RfbConnectionError(f"Can't read {msg}", ex) async def _read_text(self, msg: str, length: int) -> str: try: return (await self.__reader.readexactly(length)).decode("utf-8", errors="ignore") - except (ConnectionError, asyncio.IncompleteReadError) as err: - raise RfbConnectionError(f"Can't read {msg}", err) + except (ConnectionError, asyncio.IncompleteReadError) as ex: + raise RfbConnectionError(f"Can't read {msg}", ex) # ===== @@ -84,8 +84,8 @@ async def _write_struct(self, msg: str, fmt: str, *values: (int | bytes), drain: self.__writer.write(struct.pack(f">{fmt}", *values)) if drain: await self.__writer.drain() - except ConnectionError as err: - raise RfbConnectionError(f"Can't write {msg}", err) + except ConnectionError as ex: + raise RfbConnectionError(f"Can't write {msg}", ex) async def _write_reason(self, msg: str, text: str, drain: bool=True) -> None: encoded = text.encode("utf-8", errors="ignore") @@ -94,8 +94,8 @@ async def _write_reason(self, msg: str, text: str, drain: bool=True) -> None: self.__writer.write(encoded) if drain: await self.__writer.drain() - except ConnectionError as err: - raise RfbConnectionError(f"Can't write {msg}", err) + except ConnectionError as ex: + raise RfbConnectionError(f"Can't write {msg}", ex) async def _write_fb_update(self, msg: str, width: int, height: int, encoding: int, drain: bool=True) -> None: await self._write_struct( @@ -110,32 +110,13 @@ async def _write_fb_update(self, msg: str, width: int, height: int, encoding: in # ===== async def _start_tls(self, ssl_context: ssl.SSLContext, ssl_timeout: float) -> None: - loop = asyncio.get_event_loop() - - ssl_reader = asyncio.StreamReader() - protocol = asyncio.StreamReaderProtocol(ssl_reader) - try: - transport = await loop.start_tls( - self.__writer.transport, - protocol, + await self.__writer.start_tls( ssl_context, - server_side=True, ssl_handshake_timeout=ssl_timeout, ) - except ConnectionError as err: - raise RfbConnectionError("Can't start TLS", err) - - ssl_reader.set_transport(transport) - ssl_writer = asyncio.StreamWriter( - transport=transport, # type: ignore - protocol=protocol, - reader=ssl_reader, - loop=loop, - ) - - self.__reader = ssl_reader - self.__writer = ssl_writer + except ConnectionError as ex: + raise RfbConnectionError("Can't start TLS", ex) async def _close(self) -> None: await aiotools.close_writer(self.__writer) diff --git a/kvmd/apps/vnc/server.py b/kvmd/apps/vnc/server.py index f1203519a..8fef5462f 100644 --- a/kvmd/apps/vnc/server.py +++ b/kvmd/apps/vnc/server.py @@ -27,14 +27,14 @@ import contextlib import aiohttp +import async_lru + +from evdev import ecodes from ...logging import get_logger -from ...keyboard.keysym import SymmapModifiers from ...keyboard.keysym import build_symmap -from ...keyboard.mappings import WebModifiers -from ...keyboard.mappings import X11Modifiers -from ...keyboard.mappings import AT1_TO_WEB +from ...keyboard.magic import MagicHandler from ...clients.kvmd import KvmdClientWs from ...clients.kvmd import KvmdClientSession @@ -42,19 +42,17 @@ from ...clients.streamer import StreamerError from ...clients.streamer import StreamerPermError -from ...clients.streamer import StreamFormats +from ...clients.streamer import StreamerFormats from ...clients.streamer import BaseStreamerClient from ... import tools from ... import aiotools +from ... import network from .rfb import RfbClient from .rfb.stream import rfb_format_remote from .rfb.errors import RfbError -from .vncauth import VncAuthKvmdCredentials -from .vncauth import VncAuthManager - from .render import make_text_jpeg @@ -78,28 +76,29 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals desired_fps: int, mouse_output: str, - keymap_name: str, - symmap: dict[int, dict[int, str]], + keymap_path: str, + scroll_rate: int, kvmd: KvmdClient, streamers: list[BaseStreamerClient], - vnc_credentials: dict[str, VncAuthKvmdCredentials], + vncpasses: set[str], vencrypt: bool, none_auth_only: bool, + shared_params: _SharedParams, ) -> None: - self.__vnc_credentials = vnc_credentials - - super().__init__( + RfbClient.__init__( + self, reader=reader, writer=writer, tls_ciphers=tls_ciphers, tls_timeout=tls_timeout, x509_cert_path=x509_cert_path, x509_key_path=x509_key_path, - vnc_passwds=list(vnc_credentials), + scroll_rate=scroll_rate, + vncpasses=vncpasses, vencrypt=vencrypt, none_auth_only=none_auth_only, **dataclasses.asdict(shared_params), @@ -107,8 +106,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals self.__desired_fps = desired_fps self.__mouse_output = mouse_output - self.__keymap_name = keymap_name - self.__symmap = symmap + self.__keymap_path = keymap_path self.__kvmd = kvmd self.__streamers = streamers @@ -122,15 +120,26 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals self.__kvmd_session: (KvmdClientSession | None) = None self.__kvmd_ws: (KvmdClientWs | None) = None - self.__fb_queue: "asyncio.Queue[dict]" = asyncio.Queue() + self.__fb_q: "asyncio.Queue[dict]" = asyncio.Queue() self.__fb_has_key = False - # Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события. - # Все это нужно только чтобы не посылать лишние жсоны в сокет KVMD - self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle"], None) - self.__mouse_move = {"x": -1, "y": -1} - - self.__modifiers = 0 + self.__clipboard = "" + + self.__info_host = "" + self.__info_switch_units = 0 + self.__info_switch_active = "" + + self.__magic = MagicHandler( + proxy_handler=self.__on_magic_key_proxy, + key_handlers={ + ecodes.KEY_P: self.__on_magic_clipboard_print, + ecodes.KEY_UP: self.__on_magic_switch_prev, + ecodes.KEY_LEFT: self.__on_magic_switch_prev, + ecodes.KEY_DOWN: self.__on_magic_switch_next, + ecodes.KEY_RIGHT: self.__on_magic_switch_next, + }, + numeric_handler=self.__on_magic_switch_port, + ) # ===== @@ -174,22 +183,38 @@ async def __kvmd_task_loop(self) -> None: self.__kvmd_ws = None async def __process_ws_event(self, event_type: str, event: dict) -> None: - if event_type == "info_meta_state": - try: - host = event["server"]["host"] - except Exception: - host = None - else: - if isinstance(host, str): - name = f"PiKVM: {host}" - if self._encodings.has_rename: - await self._send_rename(name) - self.__shared_params.name = name - - elif event_type == "hid_state": - if self._encodings.has_leds_state: + if event_type == "info": + if "node" in event: + self.__info_host = event["node"]["host"] + await self.__update_info() + + elif event_type == "switch": + if "model" in event: + self.__info_switch_units = len(event["model"]["units"]) + if "summary" in event: + self.__info_switch_active = event["summary"]["active_id"] + if "model" in event or "summary" in event: + await self.__update_info() + + elif event_type == "hid": + if ( + self._encodings.has_leds_state + and ("keyboard" in event) + and ("leds" in event["keyboard"]) + ): await self._send_leds_state(**event["keyboard"]["leds"]) + async def __update_info(self) -> None: + info: list[str] = [] + if self.__info_switch_units > 0: + info.append("Port " + (self.__info_switch_active or "not selected")) + if self.__info_host: + info.append(self.__info_host) + info.append("PiKVM") + self.__shared_params.name = " | ".join(info) + if self._encodings.has_rename: + await self._send_rename(self.__shared_params.name) + # ===== async def __streamer_task_loop(self) -> None: @@ -205,23 +230,20 @@ async def __streamer_task_loop(self) -> None: if not streaming: logger.info("%s [streamer]: Streaming ...", self._remote) streaming = True - if frame["online"]: - await self.__queue_frame(frame) - else: - await self.__queue_frame("No signal") - except StreamerError as err: - if isinstance(err, StreamerPermError): + await self.__queue_frame(frame) + except StreamerError as ex: + if isinstance(ex, StreamerPermError): streamer = self.__get_default_streamer() - logger.info("%s [streamer]: Permanent error: %s; switching to %s ...", self._remote, err, streamer) + logger.info("%s [streamer]: Permanent error: %s; switching to %s ...", self._remote, ex, streamer) else: - logger.info("%s [streamer]: Waiting for stream: %s", self._remote, err) + logger.info("%s [streamer]: Waiting for stream: %s", self._remote, ex) await self.__queue_frame("Waiting for stream ...") await asyncio.sleep(1) def __get_preferred_streamer(self) -> BaseStreamerClient: formats = { - StreamFormats.JPEG: "has_tight", - StreamFormats.H264: "has_h264", + StreamerFormats.JPEG: "has_tight", + StreamerFormats.H264: "has_h264", } streamer: (BaseStreamerClient | None) = None for streamer in self.__streamers: @@ -238,40 +260,42 @@ def __get_default_streamer(self) -> BaseStreamerClient: async def __queue_frame(self, frame: (dict | str)) -> None: if isinstance(frame, str): frame = await self.__make_text_frame(frame) - self.__fb_queue.put_nowait(frame) + if self.__fb_q.qsize() > 10: + self.__fb_q.get_nowait() + self.__fb_q.put_nowait(frame) async def __make_text_frame(self, text: str) -> dict: return { "data": (await make_text_jpeg(self._width, self._height, self._encodings.tight_jpeg_quality, text)), "width": self._width, "height": self._height, - "format": StreamFormats.JPEG, + "format": StreamerFormats.JPEG, } async def __fb_sender_task_loop(self) -> None: # pylint: disable=too-many-branches last: (dict | None) = None async for _ in self._send_fb_allowed(): while True: - frame = await self.__fb_queue.get() + frame = await self.__fb_q.get() if ( last is None # pylint: disable=too-many-boolean-expressions - or frame["format"] == StreamFormats.JPEG + or frame["format"] == StreamerFormats.JPEG or last["format"] != frame["format"] - or (frame["format"] == StreamFormats.H264 and ( + or (frame["format"] == StreamerFormats.H264 and ( frame["key"] or last["width"] != frame["width"] or last["height"] != frame["height"] or len(last["data"]) + len(frame["data"]) > 4194304 )) ): - self.__fb_has_key = (frame["format"] == StreamFormats.H264 and frame["key"]) + self.__fb_has_key = (frame["format"] == StreamerFormats.H264 and frame["key"]) last = frame - if self.__fb_queue.qsize() == 0: + if self.__fb_q.qsize() == 0: break continue - assert frame["format"] == StreamFormats.H264 + assert frame["format"] == StreamerFormats.H264 last["data"] += frame["data"] - if self.__fb_queue.qsize() == 0: + if self.__fb_q.qsize() == 0: break if self._width != last["width"] or self._height != last["height"]: @@ -291,9 +315,9 @@ async def __fb_sender_task_loop(self) -> None: # pylint: disable=too-many-branc await self._send_fb_allow_again() continue - if last["format"] == StreamFormats.JPEG: + if last["format"] == StreamerFormats.JPEG: await self._send_fb_jpeg(last["data"]) - elif last["format"] == StreamFormats.H264: + elif last["format"] == StreamerFormats.H264: if not self._encodings.has_h264: raise RfbError("The client doesn't want to accept H264 anymore") if self.__fb_has_key: @@ -307,98 +331,92 @@ async def __fb_sender_task_loop(self) -> None: # pylint: disable=too-many-branc # ===== async def _authorize_userpass(self, user: str, passwd: str) -> bool: - self.__kvmd_session = self.__kvmd.make_session(user, passwd) - if (await self.__kvmd_session.auth.check()): + self.__kvmd_session = self.__kvmd.make_session() + if (await self.__kvmd_session.auth.check(user, passwd)): self.__stage1_authorized.set_passed() return True return False - async def _on_authorized_vnc_passwd(self, passwd: str) -> str: - kc = self.__vnc_credentials[passwd] - if (await self._authorize_userpass(kc.user, kc.passwd)): - return kc.user - return "" + async def _on_authorized_vncpass(self) -> None: + self.__kvmd_session = self.__kvmd.make_session() + self.__stage1_authorized.set_passed() - async def _on_authorized_none(self) -> bool: + async def _authorize_none(self) -> bool: return (await self._authorize_userpass("", "")) # ===== - async def _on_key_event(self, code: int, state: bool) -> None: - is_modifier = self.__switch_modifiers(code, state) - variants = self.__symmap.get(code) - fake_shift = False + async def _on_key_event(self, key: int, state: bool) -> None: + assert self.__stage1_authorized.is_passed() + await self.__magic.handle_key(key, state) - if variants: - if is_modifier: - web_key = variants.get(0) - else: - web_key = variants.get(self.__modifiers) - if web_key is None: - web_key = variants.get(0) - - if web_key is None and self.__modifiers == 0 and SymmapModifiers.SHIFT in variants: - # JUMP doesn't send shift events: - # - https://github.com/pikvm/pikvm/issues/820 - web_key = variants[SymmapModifiers.SHIFT] - fake_shift = True - - if web_key and self.__kvmd_ws: - if fake_shift: - await self.__kvmd_ws.send_key_event(WebModifiers.SHIFT_LEFT, True) - await self.__kvmd_ws.send_key_event(web_key, state) - if fake_shift: - await self.__kvmd_ws.send_key_event(WebModifiers.SHIFT_LEFT, False) - - async def _on_ext_key_event(self, code: int, state: bool) -> None: - web_key = AT1_TO_WEB.get(code) - if web_key: - self.__switch_modifiers(web_key, state) # Предполагаем, что модификаторы всегда известны - if self.__kvmd_ws: - await self.__kvmd_ws.send_key_event(web_key, state) - - def __switch_modifiers(self, key: (int | str), state: bool) -> bool: - mod = 0 - if key in X11Modifiers.SHIFTS or key in WebModifiers.SHIFTS: - mod = SymmapModifiers.SHIFT - elif key == X11Modifiers.ALTGR or key == WebModifiers.ALT_RIGHT: - mod = SymmapModifiers.ALTGR - elif key in X11Modifiers.CTRLS or key in WebModifiers.CTRLS: - mod = SymmapModifiers.CTRL - if mod == 0: - return False - if state: - self.__modifiers |= mod - else: - self.__modifiers &= ~mod + async def __on_magic_switch_prev(self) -> None: + assert self.__kvmd_session + if self.__info_switch_units > 0: + get_logger(0).info("%s [main]: Switching port to the previous one ...", self._remote) + await self.__kvmd_session.switch.set_active_prev() + + async def __on_magic_switch_next(self) -> None: + assert self.__kvmd_session + if self.__info_switch_units > 0: + get_logger(0).info("%s [main]: Switching port to the next one ...", self._remote) + await self.__kvmd_session.switch.set_active_next() + + async def __on_magic_switch_port(self, codes: list[int]) -> bool: + assert self.__kvmd_session + assert len(codes) > 0 + if self.__info_switch_units <= 0: + return True + elif 1 <= self.__info_switch_units <= 2: + port = float(codes[0]) + else: # self.__info_switch_units > 2: + if len(codes) == 1: + return False # Wait for the second key + port = (codes[0] + 1) + (codes[1] + 1) / 10 + get_logger(0).info("%s [main]: Switching port to %s ...", self._remote, port) + await self.__kvmd_session.switch.set_active(port) return True - async def _on_pointer_event(self, buttons: dict[str, bool], wheel: dict[str, int], move: dict[str, int]) -> None: + async def __on_magic_clipboard_print(self) -> None: + assert self.__kvmd_session + if self.__clipboard: + logger = get_logger(0) + logger.info("%s [main]: Printing %d characters ...", self._remote, len(self.__clipboard)) + try: + default_kn = os.path.basename(self.__keymap_path) # Get keymap name from the last component + (keymap_name, available) = await self.__kvmd_session.hid.get_keymaps() + if default_kn in available: + keymap_name = default_kn + await self.__kvmd_session.hid.print(self.__clipboard, 0, keymap_name) + except Exception: + logger.exception("%s [main]: Can't print characters", self._remote) + + async def __on_magic_key_proxy(self, key: int, state: bool) -> None: + if self.__kvmd_ws: + await self.__kvmd_ws.send_key_event(key, state) + + # ===== + + async def _on_mouse_button_event(self, button: int, state: bool) -> None: + assert self.__stage1_authorized.is_passed() + if self.__kvmd_ws: + await self.__kvmd_ws.send_mouse_button_event(button, state) + + async def _on_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: + assert self.__stage1_authorized.is_passed() if self.__kvmd_ws: - if wheel["x"] or wheel["y"]: - await self.__kvmd_ws.send_mouse_wheel_event(wheel["x"], wheel["y"]) + await self.__kvmd_ws.send_mouse_wheel_event(delta_x, delta_y) - if self.__mouse_move != move: - await self.__kvmd_ws.send_mouse_move_event(move["x"], move["y"]) - self.__mouse_move = move + async def _on_mouse_move_event(self, to_x: int, to_y: int) -> None: + assert self.__stage1_authorized.is_passed() + if self.__kvmd_ws: + await self.__kvmd_ws.send_mouse_move_event(to_x, to_y) - for (button, state) in buttons.items(): - if self.__mouse_buttons[button] != state: - await self.__kvmd_ws.send_mouse_button_event(button, state) - self.__mouse_buttons[button] = state + # ===== async def _on_cut_event(self, text: str) -> None: assert self.__stage1_authorized.is_passed() - assert self.__kvmd_session - logger = get_logger(0) - logger.info("%s [main]: Printing %d characters ...", self._remote, len(text)) - try: - (keymap_name, available) = await self.__kvmd_session.hid.get_keymaps() - if self.__keymap_name in available: - keymap_name = self.__keymap_name - await self.__kvmd_session.hid.print(text, 0, keymap_name) - except Exception: - logger.exception("%s [main]: Can't print characters", self._remote) + self.__clipboard = text async def _on_set_encodings(self) -> None: assert self.__stage1_authorized.is_passed() @@ -411,6 +429,10 @@ async def _on_set_encodings(self) -> None: self._remote, quality, self.__desired_fps) await self.__kvmd_session.streamer.set_params(quality, self.__desired_fps) + async def _load_symmap_cache(self) -> dict[int, dict[int, int]]: + get_logger(0).info("%s [main]: Building symmap cache ...", self._remote) + return build_symmap(self.__keymap_path) + # ===== class VncServer: # pylint: disable=too-many-instance-attributes @@ -431,25 +453,25 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals x509_cert_path: str, x509_key_path: str, + vncpass_enabled: bool, + vncpass_path: str, vencrypt_enabled: bool, desired_fps: int, mouse_output: str, keymap_path: str, + scroll_rate: int, kvmd: KvmdClient, streamers: list[BaseStreamerClient], - vnc_auth_manager: VncAuthManager, ) -> None: - self.__host = host + self.__host = network.get_listen_host(host) self.__port = port self.__max_clients = max_clients - keymap_name = os.path.basename(keymap_path) - symmap = build_symmap(keymap_path) - - self.__vnc_auth_manager = vnc_auth_manager + self.__vncpass_enabled = vncpass_enabled + self.__vncpass_path = vncpass_path shared_params = _SharedParams() @@ -476,10 +498,10 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, timeout) # type: ignore try: - async with kvmd.make_session("", "") as kvmd_session: - none_auth_only = await kvmd_session.auth.check() - except (aiohttp.ClientError, asyncio.TimeoutError) as err: - logger.error("%s [entry]: Can't check KVMD auth mode: %s", remote, tools.efmt(err)) + async with kvmd.make_session() as kvmd_session: + none_auth_only = await kvmd_session.auth.check("", "") + except (aiohttp.ClientError, asyncio.TimeoutError) as ex: + logger.error("%s [entry]: Can't check KVMD auth mode: %s", remote, tools.efmt(ex)) return await _Client( @@ -491,13 +513,13 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit x509_key_path=x509_key_path, desired_fps=desired_fps, mouse_output=mouse_output, - keymap_name=keymap_name, - symmap=symmap, + keymap_path=keymap_path, + scroll_rate=scroll_rate, kvmd=kvmd, streamers=streamers, - vnc_credentials=(await self.__vnc_auth_manager.read_credentials())[0], - none_auth_only=none_auth_only, + vncpasses=(await self.__read_vncpasses()), vencrypt=vencrypt_enabled, + none_auth_only=none_auth_only, shared_params=shared_params, ).run() except Exception: @@ -508,9 +530,6 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit self.__handle_client = handle_client async def __inner_run(self) -> None: - if not (await self.__vnc_auth_manager.read_credentials())[1]: - raise SystemExit(1) - get_logger(0).info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port) (family, _, _, _, addr) = socket.getaddrinfo(self.__host, self.__port, type=socket.SOCK_STREAM)[0] with contextlib.closing(socket.socket(family, socket.SOCK_STREAM)) as sock: @@ -527,6 +546,21 @@ async def __inner_run(self) -> None: async with server: await server.serve_forever() + @async_lru.alru_cache(maxsize=1, ttl=1) + async def __read_vncpasses(self) -> set[str]: + if self.__vncpass_enabled: + try: + vncpasses: set[str] = set() + for (_, line) in tools.passwds_splitted(await aiotools.read_file(self.__vncpass_path)): + if " -> " in line: # Compatibility with old ipmipasswd file format + line = line.split(" -> ", 1)[0] + if len(line.strip()) > 0: + vncpasses.add(line) + return vncpasses + except Exception: + get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file") + return set() + def run(self) -> None: aiotools.run(self.__inner_run()) get_logger().info("Bye-bye") diff --git a/kvmd/apps/vnc/vncauth.py b/kvmd/apps/vnc/vncauth.py deleted file mode 100644 index ebda9ef49..000000000 --- a/kvmd/apps/vnc/vncauth.py +++ /dev/null @@ -1,86 +0,0 @@ -# ========================================================================== # -# # -# KVMD - The main PiKVM daemon. # -# # -# Copyright (C) 2020 Maxim Devaev # -# # -# 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 . # -# # -# ========================================================================== # - - -import dataclasses - -from ...logging import get_logger - -from ... import aiotools - - -# ===== -class VncAuthError(Exception): - def __init__(self, path: str, lineno: int, msg: str) -> None: - super().__init__(f"Syntax error at {path}:{lineno}: {msg}") - - -# ===== -@dataclasses.dataclass(frozen=True) -class VncAuthKvmdCredentials: - user: str - passwd: str - - -class VncAuthManager: - def __init__( - self, - path: str, - enabled: bool, - ) -> None: - - self.__path = path - self.__enabled = enabled - - async def read_credentials(self) -> tuple[dict[str, VncAuthKvmdCredentials], bool]: - if self.__enabled: - try: - return (await self.__inner_read_credentials(), True) - except VncAuthError as err: - get_logger(0).error(str(err)) - except Exception: - get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file") - return ({}, (not self.__enabled)) - - async def __inner_read_credentials(self) -> dict[str, VncAuthKvmdCredentials]: - lines = (await aiotools.read_file(self.__path)).split("\n") - credentials: dict[str, VncAuthKvmdCredentials] = {} - for (lineno, line) in enumerate(lines): - if len(line.strip()) == 0 or line.lstrip().startswith("#"): - continue - - if " -> " not in line: - raise VncAuthError(self.__path, lineno, "Missing ' -> ' operator") - - (vnc_passwd, kvmd_userpass) = map(str.lstrip, line.split(" -> ", 1)) - if ":" not in kvmd_userpass: - raise VncAuthError(self.__path, lineno, "Missing ':' operator in KVMD credentials (right part)") - - (kvmd_user, kvmd_passwd) = kvmd_userpass.split(":") - kvmd_user = kvmd_user.strip() - if len(kvmd_user) == 0: - raise VncAuthError(self.__path, lineno, "Empty KVMD user (right part)") - - if vnc_passwd in credentials: - raise VncAuthError(self.__path, lineno, "Duplicating VNC password (left part)") - - credentials[vnc_passwd] = VncAuthKvmdCredentials(kvmd_user, kvmd_passwd) - return credentials diff --git a/kvmd/apps/watchdog/__init__.py b/kvmd/apps/watchdog/__init__.py index 2a3ba41b7..407e476a8 100644 --- a/kvmd/apps/watchdog/__init__.py +++ b/kvmd/apps/watchdog/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -56,8 +56,8 @@ def _write_int(rtc: int, key: str, value: int) -> None: def _reset_alarm(rtc: int, timeout: int) -> None: try: now = _read_int(rtc, "since_epoch") - except OSError as err: - if err.errno != errno.EINVAL: + except OSError as ex: + if ex.errno != errno.EINVAL: raise raise RtcIsNotAvailableError("Can't read since_epoch right now") if now == 0: @@ -65,8 +65,8 @@ def _reset_alarm(rtc: int, timeout: int) -> None: try: for wake in [0, now + timeout]: _write_int(rtc, "wakealarm", wake) - except OSError as err: - if err.errno != errno.EIO: + except OSError as ex: + if ex.errno != errno.EIO: raise raise RtcIsNotAvailableError("IO error, probably the supercapacitor is not charged") @@ -80,9 +80,9 @@ def _cmd_run(config: Section) -> None: while True: try: _reset_alarm(config.rtc, config.timeout) - except RtcIsNotAvailableError as err: + except RtcIsNotAvailableError as ex: if not fail: - logger.error("RTC%d is not available now: %s; waiting ...", config.rtc, err) + logger.error("RTC%d is not available now: %s; waiting ...", config.rtc, ex) fail = True else: if fail: @@ -101,12 +101,12 @@ def _cmd_cancel(config: Section) -> None: # ===== -def main(argv: (list[str] | None)=None) -> None: - (parent_parser, argv, config) = init(add_help=False, argv=argv) +def main() -> None: + ia = init(add_help=False) parser = argparse.ArgumentParser( prog="kvmd-watchdog", description="RTC-based hardware watchdog", - parents=[parent_parser], + parents=[ia.parser], ) parser.set_defaults(cmd=(lambda *_: parser.print_help())) subparsers = parser.add_subparsers() @@ -117,5 +117,5 @@ def main(argv: (list[str] | None)=None) -> None: cmd_cancel_parser = subparsers.add_parser("cancel", help="Cancel armed timeout") cmd_cancel_parser.set_defaults(cmd=_cmd_cancel) - options = parser.parse_args(argv[1:]) - options.cmd(config.watchdog) + options = parser.parse_args(ia.args) + options.cmd(ia.config.watchdog) diff --git a/kvmd/apps/watchdog/__main__.py b/kvmd/apps/watchdog/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/apps/watchdog/__main__.py +++ b/kvmd/apps/watchdog/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/bitbang.py b/kvmd/bitbang.py new file mode 100644 index 000000000..ae18869ee --- /dev/null +++ b/kvmd/bitbang.py @@ -0,0 +1,34 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +# ===== +def make_crc16(data: bytes) -> int: + crc = 0xFFFF + for byte in data: + crc = crc ^ byte + for _ in range(8): + if crc & 0x0001 == 0: + crc = crc >> 1 + else: + crc = crc >> 1 + crc = crc ^ 0xA001 + return crc diff --git a/kvmd/clients/__init__.py b/kvmd/clients/__init__.py index 6ed9261b1..e917c9f63 100644 --- a/kvmd/clients/__init__.py +++ b/kvmd/clients/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -18,3 +18,67 @@ # along with this program. If not, see . # # # # ========================================================================== # + + +import types + +from typing import Callable +from typing import Self + +import aiohttp + + +# ===== +class BaseHttpClientSession: + def __init__(self, make_http_session: Callable[[], aiohttp.ClientSession]) -> None: + self._make_http_session = make_http_session + self.__http_session: (aiohttp.ClientSession | None) = None + + def _ensure_http_session(self) -> aiohttp.ClientSession: + if not self.__http_session: + self.__http_session = self._make_http_session() + return self.__http_session + + async def close(self) -> None: + if self.__http_session: + await self.__http_session.close() + self.__http_session = None + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + _exc_type: type[BaseException], + _exc: BaseException, + _tb: types.TracebackType, + ) -> None: + + await self.close() + + +class BaseHttpClient: + def __init__( + self, + unix_path: str, + timeout: float, + user_agent: str, + ) -> None: + + self.__unix_path = unix_path + self.__timeout = timeout + self.__user_agent = user_agent + + def make_session(self) -> BaseHttpClientSession: + raise NotImplementedError + + def _make_http_session(self, headers: (dict[str, str] | None)=None) -> aiohttp.ClientSession: + return aiohttp.ClientSession( + base_url="http://localhost:0", + headers={ + "User-Agent": self.__user_agent, + **(headers or {}), + }, + connector=aiohttp.UnixConnector(path=self.__unix_path), + timeout=aiohttp.ClientTimeout(total=self.__timeout), + ) diff --git a/kvmd/clients/kvmd.py b/kvmd/clients/kvmd.py index 569fb9d00..ce07953cc 100644 --- a/kvmd/clients/kvmd.py +++ b/kvmd/clients/kvmd.py @@ -20,11 +20,10 @@ # ========================================================================== # -import asyncio import contextlib import struct -import types +import typing from typing import Callable from typing import AsyncGenerator @@ -34,54 +33,57 @@ from .. import htclient from .. import htserver +from . import BaseHttpClient +from . import BaseHttpClientSession + # ===== class _BaseApiPart: - def __init__( - self, - ensure_http_session: Callable[[], aiohttp.ClientSession], - make_url: Callable[[str], str], - ) -> None: - + def __init__(self, ensure_http_session: Callable[[], aiohttp.ClientSession]) -> None: self._ensure_http_session = ensure_http_session - self._make_url = make_url async def _set_params(self, handle: str, **params: (int | str | None)) -> None: session = self._ensure_http_session() async with session.post( - url=self._make_url(handle), + url=handle, params={ key: value for (key, value) in params.items() if value is not None }, - ) as response: - htclient.raise_not_200(response) + ) as resp: + htclient.raise_not_200(resp) class _AuthApiPart(_BaseApiPart): - async def check(self) -> bool: + async def check(self, user: str, passwd: str) -> bool: session = self._ensure_http_session() try: - async with session.get(self._make_url("auth/check")) as response: - htclient.raise_not_200(response) - return True - except aiohttp.ClientResponseError as err: - if err.status in [400, 401, 403]: + async with session.get("/auth/check", headers={ + "X-KVMD-User": user, + "X-KVMD-Passwd": passwd, + }) as resp: + + htclient.raise_not_200(resp) + return (resp.status == 200) # Just for my paranoia + + except aiohttp.ClientResponseError as ex: + if ex.status in [400, 401, 403]: return False raise + typing.assert_never("We should't be here") class _StreamerApiPart(_BaseApiPart): async def get_state(self) -> dict: session = self._ensure_http_session() - async with session.get(self._make_url("streamer")) as response: - htclient.raise_not_200(response) - return (await response.json())["result"] + async with session.get("/streamer") as resp: + htclient.raise_not_200(resp) + return (await resp.json())["result"] async def set_params(self, quality: (int | None)=None, desired_fps: (int | None)=None) -> None: await self._set_params( - "streamer/set_params", + "/streamer/set_params", quality=quality, desired_fps=desired_fps, ) @@ -90,23 +92,23 @@ async def set_params(self, quality: (int | None)=None, desired_fps: (int | None) class _HidApiPart(_BaseApiPart): async def get_keymaps(self) -> tuple[str, set[str]]: session = self._ensure_http_session() - async with session.get(self._make_url("hid/keymaps")) as response: - htclient.raise_not_200(response) - result = (await response.json())["result"] + async with session.get("/hid/keymaps") as resp: + htclient.raise_not_200(resp) + result = (await resp.json())["result"] return (result["keymaps"]["default"], set(result["keymaps"]["available"])) async def print(self, text: str, limit: int, keymap_name: str) -> None: session = self._ensure_http_session() async with session.post( - url=self._make_url("hid/print"), + url="/hid/print", params={"limit": limit, "keymap": keymap_name}, data=text, - ) as response: - htclient.raise_not_200(response) + ) as resp: + htclient.raise_not_200(resp) async def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | None)=None) -> None: await self._set_params( - "hid/set_params", + "/hid/set_params", keyboard_output=keyboard_output, mouse_output=mouse_output, ) @@ -115,71 +117,66 @@ async def set_params(self, keyboard_output: (str | None)=None, mouse_output: (st class _AtxApiPart(_BaseApiPart): async def get_state(self) -> dict: session = self._ensure_http_session() - async with session.get(self._make_url("atx")) as response: - htclient.raise_not_200(response) - return (await response.json())["result"] + async with session.get("/atx") as resp: + htclient.raise_not_200(resp) + return (await resp.json())["result"] async def switch_power(self, action: str) -> bool: session = self._ensure_http_session() try: async with session.post( - url=self._make_url("atx/power"), + url="/atx/power", params={"action": action}, - ) as response: - htclient.raise_not_200(response) + ) as resp: + htclient.raise_not_200(resp) return True - except aiohttp.ClientResponseError as err: - if err.status == 409: + except aiohttp.ClientResponseError as ex: + if ex.status == 409: return False raise +class _SwitchApiPart(_BaseApiPart): + async def set_active_prev(self) -> None: + session = self._ensure_http_session() + async with session.post("/switch/set_active_prev") as resp: + htclient.raise_not_200(resp) + + async def set_active_next(self) -> None: + session = self._ensure_http_session() + async with session.post("/switch/set_active_next") as resp: + htclient.raise_not_200(resp) + + async def set_active(self, port: float) -> None: + session = self._ensure_http_session() + async with session.post( + url="/switch/set_active", + params={"port": port}, + ) as resp: + htclient.raise_not_200(resp) + + # ===== class KvmdClientWs: def __init__(self, ws: aiohttp.ClientWebSocketResponse) -> None: self.__ws = ws - - self.__writer_queue: "asyncio.Queue[tuple[str, dict] | bytes]" = asyncio.Queue() self.__communicated = False async def communicate(self) -> AsyncGenerator[tuple[str, dict], None]: # pylint: disable=too-many-branches assert not self.__communicated self.__communicated = True - receive_task: (asyncio.Task | None) = None - writer_task: (asyncio.Task | None) = None try: - while True: - if receive_task is None: - receive_task = asyncio.create_task(self.__ws.receive()) - if writer_task is None: - writer_task = asyncio.create_task(self.__writer_queue.get()) - - done = (await aiotools.wait_first(receive_task, writer_task))[0] - - if receive_task in done: - msg = receive_task.result() - if msg.type == aiohttp.WSMsgType.TEXT: + async for msg in self.__ws: + match msg.type: + case aiohttp.WSMsgType.TEXT: yield htserver.parse_ws_event(msg.data) - elif msg.type == aiohttp.WSMsgType.CLOSE: + case aiohttp.WSMsgType.CLOSE: await self.__ws.close() - elif msg.type == aiohttp.WSMsgType.CLOSED: + case aiohttp.WSMsgType.CLOSED: break - else: + case _: raise RuntimeError(f"Unhandled WS message type: {msg!r}") - receive_task = None - - if writer_task in done: - payload = writer_task.result() - if isinstance(payload, bytes): - await self.__ws.send_bytes(payload) - else: - await htserver.send_ws_event(self.__ws, *payload) - writer_task = None finally: - if receive_task: - receive_task.cancel() - if writer_task: - writer_task.cancel() try: await aiotools.shield_fg(self.__ws.close()) except Exception: @@ -187,97 +184,51 @@ async def communicate(self) -> AsyncGenerator[tuple[str, dict], None]: # pylint finally: self.__communicated = False - async def send_key_event(self, key: str, state: bool) -> None: - await self.__writer_queue.put(bytes([1, state]) + key.encode("ascii")) + async def send_key_event(self, key: int, state: bool) -> None: + mask = (0b10000000 | int(bool(state))) + await self.__send_struct(">BBH", 1, mask, key) - async def send_mouse_button_event(self, button: str, state: bool) -> None: - await self.__writer_queue.put(bytes([2, state]) + button.encode("ascii")) + async def send_mouse_button_event(self, button: int, state: bool) -> None: + mask = (0b10000000 | int(bool(state))) + await self.__send_struct(">BBH", 2, mask, button) async def send_mouse_move_event(self, to_x: int, to_y: int) -> None: - await self.__writer_queue.put(struct.pack(">bhh", 3, to_x, to_y)) - - async def send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: - await self.__writer_queue.put(struct.pack(">bbbb", 5, 0, delta_x, delta_y)) - - -class KvmdClientSession: - def __init__( - self, - make_http_session: Callable[[], aiohttp.ClientSession], - make_url: Callable[[str], str], - ) -> None: + await self.__send_struct(">Bhh", 3, to_x, to_y) - self.__make_http_session = make_http_session - self.__make_url = make_url + async def send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None: + await self.__send_struct(">BBbb", 4, 0, delta_x, delta_y) - self.__http_session: (aiohttp.ClientSession | None) = None - - args = (self.__ensure_http_session, make_url) + async def send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: + await self.__send_struct(">BBbb", 5, 0, delta_x, delta_y) - self.auth = _AuthApiPart(*args) - self.streamer = _StreamerApiPart(*args) - self.hid = _HidApiPart(*args) - self.atx = _AtxApiPart(*args) + async def __send_struct(self, fmt: str, *values: int) -> None: + if not self.__communicated: + return + data = struct.pack(fmt, *values) + try: + await self.__ws.send_bytes(data) + except Exception: + # XXX: We don't care about any connection errors + # since they will be handled with communication() + pass + + +class KvmdClientSession(BaseHttpClientSession): + def __init__(self, make_http_session: Callable[[], aiohttp.ClientSession]) -> None: + super().__init__(make_http_session) + self.auth = _AuthApiPart(self._ensure_http_session) + self.streamer = _StreamerApiPart(self._ensure_http_session) + self.hid = _HidApiPart(self._ensure_http_session) + self.atx = _AtxApiPart(self._ensure_http_session) + self.switch = _SwitchApiPart(self._ensure_http_session) @contextlib.asynccontextmanager - async def ws(self) -> AsyncGenerator[KvmdClientWs, None]: - session = self.__ensure_http_session() - async with session.ws_connect(self.__make_url("ws")) as ws: + async def ws(self, stream: bool=True) -> AsyncGenerator[KvmdClientWs, None]: + session = self._ensure_http_session() + async with session.ws_connect("/ws", params={"stream": int(stream)}) as ws: yield KvmdClientWs(ws) - def __ensure_http_session(self) -> aiohttp.ClientSession: - if not self.__http_session: - self.__http_session = self.__make_http_session() - return self.__http_session - - async def close(self) -> None: - if self.__http_session: - await self.__http_session.close() - self.__http_session = None - - async def __aenter__(self) -> "KvmdClientSession": - return self - - async def __aexit__( - self, - _exc_type: type[BaseException], - _exc: BaseException, - _tb: types.TracebackType, - ) -> None: - - await self.close() - - -class KvmdClient: - def __init__( - self, - unix_path: str, - timeout: float, - user_agent: str, - ) -> None: - - self.__unix_path = unix_path - self.__timeout = timeout - self.__user_agent = user_agent - - def make_session(self, user: str, passwd: str) -> KvmdClientSession: - return KvmdClientSession( - make_http_session=(lambda: self.__make_http_session(user, passwd)), - make_url=self.__make_url, - ) - def __make_http_session(self, user: str, passwd: str) -> aiohttp.ClientSession: - kwargs: dict = { - "headers": { - "X-KVMD-User": user, - "X-KVMD-Passwd": passwd, - "User-Agent": self.__user_agent, - }, - "connector": aiohttp.UnixConnector(path=self.__unix_path), - "timeout": aiohttp.ClientTimeout(total=self.__timeout), - } - return aiohttp.ClientSession(**kwargs) - - def __make_url(self, handle: str) -> str: - assert not handle.startswith("/"), handle - return f"http://localhost:0/{handle}" +class KvmdClient(BaseHttpClient): + def make_session(self) -> KvmdClientSession: + return KvmdClientSession(self._make_http_session) diff --git a/kvmd/clients/pst.py b/kvmd/clients/pst.py new file mode 100644 index 000000000..868f388c2 --- /dev/null +++ b/kvmd/clients/pst.py @@ -0,0 +1,93 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os +import contextlib + +from typing import AsyncGenerator + +import aiohttp + +from .. import htclient +from .. import htserver + + +# ===== +class PstError(Exception): + pass + + +# ===== +class PstClient: + def __init__( + self, + subdir: str, + unix_path: str, + timeout: float, + user_agent: str, + ) -> None: + + self.__subdir = subdir + self.__unix_path = unix_path + self.__timeout = timeout + self.__user_agent = user_agent + + async def get_path(self) -> str: + async with self.__make_http_session() as session: + async with session.get("http://localhost:0/state") as resp: + htclient.raise_not_200(resp) + path = (await resp.json())["result"]["data"]["path"] + return os.path.join(path, self.__subdir) + + @contextlib.asynccontextmanager + async def writable(self) -> AsyncGenerator[str, None]: + async with self.__inner_writable() as path: + path = os.path.join(path, self.__subdir) + if not os.path.exists(path): + os.mkdir(path) + yield path + + @contextlib.asynccontextmanager + async def __inner_writable(self) -> AsyncGenerator[str, None]: + async with self.__make_http_session() as session: + async with session.ws_connect("http://localhost:0/ws") as ws: + path = "" + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + raise PstError(f"Unexpected message type: {msg!r}") + (event_type, event) = htserver.parse_ws_event(msg.data) + if event_type == "storage": + if not event["data"]["write_allowed"]: + raise PstError("Write is not allowed") + path = event["data"]["path"] + break + if not path: + raise PstError("WS loop broken without write_allowed=True flag") + # TODO: Actually we should follow ws events, but for fast writing we can safely ignore them + yield path + + def __make_http_session(self) -> aiohttp.ClientSession: + return aiohttp.ClientSession( + headers={"User-Agent": self.__user_agent}, + connector=aiohttp.UnixConnector(path=self.__unix_path), + timeout=aiohttp.ClientTimeout(total=self.__timeout), + ) diff --git a/kvmd/clients/streamer.py b/kvmd/clients/streamer.py index 1d8cd6011..9313b76f3 100644 --- a/kvmd/clients/streamer.py +++ b/kvmd/clients/streamer.py @@ -20,7 +20,11 @@ # ========================================================================== # +import io +import asyncio import contextlib +import dataclasses +import functools import types from typing import Callable @@ -31,10 +35,14 @@ import aiohttp import ustreamer +from PIL import Image as PilImage + from .. import tools -from .. import aiotools from .. import htclient +from . import BaseHttpClient +from . import BaseHttpClientSession + # ===== class StreamerError(Exception): @@ -50,11 +58,15 @@ class StreamerPermError(StreamerError): # ===== -class StreamFormats: +class StreamerFormats: JPEG = 1195724874 # V4L2_PIX_FMT_JPEG H264 = 875967048 # V4L2_PIX_FMT_H264 _MJPEG = 1196444237 # V4L2_PIX_FMT_MJPEG + @classmethod + def is_diff(cls, fmt: int) -> bool: + return (fmt == cls.H264) + class BaseStreamerClient: def get_format(self) -> int: @@ -68,17 +80,85 @@ async def reading(self) -> AsyncGenerator[Callable[[bool], Awaitable[dict]], Non # ===== +@dataclasses.dataclass(frozen=True) +class StreamerSnapshot: + online: bool + width: int + height: int + headers: tuple[tuple[str, str], ...] + data: bytes + + async def make_preview(self, max_width: int, max_height: int, quality: int) -> bytes: + assert max_width >= 0 + assert max_height >= 0 + assert quality > 0 + + if max_width == 0 and max_height == 0: + max_width = self.width // 5 + max_height = self.height // 5 + else: + max_width = min((max_width or self.width), self.width) + max_height = min((max_height or self.height), self.height) + + if (max_width, max_height) == (self.width, self.height): + return self.data + return (await asyncio.to_thread(self.__inner_make_preview, max_width, max_height, quality)) + + @functools.lru_cache(maxsize=1) + def __inner_make_preview(self, max_width: int, max_height: int, quality: int) -> bytes: + with io.BytesIO(self.data) as snapshot_bio: + with io.BytesIO() as preview_bio: + with PilImage.open(snapshot_bio) as image: + image.thumbnail((max_width, max_height), PilImage.Resampling.LANCZOS) + image.save(preview_bio, format="jpeg", quality=quality) + return preview_bio.getvalue() + + +class HttpStreamerClientSession(BaseHttpClientSession): + async def get_state(self) -> dict: + session = self._ensure_http_session() + async with session.get("/state") as resp: + htclient.raise_not_200(resp) + return (await resp.json())["result"] + + async def take_snapshot(self, timeout: float) -> StreamerSnapshot: + session = self._ensure_http_session() + async with session.get( + url="/snapshot", + timeout=aiohttp.ClientTimeout(total=timeout), + ) as resp: + + htclient.raise_not_200(resp) + return StreamerSnapshot( + online=(resp.headers["X-UStreamer-Online"] == "true"), + width=int(resp.headers["X-UStreamer-Width"]), + height=int(resp.headers["X-UStreamer-Height"]), + headers=tuple( + (key, value) + for (key, value) in tools.sorted_kvs(dict(resp.headers)) + if key.lower().startswith("x-ustreamer-") or key.lower() in [ + "x-timestamp", + "access-control-allow-origin", + "cache-control", + "pragma", + "expires", + ] + ), + data=bytes(await resp.read()), + ) + + @contextlib.contextmanager -def _http_handle_errors() -> Generator[None, None, None]: +def _http_reading_handle_errors() -> Generator[None, None, None]: try: yield - except Exception as err: # Тут бывают и ассерты, и KeyError, и прочая херня - if isinstance(err, StreamerTempError): + except Exception as ex: # Тут бывают и ассерты, и KeyError, и прочая херня + if isinstance(ex, StreamerTempError): raise - raise StreamerTempError(tools.efmt(err)) + raise StreamerTempError(tools.efmt(ex)) -class HttpStreamerClient(BaseStreamerClient): +class HttpStreamerClient(BaseHttpClient, BaseStreamerClient): def __init__( self, name: str, @@ -87,29 +167,35 @@ def __init__( user_agent: str, ) -> None: + super().__init__(unix_path, timeout, user_agent) self.__name = name - self.__unix_path = unix_path - self.__timeout = timeout - self.__user_agent = user_agent + + def make_session(self) -> HttpStreamerClientSession: + return HttpStreamerClientSession(self._make_http_session) def get_format(self) -> int: - return StreamFormats.JPEG + return StreamerFormats.JPEG @contextlib.asynccontextmanager async def reading(self) -> AsyncGenerator[Callable[[bool], Awaitable[dict]], None]: - with _http_handle_errors(): - async with self.__make_http_session() as session: + with _http_reading_handle_errors(): + async with self._make_http_session() as session: async with session.get( - url=self.__make_url("stream"), + url="/stream", params={"extra_headers": "1"}, - ) as response: - htclient.raise_not_200(response) - reader = aiohttp.MultipartReader.from_response(response) + timeout=aiohttp.ClientTimeout( + connect=session.timeout.total, + sock_read=session.timeout.total, + ), + ) as resp: + + htclient.raise_not_200(resp) + reader = aiohttp.MultipartReader.from_response(resp) self.__patch_stream_reader(reader.resp.content) async def read_frame(key_required: bool) -> dict: _ = key_required - with _http_handle_errors(): + with _http_reading_handle_errors(): frame = await reader.next() # pylint: disable=not-callable if not isinstance(frame, aiohttp.BodyPartReader): raise StreamerTempError("Expected body part") @@ -123,26 +209,11 @@ async def read_frame(key_required: bool) -> dict: "width": int(frame.headers["X-UStreamer-Width"]), "height": int(frame.headers["X-UStreamer-Height"]), "data": data, - "format": StreamFormats.JPEG, + "format": StreamerFormats.JPEG, } yield read_frame - def __make_http_session(self) -> aiohttp.ClientSession: - kwargs: dict = { - "headers": {"User-Agent": self.__user_agent}, - "connector": aiohttp.UnixConnector(path=self.__unix_path), - "timeout": aiohttp.ClientTimeout( - connect=self.__timeout, - sock_read=self.__timeout, - ), - } - return aiohttp.ClientSession(**kwargs) - - def __make_url(self, handle: str) -> str: - assert not handle.startswith("/"), handle - return f"http://localhost:0/{handle}" - def __patch_stream_reader(self, reader: aiohttp.StreamReader) -> None: # https://github.com/pikvm/pikvm/issues/92 # Infinite looping in BodyPartReader.read() because _at_eof flag. @@ -162,15 +233,15 @@ def __str__(self) -> str: # ===== @contextlib.contextmanager -def _memsink_handle_errors() -> Generator[None, None, None]: +def _memsink_reading_handle_errors() -> Generator[None, None, None]: try: yield except StreamerPermError: raise - except FileNotFoundError as err: - raise StreamerTempError(tools.efmt(err)) - except Exception as err: - raise StreamerPermError(tools.efmt(err)) + except FileNotFoundError as ex: + raise StreamerTempError(tools.efmt(ex)) + except Exception as ex: + raise StreamerPermError(tools.efmt(ex)) class MemsinkStreamerClient(BaseStreamerClient): @@ -198,21 +269,21 @@ def get_format(self) -> int: @contextlib.asynccontextmanager async def reading(self) -> AsyncGenerator[Callable[[bool], Awaitable[dict]], None]: - with _memsink_handle_errors(): + with _memsink_reading_handle_errors(): with ustreamer.Memsink(**self.__kwargs) as sink: async def read_frame(key_required: bool) -> dict: - key_required = (key_required and self.__fmt == StreamFormats.H264) - with _memsink_handle_errors(): + key_required = (key_required and self.__fmt == StreamerFormats.H264) + with _memsink_reading_handle_errors(): while True: - frame = await aiotools.run_async(sink.wait_frame, key_required) + frame = await asyncio.to_thread(sink.wait_frame, key_required) if frame is not None: self.__check_format(frame["format"]) return frame yield read_frame def __check_format(self, fmt: int) -> None: - if fmt == StreamFormats._MJPEG: # pylint: disable=protected-access - fmt = StreamFormats.JPEG + if fmt == StreamerFormats._MJPEG: # pylint: disable=protected-access + fmt = StreamerFormats.JPEG if fmt != self.__fmt: raise StreamerPermError("Invalid sink format") diff --git a/kvmd/crypto.py b/kvmd/crypto.py new file mode 100644 index 000000000..3101d0394 --- /dev/null +++ b/kvmd/crypto.py @@ -0,0 +1,63 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from passlib.context import CryptContext +from passlib.apache import HtpasswdFile as _ApacheHtpasswdFile +from passlib.apache import htpasswd_context as _apache_htpasswd_ctx + + +# ===== +_SHA512 = "ldap_salted_sha512" +_SHA256 = "ldap_salted_sha256" + + +def _make_kvmd_htpasswd_context() -> CryptContext: + # FIXME: bcrypt removed due to breaking changes: + # - https://foss.heptapod.net/python-libs/passlib/-/issues/196 + # - https://github.com/notypecheck/passlib/pull/20 + schemes = list(_apache_htpasswd_ctx.schemes()) + if "bcrypt" in schemes: + schemes.remove("bcrypt") + for alg in [_SHA256, _SHA512]: + if alg in schemes: + schemes.remove(alg) + schemes.insert(0, alg) + assert schemes[0] == _SHA512 + return CryptContext( + schemes=schemes, + default=_SHA512, + # bcrypt__ident="2y", # See note in the passlib.apache # FIXME: here too + ) + + +_kvmd_htpasswd_ctx = _make_kvmd_htpasswd_context() + + +# ===== +class KvmdHtpasswdFile(_ApacheHtpasswdFile): + def __init__(self, path: str, new: bool=False) -> None: + super().__init__( + path=path, + default_scheme=_SHA512, + context=_kvmd_htpasswd_ctx, + new=new, + ) diff --git a/kvmd/edid.py b/kvmd/edid.py new file mode 100644 index 000000000..8e8701a78 --- /dev/null +++ b/kvmd/edid.py @@ -0,0 +1,281 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os +import re +import dataclasses +import contextlib + +from typing import IO +from typing import Generator + + +# ===== +class EdidNoBlockError(Exception): + pass + + +@contextlib.contextmanager +def _smart_open(path: str, mode: str) -> Generator[IO, None, None]: + fd = (0 if "r" in mode else 1) + with (os.fdopen(fd, mode, closefd=False) if path == "-" else open(path, mode)) as file: + yield file + if "w" in mode: + file.flush() + + +@dataclasses.dataclass(frozen=True) +class _CeaBlock: + tag: int + data: bytes + + def __post_init__(self) -> None: + assert 0 < self.tag <= 0b111 + assert 0 < len(self.data) <= 0b11111 + + @property + def size(self) -> int: + return len(self.data) + 1 + + def pack(self) -> bytes: + header = (self.tag << 5) | len(self.data) + return header.to_bytes() + self.data + + @classmethod + def first_from_raw(cls, raw: (bytes | list[int])) -> "_CeaBlock": + assert 0 < raw[0] <= 0xFF + tag = (raw[0] & 0b11100000) >> 5 + data_size = (raw[0] & 0b00011111) + data = bytes(raw[1:data_size + 1]) + return _CeaBlock(tag, data) + + +_LONG = 256 +_SHORT = 128 + +_CEA = 128 +_CEA_AUDIO = 1 +_CEA_SPEAKERS = 4 + + +class Edid: + # https://en.wikipedia.org/wiki/Extended_Display_Identification_Data + + def __init__(self, data: bytes) -> None: + assert len(data) in [_SHORT, _LONG], f"Invalid EDID length: {len(data)}, should be {_SHORT} or {_LONG} bytes" + self.__long = (len(data) == _LONG) + if self.__long: + assert data[126] == 1, "Zero extensions number" + assert (data[_CEA + 0], data[_CEA + 1]) == (0x02, 0x03), "Can't find CEA extension" + self.__data = list(data) + + @classmethod + def is_header_valid(cls, data: bytes) -> bool: + return data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00") + + @classmethod + def from_file(cls, path: str) -> "Edid": + with _smart_open(path, "rb") as file: + data = file.read() + if not cls.is_header_valid(data): + text = re.sub(r"\s", "", data.decode()) + data = bytes([ + int(text[index:index + 2], 16) + for index in range(0, len(text), 2) + ]) + return Edid(data) + + def write_hex(self, path: str) -> None: + self.__update_checksums() + text = "\n".join( + "".join( + f"{item:0{2}X}" + for item in self.__data[index:index + 16] + ) + for index in range(0, len(self.__data), 16) + ) + "\n" + with _smart_open(path, "w") as file: + file.write(text) + + def write_bin(self, path: str) -> None: + self.__update_checksums() + with _smart_open(path, "wb") as file: + file.write(bytes(self.__data)) + + def __update_checksums(self) -> None: + self.__data[127] = 256 - (sum(self.__data[:127]) % 256) + if self.__long: + self.__data[255] = 256 - (sum(self.__data[128:255]) % 256) + + # ===== + + def get_mfc_id(self) -> str: + raw = self.__data[8] << 8 | self.__data[9] + return bytes([ + ((raw >> 10) & 0b11111) + 0x40, + ((raw >> 5) & 0b11111) + 0x40, + (raw & 0b11111) + 0x40, + ]).decode("ascii") + + def set_mfc_id(self, mfc_id: str) -> None: + assert len(mfc_id) == 3, "Mfc ID must be 3 characters long" + data = mfc_id.upper().encode("ascii") + for ch in data: + assert 0x41 <= ch <= 0x5A, "Mfc ID must contain only A-Z characters" + raw = ( + (data[2] - 0x40) + | ((data[1] - 0x40) << 5) + | ((data[0] - 0x40) << 10) + ) + self.__data[8] = (raw >> 8) & 0xFF + self.__data[9] = raw & 0xFF + + # ===== + + def get_product_id(self) -> int: + return (self.__data[10] | self.__data[11] << 8) + + def set_product_id(self, product_id: int) -> None: + assert 0 <= product_id <= 0xFFFF, f"Product ID should be from 0 to {0xFFFF}" + self.__data[10] = product_id & 0xFF + self.__data[11] = (product_id >> 8) & 0xFF + + # ===== + + def get_serial(self) -> int: + return ( + self.__data[12] + | self.__data[13] << 8 + | self.__data[14] << 16 + | self.__data[15] << 24 + ) + + def set_serial(self, serial: int) -> None: + assert 0 <= serial <= 0xFFFFFFFF, f"Serial should be from 0 to {0xFFFFFFFF}" + self.__data[12] = serial & 0xFF + self.__data[13] = (serial >> 8) & 0xFF + self.__data[14] = (serial >> 16) & 0xFF + self.__data[15] = (serial >> 24) & 0xFF + + # ===== + + def get_monitor_name(self) -> str: + return self.__get_dtd_text(0xFC, "Monitor Name") + + def set_monitor_name(self, text: str) -> None: + self.__set_dtd_text(0xFC, "Monitor Name", text) + + def get_monitor_serial(self) -> str: + return self.__get_dtd_text(0xFF, "Monitor Serial") + + def set_monitor_serial(self, text: str) -> None: + self.__set_dtd_text(0xFF, "Monitor Serial", text) + + def __get_dtd_text(self, d_type: int, name: str) -> str: + index = self.__find_dtd_text(d_type, name) + return bytes(self.__data[index:index + 13]).decode("cp437").strip() + + def __set_dtd_text(self, d_type: int, name: str, text: str) -> None: + index = self.__find_dtd_text(d_type, name) + encoded = (text[:13] + "\n" + " " * 12)[:13].encode("cp437") + for (offset, ch) in enumerate(encoded): + self.__data[index + offset] = ch + + def __find_dtd_text(self, d_type: int, name: str) -> int: + for index in [54, 72, 90, 108]: + if self.__data[index + 3] == d_type: + return index + 5 + raise EdidNoBlockError(f"Can't find DTD {name}") + + # ===== CEA ===== + + def get_audio(self) -> bool: + (cbs, _) = self.__parse_cea() + audio = False + speakers = False + for cb in cbs: + if cb.tag == _CEA_AUDIO: + audio = True + elif cb.tag == _CEA_SPEAKERS: + speakers = True + return (audio and speakers and self.__get_basic_audio()) + + def set_audio(self, enabled: bool) -> None: + (cbs, dtds) = self.__parse_cea() + cbs = [cb for cb in cbs if cb.tag not in [_CEA_AUDIO, _CEA_SPEAKERS]] + if enabled: + cbs.append(_CeaBlock(_CEA_AUDIO, b"\x09\x7f\x07")) + cbs.append(_CeaBlock(_CEA_SPEAKERS, b"\x01\x00\x00")) + self.__replace_cea(cbs, dtds) + self.__set_basic_audio(enabled) + + def __get_basic_audio(self) -> bool: + return bool(self.__data[_CEA + 3] & 0b01000000) + + def __set_basic_audio(self, enabled: bool) -> None: + if enabled: + self.__data[_CEA + 3] |= 0b01000000 + else: + self.__data[_CEA + 3] &= (0xFF - 0b01000000) # ~X + + def __parse_cea(self) -> tuple[list[_CeaBlock], bytes]: + if not self.__long: + raise EdidNoBlockError("This EDID does not contain any CEA blocks") + + cea = self.__data[_CEA:] + dtd_begin = cea[2] + if dtd_begin == 0: + return ([], b"") + + cbs: list[_CeaBlock] = [] + if dtd_begin > 4: + raw = cea[4:dtd_begin] + while len(raw) != 0: + cb = _CeaBlock.first_from_raw(raw) + cbs.append(cb) + raw = raw[cb.size:] + + dtds = b"" + assert dtd_begin >= 4 + raw = cea[dtd_begin:] + while len(raw) > (18 + 1) and raw[0] != 0: + dtds += bytes(raw[:18]) + raw = raw[18:] + + return (cbs, dtds) + + def __replace_cea(self, cbs: list[_CeaBlock], dtds: bytes) -> None: + cbs_packed = b"" + for cb in cbs: + cbs_packed += cb.pack() + + raw = cbs_packed + dtds + assert len(raw) <= (128 - 4 - 1), "Too many CEA blocks or DTDs" + + self.__data[_CEA + 2] = (0 if len(raw) == 0 else (len(cbs_packed) + 4)) + + for index in range(4, 127): + try: + ch = raw[index - 4] + except IndexError: + ch = 0 + self.__data[_CEA + index] = ch diff --git a/kvmd/env.py b/kvmd/env.py index f21a64321..698dbaa28 100644 --- a/kvmd/env.py +++ b/kvmd/env.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/errors.py b/kvmd/errors.py index 031fa7dff..c880aa7fe 100644 --- a/kvmd/errors.py +++ b/kvmd/errors.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/fstab.py b/kvmd/fstab.py index 72d430ef1..5a603d064 100644 --- a/kvmd/fstab.py +++ b/kvmd/fstab.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -33,6 +33,7 @@ class Partition: mount_path: str root_path: str user: str + group: str # ===== @@ -60,12 +61,13 @@ def _find_partitions(part_type: str, single: bool) -> list[Partition]: if line and not line.startswith("#"): fields = line.split() if len(fields) == 6: - options = dict(re.findall(r"X-kvmd\.%s-(root|user)(?:=([^,]+))?" % (part_type), fields[3])) + options = dict(re.findall(r"X-kvmd\.%s-(root|user|group)(?:=([^,]+))?" % (part_type), fields[3])) if options: parts.append(Partition( mount_path=os.path.normpath(fields[1]), root_path=os.path.normpath(options.get("root", "") or fields[1]), user=options.get("user", ""), + group=options.get("group", ""), )) if single: break diff --git a/kvmd/helpers/__init__.py b/kvmd/helpers/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/kvmd/helpers/__init__.py +++ b/kvmd/helpers/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/helpers/remount/__init__.py b/kvmd/helpers/remount/__init__.py index 1738a90e5..54eb77316 100644 --- a/kvmd/helpers/remount/__init__.py +++ b/kvmd/helpers/remount/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,7 +22,9 @@ import sys import os +import stat import pwd +import grp import shutil import subprocess @@ -44,8 +46,8 @@ def _remount(path: str, rw: bool) -> None: _log(f"Remounting {path} to {mode.upper()}-mode ...") try: subprocess.check_call(["/bin/mount", "--options", f"remount,{mode}", path]) - except subprocess.CalledProcessError as err: - raise SystemExit(f"Can't remount: {err}") + except subprocess.CalledProcessError as ex: + raise SystemExit(f"Can't remount: {ex}") def _mkdir(path: str) -> None: @@ -53,56 +55,98 @@ def _mkdir(path: str) -> None: _log(f"MKDIR --- {path}") try: os.mkdir(path) - except Exception as err: - raise SystemExit(f"Can't create directory: {err}") + except Exception as ex: + raise SystemExit(f"Can't create directory: {ex}") -def _rmdir(path: str) -> None: +def _rmtree(path: str) -> None: if exists(path): - _log(f"RMDIR --- {path}") + _log(f"RMALL --- {path}") try: - os.rmdir(path) - except Exception as err: - raise SystemExit(f"Can't remove directory: {err}") + shutil.rmtree(path) + except Exception as ex: + raise SystemExit(f"Can't remove directory: {ex}") + + +def _rm(path: str) -> None: + if exists(path): + _log(f"RM --- {path}") + try: + os.remove(path) + except Exception as ex: + raise SystemExit(f"Can't remove file: {ex}") def _move(src: str, dest: str) -> None: _log(f"MOVE --- {src} --> {dest}") try: os.rename(src, dest) - except Exception as err: - raise SystemExit(f"Can't move file: {err}") + except Exception as ex: + raise SystemExit(f"Can't move file: {ex}") def _chown(path: str, user: str) -> None: if pwd.getpwuid(os.stat(path).st_uid).pw_name != user: _log(f"CHOWN --- {user} - {path}") try: - shutil.chown(path, user) - except Exception as err: - raise SystemExit(f"Can't change ownership: {err}") + shutil.chown(path, user=user) + except Exception as ex: + raise SystemExit(f"Can't change ownership: {ex}") + + +def _chgrp(path: str, group: str) -> None: + if grp.getgrgid(os.stat(path).st_gid).gr_name != group: + _log(f"CHGRP --- {group} - {path}") + try: + shutil.chown(path, group=group) + except Exception as ex: + raise SystemExit(f"Can't change group: {ex}") + + +def _chmod(path: str, mode: int) -> None: + if stat.S_IMODE(os.stat(path).st_mode) != mode: + _log(f"CHMOD --- 0o{mode:o} - {path}") + try: + os.chmod(path, mode) + except Exception as ex: + raise SystemExit(f"Can't change permissions: {ex}") # ===== def _fix_msd(part: Partition) -> None: + # First images migration images_path = join(part.root_path, "images") meta_path = join(part.root_path, "meta") if exists(images_path) and exists(meta_path): - for name in os.listdir(meta_path): - _move(join(meta_path, name), join(part.root_path, f".__{name}")) - _rmdir(meta_path) for name in os.listdir(images_path): _move(join(images_path, name), os.path.join(part.root_path, name)) - _rmdir(images_path) + if not exists(join(meta_path, f"{name}.complete")): + open(os.path.join(part.root_path, f".__{name}.incomplete")).close() # pylint: disable=consider-using-with + _rmtree(images_path) + _rmtree(meta_path) + + # Second images migration + for name in os.listdir(part.root_path): + if name.startswith(".__") and name.endswith(".complete"): + _rm(join(part.root_path, name)) + if part.user: _chown(part.root_path, part.user) + if part.group: + _chgrp(part.root_path, part.group) def _fix_pst(part: Partition) -> None: path = os.path.join(part.root_path, "data") _mkdir(path) if part.user: + _chown(part.root_path, part.user) _chown(path, part.user) + if part.group: + _chgrp(part.root_path, part.group) + _chgrp(path, part.group) + if part.user and part.group: + _chmod(part.root_path, 0o1775) # ===== diff --git a/kvmd/helpers/remount/__main__.py b/kvmd/helpers/remount/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/helpers/remount/__main__.py +++ b/kvmd/helpers/remount/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/helpers/swapfiles/__init__.py b/kvmd/helpers/swapfiles/__init__.py index af6e17056..f4affe01e 100644 --- a/kvmd/helpers/swapfiles/__init__.py +++ b/kvmd/helpers/swapfiles/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/helpers/swapfiles/__main__.py b/kvmd/helpers/swapfiles/__main__.py index 73bb60b3a..4827fc498 100644 --- a/kvmd/helpers/swapfiles/__main__.py +++ b/kvmd/helpers/swapfiles/__main__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/htclient.py b/kvmd/htclient.py index 53eebe4ed..712922c4d 100644 --- a/kvmd/htclient.py +++ b/kvmd/htclient.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -36,27 +36,27 @@ def make_user_agent(app: str) -> str: return f"{app}/{__version__}" -def raise_not_200(response: aiohttp.ClientResponse) -> None: - if response.status != 200: - assert response.reason is not None - response.release() +def raise_not_200(resp: aiohttp.ClientResponse) -> None: + if resp.status != 200: + assert resp.reason is not None + resp.release() raise aiohttp.ClientResponseError( - response.request_info, - response.history, - status=response.status, - message=response.reason, - headers=response.headers, + resp.request_info, + resp.history, + status=resp.status, + message=resp.reason, + headers=resp.headers, ) -def get_filename(response: aiohttp.ClientResponse) -> str: +def get_filename(resp: aiohttp.ClientResponse) -> str: try: - disp = response.headers["Content-Disposition"] + disp = resp.headers["Content-Disposition"] parsed = aiohttp.multipart.parse_content_disposition(disp) return str(parsed[1]["filename"]) except Exception: try: - return os.path.basename(response.url.path) + return os.path.basename(resp.url.path) except Exception: raise aiohttp.ClientError("Can't determine filename") @@ -70,15 +70,15 @@ async def download( app: str="KVMD", ) -> AsyncGenerator[aiohttp.ClientResponse, None]: - kwargs: dict = { - "headers": {"User-Agent": make_user_agent(app)}, - "timeout": aiohttp.ClientTimeout( + async with aiohttp.ClientSession( + headers={"User-Agent": make_user_agent(app)}, + timeout=aiohttp.ClientTimeout( connect=timeout, sock_connect=timeout, sock_read=(read_timeout if read_timeout is not None else timeout), ), - } - async with aiohttp.ClientSession(**kwargs) as session: - async with session.get(url, verify_ssl=verify) as response: - raise_not_200(response) - yield response + ) as session: + + async with session.get(url, verify_ssl=verify) as resp: # type: ignore + raise_not_200(resp) + yield resp diff --git a/kvmd/htserver.py b/kvmd/htserver.py index fa2aedc4c..8e55c214b 100644 --- a/kvmd/htserver.py +++ b/kvmd/htserver.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,6 +22,7 @@ import os import socket +import struct import asyncio import contextlib import dataclasses @@ -41,14 +42,10 @@ from aiohttp.web import WebSocketResponse from aiohttp.web import WSMsgType from aiohttp.web import Application +from aiohttp.web import AccessLogger from aiohttp.web import run_app from aiohttp.web import normalize_path_middleware -try: - from aiohttp.web import AccessLogger # type: ignore -except ImportError: - from aiohttp.helpers import AccessLogger # type: ignore - from .logging import get_logger from .errors import OperationError @@ -87,6 +84,7 @@ class HttpExposed: method: str path: str auth_required: bool + allow_usc: bool handler: Callable @@ -94,14 +92,22 @@ class HttpExposed: _HTTP_METHOD = "_http_method" _HTTP_PATH = "_http_path" _HTTP_AUTH_REQUIRED = "_http_auth_required" +_HTTP_ALLOW_USC = "_http_allow_usc" + +def exposed_http( + http_method: str, + path: str, + auth_required: bool=True, + allow_usc: bool=True, +) -> Callable: -def exposed_http(http_method: str, path: str, auth_required: bool=True) -> Callable: def set_attrs(handler: Callable) -> Callable: setattr(handler, _HTTP_EXPOSED, True) setattr(handler, _HTTP_METHOD, http_method) setattr(handler, _HTTP_PATH, path) setattr(handler, _HTTP_AUTH_REQUIRED, auth_required) + setattr(handler, _HTTP_ALLOW_USC, allow_usc) return handler return set_attrs @@ -112,6 +118,7 @@ def _get_exposed_http(obj: object) -> list[HttpExposed]: method=getattr(handler, _HTTP_METHOD), path=getattr(handler, _HTTP_PATH), auth_required=getattr(handler, _HTTP_AUTH_REQUIRED), + allow_usc=getattr(handler, _HTTP_ALLOW_USC), handler=handler, ) for handler in [getattr(obj, name) for name in dir(obj)] @@ -161,7 +168,7 @@ def make_json_response( wrap_result: bool=True, ) -> Response: - response = Response( + resp = Response( text=json.dumps(({ "ok": (status == 200), "result": (result or {}), @@ -171,18 +178,18 @@ def make_json_response( ) if set_cookies: for (key, value) in set_cookies.items(): - response.set_cookie(key, value) - return response + resp.set_cookie(key, value, httponly=True, samesite="Strict") + return resp -def make_json_exception(err: Exception, status: (int | None)=None) -> Response: - name = type(err).__name__ - msg = str(err) - if isinstance(err, HttpError): - status = err.status +def make_json_exception(ex: Exception, status: (int | None)=None) -> Response: + name = type(ex).__name__ + msg = str(ex) + if isinstance(ex, HttpError): + status = ex.status else: get_logger().error("API error: %s: %s", name, msg) - assert status is not None, err + assert status is not None, ex return make_json_response({ "error": name, "error_msg": msg, @@ -190,35 +197,35 @@ def make_json_exception(err: Exception, status: (int | None)=None) -> Response: async def start_streaming( - request: Request, + req: Request, content_type: str, content_length: int=-1, file_name: str="", ) -> StreamResponse: - response = StreamResponse(status=200, reason="OK") - response.content_type = content_type - if content_length >= 0: - response.content_length = content_length + resp = StreamResponse(status=200, reason="OK") + resp.content_type = content_type + if content_length >= 0: # pylint: disable=consider-using-min-builtin + resp.content_length = content_length if file_name: file_name = urllib.parse.quote(file_name, safe="") - response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{file_name}" - await response.prepare(request) - return response + resp.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{file_name}" + await resp.prepare(req) + return resp -async def stream_json(response: StreamResponse, result: dict, ok: bool=True) -> None: - await response.write(json.dumps({ +async def stream_json(resp: StreamResponse, result: dict, ok: bool=True) -> None: + await resp.write(json.dumps({ "ok": ok, "result": result, }).encode("utf-8") + b"\r\n") -async def stream_json_exception(response: StreamResponse, err: Exception) -> None: - name = type(err).__name__ - msg = str(err) +async def stream_json_exception(resp: StreamResponse, ex: Exception) -> None: + name = type(ex).__name__ + msg = str(ex) get_logger().error("API error: %s: %s", name, msg) - await stream_json(response, { + await stream_json(resp, { "error": name, "error_msg": msg, }, False) @@ -236,6 +243,16 @@ async def send_ws_event( })) +async def send_ws_bin( + wsr: (ClientWebSocketResponse | WebSocketResponse), + op: int, + data: bytes, +) -> None: + + assert 0 <= op <= 255 + await wsr.send_bytes(op.to_bytes() + data) + + def parse_ws_event(msg: str) -> tuple[str, dict]: data = json.loads(msg) if not isinstance(data, dict): @@ -251,31 +268,86 @@ def parse_ws_event(msg: str) -> tuple[str, dict]: # ===== _REQUEST_AUTH_INFO = "_kvmd_auth_info" +_REQUEST_AUTH_TOKEN = "_kvmd_auth_token" -def _format_P(request: BaseRequest, *_, **__) -> str: # type: ignore # pylint: disable=invalid-name - return (getattr(request, _REQUEST_AUTH_INFO, None) or "-") +def _format_P(req: BaseRequest, *_, **__) -> str: # type: ignore # pylint: disable=invalid-name + return (getattr(req, _REQUEST_AUTH_INFO, None) or "-") AccessLogger._format_P = staticmethod(_format_P) # type: ignore # pylint: disable=protected-access -def set_request_auth_info(request: BaseRequest, info: str) -> None: - setattr(request, _REQUEST_AUTH_INFO, info) +def set_request_auth_info(req: BaseRequest, info: str, token: str="") -> None: + setattr(req, _REQUEST_AUTH_INFO, info) + setattr(req, _REQUEST_AUTH_TOKEN, token) + + +def _get_request_auth_token(req: BaseRequest) -> str: + return str(getattr(req, _REQUEST_AUTH_TOKEN, "")) + + +@dataclasses.dataclass(frozen=True) +class RequestUnixCredentials: + pid: int + uid: int + gid: int + + def __post_init__(self) -> None: + assert self.pid >= 0 + assert self.uid >= 0 + assert self.gid >= 0 + + +def get_request_unix_credentials(req: BaseRequest) -> (RequestUnixCredentials | None): + if req.transport is None: + return None + sock = req.transport.get_extra_info("socket") + if sock is None: + return None + try: + data = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("iii")) + except Exception: + return None + (pid, uid, gid) = struct.unpack("iii", data) + if pid < 0 or uid < 0 or gid < 0: + # PID == 0 when the client is outside of server's PID namespace, e.g. when kvmd runs in a container + return None + return RequestUnixCredentials(pid=pid, uid=uid, gid=gid) # ===== @dataclasses.dataclass(frozen=True) class WsSession: - wsr: WebSocketResponse - kwargs: dict[str, Any] + wsr: WebSocketResponse + token: str + kwargs: dict[str, Any] = dataclasses.field(hash=False) def __str__(self) -> str: return f"WsSession(id={id(self)}, {self.kwargs})" + def is_alive(self) -> bool: + return ( + not self.wsr.closed + and self.wsr._req is not None # pylint: disable=protected-access + and self.wsr._req.transport is not None # pylint: disable=protected-access + ) + + async def close(self) -> None: + try: + await self.wsr.close() + except Exception: + pass + async def send_event(self, event_type: str, event: (dict | None)) -> None: await send_ws_event(self.wsr, event_type, event) + async def send_bin(self, op: int, data: bytes) -> None: + await send_ws_bin(self.wsr, op, data) + + async def send_bin_raw(self, data: bytes) -> None: + await self.wsr.send_bytes(data) + class HttpServer: def __init__(self) -> None: @@ -283,7 +355,6 @@ def __init__(self) -> None: self.__ws_handlers: dict[str, Callable] = {} self.__ws_bin_handlers: dict[int, Callable] = {} self.__ws_sessions: list[WsSession] = [] - self.__ws_sessions_lock = asyncio.Lock() def run( self, @@ -298,18 +369,18 @@ def run( if unix_rm and os.path.exists(unix_path): os.remove(unix_path) - server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server_socket.bind(unix_path) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1) + sock.bind(unix_path) if unix_mode: os.chmod(unix_path, unix_mode) run_app( - sock=server_socket, + sock=sock, app=self.__make_app(), shutdown_timeout=1, access_log_format=access_log_format, print=self.__run_app_print, - loop=asyncio.get_event_loop(), ) # ===== @@ -322,16 +393,16 @@ def _add_exposed(self, *objs: object) -> None: self.__add_exposed_ws(ws_exposed) def __add_exposed_http(self, exposed: HttpExposed) -> None: - async def wrapper(request: Request) -> Response: + async def wrapper(req: Request) -> Response: try: - await self._check_request_auth(exposed, request) - return (await exposed.handler(request)) - except IsBusyError as err: - return make_json_exception(err, 409) - except (ValidatorError, OperationError) as err: - return make_json_exception(err, 400) - except HttpError as err: - return make_json_exception(err) + await self._check_request_auth(exposed, req) + return (await exposed.handler(req)) + except IsBusyError as ex: + return make_json_exception(ex, 409) + except (ValidatorError, OperationError) as ex: + return make_json_exception(ex, 400) + except HttpError as ex: + return make_json_exception(ex) self.__app.router.add_route(exposed.method, exposed.path, wrapper) def __add_exposed_ws(self, exposed: WsExposed) -> None: @@ -346,21 +417,20 @@ def __add_exposed_ws(self, exposed: WsExposed) -> None: # ===== @contextlib.asynccontextmanager - async def _ws_session(self, request: Request, **kwargs: Any) -> AsyncGenerator[WsSession, None]: + async def _ws_session(self, req: Request, **kwargs: Any) -> AsyncGenerator[WsSession, None]: assert self.__ws_heartbeat is not None wsr = WebSocketResponse(heartbeat=self.__ws_heartbeat) - await wsr.prepare(request) - ws = WsSession(wsr, kwargs) - - async with self.__ws_sessions_lock: - self.__ws_sessions.append(ws) - get_logger(2).info("Registered new client session: %s; clients now: %d", ws, len(self.__ws_sessions)) + await wsr.prepare(req) + ws = WsSession(wsr, _get_request_auth_token(req), kwargs) try: - await self._on_ws_opened() + self.__add_ws(ws) yield ws finally: - await aiotools.shield_fg(self.__close_ws(ws)) + try: + self.__remove_ws(ws) + finally: + await aiotools.shield_fg(ws.close()) async def _ws_loop(self, ws: WsSession) -> WebSocketResponse: logger = get_logger() @@ -368,8 +438,8 @@ async def _ws_loop(self, ws: WsSession) -> WebSocketResponse: if msg.type == WSMsgType.TEXT: try: (event_type, event) = parse_ws_event(msg.data) - except Exception as err: - logger.error("Can't parse JSON event from websocket: %r", err) + except Exception as ex: + logger.error("Can't parse JSON event from websocket: %r", ex) else: handler = self.__ws_handlers.get(event_type) if handler: @@ -393,35 +463,39 @@ async def _broadcast_ws_event(self, event_type: str, event: (dict | None)) -> No await asyncio.gather(*[ ws.send_event(event_type, event) for ws in self.__ws_sessions - if ( - not ws.wsr.closed - and ws.wsr._req is not None # pylint: disable=protected-access - and ws.wsr._req.transport is not None # pylint: disable=protected-access - ) + if ws.is_alive() ], return_exceptions=True) async def _close_all_wss(self) -> bool: - wss = self._get_wss() + wss = list(self.__ws_sessions) for ws in wss: - await self.__close_ws(ws) + self.__remove_ws(ws) + await asyncio.gather(*[ + ws.close() + for ws in wss + ], return_exceptions=True) return bool(wss) def _get_wss(self) -> list[WsSession]: return list(self.__ws_sessions) - async def __close_ws(self, ws: WsSession) -> None: - async with self.__ws_sessions_lock: - try: - self.__ws_sessions.remove(ws) - get_logger(3).info("Removed client socket: %s; clients now: %d", ws, len(self.__ws_sessions)) - await ws.wsr.close() - except Exception: - pass - await self._on_ws_closed() + def __add_ws(self, ws: WsSession) -> None: + self.__ws_sessions.append(ws) + get_logger(3).info("Registered new client session: %s; clients now: %d", ws, len(self.__ws_sessions)) + self._on_ws_added(ws) + + def __remove_ws(self, ws: WsSession) -> None: + if ws in self.__ws_sessions: + self.__ws_sessions.remove(ws) + get_logger(3).info("Removed client socket: %s; clients now: %d", ws, len(self.__ws_sessions)) + self._on_ws_removed(ws) # ===== - async def _check_request_auth(self, exposed: HttpExposed, request: Request) -> None: + async def _check_request_auth(self, exposed: HttpExposed, req: Request) -> None: + pass + + async def _before_app(self) -> None: pass async def _init_app(self) -> None: @@ -433,15 +507,17 @@ async def _on_shutdown(self) -> None: async def _on_cleanup(self) -> None: pass - async def _on_ws_opened(self) -> None: + def _on_ws_added(self, ws: WsSession) -> None: pass - async def _on_ws_closed(self) -> None: + def _on_ws_removed(self, ws: WsSession) -> None: pass # ===== async def __make_app(self) -> Application: + await self._before_app() + self.__app = Application(middlewares=[normalize_path_middleware( # pylint: disable=attribute-defined-outside-init append_slash=False, remove_slash=True, diff --git a/kvmd/inotify.py b/kvmd/inotify.py index d5677b139..8691ce38e 100644 --- a/kvmd/inotify.py +++ b/kvmd/inotify.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # This source file is partially based on python-watchdog module. # # # @@ -34,7 +34,6 @@ from .logging import get_logger -from . import aiotools from . import libc @@ -130,18 +129,25 @@ class InotifyMask: # | OPEN # ) - # Helper for all modify events - ALL_MODIFY_EVENTS = ( + # Helper for all changes events except MODIFY, because it fires on each write() + ALL_CHANGES_EVENTS = ( CLOSE_WRITE | CREATE | DELETE | DELETE_SELF - | MODIFY | MOVE_SELF | MOVED_FROM | MOVED_TO ) + # Helper for typicals events when we need to restart watcher + ALL_RESTART_EVENTS = ( + DELETE_SELF + | MOVE_SELF + | UNMOUNT + | ISDIR + ) + # Special flags for watch() # DONT_FOLLOW = 0x02000000 # Don't follow a symbolic link # EXCL_UNLINK = 0x04000000 # Exclude events on unlinked objects @@ -172,6 +178,10 @@ class InotifyEvent: name: str path: str + @property + def restart(self) -> bool: + return bool(self.mask & InotifyMask.ALL_RESTART_EVENTS) + def __repr__(self) -> str: return ( f" None: self.__moved: dict[int, str] = {} - self.__events_queue: "asyncio.Queue[InotifyEvent]" = asyncio.Queue() + self.__events_q: "asyncio.Queue[InotifyEvent]" = asyncio.Queue() + + async def watch_all_changes(self, *paths: str) -> None: + await self.watch(InotifyMask.ALL_CHANGES_EVENTS, *paths) async def watch(self, mask: int, *paths: str) -> None: for path in paths: path = os.path.normpath(path) assert path not in self.__wd_by_path, path - get_logger().info("Watching for %s", path) + get_logger(2).info("Watching for %s", path) # Асинхронно, чтобы не висло на NFS - wd = _inotify_check(await aiotools.run_async(libc.inotify_add_watch, self.__fd, _fs_encode(path), mask)) + wd = _inotify_check(await asyncio.to_thread(libc.inotify_add_watch, self.__fd, _fs_encode(path), mask)) self.__wd_by_path[path] = wd self.__path_by_wd[wd] = path @@ -210,19 +223,19 @@ async def watch(self, mask: int, *paths: str) -> None: # del self.__path_by_wd[wd] # def has_events(self) -> bool: -# return (not self.__events_queue.empty()) +# return (not self.__events_q.empty()) async def get_event(self, timeout: float) -> (InotifyEvent | None): assert timeout > 0 try: return (await asyncio.wait_for( - asyncio.ensure_future(self.__events_queue.get()), + asyncio.ensure_future(self.__events_q.get()), timeout=timeout, )) except asyncio.TimeoutError: return None - async def get_series(self, timeout: float) -> list[InotifyEvent]: + async def get_series(self, timeout: float, max_series: int=64) -> list[InotifyEvent]: series: list[InotifyEvent] = [] event = await self.get_event(timeout) if event: @@ -231,6 +244,8 @@ async def get_series(self, timeout: float) -> list[InotifyEvent]: event = await self.get_event(timeout) if event: series.append(event) + if len(series) >= max_series: + break return series def __read_and_queue_events(self) -> None: @@ -257,7 +272,7 @@ def __read_and_queue_events(self) -> None: del self.__wd_by_path[ignored_path] continue - self.__events_queue.put_nowait(event) + self.__events_q.put_nowait(event) def __read_parsed_events(self) -> Generator[InotifyEvent, None, None]: for (wd, mask, cookie, name_bytes) in _inotify_parsed_buffer(self.__read_buffer()): @@ -271,8 +286,8 @@ def __read_buffer(self) -> bytes: while True: try: return os.read(self.__fd, _EVENTS_BUFFER_LENGTH) - except OSError as err: - if err.errno == errno.EINTR: + except OSError as ex: + if ex.errno == errno.EINTR: pass def __enter__(self) -> "Inotify": diff --git a/kvmd/keyboard/__init__.py b/kvmd/keyboard/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/kvmd/keyboard/__init__.py +++ b/kvmd/keyboard/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/keyboard/keysym.py b/kvmd/keyboard/keysym.py index dd311e98e..1abd8ddbf 100644 --- a/kvmd/keyboard/keysym.py +++ b/kvmd/keyboard/keysym.py @@ -22,6 +22,7 @@ import pkgutil import functools +import importlib.util import importlib.machinery import Xlib.keysymdef @@ -29,9 +30,9 @@ from ..logging import get_logger from .mappings import At1Key -from .mappings import WebModifiers +from .mappings import EvdevModifiers from .mappings import X11_TO_AT1 -from .mappings import AT1_TO_WEB +from .mappings import AT1_TO_EVDEV # ===== @@ -41,11 +42,11 @@ class SymmapModifiers: CTRL: int = 0x4 -def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(modifiers, webkey), ...] +def build_symmap(path: str) -> dict[int, dict[int, int]]: # x11 keysym -> [(symmap_mods, evdev_code), ...] # https://github.com/qemu/qemu/blob/95a9457fd44ad97c518858a4e1586a5498f9773c/ui/keymaps.c logger = get_logger() - symmap: dict[int, dict[int, str]] = {} + symmap: dict[int, dict[int, int]] = {} for (src, items) in [ (path, list(_read_keyboard_layout(path).items())), ("", list(X11_TO_AT1.items())), @@ -56,17 +57,17 @@ def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(mod for (code, keys) in items: for key in keys: - web_name = AT1_TO_WEB.get(key.code) - if web_name is not None: + evdev_code = AT1_TO_EVDEV.get(key.code) + if evdev_code is not None: if ( - (web_name in WebModifiers.SHIFTS and key.shift) # pylint: disable=too-many-boolean-expressions - or (web_name in WebModifiers.ALTS and key.altgr) - or (web_name in WebModifiers.CTRLS and key.ctrl) + (evdev_code in EvdevModifiers.SHIFTS and key.shift) # pylint: disable=too-many-boolean-expressions + or (evdev_code in EvdevModifiers.ALTS and key.altgr) + or (evdev_code in EvdevModifiers.CTRLS and key.ctrl) ): - logger.error("Invalid modifier key at mapping %s: %s / %s", src, web_name, key) + logger.error("Invalid modifier key at mapping %s: %s / %s", src, evdev_code, key) continue - modifiers = ( + mods = ( 0 | (SymmapModifiers.SHIFT if key.shift else 0) | (SymmapModifiers.ALTGR if key.altgr else 0) @@ -74,7 +75,7 @@ def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(mod ) if code not in symmap: symmap[code] = {} - symmap[code].setdefault(modifiers, web_name) + symmap[code].setdefault(mods, evdev_code) return symmap @@ -87,10 +88,11 @@ def _get_keysyms() -> dict[str, int]: for (finder, module_name, _) in pkgutil.walk_packages(Xlib.keysymdef.__path__): if not isinstance(finder, importlib.machinery.FileFinder): continue - loader = finder.find_module(module_name) - if loader is None: + spec = finder.find_spec(module_name) + if spec is None or spec.loader is None: continue - module = loader.load_module(module_name) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) for keysym_name in dir(module): if keysym_name.startswith("XK_"): short_name = keysym_name[3:] @@ -133,8 +135,8 @@ def _read_keyboard_layout(path: str) -> dict[int, list[At1Key]]: # Keysym to ev try: at1_code = int(parts[1], 16) - except ValueError as err: - logger.error("Syntax error at %s:%d: %s", path, lineno, err) + except ValueError as ex: + logger.error("Syntax error at %s:%d: %s", path, lineno, ex) continue rest = parts[2:] diff --git a/kvmd/keyboard/magic.py b/kvmd/keyboard/magic.py new file mode 100644 index 000000000..915ef8e05 --- /dev/null +++ b/kvmd/keyboard/magic.py @@ -0,0 +1,82 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import time + +from typing import Callable +from typing import Awaitable + +from evdev import ecodes + + +# ===== +class MagicHandler: + __MAGIC_KEY = ecodes.KEY_LEFTALT + __MAGIC_TIMEOUT = 2 + __MAGIC_TRIGGER = 2 + + def __init__( + self, + proxy_handler: Callable[[int, bool], Awaitable[None]], + key_handlers: (dict[int, Callable[[], Awaitable[None]]] | None)=None, + numeric_handler: (Callable[[list[int]], Awaitable[bool]] | None)=None, + ) -> None: + + self.__proxy_handler = proxy_handler + self.__key_handlers = (key_handlers or {}) + self.__numeric_handler = numeric_handler + + self.__taps = 0 + self.__ts = 0.0 + self.__codes: list[int] = [] + + async def handle_key(self, key: int, state: bool) -> None: # pylint: disable=too-many-branches + if self.__ts + self.__MAGIC_TIMEOUT < time.monotonic(): + self.__taps = 0 + self.__ts = 0 + self.__codes = [] + + if key == self.__MAGIC_KEY: + if not state: + self.__taps += 1 + self.__ts = time.monotonic() + elif state: + taps = self.__taps + codes = self.__codes + self.__taps = 0 + self.__ts = 0 + self.__codes = [] + if taps >= self.__MAGIC_TRIGGER: + if key in self.__key_handlers: + await self.__key_handlers[key]() + return + elif self.__numeric_handler is not None and (ecodes.KEY_1 <= key <= ecodes.KEY_8): + codes.append(key - ecodes.KEY_1) + if not (await self.__numeric_handler(list(codes))): + # Если хандлер хочет код большей длины, он возвращает False, + # и мы ждем следующую цифру. + self.__taps = taps + self.__ts = time.monotonic() + self.__codes = codes + return + + await self.__proxy_handler(key, state) diff --git a/kvmd/keyboard/mappings.py b/kvmd/keyboard/mappings.py index 449864aa9..49f0edb1a 100644 --- a/kvmd/keyboard/mappings.py +++ b/kvmd/keyboard/mappings.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,6 +22,8 @@ import dataclasses +from evdev import ecodes + # ===== @dataclasses.dataclass(frozen=True) @@ -31,8 +33,8 @@ class McuKey: @dataclasses.dataclass(frozen=True) class UsbKey: - code: int - is_modifier: bool + code: int + is_mod: bool @dataclasses.dataclass(frozen=True) @@ -41,134 +43,263 @@ class Key: usb: UsbKey -KEYMAP: dict[str, Key] = { - "KeyA": Key(mcu=McuKey(code=1), usb=UsbKey(code=4, is_modifier=False)), - "KeyB": Key(mcu=McuKey(code=2), usb=UsbKey(code=5, is_modifier=False)), - "KeyC": Key(mcu=McuKey(code=3), usb=UsbKey(code=6, is_modifier=False)), - "KeyD": Key(mcu=McuKey(code=4), usb=UsbKey(code=7, is_modifier=False)), - "KeyE": Key(mcu=McuKey(code=5), usb=UsbKey(code=8, is_modifier=False)), - "KeyF": Key(mcu=McuKey(code=6), usb=UsbKey(code=9, is_modifier=False)), - "KeyG": Key(mcu=McuKey(code=7), usb=UsbKey(code=10, is_modifier=False)), - "KeyH": Key(mcu=McuKey(code=8), usb=UsbKey(code=11, is_modifier=False)), - "KeyI": Key(mcu=McuKey(code=9), usb=UsbKey(code=12, is_modifier=False)), - "KeyJ": Key(mcu=McuKey(code=10), usb=UsbKey(code=13, is_modifier=False)), - "KeyK": Key(mcu=McuKey(code=11), usb=UsbKey(code=14, is_modifier=False)), - "KeyL": Key(mcu=McuKey(code=12), usb=UsbKey(code=15, is_modifier=False)), - "KeyM": Key(mcu=McuKey(code=13), usb=UsbKey(code=16, is_modifier=False)), - "KeyN": Key(mcu=McuKey(code=14), usb=UsbKey(code=17, is_modifier=False)), - "KeyO": Key(mcu=McuKey(code=15), usb=UsbKey(code=18, is_modifier=False)), - "KeyP": Key(mcu=McuKey(code=16), usb=UsbKey(code=19, is_modifier=False)), - "KeyQ": Key(mcu=McuKey(code=17), usb=UsbKey(code=20, is_modifier=False)), - "KeyR": Key(mcu=McuKey(code=18), usb=UsbKey(code=21, is_modifier=False)), - "KeyS": Key(mcu=McuKey(code=19), usb=UsbKey(code=22, is_modifier=False)), - "KeyT": Key(mcu=McuKey(code=20), usb=UsbKey(code=23, is_modifier=False)), - "KeyU": Key(mcu=McuKey(code=21), usb=UsbKey(code=24, is_modifier=False)), - "KeyV": Key(mcu=McuKey(code=22), usb=UsbKey(code=25, is_modifier=False)), - "KeyW": Key(mcu=McuKey(code=23), usb=UsbKey(code=26, is_modifier=False)), - "KeyX": Key(mcu=McuKey(code=24), usb=UsbKey(code=27, is_modifier=False)), - "KeyY": Key(mcu=McuKey(code=25), usb=UsbKey(code=28, is_modifier=False)), - "KeyZ": Key(mcu=McuKey(code=26), usb=UsbKey(code=29, is_modifier=False)), - "Digit1": Key(mcu=McuKey(code=27), usb=UsbKey(code=30, is_modifier=False)), - "Digit2": Key(mcu=McuKey(code=28), usb=UsbKey(code=31, is_modifier=False)), - "Digit3": Key(mcu=McuKey(code=29), usb=UsbKey(code=32, is_modifier=False)), - "Digit4": Key(mcu=McuKey(code=30), usb=UsbKey(code=33, is_modifier=False)), - "Digit5": Key(mcu=McuKey(code=31), usb=UsbKey(code=34, is_modifier=False)), - "Digit6": Key(mcu=McuKey(code=32), usb=UsbKey(code=35, is_modifier=False)), - "Digit7": Key(mcu=McuKey(code=33), usb=UsbKey(code=36, is_modifier=False)), - "Digit8": Key(mcu=McuKey(code=34), usb=UsbKey(code=37, is_modifier=False)), - "Digit9": Key(mcu=McuKey(code=35), usb=UsbKey(code=38, is_modifier=False)), - "Digit0": Key(mcu=McuKey(code=36), usb=UsbKey(code=39, is_modifier=False)), - "Enter": Key(mcu=McuKey(code=37), usb=UsbKey(code=40, is_modifier=False)), - "Escape": Key(mcu=McuKey(code=38), usb=UsbKey(code=41, is_modifier=False)), - "Backspace": Key(mcu=McuKey(code=39), usb=UsbKey(code=42, is_modifier=False)), - "Tab": Key(mcu=McuKey(code=40), usb=UsbKey(code=43, is_modifier=False)), - "Space": Key(mcu=McuKey(code=41), usb=UsbKey(code=44, is_modifier=False)), - "Minus": Key(mcu=McuKey(code=42), usb=UsbKey(code=45, is_modifier=False)), - "Equal": Key(mcu=McuKey(code=43), usb=UsbKey(code=46, is_modifier=False)), - "BracketLeft": Key(mcu=McuKey(code=44), usb=UsbKey(code=47, is_modifier=False)), - "BracketRight": Key(mcu=McuKey(code=45), usb=UsbKey(code=48, is_modifier=False)), - "Backslash": Key(mcu=McuKey(code=46), usb=UsbKey(code=49, is_modifier=False)), - "Semicolon": Key(mcu=McuKey(code=47), usb=UsbKey(code=51, is_modifier=False)), - "Quote": Key(mcu=McuKey(code=48), usb=UsbKey(code=52, is_modifier=False)), - "Backquote": Key(mcu=McuKey(code=49), usb=UsbKey(code=53, is_modifier=False)), - "Comma": Key(mcu=McuKey(code=50), usb=UsbKey(code=54, is_modifier=False)), - "Period": Key(mcu=McuKey(code=51), usb=UsbKey(code=55, is_modifier=False)), - "Slash": Key(mcu=McuKey(code=52), usb=UsbKey(code=56, is_modifier=False)), - "CapsLock": Key(mcu=McuKey(code=53), usb=UsbKey(code=57, is_modifier=False)), - "F1": Key(mcu=McuKey(code=54), usb=UsbKey(code=58, is_modifier=False)), - "F2": Key(mcu=McuKey(code=55), usb=UsbKey(code=59, is_modifier=False)), - "F3": Key(mcu=McuKey(code=56), usb=UsbKey(code=60, is_modifier=False)), - "F4": Key(mcu=McuKey(code=57), usb=UsbKey(code=61, is_modifier=False)), - "F5": Key(mcu=McuKey(code=58), usb=UsbKey(code=62, is_modifier=False)), - "F6": Key(mcu=McuKey(code=59), usb=UsbKey(code=63, is_modifier=False)), - "F7": Key(mcu=McuKey(code=60), usb=UsbKey(code=64, is_modifier=False)), - "F8": Key(mcu=McuKey(code=61), usb=UsbKey(code=65, is_modifier=False)), - "F9": Key(mcu=McuKey(code=62), usb=UsbKey(code=66, is_modifier=False)), - "F10": Key(mcu=McuKey(code=63), usb=UsbKey(code=67, is_modifier=False)), - "F11": Key(mcu=McuKey(code=64), usb=UsbKey(code=68, is_modifier=False)), - "F12": Key(mcu=McuKey(code=65), usb=UsbKey(code=69, is_modifier=False)), - "PrintScreen": Key(mcu=McuKey(code=66), usb=UsbKey(code=70, is_modifier=False)), - "Insert": Key(mcu=McuKey(code=67), usb=UsbKey(code=73, is_modifier=False)), - "Home": Key(mcu=McuKey(code=68), usb=UsbKey(code=74, is_modifier=False)), - "PageUp": Key(mcu=McuKey(code=69), usb=UsbKey(code=75, is_modifier=False)), - "Delete": Key(mcu=McuKey(code=70), usb=UsbKey(code=76, is_modifier=False)), - "End": Key(mcu=McuKey(code=71), usb=UsbKey(code=77, is_modifier=False)), - "PageDown": Key(mcu=McuKey(code=72), usb=UsbKey(code=78, is_modifier=False)), - "ArrowRight": Key(mcu=McuKey(code=73), usb=UsbKey(code=79, is_modifier=False)), - "ArrowLeft": Key(mcu=McuKey(code=74), usb=UsbKey(code=80, is_modifier=False)), - "ArrowDown": Key(mcu=McuKey(code=75), usb=UsbKey(code=81, is_modifier=False)), - "ArrowUp": Key(mcu=McuKey(code=76), usb=UsbKey(code=82, is_modifier=False)), - "ControlLeft": Key(mcu=McuKey(code=77), usb=UsbKey(code=1, is_modifier=True)), - "ShiftLeft": Key(mcu=McuKey(code=78), usb=UsbKey(code=2, is_modifier=True)), - "AltLeft": Key(mcu=McuKey(code=79), usb=UsbKey(code=4, is_modifier=True)), - "MetaLeft": Key(mcu=McuKey(code=80), usb=UsbKey(code=8, is_modifier=True)), - "ControlRight": Key(mcu=McuKey(code=81), usb=UsbKey(code=16, is_modifier=True)), - "ShiftRight": Key(mcu=McuKey(code=82), usb=UsbKey(code=32, is_modifier=True)), - "AltRight": Key(mcu=McuKey(code=83), usb=UsbKey(code=64, is_modifier=True)), - "MetaRight": Key(mcu=McuKey(code=84), usb=UsbKey(code=128, is_modifier=True)), - "Pause": Key(mcu=McuKey(code=85), usb=UsbKey(code=72, is_modifier=False)), - "ScrollLock": Key(mcu=McuKey(code=86), usb=UsbKey(code=71, is_modifier=False)), - "NumLock": Key(mcu=McuKey(code=87), usb=UsbKey(code=83, is_modifier=False)), - "ContextMenu": Key(mcu=McuKey(code=88), usb=UsbKey(code=101, is_modifier=False)), - "NumpadDivide": Key(mcu=McuKey(code=89), usb=UsbKey(code=84, is_modifier=False)), - "NumpadMultiply": Key(mcu=McuKey(code=90), usb=UsbKey(code=85, is_modifier=False)), - "NumpadSubtract": Key(mcu=McuKey(code=91), usb=UsbKey(code=86, is_modifier=False)), - "NumpadAdd": Key(mcu=McuKey(code=92), usb=UsbKey(code=87, is_modifier=False)), - "NumpadEnter": Key(mcu=McuKey(code=93), usb=UsbKey(code=88, is_modifier=False)), - "Numpad1": Key(mcu=McuKey(code=94), usb=UsbKey(code=89, is_modifier=False)), - "Numpad2": Key(mcu=McuKey(code=95), usb=UsbKey(code=90, is_modifier=False)), - "Numpad3": Key(mcu=McuKey(code=96), usb=UsbKey(code=91, is_modifier=False)), - "Numpad4": Key(mcu=McuKey(code=97), usb=UsbKey(code=92, is_modifier=False)), - "Numpad5": Key(mcu=McuKey(code=98), usb=UsbKey(code=93, is_modifier=False)), - "Numpad6": Key(mcu=McuKey(code=99), usb=UsbKey(code=94, is_modifier=False)), - "Numpad7": Key(mcu=McuKey(code=100), usb=UsbKey(code=95, is_modifier=False)), - "Numpad8": Key(mcu=McuKey(code=101), usb=UsbKey(code=96, is_modifier=False)), - "Numpad9": Key(mcu=McuKey(code=102), usb=UsbKey(code=97, is_modifier=False)), - "Numpad0": Key(mcu=McuKey(code=103), usb=UsbKey(code=98, is_modifier=False)), - "NumpadDecimal": Key(mcu=McuKey(code=104), usb=UsbKey(code=99, is_modifier=False)), - "Power": Key(mcu=McuKey(code=105), usb=UsbKey(code=102, is_modifier=False)), - "IntlBackslash": Key(mcu=McuKey(code=106), usb=UsbKey(code=100, is_modifier=False)), - "IntlYen": Key(mcu=McuKey(code=107), usb=UsbKey(code=137, is_modifier=False)), - "IntlRo": Key(mcu=McuKey(code=108), usb=UsbKey(code=135, is_modifier=False)), - "KanaMode": Key(mcu=McuKey(code=109), usb=UsbKey(code=136, is_modifier=False)), - "Convert": Key(mcu=McuKey(code=110), usb=UsbKey(code=138, is_modifier=False)), - "NonConvert": Key(mcu=McuKey(code=111), usb=UsbKey(code=139, is_modifier=False)), +KEYMAP: dict[int, Key] = { + ecodes.KEY_A: Key(mcu=McuKey(code=1), usb=UsbKey(code=4, is_mod=False)), + ecodes.KEY_B: Key(mcu=McuKey(code=2), usb=UsbKey(code=5, is_mod=False)), + ecodes.KEY_C: Key(mcu=McuKey(code=3), usb=UsbKey(code=6, is_mod=False)), + ecodes.KEY_D: Key(mcu=McuKey(code=4), usb=UsbKey(code=7, is_mod=False)), + ecodes.KEY_E: Key(mcu=McuKey(code=5), usb=UsbKey(code=8, is_mod=False)), + ecodes.KEY_F: Key(mcu=McuKey(code=6), usb=UsbKey(code=9, is_mod=False)), + ecodes.KEY_G: Key(mcu=McuKey(code=7), usb=UsbKey(code=10, is_mod=False)), + ecodes.KEY_H: Key(mcu=McuKey(code=8), usb=UsbKey(code=11, is_mod=False)), + ecodes.KEY_I: Key(mcu=McuKey(code=9), usb=UsbKey(code=12, is_mod=False)), + ecodes.KEY_J: Key(mcu=McuKey(code=10), usb=UsbKey(code=13, is_mod=False)), + ecodes.KEY_K: Key(mcu=McuKey(code=11), usb=UsbKey(code=14, is_mod=False)), + ecodes.KEY_L: Key(mcu=McuKey(code=12), usb=UsbKey(code=15, is_mod=False)), + ecodes.KEY_M: Key(mcu=McuKey(code=13), usb=UsbKey(code=16, is_mod=False)), + ecodes.KEY_N: Key(mcu=McuKey(code=14), usb=UsbKey(code=17, is_mod=False)), + ecodes.KEY_O: Key(mcu=McuKey(code=15), usb=UsbKey(code=18, is_mod=False)), + ecodes.KEY_P: Key(mcu=McuKey(code=16), usb=UsbKey(code=19, is_mod=False)), + ecodes.KEY_Q: Key(mcu=McuKey(code=17), usb=UsbKey(code=20, is_mod=False)), + ecodes.KEY_R: Key(mcu=McuKey(code=18), usb=UsbKey(code=21, is_mod=False)), + ecodes.KEY_S: Key(mcu=McuKey(code=19), usb=UsbKey(code=22, is_mod=False)), + ecodes.KEY_T: Key(mcu=McuKey(code=20), usb=UsbKey(code=23, is_mod=False)), + ecodes.KEY_U: Key(mcu=McuKey(code=21), usb=UsbKey(code=24, is_mod=False)), + ecodes.KEY_V: Key(mcu=McuKey(code=22), usb=UsbKey(code=25, is_mod=False)), + ecodes.KEY_W: Key(mcu=McuKey(code=23), usb=UsbKey(code=26, is_mod=False)), + ecodes.KEY_X: Key(mcu=McuKey(code=24), usb=UsbKey(code=27, is_mod=False)), + ecodes.KEY_Y: Key(mcu=McuKey(code=25), usb=UsbKey(code=28, is_mod=False)), + ecodes.KEY_Z: Key(mcu=McuKey(code=26), usb=UsbKey(code=29, is_mod=False)), + ecodes.KEY_1: Key(mcu=McuKey(code=27), usb=UsbKey(code=30, is_mod=False)), + ecodes.KEY_2: Key(mcu=McuKey(code=28), usb=UsbKey(code=31, is_mod=False)), + ecodes.KEY_3: Key(mcu=McuKey(code=29), usb=UsbKey(code=32, is_mod=False)), + ecodes.KEY_4: Key(mcu=McuKey(code=30), usb=UsbKey(code=33, is_mod=False)), + ecodes.KEY_5: Key(mcu=McuKey(code=31), usb=UsbKey(code=34, is_mod=False)), + ecodes.KEY_6: Key(mcu=McuKey(code=32), usb=UsbKey(code=35, is_mod=False)), + ecodes.KEY_7: Key(mcu=McuKey(code=33), usb=UsbKey(code=36, is_mod=False)), + ecodes.KEY_8: Key(mcu=McuKey(code=34), usb=UsbKey(code=37, is_mod=False)), + ecodes.KEY_9: Key(mcu=McuKey(code=35), usb=UsbKey(code=38, is_mod=False)), + ecodes.KEY_0: Key(mcu=McuKey(code=36), usb=UsbKey(code=39, is_mod=False)), + ecodes.KEY_ENTER: Key(mcu=McuKey(code=37), usb=UsbKey(code=40, is_mod=False)), + ecodes.KEY_ESC: Key(mcu=McuKey(code=38), usb=UsbKey(code=41, is_mod=False)), + ecodes.KEY_BACKSPACE: Key(mcu=McuKey(code=39), usb=UsbKey(code=42, is_mod=False)), + ecodes.KEY_TAB: Key(mcu=McuKey(code=40), usb=UsbKey(code=43, is_mod=False)), + ecodes.KEY_SPACE: Key(mcu=McuKey(code=41), usb=UsbKey(code=44, is_mod=False)), + ecodes.KEY_MINUS: Key(mcu=McuKey(code=42), usb=UsbKey(code=45, is_mod=False)), + ecodes.KEY_EQUAL: Key(mcu=McuKey(code=43), usb=UsbKey(code=46, is_mod=False)), + ecodes.KEY_LEFTBRACE: Key(mcu=McuKey(code=44), usb=UsbKey(code=47, is_mod=False)), + ecodes.KEY_RIGHTBRACE: Key(mcu=McuKey(code=45), usb=UsbKey(code=48, is_mod=False)), + ecodes.KEY_BACKSLASH: Key(mcu=McuKey(code=46), usb=UsbKey(code=49, is_mod=False)), + ecodes.KEY_SEMICOLON: Key(mcu=McuKey(code=47), usb=UsbKey(code=51, is_mod=False)), + ecodes.KEY_APOSTROPHE: Key(mcu=McuKey(code=48), usb=UsbKey(code=52, is_mod=False)), + ecodes.KEY_GRAVE: Key(mcu=McuKey(code=49), usb=UsbKey(code=53, is_mod=False)), + ecodes.KEY_COMMA: Key(mcu=McuKey(code=50), usb=UsbKey(code=54, is_mod=False)), + ecodes.KEY_DOT: Key(mcu=McuKey(code=51), usb=UsbKey(code=55, is_mod=False)), + ecodes.KEY_SLASH: Key(mcu=McuKey(code=52), usb=UsbKey(code=56, is_mod=False)), + ecodes.KEY_CAPSLOCK: Key(mcu=McuKey(code=53), usb=UsbKey(code=57, is_mod=False)), + ecodes.KEY_F1: Key(mcu=McuKey(code=54), usb=UsbKey(code=58, is_mod=False)), + ecodes.KEY_F2: Key(mcu=McuKey(code=55), usb=UsbKey(code=59, is_mod=False)), + ecodes.KEY_F3: Key(mcu=McuKey(code=56), usb=UsbKey(code=60, is_mod=False)), + ecodes.KEY_F4: Key(mcu=McuKey(code=57), usb=UsbKey(code=61, is_mod=False)), + ecodes.KEY_F5: Key(mcu=McuKey(code=58), usb=UsbKey(code=62, is_mod=False)), + ecodes.KEY_F6: Key(mcu=McuKey(code=59), usb=UsbKey(code=63, is_mod=False)), + ecodes.KEY_F7: Key(mcu=McuKey(code=60), usb=UsbKey(code=64, is_mod=False)), + ecodes.KEY_F8: Key(mcu=McuKey(code=61), usb=UsbKey(code=65, is_mod=False)), + ecodes.KEY_F9: Key(mcu=McuKey(code=62), usb=UsbKey(code=66, is_mod=False)), + ecodes.KEY_F10: Key(mcu=McuKey(code=63), usb=UsbKey(code=67, is_mod=False)), + ecodes.KEY_F11: Key(mcu=McuKey(code=64), usb=UsbKey(code=68, is_mod=False)), + ecodes.KEY_F12: Key(mcu=McuKey(code=65), usb=UsbKey(code=69, is_mod=False)), + ecodes.KEY_SYSRQ: Key(mcu=McuKey(code=66), usb=UsbKey(code=70, is_mod=False)), + ecodes.KEY_INSERT: Key(mcu=McuKey(code=67), usb=UsbKey(code=73, is_mod=False)), + ecodes.KEY_HOME: Key(mcu=McuKey(code=68), usb=UsbKey(code=74, is_mod=False)), + ecodes.KEY_PAGEUP: Key(mcu=McuKey(code=69), usb=UsbKey(code=75, is_mod=False)), + ecodes.KEY_DELETE: Key(mcu=McuKey(code=70), usb=UsbKey(code=76, is_mod=False)), + ecodes.KEY_END: Key(mcu=McuKey(code=71), usb=UsbKey(code=77, is_mod=False)), + ecodes.KEY_PAGEDOWN: Key(mcu=McuKey(code=72), usb=UsbKey(code=78, is_mod=False)), + ecodes.KEY_RIGHT: Key(mcu=McuKey(code=73), usb=UsbKey(code=79, is_mod=False)), + ecodes.KEY_LEFT: Key(mcu=McuKey(code=74), usb=UsbKey(code=80, is_mod=False)), + ecodes.KEY_DOWN: Key(mcu=McuKey(code=75), usb=UsbKey(code=81, is_mod=False)), + ecodes.KEY_UP: Key(mcu=McuKey(code=76), usb=UsbKey(code=82, is_mod=False)), + ecodes.KEY_LEFTCTRL: Key(mcu=McuKey(code=77), usb=UsbKey(code=1, is_mod=True)), + ecodes.KEY_LEFTSHIFT: Key(mcu=McuKey(code=78), usb=UsbKey(code=2, is_mod=True)), + ecodes.KEY_LEFTALT: Key(mcu=McuKey(code=79), usb=UsbKey(code=4, is_mod=True)), + ecodes.KEY_LEFTMETA: Key(mcu=McuKey(code=80), usb=UsbKey(code=8, is_mod=True)), + ecodes.KEY_RIGHTCTRL: Key(mcu=McuKey(code=81), usb=UsbKey(code=16, is_mod=True)), + ecodes.KEY_RIGHTSHIFT: Key(mcu=McuKey(code=82), usb=UsbKey(code=32, is_mod=True)), + ecodes.KEY_RIGHTALT: Key(mcu=McuKey(code=83), usb=UsbKey(code=64, is_mod=True)), + ecodes.KEY_RIGHTMETA: Key(mcu=McuKey(code=84), usb=UsbKey(code=128, is_mod=True)), + ecodes.KEY_PAUSE: Key(mcu=McuKey(code=85), usb=UsbKey(code=72, is_mod=False)), + ecodes.KEY_SCROLLLOCK: Key(mcu=McuKey(code=86), usb=UsbKey(code=71, is_mod=False)), + ecodes.KEY_NUMLOCK: Key(mcu=McuKey(code=87), usb=UsbKey(code=83, is_mod=False)), + ecodes.KEY_CONTEXT_MENU: Key(mcu=McuKey(code=88), usb=UsbKey(code=101, is_mod=False)), + ecodes.KEY_KPSLASH: Key(mcu=McuKey(code=89), usb=UsbKey(code=84, is_mod=False)), + ecodes.KEY_KPASTERISK: Key(mcu=McuKey(code=90), usb=UsbKey(code=85, is_mod=False)), + ecodes.KEY_KPMINUS: Key(mcu=McuKey(code=91), usb=UsbKey(code=86, is_mod=False)), + ecodes.KEY_KPPLUS: Key(mcu=McuKey(code=92), usb=UsbKey(code=87, is_mod=False)), + ecodes.KEY_KPENTER: Key(mcu=McuKey(code=93), usb=UsbKey(code=88, is_mod=False)), + ecodes.KEY_KP1: Key(mcu=McuKey(code=94), usb=UsbKey(code=89, is_mod=False)), + ecodes.KEY_KP2: Key(mcu=McuKey(code=95), usb=UsbKey(code=90, is_mod=False)), + ecodes.KEY_KP3: Key(mcu=McuKey(code=96), usb=UsbKey(code=91, is_mod=False)), + ecodes.KEY_KP4: Key(mcu=McuKey(code=97), usb=UsbKey(code=92, is_mod=False)), + ecodes.KEY_KP5: Key(mcu=McuKey(code=98), usb=UsbKey(code=93, is_mod=False)), + ecodes.KEY_KP6: Key(mcu=McuKey(code=99), usb=UsbKey(code=94, is_mod=False)), + ecodes.KEY_KP7: Key(mcu=McuKey(code=100), usb=UsbKey(code=95, is_mod=False)), + ecodes.KEY_KP8: Key(mcu=McuKey(code=101), usb=UsbKey(code=96, is_mod=False)), + ecodes.KEY_KP9: Key(mcu=McuKey(code=102), usb=UsbKey(code=97, is_mod=False)), + ecodes.KEY_KP0: Key(mcu=McuKey(code=103), usb=UsbKey(code=98, is_mod=False)), + ecodes.KEY_KPDOT: Key(mcu=McuKey(code=104), usb=UsbKey(code=99, is_mod=False)), + ecodes.KEY_POWER: Key(mcu=McuKey(code=105), usb=UsbKey(code=102, is_mod=False)), + ecodes.KEY_102ND: Key(mcu=McuKey(code=106), usb=UsbKey(code=100, is_mod=False)), + ecodes.KEY_YEN: Key(mcu=McuKey(code=107), usb=UsbKey(code=137, is_mod=False)), + ecodes.KEY_RO: Key(mcu=McuKey(code=108), usb=UsbKey(code=135, is_mod=False)), + ecodes.KEY_KATAKANA: Key(mcu=McuKey(code=109), usb=UsbKey(code=136, is_mod=False)), + ecodes.KEY_HENKAN: Key(mcu=McuKey(code=110), usb=UsbKey(code=138, is_mod=False)), + ecodes.KEY_MUHENKAN: Key(mcu=McuKey(code=111), usb=UsbKey(code=139, is_mod=False)), + ecodes.KEY_MUTE: Key(mcu=McuKey(code=112), usb=UsbKey(code=127, is_mod=False)), + ecodes.KEY_VOLUMEUP: Key(mcu=McuKey(code=113), usb=UsbKey(code=128, is_mod=False)), + ecodes.KEY_VOLUMEDOWN: Key(mcu=McuKey(code=114), usb=UsbKey(code=129, is_mod=False)), + ecodes.KEY_F20: Key(mcu=McuKey(code=115), usb=UsbKey(code=111, is_mod=False)), +} + + +WEB_TO_EVDEV = { + "KeyA": ecodes.KEY_A, + "KeyB": ecodes.KEY_B, + "KeyC": ecodes.KEY_C, + "KeyD": ecodes.KEY_D, + "KeyE": ecodes.KEY_E, + "KeyF": ecodes.KEY_F, + "KeyG": ecodes.KEY_G, + "KeyH": ecodes.KEY_H, + "KeyI": ecodes.KEY_I, + "KeyJ": ecodes.KEY_J, + "KeyK": ecodes.KEY_K, + "KeyL": ecodes.KEY_L, + "KeyM": ecodes.KEY_M, + "KeyN": ecodes.KEY_N, + "KeyO": ecodes.KEY_O, + "KeyP": ecodes.KEY_P, + "KeyQ": ecodes.KEY_Q, + "KeyR": ecodes.KEY_R, + "KeyS": ecodes.KEY_S, + "KeyT": ecodes.KEY_T, + "KeyU": ecodes.KEY_U, + "KeyV": ecodes.KEY_V, + "KeyW": ecodes.KEY_W, + "KeyX": ecodes.KEY_X, + "KeyY": ecodes.KEY_Y, + "KeyZ": ecodes.KEY_Z, + "Digit1": ecodes.KEY_1, + "Digit2": ecodes.KEY_2, + "Digit3": ecodes.KEY_3, + "Digit4": ecodes.KEY_4, + "Digit5": ecodes.KEY_5, + "Digit6": ecodes.KEY_6, + "Digit7": ecodes.KEY_7, + "Digit8": ecodes.KEY_8, + "Digit9": ecodes.KEY_9, + "Digit0": ecodes.KEY_0, + "Enter": ecodes.KEY_ENTER, + "Escape": ecodes.KEY_ESC, + "Backspace": ecodes.KEY_BACKSPACE, + "Tab": ecodes.KEY_TAB, + "Space": ecodes.KEY_SPACE, + "Minus": ecodes.KEY_MINUS, + "Equal": ecodes.KEY_EQUAL, + "BracketLeft": ecodes.KEY_LEFTBRACE, + "BracketRight": ecodes.KEY_RIGHTBRACE, + "Backslash": ecodes.KEY_BACKSLASH, + "Semicolon": ecodes.KEY_SEMICOLON, + "Quote": ecodes.KEY_APOSTROPHE, + "Backquote": ecodes.KEY_GRAVE, + "Comma": ecodes.KEY_COMMA, + "Period": ecodes.KEY_DOT, + "Slash": ecodes.KEY_SLASH, + "CapsLock": ecodes.KEY_CAPSLOCK, + "F1": ecodes.KEY_F1, + "F2": ecodes.KEY_F2, + "F3": ecodes.KEY_F3, + "F4": ecodes.KEY_F4, + "F5": ecodes.KEY_F5, + "F6": ecodes.KEY_F6, + "F7": ecodes.KEY_F7, + "F8": ecodes.KEY_F8, + "F9": ecodes.KEY_F9, + "F10": ecodes.KEY_F10, + "F11": ecodes.KEY_F11, + "F12": ecodes.KEY_F12, + "PrintScreen": ecodes.KEY_SYSRQ, + "Insert": ecodes.KEY_INSERT, + "Home": ecodes.KEY_HOME, + "PageUp": ecodes.KEY_PAGEUP, + "Delete": ecodes.KEY_DELETE, + "End": ecodes.KEY_END, + "PageDown": ecodes.KEY_PAGEDOWN, + "ArrowRight": ecodes.KEY_RIGHT, + "ArrowLeft": ecodes.KEY_LEFT, + "ArrowDown": ecodes.KEY_DOWN, + "ArrowUp": ecodes.KEY_UP, + "ControlLeft": ecodes.KEY_LEFTCTRL, + "ShiftLeft": ecodes.KEY_LEFTSHIFT, + "AltLeft": ecodes.KEY_LEFTALT, + "MetaLeft": ecodes.KEY_LEFTMETA, + "ControlRight": ecodes.KEY_RIGHTCTRL, + "ShiftRight": ecodes.KEY_RIGHTSHIFT, + "AltRight": ecodes.KEY_RIGHTALT, + "MetaRight": ecodes.KEY_RIGHTMETA, + "Pause": ecodes.KEY_PAUSE, + "ScrollLock": ecodes.KEY_SCROLLLOCK, + "NumLock": ecodes.KEY_NUMLOCK, + "ContextMenu": ecodes.KEY_CONTEXT_MENU, + "NumpadDivide": ecodes.KEY_KPSLASH, + "NumpadMultiply": ecodes.KEY_KPASTERISK, + "NumpadSubtract": ecodes.KEY_KPMINUS, + "NumpadAdd": ecodes.KEY_KPPLUS, + "NumpadEnter": ecodes.KEY_KPENTER, + "Numpad1": ecodes.KEY_KP1, + "Numpad2": ecodes.KEY_KP2, + "Numpad3": ecodes.KEY_KP3, + "Numpad4": ecodes.KEY_KP4, + "Numpad5": ecodes.KEY_KP5, + "Numpad6": ecodes.KEY_KP6, + "Numpad7": ecodes.KEY_KP7, + "Numpad8": ecodes.KEY_KP8, + "Numpad9": ecodes.KEY_KP9, + "Numpad0": ecodes.KEY_KP0, + "NumpadDecimal": ecodes.KEY_KPDOT, + "Power": ecodes.KEY_POWER, + "IntlBackslash": ecodes.KEY_102ND, + "IntlYen": ecodes.KEY_YEN, + "IntlRo": ecodes.KEY_RO, + "KanaMode": ecodes.KEY_KATAKANA, + "Convert": ecodes.KEY_HENKAN, + "NonConvert": ecodes.KEY_MUHENKAN, + "AudioVolumeMute": ecodes.KEY_MUTE, + "AudioVolumeUp": ecodes.KEY_VOLUMEUP, + "AudioVolumeDown": ecodes.KEY_VOLUMEDOWN, + "F20": ecodes.KEY_F20, } # ===== -class WebModifiers: - SHIFT_LEFT = "ShiftLeft" - SHIFT_RIGHT = "ShiftRight" +class EvdevModifiers: + SHIFT_LEFT = ecodes.KEY_LEFTSHIFT + SHIFT_RIGHT = ecodes.KEY_RIGHTSHIFT SHIFTS = set([SHIFT_LEFT, SHIFT_RIGHT]) - ALT_LEFT = "AltLeft" - ALT_RIGHT = "AltRight" + ALT_LEFT = ecodes.KEY_LEFTALT + ALT_RIGHT = ecodes.KEY_RIGHTALT ALTS = set([ALT_LEFT, ALT_RIGHT]) - CTRL_LEFT = "ControlLeft" - CTRL_RIGHT = "ControlRight" - CTRLS = set([CTRL_RIGHT, CTRL_RIGHT]) + CTRL_LEFT = ecodes.KEY_LEFTCTRL + CTRL_RIGHT = ecodes.KEY_RIGHTCTRL + CTRLS = set([CTRL_LEFT, CTRL_RIGHT]) + + META_LEFT = ecodes.KEY_LEFTMETA + META_RIGHT = ecodes.KEY_RIGHTMETA + METAS = set([META_LEFT, META_RIGHT]) + + ALL = (SHIFTS | ALTS | CTRLS | METAS) class X11Modifiers: @@ -186,10 +317,10 @@ class X11Modifiers: # ===== @dataclasses.dataclass(frozen=True) class At1Key: - code: int + code: int shift: bool altgr: bool = False - ctrl: bool = False + ctrl: bool = False X11_TO_AT1 = { @@ -351,116 +482,120 @@ class At1Key: } -AT1_TO_WEB = { - 1: "Escape", - 2: "Digit1", - 3: "Digit2", - 4: "Digit3", - 5: "Digit4", - 6: "Digit5", - 7: "Digit6", - 8: "Digit7", - 9: "Digit8", - 10: "Digit9", - 11: "Digit0", - 12: "Minus", - 13: "Equal", - 14: "Backspace", - 15: "Tab", - 16: "KeyQ", - 17: "KeyW", - 18: "KeyE", - 19: "KeyR", - 20: "KeyT", - 21: "KeyY", - 22: "KeyU", - 23: "KeyI", - 24: "KeyO", - 25: "KeyP", - 26: "BracketLeft", - 27: "BracketRight", - 28: "Enter", - 29: "ControlLeft", - 30: "KeyA", - 31: "KeyS", - 32: "KeyD", - 33: "KeyF", - 34: "KeyG", - 35: "KeyH", - 36: "KeyJ", - 37: "KeyK", - 38: "KeyL", - 39: "Semicolon", - 40: "Quote", - 41: "Backquote", - 42: "ShiftLeft", - 43: "Backslash", - 44: "KeyZ", - 45: "KeyX", - 46: "KeyC", - 47: "KeyV", - 48: "KeyB", - 49: "KeyN", - 50: "KeyM", - 51: "Comma", - 52: "Period", - 53: "Slash", - 54: "ShiftRight", - 55: "NumpadMultiply", - 56: "AltLeft", - 57: "Space", - 58: "CapsLock", - 59: "F1", - 60: "F2", - 61: "F3", - 62: "F4", - 63: "F5", - 64: "F6", - 65: "F7", - 66: "F8", - 67: "F9", - 68: "F10", - 69: "NumLock", - 70: "ScrollLock", - 71: "Numpad7", - 72: "Numpad8", - 73: "Numpad9", - 74: "NumpadSubtract", - 75: "Numpad4", - 76: "Numpad5", - 77: "Numpad6", - 78: "NumpadAdd", - 79: "Numpad1", - 80: "Numpad2", - 81: "Numpad3", - 82: "Numpad0", - 83: "NumpadDecimal", - 84: "PrintScreen", - 86: "IntlBackslash", - 87: "F11", - 88: "F12", - 112: "KanaMode", - 115: "IntlRo", - 121: "Convert", - 123: "NonConvert", - 125: "IntlYen", - 57372: "NumpadEnter", - 57373: "ControlRight", - 57397: "NumpadDivide", - 57400: "AltRight", - 57414: "Pause", - 57415: "Home", - 57416: "ArrowUp", - 57417: "PageUp", - 57419: "ArrowLeft", - 57421: "ArrowRight", - 57423: "End", - 57424: "ArrowDown", - 57425: "PageDown", - 57426: "Insert", - 57427: "Delete", - 57435: "MetaLeft", - 57436: "MetaRight", - 57437: "ContextMenu", - 57438: "Power", +AT1_TO_EVDEV = { + 1: ecodes.KEY_ESC, + 2: ecodes.KEY_1, + 3: ecodes.KEY_2, + 4: ecodes.KEY_3, + 5: ecodes.KEY_4, + 6: ecodes.KEY_5, + 7: ecodes.KEY_6, + 8: ecodes.KEY_7, + 9: ecodes.KEY_8, + 10: ecodes.KEY_9, + 11: ecodes.KEY_0, + 12: ecodes.KEY_MINUS, + 13: ecodes.KEY_EQUAL, + 14: ecodes.KEY_BACKSPACE, + 15: ecodes.KEY_TAB, + 16: ecodes.KEY_Q, + 17: ecodes.KEY_W, + 18: ecodes.KEY_E, + 19: ecodes.KEY_R, + 20: ecodes.KEY_T, + 21: ecodes.KEY_Y, + 22: ecodes.KEY_U, + 23: ecodes.KEY_I, + 24: ecodes.KEY_O, + 25: ecodes.KEY_P, + 26: ecodes.KEY_LEFTBRACE, + 27: ecodes.KEY_RIGHTBRACE, + 28: ecodes.KEY_ENTER, + 29: ecodes.KEY_LEFTCTRL, + 30: ecodes.KEY_A, + 31: ecodes.KEY_S, + 32: ecodes.KEY_D, + 33: ecodes.KEY_F, + 34: ecodes.KEY_G, + 35: ecodes.KEY_H, + 36: ecodes.KEY_J, + 37: ecodes.KEY_K, + 38: ecodes.KEY_L, + 39: ecodes.KEY_SEMICOLON, + 40: ecodes.KEY_APOSTROPHE, + 41: ecodes.KEY_GRAVE, + 42: ecodes.KEY_LEFTSHIFT, + 43: ecodes.KEY_BACKSLASH, + 44: ecodes.KEY_Z, + 45: ecodes.KEY_X, + 46: ecodes.KEY_C, + 47: ecodes.KEY_V, + 48: ecodes.KEY_B, + 49: ecodes.KEY_N, + 50: ecodes.KEY_M, + 51: ecodes.KEY_COMMA, + 52: ecodes.KEY_DOT, + 53: ecodes.KEY_SLASH, + 54: ecodes.KEY_RIGHTSHIFT, + 55: ecodes.KEY_KPASTERISK, + 56: ecodes.KEY_LEFTALT, + 57: ecodes.KEY_SPACE, + 58: ecodes.KEY_CAPSLOCK, + 59: ecodes.KEY_F1, + 60: ecodes.KEY_F2, + 61: ecodes.KEY_F3, + 62: ecodes.KEY_F4, + 63: ecodes.KEY_F5, + 64: ecodes.KEY_F6, + 65: ecodes.KEY_F7, + 66: ecodes.KEY_F8, + 67: ecodes.KEY_F9, + 68: ecodes.KEY_F10, + 69: ecodes.KEY_NUMLOCK, + 70: ecodes.KEY_SCROLLLOCK, + 71: ecodes.KEY_KP7, + 72: ecodes.KEY_KP8, + 73: ecodes.KEY_KP9, + 74: ecodes.KEY_KPMINUS, + 75: ecodes.KEY_KP4, + 76: ecodes.KEY_KP5, + 77: ecodes.KEY_KP6, + 78: ecodes.KEY_KPPLUS, + 79: ecodes.KEY_KP1, + 80: ecodes.KEY_KP2, + 81: ecodes.KEY_KP3, + 82: ecodes.KEY_KP0, + 83: ecodes.KEY_KPDOT, + 84: ecodes.KEY_SYSRQ, + 86: ecodes.KEY_102ND, + 87: ecodes.KEY_F11, + 88: ecodes.KEY_F12, + 90: ecodes.KEY_F20, + 112: ecodes.KEY_KATAKANA, + 115: ecodes.KEY_RO, + 121: ecodes.KEY_HENKAN, + 123: ecodes.KEY_MUHENKAN, + 125: ecodes.KEY_YEN, + 57372: ecodes.KEY_KPENTER, + 57373: ecodes.KEY_RIGHTCTRL, + 57376: ecodes.KEY_MUTE, + 57390: ecodes.KEY_VOLUMEDOWN, + 57392: ecodes.KEY_VOLUMEUP, + 57397: ecodes.KEY_KPSLASH, + 57400: ecodes.KEY_RIGHTALT, + 57414: ecodes.KEY_PAUSE, + 57415: ecodes.KEY_HOME, + 57416: ecodes.KEY_UP, + 57417: ecodes.KEY_PAGEUP, + 57419: ecodes.KEY_LEFT, + 57421: ecodes.KEY_RIGHT, + 57423: ecodes.KEY_END, + 57424: ecodes.KEY_DOWN, + 57425: ecodes.KEY_PAGEDOWN, + 57426: ecodes.KEY_INSERT, + 57427: ecodes.KEY_DELETE, + 57435: ecodes.KEY_LEFTMETA, + 57436: ecodes.KEY_RIGHTMETA, + 57437: ecodes.KEY_CONTEXT_MENU, + 57438: ecodes.KEY_POWER, } diff --git a/kvmd/keyboard/mappings.py.mako b/kvmd/keyboard/mappings.py.mako index a04b1cb6a..03f344619 100644 --- a/kvmd/keyboard/mappings.py.mako +++ b/kvmd/keyboard/mappings.py.mako @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,6 +22,8 @@ import dataclasses +from evdev import ecodes + # ===== @dataclasses.dataclass(frozen=True) @@ -31,8 +33,8 @@ class McuKey: @dataclasses.dataclass(frozen=True) class UsbKey: - code: int - is_modifier: bool + code: int + is_mod: bool @dataclasses.dataclass(frozen=True) @@ -41,26 +43,39 @@ class Key: usb: UsbKey <%! import operator %> -KEYMAP: dict[str, Key] = { +KEYMAP: dict[int, Key] = { % for km in sorted(keymap, key=operator.attrgetter("mcu_code")): - "${km.web_name}": Key(mcu=McuKey(code=${km.mcu_code}), usb=UsbKey(code=${km.usb_key.code}, is_modifier=${km.usb_key.is_modifier})), + ecodes.${km.evdev_name}: Key(mcu=McuKey(code=${km.mcu_code}), usb=UsbKey(code=${km.usb_key.code}, is_mod=${km.usb_key.is_mod})), +% endfor +} + + +WEB_TO_EVDEV = { +% for km in sorted(keymap, key=operator.attrgetter("mcu_code")): + "${km.web_name}": ecodes.${km.evdev_name}, % endfor } # ===== -class WebModifiers: - SHIFT_LEFT = "ShiftLeft" - SHIFT_RIGHT = "ShiftRight" +class EvdevModifiers: + SHIFT_LEFT = ecodes.KEY_LEFTSHIFT + SHIFT_RIGHT = ecodes.KEY_RIGHTSHIFT SHIFTS = set([SHIFT_LEFT, SHIFT_RIGHT]) - ALT_LEFT = "AltLeft" - ALT_RIGHT = "AltRight" + ALT_LEFT = ecodes.KEY_LEFTALT + ALT_RIGHT = ecodes.KEY_RIGHTALT ALTS = set([ALT_LEFT, ALT_RIGHT]) - CTRL_LEFT = "ControlLeft" - CTRL_RIGHT = "ControlRight" - CTRLS = set([CTRL_RIGHT, CTRL_RIGHT]) + CTRL_LEFT = ecodes.KEY_LEFTCTRL + CTRL_RIGHT = ecodes.KEY_RIGHTCTRL + CTRLS = set([CTRL_LEFT, CTRL_RIGHT]) + + META_LEFT = ecodes.KEY_LEFTMETA + META_RIGHT = ecodes.KEY_RIGHTMETA + METAS = set([META_LEFT, META_RIGHT]) + + ALL = (SHIFTS | ALTS | CTRLS | METAS) class X11Modifiers: @@ -78,10 +93,10 @@ class X11Modifiers: # ===== @dataclasses.dataclass(frozen=True) class At1Key: - code: int + code: int shift: bool altgr: bool = False - ctrl: bool = False + ctrl: bool = False X11_TO_AT1 = { @@ -93,8 +108,8 @@ X11_TO_AT1 = { } -AT1_TO_WEB = { +AT1_TO_EVDEV = { % for km in sorted(keymap, key=operator.attrgetter("at1_code")): - ${km.at1_code}: "${km.web_name}", + ${km.at1_code}: ecodes.${km.evdev_name}, % endfor } diff --git a/kvmd/keyboard/printer.py b/kvmd/keyboard/printer.py index d6d5839c7..49c1bd9d9 100644 --- a/kvmd/keyboard/printer.py +++ b/kvmd/keyboard/printer.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -25,8 +25,9 @@ from typing import Generator +from evdev import ecodes + from .keysym import SymmapModifiers -from .mappings import WebModifiers # ===== @@ -56,10 +57,10 @@ def _ch_to_keysym(ch: str) -> int: # ===== -def text_to_web_keys( # pylint: disable=too-many-branches +def text_to_evdev_keys( # pylint: disable=too-many-branches text: str, - symmap: dict[int, dict[int, str]], -) -> Generator[tuple[str, bool], None, None]: + symmap: dict[int, dict[int, int]], +) -> Generator[tuple[int, bool], None, None]: shift = False altgr = False @@ -68,11 +69,11 @@ def text_to_web_keys( # pylint: disable=too-many-branches # https://stackoverflow.com/questions/12343987/convert-ascii-character-to-x11-keycode # https://www.ascii-code.com if ch == "\n": - keys = {0: "Enter"} + keys = {0: ecodes.KEY_ENTER} elif ch == "\t": - keys = {0: "Tab"} + keys = {0: ecodes.KEY_TAB} elif ch == " ": - keys = {0: "Space"} + keys = {0: ecodes.KEY_SPACE} else: if ch in ["‚", "‘", "’"]: ch = "'" @@ -89,23 +90,23 @@ def text_to_web_keys( # pylint: disable=too-many-branches except Exception: continue - for (modifiers, key) in keys.items(): - if modifiers & SymmapModifiers.CTRL: + for (mods, key) in keys.items(): + if mods & SymmapModifiers.CTRL: # Not supported yet continue - if modifiers & SymmapModifiers.SHIFT and not shift: - yield (WebModifiers.SHIFT_LEFT, True) + if mods & SymmapModifiers.SHIFT and not shift: + yield (ecodes.KEY_LEFTSHIFT, True) shift = True - elif not (modifiers & SymmapModifiers.SHIFT) and shift: - yield (WebModifiers.SHIFT_LEFT, False) + elif not (mods & SymmapModifiers.SHIFT) and shift: + yield (ecodes.KEY_LEFTSHIFT, False) shift = False - if modifiers & SymmapModifiers.ALTGR and not altgr: - yield (WebModifiers.ALT_RIGHT, True) + if mods & SymmapModifiers.ALTGR and not altgr: + yield (ecodes.KEY_RIGHTALT, True) altgr = True - elif not (modifiers & SymmapModifiers.ALTGR) and altgr: - yield (WebModifiers.ALT_RIGHT, False) + elif not (mods & SymmapModifiers.ALTGR) and altgr: + yield (ecodes.KEY_RIGHTALT, False) altgr = False yield (key, True) @@ -113,6 +114,6 @@ def text_to_web_keys( # pylint: disable=too-many-branches break if shift: - yield (WebModifiers.SHIFT_LEFT, False) + yield (ecodes.KEY_LEFTSHIFT, False) if altgr: - yield (WebModifiers.ALT_RIGHT, False) + yield (ecodes.KEY_RIGHTALT, False) diff --git a/kvmd/libc.py b/kvmd/libc.py index d46052315..53b257336 100644 --- a/kvmd/libc.py +++ b/kvmd/libc.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # This source file is partially based on python-watchdog module. # # # diff --git a/kvmd/logging.py b/kvmd/logging.py index df3efc114..a31dc24de 100644 --- a/kvmd/logging.py +++ b/kvmd/logging.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/mouse.py b/kvmd/mouse.py index c34bde245..c02ed1c6d 100644 --- a/kvmd/mouse.py +++ b/kvmd/mouse.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,8 @@ # ========================================================================== # +from evdev import ecodes + from . import tools @@ -32,3 +34,27 @@ class MouseRange: @classmethod def remap(cls, value: int, out_min: int, out_max: int) -> int: return tools.remap(value, cls.MIN, cls.MAX, out_min, out_max) + + @classmethod + def normalize(cls, value: int) -> int: + return min(max(cls.MIN, value), cls.MAX) + + +class MouseDelta: + MIN = -127 + MAX = 127 + RANGE = (MIN, MAX) + + @classmethod + def normalize(cls, value: int) -> int: + return min(max(cls.MIN, value), cls.MAX) + + +# ===== +MOUSE_TO_EVDEV = { + "left": ecodes.BTN_LEFT, + "right": ecodes.BTN_RIGHT, + "middle": ecodes.BTN_MIDDLE, + "up": ecodes.BTN_BACK, + "down": ecodes.BTN_FORWARD, +} diff --git a/kvmd/nbd/__init__.py b/kvmd/nbd/__init__.py new file mode 100644 index 000000000..dbd64172e --- /dev/null +++ b/kvmd/nbd/__init__.py @@ -0,0 +1,117 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import urllib.parse + +from typing import Final +from typing import AsyncGenerator +from typing import Type +from typing import Any + +from ..logging import get_logger + +from .. import tools +from .. import aiotools + +from ..yamlconf import make_config +from ..validators import ValidatorError + +from .errors import NbdError + +from .types import BaseNbdEvent +from .types import NbdStopEvent + +from .device import NbdDevice +from .process import NbdProcess + +from .remotes import BaseNbdRemote +from .remotes.http import NbdHttpRemote + + +# ===== +class NbdServer: + __DEVICE_BLOCK: Final[int] = 512 + __DEVICE_TIMEOUT: Final[int] = 3600 + + __REMOTES: Final[dict[str, Type[BaseNbdRemote]]] = { + scheme: cls + for cls in [NbdHttpRemote] + for scheme in cls.get_schemes() + } + + def __init__(self, path: str) -> None: + self.__device = NbdDevice(path, self.__DEVICE_BLOCK, self.__DEVICE_TIMEOUT) + self.__proc: (NbdProcess | None) = None + self.__nr = aiotools.AioNotifier() + self.__lock = asyncio.Lock() + + # ===== + + async def bind(self, url: str, **kwargs: Any) -> None: + async with self.__lock: + if self.__proc: + raise NbdError("NBD is already bound") + + scheme = urllib.parse.urlparse(url).scheme + cls = self.__REMOTES.get(scheme) + if cls is None: + raise ValidatorError("Unsupported remote URL scheme") + + try: + config = make_config({"url": url, **kwargs}, {}, cls.get_options()) + except Exception as ex: + raise ValidatorError(tools.efmt(ex)) + + remote = cls(**config._unpack()) + image = await remote.probe() + + assert self.__proc is None + self.__nr.notify() + self.__proc = NbdProcess(self.__device, remote, image) + + def unbind(self) -> None: + if self.__proc: + self.__proc.stop() + + async def poll(self) -> AsyncGenerator[BaseNbdEvent]: + while True: + await self.__nr.wait() + if self.__proc: + stop: (NbdStopEvent | None) = None + try: + async with self.__proc.running(): + async for event in self.__proc.poll(): + if isinstance(event, NbdStopEvent): + if stop is None: + stop = event + else: + yield event + except NbdError as ex: + get_logger(0).error("%s", tools.efmt(ex)) + except Exception: + get_logger(0).exception("Unexpected error in NBD poller loop") + finally: + self.__proc = None + if stop is None: + stop = NbdStopEvent("main", "Unknown stop reason", False) + yield stop diff --git a/kvmd/nbd/device.py b/kvmd/nbd/device.py new file mode 100644 index 000000000..c7c37dd47 --- /dev/null +++ b/kvmd/nbd/device.py @@ -0,0 +1,135 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import sys +import os +import fcntl +import socket +import asyncio +import contextlib +import math + +from typing import Final +from typing import Generator +from typing import AsyncGenerator + +from ..logging import get_logger + +from .. import tools + +from .errors import NbdDeviceError +from .types import NbdImage +from .link import NbdLink + + +# ===== +_NBD_CLEAR_SOCK: Final[tuple[int, str]] = (0x0000AB04, "NBD_CLEAR_SOCK") +_NBD_DO_IT: Final[tuple[int, str]] = (0x0000AB03, "NBD_DO_IT") +_NBD_DISCONNECT: Final[tuple[int, str]] = (0x0000AB08, "NBD_DISCONNECT") +_NBD_SET_BLKSIZE: Final[tuple[int, str]] = (0x0000AB01, "NBD_SET_BLKSIZE") +_NBD_SET_FLAGS: Final[tuple[int, str]] = (0x0000AB0A, "NBD_SET_FLAGS") +_NBD_SET_SIZE_BLOCKS: Final[tuple[int, str]] = (0x0000AB07, "NBD_SET_SIZE_BLOCKS") +_NBD_SET_SOCK: Final[tuple[int, str]] = (0x0000AB00, "NBD_SET_SOCK") +_NBD_SET_TIMEOUT: Final[tuple[int, str]] = (0x0000AB09, "NBD_SET_TIMEOUT") +_BLKROSET: Final[tuple[int, str]] = (0x0000125D, "BLKROSET") + + +def _ioctl(fd: int, ctl: tuple[int, str], value: (int | bytes)=0) -> None: + (req, name) = ctl + try: + fcntl.ioctl(fd, req, value) + except Exception as ex: + raise NbdDeviceError(f"Ioctl {name} error", ex) + + +@contextlib.contextmanager +def _wrap_exceptions() -> Generator[None]: + try: + yield + except NbdDeviceError: + raise + except Exception as ex: + raise NbdDeviceError(tools.efmt(ex)) + + +# ===== +class NbdDevice: + def __init__(self, path: str, block: int, timeout: float) -> None: + self.__path = path + self.__block = block + self.__timeout = timeout + + # ===== + + def get_path(self) -> str: + return self.__path + + async def open_close(self) -> None: + await asyncio.to_thread(self.__inner_open_close) + + def __inner_open_close(self) -> None: + fd = os.open(self.__path, os.O_RDONLY) + os.close(fd) + + @contextlib.asynccontextmanager + async def open_prepared(self, link: NbdLink, image: NbdImage) -> AsyncGenerator[int]: + with _wrap_exceptions(): + fd = await asyncio.to_thread(os.open, self.__path, os.O_RDWR) + try: + self.__cleanup(fd, close=False) + self.__prepare(fd, image, link.device_s) + yield fd + finally: + try: + self.__cleanup(fd, close=True) + except Exception as ex: + get_logger(0).error("Cleanup error: %s", tools.efmt(ex)) + + async def do_it(self, fd: int) -> None: + logger = get_logger(0) + logger.info("Running NBD_DO_IT ...") + await asyncio.to_thread(_ioctl, fd, _NBD_DO_IT) # Blocks here + logger.info("Stopped NBD_DO_IT") + + def __prepare(self, fd: int, image: NbdImage, sock: socket.SocketType) -> None: + logger = get_logger(0) + + blocks = (image.size + self.__block) // self.__block + flags = (0 if image.rw else 2) # NBD_FLAG_READ_ONLY + ro_bytes = int(not image.rw).to_bytes(byteorder=sys.byteorder, length=4) # Kinda ptr + + logger.info("Preparing %s: bytes=%s, bs=%s, blocks=%s, rw=%s ...", + self.__path, image.size, self.__block, blocks, image.rw) + + _ioctl(fd, _NBD_SET_BLKSIZE, self.__block) + _ioctl(fd, _NBD_SET_SIZE_BLOCKS, blocks) + _ioctl(fd, _NBD_SET_FLAGS, flags) + _ioctl(fd, _BLKROSET, ro_bytes) + _ioctl(fd, _NBD_SET_TIMEOUT, math.ceil(self.__timeout)) + _ioctl(fd, _NBD_SET_SOCK, sock.fileno()) + logger.info("Prepared") + + def __cleanup(self, fd: int, close: bool) -> None: + _ioctl(fd, _NBD_DISCONNECT) # Should be always OK .. + _ioctl(fd, _NBD_CLEAR_SOCK) # ... accordung to kernel sources + if close: + os.close(fd) diff --git a/testenv/tests/apps/cleanup/test_main.py b/kvmd/nbd/errors.py similarity index 67% rename from testenv/tests/apps/cleanup/test_main.py rename to kvmd/nbd/errors.py index 29008cf34..ca7b4c370 100644 --- a/testenv/tests/apps/cleanup/test_main.py +++ b/kvmd/nbd/errors.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2020 Maxim Devaev # # # # 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 # @@ -20,33 +20,35 @@ # ========================================================================== # -import multiprocessing -import time +from .. import tools -from typing import Literal -import setproctitle +# ===== +class NbdError(Exception): + def __init__(self, msg: str, ex: (Exception | None)=None) -> None: + if ex: + msg += ": " + tools.efmt(ex) + super().__init__(msg) + -from kvmd.apps.cleanup import main +# ===== +class NbdDeviceError(NbdError): + pass # ===== -def test_ok() -> None: - _ = Literal # Makes liters happy - queue: "multiprocessing.Queue[Literal[True]]" = multiprocessing.Queue() +class NbdIoError(NbdError): + pass - def ustreamer_fake() -> None: - setproctitle.setproctitle("kvmd/streamer: /usr/bin/ustreamer") - queue.put(True) - while True: - time.sleep(1) - proc = multiprocessing.Process(target=ustreamer_fake, daemon=True) - proc.start() - assert queue.get(timeout=5) +class NbdIoConnectionError(NbdIoError): + pass - assert proc.is_alive() - main(["kvmd-cleanup", "--run"]) - assert not proc.is_alive() - proc.join() +class NbdIoProtocolError(NbdIoError): + pass + + +# ===== +class NbdRemoteError(NbdError): + pass diff --git a/kvmd/nbd/link.py b/kvmd/nbd/link.py new file mode 100644 index 000000000..7f370a5f5 --- /dev/null +++ b/kvmd/nbd/link.py @@ -0,0 +1,93 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import socket +import contextlib +import dataclasses + +from typing import Generator +from typing import AsyncGenerator + + +# ===== +@dataclasses.dataclass(frozen=True) +class NbdLink: + device_s: socket.SocketType + remote_r: asyncio.StreamReader + remote_w: asyncio.StreamWriter + _remote_s: socket.SocketType + _stopped: bool = dataclasses.field(default=False, hash=False) + + @classmethod + @contextlib.asynccontextmanager + async def opened(cls) -> AsyncGenerator["NbdLink"]: + (device_s, remote_s) = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM, 0) + + try: + (remote_r, remote_w) = await asyncio.open_connection(sock=remote_s) + except: # noqa: E722 + for sock in [device_s, remote_s]: + try: + sock.close() + except Exception: + pass + raise + + link = NbdLink(device_s, remote_r, remote_w, remote_s) + try: + yield link + finally: + # На самом деле мы должны использовать aiotools.close_writer(remote_w), + # но для простоты обработки CancelledError этим можно пренебречь, + # особенно с учетом того, что всё это живет в подпроцессе, который + # будет отстрелян по завершении работы. + # device_s.close(); aiotools.close_writer(remote_w); + link._close() + + def is_stopped(self) -> bool: + return self._stopped + + @contextlib.contextmanager + def shutdown_at_end(self) -> Generator[None]: + try: + yield + finally: + self.shutdown() + + def shutdown(self) -> bool: + ok = True + for sock in [self.device_s, self._remote_s]: + try: + sock.shutdown(socket.SHUT_RDWR) + except Exception: + ok = False + object.__setattr__(self, "_stopped", ok) + return ok + + def _close(self) -> None: + self.shutdown() + for sock in [self.device_s, self._remote_s]: + try: + sock.close() + except Exception: + pass diff --git a/kvmd/nbd/process.py b/kvmd/nbd/process.py new file mode 100644 index 000000000..373a667f7 --- /dev/null +++ b/kvmd/nbd/process.py @@ -0,0 +1,212 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import signal +import contextlib +import logging + +from typing import Final +from typing import Generator +from typing import AsyncGenerator + +from ..logging import get_logger + +from .. import tools +from .. import aiotools +from .. import aiomulti + +from .errors import NbdError +from .errors import NbdIoConnectionError + +from .types import NbdImage +from .types import BaseNbdEvent +from .types import NbdStartEvent +from .types import NbdStopEvent + +from .device import NbdDevice +from .remotes import BaseNbdRemote +from .link import NbdLink + + +# ===== +class NbdProcess: + __QUEUE_SIZE: Final[int] = 128 + __REACT_TIMEOUT: Final[int] = 3 + + def __init__( + self, + device: NbdDevice, + remote: BaseNbdRemote, + image: NbdImage, + ) -> None: + + self.__device = device + self.__remote = remote + self.__image = image + + self.__events_q: aiomulti.AioMpQueue[BaseNbdEvent] = aiomulti.AioMpQueue(self.__QUEUE_SIZE) + self.__proc = aiomulti.AioMpProcess("nbd", self.__subprocess) + self.__ready_nr = aiomulti.AioMpNotifier() + + def stop(self) -> None: + self.__proc.send_sigterm() + + @contextlib.asynccontextmanager + async def running(self) -> AsyncGenerator[None]: + logger = get_logger(0) + logger.info("Starting NBD process ...") + + self.__proc.start() + try: + ready = await self.__ready_nr.wait(self.__image.timeout + self.__REACT_TIMEOUT) + if ready < 0: # pylint: disable=no-else-raise + # No events - not started + raise NbdError("NBD process did not respond in time at start") + elif ready == 0: + # Failed to start in time, but notified - wait for exiting + await self.__proc.async_join(self.__REACT_TIMEOUT) + return # FIXME: defunc + + yield + + finally: + try: + if self.__proc.is_alive(): + logger.info("Stopping NBD process with SIGTERM ...") + self.__proc.send_sigterm() + await self.__proc.async_join(self.__REACT_TIMEOUT) + finally: + if self.__proc.is_alive(): + logger.info("Killing NBD process with SIGKILL ...") + self.__proc.sendpg_sigkill() + + alive = await self.__proc.async_join(self.__REACT_TIMEOUT) + if not alive: + logger.info("NBD process stopped: retcode=%s", self.__proc.exitcode) + else: + logger.error("Can't stop NBD process") + + async def poll(self) -> AsyncGenerator[BaseNbdEvent]: + while self.__proc.is_alive(): + (got, event) = await self.__events_q.async_fetch(1) # FIXME: Wait for process too + if got: + assert event is not None + yield event + while not self.__events_q.empty(): + await asyncio.sleep(0) + yield self.__events_q.get_nowait() + + def __subprocess(self) -> None: + with self.__catch_exceptions("main", subtask=False): + aiotools.run(self.__subprocess_loop()) + + async def __subprocess_loop(self) -> None: + async with NbdLink.opened() as link: + tasks: list[asyncio.Task] = [] + + def stop() -> None: + # Прибиваем через shutdown(), чтобы всё, что держится на сокетах, прервалось. + # Если не получилось - делаем cancel() и дожидаемся SIGKILL. + if link.shutdown(): + self.__queue_event_noex(NbdStopEvent("main", "Shutdown", True)) + else: + for task in tasks: + task.cancel() + + for signum in [signal.SIGTERM, signal.SIGINT]: + asyncio.get_running_loop().add_signal_handler(signum, stop) + + prepared = aiotools.AioStage() + + await aiotools.spawn_and_follow( + self.__sub_device_server(link, prepared), + self.__sub_remote_server(link), + self.__sub_checker(link, prepared), + wait=1, # FIXME: Get rid of this + tasks=tasks, + ) + + async def __sub_device_server(self, link: NbdLink, prepared: aiotools.AioStage) -> None: + with self.__catch_exceptions("device", log=self.__device.__module__): + with link.shutdown_at_end(): + async with self.__device.open_prepared(link, self.__image) as fd: + prepared.set_passed() + await self.__device.do_it(fd) + + async def __sub_remote_server(self, link: NbdLink) -> None: + try: + with self.__catch_exceptions("remote", log=self.__remote.__module__): + with link.shutdown_at_end(): + try: + await self.__remote.serve(link, self.__events_q) + except NbdIoConnectionError: + if not link.is_stopped(): + raise + finally: + await self.__remote.cleanup() + + async def __sub_checker(self, link: NbdLink, prepared: aiotools.AioStage) -> None: + with self.__catch_exceptions("checker"): + with link.shutdown_at_end(): + try: + await prepared.wait_passed() + await asyncio.wait_for( + self.__device.open_close(), + timeout=self.__image.timeout, + ) + except BaseException as ex: + self.__ready_nr.notify(0) + if isinstance(ex, TimeoutError): + raise NbdError("Can't open+close device in time") + raise + self.__ready_nr.notify(1) + self.__events_q.put_nowait(NbdStartEvent(self.__image, self.__device.get_path())) + await aiotools.wait_infinite() + + @contextlib.contextmanager + def __catch_exceptions(self, src: str, log: str="", subtask: bool=True) -> Generator[None]: + logger = (logging.getLogger(log) if log else get_logger(0)) + if subtask: + logger.info("Starting subtask %s ...", src) + msg = "" + try: + yield + except asyncio.CancelledError: + pass # Normally we don't interested in this as a reason + except NbdError as ex: + msg = tools.efmt(ex) + logger.error("%s", msg) + except Exception as ex: + msg = tools.efmt(ex) + logger.exception("Unhandled exception") + finally: + if msg: + self.__queue_event_noex(NbdStopEvent(src, msg, False)) + if subtask: + logger.info("Subtask %s finished", src) + + def __queue_event_noex(self, event: BaseNbdEvent) -> None: + try: + self.__events_q.put_nowait(event) + except Exception as ex: + get_logger(0).error("Can't queue stop event: %s", tools.efmt(ex)) diff --git a/kvmd/nbd/remotes/__init__.py b/kvmd/nbd/remotes/__init__.py new file mode 100644 index 000000000..d353ead72 --- /dev/null +++ b/kvmd/nbd/remotes/__init__.py @@ -0,0 +1,216 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio +import struct +import errno + +from typing import Final + +from ...yamlconf import Option + +from ... import tools +from ... import aiomulti + +from ..errors import NbdRemoteError +from ..errors import NbdIoConnectionError +from ..errors import NbdIoProtocolError + +from ..types import NbdImage +from ..types import BaseNbdEvent +from ..types import NbdRemoteEvent + +from ..link import NbdLink + + +# ===== +class BaseNbdRemote: + # https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + # https://github.com/NetworkBlockDevice/nbd/blob/master/nbd-client.c + # https://github.com/mirror/busybox/blob/master/networking/nbd-client.c + # https://elixir.bootlin.com/linux/v6.12/source/drivers/block/nbd.c + + __MAGIC_RECV: Final[int] = 0x25609513 + __MAGIC_SEND: Final[int] = 0x67446698 + + __OP_READ: Final[int] = 0 + __OP_WRITE: Final[int] = 1 + __OP_STOP: Final[int] = 2 + + def __init__(self) -> None: + self.__recv_st = struct.Struct(">IHHQQI") + self.__send_st = struct.Struct(">IIQ") + + self.__image: (NbdImage | None) = None + self.__events_q: (aiomulti.AioMpQueue[BaseNbdEvent] | None) = None + + # ===== + + @classmethod + def get_schemes(cls) -> set[str]: + raise NotImplementedError + + @classmethod + def get_options(cls) -> dict[str, Option]: + return {} + + # ===== + + async def _do_probe(self) -> NbdImage: + raise NotImplementedError + + async def _do_again(self) -> NbdImage: + raise NotImplementedError + + async def _on_read(self, offset: int, size: int) -> bytes: + raise NotImplementedError + + async def _on_write(self, offset: int, data: bytes) -> None: + raise NotImplementedError + + async def _do_cleanup(self) -> None: + raise NotImplementedError + + # ===== + + async def _send_status_ok(self) -> None: + await self.__send_remote_event(True, "Online") + + async def _send_status_error(self, msg: str) -> None: + await self.__send_remote_event(False, msg) + + async def __send_remote_event(self, online: bool, msg: str) -> None: + assert self.__events_q is not None + try: + self.__events_q.put_nowait(NbdRemoteEvent(online, msg)) + except Exception as ex: + raise NbdRemoteError(f"Can't send status event: {tools.efmt(ex)}") + + async def _probe_again(self) -> None: + assert self.__image + image = await self._do_again() + if self.__image.rw is True and not image.rw: + raise NbdRemoteError("The source permissions changed: RW -> RO") + if self.__image.size != image.size: + raise NbdRemoteError(f"The source file has a new size: {self.__image.size} -> {image.size}") + + # ===== + + async def probe(self) -> NbdImage: + assert self.__events_q is None # Not running + self.__image = await self._do_probe() + return self.__image + + async def serve( + self, + link: NbdLink, + events_q: aiomulti.AioMpQueue[BaseNbdEvent], + ) -> None: + + assert self.__image + assert self.__events_q is None + self.__events_q = events_q + + await self._probe_again() # Validate NbdImage after first probing + await self._send_status_ok() + + while True: + (op, cookie, offset, size, data) = await self.__recv_request(link.remote_r) + result: (tuple[int, bytes] | None) = None + match op: + case self.__OP_READ: + result = await self.__handle_read(offset, size) + case self.__OP_WRITE: + result = await self.__handle_write(offset, data) + case self.__OP_STOP: + raise NbdIoConnectionError("Closed by kernel") + case _: + raise NbdIoProtocolError(f"Unknown OP received: 0x{op:X}") + assert result is not None + await self.__send_response(link.remote_w, cookie, *result) + + async def cleanup(self) -> None: + try: + await self._do_cleanup() + finally: + self.__events_q = None + self.__image = None + + async def __recv_request( + self, + reader: asyncio.StreamReader, + ) -> tuple[int, int, int, int, bytes]: + + try: + header = await reader.readexactly(self.__recv_st.size) + (magic, flags, op, cookie, offset, size) = self.__recv_st.unpack(header) + data = b"" + if op == self.__OP_WRITE and size > 0: + data = await reader.readexactly(size) + except (ConnectionError, asyncio.IncompleteReadError) as ex: + raise NbdIoConnectionError("Can't receive request", ex) + + if magic != self.__MAGIC_RECV: + raise NbdIoProtocolError(f"Invalid request magic: 0x{magic:X}") + if flags: + raise NbdIoProtocolError(f"Got non-zero request flags: 0x{flags:X}") + return (op, cookie, offset, size, data) + + async def __send_response( + self, + writer: asyncio.StreamWriter, + cookie: int, error: int, data: bytes=b"", + ) -> None: + + try: + header = self.__send_st.pack(self.__MAGIC_SEND, error, cookie) + writer.write(header) + if error == 0 and len(data) > 0: + writer.write(data) + await writer.drain() + except ConnectionError as ex: + raise NbdIoConnectionError("Can't send response", ex) + + async def __handle_read(self, offset: int, size: int) -> tuple[int, bytes]: + assert self.__image + if offset >= self.__image.size: + return (errno.EINVAL, b"") + + data = await self._on_read(offset, size) + if len(data) < size: + if offset + size > self.__image.size: + data += b"\x00" * (size - len(data)) + else: + raise NbdIoProtocolError("Insufficient READ data") + elif len(data) > size: + raise NbdIoProtocolError("Too much READ data") + + return (0, data) + + async def __handle_write(self, offset: int, data: bytes) -> tuple[int, bytes]: + assert self.__image + if not self.__image.rw: + return (errno.EPERM, b"") + if offset >= self.__image.size: + return (errno.ENOSPC, b"") + await self._on_write(offset, data) + return (0, b"") diff --git a/kvmd/nbd/remotes/http.py b/kvmd/nbd/remotes/http.py new file mode 100644 index 000000000..3653fab7b --- /dev/null +++ b/kvmd/nbd/remotes/http.py @@ -0,0 +1,167 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import asyncio + +import aiohttp + +from ... import tools +from ... import htclient + +from ...yamlconf import Option + +from ...validators.basic import valid_bool +from ...validators.basic import valid_number +from ...validators.net import valid_url + +from ..errors import NbdError +from ..errors import NbdRemoteError + +from ..types import NbdImage + +from . import BaseNbdRemote + + +# ===== +class NbdHttpRemote(BaseNbdRemote): + def __init__( + self, + url: str, + verify: bool, + user: str, + passwd: str, + timeout: float, + retries_delay: float, + ) -> None: + + super().__init__() + + self.__url = url + self.__verify = verify + self.__user = user + self.__passwd = passwd + self.__timeout = timeout + self.__retries_delay = retries_delay + + self.__session: (aiohttp.ClientSession | None) = None + + # ===== + + @classmethod + def get_schemes(cls) -> set[str]: + return set(["http", "https"]) + + @classmethod + def get_options(cls) -> dict[str, Option]: + return { + "url": Option("", type=valid_url), + "verify": Option(True, type=valid_bool), + "user": Option(""), + "passwd": Option(""), + "timeout": Option(3.0, type=valid_number.mk(min=1.0, max=30.0, type=float)), + "retries_delay": Option(5.0, type=valid_number.mk(min=1.0, max=30.0, type=float)), + } + + # ===== + + async def _do_probe(self) -> NbdImage: + async with self.__make_session() as session: + return (await self.__probe(session)) + + async def _do_again(self) -> NbdImage: + session = self.__ensure_session() + return (await self.__probe(session)) + + async def __probe(self, session: aiohttp.ClientSession) -> NbdImage: + async with session.head(self.__url) as resp: + htclient.raise_not_200(resp) + cl = resp.content_length + if not isinstance(cl, int) or cl < 0: + raise NbdRemoteError(f"Invalid Content-Length: {cl}") + return NbdImage( + size=cl, + rw=False, + timeout=self.__timeout, + ) + + # ===== + + async def _on_read(self, offset: int, size: int) -> bytes: + errors = 0 + while True: + try: + if errors > 0: + await self._probe_again() + data = (await self.__read(offset, size)) + if errors > 0: + await self._send_status_ok() + errors = 0 + return data + except NbdError: + raise + except Exception as ex: + errors += 1 + msg = f"READ: {tools.efmt(ex)}; Retrying ({errors}) ..." + await self._send_status_error(msg) + await asyncio.sleep(self.__retries_delay) + + async def __read(self, offset: int, size: int) -> bytes: + session = self.__ensure_session() + async with session.get( + url=self.__url, + headers={aiohttp.hdrs.RANGE: f"bytes={offset}-{offset + size}"}, + ) as resp: + + resp.raise_for_status() # 206 partial is OK here + return (await resp.read())[:size] + + async def _on_write(self, offset: int, data: bytes) -> None: + _ = offset + _ = data + raise RuntimeError("WRITE should not be called for HTTP") + + # ===== + + async def _do_cleanup(self) -> None: + if self.__session: + try: + await self.__session.close() + finally: + self.__session = None + + # ===== + + def __ensure_session(self) -> aiohttp.ClientSession: + if self.__session is None: + self.__session = self.__make_session() + return self.__session + + def __make_session(self) -> aiohttp.ClientSession: + return aiohttp.ClientSession( + headers={aiohttp.hdrs.USER_AGENT: htclient.make_user_agent("KVMD-NBD")}, + connector=aiohttp.TCPConnector(ssl=self.__verify), + auth=(aiohttp.BasicAuth(self.__user, self.__passwd) if self.__user else None), + timeout=aiohttp.ClientTimeout(total=self.__timeout), + + # Don't ask for compression: https://github.com/aio-libs/aiohttp/issues/5513 + skip_auto_headers=frozenset([aiohttp.hdrs.ACCEPT_ENCODING]), + ) diff --git a/kvmd/nbd/types.py b/kvmd/nbd/types.py new file mode 100644 index 000000000..a2536fe6b --- /dev/null +++ b/kvmd/nbd/types.py @@ -0,0 +1,55 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import dataclasses + + +# ===== +@dataclasses.dataclass(frozen=True) +class NbdImage: + size: int + rw: bool + timeout: float + + +# ===== +class BaseNbdEvent: + pass + + +@dataclasses.dataclass(frozen=True) +class NbdRemoteEvent(BaseNbdEvent): + online: bool + msg: str + + +@dataclasses.dataclass(frozen=True) +class NbdStartEvent(BaseNbdEvent): + image: NbdImage + path: str + + +@dataclasses.dataclass(frozen=True) +class NbdStopEvent(BaseNbdEvent): + src: str + msg: str + ok: bool diff --git a/kvmd/network.py b/kvmd/network.py new file mode 100644 index 000000000..8ec1b5d81 --- /dev/null +++ b/kvmd/network.py @@ -0,0 +1,82 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import socket +import dataclasses +import errno + +import netifaces + + +# ===== +class NoIfacesError(Exception): + pass + + +# ===== +@dataclasses.dataclass(frozen=True) +class FirstIface: + name: str + ip: str + + +# ===== +def is_ipv6_enabled() -> bool: + if not socket.has_ipv6: + # If the socket library has no support for IPv6, + # then the question is moot as we can't use IPv6 anyways. + return False + try: + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock: + sock.bind(("::1", 0)) + return True + except OSError as ex: + if ex.errno in [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT]: + return False + if ex.errno == errno.EADDRINUSE: + return True + raise + + +def get_listen_host(host: str) -> str: + if len(host) == 0: + return ("::" if is_ipv6_enabled() else "0.0.0.0") + return host + + +def get_first_iface() -> FirstIface: + gws = netifaces.gateways() + if "default" in gws: + for proto in [socket.AF_INET, socket.AF_INET6]: + if proto in gws["default"]: + iface = gws["default"][proto][1] + addrs = netifaces.ifaddresses(iface) + return FirstIface(iface, addrs[proto][0]["addr"]) + + for iface in netifaces.interfaces(): + if not iface.startswith(("lo", "docker")): + addrs = netifaces.ifaddresses(iface) + for proto in [socket.AF_INET, socket.AF_INET6]: + if proto in addrs: + return FirstIface(iface, addrs[proto][0]["addr"]) + + raise NoIfacesError() diff --git a/kvmd/plugins/__init__.py b/kvmd/plugins/__init__.py index aabe584fe..ce2567f1c 100644 --- a/kvmd/plugins/__init__.py +++ b/kvmd/plugins/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/plugins/atx/__init__.py b/kvmd/plugins/atx/__init__.py index b13946055..5f09f29a3 100644 --- a/kvmd/plugins/atx/__init__.py +++ b/kvmd/plugins/atx/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -48,7 +48,17 @@ class BaseAtx(BasePlugin): async def get_state(self) -> dict: raise NotImplementedError + async def trigger_state(self) -> None: + raise NotImplementedError + async def poll_state(self) -> AsyncGenerator[dict, None]: + # ==== Granularity table ==== + # - enabled -- Full + # - busy -- Partial, follows with acts + # - acts -- Partial, follows with busy + # - leds -- Partial + # =========================== + yield {} raise NotImplementedError diff --git a/kvmd/plugins/atx/disabled.py b/kvmd/plugins/atx/disabled.py index 1e6aace85..a829acca7 100644 --- a/kvmd/plugins/atx/disabled.py +++ b/kvmd/plugins/atx/disabled.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -36,20 +36,30 @@ def __init__(self) -> None: # ===== class Plugin(BaseAtx): + def __init__(self) -> None: + self.__notifier = aiotools.AioNotifier() + async def get_state(self) -> dict: return { "enabled": False, "busy": False, + "acts": { + "power": False, + "reset": False, + }, "leds": { "power": False, "hdd": False, }, } + async def trigger_state(self) -> None: + self.__notifier.notify() + async def poll_state(self) -> AsyncGenerator[dict, None]: while True: + await self.__notifier.wait() yield (await self.get_state()) - await aiotools.wait_infinite() # ===== diff --git a/kvmd/plugins/atx/gpio.py b/kvmd/plugins/atx/gpio.py index 58ed8a817..0258de34a 100644 --- a/kvmd/plugins/atx/gpio.py +++ b/kvmd/plugins/atx/gpio.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,9 @@ # ========================================================================== # +import asyncio +import copy + from typing import AsyncGenerator import gpiod @@ -72,15 +75,14 @@ def __init__( # pylint: disable=too-many-arguments,super-init-not-called self.__long_click_delay = long_click_delay self.__notifier = aiotools.AioNotifier() - self.__region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier) + self.__power_region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier) + self.__reset_region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier) - self.__chip: (gpiod.Chip | None) = None - self.__power_switch_line: (gpiod.Line | None) = None - self.__reset_switch_line: (gpiod.Line | None) = None + self.__line_req: (gpiod.LineRequest | None) = None self.__reader = aiogp.AioReader( path=self.__device_path, - consumer="kvmd::atx::leds", + 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), @@ -107,45 +109,55 @@ def get_plugin_options(cls) -> dict: "long_click_delay": Option(5.5, type=valid_float_f01), } - def sysprep(self) -> None: - assert self.__chip is None - assert self.__power_switch_line is None - assert self.__reset_switch_line is None - - self.__chip = gpiod.Chip(self.__device_path) - - self.__power_switch_line = self.__chip.get_line(self.__power_switch_pin) - self.__power_switch_line.request("kvmd::atx::power_switch", gpiod.LINE_REQ_DIR_OUT, default_vals=[0]) - - self.__reset_switch_line = self.__chip.get_line(self.__reset_switch_pin) - self.__reset_switch_line.request("kvmd::atx::reset_switch", gpiod.LINE_REQ_DIR_OUT, default_vals=[0]) + async 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: + power_busy = self.__power_region.is_busy() + reset_busy = self.__reset_region.is_busy() return { "enabled": True, - "busy": self.__region.is_busy(), + "busy": (power_busy or reset_busy), + "acts": { + "power": power_busy, + "reset": reset_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_state: dict = {} + prev: dict = {} while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - await self.__notifier.wait() + 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.__chip: + if self.__line_req: try: - self.__chip.close() + self.__line_req.release() except Exception: pass @@ -170,13 +182,13 @@ async def power_reset_hard(self, wait: bool) -> None: # ===== async def click_power(self, wait: bool) -> None: - await self.__click("power", self.__power_switch_line, self.__click_delay, wait) + await self.__click("power", self.__power_region, 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_line, self.__long_click_delay, wait) + await self.__click("power_long", self.__power_region, self.__power_switch_pin, self.__long_click_delay, wait) async def click_reset(self, wait: bool) -> None: - await self.__click("reset", self.__reset_switch_line, self.__click_delay, wait) + await self.__click("reset", self.__reset_region, self.__reset_switch_pin, self.__click_delay, wait) # ===== @@ -184,17 +196,23 @@ async def __get_power(self) -> bool: return (await self.get_state())["leds"]["power"] @aiotools.atomic_fg - async def __click(self, name: str, line: gpiod.Line, delay: float, wait: bool) -> None: + async def __click(self, name: str, region: aiotools.AioExclusiveRegion, pin: int, delay: float, wait: bool) -> None: if wait: - async with self.__region: - await self.__inner_click(name, line, delay) + with 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, line, delay, + region, self.__inner_click, name, pin, delay, ) @aiotools.atomic_fg - async def __inner_click(self, name: str, line: gpiod.Line, delay: float) -> None: - await aiogp.pulse(line, delay, 1) + 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) diff --git a/kvmd/plugins/auth/__init__.py b/kvmd/plugins/auth/__init__.py index 8647fc269..cdbb43878 100644 --- a/kvmd/plugins/auth/__init__.py +++ b/kvmd/plugins/auth/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/keyboard/test_keymap.py b/kvmd/plugins/auth/forbidden.py similarity index 84% rename from testenv/tests/keyboard/test_keymap.py rename to kvmd/plugins/auth/forbidden.py index aaf924c5d..c4830c7c2 100644 --- a/testenv/tests/keyboard/test_keymap.py +++ b/kvmd/plugins/auth/forbidden.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,16 +20,12 @@ # ========================================================================== # -import pytest - -from kvmd.keyboard.mappings import KEYMAP +from . import BaseAuthService # ===== -def test_ok__keymap() -> None: - assert KEYMAP["KeyA"].mcu.code == 1 - - -def test_fail__keymap() -> None: - with pytest.raises(KeyError): - print(KEYMAP["keya"]) +class Plugin(BaseAuthService): + async def authorize(self, user: str, passwd: str) -> bool: + _ = user + _ = passwd + return False diff --git a/kvmd/plugins/auth/htpasswd.py b/kvmd/plugins/auth/htpasswd.py index ced596e75..0cc3248ce 100644 --- a/kvmd/plugins/auth/htpasswd.py +++ b/kvmd/plugins/auth/htpasswd.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,11 +20,11 @@ # ========================================================================== # -import passlib.apache - from ...yamlconf import Option -from ...validators.os import valid_abs_file +from ...validators.os import valid_abs_path + +from ...crypto import KvmdHtpasswdFile from . import BaseAuthService @@ -37,11 +37,11 @@ def __init__(self, path: str) -> None: # pylint: disable=super-init-not-called @classmethod def get_plugin_options(cls) -> dict: return { - "file": Option("/etc/kvmd/htpasswd", type=valid_abs_file, unpack_as="path"), + "file": Option("/etc/kvmd/htpasswd", type=valid_abs_path, unpack_as="path"), } async def authorize(self, user: str, passwd: str) -> bool: assert user == user.strip() assert user - htpasswd = passlib.apache.HtpasswdFile(self.__path) + htpasswd = KvmdHtpasswdFile(self.__path) return htpasswd.check_password(user, passwd) diff --git a/kvmd/plugins/auth/http.py b/kvmd/plugins/auth/http.py index 7b802c557..b59218aaa 100644 --- a/kvmd/plugins/auth/http.py +++ b/kvmd/plugins/auth/http.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -75,7 +75,7 @@ async def authorize(self, user: str, passwd: str) -> bool: async with session.request( method="POST", url=self.__url, - timeout=self.__timeout, + timeout=aiohttp.ClientTimeout(total=self.__timeout), json={ "user": user, "passwd": passwd, @@ -85,8 +85,8 @@ async def authorize(self, user: str, passwd: str) -> bool: "User-Agent": htclient.make_user_agent("KVMD"), "X-KVMD-User": user, }, - ) as response: - htclient.raise_not_200(response) + ) as resp: + htclient.raise_not_200(resp) return True except Exception: get_logger().exception("Failed HTTP auth request for user %r", user) diff --git a/kvmd/plugins/auth/ldap.py b/kvmd/plugins/auth/ldap.py index 4ef9c29c6..994497067 100644 --- a/kvmd/plugins/auth/ldap.py +++ b/kvmd/plugins/auth/ldap.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,8 @@ # ========================================================================== # +import asyncio + import ldap from ...yamlconf import Option @@ -31,7 +33,6 @@ from ...logging import get_logger from ... import tools -from ... import aiotools from . import BaseAuthService @@ -67,7 +68,7 @@ def get_plugin_options(cls) -> dict: } async def authorize(self, user: str, passwd: str) -> bool: - return (await aiotools.run_async(self.__inner_authorize, user, passwd)) + return (await asyncio.to_thread(self.__inner_authorize, user, passwd)) def __inner_authorize(self, user: str, passwd: str) -> bool: if self.__user_domain: @@ -88,17 +89,22 @@ def __inner_authorize(self, user: str, passwd: str) -> bool: base=self.__base, scope=ldap.SCOPE_SUBTREE, filterstr=f"(&(objectClass=user)(userPrincipalName={user})(memberOf={self.__group}))", - attrlist=["userPrincipalName", "memberOf"], + attrlist=["memberOf"], timeout=self.__timeout, ) or []): - if dn is not None and isinstance(attrs, dict) and attrs.get("memberOf"): + if ( + dn is not None + and isinstance(attrs, dict) + and isinstance(attrs["memberOf"], (list, dict)) + and self.__group.encode() in attrs["memberOf"] + ): return True except ldap.INVALID_CREDENTIALS: pass - except ldap.SERVER_DOWN as err: - get_logger().error("LDAP server is down: %s", tools.efmt(err)) - except Exception as err: - get_logger().error("Unexpected LDAP error: %s", tools.efmt(err)) + except ldap.SERVER_DOWN as ex: + get_logger().error("LDAP server is down: %s", tools.efmt(ex)) + except Exception as ex: + get_logger().error("Unexpected LDAP error: %s", tools.efmt(ex)) finally: if conn is not None: try: diff --git a/kvmd/plugins/auth/pam.py b/kvmd/plugins/auth/pam.py index 4060ff6ab..7b5fec5ad 100644 --- a/kvmd/plugins/auth/pam.py +++ b/kvmd/plugins/auth/pam.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -32,8 +32,6 @@ from ...logging import get_logger -from ... import aiotools - from . import BaseAuthService @@ -67,7 +65,7 @@ async def authorize(self, user: str, passwd: str) -> bool: assert user == user.strip() assert user async with self.__lock: - return (await aiotools.run_async(self.__inner_authorize, user, passwd)) + return (await asyncio.to_thread(self.__inner_authorize, user, passwd)) def __inner_authorize(self, user: str, passwd: str) -> bool: if self.__allow_users and user not in self.__allow_users: diff --git a/kvmd/plugins/auth/radius.py b/kvmd/plugins/auth/radius.py index c42b7211c..cca251c26 100644 --- a/kvmd/plugins/auth/radius.py +++ b/kvmd/plugins/auth/radius.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,6 +20,7 @@ # ========================================================================== # +import asyncio import io import pyrad.client @@ -34,8 +35,6 @@ from ...logging import get_logger -from ... import aiotools - from . import BaseAuthService @@ -420,7 +419,7 @@ def get_plugin_options(cls) -> dict: } async def authorize(self, user: str, passwd: str) -> bool: - return (await aiotools.run_async(self.__inner_authorize, user, passwd)) + return (await asyncio.to_thread(self.__inner_authorize, user, passwd)) def __inner_authorize(self, user: str, passwd: str) -> bool: assert user == user.strip() @@ -435,10 +434,10 @@ def __inner_authorize(self, user: str, passwd: str) -> bool: timeout=self.__timeout, dict=dct, ) - request = client.CreateAuthPacket(code=pyrad.packet.AccessRequest, User_Name=user) - request["User-Password"] = request.PwCrypt(passwd) - response = client.SendPacket(request) - return (response.code == pyrad.packet.AccessAccept) + req = client.CreateAuthPacket(code=pyrad.packet.AccessRequest, User_Name=user) + req["User-Password"] = req.PwCrypt(passwd) + resp = client.SendPacket(req) + return (resp.code == pyrad.packet.AccessAccept) except Exception: get_logger().exception("Failed RADIUS auth request for user %r", user) return False diff --git a/kvmd/plugins/hid/__init__.py b/kvmd/plugins/hid/__init__.py index 2ef7495fc..29c2eef07 100644 --- a/kvmd/plugins/hid/__init__.py +++ b/kvmd/plugins/hid/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,22 +20,105 @@ # ========================================================================== # +import asyncio +import time + from typing import Iterable +from typing import Callable from typing import AsyncGenerator +from typing import Any + +from evdev import ecodes + +from ...yamlconf import Option + +from ...validators.basic import valid_bool +from ...validators.basic import valid_int_f1 +from ...validators.basic import valid_string_list +from ...validators.hid import valid_hid_key +from ...validators.hid import valid_hid_mouse_move + +from ...keyboard.mappings import WEB_TO_EVDEV +from ...keyboard.mappings import EvdevModifiers +from ...mouse import MouseRange from .. import BasePlugin from .. import get_plugin_class # ===== -class BaseHid(BasePlugin): - def sysprep(self) -> None: +class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes + def __init__( + self, + ignore_keys: list[str], + + mouse_x_min: int, + mouse_x_max: int, + mouse_y_min: int, + mouse_y_max: int, + + jiggler_enabled: bool, + jiggler_active: bool, + jiggler_interval: int, + ) -> None: + + self.__ignore_keys = [WEB_TO_EVDEV[key] for key in ignore_keys] + + self.__mouse_x_range = (mouse_x_min, mouse_x_max) + self.__mouse_y_range = (mouse_y_min, mouse_y_max) + + self.__j_enabled = jiggler_enabled + self.__j_active = jiggler_active + self.__j_interval = jiggler_interval + self.__j_absolute = True + self.__j_activity_ts = self.__get_monotonic_seconds() + self.__j_last_x = 0 + self.__j_last_y = 0 + + @classmethod + def _get_base_options(cls) -> dict[str, Any]: + return { + "ignore_keys": Option([], type=valid_string_list.mk(subval=valid_hid_key)), + "mouse_x_range": { + "min": Option(MouseRange.MIN, type=valid_hid_mouse_move, unpack_as="mouse_x_min"), + "max": Option(MouseRange.MAX, type=valid_hid_mouse_move, unpack_as="mouse_x_max"), + }, + "mouse_y_range": { + "min": Option(MouseRange.MIN, type=valid_hid_mouse_move, unpack_as="mouse_y_min"), + "max": Option(MouseRange.MAX, type=valid_hid_mouse_move, unpack_as="mouse_y_max"), + }, + "jiggler": { + "enabled": Option(True, type=valid_bool, unpack_as="jiggler_enabled"), + "active": Option(False, type=valid_bool, unpack_as="jiggler_active"), + "interval": Option(60, type=valid_int_f1, unpack_as="jiggler_interval"), + }, + } + + # ===== + + async def sysprep(self) -> None: raise NotImplementedError async def get_state(self) -> dict: raise NotImplementedError + async def trigger_state(self) -> None: + raise NotImplementedError + async def poll_state(self) -> AsyncGenerator[dict, None]: + # ==== Granularity table ==== + # - enabled -- Full + # - online -- Partial + # - busy -- Partial + # - connected -- Partial, nullable + # - keyboard.online -- Partial + # - keyboard.outputs -- Partial + # - keyboard.leds -- Partial + # - mouse.online -- Partial + # - mouse.outputs -- Partial, follows with absolute + # - mouse.absolute -- Partial, follows with outputs + # =========================== + yield {} raise NotImplementedError @@ -45,35 +128,168 @@ async def reset(self) -> None: async def cleanup(self) -> None: pass + def set_params( + self, + keyboard_output: (str | None)=None, + mouse_output: (str | None)=None, + jiggler: (bool | None)=None, + ) -> None: + + raise NotImplementedError + + def set_connected(self, connected: bool) -> None: + _ = connected + # ===== - def send_key_events(self, keys: Iterable[tuple[str, bool]]) -> None: + def get_inactivity_seconds(self) -> int: + return (self.__get_monotonic_seconds() - self.__j_activity_ts) + + # ===== + + async def send_key_events( + self, + keys: Iterable[tuple[int, bool]], + no_ignore_keys: bool=False, + delay: float=0.0, + ) -> None: + + for (key, state) in keys: + if no_ignore_keys or key not in self.__ignore_keys: + if delay > 0: + await asyncio.sleep(delay) + self.send_key_event(key, state, False) + + def send_key_event(self, key: int, state: bool, finish: bool) -> None: + self._send_key_event(key, state) + if state and finish and (key not in EvdevModifiers.ALL and key != ecodes.KEY_SYSRQ): + # Считаем что PrintScreen это модификатор для Alt+SysRq+... + # По-хорошему надо учитывать факт нажатия на Alt, но можно и забить. + self._send_key_event(key, False) + self.__bump_activity() + + def _send_key_event(self, key: int, state: bool) -> None: raise NotImplementedError - def send_mouse_button_event(self, button: str, state: bool) -> None: + # ===== + + def send_mouse_button_event(self, button: int, state: bool) -> None: + self._send_mouse_button_event(button, state) + self.__bump_activity() + + def _send_mouse_button_event(self, button: int, state: bool) -> None: raise NotImplementedError + # ===== + def send_mouse_move_event(self, to_x: int, to_y: int) -> None: - _ = to_x + self.__j_last_x = to_x + self.__j_last_y = to_y + if self.__mouse_x_range != MouseRange.RANGE: + to_x = MouseRange.remap(to_x, *self.__mouse_x_range) + if self.__mouse_y_range != MouseRange.RANGE: + to_y = MouseRange.remap(to_y, *self.__mouse_y_range) + self._send_mouse_move_event(to_x, to_y) + self.__bump_activity() + + def _send_mouse_move_event(self, to_x: int, to_y: int) -> None: + _ = to_x # XXX: NotImplementedError _ = to_y + # ===== + + def send_mouse_relative_events(self, deltas: Iterable[tuple[int, int]], squash: bool) -> None: + self.__process_mouse_delta_event(deltas, squash, self.send_mouse_relative_event) + def send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None: - _ = delta_x + self._send_mouse_relative_event(delta_x, delta_y) + self.__bump_activity() + + def _send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None: + _ = delta_x # XXX: NotImplementedError _ = delta_y + # ===== + + def send_mouse_wheel_events(self, deltas: Iterable[tuple[int, int]], squash: bool) -> None: + self.__process_mouse_delta_event(deltas, squash, self.send_mouse_wheel_event) + def send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: - raise NotImplementedError + self._send_mouse_wheel_event(delta_x, delta_y) + self.__bump_activity() - def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | None)=None) -> None: - _ = keyboard_output - _ = mouse_output + def _send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: + raise NotImplementedError - def set_connected(self, connected: bool) -> None: - _ = connected + # ===== def clear_events(self) -> None: + self._clear_events() # Don't bump activity here + + def _clear_events(self) -> None: raise NotImplementedError + # ===== + + def __process_mouse_delta_event( + self, + deltas: Iterable[tuple[int, int]], + squash: bool, + handler: Callable[[int, int], None], + ) -> None: + + if squash: + prev = (0, 0) + for cur in deltas: + if abs(prev[0] + cur[0]) > 127 or abs(prev[1] + cur[1]) > 127: + handler(*prev) + prev = cur + else: + prev = (prev[0] + cur[0], prev[1] + cur[1]) + if prev[0] or prev[1]: + handler(*prev) + else: + for xy in deltas: + handler(*xy) + + def __bump_activity(self) -> None: + self.__j_activity_ts = self.__get_monotonic_seconds() + + def __get_monotonic_seconds(self) -> int: + return int(time.monotonic()) + + def _set_jiggler_absolute(self, absolute: bool) -> None: + self.__j_absolute = absolute + + def _set_jiggler_active(self, active: bool) -> None: + if self.__j_enabled: + self.__j_active = active + + def _get_jiggler_state(self) -> dict: + return { + "jiggler": { + "enabled": self.__j_enabled, + "active": self.__j_active, + "interval": self.__j_interval, + }, + } + + # ===== + + async def systask(self) -> None: + while True: + if self.__j_active and (self.__j_activity_ts + self.__j_interval < self.__get_monotonic_seconds()): + if self.__j_absolute: + (x, y) = (self.__j_last_x, self.__j_last_y) + for move in (([100, -100] * 5) + [0]): + self.send_mouse_move_event(MouseRange.normalize(x + move), MouseRange.normalize(y + move)) + await asyncio.sleep(0.1) + else: + for move in ([10, -10] * 5): + self.send_mouse_relative_event(move, move) + await asyncio.sleep(0.1) + await asyncio.sleep(1) + # ===== def get_hid_class(name: str) -> type[BaseHid]: diff --git a/kvmd/plugins/hid/_mcu/__init__.py b/kvmd/plugins/hid/_mcu/__init__.py index 266325538..b638fe390 100644 --- a/kvmd/plugins/hid/_mcu/__init__.py +++ b/kvmd/plugins/hid/_mcu/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -23,18 +23,16 @@ import multiprocessing import contextlib import queue +import copy import time -from typing import Iterable from typing import Generator from typing import AsyncGenerator +from typing import Any from ....logging import get_logger -from .... import tools -from .... import aiotools from .... import aiomulti -from .... import aioproc from ....yamlconf import Option @@ -70,6 +68,10 @@ # ===== +class _SelfResetError(Exception): + pass + + class _RequestError(Exception): def __init__(self, msg: str) -> None: super().__init__(msg) @@ -86,7 +88,7 @@ class _TempRequestError(_RequestError): # ===== class BasePhyConnection: - def send(self, request: bytes) -> bytes: + def send(self, req: bytes) -> bytes: raise NotImplementedError @@ -99,24 +101,26 @@ def connected(self) -> Generator[BasePhyConnection, None, None]: raise NotImplementedError -class BaseMcuHid(BaseHid, multiprocessing.Process): # pylint: disable=too-many-instance-attributes +class BaseMcuHid(BaseHid): # pylint: disable=too-many-instance-attributes def __init__( # pylint: disable=too-many-arguments,super-init-not-called self, phy: BasePhy, - gpio_device_path: str, - reset_pin: int, - reset_inverted: bool, - reset_delay: float, + ignore_keys: list[str], + mouse_x_range: dict[str, Any], + mouse_y_range: dict[str, Any], + jiggler: dict[str, Any], + reset_self: bool, read_retries: int, common_retries: int, retries_delay: float, errors_threshold: int, noop: bool, + **gpio_kwargs: Any, ) -> None: - multiprocessing.Process.__init__(self, daemon=True) + super().__init__(ignore_keys=ignore_keys, **mouse_x_range, **mouse_y_range, **jiggler) self.__read_retries = read_retries self.__common_retries = common_retries @@ -125,12 +129,16 @@ def __init__( # pylint: disable=too-many-arguments,super-init-not-called self.__noop = noop self.__phy = phy - self.__gpio = Gpio(gpio_device_path, reset_pin, reset_inverted, reset_delay) + gpio_device_path = gpio_kwargs.pop("gpio_device_path") + self.__gpio = Gpio(device_path=gpio_device_path, **gpio_kwargs) + self.__reset_self = reset_self + + self.__proc = aiomulti.AioMpProcess("hid", self.__subprocess) self.__reset_required_event = multiprocessing.Event() - self.__events_queue: "multiprocessing.Queue[BaseEvent]" = multiprocessing.Queue() + self.__events_q: aiomulti.AioMpQueue[BaseEvent] = aiomulti.AioMpQueue() - self.__notifier = aiomulti.AioProcessNotifier() + self.__notifier = aiomulti.AioMpNotifier() self.__state_flags = aiomulti.AioSharedFlags({ "online": 0, "busy": 0, @@ -142,21 +150,27 @@ def __init__( # pylint: disable=too-many-arguments,super-init-not-called @classmethod def get_plugin_options(cls) -> dict: return { - "gpio_device": Option("/dev/gpiochip0", type=valid_abs_path, unpack_as="gpio_device_path"), - "reset_pin": Option(4, type=valid_gpio_pin_optional), - "reset_inverted": Option(False, type=valid_bool), - "reset_delay": Option(0.1, type=valid_float_f01), + # + "gpio_device": Option("/dev/gpiochip0", type=valid_abs_path, unpack_as="gpio_device_path"), + "power_detect_pin": Option(-1, type=valid_gpio_pin_optional), + "power_detect_pull_down": Option(False, type=valid_bool), + "reset_pin": Option(4, type=valid_gpio_pin_optional), + "reset_inverted": Option(False, type=valid_bool), + "reset_delay": Option(0.1, type=valid_float_f01), + # + "reset_self": Option(False, type=valid_bool), "read_retries": Option(5, type=valid_int_f1), "common_retries": Option(5, type=valid_int_f1), "retries_delay": Option(0.5, type=valid_float_f01), "errors_threshold": Option(5, type=valid_int_f0), "noop": Option(False, type=valid_bool), + + **cls._get_base_options(), } - def sysprep(self) -> None: - get_logger(0).info("Starting HID daemon ...") - self.start() + async def sysprep(self) -> None: + self.__proc.start() async def get_state(self) -> dict: state = await self.__state_flags.get() @@ -169,6 +183,7 @@ async def get_state(self) -> dict: active_mouse = get_active_mouse(outputs1) if online and active_mouse in ["usb_rel", "ps2"]: absolute = False + self._set_jiggler_absolute(absolute) keyboard_outputs: dict = {"available": [], "active": ""} mouse_outputs: dict = {"available": [], "active": ""} @@ -199,6 +214,7 @@ async def get_state(self) -> dict: mouse_outputs["active"] = active_mouse return { + "enabled": True, "online": online, "busy": bool(state["busy"]), "connected": (bool(outputs2 & 0b01000000) if outputs2 & 0b10000000 else None), @@ -216,47 +232,39 @@ async def get_state(self) -> dict: "absolute": absolute, "outputs": mouse_outputs, }, + **self._get_jiggler_state(), } + async def trigger_state(self) -> None: + self.__notifier.notify(1) + async def poll_state(self) -> AsyncGenerator[dict, None]: - prev_state: dict = {} + prev: dict = {} while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - await self.__notifier.wait() + 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.__reset_required_event.set() - @aiotools.atomic_fg async def cleanup(self) -> None: - if self.is_alive(): - get_logger(0).info("Stopping HID daemon ...") + if self.__proc.is_alive(): self.__stop_event.set() - if self.is_alive() or self.exitcode is not None: - self.join() + await self.__proc.async_join() # ===== - def send_key_events(self, keys: Iterable[tuple[str, bool]]) -> None: - for (key, state) in keys: - self.__queue_event(KeyEvent(key, state)) - - def send_mouse_button_event(self, button: str, state: bool) -> None: - self.__queue_event(MouseButtonEvent(button, state)) - - def send_mouse_move_event(self, to_x: int, to_y: int) -> None: - self.__queue_event(MouseMoveEvent(to_x, to_y)) - - def send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None: - self.__queue_event(MouseRelativeEvent(delta_x, delta_y)) - - def send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: - self.__queue_event(MouseWheelEvent(delta_x, delta_y)) + def set_params( + self, + keyboard_output: (str | None)=None, + mouse_output: (str | None)=None, + jiggler: (bool | None)=None, + ) -> None: - def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | None)=None) -> None: events: list[BaseEvent] = [] if keyboard_output is not None: events.append(SetKeyboardOutputEvent(keyboard_output)) @@ -264,11 +272,29 @@ def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | No events.append(SetMouseOutputEvent(mouse_output)) for (index, event) in enumerate(events, 1): self.__queue_event(event, clear=(index == len(events))) + if jiggler is not None: + self._set_jiggler_active(jiggler) + self.__notifier.notify() def set_connected(self, connected: bool) -> None: self.__queue_event(SetConnectedEvent(connected), clear=True) - def clear_events(self) -> None: + def _send_key_event(self, key: int, state: bool) -> None: + self.__queue_event(KeyEvent(key, state)) + + def _send_mouse_button_event(self, button: int, state: bool) -> None: + self.__queue_event(MouseButtonEvent(button, state)) + + def _send_mouse_move_event(self, to_x: int, to_y: int) -> None: + self.__queue_event(MouseMoveEvent(to_x, to_y)) + + def _send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None: + self.__queue_event(MouseRelativeEvent(delta_x, delta_y)) + + def _send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: + self.__queue_event(MouseWheelEvent(delta_x, delta_y)) + + def _clear_events(self) -> None: self.__queue_event(ClearEvent(), clear=True) def __queue_event(self, event: BaseEvent, clear: bool=False) -> None: @@ -277,11 +303,11 @@ def __queue_event(self, event: BaseEvent, clear: bool=False) -> None: # FIXME: Если очистка производится со стороны процесса хида, то возможна гонка между # очисткой и добавлением нового события. Неприятно, но не смертельно. # Починить блокировкой после перехода на асинхронные очереди. - tools.clear_queue(self.__events_queue) - self.__events_queue.put_nowait(event) + self.__events_q.clear_current() + self.__events_q.put_nowait(event) - def run(self) -> None: # pylint: disable=too-many-branches - logger = aioproc.settle("HID", "hid") + def __subprocess(self) -> None: # pylint: disable=too-many-branches + logger = get_logger(0) while not self.__stop_event.is_set(): try: with self.__gpio: @@ -298,20 +324,20 @@ def run(self) -> None: # pylint: disable=too-many-branches time.sleep(1) def __hid_loop(self) -> None: + reset = True while not self.__stop_event.is_set(): try: - if not self.__hid_loop_wait_device(): + if not self.__hid_loop_wait_device(reset): continue + reset = True with self.__phy.connected() as conn: - while not (self.__stop_event.is_set() and self.__events_queue.qsize() == 0): + while not (self.__stop_event.is_set() and self.__events_q.qsize() == 0): if self.__reset_required_event.is_set(): - try: - self.__set_state_busy(True) - self.__gpio.reset() - finally: - self.__reset_required_event.clear() + self.__set_state_busy(True) + self.__reset_required_event.clear() + break # Проваливаемся и резетим в __hid_loop_wait_device() try: - event = self.__events_queue.get(timeout=0.1) + event = self.__events_q.get(timeout=0.1) except queue.Empty: self.__process_request(conn, REQUEST_PING) else: @@ -319,28 +345,33 @@ def __hid_loop(self) -> None: self.__set_state_busy(True) if not self.__process_request(conn, event.make_request()): self.clear_events() + except _SelfResetError: + time.sleep(1) # Pico перезагружается сам вскоре после ответа + reset = False except Exception: self.clear_events() get_logger(0).exception("Unexpected error in the HID loop") time.sleep(1) - def __hid_loop_wait_device(self) -> bool: + def __hid_loop_wait_device(self, reset: bool) -> bool: logger = get_logger(0) - logger.info("Initial HID reset and wait ...") - self.__gpio.reset() - # На самом деле SPI и Serial-девайсы не пропадают, просто резет и ожидание - # логичнее всего делать именно здесь. Ну и на будущее, да + if reset: + logger.info("Initial HID reset and wait for %s ...", self.__phy) + self.__gpio.reset() + # На самом деле SPI и Serial-девайсы не пропадают, + # а вот USB CDC (Pico HID Bridge) вполне себе пропадает for _ in range(10): if self.__phy.has_device(): - logger.info("HID found") + logger.info("Physical HID interface found: %s", self.__phy) return True if self.__stop_event.is_set(): break time.sleep(1) - logger.error("Missing HID") + logger.error("Missing physical HID interface: %s", self.__phy) + self.__set_state_online(False) return False - def __process_request(self, conn: BasePhyConnection, request: bytes) -> bool: # pylint: disable=too-many-branches + def __process_request(self, conn: BasePhyConnection, req: bytes) -> bool: # pylint: disable=too-many-branches logger = get_logger() error_messages: list[str] = [] live_log_errors = False @@ -349,48 +380,48 @@ def __process_request(self, conn: BasePhyConnection, request: bytes) -> bool: # read_retries = self.__read_retries error_retval = False - while common_retries and read_retries: - response = (RESPONSE_LEGACY_OK if self.__noop else conn.send(request)) + while self.__gpio.is_powered() and common_retries and read_retries: + resp = (RESPONSE_LEGACY_OK if self.__noop else conn.send(req)) try: - if len(response) < 4: + if len(resp) < 4: read_retries -= 1 - raise _TempRequestError(f"No response from HID: request={request!r}") + raise _TempRequestError(f"No response from HID: request={req!r}") - if not check_response(response): - request = REQUEST_REPEAT + if not check_response(resp): + req = REQUEST_REPEAT raise _TempRequestError("Invalid response CRC; requesting response again ...") - code = response[1] + code = resp[1] if code == 0x48: # Request timeout # pylint: disable=no-else-raise - raise _TempRequestError(f"Got request timeout from HID: request={request!r}") + raise _TempRequestError(f"Got request timeout from HID: request={req!r}") elif code == 0x40: # CRC Error - raise _TempRequestError(f"Got CRC error of request from HID: request={request!r}") + raise _TempRequestError(f"Got CRC error of request from HID: request={req!r}") elif code == 0x45: # Unknown command - raise _PermRequestError(f"HID did not recognize the request={request!r}") + raise _PermRequestError(f"HID did not recognize the request={req!r}") elif code == 0x24: # Rebooted? raise _PermRequestError("No previous command state inside HID, seems it was rebooted") elif code == 0x20: # Legacy done self.__set_state_online(True) return True elif code & 0x80: # Pong/Done with state - self.__set_state_pong(response) + self.__set_state_pong(resp) return True - raise _TempRequestError(f"Invalid response from HID: request={request!r}, response=0x{response!r}") + raise _TempRequestError(f"Invalid response from HID: request={req!r}, response=0x{resp!r}") - except _RequestError as err: + except _RequestError as ex: common_retries -= 1 if live_log_errors: - logger.error(err.msg) + logger.error(ex.msg) else: - error_messages.append(err.msg) + error_messages.append(ex.msg) if len(error_messages) > self.__errors_threshold: for msg in error_messages: logger.error(msg) error_messages = [] live_log_errors = True - if isinstance(err, _PermRequestError): + if isinstance(ex, _PermRequestError): error_retval = True break @@ -399,10 +430,14 @@ def __process_request(self, conn: BasePhyConnection, request: bytes) -> bool: # if common_retries and read_retries: time.sleep(self.__retries_delay) + if not self.__gpio.is_powered(): + self.__set_state_online(False) + return True + for msg in error_messages: logger.error(msg) if not (common_retries and read_retries): - logger.error("Can't process HID request due many errors: %r", request) + logger.error("Can't process HID request due many errors: %r", req) return error_retval def __set_state_online(self, online: bool) -> None: @@ -411,11 +446,13 @@ def __set_state_online(self, online: bool) -> None: def __set_state_busy(self, busy: bool) -> None: self.__state_flags.update(busy=int(busy)) - def __set_state_pong(self, response: bytes) -> None: - status = response[1] << 16 - if len(response) > 4: - status |= (response[2] << 8) | response[3] - reset_required = (1 if response[1] & 0b01000000 else 0) + def __set_state_pong(self, resp: bytes) -> None: + status = resp[1] << 16 + if len(resp) > 4: + status |= (resp[2] << 8) | resp[3] + reset_required = (1 if resp[1] & 0b01000000 else 0) self.__state_flags.update(online=1, busy=reset_required, status=status) if reset_required: + if self.__reset_self: + raise _SelfResetError() self.__reset_required_event.set() diff --git a/kvmd/plugins/hid/_mcu/gpio.py b/kvmd/plugins/hid/_mcu/gpio.py index f79820f13..ab4015826 100644 --- a/kvmd/plugins/hid/_mcu/gpio.py +++ b/kvmd/plugins/hid/_mcu/gpio.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -29,30 +29,47 @@ # ===== -class Gpio: +class Gpio: # pylint: disable=too-many-instance-attributes def __init__( self, device_path: str, + power_detect_pin: int, + power_detect_pull_down: bool, reset_pin: int, reset_inverted: bool, reset_delay: float, ) -> None: self.__device_path = device_path + self.__power_detect_pin = power_detect_pin + self.__power_detect_pull_down = power_detect_pull_down self.__reset_pin = reset_pin self.__reset_inverted = reset_inverted self.__reset_delay = reset_delay - self.__chip: (gpiod.Chip | None) = None - self.__reset_line: (gpiod.Line | None) = None + self.__line_req: (gpiod.LineRequest | None) = None + self.__last_power: (bool | None) = None def __enter__(self) -> None: - if self.__reset_pin >= 0: - assert self.__chip is None - assert self.__reset_line is None - self.__chip = gpiod.Chip(self.__device_path) - self.__reset_line = self.__chip.get_line(self.__reset_pin) - self.__reset_line.request("kvmd::hid::reset", gpiod.LINE_REQ_DIR_OUT, default_vals=[int(self.__reset_inverted)]) + if self.__power_detect_pin >= 0 or self.__reset_pin >= 0: + assert self.__line_req is None + config: dict[int, gpiod.LineSettings] = {} + if self.__power_detect_pin >= 0: + config[self.__power_detect_pin] = gpiod.LineSettings( + direction=gpiod.line.Direction.INPUT, + bias=(gpiod.line.Bias.PULL_DOWN if self.__power_detect_pull_down else gpiod.line.Bias.AS_IS), + ) + if self.__reset_pin >= 0: + config[self.__reset_pin] = gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT, + output_value=gpiod.line.Value(self.__reset_inverted), + ) + assert len(config) > 0 + self.__line_req = gpiod.request_lines( + self.__device_path, + consumer="kvmd::hid", + config=config, # type: ignore + ) def __exit__( self, @@ -61,21 +78,31 @@ def __exit__( _tb: types.TracebackType, ) -> None: - if self.__chip: + if self.__line_req: try: - self.__chip.close() + self.__line_req.release() except Exception: pass - self.__reset_line = None - self.__chip = None + self.__last_power = None + self.__line_req = None + + def is_powered(self) -> bool: + if self.__power_detect_pin >= 0: + assert self.__line_req + power = bool(self.__line_req.get_value(self.__power_detect_pin).value) + if power != self.__last_power: + get_logger(0).info("HID power state changed: %s -> %s", self.__last_power, power) + self.__last_power = power + return power + return True def reset(self) -> None: if self.__reset_pin >= 0: - assert self.__reset_line + assert self.__line_req try: - self.__reset_line.set_value(int(not self.__reset_inverted)) + self.__line_req.set_value(self.__reset_pin, gpiod.line.Value(not self.__reset_inverted)) time.sleep(self.__reset_delay) finally: - self.__reset_line.set_value(int(self.__reset_inverted)) + self.__line_req.set_value(self.__reset_pin, gpiod.line.Value(self.__reset_inverted)) time.sleep(1) get_logger(0).info("Reset HID performed") diff --git a/kvmd/plugins/hid/_mcu/proto.py b/kvmd/plugins/hid/_mcu/proto.py index e7bc165de..2ae03b83a 100644 --- a/kvmd/plugins/hid/_mcu/proto.py +++ b/kvmd/plugins/hid/_mcu/proto.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -23,11 +23,15 @@ import dataclasses import struct +from evdev import ecodes + from ....keyboard.mappings import KEYMAP from ....mouse import MouseRange +from ....mouse import MouseDelta from .... import tools +from .... import bitbang # ===== @@ -104,33 +108,36 @@ def make_request(self) -> bytes: @dataclasses.dataclass(frozen=True) class KeyEvent(BaseEvent): - name: str + code: int state: bool def __post_init__(self) -> None: - assert self.name in KEYMAP + assert self.code in KEYMAP def make_request(self) -> bytes: - code = KEYMAP[self.name].mcu.code + code = KEYMAP[self.code].mcu.code return _make_request(struct.pack(">BBBxx", 0x11, code, int(self.state))) @dataclasses.dataclass(frozen=True) class MouseButtonEvent(BaseEvent): - name: str + code: int state: bool def __post_init__(self) -> None: - assert self.name in ["left", "right", "middle", "up", "down"] + assert self.code in [ + ecodes.BTN_LEFT, ecodes.BTN_RIGHT, ecodes.BTN_MIDDLE, + ecodes.BTN_BACK, ecodes.BTN_FORWARD, + ] def make_request(self) -> bytes: (code, state_pressed, is_main) = { - "left": (0b10000000, 0b00001000, True), - "right": (0b01000000, 0b00000100, True), - "middle": (0b00100000, 0b00000010, True), - "up": (0b10000000, 0b00001000, False), # Back - "down": (0b01000000, 0b00000100, False), # Forward - }[self.name] + ecodes.BTN_LEFT: (0b10000000, 0b00001000, True), + ecodes.BTN_RIGHT: (0b01000000, 0b00000100, True), + ecodes.BTN_MIDDLE: (0b00100000, 0b00000010, True), + ecodes.BTN_BACK: (0b10000000, 0b00001000, False), # Up + ecodes.BTN_FORWARD: (0b01000000, 0b00000100, False), # Down + }[self.code] if self.state: code |= state_pressed if is_main: @@ -161,8 +168,8 @@ class MouseRelativeEvent(BaseEvent): delta_y: int def __post_init__(self) -> None: - assert -127 <= self.delta_x <= 127 - assert -127 <= self.delta_y <= 127 + assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX + assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX def make_request(self) -> bytes: return _make_request(struct.pack(">Bbbxx", 0x15, self.delta_x, self.delta_y)) @@ -174,8 +181,8 @@ class MouseWheelEvent(BaseEvent): delta_y: int def __post_init__(self) -> None: - assert -127 <= self.delta_x <= 127 - assert -127 <= self.delta_y <= 127 + assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX + assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX def make_request(self) -> bytes: # Горизонтальная прокрутка пока не поддерживается @@ -183,34 +190,21 @@ def make_request(self) -> bytes: # ===== -def check_response(response: bytes) -> bool: - assert len(response) in (4, 8), response - return (_make_crc16(response[:-2]) == struct.unpack(">H", response[-2:])[0]) - - -def _make_request(command: bytes) -> bytes: - assert len(command) == 5, command - request = b"\x33" + command - request += struct.pack(">H", _make_crc16(request)) - assert len(request) == 8, request - return request +def check_response(resp: bytes) -> bool: + assert len(resp) in (4, 8), resp + return (bitbang.make_crc16(resp[:-2]) == struct.unpack(">H", resp[-2:])[0]) -def _make_crc16(data: bytes) -> int: - crc = 0xFFFF - for byte in data: - crc = crc ^ byte - for _ in range(8): - if crc & 0x0001 == 0: - crc = crc >> 1 - else: - crc = crc >> 1 - crc = crc ^ 0xA001 - return crc +def _make_request(cmd: bytes) -> bytes: + assert len(cmd) == 5, cmd + req = b"\x33" + cmd + req += struct.pack(">H", bitbang.make_crc16(req)) + assert len(req) == 8, req + return req # ===== REQUEST_PING = _make_request(b"\x01\x00\x00\x00\x00") REQUEST_REPEAT = _make_request(b"\x02\x00\x00\x00\x00") -RESPONSE_LEGACY_OK = b"\x33\x20" + struct.pack(">H", _make_crc16(b"\x33\x20")) +RESPONSE_LEGACY_OK = b"\x33\x20" + struct.pack(">H", bitbang.make_crc16(b"\x33\x20")) diff --git a/kvmd/plugins/hid/bt/__init__.py b/kvmd/plugins/hid/bt/__init__.py index 8139986f7..717ee8c7c 100644 --- a/kvmd/plugins/hid/bt/__init__.py +++ b/kvmd/plugins/hid/bt/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,10 +21,11 @@ import multiprocessing +import copy import time -from typing import Iterable from typing import AsyncGenerator +from typing import Any from ....logging import get_logger @@ -35,9 +36,7 @@ from ....validators.basic import valid_int_f1 from ....validators.basic import valid_float_f01 -from .... import aiotools from .... import aiomulti -from .... import aioproc from .. import BaseHid @@ -59,8 +58,14 @@ class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes # https://gist.github.com/whitelynx/9f9bd4cb266b3924c64dfdff14bce2e8 # https://archlinuxarm.org/forum/viewtopic.php?f=67&t=14244 - def __init__( # pylint: disable=too-many-arguments,too-many-locals,super-init-not-called + def __init__( # pylint: disable=too-many-arguments,too-many-locals self, + + ignore_keys: list[str], + mouse_x_range: dict[str, Any], + mouse_y_range: dict[str, Any], + jiggler: dict[str, Any], + manufacturer: str, product: str, description: str, @@ -78,10 +83,13 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals,super-init-n select_timeout: float, ) -> None: - self.__proc: (multiprocessing.Process | None) = None + super().__init__(ignore_keys=ignore_keys, **mouse_x_range, **mouse_y_range, **jiggler) + self._set_jiggler_absolute(False) + + self.__proc = aiomulti.AioMpProcess("hid", self.__server_worker) self.__stop_event = multiprocessing.Event() - self.__notifier = aiomulti.AioProcessNotifier() + self.__notifier = aiomulti.AioMpNotifier() self.__server = BtServer( iface=BluezIface( @@ -118,17 +126,18 @@ def get_plugin_options(cls) -> dict: "max_clients": Option(1, type=valid_int_f1), "socket_timeout": Option(5.0, type=valid_float_f01), "select_timeout": Option(1.0, type=valid_float_f01), + + **cls._get_base_options(), } - def sysprep(self) -> None: - get_logger(0).info("Starting HID daemon ...") - self.__proc = multiprocessing.Process(target=self.__server_worker, daemon=True) + async def sysprep(self) -> None: self.__proc.start() async def get_state(self) -> dict: state = await self.__server.get_state() outputs: dict = {"available": [], "active": ""} return { + "enabled": True, "online": True, "busy": False, "connected": None, @@ -146,52 +155,65 @@ async def get_state(self) -> dict: "absolute": False, "outputs": outputs, }, + **self._get_jiggler_state(), } + async def trigger_state(self) -> None: + self.__notifier.notify(1) + async def poll_state(self) -> AsyncGenerator[dict, None]: - prev_state: dict = {} + prev: dict = {} while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - await self.__notifier.wait() + 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.clear_events() self.__server.queue_event(ResetEvent()) - @aiotools.atomic_fg async def cleanup(self) -> None: - if self.__proc is not None: - if self.__proc.is_alive(): - get_logger(0).info("Stopping HID daemon ...") - self.__stop_event.set() - if self.__proc.is_alive() or self.__proc.exitcode is not None: - self.__proc.join() + if self.__proc.is_alive(): + self.__stop_event.set() + await self.__proc.async_join() # ===== - def send_key_events(self, keys: Iterable[tuple[str, bool]]) -> None: - for (key, state) in keys: - self.__server.queue_event(make_keyboard_event(key, state)) + def set_params( + self, + keyboard_output: (str | None)=None, + mouse_output: (str | None)=None, + jiggler: (bool | None)=None, + ) -> None: + + _ = keyboard_output + _ = mouse_output + if jiggler is not None: + self._set_jiggler_active(jiggler) + self.__notifier.notify() + + def _send_key_event(self, key: int, state: bool) -> None: + self.__server.queue_event(make_keyboard_event(key, state)) - def send_mouse_button_event(self, button: str, state: bool) -> None: + def _send_mouse_button_event(self, button: int, state: bool) -> None: self.__server.queue_event(MouseButtonEvent(button, state)) - def send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None: + def _send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None: self.__server.queue_event(MouseRelativeEvent(delta_x, delta_y)) - def send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: + def _send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: self.__server.queue_event(MouseWheelEvent(delta_x, delta_y)) - def clear_events(self) -> None: + def _clear_events(self) -> None: self.__server.clear_events() # ===== def __server_worker(self) -> None: # pylint: disable=too-many-branches - logger = aioproc.settle("HID", "hid") + logger = get_logger(0) while not self.__stop_event.is_set(): try: self.__server.run() diff --git a/kvmd/plugins/hid/bt/bluez.py b/kvmd/plugins/hid/bt/bluez.py index 8d1da8191..3e6e3f433 100644 --- a/kvmd/plugins/hid/bt/bluez.py +++ b/kvmd/plugins/hid/bt/bluez.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/plugins/hid/bt/sdp.py b/kvmd/plugins/hid/bt/sdp.py index e040f4bb2..928339a1e 100644 --- a/kvmd/plugins/hid/bt/sdp.py +++ b/kvmd/plugins/hid/bt/sdp.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/kvmd/plugins/hid/bt/server.py b/kvmd/plugins/hid/bt/server.py index c00480c94..59bc348f9 100644 --- a/kvmd/plugins/hid/bt/server.py +++ b/kvmd/plugins/hid/bt/server.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -24,6 +24,7 @@ import select import multiprocessing import multiprocessing.synchronize +import multiprocessing.connection import dataclasses import contextlib import queue @@ -85,7 +86,7 @@ def __init__( socket_timeout: float, select_timeout: float, - notifier: aiomulti.AioProcessNotifier, + notifier: aiomulti.AioMpNotifier, stop_event: multiprocessing.synchronize.Event, ) -> None: @@ -101,9 +102,9 @@ def __init__( self.__stop_event = stop_event self.__clients: dict[str, _BtClient] = {} - self.__to_read: set[socket.socket] = set() + self.__to_read: set[socket.SocketType | multiprocessing.connection.Connection] = set() - self.__events_queue: "multiprocessing.Queue[BaseEvent]" = multiprocessing.Queue() + self.__events_q: aiomulti.AioMpQueue[BaseEvent] = aiomulti.AioMpQueue() self.__state_flags = aiomulti.AioSharedFlags({ "online": False, @@ -111,7 +112,7 @@ def __init__( "scroll": False, "num": False, }, notifier) - self.__modifiers: set[UsbKey] = set() + self.__mods: set[UsbKey] = set() self.__keys: list[UsbKey | None] = [None] * 6 self.__mouse_buttons = 0 @@ -133,13 +134,13 @@ async def get_state(self) -> dict: def queue_event(self, event: BaseEvent) -> None: if not self.__stop_event.is_set(): - self.__events_queue.put_nowait(event) + self.__events_q.put_nowait(event) def clear_events(self) -> None: # FIXME: Если очистка производится со стороны процесса хида, то возможна гонка между # очисткой и добавлением события ClearEvent. Неприятно, но не смертельно. # Починить блокировкой после перехода на асинхронные очереди. - tools.clear_queue(self.__events_queue) + self.__events_q.clear_current() self.queue_event(ClearEvent()) # ===== @@ -160,7 +161,7 @@ def __main_loop( # pylint: disable=too-many-branches server_int_sock: socket.socket, ) -> None: - qr = self.__events_queue._reader # type: ignore # pylint: disable=protected-access + qr = self.__events_q.get_reader() self.__to_read = set([qr, server_ctl_sock, server_int_sock]) self.__clients = {} @@ -182,8 +183,8 @@ def __main_loop( # pylint: disable=too-many-branches self.__close_client("CTL", client, "ctl_sock") elif data == b"\x71": sock.send(b"\x00") - except Exception as err: - get_logger(0).exception("CTL socket error on %s: %s", client.addr, tools.efmt(err)) + except Exception as ex: + get_logger(0).exception("CTL socket error on %s: %s", client.addr, tools.efmt(ex)) self.__close_client("CTL", client, "ctl_sock") continue @@ -196,8 +197,8 @@ def __main_loop( # pylint: disable=too-many-branches self.__close_client("INT", client, "int_sock") elif data[:2] == b"\xA2\x01": self.__process_leds(data[2]) - except Exception as err: - get_logger(0).exception("INT socket error on %s: %s", client.addr, tools.efmt(err)) + except Exception as ex: + get_logger(0).exception("INT socket error on %s: %s", client.addr, tools.efmt(ex)) self.__close_client("INT", client, "ctl_sock") if qr in ready_read: @@ -213,9 +214,9 @@ def __process_leds(self, leds: int) -> None: ) def __process_events(self) -> None: # pylint: disable=too-many-branches - for _ in range(self.__events_queue.qsize()): + for _ in range(self.__events_q.qsize()): try: - event = self.__events_queue.get_nowait() + event = self.__events_q.get_nowait() except queue.Empty: break else: @@ -231,11 +232,11 @@ def __process_events(self) -> None: # pylint: disable=too-many-branches self.__send_mouse_state(0, 0, 0) elif isinstance(event, ModifierEvent): - if event.modifier in self.__modifiers: # Ранее нажатый модификатор отжимаем - self.__modifiers.remove(event.modifier) + if event.mod in self.__mods: # Ранее нажатый модификатор отжимаем + self.__mods.remove(event.mod) self.__send_keyboard_state() if event.state: # Нажимаем если нужно - self.__modifiers.add(event.modifier) + self.__mods.add(event.mod) self.__send_keyboard_state() elif isinstance(event, KeyEvent): @@ -266,7 +267,7 @@ def __process_events(self) -> None: # pylint: disable=too-many-branches def __send_keyboard_state(self) -> None: for client in list(self.__clients.values()): if client.int_sock is not None: - report = make_keyboard_report(self.__modifiers, self.__keys) + report = make_keyboard_report(self.__mods, self.__keys) self.__send_report(client, "keyboard", b"\xA1\x01" + report) def __send_mouse_state(self, move_x: int, move_y: int, wheel_y: int) -> None: @@ -279,12 +280,12 @@ def __send_report(self, client: _BtClient, name: str, report: bytes) -> None: assert client.int_sock is not None try: client.int_sock.send(report) - except Exception as err: - get_logger(0).info("Can't send %s report to %s: %s", name, client.addr, tools.efmt(err)) + except Exception as ex: + get_logger(0).info("Can't send %s report to %s: %s", name, client.addr, tools.efmt(ex)) self.__close_client_pair(client) def __clear_modifiers(self) -> None: - self.__modifiers.clear() + self.__mods.clear() def __clear_keys(self) -> None: self.__keys = [None] * 6 @@ -371,13 +372,13 @@ def __set_public(self, public: bool) -> None: logger.info("Publishing ..." if public else "Unpublishing ...") try: self.__iface.set_public(public) - except Exception as err: - logger.error("Can't change public mode: %s", tools.efmt(err)) + except Exception as ex: + logger.error("Can't change public mode: %s", tools.efmt(ex)) def __unpair_client(self, client: _BtClient) -> None: logger = get_logger(0) logger.info("Unpairing %s ...", client.addr) try: self.__iface.unpair(client.addr) - except Exception as err: - logger.error("Can't unpair %s: %s", client.addr, tools.efmt(err)) + except Exception as ex: + logger.error("Can't unpair %s: %s", client.addr, tools.efmt(ex)) diff --git a/kvmd/plugins/hid/ch9329/__init__.py b/kvmd/plugins/hid/ch9329/__init__.py new file mode 100644 index 000000000..272f1a479 --- /dev/null +++ b/kvmd/plugins/hid/ch9329/__init__.py @@ -0,0 +1,244 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2022 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import multiprocessing +import queue +import copy +import time + +from typing import AsyncGenerator +from typing import Any + +from ....logging import get_logger + +from .... import tools +from .... import aiomulti + +from ....yamlconf import Option + +from ....validators.basic import valid_float_f01 +from ....validators.os import valid_abs_path +from ....validators.hw import valid_tty_speed + +from .. import BaseHid + +from .chip import ChipResponseError +from .chip import ChipConnection +from .chip import Chip +from .mouse import Mouse +from .keyboard import Keyboard + + +# ===== +class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes + def __init__( # pylint: disable=too-many-arguments,super-init-not-called + self, + ignore_keys: list[str], + mouse_x_range: dict[str, Any], + mouse_y_range: dict[str, Any], + jiggler: dict[str, Any], + + device_path: str, + speed: int, + read_timeout: float, + ) -> None: + + super().__init__(ignore_keys=ignore_keys, **mouse_x_range, **mouse_y_range, **jiggler) + + self.__device_path = device_path + self.__speed = speed + self.__read_timeout = read_timeout + + self.__reset_required_event = multiprocessing.Event() + self.__cmd_q: aiomulti.AioMpQueue[bytes] = aiomulti.AioMpQueue() + + self.__notifier = aiomulti.AioMpNotifier() + self.__state_flags = aiomulti.AioSharedFlags({ + "online": 0, + "busy": 0, + "status": 0, + }, self.__notifier, type=int) + + self.__proc = aiomulti.AioMpProcess("hid", self.__subprocess) + self.__stop_event = multiprocessing.Event() + + self.__chip = Chip(device_path, speed, read_timeout) + self.__keyboard = Keyboard() + self.__mouse = Mouse() + + @classmethod + def get_plugin_options(cls) -> dict: + return { + "device": Option("/dev/kvmd-hid", type=valid_abs_path, unpack_as="device_path"), + "speed": Option(9600, type=valid_tty_speed), + "read_timeout": Option(0.3, type=valid_float_f01), + **cls._get_base_options(), + } + + async def sysprep(self) -> None: + self.__proc.start() + + async def get_state(self) -> dict: + state = await self.__state_flags.get() + absolute = self.__mouse.is_absolute() + leds = await self.__keyboard.get_leds() + return { + "enabled": True, + "online": state["online"], + "busy": False, + "connected": None, + "keyboard": { + "online": state["online"], + "leds": leds, + "outputs": {"available": [], "active": ""}, + }, + "mouse": { + "online": state["online"], + "absolute": absolute, + "outputs": { + "available": ["usb", "usb_rel"], + "active": ("usb" if absolute else "usb_rel"), + }, + }, + **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.__reset_required_event.set() + + async def cleanup(self) -> None: + if self.__proc.is_alive(): + self.__stop_event.set() + await self.__proc.async_join() + + # ===== + + def set_params( + self, + keyboard_output: (str | None)=None, + mouse_output: (str | None)=None, + jiggler: (bool | None)=None, + ) -> None: + + _ = keyboard_output + if mouse_output is not None: + get_logger(0).info("HID : mouse output = %s", mouse_output) + absolute = (mouse_output == "usb") + self.__mouse.set_absolute(absolute) + self._set_jiggler_absolute(absolute) + self.__notifier.notify() + if jiggler is not None: + self._set_jiggler_active(jiggler) + self.__notifier.notify() + + def _send_key_event(self, key: int, state: bool) -> None: + self.__queue_cmd(self.__keyboard.process_key(key, state)) + + def _send_mouse_button_event(self, button: int, state: bool) -> None: + self.__queue_cmd(self.__mouse.process_button(button, state)) + + def _send_mouse_move_event(self, to_x: int, to_y: int) -> None: + self.__queue_cmd(self.__mouse.process_move(to_x, to_y)) + + def _send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: + self.__queue_cmd(self.__mouse.process_wheel(delta_x, delta_y)) + + def _send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None: + self.__queue_cmd(self.__mouse.process_relative(delta_x, delta_y)) + + def _clear_events(self) -> None: + self.__cmd_q.clear_current() + + def __queue_cmd(self, cmd: bytes, clear: bool=False) -> None: + if not self.__stop_event.is_set(): + if clear: + # FIXME: Если очистка производится со стороны процесса хида, то возможна гонка между + # очисткой и добавлением нового события. Неприятно, но не смертельно. + # Починить блокировкой после перехода на асинхронные очереди. + self.__cmd_q.clear_current() + self.__cmd_q.put_nowait(cmd) + + def __subprocess(self) -> None: + logger = get_logger(0) + while not self.__stop_event.is_set(): + try: + self.__hid_loop() + except Exception: + logger.exception("Unexpected error in the run loop") + time.sleep(1) + + def __hid_loop(self) -> None: + while not self.__stop_event.is_set(): + try: + with self.__chip.connected() as conn: + while not (self.__stop_event.is_set() and self.__cmd_q.qsize() == 0): + if self.__reset_required_event.is_set(): + try: + self.__set_state_busy(True) + # self.__process_request(conn, RESET) + finally: + self.__reset_required_event.clear() + try: + cmd = self.__cmd_q.get(timeout=0.1) + # get_logger(0).info(f"HID : cmd = {cmd}") + except queue.Empty: + self.__process_cmd(conn, b"") + else: + self.__process_cmd(conn, cmd) + except Exception: + self.clear_events() + get_logger(0).exception("Unexpected error in the HID loop") + time.sleep(2) + + def __process_cmd(self, conn: ChipConnection, cmd: bytes) -> bool: # pylint: disable=too-many-branches + try: + led_byte = conn.xfer(cmd) + except ChipResponseError as ex: + self.__set_state_online(False) + get_logger(0).error("Invalid chip response: %s", tools.efmt(ex)) + time.sleep(2) + else: + if led_byte >= 0: + self.__keyboard.set_leds(led_byte) + self.__notifier.notify() + self.__set_state_online(True) + return True + return False + + def __set_state_online(self, online: bool) -> None: + self.__state_flags.update(online=int(online)) + + def __set_state_busy(self, busy: bool) -> None: + self.__state_flags.update(busy=int(busy)) diff --git a/kvmd/plugins/hid/ch9329/chip.py b/kvmd/plugins/hid/ch9329/chip.py new file mode 100644 index 000000000..b8631ec03 --- /dev/null +++ b/kvmd/plugins/hid/ch9329/chip.py @@ -0,0 +1,82 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2022 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import serial +import contextlib + +from typing import Generator + + +# ===== +class ChipResponseError(Exception): + pass + + +# ===== +class ChipConnection: + def __init__(self, tty: serial.Serial) -> None: + self.__tty = tty + + def xfer(self, cmd: bytes) -> int: + self.__send(cmd) + return self.__recv() + + def __send(self, cmd: bytes) -> None: + # RESET = [0x00,0x0F,0x00] + # GET_INFO = [0x00,0x01,0x00] + if len(cmd) == 0: + cmd = b"\x00\x01\x00" + cmd = b"\x57\xAB" + cmd + cmd += self.__make_checksum(cmd).to_bytes(1, "big") + self.__tty.write(cmd) + + def __recv(self) -> int: + data = self.__tty.read(5) + if len(data) < 5: + raise ChipResponseError("Too short response, HID might be disconnected") + + if data and data[4]: + data += self.__tty.read(data[4] + 1) + + if self.__make_checksum(data[:-1]) != data[-1]: + raise ChipResponseError("Invalid response checksum") + + if data[4] == 1 and data[5] != 0: + raise ChipResponseError(f"Response error code = {data[5]!r}") + + # led_byte (info) response + return (data[7] if data[3] == 0x81 else -1) + + def __make_checksum(self, cmd: bytes) -> int: + return (sum(cmd) % 256) + + +class Chip: + def __init__(self, device_path: str, speed: int, read_timeout: float) -> None: + self.__device_path = device_path + self.__speed = speed + self.__read_timeout = read_timeout + + @contextlib.contextmanager + def connected(self) -> Generator[ChipConnection, None, None]: # type: ignore + with serial.Serial(self.__device_path, self.__speed, timeout=self.__read_timeout) as tty: + yield ChipConnection(tty) diff --git a/kvmd/plugins/hid/ch9329/keyboard.py b/kvmd/plugins/hid/ch9329/keyboard.py new file mode 100644 index 000000000..b4592d798 --- /dev/null +++ b/kvmd/plugins/hid/ch9329/keyboard.py @@ -0,0 +1,68 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2022 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from .... import aiomulti + +from ....keyboard.mappings import KEYMAP + + +# ===== +class Keyboard: + def __init__(self) -> None: + self.__leds = aiomulti.AioSharedFlags({ + "num": False, + "caps": False, + "scroll": False, + }, aiomulti.AioMpNotifier(), bool) + self.__mods = 0 + self.__active_keys: list[int] = [] + + def set_leds(self, led_byte: int) -> None: + self.__leds.update( + num=bool(led_byte & 1), + caps=bool((led_byte >> 1) & 1), + scroll=bool((led_byte >> 2) & 1), + ) + + async def get_leds(self) -> dict[str, bool]: + return (await self.__leds.get()) + + def process_key(self, key: int, state: bool) -> bytes: + code = KEYMAP[key].usb.code + is_mod = KEYMAP[key].usb.is_mod + if state: + if is_mod: + self.__mods |= code + elif len(self.__active_keys) < 6 and code not in self.__active_keys: + self.__active_keys.append(code) + else: + if is_mod: + self.__mods &= ~code + elif code in self.__active_keys: + self.__active_keys.remove(code) + cmd = [ + 0, 0x02, 0x08, self.__mods, 0, + 0, 0, 0, 0, 0, 0, + ] + for (index, code) in enumerate(self.__active_keys): + cmd[index + 5] = code + return bytes(cmd) diff --git a/kvmd/plugins/hid/ch9329/mouse.py b/kvmd/plugins/hid/ch9329/mouse.py new file mode 100644 index 000000000..fb620f8aa --- /dev/null +++ b/kvmd/plugins/hid/ch9329/mouse.py @@ -0,0 +1,118 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2022 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import math + +from evdev import ecodes + +from ....mouse import MouseRange +from ....mouse import MouseDelta + + +# ===== +class Mouse: # pylint: disable=too-many-instance-attributes + def __init__(self) -> None: + self.__absolute = True + self.__buttons = 0 + self.__to_x = (0, 0) + self.__to_y = (0, 0) + self.__delta_x = 0 + self.__delta_y = 0 + self.__wheel_y = 0 + + def set_absolute(self, flag: bool) -> None: + self.__absolute = flag + + def is_absolute(self) -> bool: + return self.__absolute + + def process_button(self, button: int, state: bool) -> bytes: + code = 0x00 + match button: + case ecodes.BTN_LEFT: + code = 0x01 + case ecodes.BTN_RIGHT: + code = 0x02 + case ecodes.BTN_MIDDLE: + code = 0x04 + case ecodes.BTN_BACK: + code = 0x08 + case ecodes.BTN_FORWARD: + code = 0x10 + if code: + if state: + self.__buttons |= code + else: + self.__buttons &= ~code + self.__wheel_y = 0 + if not self.__absolute: + return self.__make_relative_cmd() + else: + return self.__make_absolute_cmd() + + def process_move(self, to_x: int, to_y: int) -> bytes: + self.__to_x = self.__fix_absolute(to_x) + self.__to_y = self.__fix_absolute(to_y) + self.__wheel_y = 0 + return self.__make_absolute_cmd() + + def __fix_absolute(self, value: int) -> tuple[int, int]: + assert MouseRange.MIN <= value <= MouseRange.MAX + to_fixed = math.ceil(MouseRange.remap(value, 0, MouseRange.MAX) / 8) + return (to_fixed >> 8, to_fixed & 0xFF) + + def process_wheel(self, delta_x: int, delta_y: int) -> bytes: + _ = delta_x + assert MouseDelta.MIN <= delta_y <= MouseDelta.MAX + self.__wheel_y = (1 if delta_y > 0 else 255) + if not self.__absolute: + return self.__make_relative_cmd() + else: + return self.__make_absolute_cmd() + + def process_relative(self, delta_x: int, delta_y: int) -> bytes: + self.__delta_x = self.__fix_relative(delta_x) + self.__delta_y = self.__fix_relative(delta_y) + self.__wheel_y = 0 + return self.__make_relative_cmd() + + def __make_absolute_cmd(self) -> bytes: + return bytes([ + 0, 0x04, 0x07, 0x02, + self.__buttons, + self.__to_x[1], self.__to_x[0], + self.__to_y[1], self.__to_y[0], + self.__wheel_y, + ]) + + def __make_relative_cmd(self) -> bytes: + return bytes([ + 0, 0x05, 0x05, 0x01, + self.__buttons, + self.__delta_x, self.__delta_y, + self.__wheel_y, + ]) + + def __fix_relative(self, value: int) -> int: + assert MouseDelta.MIN <= value <= MouseDelta.MAX + value = math.ceil(value / 3) + return (value if value >= 0 else (255 + value)) diff --git a/kvmd/plugins/hid/otg/__init__.py b/kvmd/plugins/hid/otg/__init__.py index 0090496cc..17a102bc8 100644 --- a/kvmd/plugins/hid/otg/__init__.py +++ b/kvmd/plugins/hid/otg/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,7 +20,9 @@ # ========================================================================== # -from typing import Iterable +import asyncio +import copy + from typing import AsyncGenerator from typing import Any @@ -44,18 +46,26 @@ # ===== class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes - def __init__( # pylint: disable=super-init-not-called + 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() + self.__notifier = aiomulti.AioMpNotifier() win98_fix = mouse.pop("absolute_win98_fix") common = {"notifier": self.__notifier, "noop": noop} @@ -80,6 +90,8 @@ def __init__( # pylint: disable=super-init-not-called # но так было проще реализовать переключение режимов self.__mouses["usb_win98"] = self.__mouses["usb"] + self._set_jiggler_absolute(self.__mouse_current.is_absolute()) + @classmethod def get_plugin_options(cls) -> dict: return { @@ -99,7 +111,7 @@ def get_plugin_options(cls) -> dict: "horizontal_wheel": Option(True, type=valid_bool), }, "mouse_alt": { - "device": Option("", type=valid_abs_path, if_empty="", unpack_as="device_path"), + "device": Option("/dev/kvmd-hid-mouse-alt", 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), @@ -108,9 +120,10 @@ def get_plugin_options(cls) -> dict: "horizontal_wheel": Option(True, type=valid_bool), }, "noop": Option(False, type=valid_bool), + **cls._get_base_options(), } - def sysprep(self) -> None: + async def sysprep(self) -> None: udc = usb.find_udc(self.__udc) get_logger(0).info("Using UDC %s", udc) self.__keyboard_proc.start(udc) @@ -122,6 +135,7 @@ 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, @@ -141,16 +155,21 @@ async def get_state(self) -> dict: }, **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_state: dict = {} + prev: dict = {} while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - await self.__notifier.wait() + 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() @@ -159,41 +178,50 @@ async def reset(self) -> None: 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() + coros = [ + self.__keyboard_proc.cleanup(), + self.__mouse_proc.cleanup(), + ] + if self.__mouse_alt_proc: + coros.append(self.__mouse_alt_proc.cleanup()) + await asyncio.gather(*coros) # ===== - def send_key_events(self, keys: Iterable[tuple[str, bool]]) -> None: - self.__keyboard_proc.send_key_events(keys) + def set_params( + self, + keyboard_output: (str | None)=None, + mouse_output: (str | None)=None, + jiggler: (bool | None)=None, + ) -> None: - def send_mouse_button_event(self, button: str, state: bool) -> 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: int, state: bool) -> None: + self.__keyboard_proc.send_key_event(key, state) + + def _send_mouse_button_event(self, button: int, state: bool) -> None: self.__mouse_current.send_button_event(button, state) - def send_mouse_move_event(self, to_x: int, to_y: int) -> None: + 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: + 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: + def _send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None: self.__mouse_current.send_wheel_event(delta_x, delta_y) - def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | 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.__notifier.notify() - - def clear_events(self) -> None: + def _clear_events(self) -> None: self.__keyboard_proc.send_clear_event() self.__mouse_proc.send_clear_event() if self.__mouse_alt_proc: diff --git a/kvmd/plugins/hid/otg/device.py b/kvmd/plugins/hid/otg/device.py index af30188b2..5f5a99b4b 100644 --- a/kvmd/plugins/hid/otg/device.py +++ b/kvmd/plugins/hid/otg/device.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -34,20 +34,19 @@ 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 +class BaseDeviceProcess: # 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, + notifier: aiomulti.AioMpNotifier, device_path: str, select_timeout: float, @@ -56,8 +55,6 @@ def __init__( # pylint: disable=too-many-arguments noop: bool, ) -> None: - super().__init__(daemon=True) - self.__name = name self.__read_size = read_size @@ -67,21 +64,23 @@ def __init__( # pylint: disable=too-many-arguments self.__write_retries = write_retries self.__noop = noop - self.__udc_state_path = "" - self.__fd = -1 - self.__events_queue: "multiprocessing.Queue[BaseEvent]" = multiprocessing.Queue() + self.__proc = aiomulti.AioMpProcess(f"hid-{self.__name}", self.__subprocess) + self.__events_q: aiomulti.AioMpQueue[BaseEvent] = aiomulti.AioMpQueue() self.__state_flags = aiomulti.AioSharedFlags({"online": True, **initial_state}, notifier) self.__stop_event = multiprocessing.Event() + + self.__udc_state_path = "" + self.__fd = -1 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() + self.__proc.start() - def run(self) -> None: # pylint: disable=too-many-branches - self.__logger = aioproc.settle(f"HID-{self.__name}", f"hid-{self.__name}") + def __subprocess(self) -> None: # pylint: disable=too-many-branches + self.__logger = get_logger(0) report = b"" retries = 0 while not self.__stop_event.is_set(): @@ -91,7 +90,7 @@ def run(self) -> None: # pylint: disable=too-many-branches self.__read_all_reports() try: - event = self.__events_queue.get(timeout=self.__queue_timeout) + event = self.__events_q.get(timeout=self.__queue_timeout) except queue.Empty: # Проблема в том, что устройство может отвечать EAGAIN или ESHUTDOWN, # если оно было отключено физически. См: @@ -143,21 +142,19 @@ def _update_state(self, **kwargs: bool) -> None: # ===== - def _stop(self) -> None: - if self.is_alive(): - get_logger().info("Stopping HID-%s daemon ...", self.__name) + async def _stop(self) -> None: + if self.__proc.is_alive(): self.__stop_event.set() - if self.is_alive() or self.exitcode is not None: - self.join() + await self.__proc.async_join() def _queue_event(self, event: BaseEvent) -> None: - self.__events_queue.put_nowait(event) + self.__events_q.put_nowait(event) def _clear_queue(self) -> None: - tools.clear_queue(self.__events_queue) + self.__events_q.clear_current() def _cleanup_write(self, report: bytes) -> None: - assert not self.is_alive() + assert not self.__proc.is_alive() assert self.__fd < 0 if self.__ensure_device(): self.__write_report(report) @@ -192,13 +189,13 @@ def __write_report(self, report: bytes) -> bool: else: logger.error("HID-%s write() error: written (%s) != report length (%d)", self.__name, written, len(report)) - except Exception as err: - if isinstance(err, OSError) and ( - # https://github.com/raspberrypi/linux/commit/61b7f805dc2fd364e0df682de89227e94ce88e25 - err.errno == errno.EAGAIN # pylint: disable=no-member - or err.errno == errno.ESHUTDOWN # pylint: disable=no-member + except Exception as ex: + if isinstance(ex, OSError) and ( + # https://github.com/raspberrypi/linux/commit/61b7f805dc2fd364e0df682de89227e94ce88e2 + 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(err)) + 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) @@ -216,16 +213,19 @@ def __read_all_reports(self) -> None: while read: try: read = bool(select.select([self.__fd], [], [], 0)[0]) - except Exception as err: - logger.error("Can't select() for read HID-%s: %s", self.__name, tools.efmt(err)) + 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 err: - if isinstance(err, OSError) and err.errno == errno.EAGAIN: # pylint: disable=no-member - logger.debug("HID-%s busy/unplugged (read): %s", self.__name, tools.efmt(err)) + except Exception as ex: + if isinstance(ex, OSError) and ( + ex.errno == errno.EAGAIN # pylint: disable=no-member + or ex.errno == errno.ESHUTDOWN # 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: @@ -255,9 +255,9 @@ def __ensure_device(self) -> bool: 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 err: + except Exception as ex: logger.error("Can't open HID-%s device %s: %s", - self.__name, self.__device_path, tools.efmt(err)) + self.__name, self.__device_path, tools.efmt(ex)) if self.__fd >= 0: try: @@ -268,8 +268,8 @@ def __ensure_device(self) -> bool: else: # Если запись недоступна, то скорее всего устройство отключено logger.debug("HID-%s is busy/unplugged (write select)", self.__name) - except Exception as err: - logger.error("Can't select() for write HID-%s: %s", self.__name, tools.efmt(err)) + 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 diff --git a/kvmd/plugins/hid/otg/events.py b/kvmd/plugins/hid/otg/events.py index 399867725..70dedb54d 100644 --- a/kvmd/plugins/hid/otg/events.py +++ b/kvmd/plugins/hid/otg/events.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -23,10 +23,13 @@ import struct import dataclasses +from evdev import ecodes + from ....keyboard.mappings import UsbKey from ....keyboard.mappings import KEYMAP from ....mouse import MouseRange +from ....mouse import MouseDelta # ===== @@ -45,25 +48,25 @@ class ResetEvent(BaseEvent): # ===== @dataclasses.dataclass(frozen=True) class KeyEvent(BaseEvent): - key: UsbKey + key: UsbKey state: bool def __post_init__(self) -> None: - assert (not self.key.is_modifier) + assert (not self.key.is_mod) @dataclasses.dataclass(frozen=True) class ModifierEvent(BaseEvent): - modifier: UsbKey + mod: UsbKey state: bool def __post_init__(self) -> None: - assert self.modifier.is_modifier + assert self.mod.is_mod -def make_keyboard_event(key: str, state: bool) -> (KeyEvent | ModifierEvent): +def make_keyboard_event(key: int, state: bool) -> (KeyEvent | ModifierEvent): usb_key = KEYMAP[key].usb - if usb_key.is_modifier: + if usb_key.is_mod: return ModifierEvent(usb_key, state) return KeyEvent(usb_key, state) @@ -82,36 +85,36 @@ def get_led_num(flags: int) -> bool: def make_keyboard_report( - pressed_modifiers: set[UsbKey], + pressed_mods: set[UsbKey], pressed_keys: list[UsbKey | None], ) -> bytes: - modifiers = 0 - for modifier in pressed_modifiers: - modifiers |= modifier.code + mods = 0 + for mod in pressed_mods: + mods |= mod.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) + return bytes([mods, 0] + keys) # ===== @dataclasses.dataclass(frozen=True) class MouseButtonEvent(BaseEvent): - button: str - state: bool - code: int = 0 + button: int + 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 + ecodes.BTN_LEFT: 0x1, + ecodes.BTN_RIGHT: 0x2, + ecodes.BTN_MIDDLE: 0x4, + ecodes.BTN_BACK: 0x8, # Back/Up + ecodes.BTN_FORWARD: 0x10, # Forward/Down }[self.button]) @@ -144,8 +147,8 @@ class MouseRelativeEvent(BaseEvent): delta_y: int def __post_init__(self) -> None: - assert -127 <= self.delta_x <= 127 - assert -127 <= self.delta_y <= 127 + assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX + assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX @dataclasses.dataclass(frozen=True) @@ -154,8 +157,8 @@ class MouseWheelEvent(BaseEvent): delta_y: int def __post_init__(self) -> None: - assert -127 <= self.delta_x <= 127 - assert -127 <= self.delta_y <= 127 + assert MouseDelta.MIN <= self.delta_x <= MouseDelta.MAX + assert MouseDelta.MIN <= self.delta_y <= MouseDelta.MAX def make_mouse_report( diff --git a/kvmd/plugins/hid/otg/keyboard.py b/kvmd/plugins/hid/otg/keyboard.py index 75d317352..28a0e28ca 100644 --- a/kvmd/plugins/hid/otg/keyboard.py +++ b/kvmd/plugins/hid/otg/keyboard.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,7 +20,6 @@ # ========================================================================== # -from typing import Iterable from typing import Generator from typing import Any @@ -52,13 +51,15 @@ def __init__(self, **kwargs: Any) -> None: **kwargs, ) - self.__pressed_modifiers: set[UsbKey] = set() + self.__pressed_mods: 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 + async def cleanup(self) -> None: + try: + await self._stop() + finally: + 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() @@ -68,9 +69,8 @@ def send_reset_event(self) -> None: self._clear_queue() self._queue_event(ResetEvent()) - def send_key_events(self, keys: Iterable[tuple[str, bool]]) -> None: - for (key, state) in keys: - self._queue_event(make_keyboard_event(key, state)) + def send_key_event(self, key: int, state: bool) -> None: + self._queue_event(make_keyboard_event(key, state)) # ===== @@ -100,13 +100,13 @@ def __process_clear_event(self) -> bytes: return self.__make_report() def __process_modifier_event(self, event: ModifierEvent) -> Generator[bytes, None, None]: - if event.modifier in self.__pressed_modifiers: + if event.mod in self.__pressed_mods: # Ранее нажатый модификатор отжимаем - self.__pressed_modifiers.remove(event.modifier) + self.__pressed_mods.remove(event.mod) yield self.__make_report() if event.state: # Нажимаем если нужно - self.__pressed_modifiers.add(event.modifier) + self.__pressed_mods.add(event.mod) yield self.__make_report() def __process_key_event(self, event: KeyEvent) -> Generator[bytes, None, None]: @@ -126,10 +126,10 @@ def __process_key_event(self, event: KeyEvent) -> Generator[bytes, None, None]: # ===== def __make_report(self) -> bytes: - return make_keyboard_report(self.__pressed_modifiers, self.__pressed_keys) + return make_keyboard_report(self.__pressed_mods, self.__pressed_keys) def __clear_modifiers(self) -> None: - self.__pressed_modifiers.clear() + self.__pressed_mods.clear() def __clear_keys(self) -> None: self.__pressed_keys = [None] * 6 diff --git a/kvmd/plugins/hid/otg/mouse.py b/kvmd/plugins/hid/otg/mouse.py index b25d83805..7219b3390 100644 --- a/kvmd/plugins/hid/otg/mouse.py +++ b/kvmd/plugins/hid/otg/mouse.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -64,18 +64,20 @@ def set_win98_fix(self, enabled: bool) -> None: 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 + async def cleanup(self) -> None: + try: + await self._stop() + finally: + 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() @@ -85,7 +87,7 @@ def send_reset_event(self) -> None: self._clear_queue() self._queue_event(ResetEvent()) - def send_button_event(self, button: str, state: bool) -> None: + def send_button_event(self, button: int, state: bool) -> None: self._queue_event(MouseButtonEvent(button, state)) def send_move_event(self, to_x: int, to_y: int) -> None: @@ -153,7 +155,6 @@ def __make_report( 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 @@ -177,5 +178,3 @@ def __make_report( def __clear_state(self) -> None: self.__pressed_buttons = 0 - self.__x = 0 - self.__y = 0 diff --git a/kvmd/plugins/hid/serial.py b/kvmd/plugins/hid/serial.py index d936e02bd..040828b4c 100644 --- a/kvmd/plugins/hid/serial.py +++ b/kvmd/plugins/hid/serial.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -44,12 +44,12 @@ class _SerialPhyConnection(BasePhyConnection): def __init__(self, tty: serial.Serial) -> None: self.__tty = tty - def send(self, request: bytes) -> bytes: - assert len(request) == 8 - assert request[0] == 0x33 + def send(self, req: bytes) -> bytes: + assert len(req) == 8 + assert req[0] == 0x33 if self.__tty.in_waiting: self.__tty.read_all() - assert self.__tty.write(request) == 8 + assert self.__tty.write(req) == 8 data = self.__tty.read(4) if len(data) == 4: if data[0] == 0x34: # New response protocol @@ -80,6 +80,9 @@ def connected(self) -> Generator[_SerialPhyConnection, None, None]: # type: ign with serial.Serial(self.__device_path, self.__speed, timeout=self.__read_timeout) as tty: yield _SerialPhyConnection(tty) + def __str__(self) -> str: + return f"Serial(path={self.__device_path})" + # ===== class Plugin(BaseMcuHid): diff --git a/kvmd/plugins/hid/spi.py b/kvmd/plugins/hid/spi.py index e3b44e50c..6580ba378 100644 --- a/kvmd/plugins/hid/spi.py +++ b/kvmd/plugins/hid/spi.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -57,9 +57,9 @@ def __init__( self.__xfer = xfer self.__read_timeout = read_timeout - def send(self, request: bytes) -> bytes: - assert len(request) == 8 - assert request[0] == 0x33 + def send(self, req: bytes) -> bytes: + assert len(req) == 8 + assert req[0] == 0x33 deadline_ts = time.monotonic() + self.__read_timeout dummy = b"\x00" * 10 @@ -70,26 +70,26 @@ def send(self, request: bytes) -> bytes: get_logger(0).error("SPI timeout reached while garbage reading") return b"" - self.__xfer(request) + self.__xfer(req) - response: list[int] = [] + resp: list[int] = [] deadline_ts = time.monotonic() + self.__read_timeout found = False while time.monotonic() < deadline_ts: - for byte in self.__xfer(b"\x00" * (9 - len(response))): + for byte in self.__xfer(b"\x00" * (9 - len(resp))): if not found: if byte == 0: continue found = True - response.append(byte) - if len(response) == 8: + resp.append(byte) + if len(resp) == 8: break - if len(response) == 8: + if len(resp) == 8: break else: get_logger(0).error("SPI timeout reached while responce waiting") return b"" - return bytes(response) + return bytes(resp) class _SpiPhy(BasePhy): # pylint: disable=too-many-instance-attributes @@ -100,6 +100,7 @@ def __init__( chip: int, hw_cs: bool, sw_cs_pin: int, + sw_cs_per_byte: bool, max_freq: int, block_usec: int, read_timeout: float, @@ -110,6 +111,7 @@ def __init__( self.__chip = chip self.__hw_cs = hw_cs self.__sw_cs_pin = sw_cs_pin + self.__sw_cs_per_byte = sw_cs_per_byte self.__max_freq = max_freq self.__block_usec = block_usec self.__read_timeout = read_timeout @@ -119,20 +121,31 @@ def has_device(self) -> bool: @contextlib.contextmanager def connected(self) -> Generator[_SpiPhyConnection, None, None]: # type: ignore - with self.__sw_cs_connected() as sw_cs_line: + with self.__sw_cs_connected() as switch_cs: # pylint: disable=contextmanager-generator-missing-cleanup with contextlib.closing(spidev.SpiDev(self.__bus, self.__chip)) as spi: spi.mode = 0 spi.no_cs = (not self.__hw_cs) spi.max_speed_hz = self.__max_freq - def xfer(data: bytes) -> bytes: + def inner_xfer(data: bytes) -> bytes: try: - if sw_cs_line is not None: - sw_cs_line.set_value(0) + if switch_cs is not None: + switch_cs(False) return spi.xfer(data, self.__max_freq, self.__block_usec) finally: - if sw_cs_line is not None: - sw_cs_line.set_value(1) + if switch_cs is not None: + switch_cs(True) + + if self.__sw_cs_per_byte: + # Режим для Pico, когда CS должен взводиться для отдельных байтов + def xfer(data: bytes) -> bytes: + got: list[int] = [] + for byte in data: + got.extend(inner_xfer(byte.to_bytes(1, "big"))) + return bytes(got) + else: + # Режим для Arduino, когда CS взводится для целого блока данных + xfer = inner_xfer yield _SpiPhyConnection( xfer=xfer, @@ -140,15 +153,25 @@ def xfer(data: bytes) -> bytes: ) @contextlib.contextmanager - def __sw_cs_connected(self) -> Generator[(gpiod.Line | None), None, None]: + def __sw_cs_connected(self) -> Generator[(Callable[[bool], None] | None), None, None]: if self.__sw_cs_pin > 0: - with contextlib.closing(gpiod.Chip(self.__gpio_device_path)) as chip: - line = chip.get_line(self.__sw_cs_pin) - line.request("kvmd::hid::sw_cs", gpiod.LINE_REQ_DIR_OUT, default_vals=[1]) - yield line + with gpiod.request_lines( + self.__gpio_device_path, + consumer="kvmd::hid", + config={ + self.__sw_cs_pin: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT, + output_value=gpiod.line.Value(True), + ), + }, + ) as line_request: + yield (lambda state: line_request.set_value(self.__sw_cs_pin, gpiod.line.Value(state))) else: yield None + def __str__(self) -> str: + return f"SPI(bus={self.__bus}, chip={self.__chip})" + # ===== class Plugin(BaseMcuHid): @@ -167,11 +190,12 @@ def get_plugin_options(cls) -> dict: @classmethod def __get_phy_options(cls) -> dict: return { - "bus": Option(-1, type=valid_int_f0), - "chip": Option(-1, type=valid_int_f0), - "hw_cs": Option(False, type=valid_bool), - "sw_cs_pin": Option(-1, type=valid_gpio_pin_optional), - "max_freq": Option(100000, type=valid_int_f1), - "block_usec": Option(1, type=valid_int_f0), - "read_timeout": Option(0.5, type=valid_float_f01), + "bus": Option(-1, type=valid_int_f0), + "chip": Option(-1, type=valid_int_f0), + "hw_cs": Option(False, type=valid_bool), + "sw_cs_pin": Option(-1, type=valid_gpio_pin_optional), + "sw_cs_per_byte": Option(False, type=valid_bool), + "max_freq": Option(100000, type=valid_int_f1), + "block_usec": Option(1, type=valid_int_f0), + "read_timeout": Option(0.5, type=valid_float_f01), } diff --git a/kvmd/plugins/msd/__init__.py b/kvmd/plugins/msd/__init__.py index cb8d81306..2e647839a 100644 --- a/kvmd/plugins/msd/__init__.py +++ b/kvmd/plugins/msd/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,6 +21,7 @@ import os +import asyncio import contextlib import time @@ -117,7 +118,22 @@ class BaseMsd(BasePlugin): async def get_state(self) -> dict: raise NotImplementedError() + async def trigger_state(self) -> None: + raise NotImplementedError() + async def poll_state(self) -> AsyncGenerator[dict, None]: + # ==== Granularity table ==== + # - enabled -- Full + # - online -- Partial + # - busy -- Partial + # - drive -- Partial, nullable + # - storage -- Partial, nullable + # - storage.parts -- Partial + # - storage.images -- Partial + # - storage.downloading -- Partial, nullable + # - storage.uploading -- Partial, nullable + # =========================== + if self is not None: # XXX: Vulture and pylint hack raise NotImplementedError() yield @@ -263,16 +279,18 @@ async def write_chunk(self, chunk: bytes) -> int: return self.__written - def is_complete(self) -> bool: - return (self.__written >= self.__file_size) - async def open(self) -> "MsdFileWriter": assert self.__file is None get_logger(1).info("Writing %r image (%d bytes) to MSD ...", self.__name, self.__file_size) await aiofiles.os.makedirs(os.path.dirname(self.__path), exist_ok=True) self.__file = await aiofiles.open(self.__path, mode="w+b", buffering=0) # type: ignore + await asyncio.to_thread(os.ftruncate, self.__file.fileno(), self.__file_size) # type: ignore return self + async def finish(self) -> bool: + await self.__sync() + return (self.__written >= self.__file_size) + async def close(self) -> None: assert self.__file is not None logger = get_logger() @@ -285,17 +303,14 @@ async def close(self) -> None: else: # written > size (log, result) = (logger.warning, "OVERFLOW") log("Written %d of %d bytes to MSD image %r: %s", self.__written, self.__file_size, self.__name, result) - try: - await self.__sync() - finally: - await self.__file.close() # type: ignore + await self.__file.close() # type: ignore except Exception: logger.exception("Can't close image writer") async def __sync(self) -> None: assert self.__file is not None await self.__file.flush() # type: ignore - await aiotools.run_async(os.fsync, self.__file.fileno()) # type: ignore + await asyncio.to_thread(os.fsync, self.__file.fileno()) # type: ignore # ===== diff --git a/kvmd/plugins/msd/disabled.py b/kvmd/plugins/msd/disabled.py index cb0ace41f..b9f14f6ec 100644 --- a/kvmd/plugins/msd/disabled.py +++ b/kvmd/plugins/msd/disabled.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -40,6 +40,9 @@ def __init__(self) -> None: # ===== class Plugin(BaseMsd): + def __init__(self) -> None: + self.__notifier = aiotools.AioNotifier() + async def get_state(self) -> dict: return { "enabled": False, @@ -49,10 +52,13 @@ async def get_state(self) -> dict: "drive": None, } + async def trigger_state(self) -> None: + self.__notifier.notify() + async def poll_state(self) -> AsyncGenerator[dict, None]: while True: + await self.__notifier.wait() yield (await self.get_state()) - await aiotools.wait_infinite() async def reset(self) -> None: raise MsdDisabledError() diff --git a/kvmd/plugins/msd/otg/__init__.py b/kvmd/plugins/msd/otg/__init__.py index c769bda2c..725bcf6a4 100644 --- a/kvmd/plugins/msd/otg/__init__.py +++ b/kvmd/plugins/msd/otg/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -23,14 +23,12 @@ import asyncio import contextlib import dataclasses -import functools -import time +import copy from typing import AsyncGenerator from ....logging import get_logger -from ....inotify import InotifyMask from ....inotify import Inotify from ....yamlconf import Option @@ -96,15 +94,17 @@ def __init__(self, notifier: aiotools.AioNotifier) -> None: @contextlib.asynccontextmanager async def busy(self, check_online: bool=True) -> AsyncGenerator[None, None]: - async with self._region: - async with self._lock: - self.__notifier.notify() - if check_online: - if self.vd is None: - raise MsdOfflineError() - assert self.storage - yield - self.__notifier.notify() + 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() @@ -132,6 +132,8 @@ def __init__( # pylint: disable=super-init-not-called self.__initial_image: str = initial["image"] self.__initial_cdrom: bool = initial["cdrom"] + self.__gadget = gadget # Only for sysprep() + self.__drive = Drive(gadget, instance=0, lun=0) self.__storage = Storage(fstab.find_msd().root_path, remount_cmd) @@ -140,17 +142,14 @@ def __init__( # pylint: disable=super-init-not-called self.__notifier = aiotools.AioNotifier() self.__state = _State(self.__notifier) - - logger = get_logger(0) - logger.info("Using OTG gadget %r as MSD", gadget) - aiotools.run_sync(self.__reload_state(notify=False)) + self.__reset = False @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)), + "read_chunk_size": Option(65536, type=valid_number.mk(min=1024)), + "write_chunk_size": Option(65536, type=valid_number.mk(min=1024)), + "sync_chunk_size": Option(4194304, type=valid_number.mk(min=1024)), "remount_cmd": Option([ "/usr/bin/sudo", "--non-interactive", @@ -163,14 +162,17 @@ def get_plugin_options(cls) -> dict: }, } + # ===== + + async def sysprep(self) -> None: + get_logger(0).info("Using OTG gadget %r as MSD", self.__gadget) + await self.__unsafe_reload_state() + async def get_state(self) -> dict: async with self.__state._lock: # pylint: disable=protected-access storage: (dict | None) = None if self.__state.storage: - if self.__writer: - # При загрузке файла показываем актуальную статистику вручную - await self.__storage.reload_parts_info() - + assert self.__state.vd storage = dataclasses.asdict(self.__state.storage) for name in list(storage["images"]): del storage["images"][name]["name"] @@ -184,34 +186,50 @@ async def get_state(self) -> dict: 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(self.__state.vd) and self.__drive.is_enabled()), + "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_state: dict = {} + prev: dict = {} while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - await self.__notifier.wait() - - async def systask(self) -> None: - await self.__watch_inotify() + 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) @@ -219,11 +237,6 @@ async def reset(self) -> None: except Exception: get_logger(0).exception("Can't reset MSD properly") - @aiotools.atomic_fg - async def cleanup(self) -> None: - await self.__close_reader() - await self.__close_writer() - # ===== @aiotools.atomic_fg @@ -285,11 +298,12 @@ async def set_connected(self, connected: bool) -> None: @contextlib.asynccontextmanager async def read_image(self, name: str) -> AsyncGenerator[MsdFileReader, None]: try: - async with self.__state._region: # pylint: disable=protected-access + 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, @@ -297,7 +311,10 @@ async def read_image(self, name: str) -> AsyncGenerator[MsdFileReader, None]: 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: @@ -305,18 +322,40 @@ async def read_image(self, name: str) -> AsyncGenerator[MsdFileReader, None]: @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: - async with self.__state._region: # pylint: disable=protected-access - image: (Image | None) = None + 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) + 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, @@ -328,22 +367,12 @@ async def write_image(self, name: str, size: int, remove_incomplete: (bool | Non self.__notifier.notify() yield self.__writer - await image.set_complete(self.__writer.is_complete()) + complete = await self.__writer.finish() finally: - try: - if image and remove_incomplete and self.__writer and not self.__writer.is_complete(): - await image.remove(fatal=False) - finally: - try: - await aiotools.shield_fg(self.__close_writer()) - finally: - if image: - await aiotools.shield_fg(image.remount_rw(False, fatal=False)) + await aiotools.shield_fg(finish_writing()) finally: - # Между закрытием файла и эвентом айнотифи состояние может быть не обновлено, - # так что форсим обновление вручную, чтобы получить актуальное состояние. - await aiotools.shield_fg(self.__reload_state()) + self.__notifier.notify() @aiotools.atomic_fg async def remove(self, name: str) -> None: @@ -393,17 +422,28 @@ async def __STORAGE_create_new_image(self, name: str) -> Image: # pylint: disab async def __close_reader(self) -> None: if self.__reader: - await self.__reader.close() - self.__reader = None + try: + await self.__reader.close() + finally: + self.__reader = None async def __close_writer(self) -> None: if self.__writer: - await self.__writer.close() - self.__writer = None + try: + await self.__writer.close() + finally: + self.__writer = None # ===== - async def __watch_inotify(self) -> None: + @aiotools.atomic_fg + async def cleanup(self) -> None: + try: + await self.__close_reader() + finally: + await self.__close_writer() + + async def systask(self) -> None: logger = get_logger(0) while True: try: @@ -415,19 +455,25 @@ async def __watch_inotify(self) -> None: await asyncio.sleep(5) with Inotify() as inotify: - await inotify.watch(InotifyMask.ALL_MODIFY_EVENTS, *self.__storage.get_watchable_paths()) - await inotify.watch(InotifyMask.ALL_MODIFY_EVENTS, *self.__drive.get_watchable_paths()) - - # После установки вотчеров еще раз проверяем стейт, чтобы ничего не потерять + # Из-за гонки между первым релоадом и установкой вотчеров, + # мы можем потерять какие-то каталоги стораджа, но это допустимо, + # так как всегда есть ручной перезапуск. + 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 = False + 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.mask & (InotifyMask.DELETE_SELF | InotifyMask.MOVE_SELF | InotifyMask.UNMOUNT | InotifyMask.ISDIR): - # Если выгрузили OTG, изменили каталоги, что-то отмонтировали или делают еще какую-то странную фигню + if event.restart: + # Если выгрузили OTG, изменили каталоги, что-то отмонтировали или делают еще какую-то странную фигню. + # Проверяется маска InotifyMask.ALL_RESTART_EVENTS logger.info("Got a big inotify event: %s; reinitializing MSD ...", event) need_restart = True break @@ -435,56 +481,71 @@ async def __watch_inotify(self) -> None: 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") - time.sleep(1) + await asyncio.sleep(1) - async def __reload_state(self, notify: bool=True) -> None: - logger = get_logger(0) + async def __reload_state(self) -> None: async with self.__state._lock: # pylint: disable=protected-access - 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.__setup_initial() + await self.__unsafe_reload_state() + self.__notifier.notify() - except Exception: - logger.exception("Error while reloading MSD state; switching to offline") - self.__state.storage = None - self.__state.vd = None + 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: - self.__state.storage = self.__storage - if drive_state.image: - # При подключенном образе виртуальный стейт заменяется реальным + if self.__state.vd is None: + # Если раньше MSD был отключен 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 + 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 - if notify: - self.__notifier.notify() + self.__state.vd.connected = False - async def __setup_initial(self) -> None: + 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) diff --git a/kvmd/plugins/msd/otg/drive.py b/kvmd/plugins/msd/otg/drive.py index 6573276eb..825354a55 100644 --- a/kvmd/plugins/msd/otg/drive.py +++ b/kvmd/plugins/msd/otg/drive.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -82,7 +82,7 @@ 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 err: - if err.errno == errno.EBUSY: + except OSError as ex: + if ex.errno == errno.EBUSY: raise MsdDriveLockedError() raise diff --git a/kvmd/plugins/msd/otg/storage.py b/kvmd/plugins/msd/otg/storage.py index 2efd87c05..14c1e5a91 100644 --- a/kvmd/plugins/msd/otg/storage.py +++ b/kvmd/plugins/msd/otg/storage.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -31,7 +31,6 @@ import aiofiles import aiofiles.os -from .... import aiotools from .... import aiohelpers from .. import MsdError @@ -54,12 +53,12 @@ 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.__complete_path = os.path.join(self.__dir_path, f".__{file_name}.complete") + 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) + self.__adopted = await asyncio.to_thread(self.__is_adopted) complete = await self.__is_complete() removable = await self.__is_removable() (size, mod_ts) = await self.__get_stat() @@ -80,7 +79,7 @@ def __is_adopted(self) -> bool: async def __is_complete(self) -> bool: if self.__storage: - return (await aiofiles.os.path.exists(self.__complete_path)) + return (not (await aiofiles.os.path.exists(self.__incomplete_path))) return True async def __is_removable(self) -> bool: @@ -113,26 +112,31 @@ async def remount_rw(self, rw: bool, fatal: bool=True) -> None: 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 - await self.set_complete(False) + finally: + # Удаляем .incomplete вместе с файлом + if removed: + await self.set_complete(True) async def set_complete(self, flag: bool) -> None: assert self.__storage if flag: - async with aiofiles.open(self.__complete_path, "w"): - pass - else: try: - await aiofiles.os.remove(self.__complete_path) + await aiofiles.os.remove(self.__incomplete_path) except FileNotFoundError: pass + else: + async with aiofiles.open(self.__incomplete_path, "w"): + pass await self._reload() @@ -151,7 +155,7 @@ def __init__(self, name: str, path: str) -> None: self.__path = path async def _reload(self) -> None: # Only for Storage() - st = await aiotools.run_async(os.statvfs, self.__path) + st = await asyncio.to_thread(os.statvfs, self.__path) if self.name == "": writable = True else: @@ -164,8 +168,6 @@ async def _reload(self) -> None: # Only for Storage() # ===== @dataclasses.dataclass(frozen=True, eq=False) class _StorageDc: - size: int = dataclasses.field(init=False) - free: int = dataclasses.field(init=False) images: dict[str, Image] = dataclasses.field(init=False) parts: dict[str, _Part] = dataclasses.field(init=False) @@ -180,25 +182,15 @@ def __init__(self, path: str, remount_cmd: list[str]) -> None: self.__images: (dict[str, Image] | None) = None self.__parts: (dict[str, _Part] | None) = None - @property - def size(self) -> int: # API Legacy - assert self.__parts is not None - return self.__parts[""].size - - @property - def free(self) -> int: # API Legacy - assert self.__parts is not None - return self.__parts[""].free - @property def images(self) -> dict[str, Image]: assert self.__images is not None - return self.__images + return dict(self.__images) @property def parts(self) -> dict[str, _Part]: assert self.__parts is not None - return self.__parts + return dict(self.__parts) async def reload(self) -> None: self.__watchable_paths = None @@ -207,7 +199,7 @@ async def reload(self) -> None: 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)): + for (root_path, is_part, files) in (await asyncio.to_thread(self.__walk)): watchable_paths.append(root_path) for path in files: name = self.__make_relative_name(path) @@ -217,6 +209,7 @@ async def reload(self) -> None: 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 diff --git a/kvmd/plugins/ugpio/__init__.py b/kvmd/plugins/ugpio/__init__.py index 48d203bec..60ba67e4b 100644 --- a/kvmd/plugins/ugpio/__init__.py +++ b/kvmd/plugins/ugpio/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -85,7 +85,7 @@ def register_output(self, pin: str, initial: (bool | None)) -> None: _ = pin _ = initial - def prepare(self) -> None: + async def prepare(self) -> None: pass async def run(self) -> None: diff --git a/kvmd/plugins/ugpio/anelpwr.py b/kvmd/plugins/ugpio/anelpwr.py index c7eaabd58..e13ec9318 100644 --- a/kvmd/plugins/ugpio/anelpwr.py +++ b/kvmd/plugins/ugpio/anelpwr.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,7 +21,6 @@ import asyncio -import functools from typing import Callable from typing import Any @@ -89,7 +88,7 @@ def get_plugin_options(cls) -> dict[str, Option]: @classmethod def get_pin_validator(cls) -> Callable[[Any], Any]: - return functools.partial(valid_number, min=0, max=7, name="ANELPWR channel") + return valid_number.mk(min=0, max=7, name="ANELPWR channel") def register_input(self, pin: str, debounce: float) -> None: _ = debounce @@ -99,27 +98,25 @@ 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 prepare(self) -> None: + await asyncio.gather(*[ + self.write(pin, state) + for (pin, state) in self.__initial.items() + if state is not None + ], return_exceptions=True) 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 response: - htclient.raise_not_200(response) - parts = (await response.text()).split(";") + 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 err: - get_logger().error("Failed ANELPWR bulk GET request: %s", tools.efmt(err)) + 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() @@ -143,26 +140,23 @@ async def write(self, pin: str, state: bool) -> None: url=f"{self.__url}/ctrl.htm", data=f"F{pin}={int(state)}", headers={"Content-Type": "text/plain"}, - ) as response: - htclient.raise_not_200(response) - except Exception as err: - get_logger().error("Failed ANELPWR POST request to pin %s: %s", pin, tools.efmt(err)) + ) 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), - } + kwargs: dict = {} 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) + self.__http_session = aiohttp.ClientSession( + headers={"User-Agent": htclient.make_user_agent("KVMD")}, + connector=aiohttp.TCPConnector(ssl=self.__verify), + timeout=aiohttp.ClientTimeout(total=self.__timeout), + ) return self.__http_session def __str__(self) -> str: diff --git a/kvmd/plugins/ugpio/cmd.py b/kvmd/plugins/ugpio/cmd.py index a5ab39f1f..b8581ce37 100644 --- a/kvmd/plugins/ugpio/cmd.py +++ b/kvmd/plugins/ugpio/cmd.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -78,9 +78,9 @@ async def write(self, pin: str, state: bool) -> None: proc = await aioproc.log_process(self.__cmd, logger=get_logger(0), prefix=str(self)) if proc.returncode != 0: raise RuntimeError(f"Custom command error: retcode={proc.returncode}") - except Exception as err: + except Exception as ex: get_logger(0).error("Can't run custom command [ %s ]: %s", - tools.cmdfmt(self.__cmd), tools.efmt(err)) + tools.cmdfmt(self.__cmd), tools.efmt(ex)) raise GpioDriverOfflineError(self) def __str__(self) -> str: diff --git a/kvmd/plugins/ugpio/cmdret.py b/kvmd/plugins/ugpio/cmdret.py index f5cd62e8c..7080a3903 100644 --- a/kvmd/plugins/ugpio/cmdret.py +++ b/kvmd/plugins/ugpio/cmdret.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -71,9 +71,9 @@ async def read(self, pin: str) -> bool: try: proc = await aioproc.log_process(self.__cmd, logger=get_logger(0), prefix=str(self)) return (proc.returncode == 0) - except Exception as err: + except Exception as ex: get_logger(0).error("Can't run custom command [ %s ]: %s", - tools.cmdfmt(self.__cmd), tools.efmt(err)) + tools.cmdfmt(self.__cmd), tools.efmt(ex)) raise GpioDriverOfflineError(self) async def write(self, pin: str, state: bool) -> None: diff --git a/kvmd/plugins/ugpio/extron.py b/kvmd/plugins/ugpio/extron.py new file mode 100644 index 000000000..3f43152d3 --- /dev/null +++ b/kvmd/plugins/ugpio/extron.py @@ -0,0 +1,180 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import re +import multiprocessing +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 ...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_q: aiomulti.AioMpQueue[int] = aiomulti.AioMpQueue() + self.__channel_q: aiomulti.AioMpQueue[int | None] = aiomulti.AioMpQueue() + self.__channel: (int | None) = -1 + + self.__proc = aiomulti.AioMpProcess(f"gpio-extron-{self._instance_name}", self.__serial_worker) + 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=valid_number.mk(min=1, max=2)), + } + + @classmethod + def get_pin_validator(cls) -> Callable[[Any], Any]: + return valid_number.mk(min=0, max=3, name="Extron USB channel") + + async def prepare(self) -> None: + self.__proc.start() + + async def run(self) -> None: + while True: + (got, channel) = await self.__channel_q.async_fetch_last(1) + if got and self.__channel != channel: + self.__channel = channel + self._notifier.notify() + + async def cleanup(self) -> None: + if self.__proc.is_alive(): + self.__stop_event.set() + await self.__proc.async_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_q.put_nowait(int(pin)) + + # ===== + + def __is_online(self) -> bool: + return ( + self.__proc.is_alive() + and self.__channel is not None + ) + + def __serial_worker(self) -> None: + logger = get_logger(0) + while not self.__stop_event.is_set(): + try: + with self.__get_serial() as tty: + data = b"" + self.__channel_q.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_q.put_nowait(channel) + + (got, channel) = self.__ctl_q.fetch_last(0.1) + if got: + assert channel is not None + self.__send_channel(tty, channel) + + except Exception as ex: + self.__channel_q.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__ diff --git a/kvmd/plugins/ugpio/ezcoo.py b/kvmd/plugins/ugpio/ezcoo.py index 9f3127430..58d104f52 100644 --- a/kvmd/plugins/ugpio/ezcoo.py +++ b/kvmd/plugins/ugpio/ezcoo.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,7 +22,6 @@ import re import multiprocessing -import functools import errno import time @@ -35,7 +34,6 @@ from ... import aiotools from ... import aiomulti -from ... import aioproc from ...yamlconf import Option @@ -68,11 +66,11 @@ def __init__( 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.__ctl_q: aiomulti.AioMpQueue[int] = aiomulti.AioMpQueue() + self.__ch_q: aiomulti.AioMpQueue[int | None] = aiomulti.AioMpQueue() + self.__ch: (int | None) = -1 - self.__proc: (multiprocessing.Process | None) = None + self.__proc = aiomulti.AioMpProcess(f"gpio-ezcoo-{self._instance_name}", self.__serial_worker) self.__stop_event = multiprocessing.Event() @classmethod @@ -81,78 +79,75 @@ def get_plugin_options(cls) -> dict: "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)), + "protocol": Option(1, type=valid_number.mk(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") + return valid_number.mk(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) + async def prepare(self) -> None: 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 + (got, ch) = await self.__ch_q.async_fetch_last(1) + if got and self.__ch != ch: + self.__ch = ch 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() + if self.__proc.is_alive(): + self.__stop_event.set() + await self.__proc.async_join() async def read(self, pin: str) -> bool: if not self.__is_online(): raise GpioDriverOfflineError(self) - return (self.__channel == int(pin)) + return (self.__ch == 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)) + self.__ctl_q.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 + self.__proc.is_alive() + and self.__ch is not None ) def __serial_worker(self) -> None: - logger = aioproc.settle(str(self), f"gpio-ezcoo-{self._instance_name}") + logger = get_logger(0) while not self.__stop_event.is_set(): try: with self.__get_serial() as tty: data = b"" - self.__channel_queue.put_nowait(-1) + self.__ch_q.put_nowait(-1) - # Switch and then recieve the state. - # FIXME: Get actual state without modifying the current. - self.__send_channel(tty, 0) + # Get actual state without modifying the current + if self.__protocol <= 1: + tty.write(b"GET OUT1 VS\n" * 2) # Twice because of some bugs + else: + tty.write(b"EZG OUT1 VS\n" * 2) + tty.flush() 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) + (ch, data) = self.__recv_channel(tty, data) + if ch is not None: + self.__ch_q.put_nowait(ch) - (got, channel) = aiomulti.queue_get_last_sync(self.__ctl_queue, 0.1) # type: ignore + (got, ch) = self.__ctl_q.fetch_last(0.1) if got: - assert channel is not None - self.__send_channel(tty, channel) + assert ch is not None + self.__send_channel(tty, ch) - except Exception as err: - self.__channel_queue.put_nowait(None) - if isinstance(err, serial.SerialException) and err.errno == errno.ENOENT: # pylint: disable=no-member + except Exception as ex: + self.__ch_q.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) @@ -162,25 +157,30 @@ 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 + ch: (int | None) = None if tty.in_waiting: data += tty.read_all() - found = re.findall(b"V[0-9a-fA-F]{2}S", data) + found = list(re.finditer(b"(OUT1 VS \\d+)|(V[0-9a-fA-F]{2}S)", data)) if found: - channel = { - b"V0CS": 0, + last = found[-1] + ch = { + b"V0CS": 0, # Switching retval (manual or via the TTY) 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 + b"OUT1 VS 1": 0, # "EZG OUT1 VS" return value + b"OUT1 VS 2": 1, + b"OUT1 VS 3": 2, + b"OUT1 VS 4": 3, + }.get(last[0], -1) + data = data[last.end(0):] + return (ch, data) + + def __send_channel(self, tty: serial.Serial, ch: int) -> None: + assert 0 <= ch <= 3 cmd = b"%s OUT1 VS IN%d\n" % ( (b"SET" if self.__protocol == 1 else b"EZS"), - channel + 1, + ch + 1, ) tty.write(cmd * 2) # Twice because of ezcoo bugs tty.flush() diff --git a/kvmd/plugins/ugpio/gpio.py b/kvmd/plugins/ugpio/gpio.py index 39c379f31..fa8d8dd1c 100644 --- a/kvmd/plugins/ugpio/gpio.py +++ b/kvmd/plugins/ugpio/gpio.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -54,9 +54,7 @@ def __init__( self.__output_pins: dict[int, (bool | None)] = {} self.__reader: (aiogp.AioReader | None) = None - - self.__chip: (gpiod.Chip | None) = None - self.__output_lines: dict[int, gpiod.Line] = {} + self.__outputs_req: (gpiod.LineRequest | None) = None @classmethod def get_plugin_options(cls) -> dict: @@ -74,29 +72,36 @@ def register_input(self, pin: str, debounce: float) -> None: def register_output(self, pin: str, initial: (bool | None)) -> None: self.__output_pins[int(pin)] = initial - def prepare(self) -> None: + async def prepare(self) -> None: assert self.__reader is None + assert self.__outputs_req is None self.__reader = aiogp.AioReader( path=self.__device_path, consumer="kvmd::gpio::inputs", pins=self.__input_pins, notifier=self._notifier, ) - - self.__chip = gpiod.Chip(self.__device_path) - for (pin, initial) in self.__output_pins.items(): - line = self.__chip.get_line(pin) - line.request("kvmd::gpio::outputs", gpiod.LINE_REQ_DIR_OUT, default_vals=[int(initial or False)]) - self.__output_lines[pin] = line + if self.__output_pins: + self.__outputs_req = gpiod.request_lines( + self.__device_path, + consumer="kvmd::gpiod::outputs", + config={ + pin: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT, + output_value=gpiod.line.Value(initial or False), + ) + for (pin, initial) in self.__output_pins.items() + }, + ) async def run(self) -> None: assert self.__reader await self.__reader.poll() async def cleanup(self) -> None: - if self.__chip: + if self.__outputs_req: try: - self.__chip.close() + self.__outputs_req.release() except Exception: pass @@ -105,10 +110,15 @@ async def read(self, pin: str) -> bool: pin_int = int(pin) if pin_int in self.__input_pins: return self.__reader.get(pin_int) - return bool(self.__output_lines[pin_int].get_value()) + assert self.__outputs_req + assert pin_int in self.__output_pins + return bool(self.__outputs_req.get_value(pin_int).value) async def write(self, pin: str, state: bool) -> None: - self.__output_lines[int(pin)].set_value(int(state)) + assert self.__outputs_req + pin_int = int(pin) + assert pin_int in self.__output_pins + self.__outputs_req.set_value(pin_int, gpiod.line.Value(state)) def __str__(self) -> str: return f"GPIO({self._instance_name})" diff --git a/kvmd/plugins/ugpio/hidrelay.py b/kvmd/plugins/ugpio/hidrelay.py index 7b8c083e7..483eac10d 100644 --- a/kvmd/plugins/ugpio/hidrelay.py +++ b/kvmd/plugins/ugpio/hidrelay.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,7 +22,6 @@ import asyncio import contextlib -import functools from typing import Callable from typing import Any @@ -82,20 +81,20 @@ def get_modes(cls) -> set[str]: @classmethod def get_pin_validator(cls) -> Callable[[Any], Any]: - return functools.partial(valid_number, min=0, max=7, name="HID relay channel") + return valid_number.mk(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: + async 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 err: + except Exception as ex: logger.error("Can't probe %s on %s: %s", - self, self.__device_path, tools.efmt(err)) + self, self.__device_path, tools.efmt(ex)) self.__reset_pins() async def run(self) -> None: @@ -137,9 +136,9 @@ def __reset_pins(self) -> None: pin, state, self, self.__device_path) try: self.__inner_write(pin, state) - except Exception as err: + except Exception as ex: logger.error("Can't reset pin=%d of %s on %s: %s", - pin, self, self.__device_path, tools.efmt(err)) + pin, self, self.__device_path, tools.efmt(ex)) def __inner_read(self, pin: int) -> bool: assert 0 <= pin <= 7 @@ -168,9 +167,9 @@ def __ensure_device(self, context: str) -> hid.device: # type: ignore get_logger(0).info("Opened %s on %s while %s", self, self.__device_path, context) try: yield self.__device - except Exception as err: + except Exception as ex: get_logger(0).error("Error occured on %s on %s while %s: %s", - self, self.__device_path, context, tools.efmt(err)) + self, self.__device_path, context, tools.efmt(ex)) self.__close_device() raise diff --git a/kvmd/plugins/ugpio/hue.py b/kvmd/plugins/ugpio/hue.py index 91f4ac52b..0050eac5b 100644 --- a/kvmd/plugins/ugpio/hue.py +++ b/kvmd/plugins/ugpio/hue.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -97,27 +97,25 @@ 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 prepare(self) -> None: + await asyncio.gather(*[ + self.write(pin, state) + for (pin, state) in self.__initial.items() + if state is not None + ], return_exceptions=True) 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 response: - results = await response.json() + 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 err: - get_logger().error("Failed Hue bulk GET request: %s", tools.efmt(err)) + 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() @@ -140,24 +138,20 @@ async def write(self, pin: str, state: bool) -> None: async with session.put( url=f"{self.__url}/api/{self.__token}/lights/{pin}/state", json={"on": state}, - ) as response: - htclient.raise_not_200(response) - except Exception as err: - get_logger().error("Failed Hue PUT request to pin %s: %s", pin, tools.efmt(err)) + ) 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) + self.__http_session = aiohttp.ClientSession( + headers={"User-Agent": htclient.make_user_agent("KVMD")}, + connector=aiohttp.TCPConnector(ssl=self.__verify), + timeout=aiohttp.ClientTimeout(total=self.__timeout), + ) return self.__http_session def __str__(self) -> str: diff --git a/kvmd/plugins/ugpio/ipmi.py b/kvmd/plugins/ugpio/ipmi.py index 32d97cfae..3e5ec8302 100644 --- a/kvmd/plugins/ugpio/ipmi.py +++ b/kvmd/plugins/ugpio/ipmi.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -123,7 +123,7 @@ def register_output(self, pin: str, initial: (bool | None)) -> None: if pin not in [*_OUTPUTS, *_OUTPUTS.values()]: raise RuntimeError(f"Unsupported mode 'output' for pin={pin} on {self}") - def prepare(self) -> None: + async def prepare(self) -> None: get_logger(0).info("Probing driver %s on %s:%d ...", self, self.__host, self.__port) async def run(self) -> None: @@ -153,9 +153,9 @@ async def write(self, pin: str, state: bool) -> None: proc = await aioproc.log_process(**self.__make_ipmitool_kwargs(action), logger=get_logger(0), prefix=str(self)) if proc.returncode != 0: raise RuntimeError(f"Ipmitool error: retcode={proc.returncode}") - except Exception as err: + except Exception as ex: get_logger(0).error("Can't send IPMI power-%s request to %s:%d: %s", - action, self.__host, self.__port, tools.efmt(err)) + action, self.__host, self.__port, tools.efmt(ex)) raise GpioDriverOfflineError(self) # ===== @@ -171,9 +171,9 @@ async def __update_power(self) -> None: self.__online = True return raise RuntimeError(f"Invalid ipmitool response: {text}") - except Exception as err: + except Exception as ex: get_logger(0).error("Can't fetch IPMI power status from %s:%d: %s", - self.__host, self.__port, tools.efmt(err)) + self.__host, self.__port, tools.efmt(ex)) self.__power = False self.__online = False diff --git a/kvmd/plugins/ugpio/locator.py b/kvmd/plugins/ugpio/locator.py index b30c0530d..6aba9f583 100644 --- a/kvmd/plugins/ugpio/locator.py +++ b/kvmd/plugins/ugpio/locator.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -53,9 +53,7 @@ def __init__( self.__device_path = device_path self.__tasks: dict[int, (asyncio.Task | None)] = {} - - self.__chip: (gpiod.Chip | None) = None - self.__lines: dict[int, gpiod.Line] = {} + self.__line_req: (gpiod.LineRequest | None) = None @classmethod def get_plugin_options(cls) -> dict: @@ -75,12 +73,17 @@ def register_output(self, pin: str, initial: (bool | None)) -> None: _ = initial self.__tasks[int(pin)] = None - def prepare(self) -> None: - self.__chip = gpiod.Chip(self.__device_path) - for pin in self.__tasks: - line = self.__chip.get_line(pin) - line.request("kvmd::locator::outputs", gpiod.LINE_REQ_DIR_OUT, default_vals=[0]) - self.__lines[pin] = line + async 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 = [ @@ -91,9 +94,9 @@ async def cleanup(self) -> None: for task in tasks: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) - if self.__chip: + if self.__line_req: try: - self.__chip.close() + self.__line_req.release() except Exception: pass @@ -111,17 +114,18 @@ async def write(self, pin: str, state: bool) -> None: self.__tasks[pin_int] = None async def __blink(self, pin: int) -> None: - line = self.__lines[pin] + assert pin in self.__tasks + assert self.__line_req try: - state = 1 + state = True while True: - line.set_value(state) - state = int(not state) + self.__line_req.set_value(pin, gpiod.line.Value(state)) + state = (not state) await asyncio.sleep(0.1) except asyncio.CancelledError: pass finally: - line.set_value(0) + self.__line_req.set_value(pin, gpiod.line.Value(False)) def __str__(self) -> str: return f"Locator({self._instance_name})" diff --git a/kvmd/plugins/ugpio/noop.py b/kvmd/plugins/ugpio/noop.py new file mode 100644 index 000000000..23116d311 --- /dev/null +++ b/kvmd/plugins/ugpio/noop.py @@ -0,0 +1,46 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from typing import Callable +from typing import Any + +from . import BaseUserGpioDriver + + +# ===== +class Plugin(BaseUserGpioDriver): + @classmethod + def get_pin_validator(cls) -> Callable[[Any], Any]: + return str + + async def read(self, pin: str) -> bool: + _ = pin + return False + + async def write(self, pin: str, state: bool) -> None: + _ = pin + _ = state + + def __str__(self) -> str: + return f"NOOP({self._instance_name})" + + __repr__ = __str__ diff --git a/kvmd/plugins/ugpio/noyito.py b/kvmd/plugins/ugpio/noyito.py new file mode 100644 index 000000000..074ac4ae0 --- /dev/null +++ b/kvmd/plugins/ugpio/noyito.py @@ -0,0 +1,164 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import contextlib + +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 valid_number.mk(min=0, max=7, name="NOYITO relay channel") + + def register_output(self, pin: str, initial: (bool | None)) -> None: + self.__initials[int(pin)] = bool(initial) + + async 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__ diff --git a/kvmd/plugins/ugpio/otgconf.py b/kvmd/plugins/ugpio/otgconf.py index e1eb7cc2b..39abef0e9 100644 --- a/kvmd/plugins/ugpio/otgconf.py +++ b/kvmd/plugins/ugpio/otgconf.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -28,7 +28,6 @@ from ...logging import get_logger -from ...inotify import InotifyMask from ...inotify import Inotify from ... import aiotools @@ -67,7 +66,7 @@ def __init__( def get_pin_validator(cls) -> Callable[[Any], Any]: return valid_stripped_string_not_empty - def prepare(self) -> None: + async def prepare(self) -> None: self.__udc = usb.find_udc(self.__udc) get_logger().info("Using UDC %s", self.__udc) @@ -82,15 +81,15 @@ async def run(self) -> None: await asyncio.sleep(5) with Inotify() as inotify: - await inotify.watch(InotifyMask.ALL_MODIFY_EVENTS, os.path.dirname(self.__udc_path)) - await inotify.watch(InotifyMask.ALL_MODIFY_EVENTS, self.__profile_path) + 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.mask & (InotifyMask.DELETE_SELF | InotifyMask.MOVE_SELF | InotifyMask.UNMOUNT): + if event.restart: logger.warning("Got fatal inotify event: %s; reinitializing OTG-bind ...", event) need_restart = True break @@ -105,29 +104,53 @@ async def run(self) -> None: async def read(self, pin: str) -> bool: if pin == "udc": return self.__is_udc_enabled() - return os.path.exists(os.path.join(self.__profile_path, pin)) + return os.path.exists(self.__get_fdest_path(pin)) async def write(self, pin: str, state: bool) -> None: async with self.__lock: + if (await 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( - os.path.join(self.__functions_path, pin), - os.path.join(self.__profile_path, pin), - ) + os.symlink(self.__get_fsrc_path(pin), self.__get_fdest_path(pin)) else: - os.unlink(os.path.join(self.__profile_path, pin)) + 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") diff --git a/kvmd/plugins/ugpio/pway.py b/kvmd/plugins/ugpio/pway.py index 26e30921a..1945206db 100644 --- a/kvmd/plugins/ugpio/pway.py +++ b/kvmd/plugins/ugpio/pway.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # Modified by SppokHCK September 2021 # # # @@ -24,7 +24,6 @@ import re import multiprocessing -import functools import errno import time @@ -37,7 +36,6 @@ from ... import aiotools from ... import aiomulti -from ... import aioproc from ...yamlconf import Option @@ -70,11 +68,11 @@ def __init__( 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.__ctl_q: aiomulti.AioMpQueue[int] = aiomulti.AioMpQueue() + self.__channel_q: aiomulti.AioMpQueue[int | None] = aiomulti.AioMpQueue() self.__channel: (int | None) = -1 - self.__proc: (multiprocessing.Process | None) = None + self.__proc = aiomulti.AioMpProcess(f"gpio-pway-{self._instance_name}", self.__serial_worker) self.__stop_event = multiprocessing.Event() @classmethod @@ -83,32 +81,27 @@ def get_plugin_options(cls) -> dict: "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)), + "protocol": Option(1, type=valid_number.mk(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") + return valid_number.mk(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) + async def prepare(self) -> None: self.__proc.start() async def run(self) -> None: while True: - (got, channel) = await aiomulti.queue_get_last(self.__channel_queue, 1) + (got, channel) = await self.__channel_q.async_fetch_last(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() + if self.__proc.is_alive(): + self.__stop_event.set() + await self.__proc.async_join() async def read(self, pin: str) -> bool: if not self.__is_online(): @@ -119,24 +112,23 @@ 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)) + self.__ctl_q.put_nowait(int(pin)) # ===== def __is_online(self) -> bool: return ( - self.__proc is not None - and self.__proc.is_alive() + 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}") + logger = get_logger(0) while not self.__stop_event.is_set(): try: with self.__get_serial() as tty: data = b"" - self.__channel_queue.put_nowait(-1) + self.__channel_q.put_nowait(-1) # Switch and then recieve the state. # FIXME: Get actual state without modifying the current. @@ -146,16 +138,16 @@ def __serial_worker(self) -> None: 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) + self.__channel_q.put_nowait(channel) - (got, channel) = aiomulti.queue_get_last_sync(self.__ctl_queue, 0.1) # type: ignore + (got, channel) = self.__ctl_q.fetch_last(0.1) if got: assert channel is not None self.__send_channel(tty, channel) - except Exception as err: - self.__channel_queue.put_nowait(None) - if isinstance(err, serial.SerialException) and err.errno == errno.ENOENT: # pylint: disable=no-member + except Exception as ex: + self.__channel_q.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) diff --git a/kvmd/plugins/ugpio/pwm.py b/kvmd/plugins/ugpio/pwm.py index f8df43387..4ad877aa1 100644 --- a/kvmd/plugins/ugpio/pwm.py +++ b/kvmd/plugins/ugpio/pwm.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # Shantur Rathore # # # # This program is free software: you can redistribute it and/or modify # @@ -24,8 +24,6 @@ from typing import Callable from typing import Any -from periphery import PWM - from ...logging import get_logger from ... import tools @@ -36,6 +34,8 @@ from ...validators.basic import valid_int_f0 from ...validators.hw import valid_gpio_pin +from ...pwm import Pwm + from . import GpioDriverOfflineError from . import UserGpioModes from . import BaseUserGpioDriver @@ -62,7 +62,7 @@ def __init__( # pylint: disable=super-init-not-called self.__duty_cycle_release = duty_cycle_release self.__channels: dict[int, (bool | None)] = {} - self.__pwms: dict[int, PWM] = {} + self.__pwms: dict[int, Pwm] = {} @classmethod def get_plugin_options(cls) -> dict: @@ -84,38 +84,40 @@ def get_pin_validator(cls) -> Callable[[Any], Any]: def register_output(self, pin: str, initial: (bool | None)) -> None: self.__channels[int(pin)] = initial - def prepare(self) -> None: + async 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) + 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 err: + pwm.set_period_ns(self.__period) + pwm.set_duty_cycle_ns(self.__get_duty_cycle(bool(initial))) + pwm.set_enabled(True) + except Exception as ex: logger.error("Can't get PWM chip %d channel %d: %s", - self.__chip, pin, tools.efmt(err)) + 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 err: + try: + pwm.set_enabled(False) + finally: + pwm.close() + except Exception as ex: get_logger(0).error("Can't cleanup PWM chip %d channel %d: %s", - self.__chip, pin, tools.efmt(err)) + 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) + return (self.__pwms[int(pin)].get_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) + self.__pwms[int(pin)].set_duty_cycle_ns(self.__get_duty_cycle(state)) except Exception: raise GpioDriverOfflineError(self) @@ -123,6 +125,6 @@ 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})" + return f"Pwm({self._instance_name})" __repr__ = __str__ diff --git a/kvmd/plugins/ugpio/servo.py b/kvmd/plugins/ugpio/servo.py index bd83c6d8f..12d9abc23 100644 --- a/kvmd/plugins/ugpio/servo.py +++ b/kvmd/plugins/ugpio/servo.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # Shantur Rathore # # # # This program is free software: you can redistribute it and/or modify # @@ -68,7 +68,7 @@ def __init__( # pylint: disable=super-init-not-called,too-many-arguments @classmethod def get_plugin_options(cls) -> dict: - valid_angle = (lambda arg: valid_number(arg, min=-360.0, max=360.0, type=float)) + valid_angle = valid_number.mk(min=-360.0, max=360.0, type=float) return { "chip": Option(0, type=valid_int_f0), "period": Option(20000000, type=valid_int_f0), diff --git a/kvmd/plugins/ugpio/tesmart.py b/kvmd/plugins/ugpio/tesmart.py index 7eef30de3..bfd1e0def 100644 --- a/kvmd/plugins/ugpio/tesmart.py +++ b/kvmd/plugins/ugpio/tesmart.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,7 +21,6 @@ import asyncio -import functools from typing import Callable from typing import Any @@ -88,7 +87,7 @@ def get_plugin_options(cls) -> dict: "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"), + "device": Option("", type=valid_abs_path, if_empty="", unpack_as="device_path"), "speed": Option(9600, type=valid_tty_speed), "timeout": Option(5.0, type=valid_float_f01), @@ -98,7 +97,7 @@ def get_plugin_options(cls) -> dict: @classmethod def get_pin_validator(cls) -> Callable[[Any], Any]: - return functools.partial(valid_number, min=0, max=15, name="TESmart channel") + return valid_number.mk(min=0, max=15, name="TESmart channel") async def run(self) -> None: prev_active = -2 @@ -115,6 +114,7 @@ async def run(self) -> None: async def cleanup(self) -> None: await self.__close_device() + self.__active = -1 async def read(self, pin: str) -> bool: return (self.__active == int(pin)) @@ -125,8 +125,8 @@ async def write(self, pin: str, state: bool) -> None: assert 1 <= channel <= 16 if state: await self.__send_command("{:c}{:c}".format(1, channel).encode()) - self.__update_notifier.notify() await asyncio.sleep(self.__switch_delay) # Slowdown + self.__update_notifier.notify() # ===== @@ -145,18 +145,25 @@ async def __send_command(self, cmd: bytes) -> int: asyncio.ensure_future(self.__reader.readexactly(6)), timeout=self.__timeout, ))[4] - except Exception as err: + except Exception as ex: get_logger(0).error("Can't send command to TESmart KVM [%s]:%d: %s", - self.__host, self.__port, tools.efmt(err)) + 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: + elif self.__device_path: await self.__ensure_device_serial() + else: + get_logger(0).error("Neither the host nor the serial device is selected" + " in the configuration for TESmart driver") + raise GpioDriverOfflineError(self) async def __ensure_device_net(self) -> None: try: @@ -164,9 +171,9 @@ async def __ensure_device_net(self) -> None: asyncio.ensure_future(asyncio.open_connection(self.__host, self.__port)), timeout=self.__timeout, ) - except Exception as err: + except Exception as ex: get_logger(0).error("Can't connect to TESmart KVM [%s]:%d: %s", - self.__host, self.__port, tools.efmt(err)) + self.__host, self.__port, tools.efmt(ex)) raise GpioDriverOfflineError(self) async def __ensure_device_serial(self) -> None: @@ -175,9 +182,9 @@ async def __ensure_device_serial(self) -> None: serial_asyncio.open_serial_connection(url=self.__device_path, baudrate=self.__speed), timeout=self.__timeout, ) - except Exception as err: + except Exception as ex: get_logger(0).error("Can't connect to TESmart KVM [%s]:%d: %s", - self.__device_path, self.__speed, tools.efmt(err)) + self.__device_path, self.__speed, tools.efmt(ex)) raise GpioDriverOfflineError(self) async def __close_device(self) -> None: @@ -185,7 +192,6 @@ async def __close_device(self) -> None: await aiotools.close_writer(self.__writer) self.__reader = None self.__writer = None - self.__active = -1 # ===== diff --git a/kvmd/plugins/ugpio/wol.py b/kvmd/plugins/ugpio/wol.py index 78670e8a2..541e8a67f 100644 --- a/kvmd/plugins/ugpio/wol.py +++ b/kvmd/plugins/ugpio/wol.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,7 +21,6 @@ import socket -import functools from typing import Callable from typing import Any @@ -61,7 +60,7 @@ def __init__( # pylint: disable=super-init-not-called @classmethod def get_plugin_options(cls) -> dict: return { - "ip": Option("255.255.255.255", type=functools.partial(valid_ip, v6=False)), + "ip": Option("255.255.255.255", type=valid_ip.mk(v6=False)), "port": Option(9, type=valid_port), "mac": Option("", type=valid_mac, if_empty=""), } diff --git a/kvmd/plugins/ugpio/xh_hk4401.py b/kvmd/plugins/ugpio/xh_hk4401.py index 995708dfd..d2a42fd25 100644 --- a/kvmd/plugins/ugpio/xh_hk4401.py +++ b/kvmd/plugins/ugpio/xh_hk4401.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # 2021-2021 Sebastian Goscik # # # # This program is free software: you can redistribute it and/or modify # @@ -23,7 +23,6 @@ import re import multiprocessing -import functools import errno import time @@ -36,7 +35,6 @@ from ... import aiotools from ... import aiomulti -from ... import aioproc from ...yamlconf import Option @@ -59,6 +57,7 @@ def __init__( device_path: str, speed: int, read_timeout: float, + protocol: int, ) -> None: super().__init__(instance_name, notifier) @@ -66,12 +65,13 @@ def __init__( 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.__ctl_q: aiomulti.AioMpQueue[int] = aiomulti.AioMpQueue() + self.__channel_q: aiomulti.AioMpQueue[int | None] = aiomulti.AioMpQueue() self.__channel: (int | None) = -1 - self.__proc: (multiprocessing.Process | None) = None + self.__proc = aiomulti.AioMpProcess(f"gpio-xh-hk4401-{self._instance_name}", self.__serial_worker) self.__stop_event = multiprocessing.Event() @classmethod @@ -80,31 +80,27 @@ def get_plugin_options(cls) -> dict: "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=valid_number.mk(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") + return valid_number.mk(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) + async def prepare(self) -> None: self.__proc.start() async def run(self) -> None: while True: - (got, channel) = await aiomulti.queue_get_last(self.__channel_queue, 1) + (got, channel) = await self.__channel_q.async_fetch_last(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() + if self.__proc.is_alive(): + self.__stop_event.set() + await self.__proc.async_join() async def read(self, pin: str) -> bool: if not self.__is_online(): @@ -115,45 +111,47 @@ 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)) + self.__ctl_q.put_nowait(int(pin)) # ===== def __is_online(self) -> bool: return ( - self.__proc is not None - and self.__proc.is_alive() + 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}") + logger = get_logger(0) while not self.__stop_event.is_set(): try: with self.__get_serial() as tty: data = b"" - self.__channel_queue.put_nowait(-1) + self.__channel_q.put_nowait(-1) - # Wait for first port heartbeat to set correct channel (~2 sec max) - while True: + # 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) + self.__channel_q.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) + self.__channel_q.put_nowait(channel) - (got, channel) = aiomulti.queue_get_last_sync(self.__ctl_queue, 0.1) # type: ignore + (got, channel) = self.__ctl_q.fetch_last(0.1) if got: assert channel is not None self.__send_channel(tty, channel) + if self.__protocol == 2: + self.__channel_q.put_nowait(channel) - except Exception as err: - self.__channel_queue.put_nowait(None) - if isinstance(err, serial.SerialException) and err.errno == errno.ENOENT: # pylint: disable=no-member + except Exception as ex: + self.__channel_q.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) @@ -166,10 +164,10 @@ def __recv_channel(self, tty: serial.Serial, data: bytes) -> tuple[(int | None), channel: (int | None) = None if tty.in_waiting: data += tty.read_all() - found = re.findall(b"AG0[1-4]gA", data) + 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]) - 1 + channel = int(found[-1][2:4] if self.__protocol == 1 else found[-1][1:3]) - 1 except Exception: channel = None data = data[-12:] @@ -177,8 +175,8 @@ def __recv_channel(self, tty: serial.Serial, data: bytes) -> tuple[(int | None), def __send_channel(self, tty: serial.Serial, channel: int) -> None: assert 0 <= channel <= 3 - cmd = "SW{port}\r\nAG{port:02d}gA".format(port=(channel + 1)).encode() - tty.write(cmd) + 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: diff --git a/kvmd/pwm.py b/kvmd/pwm.py new file mode 100644 index 000000000..b0c1fbc8c --- /dev/null +++ b/kvmd/pwm.py @@ -0,0 +1,176 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import os +import errno +import time + +from . import env + + +# ===== +class PwmError(IOError): + pass + + +# ===== +class Pwm: + # Based on https://github.com/vsergeev/python-periphery + # Copyright (c) 2015-2023 vsergeev / Ivan (Vanya) A. Sergeev + + __STAT_RETRIES = 10 # Number of retries to check for successful PWM export on open + __STAT_DELAY = 0.1 # Delay between check for scucessful PWM export on open (100ms) + + def __init__(self, chip: int, ch: int) -> None: + """ + Instantiate a PWM object and open the sysfs PWM corresponding to the + specified chip and channel. + + Args: + chip (int): PWM chip number. + ch (int): PWM channel number. + + Returns: + PWM: PWM object. + + Raises: + PwmError: if an I/O or OS error occurs. + TypeError: if `chip` or `ch` types are invalid. + LookupError: if PWM chip does not exist. + TimeoutError: if waiting for PWM export times out. + """ + + self.__chip = -1 + self.__ch = -1 + self.__open(chip, ch) + + def __del__(self) -> None: + self.close() + + def __open(self, chip: int, ch: int) -> None: + chip_path = f"{env.SYSFS_PREFIX}/sys/class/pwm/pwmchip{chip}" + ch_path = f"{env.SYSFS_PREFIX}/sys/class/pwm/pwmchip{chip}/pwm{ch}" + + if not os.path.isdir(chip_path): + raise LookupError(f"Opening PWM: PWM chip {chip} is not found") + + if not os.path.isdir(ch_path): + # Export the PWM + try: + with open(os.path.join(chip_path, "export"), "w") as file: + file.write(f"{ch}\n") + except IOError as ex: + raise PwmError(ex.errno, f"Exporting PWM channel: {ex.strerror}") + + # Loop until PWM is exported + exported = False + for _ in range(self.__STAT_RETRIES): + if os.path.isdir(ch_path): + exported = True + break + time.sleep(self.__STAT_DELAY) + + if not exported: + raise TimeoutError(f"Exporting PWM: waiting for {ch_path!r} timed out") + + # Loop until period is writable. This could take some time after + # export as application of udev rules after export is asynchronous. + for retry in range(self.__STAT_RETRIES): + try: + with open(os.path.join(ch_path, "period"), "w"): + break + except IOError as ex: + if ex.errno != errno.EACCES or (ex.errno == errno.EACCES and retry == self.__STAT_RETRIES - 1): + raise PwmError(ex.errno, f"Opening PWM period: {ex.strerror}") + time.sleep(self.__STAT_DELAY) + + self.__chip = chip + self.__ch = ch + + def close(self) -> None: + if self.__chip >= 0 and self.__ch >= 0: + # Unexport the PWM channel + try: + with open(f"{env.SYSFS_PREFIX}/sys/class/pwm/pwmchip{self.__chip}/unexport", "w") as file: + file.write(f"{self.__ch}\n") + except OSError as ex: + raise PwmError(ex.errno, f"Unexporting PWM: {ex.strerror}") + self.__chip = -1 + self.__ch = -1 + + def __write_channel_attr(self, attr: str, value: str) -> None: + path = f"{env.SYSFS_PREFIX}/sys/class/pwm/pwmchip{self.__chip}/pwm{self.__ch}/{attr}" + with open(path, "w") as file: + file.write(value + "\n") + + def __read_channel_attr(self, attr: str) -> str: + path = f"{env.SYSFS_PREFIX}/sys/class/pwm/pwmchip{self.__chip}/pwm{self.__ch}/{attr}" + with open(path, "r") as file: + return file.read().strip() + + # ===== + + def get_period_ns(self) -> int: + period_ns_str = self.__read_channel_attr("period") + try: + period_ns = int(period_ns_str) + except ValueError: + raise PwmError(None, f"Unknown period value: {period_ns_str!r}") + return period_ns + + def set_period_ns(self, period_ns: int) -> None: + self.__write_channel_attr("period", str(period_ns)) + + # ===== + + def get_duty_cycle_ns(self) -> int: + duty_cycle_ns_str = self.__read_channel_attr("duty_cycle") + try: + return int(duty_cycle_ns_str) + except ValueError: + raise PwmError(None, f"Unknown duty cycle value: {duty_cycle_ns_str!r}") + + def set_duty_cycle_ns(self, duty_cycle_ns: int) -> None: + self.__write_channel_attr("duty_cycle", str(duty_cycle_ns)) + + # ===== + + def get_polarity(self) -> str: + return self.__read_channel_attr("polarity") + + def set_polarity(self, polarity: str) -> None: + polarity = polarity.lower() + if polarity not in ["normal", "inversed"]: + raise ValueError("Invalid polarity, can be: 'normal' or 'inversed'") + self.__write_channel_attr("polarity", polarity) + + # ===== + + def is_enabled(self) -> bool: + enabled = self.__read_channel_attr("enable") + try: + return bool(int(enabled)) + except ValueError: + raise PwmError(None, f"Unknown enabled value: {enabled!r}") + + def set_enabled(self, value: bool) -> None: + self.__write_channel_attr("enable", str(int(value))) diff --git a/kvmd/tools.py b/kvmd/tools.py index 1c03f8ce3..90f1e2091 100644 --- a/kvmd/tools.py +++ b/kvmd/tools.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,19 +20,21 @@ # ========================================================================== # +import os +import tempfile import operator -import functools -import multiprocessing.queues -import queue +import contextlib import shlex -from typing import Hashable +from typing import Generator from typing import TypeVar +from typing import Any # ===== def remap(value: int, in_min: int, in_max: int, out_min: int, out_max: int) -> int: - return int((value - in_min) * (out_max - out_min) // (in_max - in_min) + out_min) + result = int((value - in_min) * (out_max - out_min) // ((in_max - in_min) or 1) + out_min) + return min(max(result, out_min), out_max) # ===== @@ -40,18 +42,11 @@ def cmdfmt(cmd: list[str]) -> str: return " ".join(map(shlex.quote, cmd)) -def efmt(err: Exception) -> str: - return f"{type(err).__name__}: {err}" +def efmt(ex: Exception) -> str: + return f"{type(ex).__name__}: {ex}" # ===== -def rget(dct: dict, *keys: Hashable) -> dict: - result = functools.reduce((lambda nxt, key: nxt.get(key, {})), keys, dct) - if not isinstance(result, dict): - raise TypeError(f"Not a dict as result: {result!r} from {dct!r} at {list(keys)}") - return result - - _DictKeyT = TypeVar("_DictKeyT") _DictValueT = TypeVar("_DictValueT") @@ -64,13 +59,30 @@ def swapped_kvs(dct: dict[_DictKeyT, _DictValueT]) -> dict[_DictValueT, _DictKey return {value: key for (key, value) in dct.items()} -# ===== -def clear_queue(q: multiprocessing.queues.Queue) -> None: # pylint: disable=invalid-name - for _ in range(q.qsize()): - try: - q.get_nowait() - except queue.Empty: - break +def walk_dict(kvs: Any, *path: str) -> dict: + if not isinstance(kvs, dict): + raise TypeError("Not a dict on the top level") + passed: list[str] = [] + for key in path: + if key not in kvs: + return {} + kvs = kvs[key] + passed.append(key) + if not isinstance(kvs, dict): + raise TypeError(f"Not a dict on the path: {'/'.join(passed) or '/'}") + return kvs + + +def is_dict(kvs: Any, *path: str) -> bool: + if not isinstance(kvs, dict): + return False + for key in path: + if key not in kvs: + return False + kvs = kvs[key] + if not isinstance(kvs, dict): + return False + return True # ===== @@ -81,3 +93,36 @@ def build_cmd(cmd: list[str], cmd_remove: list[str], cmd_append: list[str]) -> l *filter((lambda item: item not in cmd_remove), cmd[1:]), *cmd_append, ] + + +# ===== +def passwds_splitted(text: str) -> Generator[tuple[int, str]]: + for (lineno, line) in enumerate(text.split("\n")): + line = line.rstrip("\r") + ls = line.strip() + if len(ls) == 0 or ls.startswith("#"): + continue + yield (lineno, line) + + +# ===== +@contextlib.contextmanager +def atomic_file_edit(path: str) -> Generator[str]: + (tmp_fd, tmp_path) = tempfile.mkstemp( + prefix=f".{os.path.basename(path)}.", + dir=os.path.dirname(path), + ) + try: + try: + st = os.stat(path) + with open(path, "rb") as file: + os.write(tmp_fd, file.read()) + os.fchown(tmp_fd, st.st_uid, st.st_gid) + os.fchmod(tmp_fd, st.st_mode) + finally: + os.close(tmp_fd) + yield tmp_path + os.rename(tmp_path, path) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) diff --git a/kvmd/usb.py b/kvmd/usb.py index 0c23828f0..34646c3f0 100644 --- a/kvmd/usb.py +++ b/kvmd/usb.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -55,3 +55,11 @@ def get_udc_path(udc: str, *parts: str) -> str: def get_gadget_path(gadget: str, *parts: str) -> str: return os.path.join(f"{env.SYSFS_PREFIX}/sys/kernel/config/usb_gadget", gadget, *parts) + + +# ===== +def make_inquiry_string(vendor: str, product: str, revision: str) -> str: + # Vendor: 8 ASCII chars + # Product: 16 + # Revision: 4 + return "%-8.8s%-16.16s%-4.4s" % (vendor, product, revision) diff --git a/kvmd/validators/__init__.py b/kvmd/validators/__init__.py index 57c96d8a3..f9e479456 100644 --- a/kvmd/validators/__init__.py +++ b/kvmd/validators/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,11 +21,15 @@ import re +import functools +from typing import cast from typing import Mapping from typing import Sequence from typing import Callable +from typing import ParamSpec from typing import TypeVar +from typing import Protocol from typing import NoReturn from typing import Any @@ -35,8 +39,34 @@ class ValidatorError(ValueError): pass -# ===== -_RetvalSeqT = TypeVar("_RetvalSeqT", bound=Sequence) +_PP = ParamSpec("_PP") +_PR_co = TypeVar("_PR_co", covariant=True) + + +class _ContainsMkMethod(Protocol[_PP, _PR_co]): + def mk(self, **kwargs: Any) -> Callable[[Any], _PR_co]: + # I wanted to use _PP.kwargs, but I can't: + # - https://peps.python.org/pep-0612/#id1 + # - https://github.com/python/typing/issues/1524 + ... + + def __call__(self, *args: _PP.args, **kwargs: _PP.kwargs) -> _PR_co: + ... + + +_VP = ParamSpec("_VP") +_VR = TypeVar("_VR") + + +def add_validator_magic(validator: Callable[_VP, _VR]) -> _ContainsMkMethod[_VP, _VR]: + def make(**kwargs: Any) -> Callable[[Any], _VR]: + @functools.wraps(validator) + def specialized(arg: Any) -> _VR: + return validator(arg, **kwargs) + return specialized + + validator.mk = make # type: ignore + return cast(_ContainsMkMethod, validator) # ===== @@ -86,6 +116,9 @@ def check_re_match(arg: Any, name: str, pattern: str, strip: bool=True, hide: bo return arg +_RetvalSeqT = TypeVar("_RetvalSeqT", bound=Sequence) + + def check_len(arg: _RetvalSeqT, name: str, limit: int) -> _RetvalSeqT: if len(arg) > limit: raise_error(arg, name) @@ -99,3 +132,11 @@ def check_any(arg: Any, name: str, validators: list[Callable[[Any], Any]]) -> An except Exception: pass raise_error(arg, name) + + +# ===== +def filter_printable(arg: str, replace: str, limit: int) -> str: + return "".join( + (ch if ch.isprintable() else replace) + for ch in arg[:limit] + ) diff --git a/kvmd/validators/auth.py b/kvmd/validators/auth.py index f6d55c22e..14b834e2a 100644 --- a/kvmd/validators/auth.py +++ b/kvmd/validators/auth.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -23,22 +23,39 @@ from typing import Any from .basic import valid_string_list +from .basic import valid_number +from . import add_validator_magic from . import check_re_match +from . import check_string_in_list # ===== +@add_validator_magic def valid_user(arg: Any) -> str: return check_re_match(arg, "username characters", r"^[a-z_][a-z0-9_-]*$") +@add_validator_magic def valid_users_list(arg: Any) -> list[str]: return valid_string_list(arg, subval=valid_user, name="users list") +@add_validator_magic def valid_passwd(arg: Any) -> str: return check_re_match(arg, "passwd characters", r"^[\x20-\x7e]*\Z$", strip=False, hide=True) +@add_validator_magic +def valid_expire(arg: Any) -> int: + return int(valid_number(arg, min=0, name="expiration time")) + + +@add_validator_magic def valid_auth_token(arg: Any) -> str: return check_re_match(arg, "auth token", r"^[0-9a-f]{64}$", hide=True) + + +@add_validator_magic +def valid_login_redirect(arg: Any) -> str: + return check_string_in_list(arg, "login redirect", ["", "/", "/kvm", "/kvm/"]) diff --git a/kvmd/validators/basic.py b/kvmd/validators/basic.py index c7b459714..86f0d7a88 100644 --- a/kvmd/validators/basic.py +++ b/kvmd/validators/basic.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -27,18 +27,21 @@ from typing import Any from . import ValidatorError +from . import add_validator_magic from . import raise_error from . import check_not_none_string from . import check_in_list # ===== +@add_validator_magic def valid_stripped_string(arg: Any, name: str="") -> str: if not name: name = "stripped string" return check_not_none_string(arg, name) +@add_validator_magic def valid_stripped_string_not_empty(arg: Any, name: str="") -> str: if not name: name = "not empty stripped string" @@ -47,6 +50,7 @@ def valid_stripped_string_not_empty(arg: Any, name: str="") -> str: return valid_stripped_string(arg, name) +@add_validator_magic def valid_bool(arg: Any) -> bool: true_args = ["1", "true", "yes"] false_args = ["0", "false", "no"] @@ -58,6 +62,7 @@ def valid_bool(arg: Any) -> bool: return (arg in true_args) +@add_validator_magic def valid_number( arg: Any, min: (int | float | None)=None, # pylint: disable=redefined-builtin @@ -70,7 +75,13 @@ def valid_number( arg = valid_stripped_string_not_empty(arg, name) try: - arg = type(arg) + if type == int: + if arg.startswith(("0x", "0X")): + arg = int(arg[2:], 16) + else: + arg = int(arg) + else: + arg = type(arg) except Exception: raise_error(arg, name) @@ -81,22 +92,27 @@ def valid_number( return arg +@add_validator_magic def valid_int_f0(arg: Any) -> int: return int(valid_number(arg, min=0)) +@add_validator_magic def valid_int_f1(arg: Any) -> int: return int(valid_number(arg, min=1)) +@add_validator_magic def valid_float_f0(arg: Any) -> float: return float(valid_number(arg, min=0, type=float)) +@add_validator_magic def valid_float_f01(arg: Any) -> float: return float(valid_number(arg, min=0.1, type=float)) +@add_validator_magic def valid_string_list( arg: Any, delim: str=r"[,\t ]+", diff --git a/kvmd/validators/hid.py b/kvmd/validators/hid.py index 2812dcca0..74f461c41 100644 --- a/kvmd/validators/hid.py +++ b/kvmd/validators/hid.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,37 +22,46 @@ from typing import Any -from ..keyboard.mappings import KEYMAP +from ..keyboard.mappings import WEB_TO_EVDEV +from ..mouse import MOUSE_TO_EVDEV from ..mouse import MouseRange +from ..mouse import MouseDelta +from . import add_validator_magic from . import check_string_in_list from .basic import valid_number # ===== +@add_validator_magic def valid_hid_keyboard_output(arg: Any) -> str: return check_string_in_list(arg, "Keyboard output", ["usb", "ps2", "disabled"]) +@add_validator_magic def valid_hid_mouse_output(arg: Any) -> str: return check_string_in_list(arg, "Mouse output", ["usb", "usb_win98", "usb_rel", "ps2", "disabled"]) +@add_validator_magic def valid_hid_key(arg: Any) -> str: - return check_string_in_list(arg, "Keyboard key", KEYMAP, lower=False) + return check_string_in_list(arg, "Keyboard key", WEB_TO_EVDEV, lower=False) +@add_validator_magic def valid_hid_mouse_move(arg: Any) -> int: arg = valid_number(arg, name="Mouse move") - return min(max(MouseRange.MIN, arg), MouseRange.MAX) + return MouseRange.normalize(arg) +@add_validator_magic def valid_hid_mouse_button(arg: Any) -> str: - return check_string_in_list(arg, "Mouse button", ["left", "right", "middle", "up", "down"]) + return check_string_in_list(arg, "Mouse button", MOUSE_TO_EVDEV) +@add_validator_magic def valid_hid_mouse_delta(arg: Any) -> int: arg = valid_number(arg, name="Mouse delta") - return min(max(-127, arg), 127) + return MouseDelta.normalize(arg) diff --git a/kvmd/validators/hw.py b/kvmd/validators/hw.py index 7ca9c0015..00001a9fd 100644 --- a/kvmd/validators/hw.py +++ b/kvmd/validators/hw.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,6 +22,7 @@ from typing import Any +from . import add_validator_magic from . import check_in_list from . import check_string_in_list from . import check_re_match @@ -31,28 +32,34 @@ # ===== +@add_validator_magic def valid_tty_speed(arg: Any) -> int: name = "TTY speed" arg = int(valid_number(arg, name=name)) return check_in_list(arg, name, [1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200]) +@add_validator_magic def valid_gpio_pin(arg: Any) -> int: return int(valid_number(arg, min=0, name="GPIO pin")) +@add_validator_magic def valid_gpio_pin_optional(arg: Any) -> int: return int(valid_number(arg, min=-1, name="optional GPIO pin")) +@add_validator_magic def valid_otg_gadget(arg: Any) -> str: name = "OTG gadget name" return check_len(check_re_match(arg, name, r"^[a-z_][a-z0-9_-]*$"), name, 255) +@add_validator_magic def valid_otg_id(arg: Any) -> int: return int(valid_number(arg, min=0, max=65535, name="OTG ID")) +@add_validator_magic def valid_otg_ethernet(arg: Any) -> str: return check_string_in_list(arg, "OTG Ethernet driver", ["ecm", "eem", "ncm", "rndis", "rndis5"]) diff --git a/kvmd/validators/kvm.py b/kvmd/validators/kvm.py index 4296958ae..9f112e488 100644 --- a/kvmd/validators/kvm.py +++ b/kvmd/validators/kvm.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,9 +22,11 @@ from typing import Any +from . import add_validator_magic from . import raise_error from . import check_string_in_list +from .basic import valid_stripped_string from .basic import valid_stripped_string_not_empty from .basic import valid_number from .basic import valid_string_list @@ -32,17 +34,25 @@ # ===== +@add_validator_magic def valid_atx_power_action(arg: Any) -> str: return check_string_in_list(arg, "ATX power action", ["on", "off", "off_hard", "reset_hard"]) +@add_validator_magic def valid_atx_button(arg: Any) -> str: return check_string_in_list(arg, "ATX button", ["power", "power_long", "reset"]) -def valid_msd_image_name(arg: Any) -> str: +@add_validator_magic +def valid_msd_image_name(arg: Any, allow_eject: bool=False) -> str: name = "MSD image name" - arg = valid_stripped_string_not_empty(arg, name) + arg = valid_stripped_string(arg) + if len(arg) == 0: + if allow_eject: + return "" + else: + raise_error(arg, name) parts: list[str] = list(filter(None, arg.split("/"))) if len(parts) == 0: raise_error(arg, name) @@ -51,6 +61,7 @@ def valid_msd_image_name(arg: Any) -> str: return "/".join(parts) +@add_validator_magic def valid_info_fields(arg: Any, variants: set[str]) -> set[str]: return set(valid_string_list( arg=str(arg).strip(), @@ -59,18 +70,22 @@ def valid_info_fields(arg: Any, variants: set[str]) -> set[str]: )) +@add_validator_magic def valid_log_seek(arg: Any) -> int: return int(valid_number(arg, min=0, name="log seek")) +@add_validator_magic def valid_stream_quality(arg: Any) -> int: return int(valid_number(arg, min=1, max=100, name="stream quality")) +@add_validator_magic def valid_stream_fps(arg: Any) -> int: return int(valid_number(arg, min=0, max=120, name="stream FPS")) +@add_validator_magic def valid_stream_resolution(arg: Any) -> str: name = "stream resolution" arg = valid_stripped_string_not_empty(arg, name) @@ -82,9 +97,11 @@ def valid_stream_resolution(arg: Any) -> str: return f"{width}x{height}" +@add_validator_magic def valid_stream_h264_bitrate(arg: Any) -> int: return int(valid_number(arg, min=25, max=20000, name="stream H264 bitrate")) +@add_validator_magic def valid_stream_h264_gop(arg: Any) -> int: return int(valid_number(arg, min=0, max=60, name="stream H264 GOP")) diff --git a/kvmd/validators/net.py b/kvmd/validators/net.py index 8a75729f3..b895fba50 100644 --- a/kvmd/validators/net.py +++ b/kvmd/validators/net.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -27,6 +27,7 @@ from typing import Any from . import ValidatorError +from . import add_validator_magic from . import raise_error from . import check_re_match from . import check_any @@ -37,6 +38,7 @@ # ===== +@add_validator_magic def valid_ip_or_host(arg: Any) -> str: name = "IPv4/6 address or RFC-1123 hostname" return check_any( @@ -49,6 +51,7 @@ def valid_ip_or_host(arg: Any) -> str: ) +@add_validator_magic def valid_ip(arg: Any, v4: bool=True, v6: bool=True) -> str: assert v4 or v6 validators: list[Callable] = [] @@ -67,6 +70,7 @@ def valid_ip(arg: Any, v4: bool=True, v6: bool=True) -> str: ) +@add_validator_magic def valid_net(arg: Any, v4: bool=True, v6: bool=True) -> str: assert v4 or v6 validators: list[Callable] = [] @@ -87,6 +91,7 @@ def valid_net(arg: Any, v4: bool=True, v6: bool=True) -> str: ) +@add_validator_magic def valid_rfc_host(arg: Any) -> str: # http://stackoverflow.com/questions/106179/regular-expression-to-match-hostname-or-ip-address pattern = r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*" \ @@ -94,29 +99,34 @@ def valid_rfc_host(arg: Any) -> str: return check_re_match(arg, "RFC-1123 hostname", pattern) +@add_validator_magic def valid_port(arg: Any) -> int: return int(valid_number(arg, min=0, max=65535, name="network port")) +@add_validator_magic def valid_ports_list(arg: Any) -> list[int]: return list(map(int, valid_string_list(arg, subval=valid_port, name="ports list"))) +@add_validator_magic def valid_mac(arg: Any) -> str: pattern = ":".join([r"[0-9a-fA-F]{2}"] * 6) return check_re_match(arg, "MAC address", pattern).lower() +@add_validator_magic def valid_ssl_ciphers(arg: Any) -> str: name = "SSL ciphers" arg = valid_stripped_string_not_empty(arg, name) try: ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).set_ciphers(arg) - except Exception as err: - raise ValidatorError(f"The argument {arg!r} is not a valid {name}: {err}") + except Exception as ex: + raise ValidatorError(f"The argument {arg!r} is not a valid {name}: {ex}") return arg +@add_validator_magic def valid_url(arg: Any) -> str: # XXX: VERY primitive return check_re_match(arg, "HTTP(S) URL", r"^https?://[\[\w]+\S*") diff --git a/kvmd/validators/os.py b/kvmd/validators/os.py index 0f9c94d29..ab10c6ce6 100644 --- a/kvmd/validators/os.py +++ b/kvmd/validators/os.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -25,7 +25,9 @@ from typing import Any +from . import add_validator_magic from . import raise_error +from . import filter_printable from .basic import valid_number from .basic import valid_string_list @@ -33,6 +35,7 @@ # ===== +@add_validator_magic def valid_abs_path(arg: Any, type: str="", name: str="") -> str: # pylint: disable=redefined-builtin if type: if not name: @@ -55,8 +58,8 @@ def valid_abs_path(arg: Any, type: str="", name: str="") -> str: # pylint: disa if type: try: st = os.stat(arg) - except Exception as err: - raise_error(arg, f"{name}: {err}") + except Exception as ex: + raise_error(arg, f"{name}: {ex}") else: if not getattr(stat, f"S_IS{type.upper()}")(st.st_mode): raise_error(arg, name) @@ -64,20 +67,21 @@ def valid_abs_path(arg: Any, type: str="", name: str="") -> str: # pylint: disa return arg +@add_validator_magic def valid_abs_file(arg: Any, name: str="") -> str: return valid_abs_path(arg, type="file", name=name) +@add_validator_magic def valid_abs_dir(arg: Any, name: str="") -> str: return valid_abs_path(arg, type="dir", name=name) +@add_validator_magic def valid_printable_filename(arg: Any, name: str="") -> str: if not name: name = "printable filename" - arg = valid_stripped_string_not_empty(arg, name) - if ( "/" in arg or "\0" in arg @@ -85,28 +89,26 @@ def valid_printable_filename(arg: Any, name: str="") -> str: or arg == "lost+found" ): raise_error(arg, name) - - arg = "".join( - (ch if ch.isprintable() else "_") - for ch in arg[:255] - ) - return arg + return filter_printable(arg, "_", 255) # ===== +@add_validator_magic def valid_unix_mode(arg: Any) -> int: return int(valid_number(arg, min=0, name="UNIX mode")) +@add_validator_magic def valid_options(arg: Any, name: str="") -> list[str]: if not name: name = "options" return valid_string_list(arg, delim=r"[,\t]+", name=name) +@add_validator_magic def valid_command(arg: Any) -> list[str]: cmd = valid_options(arg, name="command") if len(cmd) == 0: raise_error(arg, "command") - cmd[0] = valid_abs_file(cmd[0], name="command entry point") + cmd[0] = valid_abs_path(cmd[0], name="command entry point") return cmd diff --git a/kvmd/validators/switch.py b/kvmd/validators/switch.py new file mode 100644 index 000000000..85d8ffc89 --- /dev/null +++ b/kvmd/validators/switch.py @@ -0,0 +1,73 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import re + +from typing import Any + +from . import add_validator_magic +from . import filter_printable +from . import check_re_match + +from .basic import valid_stripped_string +from .basic import valid_number + + +# ===== +@add_validator_magic +def valid_switch_port_name(arg: Any) -> str: + arg = valid_stripped_string(arg, name="switch port name") + arg = filter_printable(arg, " ", 255) + arg = re.sub(r"\s+", " ", arg) + return arg.strip() + + +@add_validator_magic +def valid_switch_edid_id(arg: Any, allow_default: bool) -> str: + pattern = "(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + if allow_default: + pattern += "|^default$" + return check_re_match(arg, "switch EDID ID", pattern).lower() + + +@add_validator_magic +def valid_switch_edid_data(arg: Any) -> str: + name = "switch EDID data" + arg = valid_stripped_string(arg, name=name) + arg = re.sub(r"\s", "", arg) + return check_re_match(arg, name, "(?i)^([0-9a-f]{256}|[0-9a-f]{512})$").upper() + + +@add_validator_magic +def valid_switch_color(arg: Any, allow_default: bool) -> str: + pattern = "(?i)^[0-9a-f]{6}:[0-9a-f]{2}:[0-9a-f]{4}$" + if allow_default: + pattern += "|^default$" + arg = check_re_match(arg, "switch color", pattern).upper() + if arg == "DEFAULT": + arg = "default" + return arg + + +@add_validator_magic +def valid_switch_atx_click_delay(arg: Any) -> float: + return valid_number(arg, min=0, max=10, type=float, name="ATX delay") diff --git a/kvmd/validators/ugpio.py b/kvmd/validators/ugpio.py index 12461d0d1..61facdaad 100644 --- a/kvmd/validators/ugpio.py +++ b/kvmd/validators/ugpio.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,6 +22,7 @@ from typing import Any +from . import add_validator_magic from . import raise_error from . import check_string_in_list from . import check_re_match @@ -29,6 +30,7 @@ # ===== +@add_validator_magic def valid_ugpio_driver(arg: Any, variants: (set[str] | None)=None) -> str: name = "GPIO driver" arg = check_len(check_re_match(arg, name, r"^[a-zA-Z_][a-zA-Z0-9_-]*$"), name, 255) @@ -37,19 +39,23 @@ def valid_ugpio_driver(arg: Any, variants: (set[str] | None)=None) -> str: return arg +@add_validator_magic def valid_ugpio_channel(arg: Any) -> str: name = "GPIO channel" return check_len(check_re_match(arg, name, r"^[a-zA-Z_][a-zA-Z0-9_.-]*$"), name, 255) +@add_validator_magic def valid_ugpio_mode(arg: Any, variants: set[str]) -> str: return check_string_in_list(arg, "GPIO driver's pin mode", variants) +@add_validator_magic def valid_ugpio_view_title(arg: Any) -> (str | list[str]): return (list(map(str, arg)) if isinstance(arg, list) else str(arg)) +@add_validator_magic def valid_ugpio_view_table(arg: Any) -> list[list[str]]: # pylint: disable=inconsistent-return-statements try: return [list(map(str, row)) for row in list(arg)] diff --git a/kvmd/yamlconf/__init__.py b/kvmd/yamlconf/__init__.py index 8e688a56a..d86c22348 100644 --- a/kvmd/yamlconf/__init__.py +++ b/kvmd/yamlconf/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,8 +20,8 @@ # ========================================================================== # +import enum import contextlib -import json from typing import Generator from typing import Callable @@ -34,41 +34,48 @@ class ConfigError(ValueError): # ===== -def build_raw_from_options(options: list[str]) -> dict[str, Any]: - raw: dict[str, Any] = {} - for option in options: - key: str - (key, value) = (option.split("=", 1) + [None])[:2] # type: ignore - if len(key.strip()) == 0: - raise ConfigError(f"Empty option key (required 'key=value' instead of {option!r})") - if value is None: - raise ConfigError(f"No value for key {key!r}") - - section = raw - subs = list(filter(None, map(str.strip, key.split("/")))) - for sub in subs[:-1]: - section.setdefault(sub, {}) - section = section[sub] - section[subs[-1]] = _parse_value(value) - return raw - - -def _parse_value(value: str) -> Any: - value = value.strip() - if ( - not value.isdigit() - and value not in ["true", "false", "null"] - and not value.startswith(("{", "[", "\"")) - ): - value = f"\"{value}\"" - return json.loads(value) +class Stub: + pass + + +class Hint(enum.Enum): + NONE = "" + HEX = "hex" + OCT = "oct" + INLINED_ITEMS = "inlined_items" + + +class Option: + __type = type + + def __init__( + self, + default: Any, + type: (Callable[[Any], Any] | None)=None, # pylint: disable=redefined-builtin + if_none: Any=Stub, + if_empty: Any=Stub, + unpack_as: str="", + hint: Hint=Hint.NONE, + ) -> None: + + self.default = default + self.type: Callable[[Any], Any] = (type or (self.__type(default) if default is not None else str)) # type: ignore + self.if_none = if_none + self.if_empty = if_empty + self.unpack_as = unpack_as + self.hint = hint + + def __repr__(self) -> str: + return ( + f"" + ) -# ===== class Section(dict): def __init__(self) -> None: dict.__init__(self) - self.__meta: dict[str, dict[str, Any]] = {} + self.__options: dict[str, Option] = {} def _unpack(self, ignore: (list[str] | None)=None) -> dict[str, Any]: if ignore is None: @@ -82,60 +89,22 @@ def _unpack(self, ignore: (list[str] | None)=None) -> dict[str, Any]: unpacked[self._get_unpack_as(key)] = value # pylint: disable=protected-access return unpacked - def _set_meta(self, key: str, default: Any, unpack_as: str, help: str) -> None: # pylint: disable=redefined-builtin - self.__meta[key] = { - "default": default, - "unpack_as": unpack_as, - "help": help, - } + def _set_option(self, key: str, option: Option) -> None: + self.__options[key] = option def _get_default(self, key: str) -> Any: - return self.__meta[key]["default"] + return self.__options[key].default def _get_unpack_as(self, key: str) -> str: - return (self.__meta[key]["unpack_as"] or key) + return (self.__options[key].unpack_as or key) - def _get_help(self, key: str) -> str: - return self.__meta[key]["help"] + def _get_hint(self, key: str) -> Hint: + return self.__options[key].hint def __getattribute__(self, key: str) -> Any: if key in self: return self[key] - else: # For pickling - return dict.__getattribute__(self, key) - - -class Stub: - pass - - -class Option: - __type = type - - def __init__( - self, - default: Any, - type: (Callable[[Any], Any] | None)=None, # pylint: disable=redefined-builtin - if_none: Any=Stub, - if_empty: Any=Stub, - only_if: str="", - unpack_as: str="", - help: str="", # pylint: disable=redefined-builtin - ) -> None: - - self.default = default - self.type: Callable[[Any], Any] = (type or (self.__type(default) if default is not None else str)) # type: ignore - self.if_none = if_none - self.if_empty = if_empty - self.only_if = only_if - self.unpack_as = unpack_as - self.help = help - - def __repr__(self) -> str: - return ( - f"" - ) + return dict.__getattribute__(self, key) # For pickling # ===== @@ -143,65 +112,58 @@ def __repr__(self) -> str: def manual_validated(value: Any, *path: str) -> Generator[None, None, None]: try: yield - except (TypeError, ValueError) as err: - raise ConfigError(f"Invalid value {value!r} for key {'/'.join(path)!r}: {err}") + except (TypeError, ValueError) as ex: + raise ConfigError(f"Invalid value {value!r} for key {'/'.join(path)!r}: {ex}") -def make_config(raw: dict[str, Any], scheme: dict[str, Any], _keys: tuple[str, ...]=()) -> Section: - if not isinstance(raw, dict): - raise ConfigError(f"The node {('/'.join(_keys) or '/')!r} must be a dictionary") +def make_config( + main: Any, + override: Any, + scheme: dict, + _path: tuple[str, ...]=(), +) -> Section: + + if not isinstance(main, dict): + raise ConfigError(f"The node {('/'.join(_path) or '/')!r} of main must be a dictionary") + if not isinstance(override, dict): + raise ConfigError(f"The node {('/'.join(_path) or '/')!r} of override must be a dictionary") config = Section() - def make_full_key(key: str) -> tuple[str, ...]: - return _keys + (key,) + def make_full_path(key: str) -> tuple[str, ...]: + return _path + (key,) def make_full_name(key: str) -> str: - return "/".join(make_full_key(key)) + return "/".join(make_full_path(key)) - def process_option(key: str, no_only_if: bool=False) -> Any: - if key not in config: + for key in scheme: + if isinstance(scheme[key], Option): option: Option = scheme[key] - only_if = option.only_if - only_if_negative = option.only_if.startswith("!") - if only_if_negative: - only_if = only_if[1:] - - if only_if and no_only_if: # pylint: disable=no-else-raise - # Перекрестный only_if запрещен - raise RuntimeError(f"Found only_if recursion on key {make_full_name(key)!r}") - elif only_if and ( - (not only_if_negative and not process_option(only_if, no_only_if=True)) - or (only_if_negative and process_option(only_if, no_only_if=True)) - ): - # Если есть условие и оно ложно - ставим дефолт и не валидируем - value = option.default + if key in main and option.default != main[key]: + option.default = main[key] + + value = override.get(key, option.default) + if option.if_none != Stub and value is None: + value = option.if_none + elif option.if_empty != Stub and not value: + value = option.if_empty else: - value = raw.get(key, option.default) - if option.if_none != Stub and value is None: - value = option.if_none - elif option.if_empty != Stub and not value: - value = option.if_empty - else: - try: - value = option.type(value) - except (TypeError, ValueError) as err: - raise ConfigError(f"Invalid value {value!r} for key {make_full_name(key)!r}: {err}") + try: + value = option.type(value) + except (TypeError, ValueError) as ex: + raise ConfigError(f"Invalid value {value!r} for key {make_full_name(key)!r}: {ex}") config[key] = value - config._set_meta( # pylint: disable=protected-access - key=key, - default=option.default, - unpack_as=option.unpack_as, - help=option.help, - ) - return config[key] + config._set_option(key, option) # pylint: disable=protected-access - for key in scheme: - if isinstance(scheme[key], Option): - process_option(key) elif isinstance(scheme[key], dict): - config[key] = make_config(raw.get(key, {}), scheme[key], make_full_key(key)) + config[key] = make_config( + main=main.get(key, {}), + override=override.get(key, {}), + scheme=scheme[key], + _path=make_full_path(key), + ) + else: raise RuntimeError(f"Incorrect scheme definition for key {make_full_name(key)!r}:" f" the value is {type(scheme[key])!r}, not dict() or Option()") diff --git a/kvmd/yamlconf/dumper.py b/kvmd/yamlconf/dumper.py index 206b5a5d0..1c5e213b1 100644 --- a/kvmd/yamlconf/dumper.py +++ b/kvmd/yamlconf/dumper.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -20,58 +20,199 @@ # ========================================================================== # +import io import textwrap +import contextlib +from typing import Callable from typing import Generator from typing import Any -import yaml +from ruamel.yaml import YAML +from ruamel.yaml.nodes import ScalarNode +from ruamel.yaml.nodes import CollectionNode +from ruamel.yaml.nodes import SequenceNode +from ruamel.yaml.nodes import MappingNode +from ruamel.yaml.comments import CommentedMap +from ruamel.yaml.representer import RoundTripRepresenter + +import pygments +import pygments.lexers.data +import pygments.formatters from .. import tools +from . import ConfigError +from . import Hint from . import Section # ===== -def make_config_dump(config: Section, indent: int=4) -> str: - return "\n".join(_inner_make_dump(config, indent)) - - -def _inner_make_dump(config: Section, indent: int, _level: int=0) -> Generator[str, None, None]: - for (key, value) in tools.sorted_kvs(config): - if isinstance(value, Section): - prefix = " " * indent * _level - yield f"{prefix}{key}:" - yield from _inner_make_dump(value, indent, _level + 1) - yield "" - else: - default = config._get_default(key) # pylint: disable=protected-access - comment = config._get_help(key) # pylint: disable=protected-access - if default == value: - yield _make_yaml_kv(key, value, indent, _level, comment=comment) +class YamlHexInt(int): + pass + + +class YamlOctInt(int): + pass + + +class YamlInlinedItemsList(list): + pass + + +class _SimpleRepresenter(RoundTripRepresenter): + def ignore_aliases(self, data: Any) -> bool: + return True + + def _represent_hex_int(self, value: YamlHexInt) -> ScalarNode: + if value > 0: + value: str = f"0x{value:X}" # type: ignore + return self.represent_scalar("tag:yaml.org,2002:int", str(value)) + + def _represent_oct_int(self, value: YamlOctInt) -> ScalarNode: + if value > 0: + value: str = f"0o{value:o}" # type: ignore + return self.represent_scalar("tag:yaml.org,2002:int", str(value)) + + def _represent_inlined_items_list(self, seq: YamlInlinedItemsList) -> SequenceNode: + node = self.represent_sequence("tag:yaml.org,2002:seq", seq) + for child in node.value: + if isinstance(child, CollectionNode): + child.flow_style = True + return node + + +_SimpleRepresenter.add_representer(YamlHexInt, _SimpleRepresenter._represent_hex_int) # pylint: disable=protected-access +_SimpleRepresenter.add_representer(YamlOctInt, _SimpleRepresenter._represent_oct_int) # pylint: disable=protected-access +_SimpleRepresenter.add_representer(YamlInlinedItemsList, _SimpleRepresenter._represent_inlined_items_list) # pylint: disable=protected-access + + +_INDENT = 4 + + +class _ConfigRepresenter(_SimpleRepresenter): + def __init__(self, *args, **kwargs) -> None: # type: ignore + super().__init__(*args, **kwargs) + + self.only_changed = False + self.__depth = 0 + + # This is used only for dumping default values. + # They should not have Sections() inside. + # Avoid potential recursion too. + self.__handler = _YamlHandler() + self.__handler.Representer = _SimpleRepresenter + + def _represent_section(self, config: Section) -> MappingNode: + self.__depth += 1 + com = CommentedMap(dict(config)) + sections: set[str] = set() + for (key, value) in config.items(): + if isinstance(value, Section): + sections.add(key) else: - yield _make_yaml_kv(key, default, indent, _level, comment=comment, commented=True) - yield _make_yaml_kv(key, value, indent, _level) - - -def _make_yaml_kv(key: str, value: Any, indent: int, level: int, comment: str="", commented: bool=False) -> str: - text = yaml.dump(value, indent=indent, allow_unicode=True) - text = text.replace("\n...\n", "").strip() - if ( - isinstance(value, dict) and text[0] != "{" - or isinstance(value, list) and text[0] != "[" - ): - text = "\n" + textwrap.indent(text, prefix=" " * indent) - else: - text = " " + text - - prefix = " " * indent * level - if commented: - prefix = prefix + "# " - text = textwrap.indent(f"{key}:{text}", prefix=prefix) - - if comment: - lines = text.split("\n") - lines[0] += " # " + comment - text = "\n".join(lines) + hint = config._get_hint(key) # pylint: disable=protected-access + com[key] = self.__get_hinted(value, hint) + default = config._get_default(key) # pylint: disable=protected-access + if value != default: + comment = self.__make_comment(default, hint) + if "\n" in comment: + com.yaml_set_comment_before_after_key( + key=key, + after=comment, + after_indent=(self.__depth * _INDENT), + ) + else: + com.yaml_add_eol_comment(comment, key, column=0) + elif self.only_changed: + com.pop(key) + + node = self.represent_mapping("tag:yaml.org,2002:map", com) + if self.only_changed: + node.value = [ + (k_node, v_node) + for (k_node, v_node) in node.value + if ( + not isinstance(v_node, MappingNode) + or not isinstance(v_node.value, list) + or len(v_node.value) != 0 + ) + ] + self.__depth -= 1 + return node + + def __get_hinted(self, value: Any, hint: Hint) -> Any: + match hint: + case Hint.HEX if isinstance(value, int): + return YamlHexInt(value) + case Hint.OCT if isinstance(value, int): + return YamlOctInt(value) + case Hint.INLINED_ITEMS if isinstance(value, list): + return YamlInlinedItemsList(value) + return value + + def __make_comment(self, default: Any, hint: Hint) -> str: + text = self.__handler.dump_as_string(self.__get_hinted(default, hint)) + text = text.rstrip() + if text.endswith("\n..."): + text = text[:-4].rstrip() + text = textwrap.dedent(text) + nl = ("\n" if "\n" in text else " ") # Multiline or single-line + return f"### Default:{nl}{text}" + + +_ConfigRepresenter.add_representer(Section, _ConfigRepresenter._represent_section) # pylint: disable=protected-access + + +class _YamlHandler(YAML): + def __init__(self) -> None: + super().__init__() + self.preserve_quotes = True + self.indent(mapping=_INDENT, sequence=_INDENT, offset=_INDENT) + # ruamel.yaml ignores oOyYnN by default: https://stackoverflow.com/questions/36463531 + + def dump_as_string(self, data: Any) -> str: + with io.StringIO() as file: + self.dump(data, file) + return file.getvalue() + + +def dump_yaml(data: Any, only_changed: bool=False, colored: bool=False) -> str: + handler = _YamlHandler() + handler.Representer = _ConfigRepresenter + handler.representer.only_changed = only_changed + text = handler.dump_as_string(data) + if colored: + text = pygments.highlight( + text, + pygments.lexers.data.YamlLexer(), + pygments.formatters.TerminalFormatter(bg="dark"), # pylint: disable=no-member + ) return text + + +@contextlib.contextmanager +def override_yaml_file(path: str, validator: Callable[[str], None]) -> Generator[Any]: + handler = _YamlHandler() + handler.Representer = _ConfigRepresenter + with tools.atomic_file_edit(path) as tmp_path: + # ruamel.yaml can't keep comments for an empty file + # but there is a trick: we can create a new CommentedMap() + # and assign the entire file content to it as comment. + with open(tmp_path) as file: + content = file.read() + doc = handler.load(content) + if doc is None: + doc = CommentedMap() + doc.yaml_set_start_comment(content) + elif not isinstance(doc, dict): + raise ConfigError(f"The root in {path!r} should be a dictionary") + + try: # pylint: disable=no-else-raise + yield doc + except Exception: # pylint: disable=try-except-raise + raise + else: # Makes pylint happy + with open(tmp_path, "w") as file: + handler.dump(doc, file) + validator(tmp_path) diff --git a/kvmd/yamlconf/loader.py b/kvmd/yamlconf/loader.py index ffd69e3ee..cf270dd68 100644 --- a/kvmd/yamlconf/loader.py +++ b/kvmd/yamlconf/loader.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -24,6 +24,7 @@ from .. import tools +from typing import Generator from typing import IO from typing import Any @@ -40,9 +41,20 @@ def load_yaml_file(path: str) -> Any: with open(path) as file: try: return yaml.load(file, _YamlLoader) - except Exception as err: + except Exception as ex: # Reraise internal exception as standard ValueError and show the incorrect file - raise ValueError(f"Invalid YAML in the file {path!r}:\n{tools.efmt(err)}") from None + raise ValueError(f"Invalid YAML in the file {path!r}:\n{tools.efmt(ex)}") from None + + +def listed_yaml_dir(path: str) -> Generator[str]: + for name in sorted(os.listdir(path)): + # TODO: We want to handle *.yaml or even *.yml, + # but but previously we didn't have such filters + # so we need to keep unfildered list processing + # for backward compatibility. + file_path = os.path.join(path, name) + if os.path.isfile(file_path) or os.path.islink(file_path): + yield file_path # ===== @@ -69,10 +81,8 @@ def __inner_include(self, incs: list[str]) -> Any: assert inc, inc inc_path = os.path.join(self.__root, inc) if os.path.isdir(inc_path): - for child in sorted(os.listdir(inc_path)): - child_path = os.path.join(inc_path, child) - if os.path.isfile(child_path) or os.path.islink(child_path): - yaml_merge(tree, (load_yaml_file(child_path) or {}), child_path) + for child_path in listed_yaml_dir(inc_path): + yaml_merge(tree, (load_yaml_file(child_path) or {}), child_path) else: # Try file yaml_merge(tree, (load_yaml_file(inc_path) or {}), inc_path) return tree diff --git a/kvmd/yamlconf/merger.py b/kvmd/yamlconf/merger.py index a11ff3aa1..689d1efc5 100644 --- a/kvmd/yamlconf/merger.py +++ b/kvmd/yamlconf/merger.py @@ -20,8 +20,14 @@ # ========================================================================== # +from typing import Mapping +from typing import Any + +import ruamel.yaml.compat + + # ===== -def yaml_merge(dest: dict, src: dict, src_name: str="") -> None: +def yaml_merge(dest: Mapping, src: Mapping, src_name: str="") -> None: """ Merges the source dictionary into the destination dictionary. """ # Checking if destination is None @@ -38,10 +44,15 @@ def yaml_merge(dest: dict, src: dict, src_name: str="") -> None: # ====== -def _merge(dest: dict, src: dict) -> None: +def _is_dict(obj: Any) -> bool: + # OrderedDict in ruamel is inherited from dict, but we want to check it explicitly. + return isinstance(obj, (dict, ruamel.yaml.compat.OrderedDict)) + + +def _merge(dest: Mapping, src: Mapping) -> None: for key in src: if key in dest: - if isinstance(dest[key], dict) and isinstance(src[key], dict): + if _is_dict(dest[key]) and _is_dict(src[key]): _merge(dest[key], src[key]) continue - dest[key] = src[key] + dest[key] = src[key] # type: ignore diff --git a/platform.install b/platform.install new file mode 100644 index 000000000..19cef834b --- /dev/null +++ b/platform.install @@ -0,0 +1,49 @@ +# 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 "==> Patching platform ..." + + if [[ "$(vercmp "$2" 4.106)" -lt 0 ]]; then + # Keep local modifications of etc configs + for i in \ + /etc/sysctl.d/99-kvmd.conf \ + /etc/udev/rules.d/99-kvmd.rules \ + /etc/modules-load.d/kvmd.conf \ + /etc/kvmd/auth.yaml \ + ; do + if [ -e "$i".pacsave ]; then + mv "$i".pacsave "$i" + fi + done + rm -f \ + /etc/kvmd/main.yaml.pacnew \ + /etc/kvmd/auth.yaml.pacnew + fi + + if [[ "$(vercmp "$2" 4.112)" -lt 0 ]]; then + cat /boot/cmdline.txt \ + | xargs printf "%s\n" \ + | grep -v '^cma=' \ + | paste -sd ' ' - \ + > /boot/cmdline.txt.kvmd-new + mv /boot/cmdline.txt.kvmd-new /boot/cmdline.txt + + # shellcheck source=/dev/null + source /usr/lib/kvmd/platform + config="/usr/share/kvmd/configs.default/os/boot-config/$PIKVM_MODEL-$PIKVM_VIDEO-$PIKVM_BOARD.txt" + if grep -q '^dtoverlay=cma,' "$config"; then + if ! grep -q '^dtoverlay=cma,' /boot/config.txt; then + # shellcheck disable=SC2129 + echo >> /boot/config.txt + grep '^dtoverlay=cma,' "$config" >> /boot/config.txt + fi + fi + fi +} diff --git a/scripts/kvmd-bootconfig b/scripts/kvmd-bootconfig index 1ea68c2c7..1d26ab76f 100755 --- a/scripts/kvmd-bootconfig +++ b/scripts/kvmd-bootconfig @@ -3,7 +3,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -50,10 +50,60 @@ fi # shellcheck disable=SC1090 source <(dos2unix < /boot/pikvm.txt) -rw +# shellcheck disable=SC1091 +source /usr/lib/kvmd/platform || true +rw -# ========== First boot configuration ========== +need_reboot() { + touch /boot/pikvm-reboot.txt +} + + +# ========== First boot and/or Avahi configuration ========== + +make_avahi_service() { + local _base + local _serial + local _platform + _base=$(tr -d '\0' < /proc/device-tree/model || echo "Unknown base") + _serial=$( (cat /proc/device-tree/serial-number || echo "0000000000000000") | tr -d '\0' | tr '[:lower:]' '[:upper:]') + _platform="$PIKVM_MODEL-$PIKVM_VIDEO-$PIKVM_BOARD" + mkdir -p /etc/avahi/services + cat < /etc/avahi/services/pikvm.service + + + + pikvm-$_serial.local + + _pikvm._tcp + 443 + path=/ + protocol=https + description=PiKVM Web Server + model=$PIKVM_MODEL + video=$PIKVM_VIDEO + board=$PIKVM_BOARD + base=$_base + serial=$_serial + platform=$_platform + + + _https._tcp + 443 + path=/ + protocol=https + description=PiKVM Web Server + model=$PIKVM_MODEL + video=$PIKVM_VIDEO + board=$PIKVM_BOARD + base=$_base + serial=$_serial + model=$_platform + + +end_of_file +} if [ -n "$FIRSTBOOT$FIRST_BOOT" ]; then ( \ @@ -90,11 +140,21 @@ if [ -n "$FIRSTBOOT$FIRST_BOOT" ]; then unset disk part npart label fi + make_avahi_service + # fc-cache is required for installed X server # shellcheck disable=SC2015 which fc-cache && fc-cache || true fi +if [ -n "$ENABLE_AVAHI" ]; then + if [ ! -f /etc/avahi/services/pikvm.service ]; then + make_avahi_service + fi + systemctl enable avahi-daemon || true + need_reboot +fi + # ========== OTG serial ========== @@ -115,7 +175,7 @@ TTYVHangup=no TTYVTDisallocate=no end_of_file systemctl enable getty@ttyGS0.service - touch /boot/pikvm-reboot.txt + need_reboot fi @@ -128,12 +188,12 @@ fi # ========== Ethernet ========== -# If the ETH_DHCP is defined, configure eth0 for DHCP -if [ -n "$ETH_DHCP" ]; then - ETH_IFACE="${ETH_IFACE:-eth0}" - cat < "/etc/systemd/network/$ETH_IFACE.network" +make_dhcp_iface() { + local _iface="$1" + local _metric="$2" + cat < "/etc/systemd/network/$_iface.network" [Match] -Name=$ETH_IFACE +Name=$_iface [Network] DHCP=yes @@ -143,25 +203,42 @@ DNSSEC=no # Use same IP by forcing to use MAC address for clientID ClientIdentifier=mac # https://github.com/pikvm/pikvm/issues/583 -RouteMetric=10 +RouteMetric=$_metric end_of_file -fi - -# If the ETH_ADDR is defined, configure a static address on eth0 -if [ -n "$ETH_ADDR" ]; then - ETH_IFACE="${ETH_IFACE:-eth0}" - cat < "/etc/systemd/network/$ETH_IFACE.network" +} + +make_static_iface() { + local _iface="$1" + local _addr="$2" + local _gw="$3" + local _dns="$4" + local _metric="$5" + cat < "/etc/systemd/network/$_iface.network" [Match] -Name=$ETH_IFACE +Name=$_iface [Network] -Address=$ETH_ADDR -DNS=$ETH_DNS +Address=$_addr +DNS=$_dns DNSSEC=no [Route] -Gateway=$ETH_GW +Gateway=$_gw +# https://github.com/pikvm/pikvm/issues/583 +Metric=$_metric end_of_file +} + +# If the ETH_DHCP is defined, configure eth0 for DHCP +if [ -n "$ETH_DHCP" ]; then + ETH_IFACE="${ETH_IFACE:-eth0}" + make_dhcp_iface "$ETH_IFACE" 10 +fi + +# If the ETH_ADDR is defined, configure a static address on eth0 +if [ -n "$ETH_ADDR" ]; then + ETH_IFACE="${ETH_IFACE:-eth0}" + make_static_iface "$ETH_IFACE" "$ETH_ADDR" "$ETH_GW" "$ETH_DNS" 10 fi @@ -178,21 +255,31 @@ fi # If the WIFI_ESSID is defined, configure wlan0 if [ -n "$WIFI_ESSID" ]; then WIFI_IFACE="${WIFI_IFACE:-wlan0}" - cat < "/etc/systemd/network/$WIFI_IFACE.network" -[Match] -Name=$WIFI_IFACE - -[Network] -DHCP=yes -DNSSEC=no - -# Use same IP by forcing to use MAC address for clientID -[DHCP] -ClientIdentifier=mac + if [ -n "$WIFI_ADDR" ]; then + make_static_iface "$WIFI_IFACE" "$WIFI_ADDR" "$WIFI_GW" "$WIFI_DNS" 50 + else + make_dhcp_iface "$WIFI_IFACE" 50 + fi + _wpa="/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" + if [ "${#WIFI_PASSWD}" -ge 8 ];then + wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "$_wpa" + else + cat < "$_wpa" +network={ + ssid=$(printf '"%q"' "$WIFI_ESSID") + key_mgmt=NONE +} end_of_file - wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" + fi + chmod 640 "$_wpa" + if [ -n "$WIFI_HIDDEN" ]; then + sed -i -e 's/^}/\tscan_ssid=1\n}/g' "$_wpa" + fi + if [ -n "$WIFI_WPA23" ]; then + sed -i -e 's/^}/\tkey_mgmt=WPA-PSK-SHA256 WPA-PSK\n\tieee80211w=1\n}/g' "$_wpa" + fi systemctl enable "wpa_supplicant@$WIFI_IFACE.service" || true - touch /boot/pikvm-reboot.txt + need_reboot fi diff --git a/scripts/kvmd-certbot b/scripts/kvmd-certbot index 425f50dc3..ebc94e503 100755 --- a/scripts/kvmd-certbot +++ b/scripts/kvmd-certbot @@ -3,7 +3,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/scripts/kvmd-gencert b/scripts/kvmd-gencert index 3c373e453..a199daf8c 100755 --- a/scripts/kvmd-gencert +++ b/scripts/kvmd-gencert @@ -3,7 +3,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -55,7 +55,7 @@ cd "$path" # - https://msol.io/blog/tech/create-a-self-signed-ecc-certificate openssl ecparam -out server.key -name prime256v1 -genkey openssl req -new -x509 -sha256 -nodes -key server.key -out server.crt -days 3650 \ - -subj "/C=RU/ST=Moscow/L=Moscow/O=PiKVM/OU=PiKVM/CN=localhost" + -subj "/C=US/O=PiKVM/OU=PiKVM/CN=localhost" chown "root:kvmd-$target" "$path"/* chmod 440 "$path/server.key" diff --git a/scripts/kvmd-udev-hdmiusb-check b/scripts/kvmd-udev-hdmiusb-check index 5b058b685..12c19204f 100755 --- a/scripts/kvmd-udev-hdmiusb-check +++ b/scripts/kvmd-udev-hdmiusb-check @@ -3,7 +3,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -48,8 +48,8 @@ case "$board" in esac;; "rpi4") case "$model" in - "Raspberry Pi 4 Model B Rev 1.1" | "Raspberry Pi 4 Model B Rev 1.2" | "Raspberry Pi 4 Model B Rev 1.4") - if [ "$port" == "1-1.4:1.0" ]; then exit 0; else exit 1; fi;; + "Raspberry Pi 4 Model B Rev 1.1" | "Raspberry Pi 4 Model B Rev 1.2" | "Raspberry Pi 4 Model B Rev 1.4" | "Raspberry Pi 4 Model B Rev 1.5") + if [ "$port" == "1-1.4:1.0" ] || [ "$port" == "2-1:1.0" ]; then exit 0; else exit 1; fi;; *) exit 0;; esac;; *) exit 1;; diff --git a/scripts/kvmd-udev-restart-pass b/scripts/kvmd-udev-restart-pass new file mode 100755 index 000000000..4b77897a6 --- /dev/null +++ b/scripts/kvmd-udev-restart-pass @@ -0,0 +1,49 @@ +#!/bin/bash +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +set -eE +set -x + +# https://forums.raspberrypi.com/viewtopic.php?p=2202017 + +card="$1" +conn_id="$2" + +test -n "$card" +test -n "$conn_id" + +card_n="${card/card/}" +test -n "$card_n" + +port=$(grep "^connector\[$conn_id\]:" "/sys/kernel/debug/dri/$card_n/state" | awk '{print $2}') +test -n "$port" + + +if [ "$port" = "HDMI-A-1" ]; then + status=$(head -n 1 "/sys/class/drm/$card-$port/status") + if [ "$status" = "connected" ]; then + for pid in $(pgrep -f '^kvmd/streamer: ' || true); do + kill "$pid" || true + done + fi +fi diff --git a/setup.py b/setup.py index f29d3f0a5..9de060438 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -28,45 +28,35 @@ # ===== +class _Template(str): + def __init__(self, text: str) -> None: + self.__text = textwrap.dedent(text).strip() + + def __mod__(self, kv: dict) -> str: + kv = {"module_name": kv["ep"].module_name, **kv} + return (self.__text % (kv)) + + class _ScriptWriter(setuptools.command.easy_install.ScriptWriter): - template = textwrap.dedent(""" - # EASY-INSTALL-ENTRY-SCRIPT: {spec},{group},{name} + template = _Template(""" + # EASY-INSTALL-ENTRY-SCRIPT: %(spec)r,%(group)r,%(name)r - __requires__ = "{spec}" + __requires__ = %(spec)r - from {module} import main + from %(module_name)s import main - if __name__ == "__main__": + if __name__ == '__main__': main() - """).strip() - - @classmethod - def get_args(cls, dist, header=None): # type: ignore - if header is None: - header = cls.get_header() - spec = str(dist.as_requirement()) - for group_type in ["console", "gui"]: - group = group_type + "_scripts" - for (name, ep) in dist.get_entry_map(group).items(): - cls._ensure_safe_name(name) - script_text = cls.template.format( - spec=spec, - group=group, - name=name, - module=ep.module_name, - ) - yield from cls._get_script_args(group_type, name, header, script_text) + """) # ===== def main() -> None: setuptools.command.easy_install.ScriptWriter = _ScriptWriter - setuptools.command.easy_install.get_script_args = _ScriptWriter.get_script_args - setuptools.command.easy_install.get_script_header = _ScriptWriter.get_script_header setup( name="kvmd", - version="3.229", + version="4.148", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", @@ -79,12 +69,15 @@ def main() -> None: "kvmd.validators", "kvmd.yamlconf", "kvmd.keyboard", + "kvmd.nbd", + "kvmd.nbd.remotes", "kvmd.plugins", "kvmd.plugins.auth", "kvmd.plugins.hid", "kvmd.plugins.hid._mcu", "kvmd.plugins.hid.otg", "kvmd.plugins.hid.bt", + "kvmd.plugins.hid.ch9329", "kvmd.plugins.atx", "kvmd.plugins.msd", "kvmd.plugins.msd.otg", @@ -92,8 +85,12 @@ def main() -> None: "kvmd.clients", "kvmd.apps", "kvmd.apps.kvmd", + "kvmd.apps.kvmd.streamer", + "kvmd.apps.kvmd.switch", "kvmd.apps.kvmd.info", "kvmd.apps.kvmd.api", + "kvmd.apps.kvmd.api.redfish", + "kvmd.apps.media", "kvmd.apps.pst", "kvmd.apps.pstrun", "kvmd.apps.otg", @@ -101,15 +98,20 @@ def main() -> None: "kvmd.apps.otgnet", "kvmd.apps.otgmsd", "kvmd.apps.otgconf", + "kvmd.apps.swctl", + "kvmd.apps.nbd", "kvmd.apps.htpasswd", "kvmd.apps.totp", "kvmd.apps.edidconf", - "kvmd.apps.cleanup", + "kvmd.apps.override", "kvmd.apps.ipmi", "kvmd.apps.vnc", "kvmd.apps.vnc.rfb", + "kvmd.apps.localhid", + "kvmd.apps.ngxmkconf", "kvmd.apps.janus", "kvmd.apps.watchdog", + "kvmd.apps.oled", "kvmd.helpers", "kvmd.helpers.remount", "kvmd.helpers.swapfiles", @@ -117,11 +119,13 @@ def main() -> None: package_data={ "kvmd.apps.vnc": ["fonts/*.ttf"], + "kvmd.apps.oled": ["fonts/*.ttf", "pics/*.ppm"], }, entry_points={ "console_scripts": [ "kvmd = kvmd.apps.kvmd:main", + "kvmd-media = kvmd.apps.media:main", "kvmd-pst = kvmd.apps.pst:main", "kvmd-pstrun = kvmd.apps.pstrun:main", "kvmd-otg = kvmd.apps.otg:main", @@ -131,11 +135,14 @@ def main() -> None: "kvmd-htpasswd = kvmd.apps.htpasswd:main", "kvmd-totp = kvmd.apps.totp:main", "kvmd-edidconf = kvmd.apps.edidconf:main", - "kvmd-cleanup = kvmd.apps.cleanup:main", + "kvmd-override = kvmd.apps.override:main", "kvmd-ipmi = kvmd.apps.ipmi:main", "kvmd-vnc = kvmd.apps.vnc:main", + "kvmd-localhid = kvmd.apps.localhid:main", + "kvmd-nginx-mkconf = kvmd.apps.ngxmkconf:main", "kvmd-janus = kvmd.apps.janus:main", "kvmd-watchdog = kvmd.apps.watchdog:main", + "kvmd-oled = kvmd.apps.oled:main", "kvmd-helper-pst-remount = kvmd.helpers.remount:main", "kvmd-helper-otgmsd-remount = kvmd.helpers.remount:main", "kvmd-helper-swapfiles = kvmd.helpers.swapfiles:main", @@ -145,7 +152,7 @@ def main() -> None: classifiers=[ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Development Status :: 5 - Production/Stable", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.14", "Topic :: System :: Systems Administration", "Operating System :: POSIX :: Linux", "Intended Audience :: System Administrators", diff --git a/switch/LICENSE b/switch/LICENSE new file mode 100644 index 000000000..5c574c884 --- /dev/null +++ b/switch/LICENSE @@ -0,0 +1,15 @@ +The PiKVM Switch Firmware +Copyright (C) 2024-2025 + +This software is distributed in binary form and is allowed for run only on original PiKVM Switch hardware. + +Modifications are not allowed. + +One day we will publish the source code, but not today. + +===== +Includes other software related under other licenses: +- MIT: TinyUSB - Copyright (c) 2018, hathach (tinyusb.org). +- MIT: Pico-PIO-USB - Copyright (c) 2021 sekigon-gonnoc. +- BSD: Pico-SDK - Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd. +- BSD: FatFS - Copyright (C) 20xx, ChaN, all right reserved. diff --git a/switch/Makefile b/switch/Makefile new file mode 100644 index 000000000..4988126aa --- /dev/null +++ b/switch/Makefile @@ -0,0 +1,8 @@ +all: + @echo "Run 'make install'" + +upload: install +install: + mount `python -m kvmd.apps.swctl bootloader 0` mnt + cp switch.uf2 mnt + umount mnt diff --git a/switch/mnt/README b/switch/mnt/README new file mode 100644 index 000000000..588522aa1 --- /dev/null +++ b/switch/mnt/README @@ -0,0 +1 @@ +This is a mount point for the switch. diff --git a/switch/switch.uf2 b/switch/switch.uf2 new file mode 100644 index 000000000..3a687508c Binary files /dev/null and b/switch/switch.uf2 differ diff --git a/testenv/Dockerfile b/testenv/Dockerfile index 7dedc9ecb..4d455a96a 100644 --- a/testenv/Dockerfile +++ b/testenv/Dockerfile @@ -3,26 +3,27 @@ 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://mirror.rackspace.com/archlinux/$repo/os/$arch' > /etc/pacman.d/mirrorlist \ +RUN echo 'Server = https://fastly.mirror.pkgbuild.com/$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 \ +RUN \ + --mount=type=cache,id=kvmd-pacman-pkg,target=/var/cache/pacman/pkg \ + --mount=type=cache,id=kvmd-pacman-db,target=/var/lib/pacman/sync \ + PACMAN="pacman --noconfirm --ask=4 --needed" \ + && $PACMAN -Syy \ + archlinux-keyring \ + && $PACMAN -S \ glibc \ pacman \ openssl \ - openssl-1.1 \ && pacman-db-upgrade \ - && pacman --noconfirm --ask=4 -Syu \ - && pacman --needed --noconfirm --ask=4 -S \ + && $PACMAN -Syu \ p11-kit \ - archlinux-keyring \ ca-certificates \ ca-certificates-mozilla \ ca-certificates-utils \ - && pacman -Syu --noconfirm --ask=4 \ - && pacman -S --needed --noconfirm --ask=4 \ + && $PACMAN -Syu \ base-devel \ autoconf-archive \ help2man \ @@ -35,17 +36,25 @@ RUN pacman --noconfirm --ask=4 -Syy \ libbsd \ python \ python-pip \ + python-build \ + python-wheel \ python-setuptools \ python-tox \ python-mako \ python-yaml \ + python-ruamel-yaml \ python-aiohttp \ python-aiofiles \ - python-periphery \ + python-async-lru \ python-passlib \ + # See kvmd/crypto.py + # python-bcrypt \ python-pyotp \ python-qrcode \ python-pyserial \ + python-pyusb \ + python-pyudev \ + python-evdev \ python-setproctitle \ python-psutil \ python-netifaces \ @@ -56,6 +65,7 @@ RUN pacman --noconfirm --ask=4 -Syy \ python-pam \ python-pillow \ python-xlib \ + python-mako \ libxkbcommon \ python-hidapi \ python-ldap \ @@ -71,38 +81,46 @@ RUN pacman --noconfirm --ask=4 -Syy \ eslint \ npm \ shellcheck \ - && (pacman -Sc --noconfirm || true) \ - && rm -rf /var/cache/pacman/pkg/* + && : COPY testenv/requirements.txt requirements.txt -RUN pip install --break-system-packages -r requirements.txt +RUN \ + --mount=type=cache,id=kvmd-pip,target=/root/.cache/pip \ + pip install --break-system-packages --root-user-action=ignore \ + -r requirements.txt # https://stackoverflow.com/questions/57534295 WORKDIR /root -RUN npm install htmlhint -g \ - && npm install pug \ - && npm install pug-cli -g \ - && npm install @babel/eslint-parser -g +RUN \ + --mount=type=cache,id=kvmd-npm,target=/root/.npm \ + --mount=type=tmpfs,target=/tmp \ + npm install -g \ + htmlhint \ + pug \ + pug-cli \ + @babel/eslint-parser 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 \ +RUN \ + --mount=type=tmpfs,target=/tmp \ + cd /tmp \ + && git clone --depth=1 https://github.com/pikvm/ustreamer \ && cd ustreamer \ - && make WITH_PYTHON=1 PREFIX=/usr DESTDIR=/ install \ - && cd - \ - && rm -rf ustreamer + && make WITH_PYTHON=1 PREFIX=/usr DESTDIR=/ install RUN mkdir -p \ /etc/kvmd/{nginx,vnc} \ /var/lib/kvmd/msd \ /var/lib/kvmd/pst/data \ + /usr/lib/kvmd \ /opt/vc/bin -COPY testenv/fakes/vcgencmd /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 +CMD ["/bin/bash"] diff --git a/testenv/env.py b/testenv/env.py index 06d00c143..b3cdf34f8 100644 --- a/testenv/env.py +++ b/testenv/env.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/fakes/proc/uptime b/testenv/fakes/proc/uptime new file mode 100644 index 000000000..70e60f35e --- /dev/null +++ b/testenv/fakes/proc/uptime @@ -0,0 +1 @@ +65425.37 370690.99 diff --git a/testenv/linters/eslintrc.js b/testenv/linters/eslintrc.js new file mode 100644 index 000000000..f1923cb78 --- /dev/null +++ b/testenv/linters/eslintrc.js @@ -0,0 +1,57 @@ +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"}, + ], + }, + }, + +]; diff --git a/testenv/linters/eslintrc.yaml b/testenv/linters/eslintrc.yaml deleted file mode 100644 index 902685066..000000000 --- a/testenv/linters/eslintrc.yaml +++ /dev/null @@ -1,36 +0,0 @@ -env: - browser: true - es6: true - -extends: "eslint:recommended" - -parser: "/usr/lib/node_modules/@babel/eslint-parser" -parserOptions: - ecmaVersion: 6 - 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} diff --git a/testenv/linters/flake8.ini b/testenv/linters/flake8.ini index e37c1dfa6..768e0b891 100644 --- a/testenv/linters/flake8.ini +++ b/testenv/linters/flake8.ini @@ -1,8 +1,9 @@ [flake8] inline-quotes = double max-line-length = 160 -ignore = W503, E227, E241, E252, Q003 +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 diff --git a/testenv/linters/mypy.ini b/testenv/linters/mypy.ini index d436fd2c4..3f9ee3cf7 100644 --- a/testenv/linters/mypy.ini +++ b/testenv/linters/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.11 +python_version = 3.14 ignore_missing_imports = true disallow_untyped_defs = true strict_optional = true diff --git a/testenv/linters/pylint.ini b/testenv/linters/pylint.ini index 978a2e431..c5f211663 100644 --- a/testenv/linters/pylint.ini +++ b/testenv/linters/pylint.ini @@ -38,6 +38,8 @@ disable = unspecified-encoding, consider-using-f-string, unnecessary-lambda-assignment, + too-many-positional-arguments, + no-else-continue, # https://github.com/PyCQA/pylint/issues/3882 [CLASSES] @@ -52,13 +54,13 @@ max-line-length = 160 [BASIC] # Good variable names which should always be accepted, separated by a comma -good-names = _, __, x, y, ws +good-names = _, __, x, y, a, b, ws # Regular expression matching correct method names -method-rgx = [a-z_][a-z0-9_]{2,50}$ +method-rgx = [a-z_][a-z0-9_]{1,50}$ # Regular expression matching correct function names -function-rgx = [a-z_][a-z0-9_]{2,50}$ +function-rgx = [a-z_][a-z0-9_]{1,50}$ # Regular expression which should only match correct module level names const-rgx = ([a-zA-Z_][a-zA-Z0-9_]*)$ diff --git a/testenv/linters/vulture-wl.py b/testenv/linters/vulture-wl.py index 6a9477769..c4d83028b 100644 --- a/testenv/linters/vulture-wl.py +++ b/testenv/linters/vulture-wl.py @@ -22,13 +22,18 @@ SpiDev.cshigh SpiDev.max_speed_hz +Pwm.get_period_ns +Pwm.get_polarity +Pwm.set_polarity + _DriveImage.complete _AtxApiPart.switch_power -_UsbKey.arduino_modifier_code +_UsbKey.arduino_mod_code _KeyMapping.web_name +_KeyMapping.evdev_name _KeyMapping.mcu_code _KeyMapping.usb_key _KeyMapping.ps2_key @@ -50,7 +55,48 @@ _Edid.set_product_id _Edid.set_serial _Edid.set_monitor_name +_Edid.set_monitor_serial _Edid.set_audio +Dumper.ignore_aliases +_YamlHandler.Representer +_YamlHandler.preserve_quotes +SequenceNode.flow_style + _auth_server_port_fixture _test_user + +Switch.__x_set_dummies +Switch.__x_set_port_names +Switch.__x_set_atx_cp_delays +Switch.__x_set_atx_cpl_delays +Switch.__x_set_atx_cr_delays +Nak.INVALID_COMMAND +Nak.BUSY +Nak.NO_DOWNLINK +Nak.DOWNLINK_OVERFLOW +UnitFlags.flashing_busy +UnitFlags.has_hpd +StateCache.get_dummies +StateCache.get_port_names +StateCache.get_atx_cp_delays +StateCache.get_atx_cpl_delays +StorageContext.write_edids +StorageContext.write_dummies +StorageContext.write_colors +StorageContext.write_port_names +StorageContext.write_atx_cp_delays +StorageContext.write_atx_cpl_delays +StorageContext.write_atx_cr_delays +StorageContext.read_edids +StorageContext.read_dummies +StorageContext.read_colors +StorageContext.read_port_names +StorageContext.read_atx_cp_delays +StorageContext.read_atx_cpl_delays +StorageContext.read_atx_cr_delays + +RequestUnixCredentials.pid +RequestUnixCredentials.gid + +KvmdClientWs.send_mouse_relative_event diff --git a/testenv/override.d/00-base.yaml b/testenv/override.d/00-base.yaml new file mode 100644 index 000000000..d4b2ee36d --- /dev/null +++ b/testenv/override.d/00-base.yaml @@ -0,0 +1,54 @@ +kvmd: + auth: + usc: + users: [root] + + server: + unix_mode: 0666 + + atx: + device: /dev/kvmd-gpio + + hid: + keyboard: + device: /dev/null + mouse: + device: /dev/null +# absolute_win98_fix: true + mouse_alt: + device: /dev/null + noop: true + + msd: + remount_cmd: /bin/true + +pst: + remount_cmd: /bin/true + +vnc: + keymap: /usr/share/kvmd/keymaps/ru + + auth: + vncauth: + enabled: true + + memsink: + h264: + sink: "" + +otgnet: + commands: + post_start_cmd: + - "/bin/true" + pre_stop_cmd: + - "/bin/true" + +nginx: + http: + port: 8080 + https: + port: 4430 + +janus: + cmd: + - "/bin/true" diff --git a/testenv/override.d/10-gpio.yaml b/testenv/override.d/10-gpio.yaml new file mode 100644 index 000000000..9747c221d --- /dev/null +++ b/testenv/override.d/10-gpio.yaml @@ -0,0 +1,101 @@ +kvmd: + gpio: + drivers: + __gpio__: + device: /dev/kvmd-gpio + __v4_locator__: + type: locator + device: /dev/kvmd-gpio +# relay: +# type: hidrelay +# device: /dev/hidraw0 + cmd1: + type: cmd + cmd: [/bin/sleep, 5] + cmd2: + type: cmd + cmd: [/bin/ls, -l] + + scheme: + __v3_usb_breaker__: + pin: 5 + mode: output + initial: true + pulse: + delay: 0 + + __v4_locator__: + driver: __v4_locator__ + pin: 12 + mode: output + pulse: + delay: 0 + + __v4_const1__: + pin: 6 + mode: output + switch: false + pulse: false + + led1: + pin: 19 + mode: input + + led2: + pin: 16 + mode: input + + button1: + pin: 26 + mode: output + switch: false + + button2: + pin: 20 + mode: output + switch: false + +# relay1: +# pin: 0 +# mode: output +# initial: null +# driver: relay +# +# relay2: +# pin: 1 +# mode: output +# initial: null +# driver: relay +# pulse: +# delay: 2 +# max_delay: 5 + + cmd1: + pin: 0 + mode: output + driver: cmd1 + switch: false + + cmd2: + pin: 0 + mode: output + driver: cmd2 + switch: false + + view: + header: + title: Switches + table: + - ["#Generic GPIO leds"] + - [] + - ["#Test 1:", led1, button1] + - ["#Test 2:", led2, button2|confirm|Testing] + - [] + - ["#HID Relays /dev/hidraw0"] + - [] + - ["#Relay #1:", "relay1|confirm|Boop 0.1"] + - ["#Relay #2:", "relay2|Boop 2.0"] + - [] + - ["#Commands"] + - ["#Cmd #1:", "cmd1|confirm|Run 'sleep 5'"] + - ["#Cmd #2:", "cmd2|Run 'ls -l'"] diff --git a/testenv/platform b/testenv/platform new file mode 100644 index 000000000..223c57dee --- /dev/null +++ b/testenv/platform @@ -0,0 +1,3 @@ +PIKVM_MODEL=test_model +PIKVM_VIDEO=test_video +PIKVM_BOARD=test_board diff --git a/testenv/redirect-to-https.conf b/testenv/redirect-to-https.conf deleted file mode 100644 index 11796e292..000000000 --- a/testenv/redirect-to-https.conf +++ /dev/null @@ -1,3 +0,0 @@ -location / { - return 301 https://$host:4430$request_uri; -} diff --git a/testenv/requirements.txt b/testenv/requirements.txt index b35a712af..d409a94eb 100644 --- a/testenv/requirements.txt +++ b/testenv/requirements.txt @@ -4,3 +4,6 @@ spidev pyrad types-PyYAML types-aiofiles +luma.core>=2.5.2 +luma.oled +gpiod>=2.3 diff --git a/testenv/tests/__init__.py b/testenv/tests/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/testenv/tests/__init__.py +++ b/testenv/tests/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/apps/__init__.py b/testenv/tests/apps/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/testenv/tests/apps/__init__.py +++ b/testenv/tests/apps/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/apps/htpasswd/__init__.py b/testenv/tests/apps/htpasswd/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/testenv/tests/apps/htpasswd/__init__.py +++ b/testenv/tests/apps/htpasswd/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/apps/htpasswd/test_main.py b/testenv/tests/apps/htpasswd/test_main.py index 5e53fc586..73e4019f5 100644 --- a/testenv/tests/apps/htpasswd/test_main.py +++ b/testenv/tests/apps/htpasswd/test_main.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -29,12 +29,12 @@ from typing import Generator from typing import Any -import passlib.apache - import pytest from kvmd.apps.htpasswd import main +from kvmd.crypto import KvmdHtpasswdFile + # ===== def _make_passwd(user: str) -> str: @@ -42,28 +42,33 @@ def _make_passwd(user: str) -> str: @pytest.fixture(name="htpasswd", params=[[], ["admin"], ["admin", "user"]]) -def _htpasswd_fixture(request) -> Generator[passlib.apache.HtpasswdFile, None, None]: # type: ignore +def _htpasswd_fixture(request) -> Generator[KvmdHtpasswdFile, None, None]: # type: ignore (fd, path) = tempfile.mkstemp() os.close(fd) - htpasswd = passlib.apache.HtpasswdFile(path) + htpasswd = KvmdHtpasswdFile(path) for user in request.param: htpasswd.set_password(user, _make_passwd(user)) htpasswd.save() - yield htpasswd - os.remove(path) + try: + yield htpasswd + finally: + 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) +def _run_htpasswd(cmd: list[str], htpasswd_path: str, int_type: str="htpasswd") -> None: + params: dict = {} + if int_type != "htpasswd": # By default + params["type"] = int_type if htpasswd_path: - cmd.append("kvmd/auth/internal/file=" + htpasswd_path) - main(cmd) + params["file"] = htpasswd_path + main( + test_args=cmd, + test_override={"kvmd": {"auth": {"internal": params}}}, + ) # ===== -def test_ok__list(htpasswd: passlib.apache.HtpasswdFile, capsys) -> None: # type: ignore +def test_ok__list(htpasswd: KvmdHtpasswdFile, capsys) -> None: # type: ignore _run_htpasswd(["list"], htpasswd.path) (out, err) = capsys.readouterr() assert len(err) == 0 @@ -71,24 +76,32 @@ def test_ok__list(htpasswd: passlib.apache.HtpasswdFile, capsys) -> None: # typ # ===== -def test_ok__set_change_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore +def test_ok__set_stdin(htpasswd: KvmdHtpasswdFile, 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) + with pytest.raises(SystemExit, match="The user 'new' is not exist"): + _run_htpasswd(["set", "new", "--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 +def test_ok__add_stdin(htpasswd: KvmdHtpasswdFile, 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) + + _run_htpasswd(["add", "new", "--read-stdin"], htpasswd.path) + + with pytest.raises(SystemExit, match="The user 'new' is already exists"): + _run_htpasswd(["add", "new", "--read-stdin"], htpasswd.path) htpasswd.load(force=True) assert htpasswd.check_password("new", " test ") @@ -96,20 +109,24 @@ def test_ok__set_add_stdin(htpasswd: passlib.apache.HtpasswdFile, mocker) -> Non # ===== -def test_ok__set_change_getpass(htpasswd: passlib.apache.HtpasswdFile, mocker) -> None: # type: ignore +def test_ok__set_getpass(htpasswd: KvmdHtpasswdFile, 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) + with pytest.raises(SystemExit, match="The user 'new' is not exist"): + _run_htpasswd(["set", "new"], 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 +def test_fail__set_getpass(htpasswd: KvmdHtpasswdFile, mocker) -> None: # type: ignore old_users = set(htpasswd.users()) if old_users: assert htpasswd.check_password("admin", _make_passwd("admin")) @@ -137,13 +154,15 @@ def fake_getpass(*_: Any, **__: Any) -> str: # ===== -def test_ok__del(htpasswd: passlib.apache.HtpasswdFile) -> None: +def test_ok__del(htpasswd: KvmdHtpasswdFile) -> None: old_users = set(htpasswd.users()) if old_users: assert htpasswd.check_password("admin", _make_passwd("admin")) + _run_htpasswd(["del", "admin"], htpasswd.path) - _run_htpasswd(["del", "admin"], htpasswd.path) + with pytest.raises(SystemExit, match="The user 'admin' is not exist"): + _run_htpasswd(["del", "admin"], htpasswd.path) htpasswd.load(force=True) assert not htpasswd.check_password("admin", _make_passwd("admin")) @@ -152,13 +171,13 @@ def test_ok__del(htpasswd: passlib.apache.HtpasswdFile) -> None: # ===== def test_fail__not_htpasswd() -> None: - with pytest.raises(SystemExit, match="Error: KVMD internal auth not using 'htpasswd'"): - _run_htpasswd(["list"], "", internal_type="http") + with pytest.raises(SystemExit, match="Error: KVMD internal auth does not use 'htpasswd'"): + _run_htpasswd(["list"], "", int_type="http") def test_fail__unknown_plugin() -> None: with pytest.raises(SystemExit, match="ConfigError: Unknown plugin 'auth/foobar'"): - _run_htpasswd(["list"], "", internal_type="foobar") + _run_htpasswd(["list"], "", int_type="foobar") def test_fail__invalid_passwd(mocker, tmpdir) -> None: # type: ignore @@ -166,4 +185,4 @@ def test_fail__invalid_passwd(mocker, tmpdir) -> None: # type: ignore 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) + _run_htpasswd(["add", "admin", "--read-stdin"], path) diff --git a/testenv/tests/apps/kvmd/__init__.py b/testenv/tests/apps/kvmd/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/testenv/tests/apps/kvmd/__init__.py +++ b/testenv/tests/apps/kvmd/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/apps/kvmd/test_auth.py b/testenv/tests/apps/kvmd/test_auth.py index a203e0168..bc1f7315e 100644 --- a/testenv/tests/apps/kvmd/test_auth.py +++ b/testenv/tests/apps/kvmd/test_auth.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -21,44 +21,67 @@ import os +import asyncio +import base64 import contextlib from typing import AsyncGenerator -import passlib.apache +from aiohttp.test_utils import make_mocked_request import pytest +from kvmd.validators import ValidatorError + from kvmd.yamlconf import make_config from kvmd.apps.kvmd.auth import AuthManager +from kvmd.apps.kvmd.api.auth import check_request_auth + +from kvmd.htserver import UnauthorizedError +from kvmd.htserver import ForbiddenError from kvmd.plugins.auth import get_auth_service_class +from kvmd.htserver import HttpExposed + +from kvmd.crypto import KvmdHtpasswdFile + # ===== +_E_AUTH = HttpExposed("GET", "/foo_auth", auth_required=True, allow_usc=True, handler=(lambda: None)) +_E_UNAUTH = HttpExposed("GET", "/bar_unauth", auth_required=True, allow_usc=True, handler=(lambda: None)) +_E_FREE = HttpExposed("GET", "/baz_free", auth_required=False, allow_usc=True, handler=(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() + return make_config({}, {"file": path}, scheme)._unpack() @contextlib.asynccontextmanager async def _get_configured_manager( - internal_path: str, - external_path: str="", - force_internal_users: (list[str] | None)=None, + unauth_paths: list[str], + int_path: str, + ext_path: str="", + force_int_users: (list[str] | None)=None, ) -> AsyncGenerator[AuthManager, None]: manager = AuthManager( enabled=True, + expire=0, + extend=False, + usc_users=[], + usc_groups=[], + unauth_paths=unauth_paths, - internal_type="htpasswd", - internal_kwargs=_make_service_kwargs(internal_path), - force_internal_users=(force_internal_users or []), + int_type="htpasswd", + int_kwargs=_make_service_kwargs(int_path), + force_int_users=(force_int_users or []), - external_type=("htpasswd" if external_path else ""), - external_kwargs=(_make_service_kwargs(external_path) if external_path else {}), + ext_type=("htpasswd" if ext_path else ""), + ext_kwargs=(_make_service_kwargs(ext_path) if ext_path else {}), totp_secret_path="", ) @@ -70,37 +93,170 @@ async def _get_configured_manager( # ===== +@pytest.mark.asyncio +async def test_ok__request(tmpdir) -> None: # type: ignore + path = os.path.abspath(str(tmpdir.join("htpasswd"))) + + htpasswd = KvmdHtpasswdFile(path, new=True) + htpasswd.set_password("admin", "pass") + htpasswd.save() + + async with _get_configured_manager([], path) as manager: + async def check(exposed: HttpExposed, **kwargs) -> None: # type: ignore + await check_request_auth(manager, exposed, make_mocked_request(exposed.method, exposed.path, **kwargs)) + + await check(_E_FREE) + with pytest.raises(UnauthorizedError): + await check(_E_AUTH) + + # === + + with pytest.raises(ForbiddenError): + await check(_E_AUTH, headers={"X-KVMD-User": "admin", "X-KVMD-Passwd": "foo"}) + with pytest.raises(ForbiddenError): + await check(_E_AUTH, headers={"X-KVMD-User": "adminx", "X-KVMD-Passwd": "pass"}) + + await check(_E_AUTH, headers={"X-KVMD-User": "admin", "X-KVMD-Passwd": "pass"}) + + # === + + with pytest.raises(UnauthorizedError): + await check(_E_AUTH, headers={"Cookie": "auth_token="}) + with pytest.raises(ValidatorError): + await check(_E_AUTH, headers={"Cookie": "auth_token=0"}) + with pytest.raises(ForbiddenError): + await check(_E_AUTH, headers={"Cookie": f"auth_token={'0' * 64}"}) + + token = await manager.login("admin", "pass", 0) + assert token + await check(_E_AUTH, headers={"Cookie": f"auth_token={token}"}) + manager.logout(token) + with pytest.raises(ForbiddenError): + await check(_E_AUTH, headers={"Cookie": f"auth_token={token}"}) + + # === + + with pytest.raises(ForbiddenError): + await check(_E_AUTH, headers={"Authorization": "basic " + base64.b64encode(b"admin:foo").decode()}) + with pytest.raises(ForbiddenError): + await check(_E_AUTH, headers={"Authorization": "basic " + base64.b64encode(b"adminx:pass").decode()}) + + await check(_E_AUTH, headers={"Authorization": "basic " + base64.b64encode(b"admin:pass").decode()}) + + +@pytest.mark.asyncio +async def test_ok__expire(tmpdir) -> None: # type: ignore + path = os.path.abspath(str(tmpdir.join("htpasswd"))) + + htpasswd = KvmdHtpasswdFile(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", 3)) is None + assert (await manager.login("admin", "foo", 3)) is None + assert (await manager.login("user", "pass", 3)) is None + + token1 = await manager.login("admin", "pass", 3) + assert isinstance(token1, str) + assert len(token1) == 64 + + token2 = await manager.login("admin", "pass", 3) + 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", 3) + assert isinstance(token3, str) + assert len(token3) == 64 + assert token1 != token3 + assert token2 != token3 + + token4 = await manager.login("admin", "pass", 6) + assert isinstance(token4, str) + assert len(token4) == 64 + assert token1 != token4 + assert token2 != token4 + assert token3 != token4 + + await asyncio.sleep(4) + + assert manager.check(token1) is None + assert manager.check(token2) is None + assert manager.check(token3) is None + assert manager.check(token4) == "admin" + + await asyncio.sleep(3) + + assert manager.check(token1) is None + assert manager.check(token2) is None + assert manager.check(token3) is None + assert manager.check(token4) is None + + @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 = KvmdHtpasswdFile(path, new=True) htpasswd.set_password("admin", "pass") htpasswd.save() - async with _get_configured_manager(path) as manager: + 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 + assert (await manager.login("user", "foo", 0)) is None + assert (await manager.login("admin", "foo", 0)) is None + assert (await manager.login("user", "pass", 0)) is None - token = await manager.login("admin", "pass") - assert isinstance(token, str) - assert len(token) == 64 + token1 = await manager.login("admin", "pass", 0) + assert isinstance(token1, str) + assert len(token1) == 64 - again = await manager.login("admin", "pass") - assert token == again + token2 = await manager.login("admin", "pass", 0) + assert isinstance(token2, str) + assert len(token2) == 64 + assert token1 != token2 - assert manager.check(token) == "admin" - manager.logout(token) - assert manager.check(token) is None + assert manager.check(token1) == "admin" + assert manager.check(token2) == "admin" + assert manager.check("foobar") is None - again = await manager.login("admin", "pass") - assert token != again + 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", 0) + assert isinstance(token3, str) + assert len(token3) == 64 + assert token1 != token3 + assert token2 != token3 @pytest.mark.asyncio @@ -108,30 +264,33 @@ 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 = KvmdHtpasswdFile(path1, new=True) htpasswd1.set_password("admin", "pass1") htpasswd1.set_password("local", "foobar") htpasswd1.save() - htpasswd2 = passlib.apache.HtpasswdFile(path2, new=True) + htpasswd2 = KvmdHtpasswdFile(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: + 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 + assert (await manager.login("local", "foobar", 0)) is None + assert (await manager.login("admin", "pass2", 0)) is None - token = await manager.login("admin", "pass1") + token = await manager.login("admin", "pass1", 0) 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") + token = await manager.login("user", "foobar", 0) assert token is not None assert manager.check(token) == "user" @@ -139,29 +298,58 @@ async def test_ok__external(tmpdir) -> None: # type: ignore 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 = KvmdHtpasswdFile(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, + expire=0, + extend=False, + usc_users=[], + usc_groups=[], + unauth_paths=[], - internal_type="foobar", - internal_kwargs={}, - force_internal_users=[], + int_type="foobar", + int_kwargs={}, + force_int_users=[], - external_type="", - external_kwargs={}, + ext_type="", + ext_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") + await manager.login("admin", "admin", 0) with pytest.raises(AssertionError): manager.logout("xxx") diff --git a/testenv/tests/keyboard/__init__.py b/testenv/tests/keyboard/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/testenv/tests/keyboard/__init__.py +++ b/testenv/tests/keyboard/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/plugins/__init__.py b/testenv/tests/plugins/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/testenv/tests/plugins/__init__.py +++ b/testenv/tests/plugins/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/plugins/auth/__init__.py b/testenv/tests/plugins/auth/__init__.py index 9c44a455d..b45837923 100644 --- a/testenv/tests/plugins/auth/__init__.py +++ b/testenv/tests/plugins/auth/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -35,7 +35,7 @@ @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()) + config = make_config({}, kwargs, service_class.get_plugin_options()) service = service_class(**config._unpack()) try: yield service diff --git a/testenv/tests/plugins/auth/test_forbidden.py b/testenv/tests/plugins/auth/test_forbidden.py new file mode 100644 index 000000000..a6b351a1e --- /dev/null +++ b/testenv/tests/plugins/auth/test_forbidden.py @@ -0,0 +1,38 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import pytest + +from . import get_configured_auth_service + + +# ===== +@pytest.mark.asyncio +async def test_ok__forbidden_service() -> None: # type: ignore + async with get_configured_auth_service("forbidden") as service: + assert not (await service.authorize("user", "foo")) + assert not (await service.authorize("admin", "foo")) + assert not (await service.authorize("user", "pass")) + assert not (await service.authorize("admin", "pass")) + assert not (await service.authorize("admin", "admin")) + assert not (await service.authorize("admin", "")) + assert not (await service.authorize("", "")) diff --git a/testenv/tests/plugins/auth/test_htpasswd.py b/testenv/tests/plugins/auth/test_htpasswd.py index 0e8714d26..398f05d39 100644 --- a/testenv/tests/plugins/auth/test_htpasswd.py +++ b/testenv/tests/plugins/auth/test_htpasswd.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,10 +22,10 @@ import os -import passlib.apache - import pytest +from kvmd.crypto import KvmdHtpasswdFile + from . import get_configured_auth_service @@ -34,7 +34,7 @@ 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 = KvmdHtpasswdFile(path, new=True) htpasswd.set_password("admin", "pass") htpasswd.save() diff --git a/testenv/tests/plugins/auth/test_http.py b/testenv/tests/plugins/auth/test_http.py index 166998aae..252ad85b8 100644 --- a/testenv/tests/plugins/auth/test_http.py +++ b/testenv/tests/plugins/auth/test_http.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -32,10 +32,10 @@ # ===== -async def _handle_auth(request: aiohttp.web.BaseRequest) -> aiohttp.web.Response: +async def _handle_auth(req: aiohttp.web.BaseRequest) -> aiohttp.web.Response: status = 400 - if request.method == "POST": - credentials = (await request.json()) + 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) diff --git a/testenv/tests/plugins/auth/test_pam.py b/testenv/tests/plugins/auth/test_pam.py index 9c327c561..26bf11be9 100644 --- a/testenv/tests/plugins/auth/test_pam.py +++ b/testenv/tests/plugins/auth/test_pam.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/test_aiomulti.py b/testenv/tests/test_aiomulti.py new file mode 100644 index 000000000..13adc9991 --- /dev/null +++ b/testenv/tests/test_aiomulti.py @@ -0,0 +1,67 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +import signal +import time + +import pytest + +from kvmd.aiomulti import AioMpProcess + + +# ===== +def _target(a: int, b: str) -> None: + assert a == 1 + assert b == "foo" + while True: + time.sleep(1) + + +# ===== +@pytest.mark.asyncio +async def test_ok__sigterm_join() -> None: + proc = AioMpProcess("test", _target, (1, "foo")) + assert not proc.is_alive() + proc.start() + assert proc.is_alive() + assert (await proc.async_join(0.1)) + assert (await proc.async_join(1)) + proc.send_sigterm() + assert not (await proc.async_join(30)) + assert not (await proc.async_join(1)) + assert not (await proc.async_join()) + assert proc.exitcode == -int(signal.SIGTERM) + + +@pytest.mark.asyncio +async def test_ok__sigkill_join() -> None: + proc = AioMpProcess("test", _target, (1, "foo")) + assert not proc.is_alive() + proc.start() + assert proc.is_alive() + assert (await proc.async_join(0.1)) + assert (await proc.async_join(1)) + proc.sendpg_sigkill() + assert not (await proc.async_join(30)) + assert not (await proc.async_join(1)) + assert not (await proc.async_join()) + assert proc.exitcode == -int(signal.SIGKILL) diff --git a/testenv/tests/test_aiotools.py b/testenv/tests/test_aiotools.py index 3a80c925a..8bb0cceaa 100644 --- a/testenv/tests/test_aiotools.py +++ b/testenv/tests/test_aiotools.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -40,14 +40,14 @@ async def test_ok__region__access_one() -> None: async def func() -> None: assert not region.is_busy() - async with region: + with region: assert region.is_busy() assert not region.is_busy() await func() assert not region.is_busy() - await region.exit() + region.exit() assert not region.is_busy() @@ -57,16 +57,16 @@ async def test_fail__region__access_one() -> None: async def func() -> None: assert not region.is_busy() - async with region: + with region: assert region.is_busy() - await region.enter() + region.enter() assert not region.is_busy() with pytest.raises(RegionIsBusyError): await func() assert not region.is_busy() - await region.exit() + region.exit() assert not region.is_busy() @@ -76,21 +76,21 @@ async def test_ok__region__access_two() -> None: region = AioExclusiveRegion(RegionIsBusyError) async def func1() -> None: - async with region: + with region: await asyncio.sleep(1) print("done func1()") async def func2() -> None: await asyncio.sleep(2) print("waiking up func2()") - async with region: + with region: await asyncio.sleep(1) print("done func2()") await asyncio.gather(func1(), func2()) assert not region.is_busy() - await region.exit() + region.exit() assert not region.is_busy() @@ -99,22 +99,22 @@ async def test_fail__region__access_two() -> None: region = AioExclusiveRegion(RegionIsBusyError) async def func1() -> None: - async with region: + with region: await asyncio.sleep(2) print("done func1()") async def func2() -> None: await asyncio.sleep(1) - async with region: + 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]) == RegionIsBusyError # pylint: disable=unidiomatic-typecheck + assert type(results[1]) is RegionIsBusyError # pylint: disable=unidiomatic-typecheck assert not region.is_busy() - await region.exit() + region.exit() assert not region.is_busy() diff --git a/testenv/tests/test_logging.py b/testenv/tests/test_logging.py index feff06688..b6f52a38d 100644 --- a/testenv/tests/test_logging.py +++ b/testenv/tests/test_logging.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/test_yamlconf.py b/testenv/tests/test_yamlconf.py index 905f0c5f6..70f9593fd 100644 --- a/testenv/tests/test_yamlconf.py +++ b/testenv/tests/test_yamlconf.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/validators/__init__.py b/testenv/tests/validators/__init__.py index 6ed9261b1..8d45fdfd3 100644 --- a/testenv/tests/validators/__init__.py +++ b/testenv/tests/validators/__init__.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tests/validators/test_auth.py b/testenv/tests/validators/test_auth.py index 7b1eee0fa..e02541b46 100644 --- a/testenv/tests/validators/test_auth.py +++ b/testenv/tests/validators/test_auth.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -28,7 +28,9 @@ 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_expire from kvmd.validators.auth import valid_auth_token +from kvmd.validators.auth import valid_login_redirect # ===== @@ -109,6 +111,20 @@ def test_fail__valid_passwd(arg: Any) -> None: print(valid_passwd(arg)) +# ===== +@pytest.mark.parametrize("arg", ["0 ", 0, 1, 13]) +def test_ok__valid_expire(arg: Any) -> None: + value = valid_expire(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_expire(arg: Any) -> None: + with pytest.raises(ValidatorError): + print(valid_expire(arg)) + + # ===== @pytest.mark.parametrize("arg", [ ("0" * 64) + " ", @@ -128,3 +144,33 @@ def test_ok__valid_auth_token(arg: Any) -> None: def test_fail__valid_auth_token(arg: Any) -> None: with pytest.raises(ValidatorError): print(valid_auth_token(arg)) + + +# ===== +@pytest.mark.parametrize("arg", [ + "", + "/", + "/kvm", + "/kvm/", + "/ ", +]) +def test_ok__valid_login_redirect(arg: Any) -> None: + assert valid_login_redirect(arg) == arg.strip() + + +@pytest.mark.parametrize("arg", [ + "/test", + "test", + "kvm/" + "kvm//" + "//kvm", + "//kvm//", + "kvm", + "//", + "..", + "http://google.com", + None, +]) +def test_fail__valid_login_redirect(arg: Any) -> None: + with pytest.raises(ValidatorError): + print(valid_login_redirect(arg)) diff --git a/testenv/tests/validators/test_basic.py b/testenv/tests/validators/test_basic.py index fe7b7ff79..d88accc34 100644 --- a/testenv/tests/validators/test_basic.py +++ b/testenv/tests/validators/test_basic.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -34,6 +34,13 @@ from kvmd.validators.basic import valid_string_list +# ===== +def _to_int(arg: Any) -> int: + if isinstance(arg, str) and arg.strip().startswith(("0x", "0X")): + arg = int(arg.strip()[2:], 16) + return int(str(arg).strip()) + + # ===== @pytest.mark.parametrize("arg, retval", [ ("1", True), @@ -60,34 +67,34 @@ def test_fail__valid_bool(arg: Any) -> None: # ===== -@pytest.mark.parametrize("arg", ["1 ", "-1", 1, -1, 0, 100500]) +@pytest.mark.parametrize("arg", ["1 ", "-1", 1, -1, 0, 100500, " 0xff"]) def test_ok__valid_number(arg: Any) -> None: - assert valid_number(arg) == int(str(arg).strip()) + assert valid_number(arg) == _to_int(arg) -@pytest.mark.parametrize("arg", ["test", "", None, "1x", 100500.0]) +@pytest.mark.parametrize("arg", ["test", "", None, "1x", 100500.0, "ff"]) 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 "]) +@pytest.mark.parametrize("arg", [-5, 0, 5, "-5 ", "0 ", "5 ", " 0x05"]) def test_ok__valid_number__min_max(arg: Any) -> None: - assert valid_number(arg, -5, 5) == int(str(arg).strip()) + assert valid_number(arg, -5, 5) == _to_int(arg) -@pytest.mark.parametrize("arg", ["test", "", None, -6, 6, "-6 ", "6 "]) +@pytest.mark.parametrize("arg", ["test", "", None, -6, 6, "-6 ", "6 ", "0x06"]) 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 "]) +@pytest.mark.parametrize("arg", [0, 1, 5, "5 ", " 0x05"]) def test_ok__valid_int_f0(arg: Any) -> None: value = valid_int_f0(arg) - assert type(value) == int # pylint: disable=unidiomatic-typecheck - assert value == int(str(arg).strip()) + assert type(value) is int # pylint: disable=unidiomatic-typecheck + assert value == _to_int(arg) @pytest.mark.parametrize("arg", ["test", "", None, -6, "-6 ", "5.0"]) @@ -97,14 +104,14 @@ def test_fail__valid_int_f0(arg: Any) -> None: # ===== -@pytest.mark.parametrize("arg", [1, 5, "5 "]) +@pytest.mark.parametrize("arg", [1, 5, "5 ", " 0x05"]) def test_ok__valid_int_f1(arg: Any) -> None: value = valid_int_f1(arg) - assert type(value) == int # pylint: disable=unidiomatic-typecheck - assert value == int(str(arg).strip()) + assert type(value) is int # pylint: disable=unidiomatic-typecheck + assert value == _to_int(arg) -@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6 ", 0, "0 ", "5.0"]) +@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6 ", 0, "0 ", "5.0", "0x0"]) def test_fail__valid_int_f1(arg: Any) -> None: with pytest.raises(ValidatorError): print(valid_int_f1(arg)) @@ -114,7 +121,7 @@ def test_fail__valid_int_f1(arg: Any) -> None: @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) == float # pylint: disable=unidiomatic-typecheck + assert type(value) is float # pylint: disable=unidiomatic-typecheck assert value == float(str(arg).strip()) @@ -128,7 +135,7 @@ def test_fail__valid_float_f0(arg: Any) -> None: @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) == float # pylint: disable=unidiomatic-typecheck + assert type(value) is float # pylint: disable=unidiomatic-typecheck assert value == float(str(arg).strip()) diff --git a/testenv/tests/validators/test_hid.py b/testenv/tests/validators/test_hid.py index 24f11df4a..f3fc32e13 100644 --- a/testenv/tests/validators/test_hid.py +++ b/testenv/tests/validators/test_hid.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -24,7 +24,7 @@ import pytest -from kvmd.keyboard.mappings import KEYMAP +from kvmd.keyboard.mappings import WEB_TO_EVDEV from kvmd.validators import ValidatorError from kvmd.validators.hid import valid_hid_key @@ -35,7 +35,7 @@ # ===== def test_ok__valid_hid_key() -> None: - for key in KEYMAP: + for key in WEB_TO_EVDEV: print(valid_hid_key(key)) print(valid_hid_key(key + " ")) diff --git a/testenv/tests/validators/test_hw.py b/testenv/tests/validators/test_hw.py index 902ba2e5a..b73af65ea 100644 --- a/testenv/tests/validators/test_hw.py +++ b/testenv/tests/validators/test_hw.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -37,7 +37,7 @@ @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) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) @@ -51,7 +51,7 @@ def test_fail__valid_tty_speed(arg: Any) -> None: @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) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) @@ -65,7 +65,7 @@ def test_fail__valid_gpio_pin(arg: Any) -> None: @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) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) @@ -110,7 +110,7 @@ def test_fail__valid_otg_gadget(arg: Any) -> None: @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) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) diff --git a/testenv/tests/validators/test_kvm.py b/testenv/tests/validators/test_kvm.py index 1a0e911d5..0942457d5 100644 --- a/testenv/tests/validators/test_kvm.py +++ b/testenv/tests/validators/test_kvm.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -84,6 +84,16 @@ def test_ok__valid_msd_image_name(arg: Any, retval: str) -> None: assert valid_msd_image_name(arg) == retval +@pytest.mark.parametrize("arg, retval", [ + ("archlinux-2018.07.01-i686.iso", "archlinux-2018.07.01-i686.iso"), + ("/bar.iso/", "bar.iso"), + (" ", ""), + ("", ""), +]) +def test_ok__valid_msd_image_name__allow_eject(arg: Any, retval: str) -> None: + assert valid_msd_image_name(arg, allow_eject=True) == retval + + @pytest.mark.parametrize("arg", [ ".", "..", @@ -116,7 +126,7 @@ def test_fail__valid_msd_image_name(arg: Any) -> None: @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) == set # pylint: disable=unidiomatic-typecheck + assert type(value) is set # pylint: disable=unidiomatic-typecheck assert value == set(filter(None, map(str.strip, str(arg).split(",")))) @@ -130,7 +140,7 @@ def test_fail__valid_info_fields(arg: Any) -> None: @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) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) @@ -144,7 +154,7 @@ def test_fail__valid_log_seek(arg: Any) -> None: @pytest.mark.parametrize("arg", ["1 ", 20, 100]) def test_ok__valid_stream_quality(arg: Any) -> None: value = valid_stream_quality(arg) - assert type(value) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) @@ -158,7 +168,7 @@ def test_fail__valid_stream_quality(arg: Any) -> None: @pytest.mark.parametrize("arg", ["1 ", 120]) def test_ok__valid_stream_fps(arg: Any) -> None: value = valid_stream_fps(arg) - assert type(value) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) @@ -172,7 +182,7 @@ def test_fail__valid_stream_fps(arg: Any) -> None: @pytest.mark.parametrize("arg", ["1280x720 ", "1x1"]) def test_ok__valid_stream_resolution(arg: Any) -> None: value = valid_stream_resolution(arg) - assert type(value) == str # pylint: disable=unidiomatic-typecheck + assert type(value) is str # pylint: disable=unidiomatic-typecheck assert value == str(arg).strip() @@ -186,7 +196,7 @@ def test_fail__valid_stream_resolution(arg: Any) -> None: @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) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) @@ -200,7 +210,7 @@ def test_fail__valid_stream_h264_bitrate(arg: Any) -> None: @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) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) diff --git a/testenv/tests/validators/test_net.py b/testenv/tests/validators/test_net.py index f21a4cbc4..a67fbefff 100644 --- a/testenv/tests/validators/test_net.py +++ b/testenv/tests/validators/test_net.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -147,7 +147,7 @@ def test_fail__valid_rfc_host(arg: Any) -> None: @pytest.mark.parametrize("arg", ["0 ", 0, "22", 443, 65535]) def test_ok__valid_port(arg: Any) -> None: value = valid_port(arg) - assert type(value) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(arg).strip()) diff --git a/testenv/tests/validators/test_os.py b/testenv/tests/validators/test_os.py index e744e187a..6f9efee6e 100644 --- a/testenv/tests/validators/test_os.py +++ b/testenv/tests/validators/test_os.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -124,7 +124,7 @@ def test_fail__valid_printable_filename(arg: Any) -> None: @pytest.mark.parametrize("arg", [0, 5, "1000"]) def test_ok__valid_unix_mode(arg: Any) -> None: value = valid_unix_mode(arg) - assert type(value) == int # pylint: disable=unidiomatic-typecheck + assert type(value) is int # pylint: disable=unidiomatic-typecheck assert value == int(str(value).strip()) @@ -140,18 +140,15 @@ def test_fail__valid_unix_mode(arg: Any) -> None: (["/bin/true", 1, 2, 3], ["/bin/true", "1", "2", "3"]), ("/bin/true, 1, 2, 3,", ["/bin/true", "1", "2", "3"]), ("/bin/true", ["/bin/true"]), + ("/bin/blahblahblah", ["/bin/blahblahblah"]), ]) def test_ok__valid_command(arg: Any, retval: list[str]) -> None: assert valid_command(arg) == retval @pytest.mark.parametrize("arg", [ - ["/bin/blahblahblah"], - ["/bin/blahblahblah", 1, 2, 3], [" "], [], - "/bin/blahblahblah, 1, 2, 3,", - "/bin/blahblahblah", " ", ]) def test_fail__valid_command(arg: Any) -> None: diff --git a/testenv/tests/validators/test_switch.py b/testenv/tests/validators/test_switch.py new file mode 100644 index 000000000..01dd20624 --- /dev/null +++ b/testenv/tests/validators/test_switch.py @@ -0,0 +1,183 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from typing import Any + +import pytest + +from kvmd.validators import ValidatorError +from kvmd.validators.switch import valid_switch_port_name +from kvmd.validators.switch import valid_switch_edid_id +from kvmd.validators.switch import valid_switch_edid_data +from kvmd.validators.switch import valid_switch_color +from kvmd.validators.switch import valid_switch_atx_click_delay + + +# ===== +@pytest.mark.parametrize("arg, retval", [ + ("\tMac OS Host #1/..", "Mac OS Host #1/.."), + ("\t", ""), + ("", ""), +]) +def test_ok__valid_msd_image_name(arg: Any, retval: str) -> None: + assert valid_switch_port_name(arg) == retval + + +@pytest.mark.parametrize("arg", [None]) +def test_fail__valid_msd_image_name(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_port_name(arg) + + +# ===== +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-446655440000", + " 00000000-0000-0000-C000-000000000046 ", + " 00000000-0000-0000-0000-000000000000 ", +]) +def test_ok__valid_switch_edid_id__no_default(arg: Any) -> None: + assert valid_switch_edid_id(arg, allow_default=False) == arg.strip().lower() # type: ignore + + +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-44665544", + "ffffuuuu-0000-0000-C000-000000000046", + "default", + "", + None, +]) +def test_fail__valid_switch_edid_id__no_default(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_edid_id(arg, allow_default=False) + + +# ===== +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-446655440000", + " 00000000-0000-0000-C000-000000000046 ", + " 00000000-0000-0000-0000-000000000000 ", + " Default", +]) +def test_ok__valid_switch_edid_id__allowed_default(arg: Any) -> None: + assert valid_switch_edid_id(arg, allow_default=True) == arg.strip().lower() # type: ignore + + +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-44665544", + "ffffuuuu-0000-0000-C000-000000000046", + "", + None, +]) +def test_fail__valid_switch_edid_id__allowed_default(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_edid_id(arg, allow_default=True) + + +# ===== +@pytest.mark.parametrize("arg", [ + "f" * 256, + "0" * 256, + "1a" * 128, + "f" * 512, + "0" * 512, + "1a" * 256, +]) +def test_ok__valid_switch_edid_data(arg: Any) -> None: + assert valid_switch_edid_data(arg) == arg.upper() # type: ignore + + +@pytest.mark.parametrize("arg", [ + "f" * 511, + "0" * 511, + "1a" * 255, + "F" * 513, + "0" * 513, + "1A" * 257, + "", + None, +]) +def test_fail__valid_switch_edid_data(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_edid_data(arg) + + +# ===== +@pytest.mark.parametrize("arg, retval", [ + ("000000:00:0000", "000000:00:0000"), + (" 0f0f0f:0f:0f0f ", "0F0F0F:0F:0F0F"), +]) +def test_ok__valid_switch_color__no_default(arg: Any, retval: str) -> None: + assert valid_switch_color(arg, allow_default=False) == retval + + +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-44665544", + "ffffuuuu-0000-0000-C000-000000000046", + "000000:00:000000000:00:000G", + "000000:00:000", + "000000:00:000G", + "default", + " Default", + "", + None, +]) +def test_fail__valid_switch_color__no_default(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_color(arg, allow_default=False) + + +# ===== +@pytest.mark.parametrize("arg, retval", [ + ("000000:00:0000", "000000:00:0000"), + (" 0f0f0f:0f:0f0f ", "0F0F0F:0F:0F0F"), + (" Default", "default"), +]) +def test_ok__valid_switch_color__allow_default(arg: Any, retval: str) -> None: + assert valid_switch_color(arg, allow_default=True) == retval + + +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-44665544", + "ffffuuuu-0000-0000-C000-000000000046", + "000000:00:000000000:00:000G", + "000000:00:000", + "000000:00:000G", + "", + None, +]) +def test_fail__valid_switch_color__allow_default(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_color(arg, allow_default=True) + + +# ===== +@pytest.mark.parametrize("arg", [0, 1, 5, "5 ", "5.0 ", " 10"]) +def test_ok__valid_switch_atx_click_delay(arg: Any) -> None: + value = valid_switch_atx_click_delay(arg) + assert type(value) is float # pylint: disable=unidiomatic-typecheck + assert value == float(str(arg).strip()) + + +@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6", "10.1"]) +def test_fail__valid_switch_atx_click_delay(arg: Any) -> None: + with pytest.raises(ValidatorError): + print(valid_switch_atx_click_delay(arg)) diff --git a/testenv/tests/validators/test_ugpio.py b/testenv/tests/validators/test_ugpio.py index d6369cec1..be29ea68f 100644 --- a/testenv/tests/validators/test_ugpio.py +++ b/testenv/tests/validators/test_ugpio.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -72,7 +72,7 @@ def test_fail__valid_ugpio_item(validator: Callable[[Any], str], arg: Any) -> No @pytest.mark.parametrize("arg", ["foo", " bar", " baz "]) def test_ok__valid_ugpio_driver_variants(arg: Any) -> None: value = valid_ugpio_driver(arg, set(["foo", "bar", "baz"])) - assert type(value) == str # pylint: disable=unidiomatic-typecheck + assert type(value) is str # pylint: disable=unidiomatic-typecheck assert value == str(arg).strip() diff --git a/testenv/tests/yamlconf/test_merger.py b/testenv/tests/yamlconf/test_merger.py index 211806051..0f876ce96 100644 --- a/testenv/tests/yamlconf/test_merger.py +++ b/testenv/tests/yamlconf/test_merger.py @@ -2,7 +2,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # diff --git a/testenv/tox.ini b/testenv/tox.ini index c0efad82a..59f7b7f59 100644 --- a/testenv/tox.ini +++ b/testenv/tox.ini @@ -3,7 +3,7 @@ envlist = flake8, pylint, mypy, vulture, pytest, eslint, htmlhint, shellcheck skipsdist = true [testenv] -basepython = python3.11 +basepython = python3.14 sitepackages = true changedir = /src @@ -11,7 +11,7 @@ changedir = /src allowlist_externals = bash commands = bash -c 'flake8 --config=testenv/linters/flake8.ini kvmd testenv/tests *.py' deps = - flake8==5.0.4 + flake8 flake8-quotes -rrequirements.txt @@ -54,7 +54,7 @@ deps = [testenv:eslint] allowlist_externals = eslint -commands = eslint --cache-location=/tmp --config=testenv/linters/eslintrc.yaml --color --ext .js web/share/js +commands = eslint --cache-location=/tmp --config=testenv/linters/eslintrc.js --color web/share/js [testenv:htmlhint] allowlist_externals = htmlhint @@ -62,4 +62,4 @@ commands = htmlhint --config=testenv/linters/htmlhint.json web/*.html web/*/*.ht [testenv:shellcheck] allowlist_externals = bash -commands = bash -c 'shellcheck --color=always kvmd.install scripts/*' +commands = bash -c 'shellcheck --color=always kvmd.install platform.install scripts/*' diff --git a/testenv/v2-hdmi-rpi4.override.yaml b/testenv/v2-hdmi-rpi4.override.yaml index be93d4554..1a05705da 100644 --- a/testenv/v2-hdmi-rpi4.override.yaml +++ b/testenv/v2-hdmi-rpi4.override.yaml @@ -1,23 +1,4 @@ kvmd: - server: - unix_mode: 0666 - - atx: - device: /dev/kvmd-gpio - - hid: - keyboard: - device: /dev/null - mouse: - device: /dev/null -# absolute_win98_fix: true -# mouse_alt: -# device: /dev/null - noop: true - - msd: - remount_cmd: /bin/true - streamer: desired_fps: 30 max_fps: 40 @@ -35,131 +16,5 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - - gpio: - drivers: - __gpio__: - device: /dev/kvmd-gpio - __v4_locator__: - type: locator - device: /dev/kvmd-gpio - relay: - type: hidrelay - device: /dev/hidraw0 - cmd1: - type: cmd - cmd: [/bin/sleep, 5] - cmd2: - type: cmd - cmd: [/bin/ls, -l] - - scheme: - __v3_usb_breaker__: - pin: 5 - mode: output - initial: true - pulse: - delay: 0 - - __v4_locator__: - driver: __v4_locator__ - pin: 12 - mode: output - pulse: - delay: 0 - - __v4_const1__: - pin: 6 - mode: output - switch: false - pulse: false - - led1: - pin: 19 - mode: input - - led2: - pin: 16 - mode: input - - button1: - pin: 26 - mode: output - switch: false - - button2: - pin: 20 - mode: output - switch: false - - relay1: - pin: 0 - mode: output - initial: null - driver: relay - - relay2: - pin: 1 - mode: output - initial: null - driver: relay - pulse: - delay: 2 - max_delay: 5 - - cmd1: - pin: 0 - mode: output - driver: cmd1 - switch: false - - cmd2: - pin: 0 - mode: output - driver: cmd2 - switch: false - - view: - header: - title: Switches - table: - - ["#Generic GPIO leds"] - - [] - - ["#Test 1:", led1, button1] - - ["#Test 2:", led2, button2|confirm|Testing] - - [] - - ["#HID Relays /dev/hidraw0"] - - [] - - ["#Relay #1:", "relay1|confirm|Boop 0.1"] - - ["#Relay #2:", "relay2|Boop 2.0"] - - [] - - ["#Commands"] - - ["#Cmd #1:", "cmd1|confirm|Run 'sleep 5'"] - - ["#Cmd #2:", "cmd2|Run 'ls -l'"] - -pst: - remount_cmd: /bin/true - -vnc: - keymap: /usr/share/kvmd/keymaps/ru - - auth: - vncauth: - enabled: true - - memsink: - jpeg: - sink: "" - h264: - sink: "" - -otgnet: - commands: - post_start_cmd: - - "/bin/true" - pre_stop_cmd: - - "/bin/true" - -janus: - cmd: - - "/bin/true" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" diff --git a/testenv/v2-hdmiusb-rpi4.override.yaml b/testenv/v2-hdmiusb-rpi4.override.yaml index f1d570897..f73bf951b 100644 --- a/testenv/v2-hdmiusb-rpi4.override.yaml +++ b/testenv/v2-hdmiusb-rpi4.override.yaml @@ -1,20 +1,4 @@ kvmd: - server: - unix_mode: 0666 - - atx: - device: /dev/kvmd-gpio - - hid: - keyboard: - device: /dev/null - mouse: - device: /dev/null - noop: true - - msd: - remount_cmd: /bin/true - streamer: cmd: - "/usr/bin/ustreamer" @@ -32,30 +16,3 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" - -pst: - remount_cmd: /bin/true - -vnc: - keymap: /usr/share/kvmd/keymaps/ru - - auth: - vncauth: - enabled: true - - memsink: - jpeg: - sink: "" - h264: - sink: "" - -otgnet: - commands: - post_start_cmd: - - "/bin/true" - pre_stop_cmd: - - "/bin/true" - -janus: - cmd: - - "/bin/true" diff --git a/web/base.pug b/web/base.pug index 4c2419fe4..eaa442551 100644 --- a/web/base.pug +++ b/web/base.pug @@ -5,7 +5,7 @@ doctype html # # # KVMD - The main PiKVM daemon. # # # - # Copyright (C) 2018-2023 Maxim Devaev # + # Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -22,37 +22,49 @@ doctype html # # ============================================================================== -- var css_dir = "/share/css" -- var js_dir = "/share/js" -- var svg_dir = "/share/svg" -- var png_dir = "/share/png" +- + var root_prefix = "./" + + title = "" + main_js = "" + body_class = "" + css_list = [] -- var title = "" -- var main_js = "" -- var body_class = "" -- var css_list = ["vars", "main"] block vars + +block _vars_dynamic + - + share_dir = `${root_prefix}share` + css_dir = `${share_dir}/css` + js_dir = `${share_dir}/js` + svg_dir = `${share_dir}/svg` + pic_dir = `${share_dir}/pic` + + html(lang="en") head meta(charset="utf-8") title #{title} - link(rel="apple-touch-icon" sizes="180x180" href="/share/apple-touch-icon.png") - link(rel="icon" type="image/png" sizes="32x32" href="/share/favicon-32x32.png") - link(rel="icon" type="image/png" sizes="16x16" href="/share/favicon-16x16.png") - link(rel="manifest" href="/share/site.webmanifest") - link(rel="mask-icon" href="/share/safari-pinned-tab.svg" color="#5bbad5") + link(rel="apple-touch-icon" sizes="180x180" href=`${share_dir}/apple-touch-icon.png`) + link(rel="icon" type="image/png" sizes="32x32" href=`${share_dir}/favicon-32x32.png`) + link(rel="icon" type="image/png" sizes="16x16" href=`${share_dir}/favicon-16x16.png`) + link(rel="manifest" href=`${share_dir}/site.webmanifest`) + link(rel="mask-icon" href=`${share_dir}/safari-pinned-tab.svg` color="#5bbad5") meta(name="msapplication-TileColor" content="#2b5797") meta(name="theme-color" content="#ffffff") - each name in css_list + each name in ["vars", "main"].concat(css_list).concat(["user"]) link(rel="stylesheet" href=`${css_dir}/${name}.css`) - link(rel="stylesheet" href=`${css_dir}/user.css`) + + block head if main_js script(type="module") + | import {setRootPrefix} from "#{js_dir}/vars.js"; + | setRootPrefix("#{root_prefix}"); | import {main} from "#{js_dir}/#{main_js}.js"; | main(); diff --git a/web/favicon.ico b/web/favicon.ico index 26af37a3e..d3b2b52bb 100644 Binary files a/web/favicon.ico and b/web/favicon.ico differ diff --git a/web/index.html b/web/index.html index b1a9ec3b2..f4c93d756 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -26,21 +26,23 @@ PiKVM Index - - - - - + + + + + - - - - - - - - @@ -49,14 +51,14 @@
- + @@ -65,8 +67,8 @@
- + - +
The Open Source IP-KVMThe Open Source KVM over IP
- + +
- Server:Name:

diff --git a/web/index.pug b/web/index.pug index 87933065f..487c5663e 100644 --- a/web/index.pug +++ b/web/index.pug @@ -1,44 +1,48 @@ extends start.pug + append vars - - title = "PiKVM Index" - - main_js = "index/main" - - css_list = css_list.concat(["window", "modal", "index/index"]) + - + title = "PiKVM Index" + main_js = "index/main" + css_list.push("window", "modal", "index/index") + block start table tr - td(class="logo") - img(class="svg-gray" src=`${svg_dir}/logo.svg` alt="PiKVM" height="40") + td.logo + a(href="https://pikvm.org" target="_blank") + img.svg-gray(src=`${svg_dir}/logo.svg` alt="PiKVM" height="40") td table - tr #[td(colspan="2" class="title") The Open Source IP-KVM] + tr #[td.title(colspan="2") The Open Source KVM over IP] tr - td(colspan="2" class="copyright") - | Copyright © 2018-2023 #[a(target="_blank" href="mailto:mdevaev@gmail.com") Maxim Devaev] + td.copyright(colspan="2") + | Copyright © 2018-2025 #[a(target="_blank" href="mailto:mdevaev@gmail.com") Maxim Devaev] hr table td(class="server") - td Server: - td #[a(id="kvmd-meta-server-host" target="_blank" href="/api/info")] + td Name: + td #[a#kvmd-node-host(target="_blank" href=`${root_prefix}api/info?legacy=0`)] hr - div(id="apps-box") + #apps-box h4 Loading ... - div(id="app-keyboard-warning") + #app-keyboard-warning hr - p(class="text") + p.text | Please note that when you are working with a KVM session or another application that captures the keyboard, | you can't use some keyboard shortcuts such as Ctrl+Alt+Del (which will be caught by your OS) or Ctrl+W (caught by your browser). - p(class="text") + p.text | To override this limitation you can use #[a(target="_blank" href="https://google.com/chrome") Google Chrome] | or #[a(target="_blank" href="https://chromium.org/Home") Chromium] in application mode. - div(id="app-text" class="code") + .code#app-text hr - p(class="text credits") + p.text.credits a(target="_blank" href="https://pikvm.org") PiKVM Project |   |   a(target="_blank" href="https://docs.pikvm.org") Documentation diff --git a/web/ipmi/index.html b/web/ipmi/index.html index c181e055e..20834cd6d 100644 --- a/web/ipmi/index.html +++ b/web/ipmi/index.html @@ -4,7 +4,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -26,24 +26,26 @@ PiKVM IPMI Info - - - - - + + + + + - - - - -
-
  ←   [ PiKVM Index ] +
  ←   [ PiKVM Index ]

This PiKVM device has running kvmd-ipmi daemon and provides IPMI 2.0 interface for some basic BMC operations like on/off/reset the server. diff --git a/web/ipmi/index.pug b/web/ipmi/index.pug index e567fe383..381ae63e8 100644 --- a/web/ipmi/index.pug +++ b/web/ipmi/index.pug @@ -1,20 +1,24 @@ extends ../start.pug + append vars - - title = "PiKVM IPMI Info" - - main_js = "ipmi/main" - - index_link = true + - + root_prefix = "../" + title = "PiKVM IPMI Info" + main_js = "ipmi/main" + index_link = true + block start - p(class="text") + p.text | This PiKVM device has running #[b kvmd-ipmi] daemon and provides IPMI 2.0 interface for some basic | BMC operations like on/off/reset the server. - p(class="text") + p.text | #[b WARNING!] We strongly don't recommend you to use IPMI in untrusted networks because | this protocol is completely unsafe by design. In short, the authentication process for IPMI mandates | that the server send a salted SHA1 or MD5 hash of the requested user's password to the client, | prior to the client authenticating. - p(class="text") + p.text | #[b NEVER] use the same passwords for KVMD and IPMI users. And even better not to use IPMI. | Instead, you can directly use KVMD API via curl. Here some examples: - div(id="ipmi-text" class="code" style="max-height:200px") + .code#ipmi-text(style="max-height:200px") diff --git a/web/kvm/index.html b/web/kvm/index.html index 3ee7e9487..107097197 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -4,7 +4,7 @@ # # # KVMD - The main PiKVM daemon. # # # -# Copyright (C) 2018-2023 Maxim Devaev # +# Copyright (C) 2018-2024 Maxim Devaev # # # # 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 # @@ -26,46 +26,53 @@ PiKVM Session - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - +

+ +
  • + + +
  • -
  • ATX -
  • + +
  • -
  • Drive -
  • + +
  • -
  • Macro -
  • + +
  • -
  • Text -
  • + +
  • -
  • GPIO - +
  • + + +
  • +
  • + +
  • -
    + -
    +