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:
mofeng-git 2025-02-01 01:08:36 +00:00
parent 5db37797ea
commit 7b3335ea94
117 changed files with 5342 additions and 479 deletions

View File

@ -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}

View File

@ -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) \

View File

@ -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\"

View File

@ -5,3 +5,7 @@ audio: {
device = "hw:0"
tc358743 = "/dev/video0"
}
aplay: {
device = "plughw:UAC2Gadget,0"
check = "/run/kvmd/otg/uac2.usb0@meta.json"
}

View File

@ -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

View 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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -0,0 +1,5 @@
name: Media
description: KVMD Media Proxy
path: media
daemon: kvmd-media
place: -1

View File

@ -0,0 +1,3 @@
upstream media {
server unix:/run/kvmd/media.sock fail_timeout=0s max_fails=0;
}

View 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;
}

View File

@ -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

View File

@ -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)

View File

@ -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);
}

View File

@ -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

View File

@ -20,4 +20,4 @@
# ========================================================================== #
__version__ = "4.20"
__version__ = "4.49"

View File

@ -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

View File

@ -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),
},
},
},
},
},

View File

@ -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"])),

View File

@ -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}")

View File

@ -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")

View File

@ -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:

View 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()

View File

@ -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]

View File

@ -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)

View File

@ -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)

View 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

View 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

View 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

View 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

View 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

View 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)

View 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)

View 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)

View File

@ -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,

View 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())

View 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
View 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)

View File

@ -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 =====")

View File

@ -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:

View File

@ -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"))

View File

@ -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,

View File

@ -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
View 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")

View 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()

View File

@ -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)),

View File

@ -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)}"]

View File

@ -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)

View File

@ -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
View 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),
)

View File

@ -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:

View File

@ -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
# =====

View File

@ -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:

View File

@ -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:

View File

@ -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)

View File

@ -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)

View File

@ -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:
# Горизонтальная прокрутка пока не поддерживается

View File

@ -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))

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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]
)

View File

@ -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)

View File

@ -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
View 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")

View File

@ -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"

View File

@ -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
View 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
View 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
View File

@ -0,0 +1 @@
This is a mount point for the switch.

BIN
switch/switch.uf2 Normal file

Binary file not shown.

View File

@ -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 \

View File

@ -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]

View File

@ -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

View 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))

View File

@ -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

View File

@ -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: ""

View File

@ -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 &amp; mouse (HID) settings</summary>
<summary i18n="kvm_text25">Keyboard &amp; 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">&bull; Caps Lock &nbsp;<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">&bull; Caps Lock &nbsp;<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">&bull; 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">&bull; 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">&#9660;</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>&times;</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>&nbsp;&nbsp;&nbsp;</td>
<td>
<button class="small" id="switch-color-active-default-button" title="Reset default">&#8635;</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>&nbsp;&nbsp;&nbsp;</td>
<td>
<button class="small" id="switch-color-inactive-default-button" title="Reset default">&#8635;</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>&nbsp;&nbsp;&nbsp;</td>
<td>
<button class="small" id="switch-color-beacon-default-button" title="Reset default">&#8635;</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>&nbsp;&nbsp;&nbsp;</td>
<td>
<button class="small" id="switch-color-flashing-default-button" title="Reset default">&#8635;</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>&nbsp;&nbsp;&nbsp;</td>
<td>
<button class="small" id="switch-color-bootloader-default-button" title="Reset default">&#8635;</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>

View File

@ -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 &nbsp;

View File

@ -9,7 +9,7 @@ li(id="shortcuts-dropdown" class="right")
div(class="buttons-row")
button(data-force-hide-menu data-shortcut="CapsLock" class="row50")
| &bull; Caps Lock &nbsp;
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") &bull; Left Win
hr
div(class="buttons-row")

23
web/kvm/navbar-switch.pug Normal file
View 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") &bull; 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")

View File

@ -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") &bull; 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")

View File

@ -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")

View File

@ -51,3 +51,4 @@ ul(id="navbar")
include navbar-text.pug
include navbar-shortcuts.pug
include navbar-gpio.pug
include navbar-switch.pug

View File

@ -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

View File

@ -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")

View File

@ -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
View 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 &times;]
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 &nbsp;&nbsp;&nbsp;
td #[button(id="switch-color-active-default-button" class="small" title="Reset default") &#8635;]
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 &nbsp;&nbsp;&nbsp;
td #[button(id="switch-color-inactive-default-button" class="small" title="Reset default") &#8635;]
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 &nbsp;&nbsp;&nbsp;
td #[button(id="switch-color-beacon-default-button" class="small" title="Reset default") &#8635;]
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 &nbsp;&nbsp;&nbsp;
td #[button(id="switch-color-flashing-default-button" class="small" title="Reset default") &#8635;]
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 &nbsp;&nbsp;&nbsp;
td #[button(id="switch-color-bootloader-default-button" class="small" title="Reset default") &#8635;]

View File

@ -1,4 +1,5 @@
include window-stream.pug
include window-keyboard.pug
include window-switch.pug
include window-about.pug
include window-webterm.pug

View File

@ -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")

View File

@ -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%;
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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 {

View File

@ -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] {

View File

@ -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);

View File

@ -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