From 7b3335ea94db03eec24449686eab8b148780ee66 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sat, 1 Feb 2025 01:08:36 +0000 Subject: [PATCH] Add support for PiKVM Switch and related features This commit introduces several new components and improvements: - Added Switch module with firmware update and configuration support - Implemented new media streaming capabilities - Updated various UI elements and CSS styles - Enhanced keyboard and mouse event handling - Added new validators and configuration options - Updated Python version support to 3.13 - Improved error handling and logging --- .bumpversion.cfg | 2 +- Makefile | 13 +- PKGBUILD | 21 +- configs/janus/janus.plugin.ustreamer.jcfg | 4 + configs/kvmd/main/v4plus-hdmi-rpi4.yaml | 10 +- configs/os/services/kvmd-media.service | 16 + configs/os/sysusers.conf | 6 + configs/os/udev/common.rules | 1 + configs/os/udev/v2-hdmiusb-generic.rules | 7 - extras/media/manifest.yaml | 5 + extras/media/nginx.ctx-http.conf | 3 + extras/media/nginx.ctx-server.conf | 7 + hid/pico/Makefile | 2 +- hid/pico/src/CMakeLists.txt | 2 +- hid/pico/src/ph_usb.c | 4 +- kvmd.install | 10 + kvmd/__init__.py | 2 +- kvmd/aiotools.py | 7 +- kvmd/apps/__init__.py | 66 +- kvmd/apps/kvmd/__init__.py | 5 + kvmd/apps/kvmd/api/export.py | 2 +- kvmd/apps/kvmd/api/hid.py | 20 +- kvmd/apps/kvmd/api/msd.py | 6 +- kvmd/apps/kvmd/api/switch.py | 164 +++++ kvmd/apps/kvmd/auth.py | 2 +- kvmd/apps/kvmd/server.py | 88 +-- kvmd/apps/kvmd/snapshoter.py | 8 +- kvmd/apps/kvmd/switch/__init__.py | 400 ++++++++++++ kvmd/apps/kvmd/switch/chain.py | 440 +++++++++++++ kvmd/apps/kvmd/switch/device.py | 196 ++++++ kvmd/apps/kvmd/switch/lib.py | 35 + kvmd/apps/kvmd/switch/proto.py | 295 +++++++++ kvmd/apps/kvmd/switch/state.py | 358 ++++++++++ kvmd/apps/kvmd/switch/storage.py | 186 ++++++ kvmd/apps/kvmd/switch/types.py | 308 +++++++++ kvmd/apps/kvmd/ugpio.py | 2 +- kvmd/apps/media/__init__.py | 48 ++ kvmd/apps/media/__main__.py | 24 + kvmd/apps/media/server.py | 190 ++++++ kvmd/apps/otg/__init__.py | 143 ++-- kvmd/apps/otgconf/__init__.py | 102 +-- kvmd/apps/otgmsd/__init__.py | 13 +- kvmd/apps/pst/server.py | 18 +- kvmd/apps/pstrun/__init__.py | 2 +- kvmd/apps/swctl/__init__.py | 167 +++++ kvmd/apps/swctl/__main__.py | 24 + kvmd/apps/vnc/rfb/__init__.py | 9 + kvmd/apps/vnc/rfb/encodings.py | 18 +- kvmd/apps/vnc/server.py | 6 +- kvmd/clients/kvmd.py | 6 +- kvmd/clients/pst.py | 93 +++ kvmd/clients/streamer.py | 4 + kvmd/htserver.py | 39 +- kvmd/keyboard/mappings.py | 8 +- kvmd/keyboard/mappings.py.mako | 8 +- kvmd/mouse.py | 14 + kvmd/plugins/hid/__init__.py | 68 +- kvmd/plugins/hid/_mcu/proto.py | 9 +- kvmd/plugins/hid/ch9329/mouse.py | 5 +- kvmd/plugins/hid/otg/events.py | 9 +- kvmd/plugins/hid/otg/mouse.py | 3 - kvmd/tools.py | 5 +- kvmd/usb.py | 8 + kvmd/validators/__init__.py | 8 + kvmd/validators/hid.py | 5 +- kvmd/validators/os.py | 10 +- kvmd/validators/switch.py | 67 ++ scripts/kvmd-bootconfig | 11 +- setup.py | 8 +- switch/LICENSE | 15 + switch/Makefile | 8 + switch/mnt/README | 1 + switch/switch.uf2 | Bin 0 -> 192512 bytes testenv/Dockerfile | 4 +- testenv/linters/pylint.ini | 1 + testenv/linters/vulture-wl.py | 25 + testenv/tests/validators/test_switch.py | 180 ++++++ testenv/tox.ini | 2 +- testenv/v2-hdmi-rpi4.override.yaml | 4 +- web/kvm/index.html | 296 ++++++++- web/kvm/navbar-msd.pug | 8 +- web/kvm/navbar-shortcuts.pug | 2 +- web/kvm/navbar-switch.pug | 23 + web/kvm/navbar-system.pug | 17 +- web/kvm/navbar-text.pug | 1 + web/kvm/navbar.pug | 1 + web/kvm/window-about.pug | 7 + web/kvm/window-keyboard.pug | 2 +- web/kvm/window-stream.pug | 1 + web/kvm/window-switch.pug | 95 +++ web/kvm/windows.pug | 1 + web/login/index.pug | 2 +- web/share/css/kvm/msd.css | 4 + web/share/css/kvm/stream.css | 3 +- web/share/css/led.css | 38 +- web/share/css/main.css | 56 +- web/share/css/modal.css | 2 + web/share/css/navbar.css | 1 + web/share/css/slider.css | 22 +- web/share/css/x-desktop.css | 10 +- web/share/css/x-mobile.css | 2 +- web/share/js/kvm/atx.js | 12 +- web/share/js/kvm/keyboard.js | 8 +- web/share/js/kvm/msd.js | 2 +- web/share/js/kvm/ocr.js | 2 +- web/share/js/kvm/paste.js | 12 +- web/share/js/kvm/recorder.js | 20 +- web/share/js/kvm/session.js | 39 +- web/share/js/kvm/stream.js | 58 +- web/share/js/kvm/stream_janus.js | 94 +-- web/share/js/kvm/stream_media.js | 241 +++++++ web/share/js/kvm/stream_mjpeg.js | 2 +- web/share/js/kvm/switch.js | 610 ++++++++++++++++++ web/share/js/tools.js | 6 +- web/share/svg/led-beacon.svg | 4 + web/share/svg/led-usb.svg | 22 + .../svg/{led-stream.svg => led-video.svg} | 0 117 files changed, 5342 insertions(+), 479 deletions(-) create mode 100644 configs/os/services/kvmd-media.service delete mode 100644 configs/os/udev/v2-hdmiusb-generic.rules create mode 100644 extras/media/manifest.yaml create mode 100644 extras/media/nginx.ctx-http.conf create mode 100644 extras/media/nginx.ctx-server.conf create mode 100644 kvmd/apps/kvmd/api/switch.py create mode 100644 kvmd/apps/kvmd/switch/__init__.py create mode 100644 kvmd/apps/kvmd/switch/chain.py create mode 100644 kvmd/apps/kvmd/switch/device.py create mode 100644 kvmd/apps/kvmd/switch/lib.py create mode 100644 kvmd/apps/kvmd/switch/proto.py create mode 100644 kvmd/apps/kvmd/switch/state.py create mode 100644 kvmd/apps/kvmd/switch/storage.py create mode 100644 kvmd/apps/kvmd/switch/types.py create mode 100644 kvmd/apps/media/__init__.py create mode 100644 kvmd/apps/media/__main__.py create mode 100644 kvmd/apps/media/server.py create mode 100644 kvmd/apps/swctl/__init__.py create mode 100644 kvmd/apps/swctl/__main__.py create mode 100644 kvmd/clients/pst.py create mode 100644 kvmd/validators/switch.py create mode 100644 switch/LICENSE create mode 100644 switch/Makefile create mode 100644 switch/mnt/README create mode 100644 switch/switch.uf2 create mode 100644 testenv/tests/validators/test_switch.py create mode 100644 web/kvm/navbar-switch.pug create mode 100644 web/kvm/window-switch.pug create mode 100644 web/share/js/kvm/stream_media.js create mode 100644 web/share/js/kvm/switch.js create mode 100644 web/share/svg/led-beacon.svg create mode 100644 web/share/svg/led-usb.svg rename web/share/svg/{led-stream.svg => led-video.svg} (100%) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 926b4e31..dfb25c31 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.20 +current_version = 4.49 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/Makefile b/Makefile index ab47094f..e71bbd4d 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,8 @@ tox: testenv && 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/edid/v2.hex /etc/kvmd/switch-edid.hex \ + && 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/main.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 \ @@ -102,6 +104,7 @@ $(TESTENV_GPIO): run: testenv $(TESTENV_GPIO) - $(DOCKER) run --rm --name kvmd \ + --ipc=shareable \ --privileged \ --volume `pwd`/testenv/run:/run/kvmd:rw \ --volume `pwd`/testenv:/testenv:ro \ @@ -128,6 +131,7 @@ run: testenv $(TESTENV_GPIO) && 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/edid/v2.hex /etc/kvmd/switch-edid.hex \ && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && ln -s /testenv/web.css /etc/kvmd/web.css \ && mkdir -p /etc/kvmd/override.d \ @@ -155,6 +159,8 @@ run-cfg: testenv && 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/edid/v2.hex /etc/kvmd/switch-edid.hex \ + && 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/main.yaml /etc/kvmd/main.yaml \ && mkdir -p /etc/kvmd/override.d \ && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ @@ -178,7 +184,8 @@ run-ipmi: testenv && 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.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 /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.ipmi --run) \ @@ -187,6 +194,7 @@ run-ipmi: testenv 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 \ @@ -201,7 +209,8 @@ run-vnc: testenv && 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.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 /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.vnc --run) \ diff --git a/PKGBUILD b/PKGBUILD index 960ed365..c56da0f9 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,15 +39,15 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.20 +pkgver=4.49 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" license=(GPL) arch=(any) depends=( - "python>=3.12" - "python<3.13" + "python>=3.13" + "python<3.14" python-yaml python-aiohttp python-aiofiles @@ -79,6 +79,7 @@ depends=( python-mako python-luma-oled python-pyusb + python-pyudev "libgpiod>=2.1" freetype2 "v4l-utils>=1.22.1-1" @@ -89,11 +90,11 @@ depends=( iproute2 dnsmasq ipmitool - "janus-gateway-pikvm>=0.14.2-3" + "janus-gateway-pikvm>=1.3.0" certbot platform-io-access raspberrypi-utils - "ustreamer>=6.16" + "ustreamer>=6.26" # Systemd UDEV bug "systemd>=248.3-2" @@ -167,7 +168,7 @@ package_kvmd() { 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" @@ -209,7 +210,7 @@ for _variant in "${_variants[@]}"; do cd \"kvmd-\$pkgver\" pkgdesc=\"PiKVM platform configs - $_platform for $_board\" - depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-1\" \"raspberrypi-bootloader-pikvm>=20240818-1\") + depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-10\" \"raspberrypi-bootloader-pikvm>=20240818-1\") backup=( etc/sysctl.d/99-kvmd.conf @@ -253,8 +254,12 @@ for _variant in "${_variants[@]}"; do fi if [[ $_platform =~ ^.*-hdmi$ ]]; then - backup=(\"\${backup[@]}\" 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/share/kvmd\" diff --git a/configs/janus/janus.plugin.ustreamer.jcfg b/configs/janus/janus.plugin.ustreamer.jcfg index e8b8b5e4..a3c1df02 100644 --- a/configs/janus/janus.plugin.ustreamer.jcfg +++ b/configs/janus/janus.plugin.ustreamer.jcfg @@ -5,3 +5,7 @@ audio: { device = "hw:0" tc358743 = "/dev/video0" } +aplay: { + device = "plughw:UAC2Gadget,0" + check = "/run/kvmd/otg/uac2.usb0@meta.json" +} diff --git a/configs/kvmd/main/v4plus-hdmi-rpi4.yaml b/configs/kvmd/main/v4plus-hdmi-rpi4.yaml index c1e6faff..4b0a9bdd 100644 --- a/configs/kvmd/main/v4plus-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v4plus-hdmi-rpi4.yaml @@ -86,13 +86,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/os/services/kvmd-media.service b/configs/os/services/kvmd-media.service new file mode 100644 index 00000000..610d4859 --- /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/sysusers.conf b/configs/os/sysusers.conf index 0359974d..4ab263b5 100644 --- a/configs/os/sysusers.conf +++ b/configs/os/sysusers.conf @@ -1,4 +1,5 @@ g kvmd - - +g kvmd-media - - g kvmd-pst - - g kvmd-ipmi - - g kvmd-vnc - - @@ -7,6 +8,7 @@ g kvmd-janus - - g kvmd-certbot - - 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" - @@ -19,8 +21,11 @@ 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 @@ -32,6 +37,7 @@ 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 diff --git a/configs/os/udev/common.rules b/configs/os/udev/common.rules index 1a0ccded..5abb1aa7 100644 --- a/configs/os/udev/common.rules +++ b/configs/os/udev/common.rules @@ -1,3 +1,4 @@ # Here are described some bindings for PiKVM devices. # Do not edit this file. KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="eda3", SYMLINK+="kvmd-hid-bridge" +KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="1080", SYMLINK+="kvmd-switch" diff --git a/configs/os/udev/v2-hdmiusb-generic.rules b/configs/os/udev/v2-hdmiusb-generic.rules deleted file mode 100644 index 0865f227..00000000 --- a/configs/os/udev/v2-hdmiusb-generic.rules +++ /dev/null @@ -1,7 +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" -KERNEL=="ttyUSB0", GROUP="kvmd", SYMLINK+="kvmd-hid" diff --git a/extras/media/manifest.yaml b/extras/media/manifest.yaml new file mode 100644 index 00000000..f81c1bbf --- /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 00000000..d4ff7ac3 --- /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 00000000..cf1d157c --- /dev/null +++ b/extras/media/nginx.ctx-server.conf @@ -0,0 +1,7 @@ +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; +} diff --git a/hid/pico/Makefile b/hid/pico/Makefile index cd1703d9..86b79b0f 100644 --- a/hid/pico/Makefile +++ b/hid/pico/Makefile @@ -31,7 +31,7 @@ endef .tinyusb: $(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0) .ps2x2pico: - $(call libdep,ps2x2pico,No0ne/ps2x2pico,404aaf02949d5bee8013e3b5d0b3239abf6e13bd) + $(call libdep,ps2x2pico,No0ne/ps2x2pico,26ce89d597e598bb0ac636622e064202d91a9efc) deps: .pico-sdk .tinyusb .ps2x2pico diff --git a/hid/pico/src/CMakeLists.txt b/hid/pico/src/CMakeLists.txt index 7eeffa18..0986741e 100644 --- a/hid/pico/src/CMakeLists.txt +++ b/hid/pico/src/CMakeLists.txt @@ -19,7 +19,7 @@ target_sources(${target_name} PRIVATE ${PS2_PATH}/ps2in.c ${PS2_PATH}/ps2kb.c ${PS2_PATH}/ps2ms.c - ${PS2_PATH}/scancodesets.c + ${PS2_PATH}/scancodes.c ) target_link_options(${target_name} PRIVATE -Xlinker --print-memory-usage) target_compile_options(${target_name} PRIVATE -Wall -Wextra) diff --git a/hid/pico/src/ph_usb.c b/hid/pico/src/ph_usb.c index a3d1da00..b56e2cc5 100644 --- a/hid/pico/src/ph_usb.c +++ b/hid/pico/src/ph_usb.c @@ -53,7 +53,7 @@ static u8 _kbd_keys[6] = {0}; static u8 _mouse_buttons = 0; static s16 _mouse_abs_x = 0; static s16 _mouse_abs_y = 0; -#define _MOUSE_CLEAR { _mouse_buttons = 0; _mouse_abs_x = 0; _mouse_abs_y = 0; } +#define _MOUSE_CLEAR { _mouse_buttons = 0; } static void _kbd_sync_report(bool new); @@ -193,7 +193,7 @@ void ph_usb_send_clear(void) { if (PH_O_IS_MOUSE_USB) { _MOUSE_CLEAR; if (PH_O_IS_MOUSE_USB_ABS) { - _mouse_abs_send_report(0, 0); + _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); } diff --git a/kvmd.install b/kvmd.install index 469fba8c..15ee2378 100644 --- a/kvmd.install +++ b/kvmd.install @@ -102,6 +102,16 @@ EOF 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 + # Some update deletes /etc/motd, WTF # shellcheck disable=SC2015,SC2166 [ ! -f /etc/motd -a -f /etc/motd.pacsave ] && mv /etc/motd.pacsave /etc/motd || true diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 32534ec6..1dac1011 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.20" +__version__ = "4.49" diff --git a/kvmd/aiotools.py b/kvmd/aiotools.py index a47c94c6..f400ad3c 100644 --- a/kvmd/aiotools.py +++ b/kvmd/aiotools.py @@ -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 @@ -166,7 +171,7 @@ def create_deadly_task(name: str, coro: Coroutine) -> asyncio.Task: 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 diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index cfc39499..7c587c3f 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -502,6 +502,37 @@ def _get_config_scheme() -> dict: "table": Option([], type=valid_ugpio_view_table), }, }, + + "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"), + }, + }, + + "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), + "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": { @@ -532,11 +563,12 @@ def _get_config_scheme() -> dict: "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), + "remote_wakeup": Option(True, 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), + "endpoints": Option(9, type=valid_int_f0), "init_delay": Option(3.0, type=valid_float_f01), "user": Option("kvmd", type=valid_user), @@ -550,6 +582,9 @@ def _get_config_scheme() -> dict: "mouse": { "start": Option(True, type=valid_bool), }, + "mouse_alt": { + "start": Option(True, type=valid_bool), + }, }, "msd": { @@ -560,6 +595,18 @@ def _get_config_scheme() -> dict: "rw": Option(False, type=valid_bool), "removable": Option(True, type=valid_bool), "fua": Option(True, type=valid_bool), + "inquiry_string": { + "cdrom": { + "vendor": Option("PiKVM", type=valid_stripped_string), + "product": Option("Optical Drive", type=valid_stripped_string), + "revision": Option("1.00", type=valid_stripped_string), + }, + "flash": { + "vendor": Option("PiKVM", type=valid_stripped_string), + "product": Option("Flash Drive", type=valid_stripped_string), + "revision": Option("1.00", type=valid_stripped_string), + }, + }, }, }, @@ -576,6 +623,11 @@ def _get_config_scheme() -> dict: "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), @@ -586,6 +638,18 @@ def _get_config_scheme() -> dict: "rw": Option(True, type=valid_bool), "removable": Option(True, type=valid_bool), "fua": Option(True, type=valid_bool), + "inquiry_string": { + "cdrom": { + "vendor": Option("PiKVM", type=valid_stripped_string), + "product": Option("Optical Drive", type=valid_stripped_string), + "revision": Option("1.00", type=valid_stripped_string), + }, + "flash": { + "vendor": Option("PiKVM", type=valid_stripped_string), + "product": Option("Flash Drive", type=valid_stripped_string), + "revision": Option("1.00", type=valid_stripped_string), + }, + }, }, }, }, diff --git a/kvmd/apps/kvmd/__init__.py b/kvmd/apps/kvmd/__init__.py index 495a320f..088a62ef 100644 --- a/kvmd/apps/kvmd/__init__.py +++ b/kvmd/apps/kvmd/__init__.py @@ -35,6 +35,7 @@ from .ugpio import UserGpio from .streamer import Streamer from .snapshoter import Snapshoter from .ocr import Ocr +from .switch import Switch from .server import KvmdServer @@ -90,6 +91,10 @@ def main(argv: (list[str] | None)=None) -> None: log_reader=(LogReader() if config.log_reader.enabled else None), user_gpio=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"])), diff --git a/kvmd/apps/kvmd/api/export.py b/kvmd/apps/kvmd/api/export.py index bb048f53..fd672f7b 100644 --- a/kvmd/apps/kvmd/api/export.py +++ b/kvmd/apps/kvmd/api/export.py @@ -66,7 +66,7 @@ class ExportApi: 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(): # type: ignore + for (channel, ch_state) in gpio_state["state"][f"{mode}s"].items(): # type: ignore if not channel.startswith("__"): # Hide special GPIOs for key in ["online", "state"]: self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}") diff --git a/kvmd/apps/kvmd/api/hid.py b/kvmd/apps/kvmd/api/hid.py index 51b9dc00..98b96313 100644 --- a/kvmd/apps/kvmd/api/hid.py +++ b/kvmd/apps/kvmd/api/hid.py @@ -123,7 +123,8 @@ class HidApi: if limit > 0: text = text[:limit] symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_keymap_name)) - self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True) + slow = valid_bool(req.query.get("slow", False)) + await self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True, slow=slow) return make_json_response() def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]: @@ -148,16 +149,17 @@ class HidApi: 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) except Exception: return - self.__hid.send_key_event(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) except Exception: return self.__hid.send_mouse_button_event(button, state) @@ -182,7 +184,7 @@ class HidApi: 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): @@ -199,9 +201,10 @@ class HidApi: try: key = valid_hid_key(event["key"]) state = valid_bool(event["state"]) + finish = valid_bool(event.get("finish", False)) except Exception: return - self.__hid.send_key_event(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: @@ -248,9 +251,10 @@ class HidApi: key = valid_hid_key(req.query.get("key")) if "state" in req.query: state = valid_bool(req.query["state"]) - self.__hid.send_key_event(key, state) + finish = valid_bool(req.query.get("finish", False)) + self.__hid.send_key_event(key, state, finish) else: - self.__hid.send_key_events([(key, True), (key, False)]) + self.__hid.send_key_event(key, True, True) return make_json_response() @exposed_http("POST", "/hid/events/send_mouse_button") diff --git a/kvmd/apps/kvmd/api/msd.py b/kvmd/apps/kvmd/api/msd.py index ca1c6cf1..799b138c 100644 --- a/kvmd/apps/kvmd/api/msd.py +++ b/kvmd/apps/kvmd/api/msd.py @@ -63,11 +63,7 @@ class MsdApi: @exposed_http("GET", "/msd") async def __state_handler(self, _: Request) -> Response: - state = await self.__msd.get_state() - if state["storage"] and state["storage"]["parts"]: - state["storage"]["size"] = state["storage"]["parts"][""]["size"] # Legacy API - state["storage"]["free"] = state["storage"]["parts"][""]["free"] # Legacy API - return make_json_response(state) + return make_json_response(await self.__msd.get_state()) @exposed_http("POST", "/msd/set_params") async def __set_params_handler(self, req: Request) -> Response: diff --git a/kvmd/apps/kvmd/api/switch.py b/kvmd/apps/kvmd/api/switch.py new file mode 100644 index 00000000..bf91b83e --- /dev/null +++ b/kvmd/apps/kvmd/api/switch.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 . # +# # +# ========================================================================== # + + +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_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") + async def __set_active_port_handler(self, req: Request) -> Response: + port = valid_int_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_int_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_int_f0(req.query.get("port")) + params = { + param: validator(req.query.get(param)) + for (param, validator) in [ + ("edid_id", (lambda arg: valid_switch_edid_id(arg, allow_default=True))), + ("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_int_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_int_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/auth.py b/kvmd/apps/kvmd/auth.py index 008e8a4f..bf979836 100644 --- a/kvmd/apps/kvmd/auth.py +++ b/kvmd/apps/kvmd/auth.py @@ -95,7 +95,7 @@ class AuthManager: secret = file.read().strip() if secret: code = passwd[-6:] - if not pyotp.TOTP(secret).verify(code): + if not pyotp.TOTP(secret).verify(code, valid_window=1): get_logger().error("Got access denied for user %r by TOTP", user) return False passwd = passwd[:-6] diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index ed85bb24..858ba1b6 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -66,6 +66,7 @@ from .ugpio import UserGpio 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 @@ -77,6 +78,7 @@ from .api.hid import HidApi 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 @@ -125,18 +127,19 @@ class _Subsystem: 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_state" - __EV_HID_STATE = "hid_state" - __EV_ATX_STATE = "atx_state" - __EV_MSD_STATE = "msd_state" - __EV_STREAMER_STATE = "streamer_state" - __EV_OCR_STATE = "ocr_state" - __EV_INFO_STATE = "info_state" + __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" def __init__( # pylint: disable=too-many-arguments,too-many-locals self, @@ -145,6 +148,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins log_reader: (LogReader | None), user_gpio: UserGpio, ocr: Ocr, + switch: Switch, hid: BaseHid, atx: BaseAtx, @@ -177,6 +181,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins AtxApi(atx), MsdApi(msd), StreamerApi(streamer, ocr), + SwitchApi(switch), ExportApi(info_manager, atx, user_gpio), RedfishApi(info_manager, atx), ] @@ -189,6 +194,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins _Subsystem.make(streamer, "Streamer", self.__EV_STREAMER_STATE), _Subsystem.make(ocr, "OCR", self.__EV_OCR_STATE), _Subsystem.make(info_manager, "Info manager", self.__EV_INFO_STATE), + _Subsystem.make(switch, "Switch", self.__EV_SWITCH_STATE), ] self.__streamer_notifier = aiotools.AioNotifier() @@ -229,8 +235,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins @exposed_http("GET", "/ws") async def __ws_handler(self, req: Request) -> WebSocketResponse: stream = valid_bool(req.query.get("stream", True)) - legacy = valid_bool(req.query.get("legacy", True)) - async with self._ws_session(req, stream=stream, legacy=legacy) as ws: + async with self._ws_session(req, stream=stream) as ws: (major, minor) = __version__.split(".") await ws.send_event("loop", { "version": { @@ -242,7 +247,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins if sub.event_type: assert sub.trigger_state await sub.trigger_state() - await self._broadcast_ws_event("hid_keymaps_state", await self.__hid_api.get_keymaps()) # FIXME + await self._broadcast_ws_event(self.__EV_HID_KEYMAPS_STATE, await self.__hid_api.get_keymaps()) # FIXME return (await self._ws_loop(ws)) @exposed_ws("ping") @@ -293,10 +298,10 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins logger.exception("Cleanup error on %s", sub.name) logger.info("On-Cleanup complete") - async def _on_ws_opened(self) -> None: + async def _on_ws_opened(self, _: WsSession) -> None: self.__streamer_notifier.notify() - async def _on_ws_closed(self) -> None: + async def _on_ws_closed(self, _: WsSession) -> None: self.__hid.clear_events() self.__streamer_notifier.notify() @@ -337,60 +342,5 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins ) async def __poll_state(self, event_type: str, poller: AsyncGenerator[dict, None]) -> None: - match event_type: - case self.__EV_GPIO_STATE: - await self.__poll_gpio_state(poller) - case self.__EV_INFO_STATE: - await self.__poll_info_state(poller) - case self.__EV_MSD_STATE: - await self.__poll_msd_state(poller) - case self.__EV_STREAMER_STATE: - await self.__poll_streamer_state(poller) - case self.__EV_OCR_STATE: - await self.__poll_ocr_state(poller) - case _: - async for state in poller: - await self._broadcast_ws_event(event_type, state) - - async def __poll_gpio_state(self, poller: AsyncGenerator[dict, None]) -> None: - prev: dict = {"state": {"inputs": {}, "outputs": {}}} async for state in poller: - await self._broadcast_ws_event(self.__EV_GPIO_STATE, state, legacy=False) - if "model" in state: # We have only "model"+"state" or "model" event - prev = state - await self._broadcast_ws_event("gpio_model_state", prev["model"], legacy=True) - else: - prev["state"]["inputs"].update(state["state"].get("inputs", {})) - prev["state"]["outputs"].update(state["state"].get("outputs", {})) - await self._broadcast_ws_event(self.__EV_GPIO_STATE, prev["state"], legacy=True) - - async def __poll_info_state(self, poller: AsyncGenerator[dict, None]) -> None: - async for state in poller: - await self._broadcast_ws_event(self.__EV_INFO_STATE, state, legacy=False) - for (key, value) in state.items(): - await self._broadcast_ws_event(f"info_{key}_state", value, legacy=True) - - async def __poll_msd_state(self, poller: AsyncGenerator[dict, None]) -> None: - prev: dict = {"storage": None} - async for state in poller: - await self._broadcast_ws_event(self.__EV_MSD_STATE, state, legacy=False) - prev_storage = prev["storage"] - prev.update(state) - if prev["storage"] is not None and prev_storage is not None: - prev_storage.update(prev["storage"]) - prev["storage"] = prev_storage - if "online" in prev: # Complete/Full - await self._broadcast_ws_event(self.__EV_MSD_STATE, prev, legacy=True) - - async def __poll_streamer_state(self, poller: AsyncGenerator[dict, None]) -> None: - prev: dict = {} - async for state in poller: - await self._broadcast_ws_event(self.__EV_STREAMER_STATE, state, legacy=False) - prev.update(state) - if "features" in prev: # Complete/Full - await self._broadcast_ws_event(self.__EV_STREAMER_STATE, prev, legacy=True) - - async def __poll_ocr_state(self, poller: AsyncGenerator[dict, None]) -> None: - async for state in poller: - await self._broadcast_ws_event(self.__EV_OCR_STATE, state, legacy=False) - await self._broadcast_ws_event("streamer_ocr_state", {"ocr": state}, legacy=True) + await self._broadcast_ws_event(event_type, state) diff --git a/kvmd/apps/kvmd/snapshoter.py b/kvmd/apps/kvmd/snapshoter.py index 197e16c4..e9391306 100644 --- a/kvmd/apps/kvmd/snapshoter.py +++ b/kvmd/apps/kvmd/snapshoter.py @@ -123,10 +123,10 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes 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), - ]) + 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/switch/__init__.py b/kvmd/apps/kvmd/switch/__init__.py new file mode 100644 index 00000000..49bfbd7d --- /dev/null +++ b/kvmd/apps/kvmd/switch/__init__.py @@ -0,0 +1,400 @@ +# ========================================================================== # +# # +# 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 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_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_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, + ) -> None: + + self.__default_edid_path = default_edid_path + + self.__chain = Chain(device_path) + 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_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_port(self, port: int) -> None: + self.__chain.set_active_port(port) + + # ===== + + async def set_port_beacon(self, port: int, on: bool) -> None: + self.__chain.set_port_beacon(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: int) -> None: + self.__inner_atx_cp(port, False, self.__X_ATX_CP_DELAYS) + + async def atx_power_off(self, port: int) -> None: + self.__inner_atx_cp(port, True, self.__X_ATX_CP_DELAYS) + + async def atx_power_off_hard(self, port: int) -> None: + self.__inner_atx_cp(port, True, self.__X_ATX_CPL_DELAYS) + + async def atx_power_reset_hard(self, port: int) -> None: + self.__inner_atx_cr(port, True) + + async def atx_click_power(self, port: int) -> None: + self.__inner_atx_cp(port, None, self.__X_ATX_CP_DELAYS) + + async def atx_click_power_long(self, port: int) -> None: + self.__inner_atx_cp(port, None, self.__X_ATX_CPL_DELAYS) + + async def atx_click_reset(self, port: int) -> None: + self.__inner_atx_cr(port, None) + + def __inner_atx_cp(self, port: int, if_powered: (bool | None), x_delay: str) -> None: + assert x_delay in [self.__X_ATX_CP_DELAYS, self.__X_ATX_CPL_DELAYS] + delay = getattr(self.__cache, f"get_{x_delay}")()[port] + self.__chain.click_power(port, delay, if_powered) + + def __inner_atx_cr(self, port: int, if_powered: (bool | None)) -> None: + 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, + 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: + + 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 (key, value) in [ + (self.__X_PORT_NAMES, name), + (self.__X_ATX_CP_DELAYS, atx_click_power_delay), + (self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay), + (self.__X_ATX_CR_DELAYS, atx_click_reset_delay), + ]: + if value is not None: + new = getattr(self.__cache, f"get_{key}")() + new[port] = (value or None) # None == reset to 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: + tasks = [ + asyncio.create_task(self.__systask_events()), + asyncio.create_task(self.__systask_default_edid()), + asyncio.create_task(self.__systask_storage()), + ] + try: + await asyncio.gather(*tasks) + except Exception: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + + 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 (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 00000000..8e4d94eb --- /dev/null +++ b/kvmd/apps/kvmd/switch/chain.py @@ -0,0 +1,440 @@ +# ========================================================================== # +# # +# 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 queue +import select +import dataclasses +import time + +from typing import AsyncGenerator + +from .lib import get_logger +from .lib import tools +from .lib import aiotools +from .lib import aioproc + +from .types import Edids +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 + + +@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 _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) -> None: + self.__device = Device(device_path) + + self.__actual = False + + self.__edids = Edids() + + self.__colors = Colors() + + self.__units: list[_UnitContext] = [] + self.__active_port = -1 + + self.__cmd_queue: "multiprocessing.Queue[_BaseCmd]" = multiprocessing.Queue() + self.__events_queue: "multiprocessing.Queue[BaseEvent]" = multiprocessing.Queue() + + self.__stop_event = multiprocessing.Event() + + def set_actual(self, actual: bool) -> None: + # Флаг разрешения синхронизации EDID и прочих чувствительных вещей + self.__queue_cmd(_CmdSetActual(actual)) + + # ===== + + 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_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 = multiprocessing.Process(target=self.__subprocess, daemon=True) + try: + proc.start() + while True: + try: + yield (await aiotools.run_async(self.__events_queue.get, True, 0.1)) + except queue.Empty: + pass + finally: + if proc.is_alive(): + self.__stop_event.set() + if proc.is_alive() or proc.exitcode is not None: + await aiotools.run_async(proc.join) + + # ===== + + def __queue_cmd(self, cmd: _BaseCmd) -> None: + if not self.__stop_event.is_set(): + self.__cmd_queue.put_nowait(cmd) + + def __queue_event(self, event: BaseEvent) -> None: + if not self.__stop_event.is_set(): + self.__events_queue.put_nowait(event) + + def __subprocess(self) -> None: + logger = aioproc.settle("Switch", "switch") + 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") + tools.clear_queue(self.__cmd_queue) + 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(): + if self.__select(): + for resp in self.__device.read_all(): + self.__update_units(resp) + self.__adjust_start_port() + self.__finish_changing_request(resp) + self.__consume_commands() + self.__ensure_config() + + def __select(self) -> bool: + try: + return bool(select.select([ + self.__device.get_fd(), + self.__cmd_queue._reader, # type: ignore # pylint: disable=protected-access + ], [], [], 1)[0]) + except Exception as ex: + raise DeviceError(ex) + + def __consume_commands(self) -> None: + while not self.__cmd_queue.empty(): + cmd = self.__cmd_queue.get() + match cmd: + case _CmdSetActual(): + self.__actual = cmd.actual + + case _CmdSetActivePort(): + # Может быть вызвано изнутри при синхронизации + 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 _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_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_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_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 00000000..b56cc406 --- /dev/null +++ b/kvmd/apps/kvmd/switch/device.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 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 BodySetColors + + +# ===== +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_colors(self, unit: int, ch: int, colors: Colors) -> int: + return self.__send_request(Header.SET_COLORS, unit, BodySetColors(ch, colors)) + + 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 00000000..4ef2647e --- /dev/null +++ b/kvmd/apps/kvmd/switch/lib.py @@ -0,0 +1,35 @@ +# ========================================================================== # +# # +# 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 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 00000000..d4f43f84 --- /dev/null +++ b/kvmd/apps/kvmd/switch/proto.py @@ -0,0 +1,295 @@ +# ========================================================================== # +# # +# 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 + + __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 UnitFlags: + changing_busy: bool + flashing_busy: bool + has_downlink: bool + + +@dataclasses.dataclass(frozen=True) +class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes + sw_version: int + hw_version: int + 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] + usb_5v_sens: tuple[bool, bool, bool, bool] + atx_busy: tuple[bool, bool, bool, bool] + + __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, + ) = cls.__struct.unpack_from(data, offset=offset) + return UnitState( + sw_version, + hw_version, + flags=UnitFlags( + changing_busy=bool(flags & 0x80), + flashing_busy=bool(flags & 0x40), + has_downlink=bool(flags & 0x02), + ), + 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), + usb_5v_sens=cls.__make_flags4(usb_5v_sens), + atx_busy=cls.__make_flags4(atx_busy), + ) + + @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 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 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 00000000..e49d0062 --- /dev/null +++ b/kvmd/apps/kvmd/switch/state.py @@ -0,0 +1,358 @@ +# ========================================================================== # +# # +# 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 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 + __FW_VERSION = 5 + + __FULL = 0xFFFF + __SUMMARY = 0x01 + __EDIDS = 0x02 + __COLORS = 0x04 + __VIDEO = 0x08 + __USB = 0x10 + __BEACONS = 0x20 + __ATX = 0x40 + + def __init__(self) -> None: + self.__edids = Edids() + 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_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, "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.sw_version}}) + 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], + "atx": { + "click_delays": { + "power": self.__atx_cp_delays[port], + "power_long": self.__atx_cpl_delays[port], + "reset": self.__atx_cr_delays[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_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 00000000..6e3a0a76 --- /dev/null +++ b/kvmd/apps/kvmd/switch/storage.py @@ -0,0 +1,186 @@ +# ========================================================================== # +# # +# 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 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_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_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_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 00000000..32225f06 --- /dev/null +++ b/kvmd/apps/kvmd/switch/types.py @@ -0,0 +1,308 @@ +# ========================================================================== # +# # +# 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 + + 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=parsed.get_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) + + __HEADER = b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00" + + def __post_init__(self) -> None: + assert len(self.name) > 0 + assert len(self.data) == 256 + object.__setattr__(self, "crc", bitbang.make_crc16(self.data)) + object.__setattr__(self, "valid", self.data.startswith(self.__HEADER)) + 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.data + + @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 data.startswith(cls.__HEADER): + 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) == 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 + + +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/ugpio.py b/kvmd/apps/kvmd/ugpio.py index e4735c61..a3c453f5 100644 --- a/kvmd/apps/kvmd/ugpio.py +++ b/kvmd/apps/kvmd/ugpio.py @@ -408,7 +408,7 @@ class UserGpio: 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, diff --git a/kvmd/apps/media/__init__.py b/kvmd/apps/media/__init__.py new file mode 100644 index 00000000..325a817c --- /dev/null +++ b/kvmd/apps/media/__init__.py @@ -0,0 +1,48 @@ +# ========================================================================== # +# # +# 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(argv: (list[str] | None)=None) -> None: + config = init( + prog="kvmd-media", + description="The media proxy", + check_run=True, + argv=argv, + )[2].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 00000000..ab578e06 --- /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 00000000..1f96b353 --- /dev/null +++ b/kvmd/apps/media/server.py @@ -0,0 +1,190 @@ +# ========================================================================== # +# # +# 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 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 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 + + +# ===== +@dataclasses.dataclass +class _Source: + type: str + fmt: str + 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) + + +@dataclasses.dataclass +class _Client: + ws: WsSession + src: _Source + queue: asyncio.Queue[dict] + sender: (asyncio.Task | None) = dataclasses.field(default=None) + + +class MediaServer(HttpServer): + __K_VIDEO = "video" + + __F_H264 = "h264" + __F_JPEG = "jpeg" + + __Q_SIZE = 32 + + def __init__( + self, + h264_streamer: (BaseStreamerClient | None), + jpeg_streamer: (BaseStreamerClient | None), + ) -> None: + + super().__init__() + + self.__srcs: list[_Source] = [] + if h264_streamer: + self.__srcs.append(_Source(self.__K_VIDEO, self.__F_H264, h264_streamer, {"profile_level_id": "42E01F"})) + if jpeg_streamer: + self.__srcs.append(_Source(self.__K_VIDEO, self.__F_JPEG, jpeg_streamer)) + + # ===== + + @exposed_http("GET", "/ws") + async def __ws_handler(self, req: Request) -> WebSocketResponse: + async with self._ws_session(req) as ws: + media: dict = {self.__K_VIDEO: {}} + for src in self.__srcs: + media[src.type][src.fmt] = src.meta + await ws.send_event("media", media) + return (await self._ws_loop(ws)) + + @exposed_ws(0) + async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None: + await ws.send_bin(255, b"") # Ping-pong + + @exposed_ws("start") + async def __ws_start_handler(self, ws: WsSession, event: dict) -> None: + try: + req_type = str(event.get("type")) + req_fmt = str(event.get("format")) + except Exception: + return + src: (_Source | None) = None + for cand in self.__srcs: + if ws in cand.clients: + return # Don't allow any double streaming + if (cand.type, cand.fmt) == (req_type, req_fmt): + src = cand + if src: + client = _Client(ws, src, asyncio.Queue(self.__Q_SIZE)) + 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) + + # ===== + + async def _init_app(self) -> None: + logger = get_logger(0) + for src in self.__srcs: + 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") + + async def _on_ws_closed(self, ws: WsSession) -> None: + for src in self.__srcs: + 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 = StreamerFormats.is_diff(client.src.streamer.get_format()) + if need_key: + client.src.key_required = True + has_key = False + while True: + frame = await client.queue.get() + has_key = (not need_key or has_key or frame["key"]) + if has_key: + try: + 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(): + try: + client.queue.put_nowait(frame) + except asyncio.QueueFull: + # Если какой-то из клиентов не справляется, очищаем ему очередь и запрашиваем кейфрейм. + # Я вижу у такой логики кучу минусов, хз как себя покажет, но лучше пока ничего не придумал. + tools.clear_queue(client.queue) + src.key_required = True + except Exception: + pass + 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/otg/__init__.py b/kvmd/apps/otg/__init__.py index 9e212cf6..3a324526 100644 --- a/kvmd/apps/otg/__init__.py +++ b/kvmd/apps/otg/__init__.py @@ -106,31 +106,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: - func = "acm.usb0" - func_path = join(self.__gadget_path, "functions", func) - _mkdir(func_path) + def add_audio_mic(self, 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: - _symlink(func_path, join(self.__profile_path, func)) - self.__create_meta(func, "Serial Port") + self.__start_function(func, eps) + self.__create_meta(func, "Microphone", eps) + + def add_serial(self, start: bool) -> None: + eps = 3 + func = "acm.usb0" + self.__create_function(func) + if start: + self.__start_function(func, eps) + self.__create_meta(func, "Serial Port", eps) def add_ethernet(self, 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: @@ -150,20 +164,20 @@ class _GadgetConfig: _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) def add_keyboard(self, start: bool, remote_wakeup: bool) -> None: self.__add_hid("Keyboard", start, remote_wakeup, make_keyboard_hid()) def add_mouse(self, start: bool, remote_wakeup: bool, absolute: bool, horizontal_wheel: bool) -> None: - name = ("Absolute" if absolute else "Relative") + " Mouse" - self.__add_hid(name, start, remote_wakeup, make_mouse_hid(absolute, horizontal_wheel)) + desc = ("Absolute" if absolute else "Relative") + " Mouse" + self.__add_hid(desc, 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, 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) @@ -172,32 +186,66 @@ class _GadgetConfig: _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) 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( + self, + 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, optional=True) if start: - _symlink(func_path, join(self.__profile_path, func)) - name = ("Mass Storage Drive" if self.__msd_instance == 0 else f"Extra Drive #{self.__msd_instance}") - self.__create_meta(func, name) + self.__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) 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) -> None: + _write(join(self.__meta_path, f"{func}@meta.json"), json.dumps({ + "function": func, + "description": desc, + "endpoints": eps, + })) def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,too-many-branches @@ -248,33 +296,50 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements, # 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) 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(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(cod.hid.mouse_alt.start, config.otg.remote_wakeup, (not ckhm.absolute), ckhm.horizontal_wheel) 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( + start=cod.msd.start, + user=config.otg.user, + inquiry_string_cdrom=usb.make_inquiry_string(**cod.msd.default.inquiry_string.cdrom._unpack()), + inquiry_string_flash=usb.make_inquiry_string(**cod.msd.default.inquiry_string.flash._unpack()), + **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( + start=cod.drives.start, + user="root", + inquiry_string_cdrom=usb.make_inquiry_string(**cod.drives.default.inquiry_string.cdrom._unpack()), + inquiry_string_flash=usb.make_inquiry_string(**cod.drives.default.inquiry_string.flash._unpack()), + **cod.drives.default._unpack(ignore="inquiry_string"), + ) + + if cod.ethernet.enabled: + logger.info("===== Ethernet =====") + gc.add_ethernet(**cod.ethernet._unpack(ignore=["enabled"])) + + if cod.serial.enabled: + logger.info("===== Serial =====") + gc.add_serial(cod.serial.start) + + if cod.audio.enabled: + logger.info("===== Microphone =====") + gc.add_audio_mic(cod.audio.start) logger.info("===== Preparing complete =====") diff --git a/kvmd/apps/otgconf/__init__.py b/kvmd/apps/otgconf/__init__.py index b7aa2277..b57f0df1 100644 --- a/kvmd/apps/otgconf/__init__.py +++ b/kvmd/apps/otgconf/__init__.py @@ -23,6 +23,7 @@ import os import json import contextlib +import dataclasses import argparse import time @@ -38,11 +39,28 @@ from .. import init # ===== +@dataclasses.dataclass(frozen=True) +class _Function: + name: str + desc: str + eps: int + enabled: bool + + 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,12 +75,12 @@ class _GadgetControl: try: yield finally: - self.__recreate_profile() + self.__clear_profile(recreate=True) time.sleep(self.__init_delay) with open(udc_path, "w") as file: file.write(udc) - def __recreate_profile(self) -> None: + 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. @@ -72,14 +90,22 @@ class _GadgetControl: if os.path.islink(path): try: os.unlink(path) - os.symlink(self.__get_fsrc_path(func), path) + if recreate: + os.symlink(self.__get_fsrc_path(func), path) except (FileNotFoundError, FileExistsError): pass - def __read_metas(self) -> Generator[dict, None, None]: - for meta_name in sorted(os.listdir(self.__meta_path)): - with open(os.path.join(self.__meta_path, meta_name)) as file: - yield json.loads(file.read()) + def __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, + ) def __get_fsrc_path(self, func: str) -> str: return usb.get_gadget_path(self.__gadget, usb.G_FUNCTIONS, func) @@ -89,20 +115,28 @@ class _GadgetControl: return usb.get_gadget_path(self.__gadget, usb.G_PROFILE) return usb.get_gadget_path(self.__gadget, usb.G_PROFILE, func) - def enable_functions(self, funcs: list[str]) -> None: + 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 RuntimeError(f"No available endpoints for this config: {eps_req} required, {self.__eps} is maximum") with self.__udc_stopped(): - for func in funcs: - os.symlink(self.__get_fsrc_path(func), self.__get_fdest_path(func)) - - def disable_functions(self, funcs: list[str]) -> None: - with self.__udc_stopped(): - for func in funcs: - os.unlink(self.__get_fdest_path(func)) + 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(self.__get_fdest_path(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} # [{func.eps}] {func.desc}") def make_gpio_config(self) -> None: class Dumper(yaml.Dumper): @@ -127,17 +161,17 @@ class _GadgetControl: "scheme": {}, "view": {"table": []}, } - 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": False, } config["view"]["table"].append(InlineList([ # type: ignore - "#" + meta["name"], - "#" + meta["func"], - meta["func"], + "#" + func.desc, + "#" + func.name, + func.name, ])) print(yaml.dump({"kvmd": {"gpio": config}}, indent=4, Dumper=Dumper)) @@ -159,25 +193,21 @@ def main(argv: (list[str] | None)=None) -> None: parents=[parent_parser], ) parser.add_argument("-l", "--list-functions", action="store_true", help="List functions") - parser.add_argument("-e", "--enable-function", nargs="+", metavar="", help="Enable function(s)") - parser.add_argument("-d", "--disable-function", nargs="+", metavar="", help="Disable function(s)") + 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("--make-gpio-config", action="store_true") options = parser.parse_args(argv[1:]) - gc = _GadgetControl(config.otg.meta, config.otg.gadget, config.otg.udc, config.otg.init_delay) + gc = _GadgetControl(config.otg.meta, config.otg.gadget, config.otg.udc, config.otg.endpoints, config.otg.init_delay) if options.list_functions: gc.list_functions() - elif options.enable_function: - funcs = list(map(valid_stripped_string_not_empty, options.enable_function)) - gc.enable_functions(funcs) - gc.list_functions() - - elif options.disable_function: - funcs = list(map(valid_stripped_string_not_empty, options.disable_function)) - gc.disable_functions(funcs) + 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/otgmsd/__init__.py b/kvmd/apps/otgmsd/__init__.py index cd8f7718..7c2159d5 100644 --- a/kvmd/apps/otgmsd/__init__.py +++ b/kvmd/apps/otgmsd/__init__.py @@ -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 @@ -72,10 +71,10 @@ def main(argv: (list[str] | None)=None) -> None: 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") @@ -103,10 +102,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/pst/server.py b/kvmd/apps/pst/server.py index 79bbf7c8..ddeccc41 100644 --- a/kvmd/apps/pst/server.py +++ b/kvmd/apps/pst/server.py @@ -24,6 +24,7 @@ import os 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 ... import fstab from ...htserver import exposed_http from ...htserver import exposed_ws +from ...htserver import make_json_response from ...htserver import WsSession from ...htserver import HttpServer @@ -65,6 +67,16 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst 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 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst await self.__remount_storage(rw=False) logger.info("On-Cleanup complete") - async def _on_ws_opened(self) -> None: + async def _on_ws_opened(self, _: WsSession) -> None: self.__notifier.notify() - async def _on_ws_closed(self) -> None: + async def _on_ws_closed(self, _: WsSession) -> None: self.__notifier.notify() # ===== SYSTEM TASKS @@ -117,7 +129,7 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst 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, diff --git a/kvmd/apps/pstrun/__init__.py b/kvmd/apps/pstrun/__init__.py index 33e1396e..d55835fd 100644 --- a/kvmd/apps/pstrun/__init__.py +++ b/kvmd/apps/pstrun/__init__.py @@ -84,7 +84,7 @@ async def _run_cmd_ws(cmd: list[str], ws: aiohttp.ClientWebSocketResponse) -> in msg = receive_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 ...") diff --git a/kvmd/apps/swctl/__init__.py b/kvmd/apps/swctl/__init__.py new file mode 100644 index 00000000..d5915d74 --- /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 + 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 00000000..4827fc49 --- /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/vnc/rfb/__init__.py b/kvmd/apps/vnc/rfb/__init__.py index d2c6b50b..fc6e435c 100644 --- a/kvmd/apps/vnc/rfb/__init__.py +++ b/kvmd/apps/vnc/rfb/__init__.py @@ -464,6 +464,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute if self._encodings.has_ext_keys: # Preferred method await self._write_fb_update("ExtKeys FBUR", 0, 0, RfbEncodings.EXT_KEYS, drain=True) + + 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: @@ -486,11 +490,16 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute async def __handle_pointer_event(self) -> None: (buttons, to_x, to_y) = await self._read_struct("pointer event", "B HH") + 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") await self._on_pointer_event( buttons={ "left": bool(buttons & 0x1), "right": bool(buttons & 0x4), "middle": bool(buttons & 0x2), + "up": bool(ext_buttons & 0x2), + "down": bool(ext_buttons & 0x1), }, wheel={ "x": (-4 if buttons & 0x40 else (4 if buttons & 0x20 else 0)), diff --git a/kvmd/apps/vnc/rfb/encodings.py b/kvmd/apps/vnc/rfb/encodings.py index 597e3a92..940a383f 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/server.py b/kvmd/apps/vnc/server.py index 5abca7b0..b2ae71fa 100644 --- a/kvmd/apps/vnc/server.py +++ b/kvmd/apps/vnc/server.py @@ -130,7 +130,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes # Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события. # Все это нужно только чтобы не посылать лишние жсоны в сокет KVMD - self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle"], None) + self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle", "up", "down"], None) self.__mouse_move = {"x": -1, "y": -1} self.__modifiers = 0 @@ -177,7 +177,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes self.__kvmd_ws = None async def __process_ws_event(self, event_type: str, event: dict) -> None: - if event_type == "info_state": + if event_type == "info": if "meta" in event: try: host = event["meta"]["server"]["host"] @@ -190,7 +190,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes await self._send_rename(name) self.__shared_params.name = name - elif event_type == "hid_state": + elif event_type == "hid": if ( self._encodings.has_leds_state and ("keyboard" in event) diff --git a/kvmd/clients/kvmd.py b/kvmd/clients/kvmd.py index a0412ce2..d9b38339 100644 --- a/kvmd/clients/kvmd.py +++ b/kvmd/clients/kvmd.py @@ -183,10 +183,12 @@ class KvmdClientWs: 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")) + mask = (0b01 if state else 0) + await self.__writer_queue.put(bytes([1, mask]) + key.encode("ascii")) async def send_mouse_button_event(self, button: str, state: bool) -> None: - await self.__writer_queue.put(bytes([2, state]) + button.encode("ascii")) + mask = (0b01 if state else 0) + await self.__writer_queue.put(bytes([2, mask]) + button.encode("ascii")) 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)) diff --git a/kvmd/clients/pst.py b/kvmd/clients/pst.py new file mode 100644 index 00000000..868f388c --- /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 5369892e..cd5f03ec 100644 --- a/kvmd/clients/streamer.py +++ b/kvmd/clients/streamer.py @@ -63,6 +63,10 @@ class StreamerFormats: 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: diff --git a/kvmd/htserver.py b/kvmd/htserver.py index 351c1328..1ef3cc48 100644 --- a/kvmd/htserver.py +++ b/kvmd/htserver.py @@ -232,6 +232,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): @@ -264,14 +274,24 @@ def set_request_auth_info(req: BaseRequest, info: str) -> None: @dataclasses.dataclass(frozen=True) class WsSession: wsr: WebSocketResponse - kwargs: dict[str, Any] + 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 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) + class HttpServer: def __init__(self) -> None: @@ -353,7 +373,7 @@ class HttpServer: get_logger(2).info("Registered new client session: %s; clients now: %d", ws, len(self.__ws_sessions)) try: - await self._on_ws_opened() + await self._on_ws_opened(ws) yield ws finally: await aiotools.shield_fg(self.__close_ws(ws)) @@ -384,17 +404,12 @@ class HttpServer: break return ws.wsr - async def _broadcast_ws_event(self, event_type: str, event: (dict | None), legacy: (bool | None)=None) -> None: + async def _broadcast_ws_event(self, event_type: str, event: (dict | None)) -> None: if self.__ws_sessions: 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 - and (legacy is None or ws.kwargs.get("legacy") == legacy) - ) + if ws.is_alive() ], return_exceptions=True) async def _close_all_wss(self) -> bool: @@ -414,7 +429,7 @@ class HttpServer: await ws.wsr.close() except Exception: pass - await self._on_ws_closed() + await self._on_ws_closed(ws) # ===== @@ -430,10 +445,10 @@ class HttpServer: async def _on_cleanup(self) -> None: pass - async def _on_ws_opened(self) -> None: + async def _on_ws_opened(self, ws: WsSession) -> None: pass - async def _on_ws_closed(self) -> None: + async def _on_ws_closed(self, ws: WsSession) -> None: pass # ===== diff --git a/kvmd/keyboard/mappings.py b/kvmd/keyboard/mappings.py index 8e7d87d1..57744cea 100644 --- a/kvmd/keyboard/mappings.py +++ b/kvmd/keyboard/mappings.py @@ -168,7 +168,13 @@ class WebModifiers: CTRL_LEFT = "ControlLeft" CTRL_RIGHT = "ControlRight" - CTRLS = set([CTRL_RIGHT, CTRL_RIGHT]) + CTRLS = set([CTRL_LEFT, CTRL_RIGHT]) + + META_LEFT = "MetaLeft" + META_RIGHT = "MetaRight" + METAS = set([META_LEFT, META_RIGHT]) + + ALL = (SHIFTS | ALTS | CTRLS | METAS) class X11Modifiers: diff --git a/kvmd/keyboard/mappings.py.mako b/kvmd/keyboard/mappings.py.mako index a8df423c..1be41854 100644 --- a/kvmd/keyboard/mappings.py.mako +++ b/kvmd/keyboard/mappings.py.mako @@ -60,7 +60,13 @@ class WebModifiers: CTRL_LEFT = "ControlLeft" CTRL_RIGHT = "ControlRight" - CTRLS = set([CTRL_RIGHT, CTRL_RIGHT]) + CTRLS = set([CTRL_LEFT, CTRL_RIGHT]) + + META_LEFT = "MetaLeft" + META_RIGHT = "MetaRight" + METAS = set([META_LEFT, META_RIGHT]) + + ALL = (SHIFTS | ALTS | CTRLS | METAS) class X11Modifiers: diff --git a/kvmd/mouse.py b/kvmd/mouse.py index 31894297..399c6a33 100644 --- a/kvmd/mouse.py +++ b/kvmd/mouse.py @@ -32,3 +32,17 @@ 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) diff --git a/kvmd/plugins/hid/__init__.py b/kvmd/plugins/hid/__init__.py index 73ff5d04..a385023a 100644 --- a/kvmd/plugins/hid/__init__.py +++ b/kvmd/plugins/hid/__init__.py @@ -37,6 +37,7 @@ 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 WebModifiers from ...mouse import MouseRange from .. import BasePlugin @@ -64,11 +65,13 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes self.__mouse_x_range = (mouse_x_min, mouse_x_max) self.__mouse_y_range = (mouse_y_min, mouse_y_max) - self.__jiggler_enabled = jiggler_enabled - self.__jiggler_active = jiggler_active - self.__jiggler_interval = jiggler_interval - self.__jiggler_absolute = True - self.__activity_ts = 0 + self.__j_enabled = jiggler_enabled + self.__j_active = jiggler_active + self.__j_interval = jiggler_interval + self.__j_absolute = True + self.__j_activity_ts = 0 + self.__j_last_x = 0 + self.__j_last_y = 0 @classmethod def _get_base_options(cls) -> dict[str, Any]: @@ -83,7 +86,7 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes "max": Option(MouseRange.MAX, type=valid_hid_mouse_move, unpack_as="mouse_y_max"), }, "jiggler": { - "enabled": Option(False, type=valid_bool, unpack_as="jiggler_enabled"), + "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"), }, @@ -137,13 +140,25 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes # ===== - def send_key_events(self, keys: Iterable[tuple[str, bool]], no_ignore_keys: bool=False) -> None: + async def send_key_events( + self, + keys: Iterable[tuple[str, bool]], + no_ignore_keys: bool=False, + slow: bool=False, + ) -> None: + for (key, state) in keys: if no_ignore_keys or key not in self.__ignore_keys: - self.send_key_event(key, state) + if slow: + await asyncio.sleep(0.02) + self.send_key_event(key, state, False) - def send_key_event(self, key: str, state: bool) -> None: + def send_key_event(self, key: str, state: bool, finish: bool) -> None: self._send_key_event(key, state) + if state and finish and (key not in WebModifiers.ALL and key != "PrintScreen"): + # Считаем что PrintScreen это модификатор для Alt+SysRq+... + # По-хорошему надо учитывать факт нажатия на Alt, но можно и забить. + self._send_key_event(key, False) self.__bump_activity() def _send_key_event(self, key: str, state: bool) -> None: @@ -161,6 +176,8 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes # ===== def send_mouse_move_event(self, to_x: int, to_y: int) -> None: + 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: @@ -229,37 +246,38 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes handler(*xy) def __bump_activity(self) -> None: - self.__activity_ts = int(time.monotonic()) + self.__j_activity_ts = int(time.monotonic()) def _set_jiggler_absolute(self, absolute: bool) -> None: - self.__jiggler_absolute = absolute + self.__j_absolute = absolute def _set_jiggler_active(self, active: bool) -> None: - if self.__jiggler_enabled: - self.__jiggler_active = active + if self.__j_enabled: + self.__j_active = active def _get_jiggler_state(self) -> dict: return { "jiggler": { - "enabled": self.__jiggler_enabled, - "active": self.__jiggler_active, - "interval": self.__jiggler_interval, + "enabled": self.__j_enabled, + "active": self.__j_active, + "interval": self.__j_interval, }, } # ===== async def systask(self) -> None: - factor = 1 while True: - if self.__jiggler_active and (self.__activity_ts + self.__jiggler_interval < int(time.monotonic())): - for _ in range(5): - if self.__jiggler_absolute: - self.send_mouse_move_event(100 * factor, 100 * factor) - else: - self.send_mouse_relative_event(10 * factor, 10 * factor) - factor *= -1 - await asyncio.sleep(0.1) + if self.__j_active and (self.__j_activity_ts + self.__j_interval < int(time.monotonic())): + if self.__j_absolute: + (x, y) = (self.__j_last_x, self.__j_last_y) + for move in [100, -100, 100, -100, 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, 10, -10]: + self.send_mouse_relative_event(move, move) + await asyncio.sleep(0.1) await asyncio.sleep(1) diff --git a/kvmd/plugins/hid/_mcu/proto.py b/kvmd/plugins/hid/_mcu/proto.py index deaf5c15..315c1f89 100644 --- a/kvmd/plugins/hid/_mcu/proto.py +++ b/kvmd/plugins/hid/_mcu/proto.py @@ -26,6 +26,7 @@ import struct from ....keyboard.mappings import KEYMAP from ....mouse import MouseRange +from ....mouse import MouseDelta from .... import tools from .... import bitbang @@ -162,8 +163,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)) @@ -175,8 +176,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: # Горизонтальная прокрутка пока не поддерживается diff --git a/kvmd/plugins/hid/ch9329/mouse.py b/kvmd/plugins/hid/ch9329/mouse.py index 0a0cfbcc..d61bab4e 100644 --- a/kvmd/plugins/hid/ch9329/mouse.py +++ b/kvmd/plugins/hid/ch9329/mouse.py @@ -23,6 +23,7 @@ import math from ....mouse import MouseRange +from ....mouse import MouseDelta # ===== @@ -79,7 +80,7 @@ class Mouse: # pylint: disable=too-many-instance-attributes def process_wheel(self, delta_x: int, delta_y: int) -> bytes: _ = delta_x - assert -127 <= delta_y <= 127 + 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() @@ -110,6 +111,6 @@ class Mouse: # pylint: disable=too-many-instance-attributes ]) def __fix_relative(self, value: int) -> int: - assert -127 <= value <= 127 + 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/events.py b/kvmd/plugins/hid/otg/events.py index ae2ba67d..44f5e373 100644 --- a/kvmd/plugins/hid/otg/events.py +++ b/kvmd/plugins/hid/otg/events.py @@ -27,6 +27,7 @@ from ....keyboard.mappings import UsbKey from ....keyboard.mappings import KEYMAP from ....mouse import MouseRange +from ....mouse import MouseDelta # ===== @@ -144,8 +145,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 +155,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/mouse.py b/kvmd/plugins/hid/otg/mouse.py index 7d3bd2e6..ae8d53b0 100644 --- a/kvmd/plugins/hid/otg/mouse.py +++ b/kvmd/plugins/hid/otg/mouse.py @@ -153,7 +153,6 @@ class MouseProcess(BaseDeviceProcess): 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 +176,3 @@ class MouseProcess(BaseDeviceProcess): def __clear_state(self) -> None: self.__pressed_buttons = 0 - self.__x = 0 - self.__y = 0 diff --git a/kvmd/tools.py b/kvmd/tools.py index 14a58ab3..6dd7d2f9 100644 --- a/kvmd/tools.py +++ b/kvmd/tools.py @@ -20,6 +20,7 @@ # ========================================================================== # +import asyncio import operator import functools import multiprocessing.queues @@ -64,11 +65,11 @@ def swapped_kvs(dct: dict[_DictKeyT, _DictValueT]) -> dict[_DictValueT, _DictKey # ===== -def clear_queue(q: multiprocessing.queues.Queue) -> None: # pylint: disable=invalid-name +def clear_queue(q: (multiprocessing.queues.Queue | asyncio.Queue)) -> None: # pylint: disable=invalid-name for _ in range(q.qsize()): try: q.get_nowait() - except queue.Empty: + except (queue.Empty, asyncio.QueueEmpty): break diff --git a/kvmd/usb.py b/kvmd/usb.py index 54309c2c..34646c3f 100644 --- a/kvmd/usb.py +++ b/kvmd/usb.py @@ -55,3 +55,11 @@ G_PROFILE = f"configs/{G_PROFILE_NAME}" 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 39ff60aa..aa997ab9 100644 --- a/kvmd/validators/__init__.py +++ b/kvmd/validators/__init__.py @@ -99,3 +99,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/hid.py b/kvmd/validators/hid.py index 376a7f62..0f74a6eb 100644 --- a/kvmd/validators/hid.py +++ b/kvmd/validators/hid.py @@ -25,6 +25,7 @@ from typing import Any from ..keyboard.mappings import KEYMAP from ..mouse import MouseRange +from ..mouse import MouseDelta from . import check_string_in_list @@ -46,7 +47,7 @@ def valid_hid_key(arg: Any) -> str: 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) def valid_hid_mouse_button(arg: Any) -> str: @@ -55,4 +56,4 @@ def valid_hid_mouse_button(arg: Any) -> str: 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/os.py b/kvmd/validators/os.py index 94d3a40f..b2381d0b 100644 --- a/kvmd/validators/os.py +++ b/kvmd/validators/os.py @@ -26,6 +26,7 @@ import stat from typing import Any from . import raise_error +from . import filter_printable from .basic import valid_number from .basic import valid_string_list @@ -75,9 +76,7 @@ def valid_abs_dir(arg: Any, name: str="") -> str: 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,12 +84,7 @@ 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) # ===== diff --git a/kvmd/validators/switch.py b/kvmd/validators/switch.py new file mode 100644 index 00000000..d4f3ab2f --- /dev/null +++ b/kvmd/validators/switch.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 re + +from typing import Any + +from . import filter_printable +from . import check_re_match + +from .basic import valid_stripped_string +from .basic import valid_number + + +# ===== +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() + + +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() + + +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]{512}$").upper() + + +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 + + +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/scripts/kvmd-bootconfig b/scripts/kvmd-bootconfig index c2fc5c6f..ad98e21c 100755 --- a/scripts/kvmd-bootconfig +++ b/scripts/kvmd-bootconfig @@ -256,7 +256,16 @@ if [ -n "$WIFI_ESSID" ]; then else make_dhcp_iface "$WIFI_IFACE" 50 fi - wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" + if [ "${#WIFI_PASSWD}" -ge 8 ];then + wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" + else + cat < "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" +network={ + ssid=$(printf '"%q"' "$WIFI_ESSID") + key_mgmt=NONE +} +end_of_file + fi chmod 640 "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" if [ -n "$WIFI_HIDDEN" ]; then sed -i -e 's/^}/\tscan_ssid=1\n}/g' "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" diff --git a/setup.py b/setup.py index 5fdbeb91..3ba240e5 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.20", + version="4.49", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", @@ -83,8 +83,10 @@ def main() -> None: "kvmd.clients", "kvmd.apps", "kvmd.apps.kvmd", + "kvmd.apps.kvmd.switch", "kvmd.apps.kvmd.info", "kvmd.apps.kvmd.api", + "kvmd.apps.media", "kvmd.apps.pst", "kvmd.apps.pstrun", "kvmd.apps.otg", @@ -92,6 +94,7 @@ def main() -> None: "kvmd.apps.otgnet", "kvmd.apps.otgmsd", "kvmd.apps.otgconf", + "kvmd.apps.swctl", "kvmd.apps.htpasswd", "kvmd.apps.totp", "kvmd.apps.edidconf", @@ -116,6 +119,7 @@ def main() -> None: 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", @@ -140,7 +144,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.12", + "Programming Language :: Python :: 3.13", "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 00000000..5c574c88 --- /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 00000000..4988126a --- /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 00000000..588522aa --- /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 0000000000000000000000000000000000000000..c3f374918ede7a622f94ed8ec0663ca4aaebed8f GIT binary patch literal 192512 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

4CKr*~G!f<)jUf3O2bvVg+T7 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 o9$n}b6cz+IauTTEx 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 z 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 Y#bWURu=PCaIM|H+f7|~$2@wAO55o!f`v3p{ literal 0 HcmV?d00001 diff --git a/testenv/Dockerfile b/testenv/Dockerfile index 95b62a86..3164b4d8 100644 --- a/testenv/Dockerfile +++ b/testenv/Dockerfile @@ -8,6 +8,8 @@ RUN echo 'Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/$repo/os/$arch && pacman-key --populate archlinux RUN pacman --noconfirm --ask=4 -Syy \ + && pacman --needed --noconfirm --ask=4 -S \ + archlinux-keyring \ && pacman --needed --noconfirm --ask=4 -S \ glibc \ pacman \ @@ -17,7 +19,6 @@ RUN pacman --noconfirm --ask=4 -Syy \ && pacman --noconfirm --ask=4 -Syu \ && pacman --needed --noconfirm --ask=4 -S \ p11-kit \ - archlinux-keyring \ ca-certificates \ ca-certificates-mozilla \ ca-certificates-utils \ @@ -48,6 +49,7 @@ RUN pacman --noconfirm --ask=4 -Syy \ python-pyotp \ python-qrcode \ python-pyserial \ + python-pyudev \ python-setproctitle \ python-psutil \ python-netifaces \ diff --git a/testenv/linters/pylint.ini b/testenv/linters/pylint.ini index fd2d8b35..835f8ad2 100644 --- a/testenv/linters/pylint.ini +++ b/testenv/linters/pylint.ini @@ -39,6 +39,7 @@ disable = consider-using-f-string, unnecessary-lambda-assignment, too-many-positional-arguments, + no-else-continue, # https://github.com/PyCQA/pylint/issues/3882 [CLASSES] diff --git a/testenv/linters/vulture-wl.py b/testenv/linters/vulture-wl.py index d8efd97a..32cfdf88 100644 --- a/testenv/linters/vulture-wl.py +++ b/testenv/linters/vulture-wl.py @@ -57,3 +57,28 @@ Dumper.ignore_aliases _auth_server_port_fixture _test_user + +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 +StateCache.get_port_names +StateCache.get_atx_cp_delays +StateCache.get_atx_cpl_delays +StorageContext.write_edids +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_colors +StorageContext.read_port_names +StorageContext.read_atx_cp_delays +StorageContext.read_atx_cpl_delays +StorageContext.read_atx_cr_delays diff --git a/testenv/tests/validators/test_switch.py b/testenv/tests/validators/test_switch.py new file mode 100644 index 00000000..6f41c6cf --- /dev/null +++ b/testenv/tests/validators/test_switch.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 . # +# # +# ========================================================================== # + + +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" * 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/tox.ini b/testenv/tox.ini index 5ca65f5d..8ebbdb2e 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.12 +basepython = python3.13 sitepackages = true changedir = /src diff --git a/testenv/v2-hdmi-rpi4.override.yaml b/testenv/v2-hdmi-rpi4.override.yaml index f8a301f1..5de7f63c 100644 --- a/testenv/v2-hdmi-rpi4.override.yaml +++ b/testenv/v2-hdmi-rpi4.override.yaml @@ -35,6 +35,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" gpio: drivers: @@ -148,8 +150,6 @@ vnc: enabled: true memsink: - jpeg: - sink: "" h264: sink: "" diff --git a/web/kvm/index.html b/web/kvm/index.html index f5799301..19f00d14 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -142,7 +142,7 @@ -

  • System +
  • System +
    +