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
205 changed files with 9359 additions and 4653 deletions

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

View File

@@ -43,6 +43,10 @@ class Plugin(BaseAtx):
return {
"enabled": False,
"busy": False,
"acts": {
"power": False,
"reset": False,
},
"leds": {
"power": False,
"hdd": False,

View File

@@ -75,7 +75,8 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
self.__long_click_delay = long_click_delay
self.__notifier = aiotools.AioNotifier()
self.__region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier)
self.__power_region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier)
self.__reset_region = aiotools.AioExclusiveRegion(AtxIsBusyError, self.__notifier)
self.__line_req: (gpiod.LineRequest | None) = None
@@ -122,9 +123,15 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
)
async def get_state(self) -> dict:
power_busy = self.__power_region.is_busy()
reset_busy = self.__reset_region.is_busy()
return {
"enabled": True,
"busy": self.__region.is_busy(),
"busy": (power_busy or reset_busy),
"acts": {
"power": power_busy,
"reset": reset_busy,
},
"leds": {
"power": self.__reader.get(self.__power_led_pin),
"hdd": self.__reader.get(self.__hdd_led_pin),
@@ -175,13 +182,13 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
# =====
async def click_power(self, wait: bool) -> None:
await self.__click("power", self.__power_switch_pin, self.__click_delay, wait)
await self.__click("power", self.__power_region, self.__power_switch_pin, self.__click_delay, wait)
async def click_power_long(self, wait: bool) -> None:
await self.__click("power_long", self.__power_switch_pin, self.__long_click_delay, wait)
await self.__click("power_long", self.__power_region, self.__power_switch_pin, self.__long_click_delay, wait)
async def click_reset(self, wait: bool) -> None:
await self.__click("reset", self.__reset_switch_pin, self.__click_delay, wait)
await self.__click("reset", self.__reset_region, self.__reset_switch_pin, self.__click_delay, wait)
# =====
@@ -189,14 +196,14 @@ class Plugin(BaseAtx): # pylint: disable=too-many-instance-attributes
return (await self.get_state())["leds"]["power"]
@aiotools.atomic_fg
async def __click(self, name: str, pin: int, delay: float, wait: bool) -> None:
async def __click(self, name: str, region: aiotools.AioExclusiveRegion, pin: int, delay: float, wait: bool) -> None:
if wait:
with self.__region:
with region:
await self.__inner_click(name, pin, delay)
else:
await aiotools.run_region_task(
f"Can't perform ATX {name} click or operation was not completed",
self.__region, self.__inner_click, name, pin, delay,
region, self.__inner_click, name, pin, delay,
)
@aiotools.atomic_fg

View File

@@ -0,0 +1,31 @@
# ========================================================================== #
# #
# 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 BaseAuthService
# =====
class Plugin(BaseAuthService):
async def authorize(self, user: str, passwd: str) -> bool:
_ = user
_ = passwd
return False

View File

@@ -20,12 +20,12 @@
# ========================================================================== #
import passlib.apache
from ...yamlconf import Option
from ...validators.os import valid_abs_file
from ...crypto import KvmdHtpasswdFile
from . import BaseAuthService
@@ -43,5 +43,5 @@ class Plugin(BaseAuthService):
async def authorize(self, user: str, passwd: str) -> bool:
assert user == user.strip()
assert user
htpasswd = passlib.apache.HtpasswdFile(self.__path)
htpasswd = KvmdHtpasswdFile(self.__path)
return htpasswd.check_password(user, passwd)

View File

@@ -29,6 +29,8 @@ from typing import Callable
from typing import AsyncGenerator
from typing import Any
from evdev import ecodes
from ...yamlconf import Option
from ...validators.basic import valid_bool
@@ -37,7 +39,8 @@ from ...validators.basic import valid_string_list
from ...validators.hid import valid_hid_key
from ...validators.hid import valid_hid_mouse_move
from ...keyboard.mappings import WebModifiers
from ...keyboard.mappings import WEB_TO_EVDEV
from ...keyboard.mappings import EvdevModifiers
from ...mouse import MouseRange
from .. import BasePlugin
@@ -60,7 +63,7 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
jiggler_interval: int,
) -> None:
self.__ignore_keys = ignore_keys
self.__ignore_keys = [WEB_TO_EVDEV[key] for key in ignore_keys]
self.__mouse_x_range = (mouse_x_min, mouse_x_max)
self.__mouse_y_range = (mouse_y_min, mouse_y_max)
@@ -69,7 +72,7 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
self.__j_active = jiggler_active
self.__j_interval = jiggler_interval
self.__j_absolute = True
self.__j_activity_ts = 0
self.__j_activity_ts = self.__get_monotonic_seconds()
self.__j_last_x = 0
self.__j_last_y = 0
@@ -140,37 +143,42 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
# =====
def get_inactivity_seconds(self) -> int:
return (self.__get_monotonic_seconds() - self.__j_activity_ts)
# =====
async def send_key_events(
self,
keys: Iterable[tuple[str, bool]],
keys: Iterable[tuple[int, bool]],
no_ignore_keys: bool=False,
slow: bool=False,
delay: float=0.0,
) -> None:
for (key, state) in keys:
if no_ignore_keys or key not in self.__ignore_keys:
if slow:
await asyncio.sleep(0.02)
if delay > 0:
await asyncio.sleep(delay)
self.send_key_event(key, state, False)
def send_key_event(self, key: str, state: bool, finish: bool) -> None:
def send_key_event(self, key: int, state: bool, finish: bool) -> None:
self._send_key_event(key, state)
if state and finish and (key not in WebModifiers.ALL and key != "PrintScreen"):
if state and finish and (key not in EvdevModifiers.ALL and key != ecodes.KEY_SYSRQ):
# Считаем что PrintScreen это модификатор для Alt+SysRq+...
# По-хорошему надо учитывать факт нажатия на Alt, но можно и забить.
self._send_key_event(key, False)
self.__bump_activity()
def _send_key_event(self, key: str, state: bool) -> None:
def _send_key_event(self, key: int, state: bool) -> None:
raise NotImplementedError
# =====
def send_mouse_button_event(self, button: str, state: bool) -> None:
def send_mouse_button_event(self, button: int, state: bool) -> None:
self._send_mouse_button_event(button, state)
self.__bump_activity()
def _send_mouse_button_event(self, button: str, state: bool) -> None:
def _send_mouse_button_event(self, button: int, state: bool) -> None:
raise NotImplementedError
# =====
@@ -246,7 +254,10 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
handler(*xy)
def __bump_activity(self) -> None:
self.__j_activity_ts = int(time.monotonic())
self.__j_activity_ts = self.__get_monotonic_seconds()
def __get_monotonic_seconds(self) -> int:
return int(time.monotonic())
def _set_jiggler_absolute(self, absolute: bool) -> None:
self.__j_absolute = absolute
@@ -268,14 +279,14 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes
async def systask(self) -> None:
while True:
if self.__j_active and (self.__j_activity_ts + self.__j_interval < int(time.monotonic())):
if self.__j_active and (self.__j_activity_ts + self.__j_interval < self.__get_monotonic_seconds()):
if self.__j_absolute:
(x, y) = (self.__j_last_x, self.__j_last_y)
for move in [100, -100, 100, -100, 0]:
for move in (([100, -100] * 5) + [0]):
self.send_mouse_move_event(MouseRange.normalize(x + move), MouseRange.normalize(y + move))
await asyncio.sleep(0.1)
else:
for move in [10, -10, 10, -10]:
for move in ([10, -10] * 5):
self.send_mouse_relative_event(move, move)
await asyncio.sleep(0.1)
await asyncio.sleep(1)

View File

@@ -285,10 +285,10 @@ class BaseMcuHid(BaseHid, multiprocessing.Process): # pylint: disable=too-many-
def set_connected(self, connected: bool) -> None:
self.__queue_event(SetConnectedEvent(connected), clear=True)
def _send_key_event(self, key: str, state: bool) -> None:
def _send_key_event(self, key: int, state: bool) -> None:
self.__queue_event(KeyEvent(key, state))
def _send_mouse_button_event(self, button: str, state: bool) -> None:
def _send_mouse_button_event(self, button: int, state: bool) -> None:
self.__queue_event(MouseButtonEvent(button, state))
def _send_mouse_move_event(self, to_x: int, to_y: int) -> None:

View File

@@ -68,7 +68,7 @@ class Gpio: # pylint: disable=too-many-instance-attributes
self.__line_req = gpiod.request_lines(
self.__device_path,
consumer="kvmd::hid",
config=config,
config=config, # type: ignore
)
def __exit__(

View File

@@ -23,6 +23,8 @@
import dataclasses
import struct
from evdev import ecodes
from ....keyboard.mappings import KEYMAP
from ....mouse import MouseRange
@@ -106,33 +108,36 @@ class ClearEvent(BaseEvent):
@dataclasses.dataclass(frozen=True)
class KeyEvent(BaseEvent):
name: str
code: int
state: bool
def __post_init__(self) -> None:
assert self.name in KEYMAP
assert self.code in KEYMAP
def make_request(self) -> bytes:
code = KEYMAP[self.name].mcu.code
code = KEYMAP[self.code].mcu.code
return _make_request(struct.pack(">BBBxx", 0x11, code, int(self.state)))
@dataclasses.dataclass(frozen=True)
class MouseButtonEvent(BaseEvent):
name: str
code: int
state: bool
def __post_init__(self) -> None:
assert self.name in ["left", "right", "middle", "up", "down"]
assert self.code in [
ecodes.BTN_LEFT, ecodes.BTN_RIGHT, ecodes.BTN_MIDDLE,
ecodes.BTN_BACK, ecodes.BTN_FORWARD,
]
def make_request(self) -> bytes:
(code, state_pressed, is_main) = {
"left": (0b10000000, 0b00001000, True),
"right": (0b01000000, 0b00000100, True),
"middle": (0b00100000, 0b00000010, True),
"up": (0b10000000, 0b00001000, False), # Back
"down": (0b01000000, 0b00000100, False), # Forward
}[self.name]
ecodes.BTN_LEFT: (0b10000000, 0b00001000, True),
ecodes.BTN_RIGHT: (0b01000000, 0b00000100, True),
ecodes.BTN_MIDDLE: (0b00100000, 0b00000010, True),
ecodes.BTN_BACK: (0b10000000, 0b00001000, False), # Up
ecodes.BTN_FORWARD: (0b01000000, 0b00000100, False), # Down
}[self.code]
if self.state:
code |= state_pressed
if is_main:

View File

@@ -203,10 +203,10 @@ class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes
self._set_jiggler_active(jiggler)
self.__notifier.notify()
def _send_key_event(self, key: str, state: bool) -> None:
def _send_key_event(self, key: int, state: bool) -> None:
self.__server.queue_event(make_keyboard_event(key, state))
def _send_mouse_button_event(self, button: str, state: bool) -> None:
def _send_mouse_button_event(self, button: int, state: bool) -> None:
self.__server.queue_event(MouseButtonEvent(button, state))
def _send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None:

View File

@@ -168,10 +168,10 @@ class Plugin(BaseHid, multiprocessing.Process): # pylint: disable=too-many-inst
self._set_jiggler_active(jiggler)
self.__notifier.notify()
def _send_key_event(self, key: str, state: bool) -> None:
def _send_key_event(self, key: int, state: bool) -> None:
self.__queue_cmd(self.__keyboard.process_key(key, state))
def _send_mouse_button_event(self, button: str, state: bool) -> None:
def _send_mouse_button_event(self, button: int, state: bool) -> None:
self.__queue_cmd(self.__mouse.process_button(button, state))
def _send_mouse_move_event(self, to_x: int, to_y: int) -> None:

View File

@@ -46,7 +46,7 @@ class Keyboard:
async def get_leds(self) -> dict[str, bool]:
return (await self.__leds.get())
def process_key(self, key: str, state: bool) -> bytes:
def process_key(self, key: int, state: bool) -> bytes:
code = KEYMAP[key].usb.code
is_modifier = KEYMAP[key].usb.is_modifier
if state:

View File

@@ -22,6 +22,8 @@
import math
from evdev import ecodes
from ....mouse import MouseRange
from ....mouse import MouseDelta
@@ -43,18 +45,18 @@ class Mouse: # pylint: disable=too-many-instance-attributes
def is_absolute(self) -> bool:
return self.__absolute
def process_button(self, button: str, state: bool) -> bytes:
def process_button(self, button: int, state: bool) -> bytes:
code = 0x00
match button:
case "left":
case ecodes.BTN_LEFT:
code = 0x01
case "right":
case ecodes.BTN_RIGHT:
code = 0x02
case "middle":
case ecodes.BTN_MIDDLE:
code = 0x04
case "up":
case ecodes.BTN_BACK:
code = 0x08
case "down":
case ecodes.BTN_FORWARD:
code = 0x10
if code:
if state:

View File

@@ -110,7 +110,7 @@ class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes
"horizontal_wheel": Option(True, type=valid_bool),
},
"mouse_alt": {
"device": Option("", type=valid_abs_path, if_empty="", unpack_as="device_path"),
"device": Option("/dev/kvmd-hid-mouse-alt", type=valid_abs_path, if_empty="", unpack_as="device_path"),
"select_timeout": Option(0.1, type=valid_float_f01),
"queue_timeout": Option(0.1, type=valid_float_f01),
"write_retries": Option(150, type=valid_int_f1),
@@ -206,10 +206,10 @@ class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes
self._set_jiggler_active(jiggler)
self.__notifier.notify()
def _send_key_event(self, key: str, state: bool) -> None:
def _send_key_event(self, key: int, state: bool) -> None:
self.__keyboard_proc.send_key_event(key, state)
def _send_mouse_button_event(self, button: str, state: bool) -> None:
def _send_mouse_button_event(self, button: int, state: bool) -> None:
self.__mouse_current.send_button_event(button, state)
def _send_mouse_move_event(self, to_x: int, to_y: int) -> None:

View File

@@ -23,6 +23,8 @@
import struct
import dataclasses
from evdev import ecodes
from ....keyboard.mappings import UsbKey
from ....keyboard.mappings import KEYMAP
@@ -46,7 +48,7 @@ class ResetEvent(BaseEvent):
# =====
@dataclasses.dataclass(frozen=True)
class KeyEvent(BaseEvent):
key: UsbKey
key: UsbKey
state: bool
def __post_init__(self) -> None:
@@ -56,13 +58,13 @@ class KeyEvent(BaseEvent):
@dataclasses.dataclass(frozen=True)
class ModifierEvent(BaseEvent):
modifier: UsbKey
state: bool
state: bool
def __post_init__(self) -> None:
assert self.modifier.is_modifier
def make_keyboard_event(key: str, state: bool) -> (KeyEvent | ModifierEvent):
def make_keyboard_event(key: int, state: bool) -> (KeyEvent | ModifierEvent):
usb_key = KEYMAP[key].usb
if usb_key.is_modifier:
return ModifierEvent(usb_key, state)
@@ -102,17 +104,17 @@ def make_keyboard_report(
# =====
@dataclasses.dataclass(frozen=True)
class MouseButtonEvent(BaseEvent):
button: str
state: bool
code: int = 0
button: int
state: bool
code: int = 0
def __post_init__(self) -> None:
object.__setattr__(self, "code", {
"left": 0x1,
"right": 0x2,
"middle": 0x4,
"up": 0x8, # Back
"down": 0x10, # Forward
ecodes.BTN_LEFT: 0x1,
ecodes.BTN_RIGHT: 0x2,
ecodes.BTN_MIDDLE: 0x4,
ecodes.BTN_BACK: 0x8, # Back/Up
ecodes.BTN_FORWARD: 0x10, # Forward/Down
}[self.button])

View File

@@ -67,7 +67,7 @@ class KeyboardProcess(BaseDeviceProcess):
self._clear_queue()
self._queue_event(ResetEvent())
def send_key_event(self, key: str, state: bool) -> None:
def send_key_event(self, key: int, state: bool) -> None:
self._queue_event(make_keyboard_event(key, state))
# =====

View File

@@ -85,7 +85,7 @@ class MouseProcess(BaseDeviceProcess):
self._clear_queue()
self._queue_event(ResetEvent())
def send_button_event(self, button: str, state: bool) -> None:
def send_button_event(self, button: int, state: bool) -> None:
self._queue_event(MouseButtonEvent(button, state))
def send_move_event(self, to_x: int, to_y: int) -> None:

View File

@@ -153,7 +153,7 @@ class _SpiPhy(BasePhy): # pylint: disable=too-many-instance-attributes
)
@contextlib.contextmanager
def __sw_cs_connected(self) -> Generator[(Callable[[bool], bool] | None), None, None]:
def __sw_cs_connected(self) -> Generator[(Callable[[bool], None] | None), None, None]:
if self.__sw_cs_pin > 0:
with gpiod.request_lines(
self.__gpio_device_path,

View File

@@ -108,7 +108,7 @@ class Plugin(BaseUserGpioDriver):
async def write(self, pin: str, state: bool) -> None:
async with self.__lock:
if self.read(pin) == state:
if (await self.read(pin)) == state:
return
if pin == "udc":
if state: