mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2025-12-12 01:00:29 +08:00
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
This commit is contained in:
parent
5db37797ea
commit
7b3335ea94
@ -1,7 +1,7 @@
|
||||
[bumpversion]
|
||||
commit = True
|
||||
tag = True
|
||||
current_version = 4.20
|
||||
current_version = 4.49
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)(\.(?P<patch>\d+)(\-(?P<release>[a-z]+))?)?
|
||||
serialize =
|
||||
{major}.{minor}
|
||||
|
||||
13
Makefile
13
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) \
|
||||
|
||||
21
PKGBUILD
21
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\"
|
||||
|
||||
@ -5,3 +5,7 @@ audio: {
|
||||
device = "hw:0"
|
||||
tc358743 = "/dev/video0"
|
||||
}
|
||||
aplay: {
|
||||
device = "plughw:UAC2Gadget,0"
|
||||
check = "/run/kvmd/otg/uac2.usb0@meta.json"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
16
configs/os/services/kvmd-media.service
Normal file
16
configs/os/services/kvmd-media.service
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
5
extras/media/manifest.yaml
Normal file
5
extras/media/manifest.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: Media
|
||||
description: KVMD Media Proxy
|
||||
path: media
|
||||
daemon: kvmd-media
|
||||
place: -1
|
||||
3
extras/media/nginx.ctx-http.conf
Normal file
3
extras/media/nginx.ctx-http.conf
Normal file
@ -0,0 +1,3 @@
|
||||
upstream media {
|
||||
server unix:/run/kvmd/media.sock fail_timeout=0s max_fails=0;
|
||||
}
|
||||
7
extras/media/nginx.ctx-server.conf
Normal file
7
extras/media/nginx.ctx-server.conf
Normal file
@ -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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
10
kvmd.install
10
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
|
||||
|
||||
@ -20,4 +20,4 @@
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
__version__ = "4.20"
|
||||
__version__ = "4.49"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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"])),
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
164
kvmd/apps/kvmd/api/switch.py
Normal file
164
kvmd/apps/kvmd/api/switch.py
Normal file
@ -0,0 +1,164 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
from 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()
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
400
kvmd/apps/kvmd/switch/__init__.py
Normal file
400
kvmd/apps/kvmd/switch/__init__.py
Normal file
@ -0,0 +1,400 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
from typing import 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
|
||||
440
kvmd/apps/kvmd/switch/chain.py
Normal file
440
kvmd/apps/kvmd/switch/chain.py
Normal file
@ -0,0 +1,440 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import 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
|
||||
196
kvmd/apps/kvmd/switch/device.py
Normal file
196
kvmd/apps/kvmd/switch/device.py
Normal file
@ -0,0 +1,196 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import os
|
||||
import 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
|
||||
35
kvmd/apps/kvmd/switch/lib.py
Normal file
35
kvmd/apps/kvmd/switch/lib.py
Normal file
@ -0,0 +1,35 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
# 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
|
||||
295
kvmd/apps/kvmd/switch/proto.py
Normal file
295
kvmd/apps/kvmd/switch/proto.py
Normal file
@ -0,0 +1,295 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import struct
|
||||
import dataclasses
|
||||
|
||||
from 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("<BHBB")
|
||||
|
||||
SIZE = __struct.size
|
||||
|
||||
def pack(self) -> 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("<B")
|
||||
|
||||
@classmethod
|
||||
def unpack(cls, data: bytes, offset: int=0) -> "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("<HHHBBHHHHHHBBBHHHHBxB30x")
|
||||
|
||||
def compare_edid(self, ch: int, edid: Optional["Edid"]) -> 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("<B")
|
||||
|
||||
@classmethod
|
||||
def unpack(cls, data: bytes, offset: int=0) -> "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("<BBH")
|
||||
|
||||
def __post_init__(self) -> 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
|
||||
358
kvmd/apps/kvmd/switch/state.py
Normal file
358
kvmd/apps/kvmd/switch/state.py
Normal file
@ -0,0 +1,358 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import asyncio
|
||||
import 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)
|
||||
186
kvmd/apps/kvmd/switch/storage.py
Normal file
186
kvmd/apps/kvmd/switch/storage.py
Normal file
@ -0,0 +1,186 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import 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)
|
||||
308
kvmd/apps/kvmd/switch/types.py
Normal file
308
kvmd/apps/kvmd/switch/types.py
Normal file
@ -0,0 +1,308 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import re
|
||||
import 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("<BBBBH")
|
||||
__rx = re.compile(r"^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{4})$")
|
||||
|
||||
def __post_init__(self) -> 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("<HHHHH")
|
||||
|
||||
def __post_init__(self) -> 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)
|
||||
@ -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,
|
||||
|
||||
48
kvmd/apps/media/__init__.py
Normal file
48
kvmd/apps/media/__init__.py
Normal file
@ -0,0 +1,48 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
from ...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())
|
||||
24
kvmd/apps/media/__main__.py
Normal file
24
kvmd/apps/media/__main__.py
Normal file
@ -0,0 +1,24 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
from . import main
|
||||
main()
|
||||
190
kvmd/apps/media/server.py
Normal file
190
kvmd/apps/media/server.py
Normal file
@ -0,0 +1,190 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import asyncio
|
||||
import 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)
|
||||
@ -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 =====")
|
||||
|
||||
|
||||
@ -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="<name>", help="Enable function(s)")
|
||||
parser.add_argument("-d", "--disable-function", nargs="+", metavar="<name>", help="Disable function(s)")
|
||||
parser.add_argument("-e", "--enable-function", nargs="+", default=[], metavar="<name>", help="Enable function(s)")
|
||||
parser.add_argument("-d", "--disable-function", nargs="+", default=[], metavar="<name>", help="Disable function(s)")
|
||||
parser.add_argument("-r", "--reset-gadget", action="store_true", help="Reset gadget")
|
||||
parser.add_argument("--make-gpio-config", action="store_true")
|
||||
options = parser.parse_args(argv[1:])
|
||||
|
||||
gc = _GadgetControl(config.otg.meta, config.otg.gadget, config.otg.udc, config.otg.init_delay)
|
||||
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:
|
||||
|
||||
@ -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="<N>", help="Drive instance (0 for KVMD drive)")
|
||||
parser.add_argument("--set-cdrom", default=None, type=valid_bool,
|
||||
metavar="<1|0|yes|no>", help="Set CD-ROM flag")
|
||||
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="<path>", 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 "<none>"))
|
||||
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"))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ...")
|
||||
|
||||
167
kvmd/apps/swctl/__init__.py
Normal file
167
kvmd/apps/swctl/__init__.py
Normal file
@ -0,0 +1,167 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import os
|
||||
import 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")
|
||||
24
kvmd/apps/swctl/__main__.py
Normal file
24
kvmd/apps/swctl/__main__.py
Normal file
@ -0,0 +1,24 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
from . import main
|
||||
main()
|
||||
@ -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)),
|
||||
|
||||
@ -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)}"]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
93
kvmd/clients/pst.py
Normal file
93
kvmd/clients/pst.py
Normal file
@ -0,0 +1,93 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import os
|
||||
import contextlib
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import 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),
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
# =====
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
# Горизонтальная прокрутка пока не поддерживается
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
# =====
|
||||
|
||||
67
kvmd/validators/switch.py
Normal file
67
kvmd/validators/switch.py
Normal file
@ -0,0 +1,67 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
import re
|
||||
|
||||
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")
|
||||
@ -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 <<end_of_file > "/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"
|
||||
|
||||
8
setup.py
8
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",
|
||||
|
||||
15
switch/LICENSE
Normal file
15
switch/LICENSE
Normal file
@ -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.
|
||||
8
switch/Makefile
Normal file
8
switch/Makefile
Normal file
@ -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
|
||||
1
switch/mnt/README
Normal file
1
switch/mnt/README
Normal file
@ -0,0 +1 @@
|
||||
This is a mount point for the switch.
|
||||
BIN
switch/switch.uf2
Normal file
BIN
switch/switch.uf2
Normal file
Binary file not shown.
@ -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 \
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
180
testenv/tests/validators/test_switch.py
Normal file
180
testenv/tests/validators/test_switch.py
Normal file
@ -0,0 +1,180 @@
|
||||
# ========================================================================== #
|
||||
# #
|
||||
# KVMD - The main PiKVM daemon. #
|
||||
# #
|
||||
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
|
||||
# #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
# ========================================================================== #
|
||||
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from kvmd.validators import ValidatorError
|
||||
from kvmd.validators.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))
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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: ""
|
||||
|
||||
|
||||
@ -142,7 +142,7 @@
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
<li class="right" id="system-dropdown"><a class="menu-button" href="#"><img class="led-gray" id="link-led" src="/share/svg/led-link.svg"><img class="led-gray" id="stream-led" src="/share/svg/led-stream.svg"><img class="led-gray" id="hid-keyboard-led" src="/share/svg/led-hid-keyboard.svg"><img class="led-gray" id="hid-mouse-led" src="/share/svg/led-hid-mouse.svg"><span i18n="kvm_text3">System</span></a>
|
||||
<li class="right" id="system-dropdown"><a class="menu-button" href="#"><img class="led-gray" id="link-led" src="/share/svg/led-link.svg"><img class="led-gray" id="stream-led" src="/share/svg/led-video.svg"><img class="led-gray" id="hid-keyboard-led" src="/share/svg/led-hid-keyboard.svg"><img class="led-gray" id="hid-mouse-led" src="/share/svg/led-hid-mouse.svg"><span>System</span></a>
|
||||
<div class="menu" id="system-menu">
|
||||
<table class="kv">
|
||||
<tr>
|
||||
@ -173,6 +173,17 @@
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
<div class="hidden" id="stream-message-no-vd">
|
||||
<div class="text">
|
||||
<table>
|
||||
<tr>
|
||||
<td rowspan="2"><img class="sign " src="/share/svg/warning.svg"></td>
|
||||
<td style="line-height:1.5"><b>Direct HTTP H.264 streaming is not supported</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
<div class="hidden" id="stream-message-no-h264">
|
||||
<div class="text">
|
||||
<table>
|
||||
@ -223,10 +234,12 @@
|
||||
<td i18n="kvm_text14">Video <a target="_blank" href="https://docs.pikvm.org/webrtc">mode</a>:</td>
|
||||
<td>
|
||||
<div class="radio-box">
|
||||
<input checked type="radio" id="stream-mode-radio-mjpeg" name="stream-mode-radio" value="mjpeg">
|
||||
<label for="stream-mode-radio-mjpeg">MJPEG / HTTP</label>
|
||||
<input type="radio" id="stream-mode-radio-janus" name="stream-mode-radio" value="janus">
|
||||
<label for="stream-mode-radio-janus">H.264 / WebRTC</label>
|
||||
<label for="stream-mode-radio-janus">WebRTC</label>
|
||||
<input type="radio" id="stream-mode-radio-media" name="stream-mode-radio" value="media">
|
||||
<label for="stream-mode-radio-media">H.264</label>
|
||||
<input checked type="radio" id="stream-mode-radio-mjpeg" name="stream-mode-radio" value="mjpeg">
|
||||
<label for="stream-mode-radio-mjpeg">MJPEG</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -252,6 +265,15 @@
|
||||
</td>
|
||||
<td class="value-number" id="stream-audio-volume-value"></td>
|
||||
</tr>
|
||||
<tr class="feature-disabled" id="stream-mic">
|
||||
<td>Microphone:</td>
|
||||
<td align="right">
|
||||
<div class="switch-box">
|
||||
<input disabled type="checkbox" id="stream-mic-switch">
|
||||
<label for="stream-mic-switch"><span class="switch-inner"></span><span class="switch"></span></label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr>
|
||||
<div class="buttons buttons-row">
|
||||
@ -280,6 +302,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
<details>
|
||||
<summary>Keyboard & mouse (HID) settings</summary>
|
||||
<summary i18n="kvm_text25">Keyboard & Mouse (HID) settings</summary>
|
||||
<div class="spoiler">
|
||||
<table class="kv">
|
||||
@ -396,6 +419,15 @@
|
||||
</div>
|
||||
</details>
|
||||
<table class="kv">
|
||||
<tr>
|
||||
<td>Bad link mode (release keys immediately):</td>
|
||||
<td align="right">
|
||||
<div class="switch-box">
|
||||
<input type="checkbox" id="hid-keyboard-bad-link-switch">
|
||||
<label for="hid-keyboard-bad-link-switch"><span class="switch-inner"></span><span class="switch"></span></label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="feature-disabled" id="hid-connect">
|
||||
<td i18n="hid-connect-switch">Connect HID to Server:</td>
|
||||
<td align="right">
|
||||
@ -416,6 +448,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="hid-mute-switch">Mute HID input events:</td>
|
||||
<td>Mute all input HID events:</td>
|
||||
<td align="right">
|
||||
<div class="switch-box">
|
||||
<input type="checkbox" id="hid-mute-switch">
|
||||
@ -502,15 +535,15 @@
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
<div class="hidden" id="msd-message-too-big-for-cdrom">
|
||||
<div class="hidden" id="msd-message-too-big-for-dvd">
|
||||
<div class="text">
|
||||
<table>
|
||||
<tr>
|
||||
<td rowspan="2"><img class="sign msd-message-too-big-for-cdrom" src="/share/svg/warning.svg"></td>
|
||||
<td rowspan="2"><img class="sign " src="/share/svg/warning.svg"></td>
|
||||
<td style="line-height:1.5"><b>Current image is too big for CD-ROM!</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sup style="line-height:1">The device filesystem will be truncated to 2.2GiB</sup></td>
|
||||
<td><sup style="line-height:1">The maximum is 31.6GiB. Please switch to the Flash mode.</sup></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@ -580,7 +613,7 @@
|
||||
<td>
|
||||
<div class="radio-box">
|
||||
<input checked type="radio" id="msd-mode-radio-cdrom" name="msd-mode-radio" value="1">
|
||||
<label for="msd-mode-radio-cdrom">CD-ROM</label>
|
||||
<label for="msd-mode-radio-cdrom">CD/DVD</label>
|
||||
<input type="radio" id="msd-mode-radio-flash" name="msd-mode-radio" value="0">
|
||||
<label for="msd-mode-radio-flash">Flash</label>
|
||||
</div>
|
||||
@ -754,16 +787,16 @@
|
||||
</table>
|
||||
<table class="kv">
|
||||
<tr>
|
||||
<td i18n="hid-pak-ask-switch">Ask paste confirmation:</td>
|
||||
<td>Slow typing:</td>
|
||||
<td align="right">
|
||||
<div class="switch-box">
|
||||
<input checked type="checkbox" id="hid-pak-ask-switch">
|
||||
<label for="hid-pak-ask-switch"><span class="switch-inner"></span><span class="switch"></span></label>
|
||||
<input type="checkbox" id="hid-pak-slow-switch">
|
||||
<label for="hid-pak-slow-switch"><span class="switch-inner"></span><span class="switch"></span></label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="feature-disabled" id="hid-pak-secure">
|
||||
<td i18n="hid-pak-secure-switch">Hide input text:</td>
|
||||
<tr>
|
||||
<td>Hide input text:</td>
|
||||
<td align="right">
|
||||
<div class="switch-box">
|
||||
<input type="checkbox" id="hid-pak-secure-switch">
|
||||
@ -771,6 +804,15 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ask paste confirmation:</td>
|
||||
<td align="right">
|
||||
<div class="switch-box">
|
||||
<input checked type="checkbox" id="hid-pak-ask-switch">
|
||||
<label for="hid-pak-ask-switch"><span class="switch-inner"></span><span class="switch"></span></label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="feature-disabled" id="stream-ocr">
|
||||
<hr><br>
|
||||
@ -809,7 +851,7 @@
|
||||
<hr>
|
||||
<div class="buttons">
|
||||
<div class="buttons-row">
|
||||
<button class="row50" data-force-hide-menu data-shortcut="CapsLock">• Caps Lock <img class="inline-lamp hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"></button>
|
||||
<button class="row50" data-force-hide-menu data-shortcut="CapsLock">• Caps Lock <img class="inline-lamp-small hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"></button>
|
||||
<button class="row50" data-force-hide-menu data-shortcut="MetaLeft">• Left Win</button>
|
||||
</div>
|
||||
<hr>
|
||||
@ -884,6 +926,50 @@
|
||||
<li class="right feature-disabled" id="gpio-dropdown"><a class="menu-button" id="gpio-menu-button" href="#"><span>GPIO</span></a>
|
||||
<div class="menu" id="gpio-menu"></div>
|
||||
</li>
|
||||
<li class="right feature-disabled" id="switch-dropdown"><a class="menu-button" id="switch-menu-button" href="#"><img class="led-gray" id="switch-atx-power-led" src="/share/svg/led-atx-power.svg"><img class="led-gray" id="switch-atx-hdd-led" src="/share/svg/led-atx-hdd.svg"><span>Switch <i><sub id="switch-active-port"></sub></i></span></a>
|
||||
<div class="menu" id="switch-menu">
|
||||
<table style="border-spacing: 0px;">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="text"><b><a target="_blank" href="https://docs.pikvm.org/switch">PiKVM Switch</a> is attached<br></b><sub>Select a port or perform any available action like ATX click</sub></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text">
|
||||
<button class="small" data-force-hide-menu data-show-window="switch-window">• Settings</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr>
|
||||
<div class="hidden" id="switch-message-update">
|
||||
<div class="text">
|
||||
<table>
|
||||
<tr>
|
||||
<td rowspan="2"><img class="sign " src="/share/svg/info.svg"></td>
|
||||
<td style="line-height:1.5"><b>Good news! Your switch is ready to get the firmware update</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sup style="line-height:1">Please <a target="_blank" href="https://docs.pikvm.org/switch/#firmware-updating">follow the instructions</a> when you decide to install it.</sup></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
<table class="kv">
|
||||
<tr>
|
||||
<td>Ask ATX click confirmation:</td>
|
||||
<td align="right">
|
||||
<div class="switch-box">
|
||||
<input checked type="checkbox" id="switch-atx-ask-switch">
|
||||
<label for="switch-atx-ask-switch"><span class="switch-inner"></span><span class="switch"></span></label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr>
|
||||
<table class="kv" id="switch-chain"></table>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="window" id="stream-ocr-window">
|
||||
<div class="hidden" id="stream-ocr-selection"></div>
|
||||
@ -901,6 +987,7 @@
|
||||
<button class="window-button-exit-full-tab">▼</button>
|
||||
<div class="stream-box-offline" id="stream-box"><img id="stream-image" src="/share/png/blank-stream.png">
|
||||
<video class="hidden" id="stream-video" disablePictureInPicture="true" autoplay playsinline muted></video>
|
||||
<canvas class="hidden" id="stream-canvas"></canvas>
|
||||
<div id="stream-fullscreen-active"></div>
|
||||
</div>
|
||||
<div class="keypad" id="stream-mouse-buttons" align="center">
|
||||
@ -1168,7 +1255,7 @@
|
||||
</div>
|
||||
<div class="keypad-row">
|
||||
<div class="key wide-2 left small" data-code="CapsLock">
|
||||
<div class="label"><img class="inline-lamp hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"><br> Caps Lock
|
||||
<div class="label"><img class="inline-lamp-small hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"><br> Caps Lock
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
@ -1343,7 +1430,7 @@
|
||||
</div>
|
||||
<div class="spacer-fixed"></div>
|
||||
<div class="key small" data-code="ScrollLock">
|
||||
<div class="label"><img class="inline-lamp hid-keyboard-scroll-led led-gray" src="/share/svg/led-square.svg"><br> ScrLk
|
||||
<div class="label"><img class="inline-lamp-small hid-keyboard-scroll-led led-gray" src="/share/svg/led-square.svg"><br> ScrLk
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer-fixed"></div>
|
||||
@ -1439,7 +1526,7 @@
|
||||
<hr>
|
||||
<div class="keypad-row">
|
||||
<div class="key small" data-code="NumLock">
|
||||
<div class="label"><img class="inline-lamp hid-keyboard-num-led led-gray" src="/share/svg/led-square.svg"><br> NmLk
|
||||
<div class="label"><img class="inline-lamp-small hid-keyboard-num-led led-gray" src="/share/svg/led-square.svg"><br> NmLk
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer-fixed"></div>
|
||||
@ -1645,7 +1732,7 @@
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="key small" data-code="ScrollLock">
|
||||
<div class="label"><img class="inline-lamp hid-keyboard-scroll-led led-gray" src="/share/svg/led-square.svg"><br> ScrLk
|
||||
<div class="label"><img class="inline-lamp-small hid-keyboard-scroll-led led-gray" src="/share/svg/led-square.svg"><br> ScrLk
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
@ -1818,7 +1905,7 @@
|
||||
</div>
|
||||
<div class="keypad-row">
|
||||
<div class="key wide-2 left small" data-code="CapsLock">
|
||||
<div class="label"><img class="inline-lamp hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"><br> Caps Lock
|
||||
<div class="label"><img class="inline-lamp-small hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"><br> Caps Lock
|
||||
</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
@ -2017,6 +2104,170 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window" id="switch-window" style="width:min-content">
|
||||
<div class="window-header">
|
||||
<div class="window-grab">Switch settings</div>
|
||||
<button class="window-button-close"><b>×</b></button>
|
||||
</div>
|
||||
<div class="tabs-box">
|
||||
<input checked type="radio" name="switch-tab-button" id="switch-tab-edid-button">
|
||||
<label for="switch-tab-edid-button">EDIDs collection</label>
|
||||
<div class="tab">
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<select id="switch-edid-selector" size="8"></select>
|
||||
</td>
|
||||
<td rowspan="2" style="vertical-align:top">
|
||||
<table class="kv">
|
||||
<tr>
|
||||
<td>Manufacturer:</td>
|
||||
<td class="value" id="switch-edid-info-mfc-id"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Product ID:</td>
|
||||
<td class="value" id="switch-edid-info-product-id"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Serial:</td>
|
||||
<td class="value" id="switch-edid-info-serial"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Monitor name:</td>
|
||||
<td class="value" id="switch-edid-info-monitor-name"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Extra serial:</td>
|
||||
<td class="value" id="switch-edid-info-monitor-serial"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Audio enabled:</td>
|
||||
<td class="value" id="switch-edid-info-audio"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Data:</td>
|
||||
<td>
|
||||
<button class="small" disabled id="switch-edid-copy-data-button">Copy</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<button id="switch-edid-add-button">Add new</button>
|
||||
</td>
|
||||
<td style="float:right">
|
||||
<button disabled id="switch-edid-remove-button">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<input type="radio" name="switch-tab-button" id="switch-tab-colors-button">
|
||||
<label for="switch-tab-colors-button">Color scheme</label>
|
||||
<div class="tab">
|
||||
<table>
|
||||
<!--tr
|
||||
td Role
|
||||
td Color
|
||||
td Brightness
|
||||
td
|
||||
td Reset
|
||||
-->
|
||||
<!--trtd
|
||||
<hr>
|
||||
td
|
||||
<hr>
|
||||
td
|
||||
<hr>
|
||||
td
|
||||
td
|
||||
<hr>
|
||||
-->
|
||||
<tr>
|
||||
<td style="white-space: nowrap">Selected port:</td>
|
||||
<td>
|
||||
<input type="color" id="switch-color-active-input">
|
||||
</td>
|
||||
<td>
|
||||
<input type="range" id="switch-color-active-brightness-slider" style="min-width:150px">
|
||||
</td>
|
||||
<td> </td>
|
||||
<td>
|
||||
<button class="small" id="switch-color-active-default-button" title="Reset default">↻</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="white-space: nowrap">Inactive port:</td>
|
||||
<td>
|
||||
<input type="color" id="switch-color-inactive-input">
|
||||
</td>
|
||||
<td>
|
||||
<input type="range" id="switch-color-inactive-brightness-slider" style="min-width:150px">
|
||||
</td>
|
||||
<td> </td>
|
||||
<td>
|
||||
<button class="small" id="switch-color-inactive-default-button" title="Reset default">↻</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="white-space: nowrap">Blinking beacon:</td>
|
||||
<td>
|
||||
<input type="color" id="switch-color-beacon-input">
|
||||
</td>
|
||||
<td>
|
||||
<input type="range" id="switch-color-beacon-brightness-slider" style="min-width:150px">
|
||||
</td>
|
||||
<td> </td>
|
||||
<td>
|
||||
<button class="small" id="switch-color-beacon-default-button" title="Reset default">↻</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<hr>
|
||||
</td>
|
||||
<td>
|
||||
<hr>
|
||||
</td>
|
||||
<td>
|
||||
<hr>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<hr>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="white-space: nowrap">Flashing downlink:</td>
|
||||
<td>
|
||||
<input type="color" id="switch-color-flashing-input">
|
||||
</td>
|
||||
<td>
|
||||
<input type="range" id="switch-color-flashing-brightness-slider" style="min-width:150px">
|
||||
</td>
|
||||
<td> </td>
|
||||
<td>
|
||||
<button class="small" id="switch-color-flashing-default-button" title="Reset default">↻</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="white-space: nowrap">Bootloader mode:</td>
|
||||
<td>
|
||||
<input type="color" id="switch-color-bootloader-input">
|
||||
</td>
|
||||
<td>
|
||||
<input type="range" id="switch-color-bootloader-brightness-slider" style="min-width:150px">
|
||||
</td>
|
||||
<td> </td>
|
||||
<td>
|
||||
<button class="small" id="switch-color-bootloader-default-button" title="Reset default">↻</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window" id="about-window">
|
||||
<div class="window-header">
|
||||
<div class="window-grab" i18n="kvm_text1">About</div>
|
||||
@ -2102,6 +2353,7 @@
|
||||
<li>Alok Anand</li>
|
||||
<li>Alucard</li>
|
||||
<li>Ananthaneshan Elampoornan</li>
|
||||
<li>Andreas Grundler</li>
|
||||
<li>Andreas Marufke</li>
|
||||
<li>Andreas Schmid</li>
|
||||
<li>Andrew Brant</li>
|
||||
@ -2162,6 +2414,7 @@
|
||||
<li>Brian T Mulcahy</li>
|
||||
<li>Brian Vecchiarelli</li>
|
||||
<li>Brian White</li>
|
||||
<li>brodonalds</li>
|
||||
<li>Bruno Gomes</li>
|
||||
<li>Bryan Adams</li>
|
||||
<li>Bryan Montgomery</li>
|
||||
@ -2498,6 +2751,7 @@
|
||||
<li>Mikael Wikström</li>
|
||||
<li>Mike Mason</li>
|
||||
<li>Mikhael Mariano</li>
|
||||
<li>Milan Burda</li>
|
||||
<li>Milan Múčka</li>
|
||||
<li>Miles Davis</li>
|
||||
<li>Minh Tang</li>
|
||||
@ -2516,6 +2770,7 @@
|
||||
<li>Nick Roethemeier</li>
|
||||
<li>Nico Baumgartner</li>
|
||||
<li>Nicolai Kragh-Hansen</li>
|
||||
<li>Nicolas Christener</li>
|
||||
<li>Nigel Smith</li>
|
||||
<li>Nihal Fernando</li>
|
||||
<li>Nils Orbat</li>
|
||||
@ -2523,6 +2778,7 @@
|
||||
<li>Nithin Philips</li>
|
||||
<li>Nod Swal</li>
|
||||
<li>Nolan Haynes</li>
|
||||
<li>Noxigen LLC</li>
|
||||
<li>nubbn</li>
|
||||
<li>nybble</li>
|
||||
<li>Oh Be</li>
|
||||
@ -2607,6 +2863,7 @@
|
||||
<li>Scuba</li>
|
||||
<li>Sean</li>
|
||||
<li>Sean Akers</li>
|
||||
<li>Sean c Rickard</li>
|
||||
<li>SEAT</li>
|
||||
<li>Sebastian</li>
|
||||
<li>Seonwoo Lee</li>
|
||||
@ -2675,6 +2932,7 @@
|
||||
<li>Udo Schroeter</li>
|
||||
<li>Uli Fahrer</li>
|
||||
<li>Vasily Lazarev</li>
|
||||
<li>Venmo</li>
|
||||
<li>Vidru Eduard</li>
|
||||
<li>Vicente Salvador Cubedo</li>
|
||||
<li>Viktor Aschenbrenner</li>
|
||||
|
||||
@ -15,9 +15,9 @@ li(id="msd-dropdown" class="right feature-disabled")
|
||||
+menu_message("warning", "Current image is broken!", "msd-message-image-broken")
|
||||
| Perhaps uploading was interrupted#[br]
|
||||
hr
|
||||
div(id="msd-message-too-big-for-cdrom" class="hidden")
|
||||
+menu_message("warning", "Current image is too big for CD-ROM!", "msd-message-too-big-for-cdrom")
|
||||
| The device filesystem will be truncated to 2.2GiB
|
||||
div(id="msd-message-too-big-for-dvd" class="hidden")
|
||||
+menu_message("warning", "Current image is too big for DVD!", "msd-message-too-big-for-dvd")
|
||||
| The maximum is 31.6GiB. Please switch to the Flash mode.
|
||||
hr
|
||||
div(id="msd-message-out-of-storage" class="hidden")
|
||||
+menu_message("warning", "Current image is out of storage", "msd-message-out-of-storage")
|
||||
@ -45,7 +45,7 @@ li(id="msd-dropdown" class="right feature-disabled")
|
||||
td
|
||||
div(class="radio-box")
|
||||
input(checked type="radio" id="msd-mode-radio-cdrom" name="msd-mode-radio" value="1")
|
||||
label(for="msd-mode-radio-cdrom") CD-ROM
|
||||
label(for="msd-mode-radio-cdrom") CD/DVD
|
||||
input(type="radio" id="msd-mode-radio-flash" name="msd-mode-radio" value="0")
|
||||
label(for="msd-mode-radio-flash") Flash
|
||||
td
|
||||
|
||||
@ -9,7 +9,7 @@ li(id="shortcuts-dropdown" class="right")
|
||||
div(class="buttons-row")
|
||||
button(data-force-hide-menu data-shortcut="CapsLock" class="row50")
|
||||
| • Caps Lock
|
||||
img(class="inline-lamp hid-keyboard-caps-led led-gray" src=`${svg_dir}/led-square.svg`)
|
||||
img(class="inline-lamp-small hid-keyboard-caps-led led-gray" src=`${svg_dir}/led-square.svg`)
|
||||
button(data-force-hide-menu data-shortcut="MetaLeft" class="row50") • Left Win
|
||||
hr
|
||||
div(class="buttons-row")
|
||||
|
||||
23
web/kvm/navbar-switch.pug
Normal file
23
web/kvm/navbar-switch.pug
Normal file
@ -0,0 +1,23 @@
|
||||
li(id="switch-dropdown" class="right feature-disabled")
|
||||
a(class="menu-button" id="switch-menu-button" href="#")
|
||||
+navbar_led("switch-atx-power-led", "led-atx-power")
|
||||
+navbar_led("switch-atx-hdd-led", "led-atx-hdd")
|
||||
span Switch #[i #[sub(id="switch-active-port") ]]
|
||||
div(id="switch-menu" class="menu")
|
||||
table(style="border-spacing: 0px;")
|
||||
tr
|
||||
td
|
||||
div(class="text")
|
||||
b #[a(target="_blank" href="https://docs.pikvm.org/switch") PiKVM Switch] is attached#[br]
|
||||
sub Select a port or perform any available action like ATX click
|
||||
td
|
||||
div(class="text")
|
||||
button(data-force-hide-menu data-show-window="switch-window" class="small") • Settings
|
||||
hr
|
||||
div(id="switch-message-update" class="hidden")
|
||||
+menu_message("info", "Good news! Your switch is ready to get the firmware update")
|
||||
| Please #[a(target="_blank" href="https://docs.pikvm.org/switch/#firmware-updating") follow the instructions] when you decide to install it.
|
||||
hr
|
||||
+menu_switch("switch-atx-ask-switch", "Ask ATX click confirmation", true, true)
|
||||
hr
|
||||
table(id="switch-chain" class="kv")
|
||||
@ -1,7 +1,7 @@
|
||||
li(id="system-dropdown" class="right")
|
||||
a(class="menu-button" href="#")
|
||||
+navbar_led("link-led", "led-link")
|
||||
+navbar_led("stream-led", "led-stream")
|
||||
+navbar_led("stream-led", "led-video")
|
||||
+navbar_led("hid-keyboard-led", "led-hid-keyboard")
|
||||
+navbar_led("hid-mouse-led", "led-hid-mouse")
|
||||
span(i18n="kvm_text3") System
|
||||
@ -19,6 +19,9 @@ li(id="system-dropdown" class="right")
|
||||
div(id="stream-message-no-webrtc" class="hidden")
|
||||
+menu_message("warning", "WebRTC is not supported by this browser", "stream-message-no-webrtc")
|
||||
hr
|
||||
div(id="stream-message-no-vd" class="hidden")
|
||||
+menu_message("warning", "Direct HTTP H.264 streaming is not supported")
|
||||
hr
|
||||
div(id="stream-message-no-h264" class="hidden")
|
||||
+menu_message("warning", "H.264 is not supported by this browser", "stream-message-no-h264")
|
||||
hr
|
||||
@ -46,10 +49,12 @@ li(id="system-dropdown" class="right")
|
||||
td(i18n="kvm_text14") Video #[a(target="_blank" href="https://docs.pikvm.org/webrtc") mode]:
|
||||
td
|
||||
div(class="radio-box")
|
||||
input(checked type="radio" id="stream-mode-radio-mjpeg" name="stream-mode-radio" value="mjpeg")
|
||||
label(for="stream-mode-radio-mjpeg") MJPEG / HTTP
|
||||
input(type="radio" id="stream-mode-radio-janus" name="stream-mode-radio" value="janus")
|
||||
label(for="stream-mode-radio-janus") H.264 / WebRTC
|
||||
label(for="stream-mode-radio-janus") WebRTC
|
||||
input(type="radio" id="stream-mode-radio-media" name="stream-mode-radio" value="media")
|
||||
label(for="stream-mode-radio-media") H.264
|
||||
input(checked type="radio" id="stream-mode-radio-mjpeg" name="stream-mode-radio" value="mjpeg")
|
||||
label(for="stream-mode-radio-mjpeg") MJPEG
|
||||
tr(id="stream-orient" class="feature-disabled")
|
||||
td(i18n="kvm_text17") Orientation:
|
||||
td
|
||||
@ -66,6 +71,8 @@ li(id="system-dropdown" class="right")
|
||||
td(i18n="kvm_text19") Audio volume:
|
||||
td(class="value-slider") #[input(type="range" id="stream-audio-volume-slider" class="slider")]
|
||||
td(id="stream-audio-volume-value" class="value-number")
|
||||
tr(id="stream-mic" class="feature-disabled")
|
||||
+menu_switch_notable("stream-mic-switch", "Microphone", false, false)
|
||||
hr
|
||||
div(class="buttons buttons-row")
|
||||
button(data-force-hide-menu data-show-window="stream-window" class="row33" i18n="kvm_text20") • Show stream
|
||||
@ -128,6 +135,8 @@ li(id="system-dropdown" class="right")
|
||||
tr
|
||||
+menu_switch_notable("page-full-tab-stream-switch", "Expand for the entire tab by default", true, false,"page-full-tab-stream-switch")
|
||||
table(class="kv")
|
||||
tr
|
||||
+menu_switch_notable("hid-keyboard-bad-link-switch", "Bad link mode (release keys immediately)", true, false)
|
||||
tr(id="hid-connect" class="feature-disabled")
|
||||
+menu_switch_notable("hid-connect-switch", "Connect HID to Server", true, true, "hid-connect-switch")
|
||||
tr(id="hid-jiggler" class="feature-disabled")
|
||||
|
||||
@ -17,6 +17,7 @@ li(id="text-dropdown" class="right")
|
||||
td
|
||||
select(id="hid-pak-keymap-selector")
|
||||
table(class="kv")
|
||||
+menu_switch_notable("hid-pak-slow-switch", "Slow typing", true, false, "hid-pak-slow-switch")
|
||||
tr
|
||||
+menu_switch_notable("hid-pak-ask-switch", "Ask paste confirmation", true, true, "hid-pak-ask-switch")
|
||||
tr(id="hid-pak-secure" class="feature-disabled")
|
||||
|
||||
@ -51,3 +51,4 @@ ul(id="navbar")
|
||||
include navbar-text.pug
|
||||
include navbar-shortcuts.pug
|
||||
include navbar-gpio.pug
|
||||
include navbar-switch.pug
|
||||
|
||||
@ -79,6 +79,7 @@ div(id="about-window" class="window")
|
||||
li Alok Anand
|
||||
li Alucard
|
||||
li Ananthaneshan Elampoornan
|
||||
li Andreas Grundler
|
||||
li Andreas Marufke
|
||||
li Andreas Schmid
|
||||
li Andrew Brant
|
||||
@ -139,6 +140,7 @@ div(id="about-window" class="window")
|
||||
li Brian T Mulcahy
|
||||
li Brian Vecchiarelli
|
||||
li Brian White
|
||||
li brodonalds
|
||||
li Bruno Gomes
|
||||
li Bryan Adams
|
||||
li Bryan Montgomery
|
||||
@ -475,6 +477,7 @@ div(id="about-window" class="window")
|
||||
li Mikael Wikström
|
||||
li Mike Mason
|
||||
li Mikhael Mariano
|
||||
li Milan Burda
|
||||
li Milan Múčka
|
||||
li Miles Davis
|
||||
li Minh Tang
|
||||
@ -493,6 +496,7 @@ div(id="about-window" class="window")
|
||||
li Nick Roethemeier
|
||||
li Nico Baumgartner
|
||||
li Nicolai Kragh-Hansen
|
||||
li Nicolas Christener
|
||||
li Nigel Smith
|
||||
li Nihal Fernando
|
||||
li Nils Orbat
|
||||
@ -500,6 +504,7 @@ div(id="about-window" class="window")
|
||||
li Nithin Philips
|
||||
li Nod Swal
|
||||
li Nolan Haynes
|
||||
li Noxigen LLC
|
||||
li nubbn
|
||||
li nybble
|
||||
li Oh Be
|
||||
@ -584,6 +589,7 @@ div(id="about-window" class="window")
|
||||
li Scuba
|
||||
li Sean
|
||||
li Sean Akers
|
||||
li Sean c Rickard
|
||||
li SEAT
|
||||
li Sebastian
|
||||
li Seonwoo Lee
|
||||
@ -652,6 +658,7 @@ div(id="about-window" class="window")
|
||||
li Udo Schroeter
|
||||
li Uli Fahrer
|
||||
li Vasily Lazarev
|
||||
li Venmo
|
||||
li Vidru Eduard
|
||||
li Vicente Salvador Cubedo
|
||||
li Viktor Aschenbrenner
|
||||
|
||||
@ -26,7 +26,7 @@ mixin empty(spacer, classes="", width=0)
|
||||
div(class="spacer-fixed")
|
||||
|
||||
mixin lamp(cls)
|
||||
img(class=`inline-lamp ${cls} led-gray` src=`${svg_dir}/led-square.svg`)
|
||||
img(class=`inline-lamp-small ${cls} led-gray` src=`${svg_dir}/led-square.svg`)
|
||||
|
||||
div(id="keyboard-window" class="window")
|
||||
div(id="keyboard-window-header" class="window-header")
|
||||
|
||||
@ -16,6 +16,7 @@ div(id="stream-window" class="window window-resizable")
|
||||
div(id="stream-box" class="stream-box-offline")
|
||||
img(id="stream-image" src=`${png_dir}/blank-stream.png`)
|
||||
video(id="stream-video" class="hidden" disablePictureInPicture="true" autoplay playsinline muted)
|
||||
canvas(id="stream-canvas" class="hidden")
|
||||
div(id="stream-fullscreen-active")
|
||||
|
||||
div(id="stream-mouse-buttons" class="keypad" align="center")
|
||||
|
||||
95
web/kvm/window-switch.pug
Normal file
95
web/kvm/window-switch.pug
Normal file
@ -0,0 +1,95 @@
|
||||
mixin switch_tab(name, title, checked=false)
|
||||
- let button_id = `switch-tab-${name}-button`
|
||||
input(checked=checked type="radio" name="switch-tab-button", id=button_id)
|
||||
label(for=button_id) #{title}
|
||||
div(class="tab")
|
||||
block
|
||||
|
||||
div(id="switch-window" class="window" style="width:min-content")
|
||||
div(class="window-header")
|
||||
div(class="window-grab") Switch settings
|
||||
button(class="window-button-close") #[b ×]
|
||||
|
||||
div(class="tabs-box")
|
||||
+switch_tab("edid", "EDIDs collection", true)
|
||||
table
|
||||
tr
|
||||
td(colspan="2")
|
||||
select(id="switch-edid-selector" size="8")
|
||||
td(rowspan="2" style="vertical-align:top")
|
||||
table(class="kv")
|
||||
tr
|
||||
td Manufacturer:
|
||||
td(id="switch-edid-info-mfc-id" class="value")
|
||||
tr
|
||||
td Product ID:
|
||||
td(id="switch-edid-info-product-id" class="value")
|
||||
tr
|
||||
td Serial:
|
||||
td(id="switch-edid-info-serial" class="value")
|
||||
tr
|
||||
td Monitor name:
|
||||
td(id="switch-edid-info-monitor-name" class="value")
|
||||
tr
|
||||
td Extra serial:
|
||||
td(id="switch-edid-info-monitor-serial" class="value")
|
||||
tr
|
||||
td Audio enabled:
|
||||
td(id="switch-edid-info-audio" class="value")
|
||||
tr
|
||||
td Data:
|
||||
td #[button(disabled id="switch-edid-copy-data-button" class="small") Copy]
|
||||
tr
|
||||
td #[button(id="switch-edid-add-button") Add new]
|
||||
td(style="float:right") #[button(disabled id="switch-edid-remove-button") Remove]
|
||||
|
||||
+switch_tab("colors", "Color scheme")
|
||||
table
|
||||
//tr
|
||||
td Role
|
||||
td Color
|
||||
td Brightness
|
||||
td
|
||||
td Reset
|
||||
//tr
|
||||
td #[hr]
|
||||
td #[hr]
|
||||
td #[hr]
|
||||
td
|
||||
td #[hr]
|
||||
tr
|
||||
td(style="white-space: nowrap") Selected port:
|
||||
td #[input(type="color" id="switch-color-active-input")]
|
||||
td #[input(type="range" id="switch-color-active-brightness-slider" style="min-width:150px")]
|
||||
td
|
||||
td #[button(id="switch-color-active-default-button" class="small" title="Reset default") ↻]
|
||||
tr
|
||||
td(style="white-space: nowrap") Inactive port:
|
||||
td #[input(type="color" id="switch-color-inactive-input")]
|
||||
td #[input(type="range" id="switch-color-inactive-brightness-slider" style="min-width:150px")]
|
||||
td
|
||||
td #[button(id="switch-color-inactive-default-button" class="small" title="Reset default") ↻]
|
||||
tr
|
||||
td(style="white-space: nowrap") Blinking beacon:
|
||||
td #[input(type="color" id="switch-color-beacon-input")]
|
||||
td #[input(type="range" id="switch-color-beacon-brightness-slider" style="min-width:150px")]
|
||||
td
|
||||
td #[button(id="switch-color-beacon-default-button" class="small" title="Reset default") ↻]
|
||||
tr
|
||||
td #[hr]
|
||||
td #[hr]
|
||||
td #[hr]
|
||||
td
|
||||
td #[hr]
|
||||
tr
|
||||
td(style="white-space: nowrap") Flashing downlink:
|
||||
td #[input(type="color" id="switch-color-flashing-input")]
|
||||
td #[input(type="range" id="switch-color-flashing-brightness-slider" style="min-width:150px")]
|
||||
td
|
||||
td #[button(id="switch-color-flashing-default-button" class="small" title="Reset default") ↻]
|
||||
tr
|
||||
td(style="white-space: nowrap") Bootloader mode:
|
||||
td #[input(type="color" id="switch-color-bootloader-input")]
|
||||
td #[input(type="range" id="switch-color-bootloader-brightness-slider" style="min-width:150px")]
|
||||
td
|
||||
td #[button(id="switch-color-bootloader-default-button" class="small" title="Reset default") ↻]
|
||||
@ -1,4 +1,5 @@
|
||||
include window-stream.pug
|
||||
include window-keyboard.pug
|
||||
include window-switch.pug
|
||||
include window-about.pug
|
||||
include window-webterm.pug
|
||||
|
||||
@ -30,7 +30,7 @@ block body
|
||||
option(id='en' i18n="english") English
|
||||
tr
|
||||
td
|
||||
td #[button(id="login-button" class="key" i18n="login") Login]
|
||||
td #[button(id="login-button" class="key" style="width:100%" i18n="login") Login]
|
||||
|
||||
ul(class="footer")
|
||||
li(class="left" i18n="footer-left")
|
||||
|
||||
@ -28,3 +28,7 @@ div#msd-menu div.msd-message,
|
||||
div#msd-menu input.msd-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div#msd-menu select#msd-image-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -85,7 +85,8 @@ div.stream-box-mouse-none {
|
||||
}
|
||||
|
||||
img#stream-image,
|
||||
video#stream-video {
|
||||
video#stream-video,
|
||||
canvas#stream-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
|
||||
@ -41,6 +41,13 @@
|
||||
--led-spin-slow: spin 6s linear infinite;
|
||||
--led-spin-medium: spin 3s linear infinite;
|
||||
--led-spin-fast: spin 2s linear infinite;
|
||||
|
||||
/* Additional colors for GPIO */
|
||||
--led-filter-blue: invert(0.5) sepia(1) saturate(5) hue-rotate(170deg);
|
||||
--led-filter-cyan: invert(0.5) sepia(1) saturate(5) hue-rotate(130deg);
|
||||
--led-filter-magenta: invert(0.5) sepia(1) saturate(5) hue-rotate(200deg);
|
||||
--led-filter-pink: invert(0.5) sepia(1) saturate(5) hue-rotate(300deg);
|
||||
--led-filter-white: invert(1) sepia(1);
|
||||
}
|
||||
|
||||
img.led-gray {
|
||||
@ -48,19 +55,16 @@ img.led-gray {
|
||||
-webkit-filter: var(--led-filter-gray);
|
||||
filter: var(--led-filter-gray);
|
||||
}
|
||||
|
||||
img.led-green {
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-filter: var(--led-filter-green);
|
||||
filter: var(--led-filter-green);
|
||||
}
|
||||
|
||||
img.led-red {
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-filter: var(--led-filter-red);
|
||||
filter: var(--led-filter-red);
|
||||
}
|
||||
|
||||
img.led-yellow {
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-filter: var(--led-filter-yellow);
|
||||
@ -73,10 +77,36 @@ img.led-red-rotating-fast {
|
||||
-webkit-animation: var(--led-spin-fast);
|
||||
animation: var(--led-spin-fast);
|
||||
}
|
||||
|
||||
img.led-yellow-rotating-fast {
|
||||
-webkit-filter: var(--led-filter-yellow);
|
||||
filter: var(--led-filter-yellow);
|
||||
-webkit-animation: var(--led-spin-fast);
|
||||
animation: var(--led-spin-fast);
|
||||
}
|
||||
|
||||
/* Additional colors for GPIO */
|
||||
img.led-blue {
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-filter: var(--led-filter-blue);
|
||||
filter: var(--led-filter-blue);
|
||||
}
|
||||
img.led-cyan {
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-filter: var(--led-filter-cyan);
|
||||
filter: var(--led-filter-cyan);
|
||||
}
|
||||
img.led-magenta {
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-filter: var(--led-filter-magenta);
|
||||
filter: var(--led-filter-magenta);
|
||||
}
|
||||
img.led-pink {
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-filter: var(--led-filter-pink);
|
||||
filter: var(--led-filter-pink);
|
||||
}
|
||||
img.led-white {
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-filter: var(--led-filter-white);
|
||||
filter: var(--led-filter-white);
|
||||
}
|
||||
|
||||
@ -88,12 +88,17 @@ img.svg-gray {
|
||||
}
|
||||
|
||||
img.inline-lamp {
|
||||
vertical-align: middle;
|
||||
height: 1em;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
img.inline-lamp-small {
|
||||
vertical-align: middle;
|
||||
height: 8px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
img.inline-lamp-big {
|
||||
vertical-align: middle;
|
||||
height: 20px;
|
||||
@ -104,7 +109,8 @@ img.inline-lamp-big {
|
||||
button,
|
||||
select,
|
||||
input[type=file]::-webkit-file-selector-button,
|
||||
input[type=file]::file-selector-button {
|
||||
input[type=file]::file-selector-button,
|
||||
input[type=color] {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--cs-control-default-fg);
|
||||
@ -117,11 +123,9 @@ input[type=file]::file-selector-button {
|
||||
}
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-left: 5px;
|
||||
}
|
||||
select[size] {
|
||||
@ -194,6 +198,7 @@ select:not([size]) option.comment {
|
||||
input[type=text], input[type=password] {
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
border: var(--border-default-thin);
|
||||
color: var(--cs-code-default-fg);
|
||||
@ -223,42 +228,35 @@ textarea::-webkit-input-placeholder {
|
||||
}
|
||||
|
||||
div.buttons-row {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.row50 {
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
}
|
||||
.row33 {
|
||||
display: inline-block;
|
||||
width: 33.33%;
|
||||
}
|
||||
.row25 {
|
||||
display: inline-block;
|
||||
width: 25%;
|
||||
}
|
||||
.row16 {
|
||||
display: inline-block;
|
||||
width: 16.66%;
|
||||
}
|
||||
.row50:not(:first-child),
|
||||
.row33:not(:first-child),
|
||||
.row25:not(:first-child),
|
||||
.row16:not(:first-child) {
|
||||
div.buttons-row button:not(:first-child) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: var(--border-control-thin) !important;
|
||||
}
|
||||
.row50:not(:last-child),
|
||||
.row33:not(:last-child),
|
||||
.row25:not(:last-child),
|
||||
.row16:not(:last-child) {
|
||||
div.buttons-row button:not(:last-child) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
button.row100 {
|
||||
width: 100% !important;
|
||||
}
|
||||
button.row50 {
|
||||
width: 50% !important;
|
||||
}
|
||||
button.row33 {
|
||||
width: 33.33% !important;
|
||||
}
|
||||
button.row25 {
|
||||
width: 25% !important;
|
||||
}
|
||||
button.row16 {
|
||||
width: 16.66% !important;
|
||||
}
|
||||
|
||||
table.kv {
|
||||
border-spacing: 5px;
|
||||
|
||||
@ -63,9 +63,11 @@ div.modal div.modal-window div.modal-content {
|
||||
|
||||
div.modal div.modal-window div.modal-buttons {
|
||||
border-top: var(--border-control-thin);
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.modal div.modal-window div.modal-buttons button {
|
||||
|
||||
@ -172,6 +172,7 @@ ul#navbar li div.menu div.buttons select {
|
||||
border-radius: 0;
|
||||
text-align: left;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ul#navbar li div.menu input[type=text] {
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
|
||||
@supports (-webkit-appearance:none) {
|
||||
input[type=range].slider {
|
||||
input[type=range] {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
@ -33,7 +33,7 @@
|
||||
}
|
||||
}
|
||||
@supports not (-webkit-appearance:none) {
|
||||
input[type=range].slider {
|
||||
input[type=range] {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
@ -42,20 +42,20 @@
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
input[type=range].slider:disabled {
|
||||
input[type=range]:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input[type=range].slider::-webkit-slider-runnable-track {
|
||||
input[type=range]::-webkit-slider-runnable-track {
|
||||
height: 5px;
|
||||
background: var(--cs-control-default-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
input[type=range].slider:disabled::-webkit-slider-runnable-track {
|
||||
input[type=range]:disabled::-webkit-slider-runnable-track {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input[type=range].slider::-webkit-slider-thumb {
|
||||
input[type=range]::-webkit-slider-thumb {
|
||||
border: var(--border-intensive-2px);
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
@ -64,29 +64,29 @@ input[type=range].slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
margin-top: -7px;
|
||||
}
|
||||
input[type=range].slider:disabled::-webkit-slider-thumb {
|
||||
input[type=range]:disabled::-webkit-slider-thumb {
|
||||
cursor: default;
|
||||
border: var(--border-default-2px);
|
||||
background: var(--cs-thumb-disabled-bg);
|
||||
}
|
||||
|
||||
input[type=range].slider::-moz-range-track {
|
||||
input[type=range]::-moz-range-track {
|
||||
height: 5px;
|
||||
background: var(--cs-control-default-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
input[type=range].slider:disabled::-moz-range-track {
|
||||
input[type=range]:disabled::-moz-range-track {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input[type=range].slider::-moz-range-thumb {
|
||||
input[type=range]::-moz-range-thumb {
|
||||
border: var(--border-intensive-2px);
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 25px;
|
||||
background: var(--cs-thumb-default-bg);
|
||||
}
|
||||
input[type=range].slider:disabled::-moz-range-thumb {
|
||||
input[type=range]:disabled::-moz-range-thumb {
|
||||
cursor: default;
|
||||
border: var(--border-default-2px);
|
||||
background: var(--cs-thumb-disabled-bg);
|
||||
|
||||
@ -25,7 +25,8 @@
|
||||
button:enabled:hover,
|
||||
select:not([size]):enabled:hover,
|
||||
input[type=file]:enabled:hover::-webkit-file-selector-button,
|
||||
input[type=file]:enabled:hover::file-selector-button {
|
||||
input[type=file]:enabled:hover::file-selector-button,
|
||||
input[type=color]:enabled:hover {
|
||||
color: var(--cs-control-hovered-fg);
|
||||
background-color: var(--cs-control-hovered-bg);
|
||||
}
|
||||
@ -33,7 +34,8 @@ input[type=file]:enabled:hover::file-selector-button {
|
||||
button:active,
|
||||
select:not([size]):active,
|
||||
input[type=file]:active::-webkit-file-selector-button,
|
||||
input[type=file]:active::file-selector-button {
|
||||
input[type=file]:active::file-selector-button,
|
||||
input[type=color]:active {
|
||||
color: var(--cs-control-pressed-fg) !important;
|
||||
background-color: var(--cs-control-pressed-bg) !important;
|
||||
}
|
||||
@ -60,12 +62,12 @@ div.radio-box input[type=radio]:not(:checked):not(:disabled) + label:hover {
|
||||
/* ===== slider.css ===== */
|
||||
|
||||
/*div.switch-box label span.switch-inner:not(:disabled):hover::before {*/
|
||||
input[type=range].slider:not(:disabled):hover::-webkit-slider-runnable-track {
|
||||
input[type=range]:not(:disabled):hover::-webkit-slider-runnable-track {
|
||||
background-color: var(--cs-control-hovered-bg);
|
||||
}
|
||||
|
||||
/*div.switch-box label span.switch-inner:not(:disabled):hover::before {*/
|
||||
input[type=range].slider:not(:disabled):hover::-moz-range-track {
|
||||
input[type=range]:not(:disabled):hover::-moz-range-track {
|
||||
background-color: var(--cs-control-hovered-bg);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user