feat: merge upstream master - version 4.94

Merge upstream PiKVM master branch updates:

- Bump version from 4.93 to 4.94
- HID: improved jiggler pattern for better compatibility
- Streamer: major refactoring for improved performance and maintainability
- Prometheus: tidying GPIO channel name formatting
- Web: added __gpio-label class for custom styling
- HID: customizable /api/hid/print delay configuration
- ATX: independent power/reset regions for better control
- OLED: added --fill option for display testing
- Web: improved keyboard handling in modal dialogs
- Web: enhanced login error messages
- Switch: added heartbeat functionality
- Web: mouse touch code simplification and refactoring
- Configs: use systemd-networkd-wait-online --any by default
- PKGBUILD: use cp -r to install systemd units properly
- Various bug fixes and performance improvements
This commit is contained in:
mofeng-git 2025-08-21 11:21:41 +08:00
commit 2c056ca3e3
205 changed files with 9359 additions and 4653 deletions

View File

@ -1,7 +1,7 @@
[bumpversion]
commit = True
tag = True
current_version = 4.49
current_version = 4.94
parse = (?P<major>\d+)\.(?P<minor>\d+)(\.(?P<patch>\d+)(\-(?P<release>[a-z]+))?)?
serialize =
{major}.{minor}

View File

@ -4,7 +4,8 @@ TESTENV_IMAGE ?= kvmd-testenv
TESTENV_HID ?= /dev/ttyS10
TESTENV_VIDEO ?= /dev/video0
TESTENV_GPIO ?= /dev/gpiochip0
TESTENV_RELAY ?= $(if $(shell ls /dev/hidraw0 2>/dev/null || true),/dev/hidraw0,)
TESTENV_RELAY ?=
#TESTENV_RELAY ?= $(if $(shell ls /dev/hidraw0 2>/dev/null || true),/dev/hidraw0,)
LIBGPIOD_VERSION ?= 1.6.3
@ -104,7 +105,7 @@ tox-local:
$(TESTENV_GPIO):
test ! -e $(TESTENV_GPIO)
sudo modprobe gpio-mockup gpio_mockup_ranges=0,40
sudo modprobe gpio_mockup gpio_mockup_ranges=0,40
test -c $(TESTENV_GPIO)

View File

@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do
pkgname+=(kvmd-platform-$_platform-$_board)
done
pkgbase=kvmd
pkgver=4.49
pkgver=4.94
pkgrel=1
pkgdesc="The main PiKVM daemon"
url="https://github.com/pikvm/kvmd"
@ -53,6 +53,8 @@ depends=(
python-aiofiles
python-async-lru
python-passlib
# python-bcrypt is needed for passlib
python-bcrypt
python-pyotp
python-qrcode
python-periphery
@ -66,7 +68,7 @@ depends=(
python-dbus
python-dbus-next
python-pygments
python-pyghmi
"python-pyghmi>=1.6.0-2"
python-pam
python-pillow
python-xlib
@ -80,6 +82,7 @@ depends=(
python-luma-oled
python-pyusb
python-pyudev
python-evdev
"libgpiod>=2.1"
freetype2
"v4l-utils>=1.22.1-1"
@ -94,7 +97,7 @@ depends=(
certbot
platform-io-access
raspberrypi-utils
"ustreamer>=6.26"
"ustreamer>=6.37"
# Systemd UDEV bug
"systemd>=248.3-2"
@ -120,7 +123,7 @@ depends=(
# fsck for /boot
dosfstools
# pgrep for kvmd-udev-restart-pass
# pgrep for kvmd-udev-restart-pass, sysctl for kvmd-otgnet
procps-ng
# Misc
@ -163,7 +166,9 @@ package_kvmd() {
install -Dm755 -t "$pkgdir/usr/bin" scripts/kvmd-{bootconfig,gencert,certbot}
install -Dm644 -t "$pkgdir/usr/lib/systemd/system" configs/os/services/*
install -dm755 "$pkgdir/usr/lib/systemd/system"
cp -rd configs/os/services -T "$pkgdir/usr/lib/systemd/system"
install -DTm644 configs/os/sysusers.conf "$pkgdir/usr/lib/sysusers.d/kvmd.conf"
install -DTm644 configs/os/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/kvmd.conf"
@ -198,6 +203,7 @@ package_kvmd() {
mkdir -p "$pkgdir/etc/kvmd/override.d"
mkdir -p "$pkgdir/var/lib/kvmd/"{msd,pst}
chmod 1775 "$pkgdir/var/lib/kvmd/pst"
}
@ -210,7 +216,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-10\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
depends=(kvmd=$pkgver-$pkgrel \"linux-rpi-pikvm>=6.6.45-13\" \"raspberrypi-bootloader-pikvm>=20240818-1\")
backup=(
etc/sysctl.d/99-kvmd.conf

View File

@ -1 +1 @@
admin:$apr1$.6mu9N8n$xOuGesr4JZZkdiZo/j318.
admin:{SSHA512}3zSmw/L9zIkpQdX5bcy6HntTxltAzTuGNP6NjHRRgOcNZkA0K+Lsrj3QplO9Gr3BA5MYVVki9rAVnFNCcIdtYC6FkLJWCmHs

View File

@ -1,14 +1,11 @@
# This file describes the credentials for IPMI users. The first pair separated by colon
# is the login and password with which the user can access to IPMI. The second pair
# is the name and password with which the user can access to KVMD API. The arrow is used
# as a separator and shows the direction of user registration in the system.
# This file describes the credentials for IPMI users in format "login:password",
# one per line. The passwords are NOT encrypted.
#
# WARNING! IPMI protocol is completely unsafe by design. In short, the authentication
# process for IPMI 2.0 mandates that the server send a salted SHA1 or MD5 hash of the
# requested user's password to the client, prior to the client authenticating. Never use
# the same passwords for KVMD and IPMI users. This default configuration is shown here
# for example only.
# requested user's password to the client, prior to the client authenticating.
#
# And even better not to use IPMI. Instead, you can directly use KVMD API via curl.
# NEVER use the same passwords for KVMD and IPMI users.
# This default configuration is shown here just for the example only.
admin:admin -> admin:admin
admin:admin

View File

@ -0,0 +1,97 @@
# Don't touch this file otherwise your device may stop working.
# Use override.yaml to modify required settings.
# You can find a working configuration in /usr/share/kvmd/configs.default/kvmd.
override: !include [override.d, override.yaml]
logging: !include logging.yaml
kvmd:
auth: !include auth.yaml
info:
hw:
ignore_past: true
fan:
unix: /run/kvmd/fan.sock
hid:
type: otg
atx:
type: gpio
power_led_pin: 4
hdd_led_pin: 5
power_switch_pin: 23
reset_switch_pin: 27
msd:
type: otg
streamer:
h264_bitrate:
default: 5000
cmd:
- "/usr/bin/ustreamer"
- "--device=/dev/kvmd-video"
- "--persistent"
- "--dv-timings"
- "--format=uyvy"
- "--buffers=6"
- "--encoder=m2m-image"
- "--workers=3"
- "--quality={quality}"
- "--desired-fps={desired_fps}"
- "--drop-same-frames=30"
- "--unix={unix}"
- "--unix-rm"
- "--unix-mode=0660"
- "--exit-on-parent-death"
- "--process-name-prefix={process_name_prefix}"
- "--notify-parent"
- "--no-log-colors"
- "--jpeg-sink=kvmd::ustreamer::jpeg"
- "--jpeg-sink-mode=0660"
- "--h264-sink=kvmd::ustreamer::h264"
- "--h264-sink-mode=0660"
- "--h264-bitrate={h264_bitrate}"
- "--h264-gop={h264_gop}"
gpio:
drivers:
__v4_locator__:
type: locator
scheme:
__v3_usb_breaker__:
pin: 22
mode: output
initial: true
pulse: false
__v4_locator__:
driver: __v4_locator__
pin: 12
mode: output
pulse: false
__v4_const1__:
pin: 6
mode: output
initial: false
switch: false
pulse: false
media:
memsink:
h264:
sink: "kvmd::ustreamer::h264"
vnc:
memsink:
jpeg:
sink: "kvmd::ustreamer::jpeg"
h264:
sink: "kvmd::ustreamer::h264"

View File

@ -17,8 +17,6 @@ kvmd:
hid:
type: otg
mouse_alt:
device: /dev/kvmd-hid-mouse-alt
atx:
type: gpio

View File

@ -4,11 +4,11 @@
# will be displayed in the web interface.
server:
host: localhost.localdomain
host: "@auto"
kvm: {
base_on: PiKVM,
app_name: One-KVM,
main_version: 241204,
author: SilentWind
base_on: "PiKVM",
app_name: "One-KVM",
main_version: "241204",
author: "SilentWind"
}

View File

@ -1,12 +1,9 @@
# This file describes the credentials for VNCAuth. The left part before arrow is a passphrase
# for VNCAuth. The right part is username and password with which the user can access to KVMD API.
# The arrow is used as a separator and shows the relationship of user registrations on the system.
# This file contains passwords for the legacy VNCAuth, one per line.
# The passwords are NOT encrypted.
#
# Never use the same passwords for VNC and IPMI users. This default configuration is shown here
# for example only.
# WARNING! The VNCAuth method is NOT secure and should not be used at all.
# But we support it for compatibility with some clients.
#
# If this file does not contain any entries, VNCAuth will be disabled and you will only be able
# to login in using your KVMD username and password using VeNCrypt methods.
# NEVER use the same passwords for KVMD, IPMI and VNCAuth users.
# pa$$phr@se -> admin:password
admin -> admin:admin
admin

View File

@ -24,6 +24,7 @@ location @login {
location /login {
root /usr/share/kvmd/web;
include /etc/kvmd/nginx/loc-nocache.conf;
auth_request off;
}
@ -65,6 +66,7 @@ location /api/hid/print {
proxy_pass http://kvmd;
include /etc/kvmd/nginx/loc-proxy.conf;
include /etc/kvmd/nginx/loc-bigpost.conf;
proxy_read_timeout 7d;
auth_request off;
}

View File

@ -1,4 +1,2 @@
limit_rate 6250k;
limit_rate_after 50k;
client_max_body_size 0;
proxy_request_buffering off;

View File

@ -39,9 +39,9 @@ http {
% if https_enabled:
server {
listen ${http_port};
listen ${http_ipv4}:${http_port};
% if ipv6_enabled:
listen [::]:${http_port};
listen [${http_ipv6}]:${http_port};
% endif
include /etc/kvmd/nginx/certbot.ctx-server.conf;
location / {
@ -54,9 +54,9 @@ http {
}
server {
listen ${https_port} ssl http2;
listen ${https_ipv4}:${https_port} ssl;
% if ipv6_enabled:
listen [::]:${https_port} ssl http2;
listen [${https_ipv6}]:${https_port} ssl;
% endif
include /etc/kvmd/nginx/ssl.conf;
include /etc/kvmd/nginx/kvmd.ctx-server.conf;
@ -66,9 +66,9 @@ http {
% else:
server {
listen ${http_port};
listen ${http_ipv4}:${http_port};
% if ipv6_enabled:
listen [::]:${http_port};
listen [${http_ipv6}]:${http_port};
% endif
include /etc/kvmd/nginx/certbot.ctx-server.conf;
include /etc/kvmd/nginx/kvmd.ctx-server.conf;

View File

@ -3,7 +3,7 @@
initramfs initramfs-linux.img followkernel
hdmi_force_hotplug=1
gpu_mem=128
gpu_mem=192
enable_uart=1
dtoverlay=disable-bt

View File

@ -1 +1 @@
s/rootwait/rootwait cma=128M/g
s/rootwait/rootwait cma=192M/g

View File

@ -0,0 +1,16 @@
[Unit]
Description=PiKVM - Local HID to KVMD proxy
After=kvmd.service systemd-udevd.service
[Service]
User=kvmd-localhid
Group=kvmd-localhid
Type=simple
Restart=always
RestartSec=3
ExecStart=/usr/bin/kvmd-localhid --run
TimeoutStopSec=3
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,8 @@
# Fix https://github.com/pikvm/pikvm/issues/1514:
# Wait for any single network interface, not all configured ones
# (Rationale: when user configures Wi-Fi via pikvm.txt or otherwise,
# we do not delete the Ethernet config, which means it will remain active
# regardless of whether the user ever intended to use Ethernet.)
[Service]
ExecStart=
ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any

View File

@ -1,8 +1,10 @@
g kvmd - -
g kvmd-selfauth - -
g kvmd-media - -
g kvmd-pst - -
g kvmd-ipmi - -
g kvmd-vnc - -
g kvmd-localhid - -
g kvmd-nginx - -
g kvmd-janus - -
g kvmd-certbot - -
@ -12,6 +14,7 @@ u kvmd-media - "PiKVM - The media proxy"
u kvmd-pst - "PiKVM - Persistent storage" -
u kvmd-ipmi - "PiKVM - IPMI to KVMD proxy" -
u kvmd-vnc - "PiKVM - VNC to KVMD/Streamer proxy" -
u kvmd-localhid - "PiKVM - Local HID to KVMD proxy" -
u kvmd-nginx - "PiKVM - HTTP entrypoint" -
u kvmd-janus - "PiKVM - Janus WebRTC Gateway" -
u kvmd-certbot - "PiKVM - Certbot-Renew for KVMD-Nginx"
@ -29,10 +32,16 @@ m kvmd-media kvmd
m kvmd-pst kvmd
m kvmd-ipmi kvmd
m kvmd-ipmi kvmd-selfauth
m kvmd-vnc kvmd
m kvmd-vnc kvmd-selfauth
m kvmd-vnc kvmd-certbot
m kvmd-localhid input
m kvmd-localhid kvmd
m kvmd-localhid kvmd-selfauth
m kvmd-janus kvmd
m kvmd-janus audio

View File

@ -1,4 +1,15 @@
# 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"
ACTION!="remove", KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="eda3", SYMLINK+="kvmd-hid-bridge"
ACTION!="remove", KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="1080", SYMLINK+="kvmd-switch"
# Disable USB autosuspend for critical devices
ACTION!="remove", SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="eda3", GOTO="kvmd-usb"
ACTION!="remove", SUBSYSTEM=="usb", ATTR{idVendor}=="2e8a", ATTR{idProduct}=="1080", GOTO="kvmd-usb"
GOTO="end"
LABEL="kvmd-usb"
ATTR{power/control}="on", ATTR{power/autosuspend_delay_ms}="-1"
LABEL="end"

File diff suppressed because it is too large Load Diff

View File

@ -49,13 +49,15 @@ oneeighth 0x03 shift altgr
quotedbl 0x04
3 0x04 shift
numbersign 0x04 altgr
sterling 0x04 shift altgr
# KVMD
#sterling 0x04 shift altgr
# evdev 5 (0x5), QKeyCode "4", number 0x5
apostrophe 0x05
4 0x05 shift
braceleft 0x05 altgr
dollar 0x05 shift altgr
# KVMD
#dollar 0x05 shift altgr
# evdev 6 (0x6), QKeyCode "5", number 0x6
parenleft 0x06
@ -91,7 +93,8 @@ plusminus 0x0a shift altgr
agrave 0x0b
0 0x0b shift
at 0x0b altgr
degree 0x0b shift altgr
# KVMD
#degree 0x0b shift altgr
# evdev 12 (0xc), QKeyCode "minus", number 0xc
parenright 0x0c
@ -122,7 +125,8 @@ AE 0x10 shift altgr
z 0x11
Z 0x11 shift
guillemotleft 0x11 altgr
less 0x11 shift altgr
#KVMD
#less 0x11 shift altgr
# evdev 18 (0x12), QKeyCode "e", number 0x12
e 0x12
@ -200,7 +204,8 @@ Greek_OMEGA 0x1e shift altgr
s 0x1f
S 0x1f shift
ssharp 0x1f altgr
section 0x1f shift altgr
# KVMD
#section 0x1f shift altgr
# evdev 32 (0x20), QKeyCode "d", number 0x20
d 0x20
@ -247,7 +252,8 @@ Lstroke 0x26 shift altgr
# evdev 39 (0x27), QKeyCode "semicolon", number 0x27
m 0x27
M 0x27 shift
mu 0x27 altgr
# KVMD
#mu 0x27 altgr
masculine 0x27 shift altgr
# evdev 40 (0x28), QKeyCode "apostrophe", number 0x28
@ -280,7 +286,8 @@ Lstroke 0x2c shift altgr
x 0x2d
X 0x2d shift
guillemotright 0x2d altgr
greater 0x2d shift altgr
# KVMD
#greater 0x2d shift altgr
# evdev 46 (0x2e), QKeyCode "c", number 0x2e
c 0x2e

View File

@ -69,9 +69,10 @@ class _X11Key:
@dataclasses.dataclass(frozen=True)
class _KeyMapping:
web_name: str
evdev_name: str
mcu_code: int
usb_key: _UsbKey
ps2_key: _Ps2Key
ps2_key: (_Ps2Key | None)
at1_code: int
x11_keys: set[_X11Key]
@ -107,7 +108,9 @@ def _parse_usb_key(key: str) -> _UsbKey:
return _UsbKey(code, is_modifier)
def _parse_ps2_key(key: str) -> _Ps2Key:
def _parse_ps2_key(key: str) -> (_Ps2Key | None):
if ":" not in key:
return None
(code_type, raw_code) = key.split(":")
return _Ps2Key(
code=int(raw_code, 16),
@ -122,6 +125,7 @@ def _read_keymap_csv(path: str) -> list[_KeyMapping]:
if len(row) >= 6:
keymap.append(_KeyMapping(
web_name=row["web_name"],
evdev_name=row["evdev_name"],
mcu_code=int(row["mcu_code"]),
usb_key=_parse_usb_key(row["usb_key"]),
ps2_key=_parse_ps2_key(row["ps2_key"]),
@ -150,6 +154,7 @@ def main() -> None:
# Fields list:
# - Web
# - Linux/evdev
# - MCU code
# - USB code (^ for the modifier mask)
# - PS/2 key

View File

@ -24,8 +24,8 @@ upload:
bash -ex -c " \
current=`cat .current`; \
if [ '$($@_CURRENT)' == 'spi' ] || [ '$($@_CURRENT)' == 'aum' ]; then \
gpioset 0 25=1; \
gpioset 0 25=0; \
gpioset -c gpiochip0 -t 30ms,0 25=1; \
gpioset -c gpiochip0 -t 30ms,0 25=0; \
fi \
"
platformio run --environment '$($@_CURRENT)' --project-conf 'platformio-$($@_CONFIG).ini' --target upload

View File

@ -2,6 +2,7 @@ programmer
id = "rpi";
desc = "RPi SPI programmer";
type = "linuxspi";
prog_modes = PM_ISP;
reset = 25;
baudrate = 400000;
;

View File

@ -148,5 +148,8 @@ void keymapPs2(uint8_t code, Ps2KeyType *ps2_type, uint8_t *ps2_code) {
case 109: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 19; return; // KanaMode
case 110: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 100; return; // Convert
case 111: *ps2_type = PS2_KEY_TYPE_REG; *ps2_code = 103; return; // NonConvert
case 112: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 35; return; // AudioVolumeMute
case 113: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 50; return; // AudioVolumeUp
case 114: *ps2_type = PS2_KEY_TYPE_SPEC; *ps2_code = 33; return; // AudioVolumeDown
}
}

View File

@ -38,7 +38,9 @@ void keymapPs2(uint8_t code, Ps2KeyType *ps2_type, uint8_t *ps2_code) {
switch (code) {
% for km in sorted(keymap, key=operator.attrgetter("mcu_code")):
% if km.ps2_key is not None:
case ${km.mcu_code}: *ps2_type = PS2_KEY_TYPE_${km.ps2_key.type.upper()}; *ps2_code = ${km.ps2_key.code}; return; // ${km.web_name}
% endif
% endfor
}
}

View File

@ -136,6 +136,10 @@ uint8_t keymapUsb(uint8_t code) {
case 109: return 136; // KanaMode
case 110: return 138; // Convert
case 111: return 139; // NonConvert
case 112: return 127; // AudioVolumeMute
case 113: return 128; // AudioVolumeUp
case 114: return 129; // AudioVolumeDown
case 115: return 111; // F20
default: return 0;
}
}

View File

@ -82,8 +82,6 @@ build_flags =
-DCDC_DISABLED
upload_protocol = custom
upload_flags =
-C
$PROJECT_PACKAGES_DIR/tool-avrdude/avrdude.conf
-C
+avrdude-rpi.conf
-P

View File

@ -28,11 +28,14 @@ define libdep
endef
.pico-sdk:
$(call libdep,pico-sdk,raspberrypi/pico-sdk,6a7db34ff63345a7badec79ebea3aaef1712f374)
.pico-sdk.patches: .pico-sdk
patch -d .pico-sdk -p1 < patches/pico-sdk.patch
touch .pico-sdk.patches
.tinyusb:
$(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0)
.ps2x2pico:
$(call libdep,ps2x2pico,No0ne/ps2x2pico,26ce89d597e598bb0ac636622e064202d91a9efc)
deps: .pico-sdk .tinyusb .ps2x2pico
deps: .pico-sdk .pico-sdk.patches .tinyusb .ps2x2pico
.PHONY: deps

View File

@ -0,0 +1,10 @@
diff --git a/tools/pioasm/CMakeLists.txt b/tools/pioasm/CMakeLists.txt
index 322408a..fc8e4b8 100644
--- a/tools/pioasm/CMakeLists.txt
+++ b/tools/pioasm/CMakeLists.txt
@@ -1,4 +1,4 @@
-cmake_minimum_required(VERSION 3.4)
+cmake_minimum_required(VERSION 3.5)
project(pioasm CXX)
set(CMAKE_CXX_STANDARD 11)

View File

@ -138,6 +138,10 @@ inline u8 ph_usb_keymap(u8 key) {
case 109: return 136; // KanaMode
case 110: return 138; // Convert
case 111: return 139; // NonConvert
case 112: return 127; // AudioVolumeMute
case 113: return 128; // AudioVolumeUp
case 114: return 129; // AudioVolumeDown
case 115: return 111; // F20
}
return 0;
}

View File

@ -1,112 +1,116 @@
web_name,mcu_code,usb_key,ps2_key,at1_code,x11_names
KeyA,1,0x04,reg:0x1c,0x1e,"^XK_A,XK_a"
KeyB,2,0x05,reg:0x32,0x30,"^XK_B,XK_b"
KeyC,3,0x06,reg:0x21,0x2e,"^XK_C,XK_c"
KeyD,4,0x07,reg:0x23,0x20,"^XK_D,XK_d"
KeyE,5,0x08,reg:0x24,0x12,"^XK_E,XK_e"
KeyF,6,0x09,reg:0x2b,0x21,"^XK_F,XK_f"
KeyG,7,0x0a,reg:0x34,0x22,"^XK_G,XK_g"
KeyH,8,0x0b,reg:0x33,0x23,"^XK_H,XK_h"
KeyI,9,0x0c,reg:0x43,0x17,"^XK_I,XK_i"
KeyJ,10,0x0d,reg:0x3b,0x24,"^XK_J,XK_j"
KeyK,11,0x0e,reg:0x42,0x25,"^XK_K,XK_k"
KeyL,12,0x0f,reg:0x4b,0x26,"^XK_L,XK_l"
KeyM,13,0x10,reg:0x3a,0x32,"^XK_M,XK_m"
KeyN,14,0x11,reg:0x31,0x31,"^XK_N,XK_n"
KeyO,15,0x12,reg:0x44,0x18,"^XK_O,XK_o"
KeyP,16,0x13,reg:0x4d,0x19,"^XK_P,XK_p"
KeyQ,17,0x14,reg:0x15,0x10,"^XK_Q,XK_q"
KeyR,18,0x15,reg:0x2d,0x13,"^XK_R,XK_r"
KeyS,19,0x16,reg:0x1b,0x1f,"^XK_S,XK_s"
KeyT,20,0x17,reg:0x2c,0x14,"^XK_T,XK_t"
KeyU,21,0x18,reg:0x3c,0x16,"^XK_U,XK_u"
KeyV,22,0x19,reg:0x2a,0x2f,"^XK_V,XK_v"
KeyW,23,0x1a,reg:0x1d,0x11,"^XK_W,XK_w"
KeyX,24,0x1b,reg:0x22,0x2d,"^XK_X,XK_x"
KeyY,25,0x1c,reg:0x35,0x15,"^XK_Y,XK_y"
KeyZ,26,0x1d,reg:0x1a,0x2c,"^XK_Z,XK_z"
Digit1,27,0x1e,reg:0x16,0x02,"XK_1,^XK_exclam"
Digit2,28,0x1f,reg:0x1e,0x03,"XK_2,^XK_at"
Digit3,29,0x20,reg:0x26,0x04,"XK_3,^XK_numbersign"
Digit4,30,0x21,reg:0x25,0x05,"XK_4,^XK_dollar"
Digit5,31,0x22,reg:0x2e,0x06,"XK_5,^XK_percent"
Digit6,32,0x23,reg:0x36,0x07,"XK_6,^XK_asciicircum"
Digit7,33,0x24,reg:0x3d,0x08,"XK_7,^XK_ampersand"
Digit8,34,0x25,reg:0x3e,0x09,"XK_8,^XK_asterisk"
Digit9,35,0x26,reg:0x46,0x0a,"XK_9,^XK_parenleft"
Digit0,36,0x27,reg:0x45,0x0b,"XK_0,^XK_parenright"
Enter,37,0x28,reg:0x5a,0x1c,XK_Return
Escape,38,0x29,reg:0x76,0x01,XK_Escape
Backspace,39,0x2a,reg:0x66,0x0e,XK_BackSpace
Tab,40,0x2b,reg:0x0d,0x0f,XK_Tab
Space,41,0x2c,reg:0x29,0x39,XK_space
Minus,42,0x2d,reg:0x4e,0x0c,"XK_minus,^XK_underscore"
Equal,43,0x2e,reg:0x55,0x0d,"XK_equal,^XK_plus"
BracketLeft,44,0x2f,reg:0x54,0x1a,"XK_bracketleft,^XK_braceleft"
BracketRight,45,0x30,reg:0x5b,0x1b,"XK_bracketright,^XK_braceright"
Backslash,46,0x31,reg:0x5d,0x2b,"XK_backslash,^XK_bar"
Semicolon,47,0x33,reg:0x4c,0x27,"XK_semicolon,^XK_colon"
Quote,48,0x34,reg:0x52,0x28,"XK_apostrophe,^XK_quotedbl"
Backquote,49,0x35,reg:0x0e,0x29,"XK_grave,^XK_asciitilde"
Comma,50,0x36,reg:0x41,0x33,"XK_comma,^XK_less"
Period,51,0x37,reg:0x49,0x34,"XK_period,^XK_greater"
Slash,52,0x38,reg:0x4a,0x35,"XK_slash,^XK_question"
CapsLock,53,0x39,reg:0x58,0x3a,XK_Caps_Lock
F1,54,0x3a,reg:0x05,0x3b,XK_F1
F2,55,0x3b,reg:0x06,0x3c,XK_F2
F3,56,0x3c,reg:0x04,0x3d,XK_F3
F4,57,0x3d,reg:0x0c,0x3e,XK_F4
F5,58,0x3e,reg:0x03,0x3f,XK_F5
F6,59,0x3f,reg:0x0b,0x40,XK_F6
F7,60,0x40,reg:0x83,0x41,XK_F7
F8,61,0x41,reg:0x0a,0x42,XK_F8
F9,62,0x42,reg:0x01,0x43,XK_F9
F10,63,0x43,reg:0x09,0x44,XK_F10
F11,64,0x44,reg:0x78,0x57,XK_F11
F12,65,0x45,reg:0x07,0x58,XK_F12
PrintScreen,66,0x46,print:0xff,0x54,XK_Sys_Req
Insert,67,0x49,spec:0x70,0xe052,XK_Insert
Home,68,0x4a,spec:0x6c,0xe047,XK_Home
PageUp,69,0x4b,spec:0x7d,0xe049,XK_Page_Up
Delete,70,0x4c,spec:0x71,0xe053,XK_Delete
End,71,0x4d,spec:0x69,0xe04f,XK_End
PageDown,72,0x4e,spec:0x7a,0xe051,XK_Page_Down
ArrowRight,73,0x4f,spec:0x74,0xe04d,XK_Right
ArrowLeft,74,0x50,spec:0x6b,0xe04b,XK_Left
ArrowDown,75,0x51,spec:0x72,0xe050,XK_Down
ArrowUp,76,0x52,spec:0x75,0xe048,XK_Up
ControlLeft,77,^0x01,reg:0x14,0x1d,XK_Control_L
ShiftLeft,78,^0x02,reg:0x12,0x2a,XK_Shift_L
AltLeft,79,^0x04,reg:0x11,0x38,XK_Alt_L
MetaLeft,80,^0x08,spec:0x1f,0xe05b,"XK_Meta_L,XK_Super_L"
ControlRight,81,^0x10,spec:0x14,0xe01d,XK_Control_R
ShiftRight,82,^0x20,reg:0x59,0x36,XK_Shift_R
AltRight,83,^0x40,spec:0x11,0xe038,"XK_Alt_R,XK_ISO_Level3_Shift"
MetaRight,84,^0x80,spec:0x27,0xe05c,"XK_Meta_R,XK_Super_R"
Pause,85,0x48,pause:0xff,0xe046,XK_Pause
ScrollLock,86,0x47,reg:0x7e,0x46,XK_Scroll_Lock
NumLock,87,0x53,reg:0x77,0x45,XK_Num_Lock
ContextMenu,88,0x65,spec:0x2f,0xe05d,XK_Menu
NumpadDivide,89,0x54,spec:0x4a,0xe035,XK_KP_Divide
NumpadMultiply,90,0x55,reg:0x7c,0x37,XK_multiply
NumpadSubtract,91,0x56,reg:0x7b,0x4a,XK_KP_Subtract
NumpadAdd,92,0x57,reg:0x79,0x4e,XK_KP_Add
NumpadEnter,93,0x58,spec:0x5a,0xe01c,XK_KP_Enter
Numpad1,94,0x59,reg:0x69,0x4f,XK_KP_1
Numpad2,95,0x5a,reg:0x72,0x50,XK_KP_2
Numpad3,96,0x5b,reg:0x7a,0x51,XK_KP_3
Numpad4,97,0x5c,reg:0x6b,0x4b,XK_KP_4
Numpad5,98,0x5d,reg:0x73,0x4c,XK_KP_5
Numpad6,99,0x5e,reg:0x74,0x4d,XK_KP_6
Numpad7,100,0x5f,reg:0x6c,0x47,XK_KP_7
Numpad8,101,0x60,reg:0x75,0x48,XK_KP_8
Numpad9,102,0x61,reg:0x7d,0x49,XK_KP_9
Numpad0,103,0x62,reg:0x70,0x52,XK_KP_0
NumpadDecimal,104,0x63,reg:0x71,0x53,XK_KP_Decimal
Power,105,0x66,spec:0x5e,0xe05e,XK_XF86_Sleep
IntlBackslash,106,0x64,reg:0x61,0x56,""
IntlYen,107,0x89,reg:0x6a,0x7d,""
IntlRo,108,0x87,reg:0x51,0x73,""
KanaMode,109,0x88,reg:0x13,0x70,""
Convert,110,0x8a,reg:0x64,0x79,""
NonConvert,111,0x8b,reg:0x67,0x7b,""
web_name,evdev_name,mcu_code,usb_key,ps2_key,at1_code,x11_names
KeyA,KEY_A,1,0x04,reg:0x1c,0x1e,"^XK_A,XK_a"
KeyB,KEY_B,2,0x05,reg:0x32,0x30,"^XK_B,XK_b"
KeyC,KEY_C,3,0x06,reg:0x21,0x2e,"^XK_C,XK_c"
KeyD,KEY_D,4,0x07,reg:0x23,0x20,"^XK_D,XK_d"
KeyE,KEY_E,5,0x08,reg:0x24,0x12,"^XK_E,XK_e"
KeyF,KEY_F,6,0x09,reg:0x2b,0x21,"^XK_F,XK_f"
KeyG,KEY_G,7,0x0a,reg:0x34,0x22,"^XK_G,XK_g"
KeyH,KEY_H,8,0x0b,reg:0x33,0x23,"^XK_H,XK_h"
KeyI,KEY_I,9,0x0c,reg:0x43,0x17,"^XK_I,XK_i"
KeyJ,KEY_J,10,0x0d,reg:0x3b,0x24,"^XK_J,XK_j"
KeyK,KEY_K,11,0x0e,reg:0x42,0x25,"^XK_K,XK_k"
KeyL,KEY_L,12,0x0f,reg:0x4b,0x26,"^XK_L,XK_l"
KeyM,KEY_M,13,0x10,reg:0x3a,0x32,"^XK_M,XK_m"
KeyN,KEY_N,14,0x11,reg:0x31,0x31,"^XK_N,XK_n"
KeyO,KEY_O,15,0x12,reg:0x44,0x18,"^XK_O,XK_o"
KeyP,KEY_P,16,0x13,reg:0x4d,0x19,"^XK_P,XK_p"
KeyQ,KEY_Q,17,0x14,reg:0x15,0x10,"^XK_Q,XK_q"
KeyR,KEY_R,18,0x15,reg:0x2d,0x13,"^XK_R,XK_r"
KeyS,KEY_S,19,0x16,reg:0x1b,0x1f,"^XK_S,XK_s"
KeyT,KEY_T,20,0x17,reg:0x2c,0x14,"^XK_T,XK_t"
KeyU,KEY_U,21,0x18,reg:0x3c,0x16,"^XK_U,XK_u"
KeyV,KEY_V,22,0x19,reg:0x2a,0x2f,"^XK_V,XK_v"
KeyW,KEY_W,23,0x1a,reg:0x1d,0x11,"^XK_W,XK_w"
KeyX,KEY_X,24,0x1b,reg:0x22,0x2d,"^XK_X,XK_x"
KeyY,KEY_Y,25,0x1c,reg:0x35,0x15,"^XK_Y,XK_y"
KeyZ,KEY_Z,26,0x1d,reg:0x1a,0x2c,"^XK_Z,XK_z"
Digit1,KEY_1,27,0x1e,reg:0x16,0x02,"XK_1,^XK_exclam"
Digit2,KEY_2,28,0x1f,reg:0x1e,0x03,"XK_2,^XK_at"
Digit3,KEY_3,29,0x20,reg:0x26,0x04,"XK_3,^XK_numbersign"
Digit4,KEY_4,30,0x21,reg:0x25,0x05,"XK_4,^XK_dollar"
Digit5,KEY_5,31,0x22,reg:0x2e,0x06,"XK_5,^XK_percent"
Digit6,KEY_6,32,0x23,reg:0x36,0x07,"XK_6,^XK_asciicircum"
Digit7,KEY_7,33,0x24,reg:0x3d,0x08,"XK_7,^XK_ampersand"
Digit8,KEY_8,34,0x25,reg:0x3e,0x09,"XK_8,^XK_asterisk"
Digit9,KEY_9,35,0x26,reg:0x46,0x0a,"XK_9,^XK_parenleft"
Digit0,KEY_0,36,0x27,reg:0x45,0x0b,"XK_0,^XK_parenright"
Enter,KEY_ENTER,37,0x28,reg:0x5a,0x1c,XK_Return
Escape,KEY_ESC,38,0x29,reg:0x76,0x01,XK_Escape
Backspace,KEY_BACKSPACE,39,0x2a,reg:0x66,0x0e,XK_BackSpace
Tab,KEY_TAB,40,0x2b,reg:0x0d,0x0f,XK_Tab
Space,KEY_SPACE,41,0x2c,reg:0x29,0x39,XK_space
Minus,KEY_MINUS,42,0x2d,reg:0x4e,0x0c,"XK_minus,^XK_underscore"
Equal,KEY_EQUAL,43,0x2e,reg:0x55,0x0d,"XK_equal,^XK_plus"
BracketLeft,KEY_LEFTBRACE,44,0x2f,reg:0x54,0x1a,"XK_bracketleft,^XK_braceleft"
BracketRight,KEY_RIGHTBRACE,45,0x30,reg:0x5b,0x1b,"XK_bracketright,^XK_braceright"
Backslash,KEY_BACKSLASH,46,0x31,reg:0x5d,0x2b,"XK_backslash,^XK_bar"
Semicolon,KEY_SEMICOLON,47,0x33,reg:0x4c,0x27,"XK_semicolon,^XK_colon"
Quote,KEY_APOSTROPHE,48,0x34,reg:0x52,0x28,"XK_apostrophe,^XK_quotedbl"
Backquote,KEY_GRAVE,49,0x35,reg:0x0e,0x29,"XK_grave,^XK_asciitilde"
Comma,KEY_COMMA,50,0x36,reg:0x41,0x33,"XK_comma,^XK_less"
Period,KEY_DOT,51,0x37,reg:0x49,0x34,"XK_period,^XK_greater"
Slash,KEY_SLASH,52,0x38,reg:0x4a,0x35,"XK_slash,^XK_question"
CapsLock,KEY_CAPSLOCK,53,0x39,reg:0x58,0x3a,XK_Caps_Lock
F1,KEY_F1,54,0x3a,reg:0x05,0x3b,XK_F1
F2,KEY_F2,55,0x3b,reg:0x06,0x3c,XK_F2
F3,KEY_F3,56,0x3c,reg:0x04,0x3d,XK_F3
F4,KEY_F4,57,0x3d,reg:0x0c,0x3e,XK_F4
F5,KEY_F5,58,0x3e,reg:0x03,0x3f,XK_F5
F6,KEY_F6,59,0x3f,reg:0x0b,0x40,XK_F6
F7,KEY_F7,60,0x40,reg:0x83,0x41,XK_F7
F8,KEY_F8,61,0x41,reg:0x0a,0x42,XK_F8
F9,KEY_F9,62,0x42,reg:0x01,0x43,XK_F9
F10,KEY_F10,63,0x43,reg:0x09,0x44,XK_F10
F11,KEY_F11,64,0x44,reg:0x78,0x57,XK_F11
F12,KEY_F12,65,0x45,reg:0x07,0x58,XK_F12
PrintScreen,KEY_SYSRQ,66,0x46,print:0xff,0x54,XK_Sys_Req
Insert,KEY_INSERT,67,0x49,spec:0x70,0xe052,XK_Insert
Home,KEY_HOME,68,0x4a,spec:0x6c,0xe047,XK_Home
PageUp,KEY_PAGEUP,69,0x4b,spec:0x7d,0xe049,XK_Page_Up
Delete,KEY_DELETE,70,0x4c,spec:0x71,0xe053,XK_Delete
End,KEY_END,71,0x4d,spec:0x69,0xe04f,XK_End
PageDown,KEY_PAGEDOWN,72,0x4e,spec:0x7a,0xe051,XK_Page_Down
ArrowRight,KEY_RIGHT,73,0x4f,spec:0x74,0xe04d,XK_Right
ArrowLeft,KEY_LEFT,74,0x50,spec:0x6b,0xe04b,XK_Left
ArrowDown,KEY_DOWN,75,0x51,spec:0x72,0xe050,XK_Down
ArrowUp,KEY_UP,76,0x52,spec:0x75,0xe048,XK_Up
ControlLeft,KEY_LEFTCTRL,77,^0x01,reg:0x14,0x1d,XK_Control_L
ShiftLeft,KEY_LEFTSHIFT,78,^0x02,reg:0x12,0x2a,XK_Shift_L
AltLeft,KEY_LEFTALT,79,^0x04,reg:0x11,0x38,XK_Alt_L
MetaLeft,KEY_LEFTMETA,80,^0x08,spec:0x1f,0xe05b,"XK_Meta_L,XK_Super_L"
ControlRight,KEY_RIGHTCTRL,81,^0x10,spec:0x14,0xe01d,XK_Control_R
ShiftRight,KEY_RIGHTSHIFT,82,^0x20,reg:0x59,0x36,XK_Shift_R
AltRight,KEY_RIGHTALT,83,^0x40,spec:0x11,0xe038,"XK_Alt_R,XK_ISO_Level3_Shift"
MetaRight,KEY_RIGHTMETA,84,^0x80,spec:0x27,0xe05c,"XK_Meta_R,XK_Super_R"
Pause,KEY_PAUSE,85,0x48,pause:0xff,0xe046,XK_Pause
ScrollLock,KEY_SCROLLLOCK,86,0x47,reg:0x7e,0x46,XK_Scroll_Lock
NumLock,KEY_NUMLOCK,87,0x53,reg:0x77,0x45,XK_Num_Lock
ContextMenu,KEY_CONTEXT_MENU,88,0x65,spec:0x2f,0xe05d,XK_Menu
NumpadDivide,KEY_KPSLASH,89,0x54,spec:0x4a,0xe035,XK_KP_Divide
NumpadMultiply,KEY_KPASTERISK,90,0x55,reg:0x7c,0x37,XK_multiply
NumpadSubtract,KEY_KPMINUS,91,0x56,reg:0x7b,0x4a,XK_KP_Subtract
NumpadAdd,KEY_KPPLUS,92,0x57,reg:0x79,0x4e,XK_KP_Add
NumpadEnter,KEY_KPENTER,93,0x58,spec:0x5a,0xe01c,XK_KP_Enter
Numpad1,KEY_KP1,94,0x59,reg:0x69,0x4f,XK_KP_1
Numpad2,KEY_KP2,95,0x5a,reg:0x72,0x50,XK_KP_2
Numpad3,KEY_KP3,96,0x5b,reg:0x7a,0x51,XK_KP_3
Numpad4,KEY_KP4,97,0x5c,reg:0x6b,0x4b,XK_KP_4
Numpad5,KEY_KP5,98,0x5d,reg:0x73,0x4c,XK_KP_5
Numpad6,KEY_KP6,99,0x5e,reg:0x74,0x4d,XK_KP_6
Numpad7,KEY_KP7,100,0x5f,reg:0x6c,0x47,XK_KP_7
Numpad8,KEY_KP8,101,0x60,reg:0x75,0x48,XK_KP_8
Numpad9,KEY_KP9,102,0x61,reg:0x7d,0x49,XK_KP_9
Numpad0,KEY_KP0,103,0x62,reg:0x70,0x52,XK_KP_0
NumpadDecimal,KEY_KPDOT,104,0x63,reg:0x71,0x53,XK_KP_Decimal
Power,KEY_POWER,105,0x66,spec:0x5e,0xe05e,XK_XF86_Sleep
IntlBackslash,KEY_102ND,106,0x64,reg:0x61,0x56,
IntlYen,KEY_YEN,107,0x89,reg:0x6a,0x7d,
IntlRo,KEY_RO,108,0x87,reg:0x51,0x73,
KanaMode,KEY_KATAKANA,109,0x88,reg:0x13,0x70,
Convert,KEY_HENKAN,110,0x8a,reg:0x64,0x79,
NonConvert,KEY_MUHENKAN,111,0x8b,reg:0x67,0x7b,
AudioVolumeMute,KEY_MUTE,112,0x7f,spec:0x23,0xe020,
AudioVolumeUp,KEY_VOLUMEUP,113,0x80,spec:0x32,0xe030,
AudioVolumeDown,KEY_VOLUMEDOWN,114,0x81,spec:0x21,0xe02e,
F20,KEY_F20,115,0x6f,,0x5a,

1 web_name evdev_name mcu_code usb_key ps2_key at1_code x11_names
2 KeyA KEY_A 1 0x04 reg:0x1c 0x1e ^XK_A,XK_a
3 KeyB KEY_B 2 0x05 reg:0x32 0x30 ^XK_B,XK_b
4 KeyC KEY_C 3 0x06 reg:0x21 0x2e ^XK_C,XK_c
5 KeyD KEY_D 4 0x07 reg:0x23 0x20 ^XK_D,XK_d
6 KeyE KEY_E 5 0x08 reg:0x24 0x12 ^XK_E,XK_e
7 KeyF KEY_F 6 0x09 reg:0x2b 0x21 ^XK_F,XK_f
8 KeyG KEY_G 7 0x0a reg:0x34 0x22 ^XK_G,XK_g
9 KeyH KEY_H 8 0x0b reg:0x33 0x23 ^XK_H,XK_h
10 KeyI KEY_I 9 0x0c reg:0x43 0x17 ^XK_I,XK_i
11 KeyJ KEY_J 10 0x0d reg:0x3b 0x24 ^XK_J,XK_j
12 KeyK KEY_K 11 0x0e reg:0x42 0x25 ^XK_K,XK_k
13 KeyL KEY_L 12 0x0f reg:0x4b 0x26 ^XK_L,XK_l
14 KeyM KEY_M 13 0x10 reg:0x3a 0x32 ^XK_M,XK_m
15 KeyN KEY_N 14 0x11 reg:0x31 0x31 ^XK_N,XK_n
16 KeyO KEY_O 15 0x12 reg:0x44 0x18 ^XK_O,XK_o
17 KeyP KEY_P 16 0x13 reg:0x4d 0x19 ^XK_P,XK_p
18 KeyQ KEY_Q 17 0x14 reg:0x15 0x10 ^XK_Q,XK_q
19 KeyR KEY_R 18 0x15 reg:0x2d 0x13 ^XK_R,XK_r
20 KeyS KEY_S 19 0x16 reg:0x1b 0x1f ^XK_S,XK_s
21 KeyT KEY_T 20 0x17 reg:0x2c 0x14 ^XK_T,XK_t
22 KeyU KEY_U 21 0x18 reg:0x3c 0x16 ^XK_U,XK_u
23 KeyV KEY_V 22 0x19 reg:0x2a 0x2f ^XK_V,XK_v
24 KeyW KEY_W 23 0x1a reg:0x1d 0x11 ^XK_W,XK_w
25 KeyX KEY_X 24 0x1b reg:0x22 0x2d ^XK_X,XK_x
26 KeyY KEY_Y 25 0x1c reg:0x35 0x15 ^XK_Y,XK_y
27 KeyZ KEY_Z 26 0x1d reg:0x1a 0x2c ^XK_Z,XK_z
28 Digit1 KEY_1 27 0x1e reg:0x16 0x02 XK_1,^XK_exclam
29 Digit2 KEY_2 28 0x1f reg:0x1e 0x03 XK_2,^XK_at
30 Digit3 KEY_3 29 0x20 reg:0x26 0x04 XK_3,^XK_numbersign
31 Digit4 KEY_4 30 0x21 reg:0x25 0x05 XK_4,^XK_dollar
32 Digit5 KEY_5 31 0x22 reg:0x2e 0x06 XK_5,^XK_percent
33 Digit6 KEY_6 32 0x23 reg:0x36 0x07 XK_6,^XK_asciicircum
34 Digit7 KEY_7 33 0x24 reg:0x3d 0x08 XK_7,^XK_ampersand
35 Digit8 KEY_8 34 0x25 reg:0x3e 0x09 XK_8,^XK_asterisk
36 Digit9 KEY_9 35 0x26 reg:0x46 0x0a XK_9,^XK_parenleft
37 Digit0 KEY_0 36 0x27 reg:0x45 0x0b XK_0,^XK_parenright
38 Enter KEY_ENTER 37 0x28 reg:0x5a 0x1c XK_Return
39 Escape KEY_ESC 38 0x29 reg:0x76 0x01 XK_Escape
40 Backspace KEY_BACKSPACE 39 0x2a reg:0x66 0x0e XK_BackSpace
41 Tab KEY_TAB 40 0x2b reg:0x0d 0x0f XK_Tab
42 Space KEY_SPACE 41 0x2c reg:0x29 0x39 XK_space
43 Minus KEY_MINUS 42 0x2d reg:0x4e 0x0c XK_minus,^XK_underscore
44 Equal KEY_EQUAL 43 0x2e reg:0x55 0x0d XK_equal,^XK_plus
45 BracketLeft KEY_LEFTBRACE 44 0x2f reg:0x54 0x1a XK_bracketleft,^XK_braceleft
46 BracketRight KEY_RIGHTBRACE 45 0x30 reg:0x5b 0x1b XK_bracketright,^XK_braceright
47 Backslash KEY_BACKSLASH 46 0x31 reg:0x5d 0x2b XK_backslash,^XK_bar
48 Semicolon KEY_SEMICOLON 47 0x33 reg:0x4c 0x27 XK_semicolon,^XK_colon
49 Quote KEY_APOSTROPHE 48 0x34 reg:0x52 0x28 XK_apostrophe,^XK_quotedbl
50 Backquote KEY_GRAVE 49 0x35 reg:0x0e 0x29 XK_grave,^XK_asciitilde
51 Comma KEY_COMMA 50 0x36 reg:0x41 0x33 XK_comma,^XK_less
52 Period KEY_DOT 51 0x37 reg:0x49 0x34 XK_period,^XK_greater
53 Slash KEY_SLASH 52 0x38 reg:0x4a 0x35 XK_slash,^XK_question
54 CapsLock KEY_CAPSLOCK 53 0x39 reg:0x58 0x3a XK_Caps_Lock
55 F1 KEY_F1 54 0x3a reg:0x05 0x3b XK_F1
56 F2 KEY_F2 55 0x3b reg:0x06 0x3c XK_F2
57 F3 KEY_F3 56 0x3c reg:0x04 0x3d XK_F3
58 F4 KEY_F4 57 0x3d reg:0x0c 0x3e XK_F4
59 F5 KEY_F5 58 0x3e reg:0x03 0x3f XK_F5
60 F6 KEY_F6 59 0x3f reg:0x0b 0x40 XK_F6
61 F7 KEY_F7 60 0x40 reg:0x83 0x41 XK_F7
62 F8 KEY_F8 61 0x41 reg:0x0a 0x42 XK_F8
63 F9 KEY_F9 62 0x42 reg:0x01 0x43 XK_F9
64 F10 KEY_F10 63 0x43 reg:0x09 0x44 XK_F10
65 F11 KEY_F11 64 0x44 reg:0x78 0x57 XK_F11
66 F12 KEY_F12 65 0x45 reg:0x07 0x58 XK_F12
67 PrintScreen KEY_SYSRQ 66 0x46 print:0xff 0x54 XK_Sys_Req
68 Insert KEY_INSERT 67 0x49 spec:0x70 0xe052 XK_Insert
69 Home KEY_HOME 68 0x4a spec:0x6c 0xe047 XK_Home
70 PageUp KEY_PAGEUP 69 0x4b spec:0x7d 0xe049 XK_Page_Up
71 Delete KEY_DELETE 70 0x4c spec:0x71 0xe053 XK_Delete
72 End KEY_END 71 0x4d spec:0x69 0xe04f XK_End
73 PageDown KEY_PAGEDOWN 72 0x4e spec:0x7a 0xe051 XK_Page_Down
74 ArrowRight KEY_RIGHT 73 0x4f spec:0x74 0xe04d XK_Right
75 ArrowLeft KEY_LEFT 74 0x50 spec:0x6b 0xe04b XK_Left
76 ArrowDown KEY_DOWN 75 0x51 spec:0x72 0xe050 XK_Down
77 ArrowUp KEY_UP 76 0x52 spec:0x75 0xe048 XK_Up
78 ControlLeft KEY_LEFTCTRL 77 ^0x01 reg:0x14 0x1d XK_Control_L
79 ShiftLeft KEY_LEFTSHIFT 78 ^0x02 reg:0x12 0x2a XK_Shift_L
80 AltLeft KEY_LEFTALT 79 ^0x04 reg:0x11 0x38 XK_Alt_L
81 MetaLeft KEY_LEFTMETA 80 ^0x08 spec:0x1f 0xe05b XK_Meta_L,XK_Super_L
82 ControlRight KEY_RIGHTCTRL 81 ^0x10 spec:0x14 0xe01d XK_Control_R
83 ShiftRight KEY_RIGHTSHIFT 82 ^0x20 reg:0x59 0x36 XK_Shift_R
84 AltRight KEY_RIGHTALT 83 ^0x40 spec:0x11 0xe038 XK_Alt_R,XK_ISO_Level3_Shift
85 MetaRight KEY_RIGHTMETA 84 ^0x80 spec:0x27 0xe05c XK_Meta_R,XK_Super_R
86 Pause KEY_PAUSE 85 0x48 pause:0xff 0xe046 XK_Pause
87 ScrollLock KEY_SCROLLLOCK 86 0x47 reg:0x7e 0x46 XK_Scroll_Lock
88 NumLock KEY_NUMLOCK 87 0x53 reg:0x77 0x45 XK_Num_Lock
89 ContextMenu KEY_CONTEXT_MENU 88 0x65 spec:0x2f 0xe05d XK_Menu
90 NumpadDivide KEY_KPSLASH 89 0x54 spec:0x4a 0xe035 XK_KP_Divide
91 NumpadMultiply KEY_KPASTERISK 90 0x55 reg:0x7c 0x37 XK_multiply
92 NumpadSubtract KEY_KPMINUS 91 0x56 reg:0x7b 0x4a XK_KP_Subtract
93 NumpadAdd KEY_KPPLUS 92 0x57 reg:0x79 0x4e XK_KP_Add
94 NumpadEnter KEY_KPENTER 93 0x58 spec:0x5a 0xe01c XK_KP_Enter
95 Numpad1 KEY_KP1 94 0x59 reg:0x69 0x4f XK_KP_1
96 Numpad2 KEY_KP2 95 0x5a reg:0x72 0x50 XK_KP_2
97 Numpad3 KEY_KP3 96 0x5b reg:0x7a 0x51 XK_KP_3
98 Numpad4 KEY_KP4 97 0x5c reg:0x6b 0x4b XK_KP_4
99 Numpad5 KEY_KP5 98 0x5d reg:0x73 0x4c XK_KP_5
100 Numpad6 KEY_KP6 99 0x5e reg:0x74 0x4d XK_KP_6
101 Numpad7 KEY_KP7 100 0x5f reg:0x6c 0x47 XK_KP_7
102 Numpad8 KEY_KP8 101 0x60 reg:0x75 0x48 XK_KP_8
103 Numpad9 KEY_KP9 102 0x61 reg:0x7d 0x49 XK_KP_9
104 Numpad0 KEY_KP0 103 0x62 reg:0x70 0x52 XK_KP_0
105 NumpadDecimal KEY_KPDOT 104 0x63 reg:0x71 0x53 XK_KP_Decimal
106 Power KEY_POWER 105 0x66 spec:0x5e 0xe05e XK_XF86_Sleep
107 IntlBackslash KEY_102ND 106 0x64 reg:0x61 0x56
108 IntlYen KEY_YEN 107 0x89 reg:0x6a 0x7d
109 IntlRo KEY_RO 108 0x87 reg:0x51 0x73
110 KanaMode KEY_KATAKANA 109 0x88 reg:0x13 0x70
111 Convert KEY_HENKAN 110 0x8a reg:0x64 0x79
112 NonConvert KEY_MUHENKAN 111 0x8b reg:0x67 0x7b
113 AudioVolumeMute KEY_MUTE 112 0x7f spec:0x23 0xe020
114 AudioVolumeUp KEY_VOLUMEUP 113 0x80 spec:0x32 0xe030
115 AudioVolumeDown KEY_VOLUMEDOWN 114 0x81 spec:0x21 0xe02e
116 F20 KEY_F20 115 0x6f 0x5a

View File

@ -112,6 +112,13 @@ EOF
cp /usr/share/kvmd/configs.default/janus/janus.plugin.ustreamer.jcfg /etc/kvmd/janus || true
fi
if [[ "$(vercmp "$2" 4.60)" -lt 0 ]]; then
if grep -q "^dtoverlay=vc4-kms-v3d" /boot/config.txt; then
sed -i -e "s/cma=128M/cma=192M/g" /boot/cmdline.txt || true
sed -i -e "s/^gpu_mem=128/gpu_mem=192/g" /boot/config.txt || true
fi
fi
# 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.49"
__version__ = "4.94"

View File

@ -23,6 +23,7 @@
import asyncio
import threading
import dataclasses
import typing
import gpiod
@ -101,10 +102,10 @@ class AioReader: # pylint: disable=too-many-instance-attributes
if line_req.wait_edge_events(1):
new: dict[int, bool] = {}
for event in line_req.read_edge_events():
(pin, value) = self.__parse_event(event)
new[pin] = value
for (pin, value) in new.items():
self.__values[pin].set(value)
(pin, state) = self.__parse_event(event)
new[pin] = state
for (pin, state) in new.items():
self.__values[pin].set(state)
else: # Timeout
# XXX: Лимит был актуален для 1.6. Надо проверить, поменялось ли это в 2.x.
# Размер буфера ядра - 16 эвентов на линии. При превышении этого числа,
@ -114,11 +115,12 @@ class AioReader: # pylint: disable=too-many-instance-attributes
self.__values[pin].set(bool(value.value)) # type: ignore
def __parse_event(self, event: gpiod.EdgeEvent) -> tuple[int, bool]:
if event.event_type == event.Type.RISING_EDGE:
return (event.line_offset, True)
elif event.event_type == event.Type.FALLING_EDGE:
return (event.line_offset, False)
raise RuntimeError(f"Invalid event {event} type: {event.type}")
match event.event_type:
case event.Type.RISING_EDGE:
return (event.line_offset, True)
case event.Type.FALLING_EDGE:
return (event.line_offset, False)
typing.assert_never(event.event_type)
class _DebouncedValue:

View File

@ -211,6 +211,18 @@ async def wait_first(*aws: asyncio.Task) -> tuple[set[asyncio.Task], set[asyncio
return (await asyncio.wait(list(aws), return_when=asyncio.FIRST_COMPLETED))
# =====
async def spawn_and_follow(*coros: Coroutine) -> None:
tasks: list[asyncio.Task] = list(map(asyncio.create_task, coros))
try:
await asyncio.gather(*tasks)
except Exception:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
# =====
async def close_writer(writer: asyncio.StreamWriter) -> bool:
closing = writer.is_closing()

View File

@ -65,6 +65,7 @@ from ..validators.basic import valid_string_list
from ..validators.auth import valid_user
from ..validators.auth import valid_users_list
from ..validators.auth import valid_expire
from ..validators.os import valid_abs_path
from ..validators.os import valid_abs_file
@ -73,6 +74,7 @@ from ..validators.os import valid_unix_mode
from ..validators.os import valid_options
from ..validators.os import valid_command
from ..validators.net import valid_ip
from ..validators.net import valid_ip_or_host
from ..validators.net import valid_net
from ..validators.net import valid_port
@ -190,6 +192,14 @@ def _init_config(config_path: str, override_options: list[str], **load_flags: bo
def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches
for (sub, cmd) in [("iface", "ip_cmd"), ("firewall", "iptables_cmd")]:
if isinstance(raw_config.get("otgnet"), dict):
if isinstance(raw_config["otgnet"].get(sub), dict):
if raw_config["otgnet"][sub].get(cmd):
raw_config["otgnet"].setdefault("commands", {})
raw_config["otgnet"]["commands"][cmd] = raw_config["otgnet"][sub][cmd]
del raw_config["otgnet"][sub][cmd]
if isinstance(raw_config.get("otg"), dict):
for (old, new) in [
("msd", "msd"),
@ -357,6 +367,12 @@ def _get_config_scheme() -> dict:
"auth": {
"enabled": Option(True, type=valid_bool),
"expire": Option(0, type=valid_expire),
"usc": {
"users": Option([], type=valid_users_list), # PiKVM username has a same regex as a UNIX username
"groups": Option(["kvmd-selfauth"], type=valid_users_list), # groupname has a same regex as a username
},
"internal": {
"type": Option("htpasswd"),
@ -457,7 +473,7 @@ def _get_config_scheme() -> dict:
"unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(2.0, type=valid_float_f01),
"snapshot_timeout": Option(1.0, type=valid_float_f01), # error_delay * 3 + 1
"snapshot_timeout": Option(5.0, type=valid_float_f01), # error_delay * 3 + 1
"process_name_prefix": Option("kvmd/streamer"),
@ -504,8 +520,9 @@ def _get_config_scheme() -> dict:
},
"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"),
"device": Option("/dev/kvmd-switch", type=valid_abs_path, unpack_as="device_path"),
"default_edid": Option("/etc/kvmd/switch-edid.hex", type=valid_abs_path, unpack_as="default_edid_path"),
"ignore_hpd_on_top": Option(False, type=valid_bool),
},
},
@ -558,15 +575,15 @@ def _get_config_scheme() -> dict:
"vendor_id": Option(0x1D6B, type=valid_otg_id), # Linux Foundation
"product_id": Option(0x0104, type=valid_otg_id), # Multifunction Composite Gadget
"manufacturer": Option("PiKVM", type=valid_stripped_string),
"product": Option("Composite KVM Device", type=valid_stripped_string),
"product": Option("PiKVM Composite Device", type=valid_stripped_string),
"serial": Option("CAFEBABE", type=valid_stripped_string, if_none=None),
"config": Option("", type=valid_stripped_string),
"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(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),
@ -657,8 +674,7 @@ def _get_config_scheme() -> dict:
"otgnet": {
"iface": {
"net": Option("172.30.30.0/24", type=functools.partial(valid_net, v6=False)),
"ip_cmd": Option(["/usr/bin/ip"], type=valid_command),
"net": Option("172.30.30.0/24", type=functools.partial(valid_net, v6=False)),
},
"firewall": {
@ -666,10 +682,13 @@ def _get_config_scheme() -> dict:
"allow_tcp": Option([], type=valid_ports_list),
"allow_udp": Option([67], type=valid_ports_list),
"forward_iface": Option("", type=valid_stripped_string),
"iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command),
},
"commands": {
"ip_cmd": Option(["/usr/bin/ip"], type=valid_command),
"iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command),
"sysctl_cmd": Option(["/usr/sbin/sysctl"], type=valid_command),
"pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command),
"pre_start_cmd_remove": Option([], type=valid_options),
"pre_start_cmd_append": Option([], type=valid_options),
@ -734,7 +753,7 @@ def _get_config_scheme() -> dict:
"desired_fps": Option(30, type=valid_stream_fps),
"mouse_output": Option("usb", type=valid_hid_mouse_output),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"allow_cut_after": Option(3.0, type=valid_float_f0),
"scroll_rate": Option(4, type=functools.partial(valid_number, min=1, max=30)),
"server": {
"host": Option("", type=valid_ip_or_host, if_empty=""),
@ -786,8 +805,8 @@ def _get_config_scheme() -> dict:
"auth": {
"vncauth": {
"enabled": Option(False, type=valid_bool),
"file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="path"),
"enabled": Option(False, type=valid_bool, unpack_as="vncpass_enabled"),
"file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="vncpass_path"),
},
"vencrypt": {
"enabled": Option(True, type=valid_bool, unpack_as="vencrypt_enabled"),
@ -795,13 +814,24 @@ def _get_config_scheme() -> dict:
},
},
"localhid": {
"kvmd": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
},
"nginx": {
"http": {
"port": Option(80, type=valid_port),
"ipv4": Option("0.0.0.0", type=functools.partial(valid_ip, v6=False)),
"ipv6": Option("::", type=functools.partial(valid_ip, v4=False)),
"port": Option(80, type=valid_port),
},
"https": {
"enabled": Option(True, type=valid_bool),
"port": Option(443, type=valid_port),
"enabled": Option(True, type=valid_bool),
"ipv4": Option("0.0.0.0", type=functools.partial(valid_ip, v6=False)),
"ipv6": Option("::", type=functools.partial(valid_ip, v4=False)),
"port": Option(443, type=valid_port),
},
},

View File

@ -61,6 +61,33 @@ def _print_edid(edid: Edid) -> None:
pass
def _find_out2_edid_path() -> str:
card = os.path.basename(os.readlink("/dev/dri/by-path/platform-gpu-card"))
path = f"/sys/devices/platform/gpu/drm/{card}/{card}-HDMI-A-2"
with open(os.path.join(path, "status")) as file:
if file.read().startswith("d"):
raise SystemExit("No display found")
return os.path.join(path, "edid")
def _adopt_out2_ids(dest: Edid) -> None:
src = Edid.from_file(_find_out2_edid_path())
dest.set_monitor_name(src.get_monitor_name())
try:
dest.get_monitor_serial()
except EdidNoBlockError:
pass
else:
try:
ser = src.get_monitor_serial()
except EdidNoBlockError:
ser = "{:08X}".format(src.get_serial())
dest.set_monitor_serial(ser)
dest.set_mfc_id(src.get_mfc_id())
dest.set_product_id(src.get_product_id())
dest.set_serial(src.get_serial())
# =====
def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-branches,too-many-statements
# (parent_parser, argv, _) = init(
@ -89,6 +116,10 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
help="Import the specified bin/hex EDID to the [--edid] file as a hex text", metavar="<file>")
parser.add_argument("--import-preset", choices=presets,
help="Restore default EDID or choose the preset", metavar=f"{{ {' | '.join(presets)} }}",)
parser.add_argument("--import-display-ids", action="store_true",
help="On PiKVM V4, import and adopt IDs from a physical display connected to the OUT2 port")
parser.add_argument("--import-display", action="store_true",
help="On PiKVM V4, import full EDID from a physical display connected to the OUT2 port")
parser.add_argument("--set-audio", type=valid_bool,
help="Enable or disable audio", metavar="<yes|no>")
parser.add_argument("--set-mfc-id",
@ -120,6 +151,9 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
imp = f"_{imp}"
options.imp = os.path.join(options.presets_path, f"{imp}.hex")
if options.import_display:
options.imp = _find_out2_edid_path()
orig_edid_path = options.edid_path
if options.imp:
options.export_hex = options.edid_path
@ -128,6 +162,10 @@ def main(argv: (list[str] | None)=None) -> None: # pylint: disable=too-many-bra
edid = Edid.from_file(options.edid_path)
changed = False
if options.import_display_ids:
_adopt_out2_ids(edid)
changed = True
for cmd in dir(Edid):
if cmd.startswith("set_"):
value = getattr(options, cmd)

View File

@ -30,27 +30,27 @@ import argparse
from typing import Generator
import passlib.apache
from ...yamlconf import Section
from ...validators import ValidatorError
from ...validators.auth import valid_user
from ...validators.auth import valid_passwd
from ...crypto import KvmdHtpasswdFile
from .. import init
# =====
def _get_htpasswd_path(config: Section) -> str:
if config.kvmd.auth.internal.type != "htpasswd":
raise SystemExit(f"Error: KVMD internal auth not using 'htpasswd'"
raise SystemExit(f"Error: KVMD internal auth does not use 'htpasswd'"
f" (now configured {config.kvmd.auth.internal.type!r})")
return config.kvmd.auth.internal.file
@contextlib.contextmanager
def _get_htpasswd_for_write(config: Section) -> Generator[passlib.apache.HtpasswdFile, None, None]:
def _get_htpasswd_for_write(config: Section) -> Generator[KvmdHtpasswdFile, None, None]:
path = _get_htpasswd_path(config)
(tmp_fd, tmp_path) = tempfile.mkstemp(
prefix=f".{os.path.basename(path)}.",
@ -65,7 +65,7 @@ def _get_htpasswd_for_write(config: Section) -> Generator[passlib.apache.Htpassw
os.fchmod(tmp_fd, st.st_mode)
finally:
os.close(tmp_fd)
htpasswd = passlib.apache.HtpasswdFile(tmp_path)
htpasswd = KvmdHtpasswdFile(tmp_path)
yield htpasswd
htpasswd.save()
os.rename(tmp_path, path)
@ -96,28 +96,55 @@ def _print_invalidate_tip(prepend_nl: bool) -> None:
# ====
def _cmd_list(config: Section, _: argparse.Namespace) -> None:
for user in sorted(passlib.apache.HtpasswdFile(_get_htpasswd_path(config)).users()):
for user in sorted(KvmdHtpasswdFile(_get_htpasswd_path(config)).users()):
print(user)
def _cmd_set(config: Section, options: argparse.Namespace) -> None:
def _change_user(config: Section, options: argparse.Namespace, create: bool) -> None:
with _get_htpasswd_for_write(config) as htpasswd:
assert options.user == options.user.strip()
assert options.user
has_user = (options.user in htpasswd.users())
if create:
if has_user:
raise SystemExit(f"The user {options.user!r} is already exists")
else:
if not has_user:
raise SystemExit(f"The user {options.user!r} is not exist")
if options.read_stdin:
passwd = valid_passwd(input())
else:
passwd = valid_passwd(getpass.getpass("Password: ", stream=sys.stderr))
if valid_passwd(getpass.getpass("Repeat: ", stream=sys.stderr)) != passwd:
raise SystemExit("Sorry, passwords do not match")
htpasswd.set_password(options.user, passwd)
if has_user and not options.quiet:
_print_invalidate_tip(True)
def _cmd_add(config: Section, options: argparse.Namespace) -> None:
_change_user(config, options, create=True)
def _cmd_set(config: Section, options: argparse.Namespace) -> None:
_change_user(config, options, create=False)
def _cmd_delete(config: Section, options: argparse.Namespace) -> None:
with _get_htpasswd_for_write(config) as htpasswd:
assert options.user == options.user.strip()
assert options.user
has_user = (options.user in htpasswd.users())
if not has_user:
raise SystemExit(f"The user {options.user!r} is not exist")
htpasswd.delete(options.user)
if has_user and not options.quiet:
_print_invalidate_tip(False)
@ -138,19 +165,25 @@ def main(argv: (list[str] | None)=None) -> None:
parser.set_defaults(cmd=(lambda *_: parser.print_help()))
subparsers = parser.add_subparsers()
cmd_list_parser = subparsers.add_parser("list", help="List users")
cmd_list_parser.set_defaults(cmd=_cmd_list)
sub = subparsers.add_parser("list", help="List users")
sub.set_defaults(cmd=_cmd_list)
cmd_set_parser = subparsers.add_parser("set", help="Create user or change password")
cmd_set_parser.add_argument("user", type=valid_user)
cmd_set_parser.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
cmd_set_parser.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
cmd_set_parser.set_defaults(cmd=_cmd_set)
sub = subparsers.add_parser("add", help="Add user")
sub.add_argument("user", type=valid_user)
sub.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
sub.set_defaults(cmd=_cmd_add)
cmd_delete_parser = subparsers.add_parser("del", help="Delete user")
cmd_delete_parser.add_argument("user", type=valid_user)
cmd_delete_parser.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
cmd_delete_parser.set_defaults(cmd=_cmd_delete)
sub = subparsers.add_parser("set", help="Change user's password")
sub.add_argument("user", type=valid_user)
sub.add_argument("-i", "--read-stdin", action="store_true", help="Read password from stdin")
sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
sub.set_defaults(cmd=_cmd_set)
sub = subparsers.add_parser("del", help="Delete user")
sub.add_argument("user", type=valid_user)
sub.add_argument("-q", "--quiet", action="store_true", help="Don't show invalidation note")
sub.set_defaults(cmd=_cmd_delete)
options = parser.parse_args(argv[1:])
try:

View File

@ -20,7 +20,13 @@
# ========================================================================== #
import dataclasses
import threading
import functools
import time
from ...logging import get_logger
from ... import tools
# =====
@ -29,60 +35,42 @@ class IpmiPasswdError(Exception):
super().__init__(f"Syntax error at {path}:{lineno}: {msg}")
@dataclasses.dataclass(frozen=True)
class IpmiUserCredentials:
ipmi_user: str
ipmi_passwd: str
kvmd_user: str
kvmd_passwd: str
class IpmiAuthManager:
def __init__(self, path: str) -> None:
self.__path = path
with open(path) as file:
self.__credentials = self.__parse_passwd_file(file.read().split("\n"))
self.__lock = threading.Lock()
def __contains__(self, ipmi_user: str) -> bool:
return (ipmi_user in self.__credentials)
def get(self, user: str) -> (str | None):
creds = self.__get_credentials(int(time.time()))
return creds.get(user)
def __getitem__(self, ipmi_user: str) -> str:
return self.__credentials[ipmi_user].ipmi_passwd
@functools.lru_cache(maxsize=1)
def __get_credentials(self, ts: int) -> dict[str, str]:
_ = ts
with self.__lock:
try:
return self.__read_credentials()
except Exception as ex:
get_logger().error("%s", tools.efmt(ex))
return {}
def get_credentials(self, ipmi_user: str) -> IpmiUserCredentials:
return self.__credentials[ipmi_user]
def __read_credentials(self) -> dict[str, str]:
with open(self.__path) as file:
creds: dict[str, str] = {}
for (lineno, line) in tools.passwds_splitted(file.read()):
if " -> " in line: # Compatibility with old ipmipasswd file format
line = line.split(" -> ", 1)[0]
def __parse_passwd_file(self, lines: list[str]) -> dict[str, IpmiUserCredentials]:
credentials: dict[str, IpmiUserCredentials] = {}
for (lineno, line) in enumerate(lines):
if len(line.strip()) == 0 or line.lstrip().startswith("#"):
continue
if ":" not in line:
raise IpmiPasswdError(self.__path, lineno, "Missing ':' operator")
if " -> " not in line:
raise IpmiPasswdError(self.__path, lineno, "Missing ' -> ' operator")
(user, passwd) = line.split(":", 1)
user = user.strip()
if len(user) == 0:
raise IpmiPasswdError(self.__path, lineno, "Empty IPMI user")
(left, right) = map(str.lstrip, line.split(" -> ", 1))
for (name, pair) in [("left", left), ("right", right)]:
if ":" not in pair:
raise IpmiPasswdError(self.__path, lineno, f"Missing ':' operator in {name} credentials")
if user in creds:
raise IpmiPasswdError(self.__path, lineno, f"Found duplicating user {user!r}")
(ipmi_user, ipmi_passwd) = left.split(":")
ipmi_user = ipmi_user.strip()
if len(ipmi_user) == 0:
raise IpmiPasswdError(self.__path, lineno, "Empty IPMI user (left)")
(kvmd_user, kvmd_passwd) = right.split(":")
kvmd_user = kvmd_user.strip()
if len(kvmd_user) == 0:
raise IpmiPasswdError(self.__path, lineno, "Empty KVMD user (left)")
if ipmi_user in credentials:
raise IpmiPasswdError(self.__path, lineno, f"Found duplicating user {ipmi_user!r} (left)")
credentials[ipmi_user] = IpmiUserCredentials(
ipmi_user=ipmi_user,
ipmi_passwd=ipmi_passwd,
kvmd_user=kvmd_user,
kvmd_passwd=kvmd_passwd,
)
return credentials
creds[user] = passwd
return creds

View File

@ -70,7 +70,6 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
super().__init__(authdata=auth_manager, address=host, port=port)
self.__auth_manager = auth_manager
self.__kvmd = kvmd
self.__host = host
@ -165,11 +164,10 @@ class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attribute
def __make_request(self, session: IpmiServerSession, name: str, func_path: str, **kwargs): # type: ignore
async def runner(): # type: ignore
logger = get_logger(0)
credentials = self.__auth_manager.get_credentials(session.username.decode())
logger.info("[%s]: Performing request %s from user %r (IPMI) as %r (KVMD)",
session.sockaddr[0], name, credentials.ipmi_user, credentials.kvmd_user)
logger.info("[%s]: Performing request %s from IPMI user %r ...",
session.sockaddr[0], name, session.username.decode())
try:
async with self.__kvmd.make_session(credentials.kvmd_user, credentials.kvmd_passwd) as kvmd_session:
async with self.__kvmd.make_session() as kvmd_session:
func = functools.reduce(getattr, func_path.split("."), kvmd_session)
return (await func(**kwargs))
except (aiohttp.ClientError, asyncio.TimeoutError) as ex:

View File

@ -21,6 +21,7 @@ class _Netcfg:
nat_type: StunNatType = dataclasses.field(default=StunNatType.ERROR)
src_ip: str = dataclasses.field(default="")
ext_ip: str = dataclasses.field(default="")
stun_host: str = dataclasses.field(default="")
stun_ip: str = dataclasses.field(default="")
stun_port: int = dataclasses.field(default=0)
@ -172,7 +173,10 @@ class JanusRunner: # pylint: disable=too-many-instance-attributes
part.format(**placeholders)
for part in cmd
]
self.__janus_proc = await aioproc.run_process(cmd)
self.__janus_proc = await aioproc.run_process(
cmd=cmd,
env={"JANUS_USTREAMER_WEB_ICE_URL": f"stun:{netcfg.stun_host}:{netcfg.stun_port}"},
)
get_logger(0).info("Started Janus pid=%d: %s", self.__janus_proc.pid, tools.cmdfmt(cmd))
async def __kill_janus_proc(self) -> None:

View File

@ -30,6 +30,7 @@ class StunInfo:
nat_type: StunNatType
src_ip: str
ext_ip: str
stun_host: str
stun_ip: str
stun_port: int
@ -102,6 +103,7 @@ class Stun:
nat_type=nat_type,
src_ip=src_ip,
ext_ip=ext_ip,
stun_host=self.__host,
stun_ip=self.__stun_ip,
stun_port=self.__port,
)

View File

@ -76,14 +76,17 @@ def main(argv: (list[str] | None)=None) -> None:
KvmdServer(
auth_manager=AuthManager(
enabled=config.auth.enabled,
expire=config.auth.expire,
usc_users=config.auth.usc.users,
usc_groups=config.auth.usc.groups,
unauth_paths=([] if config.prometheus.auth.enabled else ["/export/prometheus/metrics"]),
internal_type=config.auth.internal.type,
internal_kwargs=config.auth.internal._unpack(ignore=["type", "force_users"]),
force_internal_users=config.auth.internal.force_users,
int_type=config.auth.internal.type,
int_kwargs=config.auth.internal._unpack(ignore=["type", "force_users"]),
force_int_users=config.auth.internal.force_users,
external_type=config.auth.external.type,
external_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}),
ext_type=config.auth.external.type,
ext_kwargs=(config.auth.external._unpack(ignore=["type"]) if config.auth.external.type else {}),
totp_secret_path=config.auth.totp.secret.file,
),

View File

@ -31,9 +31,11 @@ from ....htserver import HttpExposed
from ....htserver import exposed_http
from ....htserver import make_json_response
from ....htserver import set_request_auth_info
from ....htserver import get_request_unix_credentials
from ....validators.auth import valid_user
from ....validators.auth import valid_passwd
from ....validators.auth import valid_expire
from ....validators.auth import valid_auth_token
from ..auth import AuthManager
@ -43,39 +45,64 @@ from ..auth import AuthManager
_COOKIE_AUTH_TOKEN = "auth_token"
async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> None:
if auth_manager.is_auth_required(exposed):
user = req.headers.get("X-KVMD-User", "")
async def _check_xhdr(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
user = req.headers.get("X-KVMD-User", "")
if user:
user = valid_user(user)
passwd = req.headers.get("X-KVMD-Passwd", "")
set_request_auth_info(req, f"{user} (xhdr)")
if (await auth_manager.authorize(user, valid_passwd(passwd))):
return True
raise ForbiddenError()
return False
async def _check_token(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
token = req.cookies.get(_COOKIE_AUTH_TOKEN, "")
if token:
user = auth_manager.check(valid_auth_token(token))
if user:
user = valid_user(user)
passwd = req.headers.get("X-KVMD-Passwd", "")
set_request_auth_info(req, f"{user} (xhdr)")
if not (await auth_manager.authorize(user, valid_passwd(passwd))):
raise ForbiddenError()
return
token = req.cookies.get(_COOKIE_AUTH_TOKEN, "")
if token:
user = auth_manager.check(valid_auth_token(token)) # type: ignore
if not user:
set_request_auth_info(req, "- (token)")
raise ForbiddenError()
set_request_auth_info(req, f"{user} (token)")
return
return True
set_request_auth_info(req, "- (token)")
raise ForbiddenError()
return False
basic_auth = req.headers.get("Authorization", "")
if basic_auth and basic_auth[:6].lower() == "basic ":
try:
(user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":")
except Exception:
raise UnauthorizedError()
user = valid_user(user)
set_request_auth_info(req, f"{user} (basic)")
if not (await auth_manager.authorize(user, valid_passwd(passwd))):
raise ForbiddenError()
return
async def _check_basic(auth_manager: AuthManager, _: HttpExposed, req: Request) -> bool:
basic_auth = req.headers.get("Authorization", "")
if basic_auth and basic_auth[:6].lower() == "basic ":
try:
(user, passwd) = base64.b64decode(basic_auth[6:]).decode("utf-8").split(":")
except Exception:
raise UnauthorizedError()
user = valid_user(user)
set_request_auth_info(req, f"{user} (basic)")
if (await auth_manager.authorize(user, valid_passwd(passwd))):
return True
raise ForbiddenError()
return False
async def _check_usc(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> bool:
if exposed.allow_usc:
creds = get_request_unix_credentials(req)
if creds is not None:
user = auth_manager.check_unix_credentials(creds)
if user:
set_request_auth_info(req, f"{user}[{creds.uid}] (unix)")
return True
raise UnauthorizedError()
return False
async def check_request_auth(auth_manager: AuthManager, exposed: HttpExposed, req: Request) -> None:
if not auth_manager.is_auth_required(exposed):
return
for checker in [_check_xhdr, _check_token, _check_basic, _check_usc]:
if (await checker(auth_manager, exposed, req)):
return
raise UnauthorizedError()
class AuthApi:
@ -84,26 +111,28 @@ class AuthApi:
# =====
@exposed_http("POST", "/auth/login", auth_required=False)
@exposed_http("POST", "/auth/login", auth_required=False, allow_usc=False)
async def __login_handler(self, req: Request) -> Response:
if self.__auth_manager.is_auth_enabled():
credentials = await req.post()
token = await self.__auth_manager.login(
user=valid_user(credentials.get("user", "")),
passwd=valid_passwd(credentials.get("passwd", "")),
expire=valid_expire(credentials.get("expire", "0")),
)
if token:
return make_json_response(set_cookies={_COOKIE_AUTH_TOKEN: token})
raise ForbiddenError()
return make_json_response()
@exposed_http("POST", "/auth/logout")
@exposed_http("POST", "/auth/logout", allow_usc=False)
async def __logout_handler(self, req: Request) -> Response:
if self.__auth_manager.is_auth_enabled():
token = valid_auth_token(req.cookies.get(_COOKIE_AUTH_TOKEN, ""))
self.__auth_manager.logout(token)
return make_json_response()
@exposed_http("GET", "/auth/check")
# XXX: This handle is used for access control so it should NEVER allow access by socket credentials
@exposed_http("GET", "/auth/check", allow_usc=False)
async def __check_handler(self, _: Request) -> Response:
return make_json_response()

View File

@ -21,6 +21,7 @@
import asyncio
import re
from typing import Any
@ -57,7 +58,7 @@ class ExportApi:
async def __get_prometheus_metrics(self) -> str:
(atx_state, info_state, gpio_state) = await asyncio.gather(*[
self.__atx.get_state(),
self.__info_manager.get_state(["hw", "fan"]),
self.__info_manager.get_state(["health", "fan"]),
self.__user_gpio.get_state(),
])
rows: list[str] = []
@ -68,10 +69,11 @@ class ExportApi:
for mode in sorted(UserGpioModes.ALL):
for (channel, ch_state) in gpio_state["state"][f"{mode}s"].items(): # type: ignore
if not channel.startswith("__"): # Hide special GPIOs
channel = re.sub(r"[^\w]", "_", channel)
for key in ["online", "state"]:
self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}")
self.__append_prometheus_rows(rows, info_state["hw"]["health"], "pikvm_hw") # type: ignore
self.__append_prometheus_rows(rows, info_state["health"], "pikvm_hw") # type: ignore
self.__append_prometheus_rows(rows, info_state["fan"], "pikvm_fan")
return "\n".join(rows)

View File

@ -23,6 +23,7 @@
import os
import stat
import functools
import itertools
import struct
from typing import Iterable
@ -31,8 +32,11 @@ from typing import Callable
from aiohttp.web import Request
from aiohttp.web import Response
from ....keyboard.mappings import WEB_TO_EVDEV
from ....keyboard.keysym import build_symmap
from ....keyboard.printer import text_to_web_keys
from ....keyboard.printer import text_to_evdev_keys
from ....mouse import MOUSE_TO_EVDEV
from ....htserver import exposed_http
from ....htserver import exposed_ws
@ -43,7 +47,9 @@ from ....plugins.hid import BaseHid
from ....validators import raise_error
from ....validators.basic import valid_bool
from ....validators.basic import valid_number
from ....validators.basic import valid_int_f0
from ....validators.basic import valid_string_list
from ....validators.os import valid_printable_filename
from ....validators.hid import valid_hid_keyboard_output
from ....validators.hid import valid_hid_mouse_output
@ -97,6 +103,11 @@ class HidApi:
await self.__hid.reset()
return make_json_response()
@exposed_http("GET", "/hid/inactivity")
async def __inactivity_handler(self, _: Request) -> Response:
secs = self.__hid.get_inactivity_seconds()
return make_json_response({"inactivity": secs})
# =====
async def get_keymaps(self) -> dict: # Ugly hack to generate hid_keymaps_state (see server.py)
@ -119,15 +130,26 @@ class HidApi:
@exposed_http("POST", "/hid/print")
async def __print_handler(self, req: Request) -> Response:
text = await req.text()
limit = int(valid_int_f0(req.query.get("limit", 1024)))
limit = valid_int_f0(req.query.get("limit", 1024))
if limit > 0:
text = text[:limit]
symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_keymap_name))
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)
delay = float(valid_number(
arg=req.query.get("delay", (0.02 if slow else 0)),
min=0,
max=5,
type=float,
name="keys delay",
))
await self.__hid.send_key_events(
keys=text_to_evdev_keys(text, symmap),
no_ignore_keys=True,
delay=delay,
)
return make_json_response()
def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]:
def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, int]]:
keymap_name = valid_printable_filename(keymap_name, "keymap")
path = os.path.join(self.__keymaps_dir_path, keymap_name)
try:
@ -139,7 +161,7 @@ class HidApi:
return self.__inner_ensure_symmap(path, st.st_mtime)
@functools.lru_cache(maxsize=10)
def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, str]]:
def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, int]]:
_ = mod_ts # For LRU
return build_symmap(path)
@ -148,9 +170,12 @@ class HidApi:
@exposed_ws(1)
async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None:
try:
key = valid_hid_key(data[1:].decode("ascii"))
state = bool(data[0] & 0b01)
finish = bool(data[0] & 0b10)
if data[0] & 0b10000000:
key = struct.unpack(">H", data[1:])[0]
else:
key = WEB_TO_EVDEV[valid_hid_key(data[1:33].decode("ascii"))]
except Exception:
return
self.__hid.send_key_event(key, state, finish)
@ -158,7 +183,11 @@ class HidApi:
@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 = bool(data[0] & 0b01)
if data[0] & 0b10000000:
button = struct.unpack(">H", data[1:])[0]
else:
button = MOUSE_TO_EVDEV[valid_hid_mouse_button(data[1:33].decode("ascii"))]
state = bool(data[0] & 0b01)
except Exception:
return
@ -199,7 +228,7 @@ class HidApi:
@exposed_ws("key")
async def __ws_key_handler(self, _: WsSession, event: dict) -> None:
try:
key = valid_hid_key(event["key"])
key = WEB_TO_EVDEV[valid_hid_key(event["key"])]
state = valid_bool(event["state"])
finish = valid_bool(event.get("finish", False))
except Exception:
@ -209,7 +238,7 @@ class HidApi:
@exposed_ws("mouse_button")
async def __ws_mouse_button_handler(self, _: WsSession, event: dict) -> None:
try:
button = valid_hid_mouse_button(event["button"])
button = MOUSE_TO_EVDEV[valid_hid_mouse_button(event["button"])]
state = valid_bool(event["state"])
except Exception:
return
@ -246,9 +275,22 @@ class HidApi:
# =====
@exposed_http("POST", "/hid/events/send_shortcut")
async def __events_send_shortcut_handler(self, req: Request) -> Response:
shortcut = valid_string_list(req.query.get("keys"), subval=valid_hid_key)
if shortcut:
press = [WEB_TO_EVDEV[key] for key in shortcut]
release = list(reversed(press))
seq = [
*zip(press, itertools.repeat(True)),
*zip(release, itertools.repeat(False)),
]
await self.__hid.send_key_events(seq, no_ignore_keys=True, delay=0.05)
return make_json_response()
@exposed_http("POST", "/hid/events/send_key")
async def __events_send_key_handler(self, req: Request) -> Response:
key = valid_hid_key(req.query.get("key"))
key = WEB_TO_EVDEV[valid_hid_key(req.query.get("key"))]
if "state" in req.query:
state = valid_bool(req.query["state"])
finish = valid_bool(req.query.get("finish", False))
@ -259,7 +301,7 @@ class HidApi:
@exposed_http("POST", "/hid/events/send_mouse_button")
async def __events_send_mouse_button_handler(self, req: Request) -> Response:
button = valid_hid_mouse_button(req.query.get("button"))
button = MOUSE_TO_EVDEV[valid_hid_mouse_button(req.query.get("button"))]
if "state" in req.query:
state = valid_bool(req.query["state"])
self.__hid.send_mouse_button_event(button, state)

View File

@ -45,7 +45,10 @@ class InfoApi:
def __valid_info_fields(self, req: Request) -> list[str]:
available = self.__info_manager.get_subs()
available.add("hw")
default = set(available)
default.remove("health")
return sorted(valid_info_fields(
arg=req.query.get("fields", ",".join(available)),
variants=available,
arg=req.query.get("fields", ",".join(default)),
variants=(available),
) or available)

View File

@ -52,17 +52,15 @@ class LogApi:
raise LogReaderDisabledError()
seek = valid_log_seek(req.query.get("seek", 0))
follow = valid_bool(req.query.get("follow", False))
response = await start_streaming(req, "text/plain")
resp = await start_streaming(req, "text/plain")
try:
async for record in self.__log_reader.poll_log(seek, follow):
await response.write(("[%s %s] --- %s" % (
await resp.write(("[%s %s] --- %s" % (
record["dt"].strftime("%Y-%m-%d %H:%M:%S"),
record["service"],
record["msg"],
)).encode("utf-8") + b"\r\n")
except Exception as exception:
if record is None:
record = exception
await response.write(f"Module systemd.journal is unavailable.\n{record}".encode("utf-8"))
return response
return response
await resp.write(f"Module systemd.journal is unavailable.\n{exception}".encode("utf-8"))
return resp
return resp

View File

@ -133,10 +133,10 @@ class MsdApi:
src = compressed()
size = -1
response = await start_streaming(req, "application/octet-stream", size, name + suffix)
resp = await start_streaming(req, "application/octet-stream", size, name + suffix)
async for chunk in src:
await response.write(chunk)
return response
await resp.write(chunk)
return resp
# =====
@ -166,11 +166,11 @@ class MsdApi:
name = ""
size = written = 0
response: (StreamResponse | None) = None
resp: (StreamResponse | None) = None
async def stream_write_info() -> None:
assert response is not None
await stream_json(response, self.__make_write_info(name, size, written))
assert resp is not None
await stream_json(resp, self.__make_write_info(name, size, written))
try:
async with htclient.download(
@ -190,7 +190,7 @@ class MsdApi:
get_logger(0).info("Downloading image %r as %r to MSD ...", url, name)
async with self.__msd.write_image(name, size, remove_incomplete) as writer:
chunk_size = writer.get_chunk_size()
response = await start_streaming(req, "application/x-ndjson")
resp = await start_streaming(req, "application/x-ndjson")
await stream_write_info()
last_report_ts = 0
async for chunk in remote.content.iter_chunked(chunk_size):
@ -201,12 +201,12 @@ class MsdApi:
last_report_ts = now
await stream_write_info()
return response
return resp
except Exception as ex:
if response is not None:
if resp is not None:
await stream_write_info()
await stream_json_exception(response, ex)
await stream_json_exception(resp, ex)
elif isinstance(ex, aiohttp.ClientError):
return make_json_exception(ex, 400)
raise

View File

@ -102,14 +102,26 @@ class RedfishApi:
"Actions": {
"#ComputerSystem.Reset": {
"ResetType@Redfish.AllowableValues": list(self.__actions),
"target": "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset"
"target": "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset",
},
"#ComputerSystem.SetDefaultBootOrder": { # https://github.com/pikvm/pikvm/issues/1525
"target": "/redfish/v1/Systems/0/Actions/ComputerSystem.SetDefaultBootOrder",
},
},
"Id": "0",
"HostName": host,
"PowerState": ("On" if atx_state["leds"]["power"] else "Off"), # type: ignore
"Boot": {
"BootSourceOverrideEnabled": "Disabled",
"BootSourceOverrideTarget": None,
},
}, wrap_result=False)
@exposed_http("PATCH", "/redfish/v1/Systems/0")
async def __patch_handler(self, _: Request) -> Response:
# https://github.com/pikvm/pikvm/issues/1525
return Response(body=None, status=204)
@exposed_http("POST", "/redfish/v1/Systems/0/Actions/ComputerSystem.Reset")
async def __power_handler(self, req: Request) -> Response:
try:

View File

@ -28,6 +28,7 @@ from ....htserver import make_json_response
from ....validators.basic import valid_bool
from ....validators.basic import valid_int_f0
from ....validators.basic import valid_float_f0
from ....validators.basic import valid_stripped_string_not_empty
from ....validators.kvm import valid_atx_power_action
from ....validators.kvm import valid_atx_button
@ -52,9 +53,19 @@ class SwitchApi:
async def __state_handler(self, _: Request) -> Response:
return make_json_response(await self.__switch.get_state())
@exposed_http("POST", "/switch/set_active_prev")
async def __set_active_prev_handler(self, _: Request) -> Response:
await self.__switch.set_active_prev()
return make_json_response()
@exposed_http("POST", "/switch/set_active_next")
async def __set_active_next_handler(self, _: Request) -> Response:
await self.__switch.set_active_next()
return make_json_response()
@exposed_http("POST", "/switch/set_active")
async def __set_active_port_handler(self, req: Request) -> Response:
port = valid_int_f0(req.query.get("port"))
port = valid_float_f0(req.query.get("port"))
await self.__switch.set_active_port(port)
return make_json_response()
@ -62,7 +73,7 @@ class SwitchApi:
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"))
port = valid_float_f0(req.query.get("port"))
await self.__switch.set_port_beacon(port, on)
elif "uplink" in req.query:
unit = valid_int_f0(req.query.get("uplink"))
@ -74,11 +85,12 @@ class SwitchApi:
@exposed_http("POST", "/switch/set_port_params")
async def __set_port_params(self, req: Request) -> Response:
port = valid_int_f0(req.query.get("port"))
port = valid_float_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))),
("dummy", valid_bool),
("name", valid_switch_port_name),
("atx_click_power_delay", valid_switch_atx_click_delay),
("atx_click_power_long_delay", valid_switch_atx_click_delay),
@ -142,7 +154,7 @@ class SwitchApi:
@exposed_http("POST", "/switch/atx/power")
async def __power_handler(self, req: Request) -> Response:
port = valid_int_f0(req.query.get("port"))
port = valid_float_f0(req.query.get("port"))
action = valid_atx_power_action(req.query.get("action"))
await ({
"on": self.__switch.atx_power_on,
@ -154,7 +166,7 @@ class SwitchApi:
@exposed_http("POST", "/switch/atx/click")
async def __click_handler(self, req: Request) -> Response:
port = valid_int_f0(req.query.get("port"))
port = valid_float_f0(req.query.get("port"))
button = valid_atx_button(req.query.get("button"))
await ({
"power": self.__switch.atx_click_power,

View File

@ -20,6 +20,12 @@
# ========================================================================== #
import pwd
import grp
import dataclasses
import time
import datetime
import secrets
import pyotp
@ -31,48 +37,79 @@ from ...plugins.auth import BaseAuthService
from ...plugins.auth import get_auth_service_class
from ...htserver import HttpExposed
from ...htserver import RequestUnixCredentials
# =====
class AuthManager:
@dataclasses.dataclass(frozen=True)
class _Session:
user: str
expire_ts: int
def __post_init__(self) -> None:
assert self.user == self.user.strip()
assert self.user
assert self.expire_ts >= 0
class AuthManager: # pylint: disable=too-many-arguments,too-many-instance-attributes
def __init__(
self,
enabled: bool,
expire: int,
usc_users: list[str],
usc_groups: list[str],
unauth_paths: list[str],
internal_type: str,
internal_kwargs: dict,
force_internal_users: list[str],
int_type: str,
int_kwargs: dict,
force_int_users: list[str],
external_type: str,
external_kwargs: dict,
ext_type: str,
ext_kwargs: dict,
totp_secret_path: str,
) -> None:
logger = get_logger(0)
self.__enabled = enabled
if not enabled:
get_logger().warning("AUTHORIZATION IS DISABLED")
logger.warning("AUTHORIZATION IS DISABLED")
assert expire >= 0
self.__expire = expire
if expire > 0:
logger.info("Maximum user session time is limited: %s",
self.__format_seconds(expire))
self.__usc_uids = self.__load_usc_uids(usc_users, usc_groups)
if self.__usc_uids:
logger.info("Selfauth UNIX socket access is allowed for users: %s",
list(self.__usc_uids.values()))
self.__unauth_paths = frozenset(unauth_paths) # To speed up
for path in self.__unauth_paths:
get_logger().warning("Authorization is disabled for API %r", path)
if self.__unauth_paths:
logger.info("Authorization is disabled for APIs: %s",
list(self.__unauth_paths))
self.__internal_service: (BaseAuthService | None) = None
self.__int_service: (BaseAuthService | None) = None
if enabled:
self.__internal_service = get_auth_service_class(internal_type)(**internal_kwargs)
get_logger().info("Using internal auth service %r", self.__internal_service.get_plugin_name())
self.__int_service = get_auth_service_class(int_type)(**int_kwargs)
logger.info("Using internal auth service %r",
self.__int_service.get_plugin_name())
self.__force_internal_users = force_internal_users
self.__force_int_users = force_int_users
self.__external_service: (BaseAuthService | None) = None
if enabled and external_type:
self.__external_service = get_auth_service_class(external_type)(**external_kwargs)
get_logger().info("Using external auth service %r", self.__external_service.get_plugin_name())
self.__ext_service: (BaseAuthService | None) = None
if enabled and ext_type:
self.__ext_service = get_auth_service_class(ext_type)(**ext_kwargs)
logger.info("Using external auth service %r",
self.__ext_service.get_plugin_name())
self.__totp_secret_path = totp_secret_path
self.__tokens: dict[str, str] = {} # {token: user}
self.__sessions: dict[str, _Session] = {} # {token: session}
def is_auth_enabled(self) -> bool:
return self.__enabled
@ -88,7 +125,8 @@ class AuthManager:
assert user == user.strip()
assert user
assert self.__enabled
assert self.__internal_service
assert self.__int_service
logger = get_logger(0)
if self.__totp_secret_path:
with open(self.__totp_secret_path) as file:
@ -96,60 +134,150 @@ class AuthManager:
if secret:
code = passwd[-6:]
if not pyotp.TOTP(secret).verify(code, valid_window=1):
get_logger().error("Got access denied for user %r by TOTP", user)
logger.error("Got access denied for user %r by TOTP", user)
return False
passwd = passwd[:-6]
if user not in self.__force_internal_users and self.__external_service:
service = self.__external_service
if user not in self.__force_int_users and self.__ext_service:
service = self.__ext_service
else:
service = self.__internal_service
service = self.__int_service
pname = service.get_plugin_name()
ok = (await service.authorize(user, passwd))
if ok:
get_logger().info("Authorized user %r via auth service %r", user, service.get_plugin_name())
logger.info("Authorized user %r via auth service %r", user, pname)
else:
get_logger().error("Got access denied for user %r from auth service %r", user, service.get_plugin_name())
logger.error("Got access denied for user %r from auth service %r", user, pname)
return ok
async def login(self, user: str, passwd: str) -> (str | None):
async def login(self, user: str, passwd: str, expire: int) -> (str | None):
assert user == user.strip()
assert user
assert expire >= 0
assert self.__enabled
if (await self.authorize(user, passwd)):
token = self.__make_new_token()
self.__tokens[token] = user
get_logger().info("Logged in user %r", user)
session = _Session(
user=user,
expire_ts=self.__make_expire_ts(expire),
)
self.__sessions[token] = session
get_logger(0).info("Logged in user %r; expire=%s, sessions_now=%d",
session.user,
self.__format_expire_ts(session.expire_ts),
self.__get_sessions_number(session.user))
return token
else:
return None
return None
def __make_new_token(self) -> str:
for _ in range(10):
token = secrets.token_hex(32)
if token not in self.__tokens:
if token not in self.__sessions:
return token
raise AssertionError("Can't generate new unique token")
raise RuntimeError("Can't generate new unique token")
def __make_expire_ts(self, expire: int) -> int:
assert expire >= 0
assert self.__expire >= 0
if expire == 0:
# The user requested infinite session: apply global expire.
# It will allow this (0) or set a limit.
expire = self.__expire
else:
# The user wants a limited session
if self.__expire > 0:
# If we have a global limit, override the user limit
assert expire > 0
expire = min(expire, self.__expire)
if expire > 0:
return (self.__get_now_ts() + expire)
assert expire == 0
return 0
def __get_now_ts(self) -> int:
return int(time.monotonic())
def __format_expire_ts(self, expire_ts: int) -> str:
if expire_ts > 0:
seconds = expire_ts - self.__get_now_ts()
return f"[{self.__format_seconds(seconds)}]"
return "INF"
def __format_seconds(self, seconds: int) -> str:
return str(datetime.timedelta(seconds=seconds))
def __get_sessions_number(self, user: str) -> int:
return sum(
1
for session in self.__sessions.values()
if session.user == user
)
def logout(self, token: str) -> None:
assert self.__enabled
if token in self.__tokens:
user = self.__tokens[token]
if token in self.__sessions:
user = self.__sessions[token].user
count = 0
for (r_token, r_user) in list(self.__tokens.items()):
if r_user == user:
for (key_t, session) in list(self.__sessions.items()):
if session.user == user:
count += 1
del self.__tokens[r_token]
get_logger().info("Logged out user %r (%d)", user, count)
del self.__sessions[key_t]
get_logger(0).info("Logged out user %r; sessions_closed=%d", user, count)
def check(self, token: str) -> (str | None):
assert self.__enabled
return self.__tokens.get(token)
session = self.__sessions.get(token)
if session is not None:
if session.expire_ts <= 0:
# Infinite session
return session.user
else:
# Limited session
if self.__get_now_ts() < session.expire_ts:
return session.user
else:
del self.__sessions[token]
get_logger(0).info("The session of user %r is expired; sessions_left=%d",
session.user,
self.__get_sessions_number(session.user))
return None
@aiotools.atomic_fg
async def cleanup(self) -> None:
if self.__enabled:
assert self.__internal_service
await self.__internal_service.cleanup()
if self.__external_service:
await self.__external_service.cleanup()
assert self.__int_service
await self.__int_service.cleanup()
if self.__ext_service:
await self.__ext_service.cleanup()
# =====
def __load_usc_uids(self, users: list[str], groups: list[str]) -> dict[int, str]:
uids: dict[int, str] = {}
pwds: dict[str, int] = {}
for pw in pwd.getpwall():
assert pw.pw_name == pw.pw_name.strip()
assert pw.pw_name
pwds[pw.pw_name] = pw.pw_uid
if pw.pw_name in users:
uids[pw.pw_uid] = pw.pw_name
for gr in grp.getgrall():
if gr.gr_name in groups:
for member in gr.gr_mem:
if member in pwds:
uid = pwds[member]
uids[uid] = member
return uids
def check_unix_credentials(self, creds: RequestUnixCredentials) -> (str | None):
assert self.__enabled
return self.__usc_uids.get(creds.uid)

View File

@ -31,7 +31,7 @@ from .auth import AuthInfoSubmanager
from .system import SystemInfoSubmanager
from .meta import MetaInfoSubmanager
from .extras import ExtrasInfoSubmanager
from .hw import HwInfoSubmanager
from .health import HealthInfoSubmanager
from .fan import FanInfoSubmanager
@ -39,11 +39,11 @@ from .fan import FanInfoSubmanager
class InfoManager:
def __init__(self, config: Section) -> None:
self.__subs: dict[str, BaseInfoSubmanager] = {
"system": SystemInfoSubmanager(config.kvmd.streamer.cmd),
"system": SystemInfoSubmanager(config.kvmd.info.hw.platform, config.kvmd.streamer.cmd),
"auth": AuthInfoSubmanager(config.kvmd.auth.enabled),
"meta": MetaInfoSubmanager(config.kvmd.info.meta),
"extras": ExtrasInfoSubmanager(config),
"hw": HwInfoSubmanager(**config.kvmd.info.hw._unpack()),
"health": HealthInfoSubmanager(**config.kvmd.info.hw._unpack(ignore="platform")),
"fan": FanInfoSubmanager(**config.kvmd.info.fan._unpack()),
}
self.__queue: "asyncio.Queue[tuple[str, (dict | None)]]" = asyncio.Queue()
@ -52,12 +52,29 @@ class InfoManager:
return set(self.__subs)
async def get_state(self, fields: (list[str] | None)=None) -> dict:
fields = (fields or list(self.__subs))
return dict(zip(fields, await asyncio.gather(*[
fields_set = set(fields or list(self.__subs))
hw = ("hw" in fields_set) # Old for compatible
system = ("system" in fields_set)
if hw:
fields_set.remove("hw")
fields_set.add("health")
fields_set.add("system")
state = dict(zip(fields_set, await asyncio.gather(*[
self.__subs[field].get_state()
for field in fields
for field in fields_set
])))
if hw:
state["hw"] = {
"health": state.pop("health"),
"platform": (state["system"] or {}).pop("platform"), # {} makes mypy happy
}
if not system:
state.pop("system")
return state
async def trigger_state(self) -> None:
await asyncio.gather(*[
sub.trigger_state()
@ -70,7 +87,7 @@ class InfoManager:
# - auth -- Partial
# - meta -- Partial, nullable
# - extras -- Partial, nullable
# - hw -- Partial
# - health -- Partial
# - fan -- Partial
# ===========================

View File

@ -99,9 +99,9 @@ class FanInfoSubmanager(BaseInfoSubmanager):
async def __get_fan_state(self) -> (dict | None):
try:
async with self.__make_http_session() as session:
async with session.get("http://localhost/state") as response:
htclient.raise_not_200(response)
return (await response.json())["result"]
async with session.get("http://localhost/state") as resp:
htclient.raise_not_200(resp)
return (await resp.json())["result"]
except Exception as ex:
get_logger(0).error("Can't read fan state: %s", ex)
return None

View File

@ -20,7 +20,6 @@
# ========================================================================== #
import os
import asyncio
import copy
@ -45,59 +44,41 @@ _RetvalT = TypeVar("_RetvalT")
# =====
class HwInfoSubmanager(BaseInfoSubmanager):
class HealthInfoSubmanager(BaseInfoSubmanager):
def __init__(
self,
platform_path: str,
vcgencmd_cmd: list[str],
ignore_past: bool,
state_poll: float,
) -> None:
self.__platform_path = platform_path
self.__vcgencmd_cmd = vcgencmd_cmd
self.__ignore_past = ignore_past
self.__state_poll = state_poll
self.__dt_cache: dict[str, str] = {}
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
(
base,
serial,
platform,
throttling,
cpu_percent,
cpu_temp,
mem,
) = await asyncio.gather(
self.__read_dt_file("model", _upper=False),
self.__read_dt_file("serial-number", _upper=True),
self.__read_platform_file(),
self.__get_throttling(),
self.__get_cpu_percent(),
self.__get_cpu_temp(),
self.__get_mem(),
)
return {
"platform": {
"type": "rpi",
"base": base,
"serial": serial,
**platform, # type: ignore
"temp": {
"cpu": cpu_temp,
},
"health": {
"temp": {
"cpu": cpu_temp,
},
"cpu": {
"percent": cpu_percent,
},
"mem": mem,
"throttling": throttling,
"cpu": {
"percent": cpu_percent,
},
"mem": mem,
"throttling": throttling,
}
async def trigger_state(self) -> None:
@ -115,36 +96,6 @@ class HwInfoSubmanager(BaseInfoSubmanager):
# =====
async def __read_dt_file(self, name: str, _upper: bool) -> (str | None):
if name not in self.__dt_cache:
path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name)
if not os.path.exists(path):
path = os.path.join(f"{env.PROCFS_PREFIX}/etc/kvmd/hw_info/", name)
try:
self.__dt_cache[name] = (await aiotools.read_file(path)).strip(" \t\r\n\0")
except Exception:
# get_logger(0).warn("Can't read DT %s from %s: %s", name, path, err)
return None
return self.__dt_cache[name]
async def __read_platform_file(self) -> dict:
try:
text = await aiotools.read_file(self.__platform_path)
parsed: dict[str, str] = {}
for row in text.split("\n"):
row = row.strip()
if row:
(key, value) = row.split("=", 1)
parsed[key.strip()] = value.strip()
return {
"model": parsed["PIKVM_MODEL"],
"video": parsed["PIKVM_VIDEO"],
"board": parsed["PIKVM_BOARD"],
}
except Exception:
get_logger(0).exception("Can't read device model")
return {"model": None, "video": None, "board": None}
async def __get_cpu_temp(self) -> (float | None):
temp_path = f"{env.SYSFS_PREFIX}/sys/class/thermal/thermal_zone0/temp"
try:

View File

@ -20,6 +20,8 @@
# ========================================================================== #
import socket
from typing import AsyncGenerator
from ....logging import get_logger
@ -39,7 +41,10 @@ class MetaInfoSubmanager(BaseInfoSubmanager):
async def get_state(self) -> (dict | None):
try:
return ((await aiotools.run_async(load_yaml_file, self.__meta_path)) or {})
meta = ((await aiotools.run_async(load_yaml_file, self.__meta_path)) or {})
if meta["server"]["host"] == "@auto":
meta["server"]["host"] = socket.getfqdn()
return meta
except Exception:
get_logger(0).exception("Can't parse meta")
return None

View File

@ -28,6 +28,7 @@ from typing import AsyncGenerator
from ....logging import get_logger
from .... import env
from .... import aiotools
from .... import aioproc
@ -38,12 +39,30 @@ from .base import BaseInfoSubmanager
# =====
class SystemInfoSubmanager(BaseInfoSubmanager):
def __init__(self, streamer_cmd: list[str]) -> None:
def __init__(
self,
platform_path: str,
streamer_cmd: list[str],
) -> None:
self.__platform_path = platform_path
self.__streamer_cmd = streamer_cmd
self.__dt_cache: dict[str, str] = {}
self.__notifier = aiotools.AioNotifier()
async def get_state(self) -> dict:
streamer_info = await self.__get_streamer_info()
(
base,
serial,
pl,
streamer_info,
) = await asyncio.gather(
self.__read_dt_file("model", upper=False),
self.__read_dt_file("serial-number", upper=True),
self.__read_platform_file(),
self.__get_streamer_info(),
)
uname_info = platform.uname() # Uname using the internal cache
return {
"kvmd": {"version": __version__},
@ -52,6 +71,12 @@ class SystemInfoSubmanager(BaseInfoSubmanager):
field: getattr(uname_info, field)
for field in ["system", "release", "version", "machine"]
},
"platform": {
"type": "rpi",
"base": base,
"serial": serial,
**pl, # type: ignore
},
}
async def trigger_state(self) -> None:
@ -64,6 +89,35 @@ class SystemInfoSubmanager(BaseInfoSubmanager):
# =====
async def __read_dt_file(self, name: str, upper: bool) -> (str | None):
if name not in self.__dt_cache:
path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name)
try:
value = (await aiotools.read_file(path)).strip(" \t\r\n\0")
self.__dt_cache[name] = (value.upper() if upper else value)
except Exception as ex:
get_logger(0).error("Can't read DT %s from %s: %s", name, path, ex)
return None
return self.__dt_cache[name]
async def __read_platform_file(self) -> dict:
try:
text = await aiotools.read_file(self.__platform_path)
parsed: dict[str, str] = {}
for row in text.split("\n"):
row = row.strip()
if row:
(key, value) = row.split("=", 1)
parsed[key.strip()] = value.strip()
return {
"model": parsed["PIKVM_MODEL"],
"video": parsed["PIKVM_VIDEO"],
"board": parsed["PIKVM_BOARD"],
}
except Exception:
get_logger(0).exception("Can't read device model")
return {"model": None, "video": None, "board": None}
async def __get_streamer_info(self) -> dict:
version = ""
features: dict[str, bool] = {}

View File

@ -254,6 +254,10 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None:
await ws.send_event("pong", {})
@exposed_ws(0)
async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None:
await ws.send_bin(255, b"") # Ping-pong
# ===== SYSTEM STUFF
def run(self, **kwargs: Any) -> None: # type: ignore # pylint: disable=arguments-differ
@ -318,18 +322,17 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
while True:
cur = (self.__has_stream_clients() or self.__snapshoter.snapshoting() or self.__stream_forever)
if not prev and cur:
await self.__streamer.ensure_start(reset=False)
await self.__streamer.ensure_start()
elif prev and not cur:
await self.__streamer.ensure_stop(immediately=False)
await self.__streamer.ensure_stop()
if self.__reset_streamer or self.__new_streamer_params:
start = self.__streamer.is_working()
await self.__streamer.ensure_stop(immediately=True)
if self.__new_streamer_params:
self.__streamer.set_params(self.__new_streamer_params)
self.__new_streamer_params = {}
if start:
await self.__streamer.ensure_start(reset=self.__reset_streamer)
if self.__new_streamer_params:
self.__streamer.set_params(self.__new_streamer_params)
self.__new_streamer_params = {}
self.__reset_streamer = True
if self.__reset_streamer:
await self.__streamer.ensure_restart()
self.__reset_streamer = False
prev = cur

View File

@ -31,6 +31,8 @@ from ... import aiotools
from ...plugins.hid import BaseHid
from ...keyboard.mappings import WEB_TO_EVDEV
from .streamer import Streamer
@ -63,7 +65,7 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes
else:
self.__idle_interval = self.__live_interval = 0.0
self.__wakeup_key = wakeup_key
self.__wakeup_key = WEB_TO_EVDEV.get(wakeup_key, 0)
self.__wakeup_move = wakeup_move
self.__online_delay = online_delay
@ -121,8 +123,8 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes
async def __wakeup(self) -> None:
logger = get_logger(0)
if self.__wakeup_key:
logger.info("Waking up using key %r ...", self.__wakeup_key)
if self.__wakeup_key > 0:
logger.info("Waking up using keyboard ...")
await self.__hid.send_key_events(
keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)],
no_ignore_keys=True,

View File

@ -1,456 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import signal
import asyncio
import asyncio.subprocess
import dataclasses
import copy
from typing import AsyncGenerator
from typing import Any
import aiohttp
from ...logging import get_logger
from ...clients.streamer import StreamerSnapshot
from ...clients.streamer import HttpStreamerClient
from ...clients.streamer import HttpStreamerClientSession
from ... import tools
from ... import aiotools
from ... import aioproc
from ... import htclient
# =====
class _StreamerParams:
__DESIRED_FPS = "desired_fps"
__QUALITY = "quality"
__RESOLUTION = "resolution"
__AVAILABLE_RESOLUTIONS = "available_resolutions"
__H264_BITRATE = "h264_bitrate"
__H264_GOP = "h264_gop"
def __init__( # pylint: disable=too-many-arguments
self,
quality: int,
resolution: str,
available_resolutions: list[str],
desired_fps: int,
desired_fps_min: int,
desired_fps_max: int,
h264_bitrate: int,
h264_bitrate_min: int,
h264_bitrate_max: int,
h264_gop: int,
h264_gop_min: int,
h264_gop_max: int,
) -> None:
self.__has_quality = bool(quality)
self.__has_resolution = bool(resolution)
self.__has_h264 = bool(h264_bitrate)
self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)}
self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}}
if self.__has_quality:
self.__params[self.__QUALITY] = quality
if self.__has_resolution:
self.__params[self.__RESOLUTION] = resolution
self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions
if self.__has_h264:
self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max)
self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max}
self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max)
self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max}
def get_features(self) -> dict:
return {
self.__QUALITY: self.__has_quality,
self.__RESOLUTION: self.__has_resolution,
"h264": self.__has_h264,
}
def get_limits(self) -> dict:
limits = copy.deepcopy(self.__limits)
if self.__has_resolution:
limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
return limits
def get_params(self) -> dict:
return dict(self.__params)
def set_params(self, params: dict) -> None:
new_params = dict(self.__params)
if self.__QUALITY in params and self.__has_quality:
new_params[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100)
if self.__RESOLUTION in params and self.__has_resolution:
if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]:
new_params[self.__RESOLUTION] = params[self.__RESOLUTION]
for (key, enabled) in [
(self.__DESIRED_FPS, True),
(self.__H264_BITRATE, self.__has_h264),
(self.__H264_GOP, self.__has_h264),
]:
if key in params and enabled:
if self.__check_limits_min_max(key, params[key]):
new_params[key] = params[key]
self.__params = new_params
def __check_limits_min_max(self, key: str, value: int) -> bool:
return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"])
class Streamer: # pylint: disable=too-many-instance-attributes
__ST_FULL = 0xFF
__ST_PARAMS = 0x01
__ST_STREAMER = 0x02
__ST_SNAPSHOT = 0x04
def __init__( # pylint: disable=too-many-arguments,too-many-locals
self,
reset_delay: float,
shutdown_delay: float,
state_poll: float,
unix_path: str,
timeout: float,
snapshot_timeout: float,
process_name_prefix: str,
pre_start_cmd: list[str],
pre_start_cmd_remove: list[str],
pre_start_cmd_append: list[str],
cmd: list[str],
cmd_remove: list[str],
cmd_append: list[str],
post_stop_cmd: list[str],
post_stop_cmd_remove: list[str],
post_stop_cmd_append: list[str],
**params_kwargs: Any,
) -> None:
self.__reset_delay = reset_delay
self.__shutdown_delay = shutdown_delay
self.__state_poll = state_poll
self.__unix_path = unix_path
self.__snapshot_timeout = snapshot_timeout
self.__process_name_prefix = process_name_prefix
self.__pre_start_cmd = tools.build_cmd(pre_start_cmd, pre_start_cmd_remove, pre_start_cmd_append)
self.__cmd = tools.build_cmd(cmd, cmd_remove, cmd_append)
self.__post_stop_cmd = tools.build_cmd(post_stop_cmd, post_stop_cmd_remove, post_stop_cmd_append)
self.__params = _StreamerParams(**params_kwargs)
self.__stop_task: (asyncio.Task | None) = None
self.__stop_wip = False
self.__streamer_task: (asyncio.Task | None) = None
self.__streamer_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
self.__client = HttpStreamerClient(
name="jpeg",
unix_path=self.__unix_path,
timeout=timeout,
user_agent=htclient.make_user_agent("KVMD"),
)
self.__client_session: (HttpStreamerClientSession | None) = None
self.__snapshot: (StreamerSnapshot | None) = None
self.__notifier = aiotools.AioNotifier()
# =====
@aiotools.atomic_fg
async def ensure_start(self, reset: bool) -> None:
if not self.__streamer_task or self.__stop_task:
logger = get_logger(0)
if self.__stop_task:
if not self.__stop_wip:
self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info("Streamer stop cancelled")
return
else:
await asyncio.gather(self.__stop_task, return_exceptions=True)
if reset and self.__reset_delay > 0:
logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay)
await asyncio.sleep(self.__reset_delay)
logger.info("Starting streamer ...")
await self.__inner_start()
@aiotools.atomic_fg
async def ensure_stop(self, immediately: bool) -> None:
if self.__streamer_task:
logger = get_logger(0)
if immediately:
if self.__stop_task:
if not self.__stop_wip:
self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
else:
await asyncio.gather(self.__stop_task, return_exceptions=True)
else:
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
elif not self.__stop_task:
async def delayed_stop() -> None:
try:
await asyncio.sleep(self.__shutdown_delay)
self.__stop_wip = True
logger.info("Stopping streamer after delay ...")
await self.__inner_stop()
finally:
self.__stop_task = None
self.__stop_wip = False
logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay)
self.__stop_task = asyncio.create_task(delayed_stop())
def is_working(self) -> bool:
# Запущено и не планирует останавливаться
return bool(self.__streamer_task and not self.__stop_task)
# =====
def set_params(self, params: dict) -> None:
assert not self.__streamer_task
self.__notifier.notify(self.__ST_PARAMS)
return self.__params.set_params(params)
def get_params(self) -> dict:
return self.__params.get_params()
# =====
async def get_state(self) -> dict:
return {
"features": self.__params.get_features(),
"limits": self.__params.get_limits(),
"params": self.__params.get_params(),
"streamer": (await self.__get_streamer_state()),
"snapshot": self.__get_snapshot_state(),
}
async def trigger_state(self) -> None:
self.__notifier.notify(self.__ST_FULL)
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - features -- Full
# - limits -- Partial, paired with params
# - params -- Partial, paired with limits
# - streamer -- Partial, nullable
# - snapshot -- Partial
# ===========================
def signal_handler(*_: Any) -> None:
get_logger(0).info("Got SIGUSR2, checking the stream state ...")
self.__notifier.notify(self.__ST_STREAMER)
get_logger(0).info("Installing SIGUSR2 streamer handler ...")
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
prev: dict = {}
while True:
new: dict = {}
mask = await self.__notifier.wait(timeout=self.__state_poll)
if mask == self.__ST_FULL:
new = await self.get_state()
prev = copy.deepcopy(new)
yield new
continue
if mask < 0:
mask = self.__ST_STREAMER
def check_update(key: str, value: (dict | None)) -> None:
if prev.get(key) != value:
new[key] = value
if mask & self.__ST_PARAMS:
check_update("params", self.__params.get_params())
if mask & self.__ST_STREAMER:
check_update("streamer", await self.__get_streamer_state())
if mask & self.__ST_SNAPSHOT:
check_update("snapshot", self.__get_snapshot_state())
if new and prev != new:
prev.update(copy.deepcopy(new))
yield new
async def __get_streamer_state(self) -> (dict | None):
if self.__streamer_task:
session = self.__ensure_client_session()
try:
return (await session.get_state())
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
pass
except Exception:
get_logger().exception("Invalid streamer response from /state")
return None
def __get_snapshot_state(self) -> dict:
if self.__snapshot:
snapshot = dataclasses.asdict(self.__snapshot)
del snapshot["headers"]
del snapshot["data"]
return {"saved": snapshot}
return {"saved": None}
# =====
async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None):
if load:
return self.__snapshot
logger = get_logger()
session = self.__ensure_client_session()
try:
snapshot = await session.take_snapshot(self.__snapshot_timeout)
if snapshot.online or allow_offline:
if save:
self.__snapshot = snapshot
self.__notifier.notify(self.__ST_SNAPSHOT)
return snapshot
logger.error("Stream is offline, no signal or so")
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as ex:
logger.error("Can't connect to streamer: %s", tools.efmt(ex))
except Exception:
logger.exception("Invalid streamer response from /snapshot")
return None
def remove_snapshot(self) -> None:
self.__snapshot = None
# =====
@aiotools.atomic_fg
async def cleanup(self) -> None:
await self.ensure_stop(immediately=True)
if self.__client_session:
await self.__client_session.close()
self.__client_session = None
def __ensure_client_session(self) -> HttpStreamerClientSession:
if not self.__client_session:
self.__client_session = self.__client.make_session()
return self.__client_session
# =====
@aiotools.atomic_fg
async def __inner_start(self) -> None:
assert not self.__streamer_task
await self.__run_hook("PRE-START-CMD", self.__pre_start_cmd)
self.__streamer_task = asyncio.create_task(self.__streamer_task_loop())
@aiotools.atomic_fg
async def __inner_stop(self) -> None:
assert self.__streamer_task
self.__streamer_task.cancel()
await asyncio.gather(self.__streamer_task, return_exceptions=True)
await self.__kill_streamer_proc()
await self.__run_hook("POST-STOP-CMD", self.__post_stop_cmd)
self.__streamer_task = None
# =====
async def __streamer_task_loop(self) -> None: # pylint: disable=too-many-branches
logger = get_logger(0)
while True: # pylint: disable=too-many-nested-blocks
try:
await self.__start_streamer_proc()
assert self.__streamer_proc is not None
await aioproc.log_stdout_infinite(self.__streamer_proc, logger)
raise RuntimeError("Streamer unexpectedly died")
except asyncio.CancelledError:
break
except Exception:
if self.__streamer_proc:
logger.exception("Unexpected streamer error: pid=%d", self.__streamer_proc.pid)
else:
logger.exception("Can't start streamer")
await self.__kill_streamer_proc()
await asyncio.sleep(1)
def __make_cmd(self, cmd: list[str]) -> list[str]:
return [
part.format(
unix=self.__unix_path,
process_name_prefix=self.__process_name_prefix,
**self.__params.get_params(),
)
for part in cmd
]
async def __run_hook(self, name: str, cmd: list[str]) -> None:
logger = get_logger()
cmd = self.__make_cmd(cmd)
logger.info("%s: %s", name, tools.cmdfmt(cmd))
try:
await aioproc.log_process(cmd, logger, prefix=name)
except Exception as ex:
logger.exception("Can't execute command: %s", ex)
async def __start_streamer_proc(self) -> None:
assert self.__streamer_proc is None
cmd = self.__make_cmd(self.__cmd)
self.__streamer_proc = await aioproc.run_process(cmd)
get_logger(0).info("Started streamer pid=%d: %s", self.__streamer_proc.pid, tools.cmdfmt(cmd))
async def __kill_streamer_proc(self) -> None:
if self.__streamer_proc:
await aioproc.kill_process(self.__streamer_proc, 1, get_logger(0))
self.__streamer_proc = None

View File

@ -0,0 +1,254 @@
# ========================================================================== #
# #
# 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 signal
import asyncio
import dataclasses
import copy
from typing import AsyncGenerator
from typing import Any
import aiohttp
from ....logging import get_logger
from ....clients.streamer import StreamerSnapshot
from ....clients.streamer import HttpStreamerClient
from ....clients.streamer import HttpStreamerClientSession
from .... import tools
from .... import aiotools
from .... import htclient
from .params import Params
from .runner import Runner
# =====
class Streamer: # pylint: disable=too-many-instance-attributes
__ST_FULL = 0xFF
__ST_PARAMS = 0x01
__ST_STREAMER = 0x02
__ST_SNAPSHOT = 0x04
def __init__( # pylint: disable=too-many-arguments,too-many-locals
self,
reset_delay: float,
shutdown_delay: float,
state_poll: float,
unix_path: str,
timeout: float,
snapshot_timeout: float,
process_name_prefix: str,
pre_start_cmd: list[str],
pre_start_cmd_remove: list[str],
pre_start_cmd_append: list[str],
cmd: list[str],
cmd_remove: list[str],
cmd_append: list[str],
post_stop_cmd: list[str],
post_stop_cmd_remove: list[str],
post_stop_cmd_append: list[str],
**params_kwargs: Any,
) -> None:
self.__state_poll = state_poll
self.__unix_path = unix_path
self.__snapshot_timeout = snapshot_timeout
self.__process_name_prefix = process_name_prefix
self.__params = Params(**params_kwargs)
self.__runner = Runner(
reset_delay=reset_delay,
shutdown_delay=shutdown_delay,
pre_start_cmd=tools.build_cmd(pre_start_cmd, pre_start_cmd_remove, pre_start_cmd_append),
cmd=tools.build_cmd(cmd, cmd_remove, cmd_append),
post_stop_cmd=tools.build_cmd(post_stop_cmd, post_stop_cmd_remove, post_stop_cmd_append),
)
self.__client = HttpStreamerClient(
name="jpeg",
unix_path=self.__unix_path,
timeout=timeout,
user_agent=htclient.make_user_agent("KVMD"),
)
self.__client_session: (HttpStreamerClientSession | None) = None
self.__snapshot: (StreamerSnapshot | None) = None
self.__notifier = aiotools.AioNotifier()
# =====
@aiotools.atomic_fg
async def ensure_start(self) -> None:
await self.__runner.ensure_start(self.__make_params())
@aiotools.atomic_fg
async def ensure_restart(self) -> None:
await self.__runner.ensure_restart(self.__make_params())
def __make_params(self) -> dict:
return {
"unix": self.__unix_path,
"process_name_prefix": self.__process_name_prefix,
**self.__params.get_params(),
}
@aiotools.atomic_fg
async def ensure_stop(self) -> None:
await self.__runner.ensure_stop(immediately=False)
# =====
def set_params(self, params: dict) -> None:
self.__notifier.notify(self.__ST_PARAMS)
return self.__params.set_params(params)
def get_params(self) -> dict:
return self.__params.get_params()
# =====
async def get_state(self) -> dict:
return {
"features": self.__params.get_features(),
"limits": self.__params.get_limits(),
"params": self.__params.get_params(),
"streamer": (await self.__get_streamer_state()),
"snapshot": self.__get_snapshot_state(),
}
async def trigger_state(self) -> None:
self.__notifier.notify(self.__ST_FULL)
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - features -- Full
# - limits -- Partial, paired with params
# - params -- Partial, paired with limits
# - streamer -- Partial, nullable
# - snapshot -- Partial
# ===========================
def signal_handler(*_: Any) -> None:
get_logger(0).info("Got SIGUSR2, checking the stream state ...")
self.__notifier.notify(self.__ST_STREAMER)
get_logger(0).info("Installing SIGUSR2 streamer handler ...")
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
prev: dict = {}
while True:
new: dict = {}
mask = await self.__notifier.wait(timeout=self.__state_poll)
if mask == self.__ST_FULL:
new = await self.get_state()
prev = copy.deepcopy(new)
yield new
continue
if mask < 0:
mask = self.__ST_STREAMER
def check_update(key: str, value: (dict | None)) -> None:
if prev.get(key) != value:
new[key] = value
if mask & self.__ST_PARAMS:
check_update("params", self.__params.get_params())
if mask & self.__ST_STREAMER:
check_update("streamer", await self.__get_streamer_state())
if mask & self.__ST_SNAPSHOT:
check_update("snapshot", self.__get_snapshot_state())
if new and prev != new:
prev.update(copy.deepcopy(new))
yield new
async def __get_streamer_state(self) -> (dict | None):
if self.__runner.is_running():
session = self.__ensure_client_session()
try:
return (await session.get_state())
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
pass
except Exception:
get_logger().exception("Invalid streamer response from /state")
return None
def __get_snapshot_state(self) -> dict:
if self.__snapshot:
snapshot = dataclasses.asdict(self.__snapshot)
del snapshot["headers"]
del snapshot["data"]
return {"saved": snapshot}
return {"saved": None}
# =====
async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None):
if load:
return self.__snapshot
logger = get_logger()
session = self.__ensure_client_session()
try:
snapshot = await session.take_snapshot(self.__snapshot_timeout)
if snapshot.online or allow_offline:
if save:
self.__snapshot = snapshot
self.__notifier.notify(self.__ST_SNAPSHOT)
return snapshot
logger.error("Stream is offline, no signal or so")
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as ex:
logger.error("Can't connect to streamer: %s", tools.efmt(ex))
except Exception:
logger.exception("Invalid streamer response from /snapshot")
return None
def remove_snapshot(self) -> None:
self.__snapshot = None
# =====
@aiotools.atomic_fg
async def cleanup(self) -> None:
await self.__runner.ensure_stop(immediately=True)
if self.__client_session:
await self.__client_session.close()
self.__client_session = None
def __ensure_client_session(self) -> HttpStreamerClientSession:
if not self.__client_session:
self.__client_session = self.__client.make_session()
return self.__client_session

View File

@ -0,0 +1,117 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import copy
# =====
class Params:
__DESIRED_FPS = "desired_fps"
__QUALITY = "quality"
__RESOLUTION = "resolution"
__AVAILABLE_RESOLUTIONS = "available_resolutions"
__H264 = "h264"
__H264_BITRATE = "h264_bitrate"
__H264_GOP = "h264_gop"
def __init__( # pylint: disable=too-many-arguments
self,
quality: int,
resolution: str,
available_resolutions: list[str],
desired_fps: int,
desired_fps_min: int,
desired_fps_max: int,
h264_bitrate: int,
h264_bitrate_min: int,
h264_bitrate_max: int,
h264_gop: int,
h264_gop_min: int,
h264_gop_max: int,
) -> None:
self.__has_quality = bool(quality)
self.__has_resolution = bool(resolution)
self.__has_h264 = bool(h264_bitrate)
self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)}
self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}}
if self.__has_quality:
self.__params[self.__QUALITY] = quality
if self.__has_resolution:
self.__params[self.__RESOLUTION] = resolution
self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions
if self.__has_h264:
self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max)
self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max}
self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max)
self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max}
def get_features(self) -> dict:
return {
self.__QUALITY: self.__has_quality,
self.__RESOLUTION: self.__has_resolution,
self.__H264: self.__has_h264,
}
def get_limits(self) -> dict:
limits = copy.deepcopy(self.__limits)
if self.__has_resolution:
limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
return limits
def get_params(self) -> dict:
return dict(self.__params)
def set_params(self, params: dict) -> None:
new = dict(self.__params)
if self.__QUALITY in params and self.__has_quality:
new[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100)
if self.__RESOLUTION in params and self.__has_resolution:
if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]:
new[self.__RESOLUTION] = params[self.__RESOLUTION]
for (key, enabled) in [
(self.__DESIRED_FPS, True),
(self.__H264_BITRATE, self.__has_h264),
(self.__H264_GOP, self.__has_h264),
]:
if key in params and enabled:
if self.__check_limits_min_max(key, params[key]):
new[key] = params[key]
self.__params = new
def __check_limits_min_max(self, key: str, value: int) -> bool:
return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"])

View File

@ -0,0 +1,182 @@
# ========================================================================== #
# #
# 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 asyncio.subprocess
from ....logging import get_logger
from .... import tools
from .... import aiotools
from .... import aioproc
# =====
class Runner: # pylint: disable=too-many-instance-attributes
def __init__(
self,
reset_delay: float,
shutdown_delay: float,
pre_start_cmd: list[str],
cmd: list[str],
post_stop_cmd: list[str],
) -> None:
self.__reset_delay = reset_delay
self.__shutdown_delay = shutdown_delay
self.__pre_start_cmd: list[str] = pre_start_cmd
self.__cmd: list[str] = cmd
self.__post_stop_cmd: list[str] = post_stop_cmd
self.__proc_params: dict = {}
self.__proc_task: (asyncio.Task | None) = None
self.__proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
self.__stopper_task: (asyncio.Task | None) = None
self.__stopper_wip = False
@aiotools.atomic_fg
async def ensure_start(self, params: dict) -> None:
if not self.__proc_task or self.__stopper_task:
logger = get_logger(0)
if self.__stopper_task:
if not self.__stopper_wip:
self.__stopper_task.cancel()
await asyncio.gather(self.__stopper_task, return_exceptions=True)
logger.info("Streamer stop cancelled")
return
else:
await asyncio.gather(self.__stopper_task, return_exceptions=True)
logger.info("Starting streamer ...")
await self.__inner_start(params)
@aiotools.atomic_fg
async def ensure_restart(self, params: dict) -> None:
logger = get_logger(0)
start = bool(self.__proc_task and not self.__stopper_task) # Если запущено и не планирует останавливаться
await self.ensure_stop(immediately=True)
if self.__reset_delay > 0:
logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay)
await asyncio.sleep(self.__reset_delay)
if start:
await self.ensure_start(params)
@aiotools.atomic_fg
async def ensure_stop(self, immediately: bool) -> None:
if self.__proc_task:
logger = get_logger(0)
if immediately:
if self.__stopper_task:
if not self.__stopper_wip:
self.__stopper_task.cancel()
await asyncio.gather(self.__stopper_task, return_exceptions=True)
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
else:
await asyncio.gather(self.__stopper_task, return_exceptions=True)
else:
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
elif not self.__stopper_task:
async def delayed_stop() -> None:
try:
await asyncio.sleep(self.__shutdown_delay)
self.__stopper_wip = True
logger.info("Stopping streamer after delay ...")
await self.__inner_stop()
finally:
self.__stopper_task = None
self.__stopper_wip = False
logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay)
self.__stopper_task = asyncio.create_task(delayed_stop())
def is_running(self) -> bool:
return bool(self.__proc_task)
# =====
@aiotools.atomic_fg
async def __inner_start(self, params: dict) -> None:
assert not self.__proc_task
self.__proc_params = params
await self.__run_hook("PRE-START-CMD", self.__pre_start_cmd)
self.__proc_task = asyncio.create_task(self.__process_task_loop())
@aiotools.atomic_fg
async def __inner_stop(self) -> None:
assert self.__proc_task
self.__proc_task.cancel()
await asyncio.gather(self.__proc_task, return_exceptions=True)
await self.__kill_process()
await self.__run_hook("POST-STOP-CMD", self.__post_stop_cmd)
self.__proc_task = None
# =====
async def __process_task_loop(self) -> None: # pylint: disable=too-many-branches
logger = get_logger(0)
while True: # pylint: disable=too-many-nested-blocks
try:
await self.__start_process()
assert self.__proc is not None
await aioproc.log_stdout_infinite(self.__proc, logger)
raise RuntimeError("Streamer unexpectedly died")
except asyncio.CancelledError:
break
except Exception:
if self.__proc:
logger.exception("Unexpected streamer error: pid=%d", self.__proc.pid)
else:
logger.exception("Can't start streamer")
await self.__kill_process()
await asyncio.sleep(1)
def __make_cmd(self, cmd: list[str]) -> list[str]:
return [part.format(**self.__proc_params) for part in cmd]
async def __run_hook(self, name: str, cmd: list[str]) -> None:
logger = get_logger()
cmd = self.__make_cmd(cmd)
logger.info("%s: %s", name, tools.cmdfmt(cmd))
try:
await aioproc.log_process(cmd, logger, prefix=name)
except Exception:
logger.exception("Can't execute %s hook: %s", name, tools.cmdfmt(cmd))
async def __start_process(self) -> None:
assert self.__proc is None
cmd = self.__make_cmd(self.__cmd)
self.__proc = await aioproc.run_process(cmd)
get_logger(0).info("Started streamer pid=%d: %s", self.__proc.pid, tools.cmdfmt(cmd))
async def __kill_process(self) -> None:
if self.__proc:
await aioproc.kill_process(self.__proc, 1, get_logger(0))
self.__proc = None

View File

@ -32,6 +32,7 @@ from .lib import Inotify
from .types import Edid
from .types import Edids
from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@ -68,6 +69,7 @@ class SwitchUnknownEdidError(SwitchOperationError):
# =====
class Switch: # pylint: disable=too-many-public-methods
__X_EDIDS = "edids"
__X_DUMMIES = "dummies"
__X_COLORS = "colors"
__X_PORT_NAMES = "port_names"
__X_ATX_CP_DELAYS = "atx_cp_delays"
@ -75,7 +77,7 @@ class Switch: # pylint: disable=too-many-public-methods
__X_ATX_CR_DELAYS = "atx_cr_delays"
__X_ALL = frozenset([
__X_EDIDS, __X_COLORS, __X_PORT_NAMES,
__X_EDIDS, __X_DUMMIES, __X_COLORS, __X_PORT_NAMES,
__X_ATX_CP_DELAYS, __X_ATX_CPL_DELAYS, __X_ATX_CR_DELAYS,
])
@ -84,11 +86,12 @@ class Switch: # pylint: disable=too-many-public-methods
device_path: str,
default_edid_path: str,
pst_unix_path: str,
ignore_hpd_on_top: bool,
) -> None:
self.__default_edid_path = default_edid_path
self.__chain = Chain(device_path)
self.__chain = Chain(device_path, ignore_hpd_on_top)
self.__cache = StateCache()
self.__storage = Storage(pst_unix_path)
@ -104,6 +107,12 @@ class Switch: # pylint: disable=too-many-public-methods
if save:
self.__save_notifier.notify()
def __x_set_dummies(self, dummies: Dummies, save: bool=True) -> None:
self.__chain.set_dummies(dummies)
self.__cache.set_dummies(dummies)
if save:
self.__save_notifier.notify()
def __x_set_colors(self, colors: Colors, save: bool=True) -> None:
self.__chain.set_colors(colors)
self.__cache.set_colors(colors)
@ -132,13 +141,19 @@ class Switch: # pylint: disable=too-many-public-methods
# =====
async def set_active_port(self, port: int) -> None:
self.__chain.set_active_port(port)
async def set_active_prev(self) -> None:
self.__chain.set_active_prev()
async def set_active_next(self) -> None:
self.__chain.set_active_next()
async def set_active_port(self, port: float) -> None:
self.__chain.set_active_port(self.__chain.translate_port(port))
# =====
async def set_port_beacon(self, port: int, on: bool) -> None:
self.__chain.set_port_beacon(port, on)
async def set_port_beacon(self, port: float, on: bool) -> None:
self.__chain.set_port_beacon(self.__chain.translate_port(port), on)
async def set_uplink_beacon(self, unit: int, on: bool) -> None:
self.__chain.set_uplink_beacon(unit, on)
@ -148,33 +163,35 @@ class Switch: # pylint: disable=too-many-public-methods
# =====
async def atx_power_on(self, port: int) -> None:
async def atx_power_on(self, port: float) -> None:
self.__inner_atx_cp(port, False, self.__X_ATX_CP_DELAYS)
async def atx_power_off(self, port: int) -> None:
async def atx_power_off(self, port: float) -> None:
self.__inner_atx_cp(port, True, self.__X_ATX_CP_DELAYS)
async def atx_power_off_hard(self, port: int) -> None:
async def atx_power_off_hard(self, port: float) -> None:
self.__inner_atx_cp(port, True, self.__X_ATX_CPL_DELAYS)
async def atx_power_reset_hard(self, port: int) -> None:
async def atx_power_reset_hard(self, port: float) -> None:
self.__inner_atx_cr(port, True)
async def atx_click_power(self, port: int) -> None:
async def atx_click_power(self, port: float) -> None:
self.__inner_atx_cp(port, None, self.__X_ATX_CP_DELAYS)
async def atx_click_power_long(self, port: int) -> None:
async def atx_click_power_long(self, port: float) -> None:
self.__inner_atx_cp(port, None, self.__X_ATX_CPL_DELAYS)
async def atx_click_reset(self, port: int) -> None:
async def atx_click_reset(self, port: float) -> None:
self.__inner_atx_cr(port, None)
def __inner_atx_cp(self, port: int, if_powered: (bool | None), x_delay: str) -> None:
def __inner_atx_cp(self, port: float, if_powered: (bool | None), x_delay: str) -> None:
assert x_delay in [self.__X_ATX_CP_DELAYS, self.__X_ATX_CPL_DELAYS]
port = self.__chain.translate_port(port)
delay = getattr(self.__cache, f"get_{x_delay}")()[port]
self.__chain.click_power(port, delay, if_powered)
def __inner_atx_cr(self, port: int, if_powered: (bool | None)) -> None:
def __inner_atx_cr(self, port: float, if_powered: (bool | None)) -> None:
port = self.__chain.translate_port(port)
delay = self.__cache.get_atx_cr_delays()[port]
self.__chain.click_reset(port, delay, if_powered)
@ -235,12 +252,14 @@ class Switch: # pylint: disable=too-many-public-methods
self,
port: int,
edid_id: (str | None)=None,
dummy: (bool | None)=None,
name: (str | None)=None,
atx_click_power_delay: (float | None)=None,
atx_click_power_long_delay: (float | None)=None,
atx_click_reset_delay: (float | None)=None,
) -> None:
port = self.__chain.translate_port(port)
async with self.__lock:
if edid_id is not None:
edids = self.__cache.get_edids()
@ -249,15 +268,16 @@ class Switch: # pylint: disable=too-many-public-methods
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),
for (reset, key, value) in [
(None, self.__X_DUMMIES, dummy), # None can't be used now
("", self.__X_PORT_NAMES, name),
(0, self.__X_ATX_CP_DELAYS, atx_click_power_delay),
(0, self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay),
(0, self.__X_ATX_CR_DELAYS, atx_click_reset_delay),
]:
if value is not None:
new = getattr(self.__cache, f"get_{key}")()
new[port] = (value or None) # None == reset to default
new[port] = (None if value == reset else value) # Value or reset default
getattr(self, f"_Switch__x_set_{key}")(new)
# =====
@ -374,7 +394,7 @@ class Switch: # pylint: disable=too-many-public-methods
prevs = dict.fromkeys(self.__X_ALL)
while True:
await self.__save_notifier.wait()
while (await self.__save_notifier.wait(5)):
while not (await self.__save_notifier.wait(5)):
pass
while True:
try:

View File

@ -34,6 +34,7 @@ from .lib import aiotools
from .lib import aioproc
from .types import Edids
from .types import Dummies
from .types import Colors
from .proto import Response
@ -54,6 +55,14 @@ class _CmdSetActual(_BaseCmd):
actual: bool
class _CmdSetActivePrev(_BaseCmd):
pass
class _CmdSetActiveNext(_BaseCmd):
pass
@dataclasses.dataclass(frozen=True)
class _CmdSetActivePort(_BaseCmd):
port: int
@ -80,6 +89,11 @@ class _CmdSetEdids(_BaseCmd):
edids: Edids
@dataclasses.dataclass(frozen=True)
class _CmdSetDummies(_BaseCmd):
dummies: Dummies
@dataclasses.dataclass(frozen=True)
class _CmdSetColors(_BaseCmd):
colors: Colors
@ -177,13 +191,19 @@ class UnitAtxLedsEvent(BaseEvent):
# =====
class Chain: # pylint: disable=too-many-instance-attributes
def __init__(self, device_path: str) -> None:
def __init__(
self,
device_path: str,
ignore_hpd_on_top: bool,
) -> None:
self.__device = Device(device_path)
self.__ignore_hpd_on_top = ignore_hpd_on_top
self.__actual = False
self.__edids = Edids()
self.__dummies = Dummies({})
self.__colors = Colors()
self.__units: list[_UnitContext] = []
@ -200,6 +220,24 @@ class Chain: # pylint: disable=too-many-instance-attributes
# =====
def translate_port(self, port: float) -> int:
assert port >= 0
if int(port) == port:
return int(port)
(unit, ch) = map(int, str(port).split("."))
unit = min(max(unit, 1), 5)
ch = min(max(ch, 1), 4)
port = min((unit - 1) * 4 + (ch - 1), 19)
return port
# =====
def set_active_prev(self) -> None:
self.__queue_cmd(_CmdSetActivePrev())
def set_active_next(self) -> None:
self.__queue_cmd(_CmdSetActiveNext())
def set_active_port(self, port: int) -> None:
self.__queue_cmd(_CmdSetActivePort(port))
@ -219,6 +257,9 @@ class Chain: # pylint: disable=too-many-instance-attributes
def set_edids(self, edids: Edids) -> None:
self.__queue_cmd(_CmdSetEdids(edids)) # Will be copied because of multiprocessing.Queue()
def set_dummies(self, dummies: Dummies) -> None:
self.__queue_cmd(_CmdSetDummies(dummies))
def set_colors(self, colors: Colors) -> None:
self.__queue_cmd(_CmdSetColors(colors))
@ -290,12 +331,21 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__device.request_state()
self.__device.request_atx_leds()
while not self.__stop_event.is_set():
count = 0
if self.__select():
count = 0
for resp in self.__device.read_all():
self.__update_units(resp)
self.__adjust_quirks()
self.__adjust_start_port()
self.__finish_changing_request(resp)
self.__consume_commands()
else:
count += 1
if count >= 5:
# Heartbeat
self.__device.request_state()
count = 0
self.__ensure_config()
def __select(self) -> bool:
@ -314,10 +364,29 @@ class Chain: # pylint: disable=too-many-instance-attributes
case _CmdSetActual():
self.__actual = cmd.actual
case _CmdSetActivePrev():
if len(self.__units) > 0:
port = self.__active_port
port -= 1
if port >= 0:
self.__active_port = port
self.__queue_event(PortActivatedEvent(self.__active_port))
case _CmdSetActiveNext():
port = self.__active_port
if port < 0:
port = 0
else:
port += 1
if port < len(self.__units) * 4:
self.__active_port = port
self.__queue_event(PortActivatedEvent(self.__active_port))
case _CmdSetActivePort():
# Может быть вызвано изнутри при синхронизации
self.__active_port = cmd.port
self.__queue_event(PortActivatedEvent(self.__active_port))
if cmd.port < len(self.__units) * 4:
self.__active_port = cmd.port
self.__queue_event(PortActivatedEvent(self.__active_port))
case _CmdSetPortBeacon():
(unit, ch) = self.get_real_unit_channel(cmd.port)
@ -341,6 +410,9 @@ class Chain: # pylint: disable=too-many-instance-attributes
case _CmdSetEdids():
self.__edids = cmd.edids
case _CmdSetDummies():
self.__dummies = cmd.dummies
case _CmdSetColors():
self.__colors = cmd.colors
@ -364,6 +436,15 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__units[resp.header.unit].atx_leds = resp.body
self.__queue_event(UnitAtxLedsEvent(resp.header.unit, resp.body))
def __adjust_quirks(self) -> None:
for (unit, ctx) in enumerate(self.__units):
if ctx.state is not None and ctx.state.version.is_fresh(7):
ignore_hpd = (unit == 0 and self.__ignore_hpd_on_top)
if ctx.state.quirks.ignore_hpd != ignore_hpd:
get_logger().info("Applying quirk ignore_hpd=%s to [%d] ...",
ignore_hpd, unit)
self.__device.request_set_quirks(unit, ignore_hpd)
def __adjust_start_port(self) -> None:
if self.__active_port < 0:
for (unit, ctx) in enumerate(self.__units):
@ -387,6 +468,7 @@ class Chain: # pylint: disable=too-many-instance-attributes
self.__ensure_config_port(unit, ctx)
if self.__actual:
self.__ensure_config_edids(unit, ctx)
self.__ensure_config_dummies(unit, ctx)
self.__ensure_config_colors(unit, ctx)
def __ensure_config_port(self, unit: int, ctx: _UnitContext) -> None:
@ -413,6 +495,19 @@ class Chain: # pylint: disable=too-many-instance-attributes
ctx.changing_rid = self.__device.request_set_edid(unit, ch, edid)
break # Busy globally
def __ensure_config_dummies(self, unit: int, ctx: _UnitContext) -> None:
assert ctx.state is not None
if ctx.state.version.is_fresh(8) and ctx.can_be_changed():
for ch in range(4):
port = self.get_virtual_port(unit, ch)
dummy = self.__dummies[port]
if ctx.state.video_dummies[ch] != dummy:
get_logger().info("Changing dummy flag on port %d on [%d:%d]: %d -> %d ...",
port, unit, ch,
ctx.state.video_dummies[ch], dummy)
ctx.changing_rid = self.__device.request_set_dummy(unit, ch, dummy)
break # Busy globally (actually not but it can be changed in the firmware)
def __ensure_config_colors(self, unit: int, ctx: _UnitContext) -> None:
assert self.__actual
assert ctx.state is not None

View File

@ -41,7 +41,9 @@ from .proto import BodySetBeacon
from .proto import BodyAtxClick
from .proto import BodySetEdid
from .proto import BodyClearEdid
from .proto import BodySetDummy
from .proto import BodySetColors
from .proto import BodySetQuirks
# =====
@ -163,9 +165,15 @@ class Device:
return self.__send_request(Header.SET_EDID, unit, BodySetEdid(ch, edid))
return self.__send_request(Header.CLEAR_EDID, unit, BodyClearEdid(ch))
def request_set_dummy(self, unit: int, ch: int, on: bool) -> int:
return self.__send_request(Header.SET_DUMMY, unit, BodySetDummy(ch, on))
def request_set_colors(self, unit: int, ch: int, colors: Colors) -> int:
return self.__send_request(Header.SET_COLORS, unit, BodySetColors(ch, colors))
def request_set_quirks(self, unit: int, ignore_hpd: bool) -> int:
return self.__send_request(Header.SET_QUIRKS, unit, BodySetQuirks(ignore_hpd))
def __send_request(self, op: int, unit: int, body: (Packable | None)) -> int:
assert self.__tty is not None
req = Request(Header(

View File

@ -60,6 +60,8 @@ class Header(Packable, Unpackable):
SET_EDID = 9
CLEAR_EDID = 10
SET_COLORS = 12
SET_QUIRKS = 13
SET_DUMMY = 14
__struct = struct.Struct("<BHBB")
@ -89,17 +91,32 @@ class Nak(Unpackable):
return Nak(*cls.__struct.unpack_from(data, offset=offset))
@dataclasses.dataclass(frozen=True)
class UnitVersion:
hw: int
sw: int
sw_dev: bool
def is_fresh(self, version: int) -> bool:
return (self.sw_dev or (self.sw >= version))
@dataclasses.dataclass(frozen=True)
class UnitFlags:
changing_busy: bool
flashing_busy: bool
has_downlink: bool
has_hpd: bool
@dataclasses.dataclass(frozen=True)
class UnitQuirks:
ignore_hpd: bool
@dataclasses.dataclass(frozen=True)
class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
sw_version: int
hw_version: int
version: UnitVersion
flags: UnitFlags
ch: int
beacons: tuple[bool, bool, bool, bool, bool, bool]
@ -108,10 +125,12 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
video_hpd: tuple[bool, bool, bool, bool, bool]
video_edid: tuple[bool, bool, bool, bool]
video_crc: tuple[int, int, int, int]
video_dummies: tuple[bool, bool, bool, bool]
usb_5v_sens: tuple[bool, bool, bool, bool]
atx_busy: tuple[bool, bool, bool, bool]
quirks: UnitQuirks
__struct = struct.Struct("<HHHBBHHHHHHBBBHHHHBxB30x")
__struct = struct.Struct("<HHHBBHHHHHHBBBHHHHBxBBB28x")
def compare_edid(self, ch: int, edid: Optional["Edid"]) -> bool:
if edid is None:
@ -128,15 +147,19 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
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,
usb_5v_sens, atx_busy, quirks, video_dummies,
) = cls.__struct.unpack_from(data, offset=offset)
return UnitState(
sw_version,
hw_version,
version=UnitVersion(
hw=hw_version,
sw=(sw_version & 0x7FFF),
sw_dev=bool(sw_version & 0x8000),
),
flags=UnitFlags(
changing_busy=bool(flags & 0x80),
flashing_busy=bool(flags & 0x40),
has_downlink=bool(flags & 0x02),
has_hpd=bool(flags & 0x04),
),
ch=ch,
beacons=cls.__make_flags6(beacons),
@ -145,8 +168,10 @@ class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
video_hpd=cls.__make_flags5(video_hpd),
video_edid=cls.__make_flags4(video_edid),
video_crc=(vc0, vc1, vc2, vc3),
video_dummies=cls.__make_flags4(video_dummies),
usb_5v_sens=cls.__make_flags4(usb_5v_sens),
atx_busy=cls.__make_flags4(atx_busy),
quirks=UnitQuirks(ignore_hpd=bool(quirks & 0x01)),
)
@classmethod
@ -251,6 +276,18 @@ class BodyClearEdid(Packable):
return self.ch.to_bytes()
@dataclasses.dataclass(frozen=True)
class BodySetDummy(Packable):
ch: int
on: bool
def __post_init__(self) -> None:
assert 0 <= self.ch <= 3
def pack(self) -> bytes:
return self.ch.to_bytes() + self.on.to_bytes()
@dataclasses.dataclass(frozen=True)
class BodySetColors(Packable):
ch: int
@ -263,6 +300,14 @@ class BodySetColors(Packable):
return self.ch.to_bytes() + self.colors.pack()
@dataclasses.dataclass(frozen=True)
class BodySetQuirks(Packable):
ignore_hpd: bool
def pack(self) -> bytes:
return self.ignore_hpd.to_bytes()
# =====
@dataclasses.dataclass(frozen=True)
class Request:

View File

@ -27,6 +27,7 @@ import time
from typing import AsyncGenerator
from .types import Edids
from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@ -48,8 +49,8 @@ class _UnitInfo:
# =====
class StateCache: # pylint: disable=too-many-instance-attributes
__FW_VERSION = 5
class StateCache: # pylint: disable=too-many-instance-attributes,too-many-public-methods
__FW_VERSION = 8
__FULL = 0xFFFF
__SUMMARY = 0x01
@ -62,6 +63,7 @@ class StateCache: # pylint: disable=too-many-instance-attributes
def __init__(self) -> None:
self.__edids = Edids()
self.__dummies = Dummies({})
self.__colors = Colors()
self.__port_names = PortNames({})
self.__atx_cp_delays = AtxClickPowerDelays({})
@ -77,6 +79,9 @@ class StateCache: # pylint: disable=too-many-instance-attributes
def get_edids(self) -> Edids:
return self.__edids.copy()
def get_dummies(self) -> Dummies:
return self.__dummies.copy()
def get_colors(self) -> Colors:
return self.__colors
@ -158,7 +163,17 @@ class StateCache: # pylint: disable=too-many-instance-attributes
},
}
if x_summary:
state["summary"] = {"active_port": self.__active_port, "synced": self.__synced}
state["summary"] = {
"active_port": self.__active_port,
"active_id": (
"" if self.__active_port < 0 else (
f"{self.__active_port // 4 + 1}.{self.__active_port % 4 + 1}"
if len(self.__units) > 1 else
f"{self.__active_port + 1}"
)
),
"synced": self.__synced,
}
if x_edids:
state["edids"] = {
"all": {
@ -195,7 +210,10 @@ class StateCache: # pylint: disable=too-many-instance-attributes
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}})
state["model"]["units"].append({"firmware": {
"version": ui.state.version.sw,
"devbuild": ui.state.version.sw_dev,
}})
if x_video:
state["video"]["links"].extend(ui.state.video_5v_sens[:4])
if x_usb:
@ -216,6 +234,7 @@ class StateCache: # pylint: disable=too-many-instance-attributes
"unit": unit,
"channel": ch,
"name": self.__port_names[port],
"id": (f"{unit + 1}.{ch + 1}" if len(self.__units) > 1 else f"{ch + 1}"),
"atx": {
"click_delays": {
"power": self.__atx_cp_delays[port],
@ -223,6 +242,9 @@ class StateCache: # pylint: disable=too-many-instance-attributes
"reset": self.__atx_cr_delays[port],
},
},
"video": {
"dummy": self.__dummies[port],
},
})
if x_edids:
state["edids"]["used"].append(self.__edids.get_id_for_port(port))
@ -324,6 +346,12 @@ class StateCache: # pylint: disable=too-many-instance-attributes
if changed:
self.__bump_state(self.__EDIDS)
def set_dummies(self, dummies: Dummies) -> None:
changed = (not self.__dummies.compare_on_ports(dummies, self.__get_ports()))
self.__dummies = dummies.copy()
if changed:
self.__bump_state(self.__FULL)
def set_colors(self, colors: Colors) -> None:
changed = (self.__colors != colors)
self.__colors = colors

View File

@ -39,6 +39,7 @@ from .lib import get_logger
from .types import Edid
from .types import Edids
from .types import Dummies
from .types import Color
from .types import Colors
from .types import PortNames
@ -52,6 +53,8 @@ class StorageContext:
__F_EDIDS_ALL = "edids_all.json"
__F_EDIDS_PORT = "edids_port.json"
__F_DUMMIES = "dummies.json"
__F_COLORS = "colors.json"
__F_PORT_NAMES = "port_names.json"
@ -74,6 +77,9 @@ class StorageContext:
})
await self.__write_json_keyvals(self.__F_EDIDS_PORT, edids.port)
async def write_dummies(self, dummies: Dummies) -> None:
await self.__write_json_keyvals(self.__F_DUMMIES, dummies.kvs)
async def write_colors(self, colors: Colors) -> None:
await self.__write_json_keyvals(self.__F_COLORS, {
role: {
@ -116,6 +122,10 @@ class StorageContext:
port_edids = await self.__read_json_keyvals_int(self.__F_EDIDS_PORT)
return Edids(all_edids, port_edids)
async def read_dummies(self) -> Dummies:
kvs = await self.__read_json_keyvals_int(self.__F_DUMMIES)
return Dummies({key: bool(value) for (key, value) in kvs.items()})
async def read_colors(self) -> Colors:
raw = await self.__read_json_keyvals(self.__F_COLORS)
return Colors(**{ # type: ignore

View File

@ -59,31 +59,37 @@ class EdidInfo:
except ParsedEdidNoBlockError:
pass
audio: bool = False
try:
audio = parsed.get_audio()
except ParsedEdidNoBlockError:
pass
return EdidInfo(
mfc_id=parsed.get_mfc_id(),
product_id=parsed.get_product_id(),
serial=parsed.get_serial(),
monitor_name=monitor_name,
monitor_serial=monitor_serial,
audio=parsed.get_audio(),
audio=audio,
)
@dataclasses.dataclass(frozen=True)
class Edid:
name: str
data: bytes
crc: int = dataclasses.field(default=0)
valid: bool = dataclasses.field(default=False)
info: (EdidInfo | None) = dataclasses.field(default=None)
__HEADER = b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"
name: str
data: bytes
crc: int = dataclasses.field(default=0)
valid: bool = dataclasses.field(default=False)
info: (EdidInfo | None) = dataclasses.field(default=None)
_packed: bytes = dataclasses.field(default=b"")
def __post_init__(self) -> None:
assert len(self.name) > 0
assert len(self.data) == 256
object.__setattr__(self, "crc", bitbang.make_crc16(self.data))
object.__setattr__(self, "valid", self.data.startswith(self.__HEADER))
assert len(self.data) in [128, 256]
object.__setattr__(self, "_packed", (self.data + (b"\x00" * 128))[:256])
object.__setattr__(self, "crc", bitbang.make_crc16(self._packed)) # Calculate CRC for filled data
object.__setattr__(self, "valid", ParsedEdid.is_header_valid(self.data))
try:
object.__setattr__(self, "info", EdidInfo.from_data(self.data))
except Exception:
@ -93,7 +99,7 @@ class Edid:
return "".join(f"{item:0{2}X}" for item in self.data)
def pack(self) -> bytes:
return self.data
return self._packed
@classmethod
def from_data(cls, name: str, data: (str | bytes | None)) -> "Edid":
@ -101,14 +107,14 @@ class Edid:
return Edid(name, b"\x00" * 256)
if isinstance(data, bytes):
if data.startswith(cls.__HEADER):
if ParsedEdid.is_header_valid(cls.data):
return Edid(name, data) # Бинарный едид
data_hex = data.decode() # Текстовый едид, прочитанный как бинарный из файла
else: # isinstance(data, str)
data_hex = str(data) # Текстовый едид
data_hex = re.sub(r"\s", "", data_hex)
assert len(data_hex) == 512
assert len(data_hex) in [256, 512]
data = bytes([
int(data_hex[index:index + 2], 16)
for index in range(0, len(data_hex), 2)
@ -275,6 +281,19 @@ class _PortsDict(Generic[_T]):
else:
self.kvs[port] = value
def __eq__(self, other: object) -> bool:
if not isinstance(other, self.__class__):
return False
return (self.kvs == other.kvs)
class Dummies(_PortsDict[bool]):
def __init__(self, kvs: dict[int, bool]) -> None:
super().__init__(True, kvs)
def copy(self) -> "Dummies":
return Dummies(self.kvs)
class PortNames(_PortsDict[str]):
def __init__(self, kvs: dict[int, str]) -> None:

View File

@ -0,0 +1,45 @@
# ========================================================================== #
# #
# 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.kvmd import KvmdClient
from ... import htclient
from .. import init
from .server import LocalHidServer
# =====
def main(argv: (list[str] | None)=None) -> None:
config = init(
prog="kvmd-localhid",
description=" Local HID to KVMD proxy",
check_run=True,
argv=argv,
)[2].localhid
user_agent = htclient.make_user_agent("KVMD-LocalHID")
LocalHidServer(
kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()),
).run()

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

152
kvmd/apps/localhid/hid.py Normal file
View File

@ -0,0 +1,152 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
from typing import Final
from typing import Generator
import evdev
from evdev import ecodes
# =====
class Hid: # pylint: disable=too-many-instance-attributes
KEY: Final[int] = 0
MOUSE_BUTTON: Final[int] = 1
MOUSE_REL: Final[int] = 2
MOUSE_WHEEL: Final[int] = 3
def __init__(self, path: str) -> None:
self.__device = evdev.InputDevice(path)
caps = self.__device.capabilities(absinfo=False)
syns = caps.get(ecodes.EV_SYN, [])
self.__has_syn = (ecodes.SYN_REPORT in syns)
leds = caps.get(ecodes.EV_LED, [])
self.__has_caps = (ecodes.LED_CAPSL in leds)
self.__has_scroll = (ecodes.LED_SCROLLL in leds)
self.__has_num = (ecodes.LED_NUML in leds)
keys = caps.get(ecodes.EV_KEY, [])
self.__has_keyboard = (
ecodes.KEY_LEFTCTRL in keys
or ecodes.KEY_RIGHTCTRL in keys
or ecodes.KEY_LEFTSHIFT in keys
or ecodes.KEY_RIGHTSHIFT in keys
)
rels = caps.get(ecodes.EV_REL, [])
self.__has_mouse_rel = (
ecodes.BTN_LEFT in keys
and ecodes.REL_X in rels
)
self.__grabbed = False
def is_suitable(self) -> bool:
return (self.__has_keyboard or self.__has_mouse_rel)
def set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
if self.__grabbed:
if self.__has_caps:
self.__device.set_led(ecodes.LED_CAPSL, caps)
if self.__has_scroll:
self.__device.set_led(ecodes.LED_SCROLLL, scroll)
if self.__has_num:
self.__device.set_led(ecodes.LED_NUML, num)
def set_grabbed(self, grabbed: bool) -> None:
if self.__grabbed != grabbed:
getattr(self.__device, ("grab" if grabbed else "ungrab"))()
self.__grabbed = grabbed
def close(self) -> None:
try:
self.__device.close()
except Exception:
pass
async def poll_to_queue(self, queue: asyncio.Queue[tuple[int, tuple]]) -> None:
def put(event: int, args: tuple) -> None:
queue.put_nowait((event, args))
move_x = move_y = 0
wheel_x = wheel_y = 0
async for event in self.__device.async_read_loop():
if not self.__grabbed:
# Клавиши перехватываются всегда для обработки хоткеев,
# всё остальное пропускается для экономии ресурсов.
if event.type == ecodes.EV_KEY and event.value != 2 and (event.code in ecodes.KEY):
put(self.KEY, (event.code, bool(event.value)))
continue
if event.type == ecodes.EV_REL:
match event.code:
case ecodes.REL_X:
move_x += event.value
case ecodes.REL_Y:
move_y += event.value
case ecodes.REL_HWHEEL:
wheel_x += event.value
case ecodes.REL_WHEEL:
wheel_y += event.value
if not self.__has_syn or event.type == ecodes.SYN_REPORT:
if move_x or move_y:
for xy in self.__splitted_deltas(move_x, move_y):
put(self.MOUSE_REL, xy)
move_x = move_y = 0
if wheel_x or wheel_y:
for xy in self.__splitted_deltas(wheel_x, wheel_y):
put(self.MOUSE_WHEEL, xy)
wheel_x = wheel_y = 0
elif event.type == ecodes.EV_KEY and event.value != 2:
if event.code in ecodes.KEY:
put(self.KEY, (event.code, bool(event.value)))
elif event.code in ecodes.BTN:
put(self.MOUSE_BUTTON, (event.code, bool(event.value)))
def __splitted_deltas(self, delta_x: int, delta_y: int) -> Generator[tuple[int, int], None, None]:
sign_x = (-1 if delta_x < 0 else 1)
sign_y = (-1 if delta_y < 0 else 1)
delta_x = abs(delta_x)
delta_y = abs(delta_y)
while delta_x > 0 or delta_y > 0:
dx = sign_x * max(min(delta_x, 127), 0)
dy = sign_y * max(min(delta_y, 127), 0)
yield (dx, dy)
delta_x -= 127
delta_y -= 127
def __str__(self) -> str:
info: list[str] = []
if self.__has_syn:
info.append("syn")
if self.__has_keyboard:
info.append("keyboard")
if self.__has_mouse_rel:
info.append("mouse_rel")
return f"Hid({self.__device.path!r}, {self.__device.name!r}, {self.__device.phys!r}, {', '.join(info)})"

178
kvmd/apps/localhid/multi.py Normal file
View File

@ -0,0 +1,178 @@
# ========================================================================== #
# #
# 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
import errno
from typing import AsyncGenerator
import pyudev
from ...logging import get_logger
from ... import aiotools
from .hid import Hid
# =====
def _udev_check(device: pyudev.Device) -> str:
props = device.properties
if props.get("ID_INPUT") == "1":
path = props.get("DEVNAME")
if isinstance(path, str) and path.startswith("/dev/input/event"):
return path
return ""
async def _follow_udev_hids() -> AsyncGenerator[tuple[bool, str], None]:
ctx = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(pyudev.Context())
monitor.filter_by(subsystem="input")
monitor.start()
fd = monitor.fileno()
read_event = asyncio.Event()
loop = asyncio.get_event_loop()
loop.add_reader(fd, read_event.set)
try:
for device in ctx.list_devices(subsystem="input"):
path = _udev_check(device)
if path:
yield (True, path)
while True:
await read_event.wait()
while True:
device = monitor.poll(0)
if device is None:
read_event.clear()
break
path = _udev_check(device)
if path:
if device.action == "add":
yield (True, path)
elif device.action == "remove":
yield (False, path)
finally:
loop.remove_reader(fd)
@dataclasses.dataclass
class _Worker:
task: asyncio.Task
hid: (Hid | None)
class MultiHid:
def __init__(self, queue: asyncio.Queue[tuple[int, tuple]]) -> None:
self.__queue = queue
self.__workers: dict[str, _Worker] = {}
self.__grabbed = True
self.__leds = (False, False, False)
async def run(self) -> None:
logger = get_logger(0)
logger.info("Starting UDEV loop ...")
try:
async for (added, path) in _follow_udev_hids():
if added:
await self.__add_worker(path)
else:
await self.__remove_worker(path)
finally:
logger.info("Cleanup ...")
await aiotools.shield_fg(self.__cleanup())
async def __cleanup(self) -> None:
for path in list(self.__workers):
await self.__remove_worker(path)
async def __add_worker(self, path: str) -> None:
if path in self.__workers:
await self.__remove_worker(path)
self.__workers[path] = _Worker(asyncio.create_task(self.__worker_task_loop(path)), None)
async def __remove_worker(self, path: str) -> None:
if path not in self.__workers:
return
try:
worker = self.__workers[path]
worker.task.cancel()
await asyncio.gather(worker.task, return_exceptions=True)
except Exception:
pass
finally:
self.__workers.pop(path, None)
async def __worker_task_loop(self, path: str) -> None:
logger = get_logger(0)
while True:
hid: (Hid | None) = None
try:
hid = Hid(path)
if not hid.is_suitable():
break
logger.info("Opened: %s", hid)
if self.__grabbed:
hid.set_grabbed(True)
hid.set_leds(*self.__leds)
self.__workers[path].hid = hid
await hid.poll_to_queue(self.__queue)
except Exception as ex:
if isinstance(ex, OSError) and ex.errno == errno.ENODEV: # pylint: disable=no-member
logger.info("Closed: %s", hid)
break
logger.exception("Unhandled exception while polling %s", hid)
await asyncio.sleep(5)
finally:
self.__workers[path].hid = None
if hid:
hid.close()
def is_grabbed(self) -> bool:
return self.__grabbed
async def set_grabbed(self, grabbed: bool) -> None:
await aiotools.run_async(self.__inner_set_grabbed, grabbed)
def __inner_set_grabbed(self, grabbed: bool) -> None:
if self.__grabbed != grabbed:
get_logger(0).info("Grabbing ..." if grabbed else "Ungrabbing ...")
self.__grabbed = grabbed
for worker in self.__workers.values():
if worker.hid:
worker.hid.set_grabbed(grabbed)
self.__inner_set_leds(*self.__leds)
async def set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
await aiotools.run_async(self.__inner_set_leds, caps, scroll, num)
def __inner_set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
self.__leds = (caps, scroll, num)
if self.__grabbed:
for worker in self.__workers.values():
if worker.hid:
worker.hid.set_leds(*self.__leds)

View File

@ -0,0 +1,192 @@
# ========================================================================== #
# #
# 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 errno
from typing import Callable
from typing import Coroutine
import aiohttp
import async_lru
from evdev import ecodes
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ...keyboard.magic import MagicHandler
from ...clients.kvmd import KvmdClient
from ...clients.kvmd import KvmdClientSession
from ...clients.kvmd import KvmdClientWs
from .hid import Hid
from .multi import MultiHid
# =====
class LocalHidServer: # pylint: disable=too-many-instance-attributes
def __init__(self, kvmd: KvmdClient) -> None:
self.__kvmd = kvmd
self.__kvmd_session: (KvmdClientSession | None) = None
self.__kvmd_ws: (KvmdClientWs | None) = None
self.__queue: asyncio.Queue[tuple[int, tuple]] = asyncio.Queue()
self.__hid = MultiHid(self.__queue)
self.__info_switch_units = 0
self.__info_switch_active = ""
self.__info_mouse_absolute = True
self.__info_mouse_outputs: list[str] = []
self.__magic = MagicHandler(
proxy_handler=self.__on_magic_key_proxy,
key_handlers={
ecodes.KEY_H: self.__on_magic_grab,
ecodes.KEY_K: self.__on_magic_ungrab,
ecodes.KEY_UP: self.__on_magic_switch_prev,
ecodes.KEY_LEFT: self.__on_magic_switch_prev,
ecodes.KEY_DOWN: self.__on_magic_switch_next,
ecodes.KEY_RIGHT: self.__on_magic_switch_next,
},
numeric_handler=self.__on_magic_switch_port,
)
def run(self) -> None:
try:
aiotools.run(self.__inner_run())
finally:
get_logger(0).info("Bye-bye")
async def __inner_run(self) -> None:
await aiotools.spawn_and_follow(
self.__create_loop(self.__hid.run),
self.__create_loop(self.__queue_worker),
self.__create_loop(self.__api_worker),
)
async def __create_loop(self, func: Callable[[], Coroutine]) -> None:
while True:
try:
await func()
except Exception as ex:
if isinstance(ex, OSError) and ex.errno == errno.ENODEV: # pylint: disable=no-member
pass # Device disconnected
elif isinstance(ex, aiohttp.ClientError):
get_logger(0).error("KVMD client error: %s", tools.efmt(ex))
else:
get_logger(0).exception("Unhandled exception in the loop: %s", func)
await asyncio.sleep(5)
async def __queue_worker(self) -> None:
while True:
(event, args) = await self.__queue.get()
if event == Hid.KEY:
await self.__magic.handle_key(*args)
continue
elif self.__hid.is_grabbed() and self.__kvmd_session and self.__kvmd_ws:
match event:
case Hid.MOUSE_BUTTON:
await self.__kvmd_ws.send_mouse_button_event(*args)
case Hid.MOUSE_REL:
await self.__ensure_mouse_relative()
await self.__kvmd_ws.send_mouse_relative_event(*args)
case Hid.MOUSE_WHEEL:
await self.__kvmd_ws.send_mouse_wheel_event(*args)
async def __api_worker(self) -> None:
logger = get_logger(0)
async with self.__kvmd.make_session() as session:
async with session.ws(stream=False) as ws:
logger.info("KVMD session opened")
self.__kvmd_session = session
self.__kvmd_ws = ws
try:
async for (event_type, event) in ws.communicate():
if event_type == "hid":
if "leds" in event.get("keyboard", {}):
await self.__hid.set_leds(**event["keyboard"]["leds"])
if "absolute" in event.get("mouse", {}):
self.__info_mouse_outputs = event["mouse"]["outputs"]["available"]
self.__info_mouse_absolute = event["mouse"]["absolute"]
elif event_type == "switch":
if "model" in event:
self.__info_switch_units = len(event["model"]["units"])
if "summary" in event:
self.__info_switch_active = event["summary"]["active_id"]
finally:
logger.info("KVMD session closed")
self.__kvmd_session = None
self.__kvmd_ws = None
# =====
async def __ensure_mouse_relative(self) -> None:
if self.__info_mouse_absolute:
# Avoid unnecessary LRU checks, just to speed up a bit
await self.__inner_ensure_mouse_relative()
@async_lru.alru_cache(maxsize=1, ttl=1)
async def __inner_ensure_mouse_relative(self) -> None:
if self.__kvmd_session and self.__info_mouse_absolute:
for output in ["usb_rel", "ps2"]:
if output in self.__info_mouse_outputs:
await self.__kvmd_session.hid.set_params(mouse_output=output)
async def __on_magic_key_proxy(self, key: int, state: bool) -> None:
if self.__hid.is_grabbed() and self.__kvmd_ws:
await self.__kvmd_ws.send_key_event(key, state)
async def __on_magic_grab(self) -> None:
await self.__hid.set_grabbed(True)
async def __on_magic_ungrab(self) -> None:
await self.__hid.set_grabbed(False)
async def __on_magic_switch_prev(self) -> None:
if self.__kvmd_session and self.__info_switch_units > 0:
get_logger(0).info("Switching port to the previous one ...")
await self.__kvmd_session.switch.set_active_prev()
async def __on_magic_switch_next(self) -> None:
if self.__kvmd_session and self.__info_switch_units > 0:
get_logger(0).info("Switching port to the next one ...")
await self.__kvmd_session.switch.set_active_next()
async def __on_magic_switch_port(self, codes: list[int]) -> bool:
assert len(codes) > 0
if self.__info_switch_units <= 0:
return True
elif 1 <= self.__info_switch_units <= 2:
port = float(codes[0])
else: # self.__info_switch_units > 2:
if len(codes) == 1:
return False # Wait for the second key
port = (codes[0] + 1) + (codes[1] + 1) / 10
if self.__kvmd_session:
get_logger(0).info("Switching port to %s ...", port)
await self.__kvmd_session.switch.set_active(port)
return True

View File

@ -52,6 +52,9 @@ class _Source:
clients: dict[WsSession, "_Client"] = dataclasses.field(default_factory=dict)
key_required: bool = dataclasses.field(default=False)
def is_diff(self) -> bool:
return StreamerFormats.is_diff(self.streamer.get_format())
@dataclasses.dataclass
class _Client:
@ -98,6 +101,14 @@ class MediaServer(HttpServer):
async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None:
await ws.send_bin(255, b"") # Ping-pong
@exposed_ws(1)
async def __ws_bin_key_handler(self, ws: WsSession, _: bytes) -> None:
for src in self.__srcs:
if ws in src.clients:
if src.is_diff():
src.key_required = True
break
@exposed_ws("start")
async def __ws_start_handler(self, ws: WsSession, event: dict) -> None:
try:
@ -145,7 +156,7 @@ class MediaServer(HttpServer):
# =====
async def __sender(self, client: _Client) -> None:
need_key = StreamerFormats.is_diff(client.src.streamer.get_format())
need_key = client.src.is_diff()
if need_key:
client.src.key_required = True
has_key = False

View File

@ -50,8 +50,12 @@ def main(argv: (list[str] | None)=None) -> None:
template = in_file.read()
rendered = mako.template.Template(template).render(
http_ipv4=config.nginx.http.ipv4,
http_ipv6=config.nginx.http.ipv6,
http_port=config.nginx.http.port,
https_enabled=config.nginx.https.enabled,
https_ipv4=config.nginx.https.ipv4,
https_ipv6=config.nginx.https.ipv6,
https_port=config.nginx.https.port,
ipv6_enabled=network.is_ipv6_enabled(),
)

View File

@ -78,6 +78,7 @@ def main() -> None: # pylint: disable=too-many-locals,too-many-branches,too-man
parser.add_argument("--image", default="", type=(lambda arg: _get_data_path("pics", arg)), help="Display some image, wait a single interval and exit")
parser.add_argument("--text", default="", help="Display some text, wait a single interval and exit")
parser.add_argument("--pipe", action="store_true", help="Read and display lines from stdin until EOF, wait a single interval and exit")
parser.add_argument("--fill", action="store_true", help="Fill the display with 0xFF")
parser.add_argument("--clear-on-exit", action="store_true", help="Clear display on exit")
parser.add_argument("--contrast", default=64, type=int, help="Set OLED contrast, values from 0 to 255")
parser.add_argument("--fahrenheit", action="store_true", help="Display temperature in Fahrenheit instead of Celsius")
@ -121,6 +122,9 @@ def main() -> None: # pylint: disable=too-many-locals,too-many-branches,too-man
text = ""
time.sleep(options.interval)
elif options.fill:
screen.draw_white()
else:
stop_reason: (str | None) = None

View File

@ -52,3 +52,7 @@ class Screen:
def draw_image(self, image_path: str) -> None:
with luma_canvas(self.__device) as draw:
draw.bitmap(self.__offset, Image.open(image_path).convert("1"), fill="white")
def draw_white(self) -> None:
with luma_canvas(self.__device) as draw:
draw.rectangle((0, 0, self.__device.width, self.__device.height), fill="white")

View File

@ -291,8 +291,9 @@ def _cmd_start(config: Section) -> None: # pylint: disable=too-many-statements,
profile_path = join(gadget_path, usb.G_PROFILE)
_mkdir(profile_path)
_mkdir(join(profile_path, "strings/0x409"))
_write(join(profile_path, "strings/0x409/configuration"), f"Config 1: {config.otg.config}")
if config.otg.config:
_mkdir(join(profile_path, "strings/0x409"))
_write(join(profile_path, "strings/0x409/configuration"), config.otg.config)
_write(join(profile_path, "MaxPower"), config.otg.max_power)
if config.otg.remote_wakeup:
# XXX: Should we use MaxPower=100 with Remote Wakeup?

View File

@ -45,6 +45,7 @@ from .netctl import IptablesAllowIcmpCtl
from .netctl import IptablesAllowPortCtl
from .netctl import IptablesForwardOut
from .netctl import IptablesForwardIn
from .netctl import SysctlIpv4ForwardCtl
from .netctl import CustomCtl
@ -63,14 +64,16 @@ class _Netcfg: # pylint: disable=too-many-instance-attributes
class _Service: # pylint: disable=too-many-instance-attributes
def __init__(self, config: Section) -> None:
self.__ip_cmd: list[str] = config.otgnet.commands.ip_cmd
self.__iptables_cmd: list[str] = config.otgnet.commands.iptables_cmd
self.__sysctl_cmd: list[str] = config.otgnet.commands.sysctl_cmd
self.__iface_net: str = config.otgnet.iface.net
self.__ip_cmd: list[str] = config.otgnet.iface.ip_cmd
self.__allow_icmp: bool = config.otgnet.firewall.allow_icmp
self.__allow_tcp: list[int] = sorted(set(config.otgnet.firewall.allow_tcp))
self.__allow_udp: list[int] = sorted(set(config.otgnet.firewall.allow_udp))
self.__forward_iface: str = config.otgnet.firewall.forward_iface
self.__iptables_cmd: list[str] = config.otgnet.firewall.iptables_cmd
def build_cmd(key: str) -> list[str]:
return tools.build_cmd(
@ -115,6 +118,7 @@ class _Service: # pylint: disable=too-many-instance-attributes
*([IptablesForwardIn(self.__iptables_cmd, netcfg.iface)] if self.__forward_iface else []),
IptablesDropAllCtl(self.__iptables_cmd, netcfg.iface),
IfaceAddIpCtl(self.__ip_cmd, netcfg.iface, f"{netcfg.iface_ip}/{netcfg.net_prefix}"),
*([SysctlIpv4ForwardCtl(self.__sysctl_cmd)] if self.__forward_iface else []),
CustomCtl(self.__post_start_cmd, self.__pre_stop_cmd, placeholders),
]
if direct:
@ -130,6 +134,8 @@ class _Service: # pylint: disable=too-many-instance-attributes
async def __run_ctl(self, ctl: BaseCtl, direct: bool) -> bool:
logger = get_logger()
cmd = ctl.get_command(direct)
if not cmd:
return True
logger.info("CMD: %s", tools.cmdfmt(cmd))
try:
return (not (await aioproc.log_process(cmd, logger)).returncode)

View File

@ -121,6 +121,16 @@ class IptablesForwardIn(BaseCtl):
]
class SysctlIpv4ForwardCtl(BaseCtl):
def __init__(self, base_cmd: list[str]) -> None:
self.__base_cmd = base_cmd
def get_command(self, direct: bool) -> list[str]:
if direct:
return [*self.__base_cmd, "net.ipv4.ip_forward=1"]
return [] # Don't revert the command because some services can require it too
class CustomCtl(BaseCtl):
def __init__(
self,

View File

@ -66,22 +66,22 @@ async def _run_process(cmd: list[str], data_path: str) -> asyncio.subprocess.Pro
async def _run_cmd_ws(cmd: list[str], ws: aiohttp.ClientWebSocketResponse) -> int: # pylint: disable=too-many-branches
logger = get_logger(0)
receive_task: (asyncio.Task | None) = None
recv_task: (asyncio.Task | None) = None
proc_task: (asyncio.Task | None) = None
proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
try: # pylint: disable=too-many-nested-blocks
while True:
if receive_task is None:
receive_task = asyncio.create_task(ws.receive())
if recv_task is None:
recv_task = asyncio.create_task(ws.receive())
if proc_task is None and proc is not None:
proc_task = asyncio.create_task(proc.wait())
tasks = list(filter(None, [receive_task, proc_task]))
tasks = list(filter(None, [recv_task, proc_task]))
done = (await aiotools.wait_first(*tasks))[0]
if receive_task in done:
msg = receive_task.result()
if recv_task in done:
msg = recv_task.result()
if msg.type == aiohttp.WSMsgType.TEXT:
(event_type, event) = htserver.parse_ws_event(msg.data)
if event_type == "storage":
@ -98,15 +98,15 @@ async def _run_cmd_ws(cmd: list[str], ws: aiohttp.ClientWebSocketResponse) -> in
else:
logger.error("Unknown PST message type: %r", msg)
break
receive_task = None
recv_task = None
if proc_task in done:
break
except Exception:
logger.exception("Unhandled exception")
if receive_task is not None:
receive_task.cancel()
if recv_task is not None:
recv_task.cancel()
if proc_task is not None:
proc_task.cancel()
if proc is not None:

View File

@ -30,7 +30,6 @@ from ... import htclient
from .. import init
from .vncauth import VncAuthManager
from .server import VncServer
@ -71,12 +70,12 @@ def main(argv: (list[str] | None)=None) -> None:
desired_fps=config.desired_fps,
mouse_output=config.mouse_output,
keymap_path=config.keymap,
allow_cut_after=config.allow_cut_after,
scroll_rate=config.scroll_rate,
kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()),
streamers=streamers,
vnc_auth_manager=VncAuthManager(**config.auth.vncauth._unpack()),
**config.server.keepalive._unpack(),
**config.auth.vncauth._unpack(),
**config.auth.vencrypt._unpack(),
).run()

View File

@ -22,17 +22,22 @@
import asyncio
import ssl
import time
from typing import Callable
from typing import Coroutine
from typing import AsyncGenerator
from evdev import ecodes
from ....logging import get_logger
from .... import tools
from .... import aiotools
from ....keyboard.keysym import SymmapModifiers
from ....keyboard.mappings import EvdevModifiers
from ....keyboard.mappings import X11Modifiers
from ....keyboard.mappings import AT1_TO_EVDEV
from ....mouse import MouseRange
from .errors import RfbError
@ -47,6 +52,11 @@ from .crypto import rfb_encrypt_challenge
from .stream import RfbClientStream
# =====
class _SecurityError(Exception):
pass
# =====
class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attributes
# https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst
@ -65,8 +75,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
width: int,
height: int,
name: str,
allow_cut_after: float,
vnc_passwds: list[str],
symmap: dict[int, dict[int, int]],
scroll_rate: int,
vncpasses: set[str],
vencrypt: bool,
none_auth_only: bool,
) -> None:
@ -81,8 +93,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
self._width = width
self._height = height
self.__name = name
self.__allow_cut_after = allow_cut_after
self.__vnc_passwds = vnc_passwds
self.__scroll_rate = scroll_rate
self.__symmap = symmap
self.__vncpasses = vncpasses
self.__vencrypt = vencrypt
self.__none_auth_only = none_auth_only
@ -93,10 +107,16 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
self.__fb_cont_updates = False
self.__fb_reset_h264 = False
self.__allow_cut_since_ts = 0.0
self.__authorized = False
self.__lock = asyncio.Lock()
# Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события.
# Все это нужно только чтобы не посылать лишние события в сокет KVMD
self.__modifiers = 0
self.__mouse_buttons: dict[int, bool] = {}
self.__mouse_move = (-1, -1, -1, -1) # (width, height, X, Y)
# =====
async def _run(self, **coros: Coroutine) -> None:
@ -135,6 +155,8 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def __main_task_loop(self) -> None:
await self.__handshake_version()
await self.__handshake_security()
if not self.__authorized:
raise _SecurityError()
await self.__handshake_init()
await self.__main_loop()
@ -143,21 +165,24 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def _authorize_userpass(self, user: str, passwd: str) -> bool:
raise NotImplementedError
async def _on_authorized_vnc_passwd(self, passwd: str) -> str:
async def _on_authorized_vncpass(self) -> None:
raise NotImplementedError
async def _on_authorized_none(self) -> bool:
async def _authorize_none(self) -> bool:
raise NotImplementedError
# =====
async def _on_key_event(self, code: int, state: bool) -> None:
async def _on_key_event(self, key: int, state: bool) -> None:
raise NotImplementedError
async def _on_ext_key_event(self, code: int, state: bool) -> None:
async def _on_mouse_button_event(self, button: int, state: bool) -> None:
raise NotImplementedError
async def _on_pointer_event(self, buttons: dict[str, bool], wheel: dict[str, int], move: dict[str, int]) -> None:
async def _on_mouse_move_event(self, to_x: int, to_y: int) -> None:
raise NotImplementedError
async def _on_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None:
raise NotImplementedError
async def _on_cut_event(self, text: str) -> None:
@ -235,18 +260,18 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
await self._write_struct("handshake server version", "", b"RFB 003.008\n")
response = await self._read_text("handshake client version", 12)
resp = await self._read_text("handshake client version", 12)
if (
not response.startswith("RFB 003.00")
or not response.endswith("\n")
or response[-2] not in ["3", "5", "7", "8"]
not resp.startswith("RFB 003.00")
or not resp.endswith("\n")
or resp[-2] not in ["3", "5", "7", "8"]
):
raise RfbError(f"Invalid version response: {response!r}")
raise RfbError(f"Invalid version response: {resp!r}")
try:
version = int(response[-2])
version = int(resp[-2])
except ValueError:
raise RfbError(f"Invalid version response: {response!r}")
raise RfbError(f"Invalid version response: {resp!r}")
self.__rfb_version = (3 if version == 5 else version)
get_logger(0).info("%s [main]: Using RFB version 3.%d", self._remote, self.__rfb_version)
@ -258,7 +283,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
sec_types[19] = ("VeNCrypt", self.__handshake_security_vencrypt)
if self.__none_auth_only:
sec_types[1] = ("None", self.__handshake_security_none)
elif self.__vnc_passwds:
elif self.__vncpasses:
sec_types[2] = ("VNCAuth", self.__handshake_security_vnc_auth)
if not sec_types:
@ -304,7 +329,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
if self.__x509_cert_path:
auth_types[262] = ("VeNCrypt/X509Plain", 2, self.__handshake_security_vencrypt_userpass)
auth_types[259] = ("VeNCrypt/TLSPlain", 1, self.__handshake_security_vencrypt_userpass)
if self.__vnc_passwds:
if self.__vncpasses:
# Некоторые клиенты не умеют работать с нешифрованными соединениями внутри VeNCrypt:
# - https://github.com/LibVNC/libvncserver/issues/458
# - https://bugzilla.redhat.com/show_bug.cgi?id=692048
@ -354,7 +379,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
)
async def __handshake_security_none(self) -> None:
allow = await self._on_authorized_none()
allow = await self._authorize_none()
await self.__handshake_security_send_result(
allow=allow,
allow_msg="NoneAuth access granted",
@ -366,20 +391,19 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
challenge = rfb_make_challenge()
await self._write_struct("VNCAuth challenge request", "", challenge)
user = ""
allow = False
response = (await self._read_struct("VNCAuth challenge response", "16s"))[0]
for passwd in self.__vnc_passwds:
for passwd in self.__vncpasses:
passwd_bytes = passwd.encode("utf-8", errors="ignore")
if rfb_encrypt_challenge(challenge, passwd_bytes) == response:
user = await self._on_authorized_vnc_passwd(passwd)
if user:
assert user == user.strip()
await self._on_authorized_vncpass()
allow = True
break
await self.__handshake_security_send_result(
allow=bool(user),
allow_msg=f"VNCAuth access granted for user {user!r}",
deny_msg="VNCAuth access denied (user not found)",
allow=allow,
allow_msg="VNCAuth access granted",
deny_msg="VNCAuth access denied (passwd not found)",
deny_reason="Invalid password",
)
@ -387,6 +411,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
if allow:
get_logger(0).info("%s [main]: %s", self._remote, allow_msg)
await self._write_struct("access OK", "L", 0)
self.__authorized = True
else:
await self._write_struct("access denial flag", "L", 1, drain=(self.__rfb_version < 8))
if self.__rfb_version >= 8:
@ -396,6 +421,9 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
# =====
async def __handshake_init(self) -> None:
if not self.__authorized:
raise _SecurityError()
await self._read_number("initial shared flag", "B") # Shared flag, ignored
await self._write_struct("initial FB size", "HH", self._width, self._height, drain=False)
@ -419,7 +447,8 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
# =====
async def __main_loop(self) -> None:
self.__allow_cut_since_ts = time.monotonic() + self.__allow_cut_after
if not self.__authorized:
raise _SecurityError()
handlers = {
0: self.__handle_set_pixel_format,
2: self.__handle_set_encodings,
@ -486,40 +515,101 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def __handle_key_event(self) -> None:
(state, code) = await self._read_struct("key event", "? xx L")
await self._on_key_event(code, state) # type: ignore
state = bool(state)
is_modifier = self.__switch_modifiers_x11(code, state)
variants = self.__symmap.get(code)
fake_shift = False
if variants:
if is_modifier:
key = variants.get(0)
else:
key = variants.get(self.__modifiers)
if key is None:
key = variants.get(0)
if key is None and self.__modifiers == 0 and SymmapModifiers.SHIFT in variants:
# JUMP doesn't send shift events:
# - https://github.com/pikvm/pikvm/issues/820
key = variants[SymmapModifiers.SHIFT]
fake_shift = True
if key:
if fake_shift:
await self._on_key_event(EvdevModifiers.SHIFT_LEFT, True)
await self._on_key_event(key, state)
if fake_shift:
await self._on_key_event(EvdevModifiers.SHIFT_LEFT, False)
def __switch_modifiers_x11(self, code: int, state: bool) -> bool:
mod = 0
if code in X11Modifiers.SHIFTS:
mod = SymmapModifiers.SHIFT
elif code == X11Modifiers.ALTGR:
mod = SymmapModifiers.ALTGR
elif code in X11Modifiers.CTRLS:
mod = SymmapModifiers.CTRL
if mod == 0:
return False
if state:
self.__modifiers |= mod
else:
self.__modifiers &= ~mod
return True
def __switch_modifiers_evdev(self, key: int, state: bool) -> bool:
mod = 0
if key in EvdevModifiers.SHIFTS:
mod = SymmapModifiers.SHIFT
elif key == EvdevModifiers.ALT_RIGHT:
mod = SymmapModifiers.ALTGR
elif key in EvdevModifiers.CTRLS:
mod = SymmapModifiers.CTRL
if mod == 0:
return False
if state:
self.__modifiers |= mod
else:
self.__modifiers &= ~mod
return True
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)),
"y": (-4 if buttons & 0x10 else (4 if buttons & 0x8 else 0)),
},
move={
"x": tools.remap(to_x, 0, self._width, *MouseRange.RANGE),
"y": tools.remap(to_y, 0, self._height, *MouseRange.RANGE),
},
)
if buttons & (0x40 | 0x20 | 0x10 | 0x08):
sr = self.__scroll_rate
await self._on_mouse_wheel_event(
(-sr if buttons & 0x40 else (sr if buttons & 0x20 else 0)),
(-sr if buttons & 0x10 else (sr if buttons & 0x08 else 0)),
)
move = (self._width, self._height, to_x, to_y)
if self.__mouse_move != move:
await self._on_mouse_move_event(
tools.remap(to_x, 0, self._width - 1, *MouseRange.RANGE),
tools.remap(to_y, 0, self._height - 1, *MouseRange.RANGE),
)
self.__mouse_move = move
for (code, state) in [
(ecodes.BTN_LEFT, bool(buttons & 0x1)),
(ecodes.BTN_RIGHT, bool(buttons & 0x4)),
(ecodes.BTN_MIDDLE, bool(buttons & 0x2)),
(ecodes.BTN_BACK, bool(ext_buttons & 0x2)),
(ecodes.BTN_FORWARD, bool(ext_buttons & 0x1)),
]:
if self.__mouse_buttons.get(code) != state:
await self._on_mouse_button_event(code, state)
self.__mouse_buttons[code] = state
async def __handle_client_cut_text(self) -> None:
length = (await self._read_struct("cut text length", "xxx L"))[0]
text = await self._read_text("cut text data", length)
if self.__allow_cut_since_ts > 0 and time.monotonic() >= self.__allow_cut_since_ts:
# We should ignore cut event a few seconds after handshake
# because bVNC, AVNC and maybe some other clients perform
# it right after the connection automatically.
# - https://github.com/pikvm/pikvm/issues/1420
await self._on_cut_event(text)
await self._on_cut_event(text)
async def __handle_enable_cont_updates(self) -> None:
enabled = bool((await self._read_struct("enabled ContUpdates", "B HH HH"))[0])
@ -532,6 +622,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
async def __handle_qemu_event(self) -> None:
(sub_type, state, code) = await self._read_struct("QEMU event (key?)", "B H xxxx L")
state = bool(state)
if sub_type != 0:
raise RfbError(f"Invalid QEMU sub-message type: {sub_type}")
if code == 0xB7:
@ -539,4 +630,7 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute
code = 0x54
if code & 0x80:
code = (0xE0 << 8) | (code & ~0x80)
await self._on_ext_key_event(code, bool(state))
key = AT1_TO_EVDEV.get(code, 0)
if key:
self.__switch_modifiers_evdev(key, state) # Предполагаем, что модификаторы всегда известны
await self._on_key_event(key, state)

View File

@ -110,32 +110,13 @@ class RfbClientStream:
# =====
async def _start_tls(self, ssl_context: ssl.SSLContext, ssl_timeout: float) -> None:
loop = asyncio.get_event_loop()
ssl_reader = asyncio.StreamReader()
protocol = asyncio.StreamReaderProtocol(ssl_reader)
try:
transport = await loop.start_tls(
self.__writer.transport,
protocol,
await self.__writer.start_tls(
ssl_context,
server_side=True,
ssl_handshake_timeout=ssl_timeout,
)
except ConnectionError as ex:
raise RfbConnectionError("Can't start TLS", ex)
ssl_reader.set_transport(transport) # type: ignore
ssl_writer = asyncio.StreamWriter(
transport=transport, # type: ignore
protocol=protocol,
reader=ssl_reader,
loop=loop,
)
self.__reader = ssl_reader
self.__writer = ssl_writer
async def _close(self) -> None:
await aiotools.close_writer(self.__writer)

View File

@ -27,14 +27,14 @@ import dataclasses
import contextlib
import aiohttp
import async_lru
from evdev import ecodes
from ...logging import get_logger
from ...keyboard.keysym import SymmapModifiers
from ...keyboard.keysym import build_symmap
from ...keyboard.mappings import WebModifiers
from ...keyboard.mappings import X11Modifiers
from ...keyboard.mappings import AT1_TO_WEB
from ...keyboard.magic import MagicHandler
from ...clients.kvmd import KvmdClientWs
from ...clients.kvmd import KvmdClientSession
@ -53,9 +53,6 @@ from .rfb import RfbClient
from .rfb.stream import rfb_format_remote
from .rfb.errors import RfbError
from .vncauth import VncAuthKvmdCredentials
from .vncauth import VncAuthManager
from .render import make_text_jpeg
@ -80,29 +77,30 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
desired_fps: int,
mouse_output: str,
keymap_name: str,
symmap: dict[int, dict[int, str]],
allow_cut_after: float,
symmap: dict[int, dict[int, int]],
scroll_rate: int,
kvmd: KvmdClient,
streamers: list[BaseStreamerClient],
vnc_credentials: dict[str, VncAuthKvmdCredentials],
vncpasses: set[str],
vencrypt: bool,
none_auth_only: bool,
shared_params: _SharedParams,
) -> None:
self.__vnc_credentials = vnc_credentials
super().__init__(
RfbClient.__init__(
self,
reader=reader,
writer=writer,
tls_ciphers=tls_ciphers,
tls_timeout=tls_timeout,
x509_cert_path=x509_cert_path,
x509_key_path=x509_key_path,
allow_cut_after=allow_cut_after,
vnc_passwds=list(vnc_credentials),
symmap=symmap,
scroll_rate=scroll_rate,
vncpasses=vncpasses,
vencrypt=vencrypt,
none_auth_only=none_auth_only,
**dataclasses.asdict(shared_params),
@ -111,7 +109,6 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
self.__desired_fps = desired_fps
self.__mouse_output = mouse_output
self.__keymap_name = keymap_name
self.__symmap = symmap
self.__kvmd = kvmd
self.__streamers = streamers
@ -128,12 +125,23 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
self.__fb_queue: "asyncio.Queue[dict]" = asyncio.Queue()
self.__fb_has_key = False
# Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события.
# Все это нужно только чтобы не посылать лишние жсоны в сокет KVMD
self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle", "up", "down"], None)
self.__mouse_move = {"x": -1, "y": -1}
self.__clipboard = ""
self.__modifiers = 0
self.__info_host = ""
self.__info_switch_units = 0
self.__info_switch_active = ""
self.__magic = MagicHandler(
proxy_handler=self.__on_magic_key_proxy,
key_handlers={
ecodes.KEY_P: self.__on_magic_clipboard_print,
ecodes.KEY_UP: self.__on_magic_switch_prev,
ecodes.KEY_LEFT: self.__on_magic_switch_prev,
ecodes.KEY_DOWN: self.__on_magic_switch_next,
ecodes.KEY_RIGHT: self.__on_magic_switch_next,
},
numeric_handler=self.__on_magic_switch_port,
)
# =====
@ -179,16 +187,22 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
async def __process_ws_event(self, event_type: str, event: dict) -> None:
if event_type == "info":
if "meta" in event:
host = ""
try:
host = event["meta"]["server"]["host"]
if isinstance(event["meta"]["server"]["host"], str):
host = event["meta"]["server"]["host"].strip()
except Exception:
host = None
else:
if isinstance(host, str):
name = f"PiKVM: {host}"
if self._encodings.has_rename:
await self._send_rename(name)
self.__shared_params.name = name
pass
self.__info_host = host
await self.__update_info()
elif event_type == "switch":
if "model" in event:
self.__info_switch_units = len(event["model"]["units"])
if "summary" in event:
self.__info_switch_active = event["summary"]["active_id"]
if "model" in event or "summary" in event:
await self.__update_info()
elif event_type == "hid":
if (
@ -198,6 +212,17 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
):
await self._send_leds_state(**event["keyboard"]["leds"])
async def __update_info(self) -> None:
info: list[str] = []
if self.__info_switch_units > 0:
info.append("Port " + (self.__info_switch_active or "not selected"))
if self.__info_host:
info.append(self.__info_host)
info.append("PiKVM")
self.__shared_params.name = " | ".join(info)
if self._encodings.has_rename:
await self._send_rename(self.__shared_params.name)
# =====
async def __streamer_task_loop(self) -> None:
@ -213,10 +238,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
if not streaming:
logger.info("%s [streamer]: Streaming ...", self._remote)
streaming = True
if frame["online"]:
await self.__queue_frame(frame)
else:
await self.__queue_frame("No signal")
await self.__queue_frame(frame)
except StreamerError as ex:
if isinstance(ex, StreamerPermError):
streamer = self.__get_default_streamer()
@ -317,98 +339,91 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes
# =====
async def _authorize_userpass(self, user: str, passwd: str) -> bool:
self.__kvmd_session = self.__kvmd.make_session(user, passwd)
if (await self.__kvmd_session.auth.check()):
self.__kvmd_session = self.__kvmd.make_session()
if (await self.__kvmd_session.auth.check(user, passwd)):
self.__stage1_authorized.set_passed()
return True
return False
async def _on_authorized_vnc_passwd(self, passwd: str) -> str:
kc = self.__vnc_credentials[passwd]
if (await self._authorize_userpass(kc.user, kc.passwd)):
return kc.user
return ""
async def _on_authorized_vncpass(self) -> None:
self.__kvmd_session = self.__kvmd.make_session()
self.__stage1_authorized.set_passed()
async def _on_authorized_none(self) -> bool:
async def _authorize_none(self) -> bool:
return (await self._authorize_userpass("", ""))
# =====
async def _on_key_event(self, code: int, state: bool) -> None:
is_modifier = self.__switch_modifiers(code, state)
variants = self.__symmap.get(code)
fake_shift = False
async def _on_key_event(self, key: int, state: bool) -> None:
assert self.__stage1_authorized.is_passed()
await self.__magic.handle_key(key, state)
if variants:
if is_modifier:
web_key = variants.get(0)
else:
web_key = variants.get(self.__modifiers)
if web_key is None:
web_key = variants.get(0)
async def __on_magic_switch_prev(self) -> None:
assert self.__kvmd_session
if self.__info_switch_units > 0:
get_logger(0).info("%s [main]: Switching port to the previous one ...", self._remote)
await self.__kvmd_session.switch.set_active_prev()
if web_key is None and self.__modifiers == 0 and SymmapModifiers.SHIFT in variants:
# JUMP doesn't send shift events:
# - https://github.com/pikvm/pikvm/issues/820
web_key = variants[SymmapModifiers.SHIFT]
fake_shift = True
async def __on_magic_switch_next(self) -> None:
assert self.__kvmd_session
if self.__info_switch_units > 0:
get_logger(0).info("%s [main]: Switching port to the next one ...", self._remote)
await self.__kvmd_session.switch.set_active_next()
if web_key and self.__kvmd_ws:
if fake_shift:
await self.__kvmd_ws.send_key_event(WebModifiers.SHIFT_LEFT, True)
await self.__kvmd_ws.send_key_event(web_key, state)
if fake_shift:
await self.__kvmd_ws.send_key_event(WebModifiers.SHIFT_LEFT, False)
async def _on_ext_key_event(self, code: int, state: bool) -> None:
web_key = AT1_TO_WEB.get(code)
if web_key:
self.__switch_modifiers(web_key, state) # Предполагаем, что модификаторы всегда известны
if self.__kvmd_ws:
await self.__kvmd_ws.send_key_event(web_key, state)
def __switch_modifiers(self, key: (int | str), state: bool) -> bool:
mod = 0
if key in X11Modifiers.SHIFTS or key in WebModifiers.SHIFTS:
mod = SymmapModifiers.SHIFT
elif key == X11Modifiers.ALTGR or key == WebModifiers.ALT_RIGHT:
mod = SymmapModifiers.ALTGR
elif key in X11Modifiers.CTRLS or key in WebModifiers.CTRLS:
mod = SymmapModifiers.CTRL
if mod == 0:
return False
if state:
self.__modifiers |= mod
else:
self.__modifiers &= ~mod
async def __on_magic_switch_port(self, codes: list[int]) -> bool:
assert self.__kvmd_session
assert len(codes) > 0
if self.__info_switch_units <= 0:
return True
elif 1 <= self.__info_switch_units <= 2:
port = float(codes[0])
else: # self.__info_switch_units > 2:
if len(codes) == 1:
return False # Wait for the second key
port = (codes[0] + 1) + (codes[1] + 1) / 10
get_logger(0).info("%s [main]: Switching port to %s ...", self._remote, port)
await self.__kvmd_session.switch.set_active(port)
return True
async def _on_pointer_event(self, buttons: dict[str, bool], wheel: dict[str, int], move: dict[str, int]) -> None:
async def __on_magic_clipboard_print(self) -> None:
assert self.__kvmd_session
if self.__clipboard:
logger = get_logger(0)
logger.info("%s [main]: Printing %d characters ...", self._remote, len(self.__clipboard))
try:
(keymap_name, available) = await self.__kvmd_session.hid.get_keymaps()
if self.__keymap_name in available:
keymap_name = self.__keymap_name
await self.__kvmd_session.hid.print(self.__clipboard, 0, keymap_name)
except Exception:
logger.exception("%s [main]: Can't print characters", self._remote)
async def __on_magic_key_proxy(self, key: int, state: bool) -> None:
if self.__kvmd_ws:
if wheel["x"] or wheel["y"]:
await self.__kvmd_ws.send_mouse_wheel_event(wheel["x"], wheel["y"])
await self.__kvmd_ws.send_key_event(key, state)
if self.__mouse_move != move:
await self.__kvmd_ws.send_mouse_move_event(move["x"], move["y"])
self.__mouse_move = move
# =====
for (button, state) in buttons.items():
if self.__mouse_buttons[button] != state:
await self.__kvmd_ws.send_mouse_button_event(button, state)
self.__mouse_buttons[button] = state
async def _on_mouse_button_event(self, button: int, state: bool) -> None:
assert self.__stage1_authorized.is_passed()
if self.__kvmd_ws:
await self.__kvmd_ws.send_mouse_button_event(button, state)
async def _on_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None:
assert self.__stage1_authorized.is_passed()
if self.__kvmd_ws:
await self.__kvmd_ws.send_mouse_wheel_event(delta_x, delta_y)
async def _on_mouse_move_event(self, to_x: int, to_y: int) -> None:
assert self.__stage1_authorized.is_passed()
if self.__kvmd_ws:
await self.__kvmd_ws.send_mouse_move_event(to_x, to_y)
# =====
async def _on_cut_event(self, text: str) -> None:
assert self.__stage1_authorized.is_passed()
assert self.__kvmd_session
logger = get_logger(0)
logger.info("%s [main]: Printing %d characters ...", self._remote, len(text))
try:
(keymap_name, available) = await self.__kvmd_session.hid.get_keymaps()
if self.__keymap_name in available:
keymap_name = self.__keymap_name
await self.__kvmd_session.hid.print(text, 0, keymap_name)
except Exception:
logger.exception("%s [main]: Can't print characters", self._remote)
self.__clipboard = text
async def _on_set_encodings(self) -> None:
assert self.__stage1_authorized.is_passed()
@ -441,16 +456,17 @@ class VncServer: # pylint: disable=too-many-instance-attributes
x509_cert_path: str,
x509_key_path: str,
vncpass_enabled: bool,
vncpass_path: str,
vencrypt_enabled: bool,
desired_fps: int,
mouse_output: str,
keymap_path: str,
allow_cut_after: float,
scroll_rate: int,
kvmd: KvmdClient,
streamers: list[BaseStreamerClient],
vnc_auth_manager: VncAuthManager,
) -> None:
self.__host = network.get_listen_host(host)
@ -460,7 +476,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes
keymap_name = os.path.basename(keymap_path)
symmap = build_symmap(keymap_path)
self.__vnc_auth_manager = vnc_auth_manager
self.__vncpass_enabled = vncpass_enabled
self.__vncpass_path = vncpass_path
shared_params = _SharedParams()
@ -487,8 +504,8 @@ class VncServer: # pylint: disable=too-many-instance-attributes
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, timeout) # type: ignore
try:
async with kvmd.make_session("", "") as kvmd_session:
none_auth_only = await kvmd_session.auth.check()
async with kvmd.make_session() as kvmd_session:
none_auth_only = await kvmd_session.auth.check("", "")
except (aiohttp.ClientError, asyncio.TimeoutError) as ex:
logger.error("%s [entry]: Can't check KVMD auth mode: %s", remote, tools.efmt(ex))
return
@ -504,12 +521,12 @@ class VncServer: # pylint: disable=too-many-instance-attributes
mouse_output=mouse_output,
keymap_name=keymap_name,
symmap=symmap,
allow_cut_after=allow_cut_after,
scroll_rate=scroll_rate,
kvmd=kvmd,
streamers=streamers,
vnc_credentials=(await self.__vnc_auth_manager.read_credentials())[0],
none_auth_only=none_auth_only,
vncpasses=(await self.__read_vncpasses()),
vencrypt=vencrypt_enabled,
none_auth_only=none_auth_only,
shared_params=shared_params,
).run()
except Exception:
@ -520,9 +537,6 @@ class VncServer: # pylint: disable=too-many-instance-attributes
self.__handle_client = handle_client
async def __inner_run(self) -> None:
if not (await self.__vnc_auth_manager.read_credentials())[1]:
raise SystemExit(1)
get_logger(0).info("Listening VNC on TCP [%s]:%d ...", self.__host, self.__port)
(family, _, _, _, addr) = socket.getaddrinfo(self.__host, self.__port, type=socket.SOCK_STREAM)[0]
with contextlib.closing(socket.socket(family, socket.SOCK_STREAM)) as sock:
@ -539,6 +553,21 @@ class VncServer: # pylint: disable=too-many-instance-attributes
async with server:
await server.serve_forever()
@async_lru.alru_cache(maxsize=1, ttl=1)
async def __read_vncpasses(self) -> set[str]:
if self.__vncpass_enabled:
try:
vncpasses: set[str] = set()
for (_, line) in tools.passwds_splitted(await aiotools.read_file(self.__vncpass_path)):
if " -> " in line: # Compatibility with old ipmipasswd file format
line = line.split(" -> ", 1)[0]
if len(line.strip()) > 0:
vncpasses.add(line)
return vncpasses
except Exception:
get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file")
return set()
def run(self) -> None:
aiotools.run(self.__inner_run())
get_logger().info("Bye-bye")

View File

@ -1,86 +0,0 @@
# ========================================================================== #
# #
# 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 dataclasses
from ...logging import get_logger
from ... import aiotools
# =====
class VncAuthError(Exception):
def __init__(self, path: str, lineno: int, msg: str) -> None:
super().__init__(f"Syntax error at {path}:{lineno}: {msg}")
# =====
@dataclasses.dataclass(frozen=True)
class VncAuthKvmdCredentials:
user: str
passwd: str
class VncAuthManager:
def __init__(
self,
path: str,
enabled: bool,
) -> None:
self.__path = path
self.__enabled = enabled
async def read_credentials(self) -> tuple[dict[str, VncAuthKvmdCredentials], bool]:
if self.__enabled:
try:
return (await self.__inner_read_credentials(), True)
except VncAuthError as ex:
get_logger(0).error(str(ex))
except Exception:
get_logger(0).exception("Unhandled exception while reading VNCAuth passwd file")
return ({}, (not self.__enabled))
async def __inner_read_credentials(self) -> dict[str, VncAuthKvmdCredentials]:
lines = (await aiotools.read_file(self.__path)).split("\n")
credentials: dict[str, VncAuthKvmdCredentials] = {}
for (lineno, line) in enumerate(lines):
if len(line.strip()) == 0 or line.lstrip().startswith("#"):
continue
if " -> " not in line:
raise VncAuthError(self.__path, lineno, "Missing ' -> ' operator")
(vnc_passwd, kvmd_userpass) = map(str.lstrip, line.split(" -> ", 1))
if ":" not in kvmd_userpass:
raise VncAuthError(self.__path, lineno, "Missing ':' operator in KVMD credentials (right part)")
(kvmd_user, kvmd_passwd) = kvmd_userpass.split(":")
kvmd_user = kvmd_user.strip()
if len(kvmd_user) == 0:
raise VncAuthError(self.__path, lineno, "Empty KVMD user (right part)")
if vnc_passwd in credentials:
raise VncAuthError(self.__path, lineno, "Duplicating VNC password (left part)")
credentials[vnc_passwd] = VncAuthKvmdCredentials(kvmd_user, kvmd_passwd)
return credentials

View File

@ -20,10 +20,10 @@
# ========================================================================== #
import asyncio
import contextlib
import struct
import typing
from typing import Callable
from typing import AsyncGenerator
@ -51,29 +51,35 @@ class _BaseApiPart:
for (key, value) in params.items()
if value is not None
},
) as response:
htclient.raise_not_200(response)
) as resp:
htclient.raise_not_200(resp)
class _AuthApiPart(_BaseApiPart):
async def check(self) -> bool:
async def check(self, user: str, passwd: str) -> bool:
session = self._ensure_http_session()
try:
async with session.get("/auth/check") as response:
htclient.raise_not_200(response)
return True
async with session.get("/auth/check", headers={
"X-KVMD-User": user,
"X-KVMD-Passwd": passwd,
}) as resp:
htclient.raise_not_200(resp)
return (resp.status == 200) # Just for my paranoia
except aiohttp.ClientResponseError as ex:
if ex.status in [400, 401, 403]:
return False
raise
typing.assert_never("We should't be here")
class _StreamerApiPart(_BaseApiPart):
async def get_state(self) -> dict:
session = self._ensure_http_session()
async with session.get("/streamer") as response:
htclient.raise_not_200(response)
return (await response.json())["result"]
async with session.get("/streamer") as resp:
htclient.raise_not_200(resp)
return (await resp.json())["result"]
async def set_params(self, quality: (int | None)=None, desired_fps: (int | None)=None) -> None:
await self._set_params(
@ -86,9 +92,9 @@ class _StreamerApiPart(_BaseApiPart):
class _HidApiPart(_BaseApiPart):
async def get_keymaps(self) -> tuple[str, set[str]]:
session = self._ensure_http_session()
async with session.get("/hid/keymaps") as response:
htclient.raise_not_200(response)
result = (await response.json())["result"]
async with session.get("/hid/keymaps") as resp:
htclient.raise_not_200(resp)
result = (await resp.json())["result"]
return (result["keymaps"]["default"], set(result["keymaps"]["available"]))
async def print(self, text: str, limit: int, keymap_name: str) -> None:
@ -97,8 +103,8 @@ class _HidApiPart(_BaseApiPart):
url="/hid/print",
params={"limit": limit, "keymap": keymap_name},
data=text,
) as response:
htclient.raise_not_200(response)
) as resp:
htclient.raise_not_200(resp)
async def set_params(self, keyboard_output: (str | None)=None, mouse_output: (str | None)=None) -> None:
await self._set_params(
@ -111,9 +117,9 @@ class _HidApiPart(_BaseApiPart):
class _AtxApiPart(_BaseApiPart):
async def get_state(self) -> dict:
session = self._ensure_http_session()
async with session.get("/atx") as response:
htclient.raise_not_200(response)
return (await response.json())["result"]
async with session.get("/atx") as resp:
htclient.raise_not_200(resp)
return (await resp.json())["result"]
async def switch_power(self, action: str) -> bool:
session = self._ensure_http_session()
@ -121,8 +127,8 @@ class _AtxApiPart(_BaseApiPart):
async with session.post(
url="/atx/power",
params={"action": action},
) as response:
htclient.raise_not_200(response)
) as resp:
htclient.raise_not_200(resp)
return True
except aiohttp.ClientResponseError as ex:
if ex.status == 409:
@ -130,51 +136,47 @@ class _AtxApiPart(_BaseApiPart):
raise
class _SwitchApiPart(_BaseApiPart):
async def set_active_prev(self) -> None:
session = self._ensure_http_session()
async with session.post("/switch/set_active_prev") as resp:
htclient.raise_not_200(resp)
async def set_active_next(self) -> None:
session = self._ensure_http_session()
async with session.post("/switch/set_active_next") as resp:
htclient.raise_not_200(resp)
async def set_active(self, port: float) -> None:
session = self._ensure_http_session()
async with session.post(
url="/switch/set_active",
params={"port": port},
) as resp:
htclient.raise_not_200(resp)
# =====
class KvmdClientWs:
def __init__(self, ws: aiohttp.ClientWebSocketResponse) -> None:
self.__ws = ws
self.__writer_queue: "asyncio.Queue[tuple[str, dict] | bytes]" = asyncio.Queue()
self.__communicated = False
async def communicate(self) -> AsyncGenerator[tuple[str, dict], None]: # pylint: disable=too-many-branches
assert not self.__communicated
self.__communicated = True
receive_task: (asyncio.Task | None) = None
writer_task: (asyncio.Task | None) = None
try:
while True:
if receive_task is None:
receive_task = asyncio.create_task(self.__ws.receive())
if writer_task is None:
writer_task = asyncio.create_task(self.__writer_queue.get())
done = (await aiotools.wait_first(receive_task, writer_task))[0]
if receive_task in done:
msg = receive_task.result()
if msg.type == aiohttp.WSMsgType.TEXT:
async for msg in self.__ws:
match msg.type:
case aiohttp.WSMsgType.TEXT:
yield htserver.parse_ws_event(msg.data)
elif msg.type == aiohttp.WSMsgType.CLOSE:
case aiohttp.WSMsgType.CLOSE:
await self.__ws.close()
elif msg.type == aiohttp.WSMsgType.CLOSED:
case aiohttp.WSMsgType.CLOSED:
break
else:
case _:
raise RuntimeError(f"Unhandled WS message type: {msg!r}")
receive_task = None
if writer_task in done:
payload = writer_task.result()
if isinstance(payload, bytes):
await self.__ws.send_bytes(payload)
else:
await htserver.send_ws_event(self.__ws, *payload)
writer_task = None
finally:
if receive_task:
receive_task.cancel()
if writer_task:
writer_task.cancel()
try:
await aiotools.shield_fg(self.__ws.close())
except Exception:
@ -182,19 +184,33 @@ class KvmdClientWs:
finally:
self.__communicated = False
async def send_key_event(self, key: str, state: bool) -> None:
mask = (0b01 if state else 0)
await self.__writer_queue.put(bytes([1, mask]) + key.encode("ascii"))
async def send_key_event(self, key: int, state: bool) -> None:
mask = (0b10000000 | int(bool(state)))
await self.__send_struct(">BBH", 1, mask, key)
async def send_mouse_button_event(self, button: str, state: bool) -> None:
mask = (0b01 if state else 0)
await self.__writer_queue.put(bytes([2, mask]) + button.encode("ascii"))
async def send_mouse_button_event(self, button: int, state: bool) -> None:
mask = (0b10000000 | int(bool(state)))
await self.__send_struct(">BBH", 2, mask, button)
async def send_mouse_move_event(self, to_x: int, to_y: int) -> None:
await self.__writer_queue.put(struct.pack(">bhh", 3, to_x, to_y))
await self.__send_struct(">Bhh", 3, to_x, to_y)
async def send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None:
await self.__send_struct(">BBbb", 4, 0, delta_x, delta_y)
async def send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None:
await self.__writer_queue.put(struct.pack(">bbbb", 5, 0, delta_x, delta_y))
await self.__send_struct(">BBbb", 5, 0, delta_x, delta_y)
async def __send_struct(self, fmt: str, *values: int) -> None:
if not self.__communicated:
return
data = struct.pack(fmt, *values)
try:
await self.__ws.send_bytes(data)
except Exception:
# XXX: We don't care about any connection errors
# since they will be handled with communication()
pass
class KvmdClientSession(BaseHttpClientSession):
@ -204,18 +220,15 @@ class KvmdClientSession(BaseHttpClientSession):
self.streamer = _StreamerApiPart(self._ensure_http_session)
self.hid = _HidApiPart(self._ensure_http_session)
self.atx = _AtxApiPart(self._ensure_http_session)
self.switch = _SwitchApiPart(self._ensure_http_session)
@contextlib.asynccontextmanager
async def ws(self) -> AsyncGenerator[KvmdClientWs, None]:
async def ws(self, stream: bool=True) -> AsyncGenerator[KvmdClientWs, None]:
session = self._ensure_http_session()
async with session.ws_connect("/ws", params={"legacy": "0"}) as ws:
async with session.ws_connect("/ws", params={"stream": int(stream)}) as ws:
yield KvmdClientWs(ws)
class KvmdClient(BaseHttpClient):
def make_session(self, user: str="", passwd: str="") -> KvmdClientSession:
headers = {
"X-KVMD-User": user,
"X-KVMD-Passwd": passwd,
}
return KvmdClientSession(lambda: self._make_http_session(headers))
def make_session(self) -> KvmdClientSession:
return KvmdClientSession(self._make_http_session)

View File

@ -117,25 +117,25 @@ class StreamerSnapshot:
class HttpStreamerClientSession(BaseHttpClientSession):
async def get_state(self) -> dict:
session = self._ensure_http_session()
async with session.get("/state") as response:
htclient.raise_not_200(response)
return (await response.json())["result"]
async with session.get("/state") as resp:
htclient.raise_not_200(resp)
return (await resp.json())["result"]
async def take_snapshot(self, timeout: float) -> StreamerSnapshot:
session = self._ensure_http_session()
async with session.get(
url="/snapshot",
timeout=aiohttp.ClientTimeout(total=timeout),
) as response:
) as resp:
htclient.raise_not_200(response)
htclient.raise_not_200(resp)
return StreamerSnapshot(
online=(response.headers["X-UStreamer-Online"] == "true"),
width=int(response.headers["X-UStreamer-Width"]),
height=int(response.headers["X-UStreamer-Height"]),
online=(resp.headers["X-UStreamer-Online"] == "true"),
width=int(resp.headers["X-UStreamer-Width"]),
height=int(resp.headers["X-UStreamer-Height"]),
headers=tuple(
(key, value)
for (key, value) in tools.sorted_kvs(dict(response.headers))
for (key, value) in tools.sorted_kvs(dict(resp.headers))
if key.lower().startswith("x-ustreamer-") or key.lower() in [
"x-timestamp",
"access-control-allow-origin",
@ -144,7 +144,7 @@ class HttpStreamerClientSession(BaseHttpClientSession):
"expires",
]
),
data=bytes(await response.read()),
data=bytes(await resp.read()),
)
@ -187,10 +187,10 @@ class HttpStreamerClient(BaseHttpClient, BaseStreamerClient):
connect=session.timeout.total,
sock_read=session.timeout.total,
),
) as response:
) as resp:
htclient.raise_not_200(response)
reader = aiohttp.MultipartReader.from_response(response)
htclient.raise_not_200(resp)
reader = aiohttp.MultipartReader.from_response(resp)
self.__patch_stream_reader(reader.resp.content)
async def read_frame(key_required: bool) -> dict:

58
kvmd/crypto.py Normal file
View File

@ -0,0 +1,58 @@
# ========================================================================== #
# #
# 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 passlib.context import CryptContext
from passlib.apache import HtpasswdFile as _ApacheHtpasswdFile
from passlib.apache import htpasswd_context as _apache_htpasswd_ctx
# =====
_SHA512 = "ldap_salted_sha512"
_SHA256 = "ldap_salted_sha256"
def _make_kvmd_htpasswd_context() -> CryptContext:
schemes = list(_apache_htpasswd_ctx.schemes())
for alg in [_SHA256, _SHA512]:
if alg in schemes:
schemes.remove(alg)
schemes.insert(0, alg)
assert schemes[0] == _SHA512
return CryptContext(
schemes=schemes,
default=_SHA512,
bcrypt__ident="2y", # See note in the passlib.apache
)
_kvmd_htpasswd_ctx = _make_kvmd_htpasswd_context()
# =====
class KvmdHtpasswdFile(_ApacheHtpasswdFile):
def __init__(self, path: str, new: bool=False) -> None:
super().__init__(
path=path,
default_scheme=_SHA512,
context=_kvmd_htpasswd_ctx,
new=new,
)

View File

@ -69,6 +69,9 @@ class _CeaBlock:
return _CeaBlock(tag, data)
_LONG = 256
_SHORT = 128
_CEA = 128
_CEA_AUDIO = 1
_CEA_SPEAKERS = 4
@ -78,22 +81,27 @@ class Edid:
# https://en.wikipedia.org/wiki/Extended_Display_Identification_Data
def __init__(self, data: bytes) -> None:
assert len(data) == 256
assert len(data) in [_SHORT, _LONG], f"Invalid EDID length: {len(data)}, should be {_SHORT} or {_LONG} bytes"
self.__long = (len(data) == _LONG)
if self.__long:
assert data[126] == 1, "Zero extensions number"
assert (data[_CEA + 0], data[_CEA + 1]) == (0x02, 0x03), "Can't find CEA extension"
self.__data = list(data)
@classmethod
def is_header_valid(cls, data: bytes) -> bool:
return data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00")
@classmethod
def from_file(cls, path: str) -> "Edid":
with _smart_open(path, "rb") as file:
data = file.read()
if not data.startswith(b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00"):
if not cls.is_header_valid(data):
text = re.sub(r"\s", "", data.decode())
data = bytes([
int(text[index:index + 2], 16)
for index in range(0, len(text), 2)
])
assert len(data) == 256, f"Invalid EDID length: {len(data)}, should be 256 bytes"
assert data[126] == 1, "Zero extensions number"
assert (data[_CEA + 0], data[_CEA + 1]) == (0x02, 0x03), "Can't find CEA extension"
return Edid(data)
def write_hex(self, path: str) -> None:
@ -115,7 +123,8 @@ class Edid:
def __update_checksums(self) -> None:
self.__data[127] = 256 - (sum(self.__data[:127]) % 256)
self.__data[255] = 256 - (sum(self.__data[128:255]) % 256)
if self.__long:
self.__data[255] = 256 - (sum(self.__data[128:255]) % 256)
# =====
@ -229,6 +238,9 @@ class Edid:
self.__data[_CEA + 3] &= (0xFF - 0b01000000) # ~X
def __parse_cea(self) -> tuple[list[_CeaBlock], bytes]:
if not self.__long:
raise EdidNoBlockError("This EDID does not contain any CEA blocks")
cea = self.__data[_CEA:]
dtd_begin = cea[2]
if dtd_begin == 0:

View File

@ -22,6 +22,7 @@
import os
import socket
import struct
import asyncio
import contextlib
import dataclasses
@ -83,6 +84,7 @@ class HttpExposed:
method: str
path: str
auth_required: bool
allow_usc: bool
handler: Callable
@ -90,14 +92,22 @@ _HTTP_EXPOSED = "_http_exposed"
_HTTP_METHOD = "_http_method"
_HTTP_PATH = "_http_path"
_HTTP_AUTH_REQUIRED = "_http_auth_required"
_HTTP_ALLOW_USC = "_http_allow_usc"
def exposed_http(http_method: str, path: str, auth_required: bool=True) -> Callable:
def exposed_http(
http_method: str,
path: str,
auth_required: bool=True,
allow_usc: bool=True,
) -> Callable:
def set_attrs(handler: Callable) -> Callable:
setattr(handler, _HTTP_EXPOSED, True)
setattr(handler, _HTTP_METHOD, http_method)
setattr(handler, _HTTP_PATH, path)
setattr(handler, _HTTP_AUTH_REQUIRED, auth_required)
setattr(handler, _HTTP_ALLOW_USC, allow_usc)
return handler
return set_attrs
@ -108,6 +118,7 @@ def _get_exposed_http(obj: object) -> list[HttpExposed]:
method=getattr(handler, _HTTP_METHOD),
path=getattr(handler, _HTTP_PATH),
auth_required=getattr(handler, _HTTP_AUTH_REQUIRED),
allow_usc=getattr(handler, _HTTP_ALLOW_USC),
handler=handler,
)
for handler in [getattr(obj, name) for name in dir(obj)]
@ -270,6 +281,35 @@ def set_request_auth_info(req: BaseRequest, info: str) -> None:
setattr(req, _REQUEST_AUTH_INFO, info)
@dataclasses.dataclass(frozen=True)
class RequestUnixCredentials:
pid: int
uid: int
gid: int
def __post_init__(self) -> None:
assert self.pid >= 0
assert self.uid >= 0
assert self.gid >= 0
def get_request_unix_credentials(req: BaseRequest) -> (RequestUnixCredentials | None):
if req.transport is None:
return None
sock = req.transport.get_extra_info("socket")
if sock is None:
return None
try:
data = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("iii"))
except Exception:
return None
(pid, uid, gid) = struct.unpack("iii", data)
if pid < 0 or uid < 0 or gid < 0:
# PID == 0 when the client is outside of server's PID namespace, e.g. when kvmd runs in a container
return None
return RequestUnixCredentials(pid=pid, uid=uid, gid=gid)
# =====
@dataclasses.dataclass(frozen=True)
class WsSession:
@ -314,13 +354,14 @@ class HttpServer:
if unix_rm and os.path.exists(unix_path):
os.remove(unix_path)
server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server_socket.bind(unix_path)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1)
sock.bind(unix_path)
if unix_mode:
os.chmod(unix_path, unix_mode)
run_app(
sock=server_socket,
sock=sock,
app=self.__make_app(),
shutdown_timeout=1,
access_log_format=access_log_format,

View File

@ -30,9 +30,9 @@ import Xlib.keysymdef
from ..logging import get_logger
from .mappings import At1Key
from .mappings import WebModifiers
from .mappings import EvdevModifiers
from .mappings import X11_TO_AT1
from .mappings import AT1_TO_WEB
from .mappings import AT1_TO_EVDEV
# =====
@ -42,11 +42,11 @@ class SymmapModifiers:
CTRL: int = 0x4
def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(modifiers, webkey), ...]
def build_symmap(path: str) -> dict[int, dict[int, int]]: # x11 keysym -> [(symmap_modifiers, evdev_code), ...]
# https://github.com/qemu/qemu/blob/95a9457fd44ad97c518858a4e1586a5498f9773c/ui/keymaps.c
logger = get_logger()
symmap: dict[int, dict[int, str]] = {}
symmap: dict[int, dict[int, int]] = {}
for (src, items) in [
(path, list(_read_keyboard_layout(path).items())),
("<builtin>", list(X11_TO_AT1.items())),
@ -57,14 +57,14 @@ def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(mod
for (code, keys) in items:
for key in keys:
web_name = AT1_TO_WEB.get(key.code)
if web_name is not None:
evdev_code = AT1_TO_EVDEV.get(key.code)
if evdev_code is not None:
if (
(web_name in WebModifiers.SHIFTS and key.shift) # pylint: disable=too-many-boolean-expressions
or (web_name in WebModifiers.ALTS and key.altgr)
or (web_name in WebModifiers.CTRLS and key.ctrl)
(evdev_code in EvdevModifiers.SHIFTS and key.shift) # pylint: disable=too-many-boolean-expressions
or (evdev_code in EvdevModifiers.ALTS and key.altgr)
or (evdev_code in EvdevModifiers.CTRLS and key.ctrl)
):
logger.error("Invalid modifier key at mapping %s: %s / %s", src, web_name, key)
logger.error("Invalid modifier key at mapping %s: %s / %s", src, evdev_code, key)
continue
modifiers = (
@ -75,7 +75,7 @@ def build_symmap(path: str) -> dict[int, dict[int, str]]: # x11 keysym -> [(mod
)
if code not in symmap:
symmap[code] = {}
symmap[code].setdefault(modifiers, web_name)
symmap[code].setdefault(modifiers, evdev_code)
return symmap

82
kvmd/keyboard/magic.py Normal file
View File

@ -0,0 +1,82 @@
# ========================================================================== #
# #
# 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 time
from typing import Callable
from typing import Awaitable
from evdev import ecodes
# =====
class MagicHandler:
__MAGIC_KEY = ecodes.KEY_LEFTALT
__MAGIC_TIMEOUT = 2
__MAGIC_TRIGGER = 2
def __init__(
self,
proxy_handler: Callable[[int, bool], Awaitable[None]],
key_handlers: (dict[int, Callable[[], Awaitable[None]]] | None)=None,
numeric_handler: (Callable[[list[int]], Awaitable[bool]] | None)=None,
) -> None:
self.__proxy_handler = proxy_handler
self.__key_handlers = (key_handlers or {})
self.__numeric_handler = numeric_handler
self.__taps = 0
self.__ts = 0.0
self.__codes: list[int] = []
async def handle_key(self, key: int, state: bool) -> None: # pylint: disable=too-many-branches
if self.__ts + self.__MAGIC_TIMEOUT < time.monotonic():
self.__taps = 0
self.__ts = 0
self.__codes = []
if key == self.__MAGIC_KEY:
if not state:
self.__taps += 1
self.__ts = time.monotonic()
elif state:
taps = self.__taps
codes = self.__codes
self.__taps = 0
self.__ts = 0
self.__codes = []
if taps >= self.__MAGIC_TRIGGER:
if key in self.__key_handlers:
await self.__key_handlers[key]()
return
elif self.__numeric_handler is not None and (ecodes.KEY_1 <= key <= ecodes.KEY_8):
codes.append(key - ecodes.KEY_1)
if not (await self.__numeric_handler(list(codes))):
# Если хандлер хочет код большей длины, он возвращает False,
# и мы ждем следующую цифру.
self.__taps = taps
self.__ts = time.monotonic()
self.__codes = codes
return
await self.__proxy_handler(key, state)

View File

@ -22,6 +22,8 @@
import dataclasses
from evdev import ecodes
# =====
@dataclasses.dataclass(frozen=True)
@ -31,7 +33,7 @@ class McuKey:
@dataclasses.dataclass(frozen=True)
class UsbKey:
code: int
code: int
is_modifier: bool
@ -41,137 +43,260 @@ class Key:
usb: UsbKey
KEYMAP: dict[str, Key] = {
"KeyA": Key(mcu=McuKey(code=1), usb=UsbKey(code=4, is_modifier=False)),
"KeyB": Key(mcu=McuKey(code=2), usb=UsbKey(code=5, is_modifier=False)),
"KeyC": Key(mcu=McuKey(code=3), usb=UsbKey(code=6, is_modifier=False)),
"KeyD": Key(mcu=McuKey(code=4), usb=UsbKey(code=7, is_modifier=False)),
"KeyE": Key(mcu=McuKey(code=5), usb=UsbKey(code=8, is_modifier=False)),
"KeyF": Key(mcu=McuKey(code=6), usb=UsbKey(code=9, is_modifier=False)),
"KeyG": Key(mcu=McuKey(code=7), usb=UsbKey(code=10, is_modifier=False)),
"KeyH": Key(mcu=McuKey(code=8), usb=UsbKey(code=11, is_modifier=False)),
"KeyI": Key(mcu=McuKey(code=9), usb=UsbKey(code=12, is_modifier=False)),
"KeyJ": Key(mcu=McuKey(code=10), usb=UsbKey(code=13, is_modifier=False)),
"KeyK": Key(mcu=McuKey(code=11), usb=UsbKey(code=14, is_modifier=False)),
"KeyL": Key(mcu=McuKey(code=12), usb=UsbKey(code=15, is_modifier=False)),
"KeyM": Key(mcu=McuKey(code=13), usb=UsbKey(code=16, is_modifier=False)),
"KeyN": Key(mcu=McuKey(code=14), usb=UsbKey(code=17, is_modifier=False)),
"KeyO": Key(mcu=McuKey(code=15), usb=UsbKey(code=18, is_modifier=False)),
"KeyP": Key(mcu=McuKey(code=16), usb=UsbKey(code=19, is_modifier=False)),
"KeyQ": Key(mcu=McuKey(code=17), usb=UsbKey(code=20, is_modifier=False)),
"KeyR": Key(mcu=McuKey(code=18), usb=UsbKey(code=21, is_modifier=False)),
"KeyS": Key(mcu=McuKey(code=19), usb=UsbKey(code=22, is_modifier=False)),
"KeyT": Key(mcu=McuKey(code=20), usb=UsbKey(code=23, is_modifier=False)),
"KeyU": Key(mcu=McuKey(code=21), usb=UsbKey(code=24, is_modifier=False)),
"KeyV": Key(mcu=McuKey(code=22), usb=UsbKey(code=25, is_modifier=False)),
"KeyW": Key(mcu=McuKey(code=23), usb=UsbKey(code=26, is_modifier=False)),
"KeyX": Key(mcu=McuKey(code=24), usb=UsbKey(code=27, is_modifier=False)),
"KeyY": Key(mcu=McuKey(code=25), usb=UsbKey(code=28, is_modifier=False)),
"KeyZ": Key(mcu=McuKey(code=26), usb=UsbKey(code=29, is_modifier=False)),
"Digit1": Key(mcu=McuKey(code=27), usb=UsbKey(code=30, is_modifier=False)),
"Digit2": Key(mcu=McuKey(code=28), usb=UsbKey(code=31, is_modifier=False)),
"Digit3": Key(mcu=McuKey(code=29), usb=UsbKey(code=32, is_modifier=False)),
"Digit4": Key(mcu=McuKey(code=30), usb=UsbKey(code=33, is_modifier=False)),
"Digit5": Key(mcu=McuKey(code=31), usb=UsbKey(code=34, is_modifier=False)),
"Digit6": Key(mcu=McuKey(code=32), usb=UsbKey(code=35, is_modifier=False)),
"Digit7": Key(mcu=McuKey(code=33), usb=UsbKey(code=36, is_modifier=False)),
"Digit8": Key(mcu=McuKey(code=34), usb=UsbKey(code=37, is_modifier=False)),
"Digit9": Key(mcu=McuKey(code=35), usb=UsbKey(code=38, is_modifier=False)),
"Digit0": Key(mcu=McuKey(code=36), usb=UsbKey(code=39, is_modifier=False)),
"Enter": Key(mcu=McuKey(code=37), usb=UsbKey(code=40, is_modifier=False)),
"Escape": Key(mcu=McuKey(code=38), usb=UsbKey(code=41, is_modifier=False)),
"Backspace": Key(mcu=McuKey(code=39), usb=UsbKey(code=42, is_modifier=False)),
"Tab": Key(mcu=McuKey(code=40), usb=UsbKey(code=43, is_modifier=False)),
"Space": Key(mcu=McuKey(code=41), usb=UsbKey(code=44, is_modifier=False)),
"Minus": Key(mcu=McuKey(code=42), usb=UsbKey(code=45, is_modifier=False)),
"Equal": Key(mcu=McuKey(code=43), usb=UsbKey(code=46, is_modifier=False)),
"BracketLeft": Key(mcu=McuKey(code=44), usb=UsbKey(code=47, is_modifier=False)),
"BracketRight": Key(mcu=McuKey(code=45), usb=UsbKey(code=48, is_modifier=False)),
"Backslash": Key(mcu=McuKey(code=46), usb=UsbKey(code=49, is_modifier=False)),
"Semicolon": Key(mcu=McuKey(code=47), usb=UsbKey(code=51, is_modifier=False)),
"Quote": Key(mcu=McuKey(code=48), usb=UsbKey(code=52, is_modifier=False)),
"Backquote": Key(mcu=McuKey(code=49), usb=UsbKey(code=53, is_modifier=False)),
"Comma": Key(mcu=McuKey(code=50), usb=UsbKey(code=54, is_modifier=False)),
"Period": Key(mcu=McuKey(code=51), usb=UsbKey(code=55, is_modifier=False)),
"Slash": Key(mcu=McuKey(code=52), usb=UsbKey(code=56, is_modifier=False)),
"CapsLock": Key(mcu=McuKey(code=53), usb=UsbKey(code=57, is_modifier=False)),
"F1": Key(mcu=McuKey(code=54), usb=UsbKey(code=58, is_modifier=False)),
"F2": Key(mcu=McuKey(code=55), usb=UsbKey(code=59, is_modifier=False)),
"F3": Key(mcu=McuKey(code=56), usb=UsbKey(code=60, is_modifier=False)),
"F4": Key(mcu=McuKey(code=57), usb=UsbKey(code=61, is_modifier=False)),
"F5": Key(mcu=McuKey(code=58), usb=UsbKey(code=62, is_modifier=False)),
"F6": Key(mcu=McuKey(code=59), usb=UsbKey(code=63, is_modifier=False)),
"F7": Key(mcu=McuKey(code=60), usb=UsbKey(code=64, is_modifier=False)),
"F8": Key(mcu=McuKey(code=61), usb=UsbKey(code=65, is_modifier=False)),
"F9": Key(mcu=McuKey(code=62), usb=UsbKey(code=66, is_modifier=False)),
"F10": Key(mcu=McuKey(code=63), usb=UsbKey(code=67, is_modifier=False)),
"F11": Key(mcu=McuKey(code=64), usb=UsbKey(code=68, is_modifier=False)),
"F12": Key(mcu=McuKey(code=65), usb=UsbKey(code=69, is_modifier=False)),
"PrintScreen": Key(mcu=McuKey(code=66), usb=UsbKey(code=70, is_modifier=False)),
"Insert": Key(mcu=McuKey(code=67), usb=UsbKey(code=73, is_modifier=False)),
"Home": Key(mcu=McuKey(code=68), usb=UsbKey(code=74, is_modifier=False)),
"PageUp": Key(mcu=McuKey(code=69), usb=UsbKey(code=75, is_modifier=False)),
"Delete": Key(mcu=McuKey(code=70), usb=UsbKey(code=76, is_modifier=False)),
"End": Key(mcu=McuKey(code=71), usb=UsbKey(code=77, is_modifier=False)),
"PageDown": Key(mcu=McuKey(code=72), usb=UsbKey(code=78, is_modifier=False)),
"ArrowRight": Key(mcu=McuKey(code=73), usb=UsbKey(code=79, is_modifier=False)),
"ArrowLeft": Key(mcu=McuKey(code=74), usb=UsbKey(code=80, is_modifier=False)),
"ArrowDown": Key(mcu=McuKey(code=75), usb=UsbKey(code=81, is_modifier=False)),
"ArrowUp": Key(mcu=McuKey(code=76), usb=UsbKey(code=82, is_modifier=False)),
"ControlLeft": Key(mcu=McuKey(code=77), usb=UsbKey(code=1, is_modifier=True)),
"ShiftLeft": Key(mcu=McuKey(code=78), usb=UsbKey(code=2, is_modifier=True)),
"AltLeft": Key(mcu=McuKey(code=79), usb=UsbKey(code=4, is_modifier=True)),
"MetaLeft": Key(mcu=McuKey(code=80), usb=UsbKey(code=8, is_modifier=True)),
"ControlRight": Key(mcu=McuKey(code=81), usb=UsbKey(code=16, is_modifier=True)),
"ShiftRight": Key(mcu=McuKey(code=82), usb=UsbKey(code=32, is_modifier=True)),
"AltRight": Key(mcu=McuKey(code=83), usb=UsbKey(code=64, is_modifier=True)),
"MetaRight": Key(mcu=McuKey(code=84), usb=UsbKey(code=128, is_modifier=True)),
"Pause": Key(mcu=McuKey(code=85), usb=UsbKey(code=72, is_modifier=False)),
"ScrollLock": Key(mcu=McuKey(code=86), usb=UsbKey(code=71, is_modifier=False)),
"NumLock": Key(mcu=McuKey(code=87), usb=UsbKey(code=83, is_modifier=False)),
"ContextMenu": Key(mcu=McuKey(code=88), usb=UsbKey(code=101, is_modifier=False)),
"NumpadDivide": Key(mcu=McuKey(code=89), usb=UsbKey(code=84, is_modifier=False)),
"NumpadMultiply": Key(mcu=McuKey(code=90), usb=UsbKey(code=85, is_modifier=False)),
"NumpadSubtract": Key(mcu=McuKey(code=91), usb=UsbKey(code=86, is_modifier=False)),
"NumpadAdd": Key(mcu=McuKey(code=92), usb=UsbKey(code=87, is_modifier=False)),
"NumpadEnter": Key(mcu=McuKey(code=93), usb=UsbKey(code=88, is_modifier=False)),
"Numpad1": Key(mcu=McuKey(code=94), usb=UsbKey(code=89, is_modifier=False)),
"Numpad2": Key(mcu=McuKey(code=95), usb=UsbKey(code=90, is_modifier=False)),
"Numpad3": Key(mcu=McuKey(code=96), usb=UsbKey(code=91, is_modifier=False)),
"Numpad4": Key(mcu=McuKey(code=97), usb=UsbKey(code=92, is_modifier=False)),
"Numpad5": Key(mcu=McuKey(code=98), usb=UsbKey(code=93, is_modifier=False)),
"Numpad6": Key(mcu=McuKey(code=99), usb=UsbKey(code=94, is_modifier=False)),
"Numpad7": Key(mcu=McuKey(code=100), usb=UsbKey(code=95, is_modifier=False)),
"Numpad8": Key(mcu=McuKey(code=101), usb=UsbKey(code=96, is_modifier=False)),
"Numpad9": Key(mcu=McuKey(code=102), usb=UsbKey(code=97, is_modifier=False)),
"Numpad0": Key(mcu=McuKey(code=103), usb=UsbKey(code=98, is_modifier=False)),
"NumpadDecimal": Key(mcu=McuKey(code=104), usb=UsbKey(code=99, is_modifier=False)),
"Power": Key(mcu=McuKey(code=105), usb=UsbKey(code=102, is_modifier=False)),
"IntlBackslash": Key(mcu=McuKey(code=106), usb=UsbKey(code=100, is_modifier=False)),
"IntlYen": Key(mcu=McuKey(code=107), usb=UsbKey(code=137, is_modifier=False)),
"IntlRo": Key(mcu=McuKey(code=108), usb=UsbKey(code=135, is_modifier=False)),
"KanaMode": Key(mcu=McuKey(code=109), usb=UsbKey(code=136, is_modifier=False)),
"Convert": Key(mcu=McuKey(code=110), usb=UsbKey(code=138, is_modifier=False)),
"NonConvert": Key(mcu=McuKey(code=111), usb=UsbKey(code=139, is_modifier=False)),
KEYMAP: dict[int, Key] = {
ecodes.KEY_A: Key(mcu=McuKey(code=1), usb=UsbKey(code=4, is_modifier=False)),
ecodes.KEY_B: Key(mcu=McuKey(code=2), usb=UsbKey(code=5, is_modifier=False)),
ecodes.KEY_C: Key(mcu=McuKey(code=3), usb=UsbKey(code=6, is_modifier=False)),
ecodes.KEY_D: Key(mcu=McuKey(code=4), usb=UsbKey(code=7, is_modifier=False)),
ecodes.KEY_E: Key(mcu=McuKey(code=5), usb=UsbKey(code=8, is_modifier=False)),
ecodes.KEY_F: Key(mcu=McuKey(code=6), usb=UsbKey(code=9, is_modifier=False)),
ecodes.KEY_G: Key(mcu=McuKey(code=7), usb=UsbKey(code=10, is_modifier=False)),
ecodes.KEY_H: Key(mcu=McuKey(code=8), usb=UsbKey(code=11, is_modifier=False)),
ecodes.KEY_I: Key(mcu=McuKey(code=9), usb=UsbKey(code=12, is_modifier=False)),
ecodes.KEY_J: Key(mcu=McuKey(code=10), usb=UsbKey(code=13, is_modifier=False)),
ecodes.KEY_K: Key(mcu=McuKey(code=11), usb=UsbKey(code=14, is_modifier=False)),
ecodes.KEY_L: Key(mcu=McuKey(code=12), usb=UsbKey(code=15, is_modifier=False)),
ecodes.KEY_M: Key(mcu=McuKey(code=13), usb=UsbKey(code=16, is_modifier=False)),
ecodes.KEY_N: Key(mcu=McuKey(code=14), usb=UsbKey(code=17, is_modifier=False)),
ecodes.KEY_O: Key(mcu=McuKey(code=15), usb=UsbKey(code=18, is_modifier=False)),
ecodes.KEY_P: Key(mcu=McuKey(code=16), usb=UsbKey(code=19, is_modifier=False)),
ecodes.KEY_Q: Key(mcu=McuKey(code=17), usb=UsbKey(code=20, is_modifier=False)),
ecodes.KEY_R: Key(mcu=McuKey(code=18), usb=UsbKey(code=21, is_modifier=False)),
ecodes.KEY_S: Key(mcu=McuKey(code=19), usb=UsbKey(code=22, is_modifier=False)),
ecodes.KEY_T: Key(mcu=McuKey(code=20), usb=UsbKey(code=23, is_modifier=False)),
ecodes.KEY_U: Key(mcu=McuKey(code=21), usb=UsbKey(code=24, is_modifier=False)),
ecodes.KEY_V: Key(mcu=McuKey(code=22), usb=UsbKey(code=25, is_modifier=False)),
ecodes.KEY_W: Key(mcu=McuKey(code=23), usb=UsbKey(code=26, is_modifier=False)),
ecodes.KEY_X: Key(mcu=McuKey(code=24), usb=UsbKey(code=27, is_modifier=False)),
ecodes.KEY_Y: Key(mcu=McuKey(code=25), usb=UsbKey(code=28, is_modifier=False)),
ecodes.KEY_Z: Key(mcu=McuKey(code=26), usb=UsbKey(code=29, is_modifier=False)),
ecodes.KEY_1: Key(mcu=McuKey(code=27), usb=UsbKey(code=30, is_modifier=False)),
ecodes.KEY_2: Key(mcu=McuKey(code=28), usb=UsbKey(code=31, is_modifier=False)),
ecodes.KEY_3: Key(mcu=McuKey(code=29), usb=UsbKey(code=32, is_modifier=False)),
ecodes.KEY_4: Key(mcu=McuKey(code=30), usb=UsbKey(code=33, is_modifier=False)),
ecodes.KEY_5: Key(mcu=McuKey(code=31), usb=UsbKey(code=34, is_modifier=False)),
ecodes.KEY_6: Key(mcu=McuKey(code=32), usb=UsbKey(code=35, is_modifier=False)),
ecodes.KEY_7: Key(mcu=McuKey(code=33), usb=UsbKey(code=36, is_modifier=False)),
ecodes.KEY_8: Key(mcu=McuKey(code=34), usb=UsbKey(code=37, is_modifier=False)),
ecodes.KEY_9: Key(mcu=McuKey(code=35), usb=UsbKey(code=38, is_modifier=False)),
ecodes.KEY_0: Key(mcu=McuKey(code=36), usb=UsbKey(code=39, is_modifier=False)),
ecodes.KEY_ENTER: Key(mcu=McuKey(code=37), usb=UsbKey(code=40, is_modifier=False)),
ecodes.KEY_ESC: Key(mcu=McuKey(code=38), usb=UsbKey(code=41, is_modifier=False)),
ecodes.KEY_BACKSPACE: Key(mcu=McuKey(code=39), usb=UsbKey(code=42, is_modifier=False)),
ecodes.KEY_TAB: Key(mcu=McuKey(code=40), usb=UsbKey(code=43, is_modifier=False)),
ecodes.KEY_SPACE: Key(mcu=McuKey(code=41), usb=UsbKey(code=44, is_modifier=False)),
ecodes.KEY_MINUS: Key(mcu=McuKey(code=42), usb=UsbKey(code=45, is_modifier=False)),
ecodes.KEY_EQUAL: Key(mcu=McuKey(code=43), usb=UsbKey(code=46, is_modifier=False)),
ecodes.KEY_LEFTBRACE: Key(mcu=McuKey(code=44), usb=UsbKey(code=47, is_modifier=False)),
ecodes.KEY_RIGHTBRACE: Key(mcu=McuKey(code=45), usb=UsbKey(code=48, is_modifier=False)),
ecodes.KEY_BACKSLASH: Key(mcu=McuKey(code=46), usb=UsbKey(code=49, is_modifier=False)),
ecodes.KEY_SEMICOLON: Key(mcu=McuKey(code=47), usb=UsbKey(code=51, is_modifier=False)),
ecodes.KEY_APOSTROPHE: Key(mcu=McuKey(code=48), usb=UsbKey(code=52, is_modifier=False)),
ecodes.KEY_GRAVE: Key(mcu=McuKey(code=49), usb=UsbKey(code=53, is_modifier=False)),
ecodes.KEY_COMMA: Key(mcu=McuKey(code=50), usb=UsbKey(code=54, is_modifier=False)),
ecodes.KEY_DOT: Key(mcu=McuKey(code=51), usb=UsbKey(code=55, is_modifier=False)),
ecodes.KEY_SLASH: Key(mcu=McuKey(code=52), usb=UsbKey(code=56, is_modifier=False)),
ecodes.KEY_CAPSLOCK: Key(mcu=McuKey(code=53), usb=UsbKey(code=57, is_modifier=False)),
ecodes.KEY_F1: Key(mcu=McuKey(code=54), usb=UsbKey(code=58, is_modifier=False)),
ecodes.KEY_F2: Key(mcu=McuKey(code=55), usb=UsbKey(code=59, is_modifier=False)),
ecodes.KEY_F3: Key(mcu=McuKey(code=56), usb=UsbKey(code=60, is_modifier=False)),
ecodes.KEY_F4: Key(mcu=McuKey(code=57), usb=UsbKey(code=61, is_modifier=False)),
ecodes.KEY_F5: Key(mcu=McuKey(code=58), usb=UsbKey(code=62, is_modifier=False)),
ecodes.KEY_F6: Key(mcu=McuKey(code=59), usb=UsbKey(code=63, is_modifier=False)),
ecodes.KEY_F7: Key(mcu=McuKey(code=60), usb=UsbKey(code=64, is_modifier=False)),
ecodes.KEY_F8: Key(mcu=McuKey(code=61), usb=UsbKey(code=65, is_modifier=False)),
ecodes.KEY_F9: Key(mcu=McuKey(code=62), usb=UsbKey(code=66, is_modifier=False)),
ecodes.KEY_F10: Key(mcu=McuKey(code=63), usb=UsbKey(code=67, is_modifier=False)),
ecodes.KEY_F11: Key(mcu=McuKey(code=64), usb=UsbKey(code=68, is_modifier=False)),
ecodes.KEY_F12: Key(mcu=McuKey(code=65), usb=UsbKey(code=69, is_modifier=False)),
ecodes.KEY_SYSRQ: Key(mcu=McuKey(code=66), usb=UsbKey(code=70, is_modifier=False)),
ecodes.KEY_INSERT: Key(mcu=McuKey(code=67), usb=UsbKey(code=73, is_modifier=False)),
ecodes.KEY_HOME: Key(mcu=McuKey(code=68), usb=UsbKey(code=74, is_modifier=False)),
ecodes.KEY_PAGEUP: Key(mcu=McuKey(code=69), usb=UsbKey(code=75, is_modifier=False)),
ecodes.KEY_DELETE: Key(mcu=McuKey(code=70), usb=UsbKey(code=76, is_modifier=False)),
ecodes.KEY_END: Key(mcu=McuKey(code=71), usb=UsbKey(code=77, is_modifier=False)),
ecodes.KEY_PAGEDOWN: Key(mcu=McuKey(code=72), usb=UsbKey(code=78, is_modifier=False)),
ecodes.KEY_RIGHT: Key(mcu=McuKey(code=73), usb=UsbKey(code=79, is_modifier=False)),
ecodes.KEY_LEFT: Key(mcu=McuKey(code=74), usb=UsbKey(code=80, is_modifier=False)),
ecodes.KEY_DOWN: Key(mcu=McuKey(code=75), usb=UsbKey(code=81, is_modifier=False)),
ecodes.KEY_UP: Key(mcu=McuKey(code=76), usb=UsbKey(code=82, is_modifier=False)),
ecodes.KEY_LEFTCTRL: Key(mcu=McuKey(code=77), usb=UsbKey(code=1, is_modifier=True)),
ecodes.KEY_LEFTSHIFT: Key(mcu=McuKey(code=78), usb=UsbKey(code=2, is_modifier=True)),
ecodes.KEY_LEFTALT: Key(mcu=McuKey(code=79), usb=UsbKey(code=4, is_modifier=True)),
ecodes.KEY_LEFTMETA: Key(mcu=McuKey(code=80), usb=UsbKey(code=8, is_modifier=True)),
ecodes.KEY_RIGHTCTRL: Key(mcu=McuKey(code=81), usb=UsbKey(code=16, is_modifier=True)),
ecodes.KEY_RIGHTSHIFT: Key(mcu=McuKey(code=82), usb=UsbKey(code=32, is_modifier=True)),
ecodes.KEY_RIGHTALT: Key(mcu=McuKey(code=83), usb=UsbKey(code=64, is_modifier=True)),
ecodes.KEY_RIGHTMETA: Key(mcu=McuKey(code=84), usb=UsbKey(code=128, is_modifier=True)),
ecodes.KEY_PAUSE: Key(mcu=McuKey(code=85), usb=UsbKey(code=72, is_modifier=False)),
ecodes.KEY_SCROLLLOCK: Key(mcu=McuKey(code=86), usb=UsbKey(code=71, is_modifier=False)),
ecodes.KEY_NUMLOCK: Key(mcu=McuKey(code=87), usb=UsbKey(code=83, is_modifier=False)),
ecodes.KEY_CONTEXT_MENU: Key(mcu=McuKey(code=88), usb=UsbKey(code=101, is_modifier=False)),
ecodes.KEY_KPSLASH: Key(mcu=McuKey(code=89), usb=UsbKey(code=84, is_modifier=False)),
ecodes.KEY_KPASTERISK: Key(mcu=McuKey(code=90), usb=UsbKey(code=85, is_modifier=False)),
ecodes.KEY_KPMINUS: Key(mcu=McuKey(code=91), usb=UsbKey(code=86, is_modifier=False)),
ecodes.KEY_KPPLUS: Key(mcu=McuKey(code=92), usb=UsbKey(code=87, is_modifier=False)),
ecodes.KEY_KPENTER: Key(mcu=McuKey(code=93), usb=UsbKey(code=88, is_modifier=False)),
ecodes.KEY_KP1: Key(mcu=McuKey(code=94), usb=UsbKey(code=89, is_modifier=False)),
ecodes.KEY_KP2: Key(mcu=McuKey(code=95), usb=UsbKey(code=90, is_modifier=False)),
ecodes.KEY_KP3: Key(mcu=McuKey(code=96), usb=UsbKey(code=91, is_modifier=False)),
ecodes.KEY_KP4: Key(mcu=McuKey(code=97), usb=UsbKey(code=92, is_modifier=False)),
ecodes.KEY_KP5: Key(mcu=McuKey(code=98), usb=UsbKey(code=93, is_modifier=False)),
ecodes.KEY_KP6: Key(mcu=McuKey(code=99), usb=UsbKey(code=94, is_modifier=False)),
ecodes.KEY_KP7: Key(mcu=McuKey(code=100), usb=UsbKey(code=95, is_modifier=False)),
ecodes.KEY_KP8: Key(mcu=McuKey(code=101), usb=UsbKey(code=96, is_modifier=False)),
ecodes.KEY_KP9: Key(mcu=McuKey(code=102), usb=UsbKey(code=97, is_modifier=False)),
ecodes.KEY_KP0: Key(mcu=McuKey(code=103), usb=UsbKey(code=98, is_modifier=False)),
ecodes.KEY_KPDOT: Key(mcu=McuKey(code=104), usb=UsbKey(code=99, is_modifier=False)),
ecodes.KEY_POWER: Key(mcu=McuKey(code=105), usb=UsbKey(code=102, is_modifier=False)),
ecodes.KEY_102ND: Key(mcu=McuKey(code=106), usb=UsbKey(code=100, is_modifier=False)),
ecodes.KEY_YEN: Key(mcu=McuKey(code=107), usb=UsbKey(code=137, is_modifier=False)),
ecodes.KEY_RO: Key(mcu=McuKey(code=108), usb=UsbKey(code=135, is_modifier=False)),
ecodes.KEY_KATAKANA: Key(mcu=McuKey(code=109), usb=UsbKey(code=136, is_modifier=False)),
ecodes.KEY_HENKAN: Key(mcu=McuKey(code=110), usb=UsbKey(code=138, is_modifier=False)),
ecodes.KEY_MUHENKAN: Key(mcu=McuKey(code=111), usb=UsbKey(code=139, is_modifier=False)),
ecodes.KEY_MUTE: Key(mcu=McuKey(code=112), usb=UsbKey(code=127, is_modifier=False)),
ecodes.KEY_VOLUMEUP: Key(mcu=McuKey(code=113), usb=UsbKey(code=128, is_modifier=False)),
ecodes.KEY_VOLUMEDOWN: Key(mcu=McuKey(code=114), usb=UsbKey(code=129, is_modifier=False)),
ecodes.KEY_F20: Key(mcu=McuKey(code=115), usb=UsbKey(code=111, is_modifier=False)),
}
WEB_TO_EVDEV = {
"KeyA": ecodes.KEY_A,
"KeyB": ecodes.KEY_B,
"KeyC": ecodes.KEY_C,
"KeyD": ecodes.KEY_D,
"KeyE": ecodes.KEY_E,
"KeyF": ecodes.KEY_F,
"KeyG": ecodes.KEY_G,
"KeyH": ecodes.KEY_H,
"KeyI": ecodes.KEY_I,
"KeyJ": ecodes.KEY_J,
"KeyK": ecodes.KEY_K,
"KeyL": ecodes.KEY_L,
"KeyM": ecodes.KEY_M,
"KeyN": ecodes.KEY_N,
"KeyO": ecodes.KEY_O,
"KeyP": ecodes.KEY_P,
"KeyQ": ecodes.KEY_Q,
"KeyR": ecodes.KEY_R,
"KeyS": ecodes.KEY_S,
"KeyT": ecodes.KEY_T,
"KeyU": ecodes.KEY_U,
"KeyV": ecodes.KEY_V,
"KeyW": ecodes.KEY_W,
"KeyX": ecodes.KEY_X,
"KeyY": ecodes.KEY_Y,
"KeyZ": ecodes.KEY_Z,
"Digit1": ecodes.KEY_1,
"Digit2": ecodes.KEY_2,
"Digit3": ecodes.KEY_3,
"Digit4": ecodes.KEY_4,
"Digit5": ecodes.KEY_5,
"Digit6": ecodes.KEY_6,
"Digit7": ecodes.KEY_7,
"Digit8": ecodes.KEY_8,
"Digit9": ecodes.KEY_9,
"Digit0": ecodes.KEY_0,
"Enter": ecodes.KEY_ENTER,
"Escape": ecodes.KEY_ESC,
"Backspace": ecodes.KEY_BACKSPACE,
"Tab": ecodes.KEY_TAB,
"Space": ecodes.KEY_SPACE,
"Minus": ecodes.KEY_MINUS,
"Equal": ecodes.KEY_EQUAL,
"BracketLeft": ecodes.KEY_LEFTBRACE,
"BracketRight": ecodes.KEY_RIGHTBRACE,
"Backslash": ecodes.KEY_BACKSLASH,
"Semicolon": ecodes.KEY_SEMICOLON,
"Quote": ecodes.KEY_APOSTROPHE,
"Backquote": ecodes.KEY_GRAVE,
"Comma": ecodes.KEY_COMMA,
"Period": ecodes.KEY_DOT,
"Slash": ecodes.KEY_SLASH,
"CapsLock": ecodes.KEY_CAPSLOCK,
"F1": ecodes.KEY_F1,
"F2": ecodes.KEY_F2,
"F3": ecodes.KEY_F3,
"F4": ecodes.KEY_F4,
"F5": ecodes.KEY_F5,
"F6": ecodes.KEY_F6,
"F7": ecodes.KEY_F7,
"F8": ecodes.KEY_F8,
"F9": ecodes.KEY_F9,
"F10": ecodes.KEY_F10,
"F11": ecodes.KEY_F11,
"F12": ecodes.KEY_F12,
"PrintScreen": ecodes.KEY_SYSRQ,
"Insert": ecodes.KEY_INSERT,
"Home": ecodes.KEY_HOME,
"PageUp": ecodes.KEY_PAGEUP,
"Delete": ecodes.KEY_DELETE,
"End": ecodes.KEY_END,
"PageDown": ecodes.KEY_PAGEDOWN,
"ArrowRight": ecodes.KEY_RIGHT,
"ArrowLeft": ecodes.KEY_LEFT,
"ArrowDown": ecodes.KEY_DOWN,
"ArrowUp": ecodes.KEY_UP,
"ControlLeft": ecodes.KEY_LEFTCTRL,
"ShiftLeft": ecodes.KEY_LEFTSHIFT,
"AltLeft": ecodes.KEY_LEFTALT,
"MetaLeft": ecodes.KEY_LEFTMETA,
"ControlRight": ecodes.KEY_RIGHTCTRL,
"ShiftRight": ecodes.KEY_RIGHTSHIFT,
"AltRight": ecodes.KEY_RIGHTALT,
"MetaRight": ecodes.KEY_RIGHTMETA,
"Pause": ecodes.KEY_PAUSE,
"ScrollLock": ecodes.KEY_SCROLLLOCK,
"NumLock": ecodes.KEY_NUMLOCK,
"ContextMenu": ecodes.KEY_CONTEXT_MENU,
"NumpadDivide": ecodes.KEY_KPSLASH,
"NumpadMultiply": ecodes.KEY_KPASTERISK,
"NumpadSubtract": ecodes.KEY_KPMINUS,
"NumpadAdd": ecodes.KEY_KPPLUS,
"NumpadEnter": ecodes.KEY_KPENTER,
"Numpad1": ecodes.KEY_KP1,
"Numpad2": ecodes.KEY_KP2,
"Numpad3": ecodes.KEY_KP3,
"Numpad4": ecodes.KEY_KP4,
"Numpad5": ecodes.KEY_KP5,
"Numpad6": ecodes.KEY_KP6,
"Numpad7": ecodes.KEY_KP7,
"Numpad8": ecodes.KEY_KP8,
"Numpad9": ecodes.KEY_KP9,
"Numpad0": ecodes.KEY_KP0,
"NumpadDecimal": ecodes.KEY_KPDOT,
"Power": ecodes.KEY_POWER,
"IntlBackslash": ecodes.KEY_102ND,
"IntlYen": ecodes.KEY_YEN,
"IntlRo": ecodes.KEY_RO,
"KanaMode": ecodes.KEY_KATAKANA,
"Convert": ecodes.KEY_HENKAN,
"NonConvert": ecodes.KEY_MUHENKAN,
"AudioVolumeMute": ecodes.KEY_MUTE,
"AudioVolumeUp": ecodes.KEY_VOLUMEUP,
"AudioVolumeDown": ecodes.KEY_VOLUMEDOWN,
"F20": ecodes.KEY_F20,
}
# =====
class WebModifiers:
SHIFT_LEFT = "ShiftLeft"
SHIFT_RIGHT = "ShiftRight"
class EvdevModifiers:
SHIFT_LEFT = ecodes.KEY_LEFTSHIFT
SHIFT_RIGHT = ecodes.KEY_RIGHTSHIFT
SHIFTS = set([SHIFT_LEFT, SHIFT_RIGHT])
ALT_LEFT = "AltLeft"
ALT_RIGHT = "AltRight"
ALT_LEFT = ecodes.KEY_LEFTALT
ALT_RIGHT = ecodes.KEY_RIGHTALT
ALTS = set([ALT_LEFT, ALT_RIGHT])
CTRL_LEFT = "ControlLeft"
CTRL_RIGHT = "ControlRight"
CTRL_LEFT = ecodes.KEY_LEFTCTRL
CTRL_RIGHT = ecodes.KEY_RIGHTCTRL
CTRLS = set([CTRL_LEFT, CTRL_RIGHT])
META_LEFT = "MetaLeft"
META_RIGHT = "MetaRight"
META_LEFT = ecodes.KEY_LEFTMETA
META_RIGHT = ecodes.KEY_RIGHTMETA
METAS = set([META_LEFT, META_RIGHT])
ALL = (SHIFTS | ALTS | CTRLS | METAS)
@ -192,10 +317,10 @@ class X11Modifiers:
# =====
@dataclasses.dataclass(frozen=True)
class At1Key:
code: int
code: int
shift: bool
altgr: bool = False
ctrl: bool = False
ctrl: bool = False
X11_TO_AT1 = {
@ -357,116 +482,120 @@ X11_TO_AT1 = {
}
AT1_TO_WEB = {
1: "Escape",
2: "Digit1",
3: "Digit2",
4: "Digit3",
5: "Digit4",
6: "Digit5",
7: "Digit6",
8: "Digit7",
9: "Digit8",
10: "Digit9",
11: "Digit0",
12: "Minus",
13: "Equal",
14: "Backspace",
15: "Tab",
16: "KeyQ",
17: "KeyW",
18: "KeyE",
19: "KeyR",
20: "KeyT",
21: "KeyY",
22: "KeyU",
23: "KeyI",
24: "KeyO",
25: "KeyP",
26: "BracketLeft",
27: "BracketRight",
28: "Enter",
29: "ControlLeft",
30: "KeyA",
31: "KeyS",
32: "KeyD",
33: "KeyF",
34: "KeyG",
35: "KeyH",
36: "KeyJ",
37: "KeyK",
38: "KeyL",
39: "Semicolon",
40: "Quote",
41: "Backquote",
42: "ShiftLeft",
43: "Backslash",
44: "KeyZ",
45: "KeyX",
46: "KeyC",
47: "KeyV",
48: "KeyB",
49: "KeyN",
50: "KeyM",
51: "Comma",
52: "Period",
53: "Slash",
54: "ShiftRight",
55: "NumpadMultiply",
56: "AltLeft",
57: "Space",
58: "CapsLock",
59: "F1",
60: "F2",
61: "F3",
62: "F4",
63: "F5",
64: "F6",
65: "F7",
66: "F8",
67: "F9",
68: "F10",
69: "NumLock",
70: "ScrollLock",
71: "Numpad7",
72: "Numpad8",
73: "Numpad9",
74: "NumpadSubtract",
75: "Numpad4",
76: "Numpad5",
77: "Numpad6",
78: "NumpadAdd",
79: "Numpad1",
80: "Numpad2",
81: "Numpad3",
82: "Numpad0",
83: "NumpadDecimal",
84: "PrintScreen",
86: "IntlBackslash",
87: "F11",
88: "F12",
112: "KanaMode",
115: "IntlRo",
121: "Convert",
123: "NonConvert",
125: "IntlYen",
57372: "NumpadEnter",
57373: "ControlRight",
57397: "NumpadDivide",
57400: "AltRight",
57414: "Pause",
57415: "Home",
57416: "ArrowUp",
57417: "PageUp",
57419: "ArrowLeft",
57421: "ArrowRight",
57423: "End",
57424: "ArrowDown",
57425: "PageDown",
57426: "Insert",
57427: "Delete",
57435: "MetaLeft",
57436: "MetaRight",
57437: "ContextMenu",
57438: "Power",
AT1_TO_EVDEV = {
1: ecodes.KEY_ESC,
2: ecodes.KEY_1,
3: ecodes.KEY_2,
4: ecodes.KEY_3,
5: ecodes.KEY_4,
6: ecodes.KEY_5,
7: ecodes.KEY_6,
8: ecodes.KEY_7,
9: ecodes.KEY_8,
10: ecodes.KEY_9,
11: ecodes.KEY_0,
12: ecodes.KEY_MINUS,
13: ecodes.KEY_EQUAL,
14: ecodes.KEY_BACKSPACE,
15: ecodes.KEY_TAB,
16: ecodes.KEY_Q,
17: ecodes.KEY_W,
18: ecodes.KEY_E,
19: ecodes.KEY_R,
20: ecodes.KEY_T,
21: ecodes.KEY_Y,
22: ecodes.KEY_U,
23: ecodes.KEY_I,
24: ecodes.KEY_O,
25: ecodes.KEY_P,
26: ecodes.KEY_LEFTBRACE,
27: ecodes.KEY_RIGHTBRACE,
28: ecodes.KEY_ENTER,
29: ecodes.KEY_LEFTCTRL,
30: ecodes.KEY_A,
31: ecodes.KEY_S,
32: ecodes.KEY_D,
33: ecodes.KEY_F,
34: ecodes.KEY_G,
35: ecodes.KEY_H,
36: ecodes.KEY_J,
37: ecodes.KEY_K,
38: ecodes.KEY_L,
39: ecodes.KEY_SEMICOLON,
40: ecodes.KEY_APOSTROPHE,
41: ecodes.KEY_GRAVE,
42: ecodes.KEY_LEFTSHIFT,
43: ecodes.KEY_BACKSLASH,
44: ecodes.KEY_Z,
45: ecodes.KEY_X,
46: ecodes.KEY_C,
47: ecodes.KEY_V,
48: ecodes.KEY_B,
49: ecodes.KEY_N,
50: ecodes.KEY_M,
51: ecodes.KEY_COMMA,
52: ecodes.KEY_DOT,
53: ecodes.KEY_SLASH,
54: ecodes.KEY_RIGHTSHIFT,
55: ecodes.KEY_KPASTERISK,
56: ecodes.KEY_LEFTALT,
57: ecodes.KEY_SPACE,
58: ecodes.KEY_CAPSLOCK,
59: ecodes.KEY_F1,
60: ecodes.KEY_F2,
61: ecodes.KEY_F3,
62: ecodes.KEY_F4,
63: ecodes.KEY_F5,
64: ecodes.KEY_F6,
65: ecodes.KEY_F7,
66: ecodes.KEY_F8,
67: ecodes.KEY_F9,
68: ecodes.KEY_F10,
69: ecodes.KEY_NUMLOCK,
70: ecodes.KEY_SCROLLLOCK,
71: ecodes.KEY_KP7,
72: ecodes.KEY_KP8,
73: ecodes.KEY_KP9,
74: ecodes.KEY_KPMINUS,
75: ecodes.KEY_KP4,
76: ecodes.KEY_KP5,
77: ecodes.KEY_KP6,
78: ecodes.KEY_KPPLUS,
79: ecodes.KEY_KP1,
80: ecodes.KEY_KP2,
81: ecodes.KEY_KP3,
82: ecodes.KEY_KP0,
83: ecodes.KEY_KPDOT,
84: ecodes.KEY_SYSRQ,
86: ecodes.KEY_102ND,
87: ecodes.KEY_F11,
88: ecodes.KEY_F12,
90: ecodes.KEY_F20,
112: ecodes.KEY_KATAKANA,
115: ecodes.KEY_RO,
121: ecodes.KEY_HENKAN,
123: ecodes.KEY_MUHENKAN,
125: ecodes.KEY_YEN,
57372: ecodes.KEY_KPENTER,
57373: ecodes.KEY_RIGHTCTRL,
57376: ecodes.KEY_MUTE,
57390: ecodes.KEY_VOLUMEDOWN,
57392: ecodes.KEY_VOLUMEUP,
57397: ecodes.KEY_KPSLASH,
57400: ecodes.KEY_RIGHTALT,
57414: ecodes.KEY_PAUSE,
57415: ecodes.KEY_HOME,
57416: ecodes.KEY_UP,
57417: ecodes.KEY_PAGEUP,
57419: ecodes.KEY_LEFT,
57421: ecodes.KEY_RIGHT,
57423: ecodes.KEY_END,
57424: ecodes.KEY_DOWN,
57425: ecodes.KEY_PAGEDOWN,
57426: ecodes.KEY_INSERT,
57427: ecodes.KEY_DELETE,
57435: ecodes.KEY_LEFTMETA,
57436: ecodes.KEY_RIGHTMETA,
57437: ecodes.KEY_CONTEXT_MENU,
57438: ecodes.KEY_POWER,
}

View File

@ -22,6 +22,8 @@
import dataclasses
from evdev import ecodes
# =====
@dataclasses.dataclass(frozen=True)
@ -31,7 +33,7 @@ class McuKey:
@dataclasses.dataclass(frozen=True)
class UsbKey:
code: int
code: int
is_modifier: bool
@ -41,29 +43,36 @@ class Key:
usb: UsbKey
<%! import operator %>
KEYMAP: dict[str, Key] = {
KEYMAP: dict[int, Key] = {
% for km in sorted(keymap, key=operator.attrgetter("mcu_code")):
"${km.web_name}": Key(mcu=McuKey(code=${km.mcu_code}), usb=UsbKey(code=${km.usb_key.code}, is_modifier=${km.usb_key.is_modifier})),
ecodes.${km.evdev_name}: Key(mcu=McuKey(code=${km.mcu_code}), usb=UsbKey(code=${km.usb_key.code}, is_modifier=${km.usb_key.is_modifier})),
% endfor
}
WEB_TO_EVDEV = {
% for km in sorted(keymap, key=operator.attrgetter("mcu_code")):
"${km.web_name}": ecodes.${km.evdev_name},
% endfor
}
# =====
class WebModifiers:
SHIFT_LEFT = "ShiftLeft"
SHIFT_RIGHT = "ShiftRight"
class EvdevModifiers:
SHIFT_LEFT = ecodes.KEY_LEFTSHIFT
SHIFT_RIGHT = ecodes.KEY_RIGHTSHIFT
SHIFTS = set([SHIFT_LEFT, SHIFT_RIGHT])
ALT_LEFT = "AltLeft"
ALT_RIGHT = "AltRight"
ALT_LEFT = ecodes.KEY_LEFTALT
ALT_RIGHT = ecodes.KEY_RIGHTALT
ALTS = set([ALT_LEFT, ALT_RIGHT])
CTRL_LEFT = "ControlLeft"
CTRL_RIGHT = "ControlRight"
CTRL_LEFT = ecodes.KEY_LEFTCTRL
CTRL_RIGHT = ecodes.KEY_RIGHTCTRL
CTRLS = set([CTRL_LEFT, CTRL_RIGHT])
META_LEFT = "MetaLeft"
META_RIGHT = "MetaRight"
META_LEFT = ecodes.KEY_LEFTMETA
META_RIGHT = ecodes.KEY_RIGHTMETA
METAS = set([META_LEFT, META_RIGHT])
ALL = (SHIFTS | ALTS | CTRLS | METAS)
@ -84,10 +93,10 @@ class X11Modifiers:
# =====
@dataclasses.dataclass(frozen=True)
class At1Key:
code: int
code: int
shift: bool
altgr: bool = False
ctrl: bool = False
ctrl: bool = False
X11_TO_AT1 = {
@ -99,8 +108,8 @@ X11_TO_AT1 = {
}
AT1_TO_WEB = {
AT1_TO_EVDEV = {
% for km in sorted(keymap, key=operator.attrgetter("at1_code")):
${km.at1_code}: "${km.web_name}",
${km.at1_code}: ecodes.${km.evdev_name},
% endfor
}

View File

@ -25,8 +25,9 @@ import ctypes.util
from typing import Generator
from evdev import ecodes
from .keysym import SymmapModifiers
from .mappings import WebModifiers
# =====
@ -56,10 +57,10 @@ def _ch_to_keysym(ch: str) -> int:
# =====
def text_to_web_keys( # pylint: disable=too-many-branches
def text_to_evdev_keys( # pylint: disable=too-many-branches
text: str,
symmap: dict[int, dict[int, str]],
) -> Generator[tuple[str, bool], None, None]:
symmap: dict[int, dict[int, int]],
) -> Generator[tuple[int, bool], None, None]:
shift = False
altgr = False
@ -68,11 +69,11 @@ def text_to_web_keys( # pylint: disable=too-many-branches
# https://stackoverflow.com/questions/12343987/convert-ascii-character-to-x11-keycode
# https://www.ascii-code.com
if ch == "\n":
keys = {0: "Enter"}
keys = {0: ecodes.KEY_ENTER}
elif ch == "\t":
keys = {0: "Tab"}
keys = {0: ecodes.KEY_TAB}
elif ch == " ":
keys = {0: "Space"}
keys = {0: ecodes.KEY_SPACE}
else:
if ch in ["", "", ""]:
ch = "'"
@ -95,17 +96,17 @@ def text_to_web_keys( # pylint: disable=too-many-branches
continue
if modifiers & SymmapModifiers.SHIFT and not shift:
yield (WebModifiers.SHIFT_LEFT, True)
yield (ecodes.KEY_LEFTSHIFT, True)
shift = True
elif not (modifiers & SymmapModifiers.SHIFT) and shift:
yield (WebModifiers.SHIFT_LEFT, False)
yield (ecodes.KEY_LEFTSHIFT, False)
shift = False
if modifiers & SymmapModifiers.ALTGR and not altgr:
yield (WebModifiers.ALT_RIGHT, True)
yield (ecodes.KEY_RIGHTALT, True)
altgr = True
elif not (modifiers & SymmapModifiers.ALTGR) and altgr:
yield (WebModifiers.ALT_RIGHT, False)
yield (ecodes.KEY_RIGHTALT, False)
altgr = False
yield (key, True)
@ -113,6 +114,6 @@ def text_to_web_keys( # pylint: disable=too-many-branches
break
if shift:
yield (WebModifiers.SHIFT_LEFT, False)
yield (ecodes.KEY_LEFTSHIFT, False)
if altgr:
yield (WebModifiers.ALT_RIGHT, False)
yield (ecodes.KEY_RIGHTALT, False)

View File

@ -20,6 +20,8 @@
# ========================================================================== #
from evdev import ecodes
from . import tools
@ -46,3 +48,13 @@ class MouseDelta:
@classmethod
def normalize(cls, value: int) -> int:
return min(max(cls.MIN, value), cls.MAX)
# =====
MOUSE_TO_EVDEV = {
"left": ecodes.BTN_LEFT,
"right": ecodes.BTN_RIGHT,
"middle": ecodes.BTN_MIDDLE,
"up": ecodes.BTN_BACK,
"down": ecodes.BTN_FORWARD,
}

View File

@ -54,7 +54,8 @@ class BaseAtx(BasePlugin):
async def poll_state(self) -> AsyncGenerator[dict, None]:
# ==== Granularity table ====
# - enabled -- Full
# - busy -- Partial
# - busy -- Partial, follows with acts
# - acts -- Partial, follows with busy
# - leds -- Partial
# ===========================

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