From 3cbeabe2e86ba988f58c3a269889f1cc241b3ca8 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 20 Nov 2024 17:50:27 +0200 Subject: [PATCH 01/84] VNC: Supported ExtendedMouseButtons --- kvmd/apps/vnc/rfb/__init__.py | 9 +++++++++ kvmd/apps/vnc/rfb/encodings.py | 18 ++++++++++-------- kvmd/apps/vnc/server.py | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/kvmd/apps/vnc/rfb/__init__.py b/kvmd/apps/vnc/rfb/__init__.py index d2c6b50b..fc6e435c 100644 --- a/kvmd/apps/vnc/rfb/__init__.py +++ b/kvmd/apps/vnc/rfb/__init__.py @@ -464,6 +464,10 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute if self._encodings.has_ext_keys: # Preferred method await self._write_fb_update("ExtKeys FBUR", 0, 0, RfbEncodings.EXT_KEYS, drain=True) + + if self._encodings.has_ext_mouse: # Preferred too + await self._write_fb_update("ExtMouse FBUR", 0, 0, RfbEncodings.EXT_MOUSE, drain=True) + await self._on_set_encodings() async def __handle_fb_update_request(self) -> None: @@ -486,11 +490,16 @@ class RfbClient(RfbClientStream): # pylint: disable=too-many-instance-attribute async def __handle_pointer_event(self) -> None: (buttons, to_x, to_y) = await self._read_struct("pointer event", "B HH") + ext_buttons = 0 + if self._encodings.has_ext_mouse and (buttons & 0x80): # Marker bit 7 for ext event + ext_buttons = await self._read_number("ext pointer event buttons", "B") await self._on_pointer_event( buttons={ "left": bool(buttons & 0x1), "right": bool(buttons & 0x4), "middle": bool(buttons & 0x2), + "up": bool(ext_buttons & 0x2), + "down": bool(ext_buttons & 0x1), }, wheel={ "x": (-4 if buttons & 0x40 else (4 if buttons & 0x20 else 0)), diff --git a/kvmd/apps/vnc/rfb/encodings.py b/kvmd/apps/vnc/rfb/encodings.py index 597e3a92..940a383f 100644 --- a/kvmd/apps/vnc/rfb/encodings.py +++ b/kvmd/apps/vnc/rfb/encodings.py @@ -31,6 +31,7 @@ class RfbEncodings: RENAME = -307 # DesktopName Pseudo-encoding LEDS_STATE = -261 # QEMU LED State Pseudo-encoding EXT_KEYS = -258 # QEMU Extended Key Events Pseudo-encoding + EXT_MOUSE = -316 # ExtendedMouseButtons Pseudo-encoding CONT_UPDATES = -313 # ContinuousUpdates Pseudo-encoding TIGHT = 7 @@ -50,16 +51,17 @@ def _make_meta(variants: (int | frozenset[int])) -> dict: class RfbClientEncodings: # pylint: disable=too-many-instance-attributes encodings: frozenset[int] - has_resize: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RESIZE)) # noqa: E224 - has_rename: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RENAME)) # noqa: E224 - has_leds_state: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.LEDS_STATE)) # noqa: E224 - has_ext_keys: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_KEYS)) # noqa: E224 - has_cont_updates: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.CONT_UPDATES)) # noqa: E224 + has_resize: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RESIZE)) # noqa: E224 + has_rename: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.RENAME)) # noqa: E224 + has_leds_state: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.LEDS_STATE)) # noqa: E224 + has_ext_keys: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_KEYS)) # noqa: E224 + has_ext_mouse: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.EXT_MOUSE)) # noqa: E224 + has_cont_updates: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.CONT_UPDATES)) # noqa: E224 - has_tight: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.TIGHT)) # noqa: E224 - tight_jpeg_quality: int = dataclasses.field(default=0, metadata=_make_meta(frozenset(RfbEncodings.TIGHT_JPEG_QUALITIES))) # noqa: E224 + has_tight: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.TIGHT)) # noqa: E224 + tight_jpeg_quality: int = dataclasses.field(default=0, metadata=_make_meta(frozenset(RfbEncodings.TIGHT_JPEG_QUALITIES))) # noqa: E224 - has_h264: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.H264)) # noqa: E224 + has_h264: bool = dataclasses.field(default=False, metadata=_make_meta(RfbEncodings.H264)) # noqa: E224 def get_summary(self) -> list[str]: summary: list[str] = [f"encodings -- {sorted(self.encodings)}"] diff --git a/kvmd/apps/vnc/server.py b/kvmd/apps/vnc/server.py index 5abca7b0..6bb7caed 100644 --- a/kvmd/apps/vnc/server.py +++ b/kvmd/apps/vnc/server.py @@ -130,7 +130,7 @@ class _Client(RfbClient): # pylint: disable=too-many-instance-attributes # Эти состояния шарить не обязательно - бекенд исключает дублирующиеся события. # Все это нужно только чтобы не посылать лишние жсоны в сокет KVMD - self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle"], None) + self.__mouse_buttons: dict[str, (bool | None)] = dict.fromkeys(["left", "right", "middle", "up", "down"], None) self.__mouse_move = {"x": -1, "y": -1} self.__modifiers = 0 From d25e43c9340c1061efcde966b6a01c6b0ce97b0f Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 20 Nov 2024 18:53:10 +0200 Subject: [PATCH 02/84] pikvm/pikvm#1415: Allow autoconnecting to open wifi --- scripts/kvmd-bootconfig | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/kvmd-bootconfig b/scripts/kvmd-bootconfig index c2fc5c6f..6e89944d 100755 --- a/scripts/kvmd-bootconfig +++ b/scripts/kvmd-bootconfig @@ -256,7 +256,16 @@ if [ -n "$WIFI_ESSID" ]; then else make_dhcp_iface "$WIFI_IFACE" 50 fi - wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" + if [ "${#WIFI_PASSWD}" -ge 8 ];then + wpa_passphrase "$WIFI_ESSID" "$WIFI_PASSWD" > "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" + else + cat < "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" +network={ + ssid=${WIFI_ESSID@Q} + key_mgmt=NONE +} +end_of_file + fi chmod 640 "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" if [ -n "$WIFI_HIDDEN" ]; then sed -i -e 's/^}/\tscan_ssid=1\n}/g' "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" From 8929d0f311310435f3c75eb1db2b3e09d80f3c46 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 20 Nov 2024 21:35:13 +0200 Subject: [PATCH 03/84] pikvm/pikvm#1415: kvmd-bootconfig: Supported open wifi network --- scripts/kvmd-bootconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/kvmd-bootconfig b/scripts/kvmd-bootconfig index 6e89944d..ad98e21c 100755 --- a/scripts/kvmd-bootconfig +++ b/scripts/kvmd-bootconfig @@ -261,7 +261,7 @@ if [ -n "$WIFI_ESSID" ]; then else cat < "/etc/wpa_supplicant/wpa_supplicant-$WIFI_IFACE.conf" network={ - ssid=${WIFI_ESSID@Q} + ssid=$(printf '"%q"' "$WIFI_ESSID") key_mgmt=NONE } end_of_file From 7c453b8b49866e9fa45b63f919d34f8b625ae7dd Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Fri, 22 Nov 2024 16:29:59 +0200 Subject: [PATCH 04/84] new sponsors --- web/kvm/index.html | 7 +++++++ web/kvm/window-about.pug | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/web/kvm/index.html b/web/kvm/index.html index d189ea13..d698cea9 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -2075,6 +2075,7 @@
  • Alok Anand
  • Alucard
  • Ananthaneshan Elampoornan
  • +
  • Andreas Grundler
  • Andreas Marufke
  • Andreas Schmid
  • Andrew Brant
  • @@ -2135,6 +2136,7 @@
  • Brian T Mulcahy
  • Brian Vecchiarelli
  • Brian White
  • +
  • brodonalds
  • Bruno Gomes
  • Bryan Adams
  • Bryan Montgomery
  • @@ -2471,6 +2473,7 @@
  • Mikael Wikström
  • Mike Mason
  • Mikhael Mariano
  • +
  • Milan Burda
  • Milan Múčka
  • Miles Davis
  • Minh Tang
  • @@ -2489,6 +2492,7 @@
  • Nick Roethemeier
  • Nico Baumgartner
  • Nicolai Kragh-Hansen
  • +
  • Nicolas Christener
  • Nigel Smith
  • Nihal Fernando
  • Nils Orbat
  • @@ -2496,6 +2500,7 @@
  • Nithin Philips
  • Nod Swal
  • Nolan Haynes
  • +
  • Noxigen LLC
  • nubbn
  • nybble
  • Oh Be
  • @@ -2580,6 +2585,7 @@
  • Scuba
  • Sean
  • Sean Akers
  • +
  • Sean c Rickard
  • SEAT
  • Sebastian
  • Seonwoo Lee
  • @@ -2648,6 +2654,7 @@
  • Udo Schroeter
  • Uli Fahrer
  • Vasily Lazarev
  • +
  • Venmo
  • Vidru Eduard
  • Vicente Salvador Cubedo
  • Viktor Aschenbrenner
  • diff --git a/web/kvm/window-about.pug b/web/kvm/window-about.pug index a9c50aac..d8bc8c2e 100644 --- a/web/kvm/window-about.pug +++ b/web/kvm/window-about.pug @@ -80,6 +80,7 @@ div(id="about-window" class="window") li Alok Anand li Alucard li Ananthaneshan Elampoornan + li Andreas Grundler li Andreas Marufke li Andreas Schmid li Andrew Brant @@ -140,6 +141,7 @@ div(id="about-window" class="window") li Brian T Mulcahy li Brian Vecchiarelli li Brian White + li brodonalds li Bruno Gomes li Bryan Adams li Bryan Montgomery @@ -476,6 +478,7 @@ div(id="about-window" class="window") li Mikael Wikström li Mike Mason li Mikhael Mariano + li Milan Burda li Milan Múčka li Miles Davis li Minh Tang @@ -494,6 +497,7 @@ div(id="about-window" class="window") li Nick Roethemeier li Nico Baumgartner li Nicolai Kragh-Hansen + li Nicolas Christener li Nigel Smith li Nihal Fernando li Nils Orbat @@ -501,6 +505,7 @@ div(id="about-window" class="window") li Nithin Philips li Nod Swal li Nolan Haynes + li Noxigen LLC li nubbn li nybble li Oh Be @@ -585,6 +590,7 @@ div(id="about-window" class="window") li Scuba li Sean li Sean Akers + li Sean c Rickard li SEAT li Sebastian li Seonwoo Lee @@ -653,6 +659,7 @@ div(id="about-window" class="window") li Udo Schroeter li Uli Fahrer li Vasily Lazarev + li Venmo li Vidru Eduard li Vicente Salvador Cubedo li Viktor Aschenbrenner From 1b9b27660aca79eadf975883d7a377a74106d7f0 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Fri, 22 Nov 2024 16:32:05 +0200 Subject: [PATCH 05/84] =?UTF-8?q?Bump=20version:=204.20=20=E2=86=92=204.21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- PKGBUILD | 2 +- kvmd/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 926b4e31..6c9a03fe 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.20 +current_version = 4.21 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/PKGBUILD b/PKGBUILD index 960ed365..23f9a3a6 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.20 +pkgver=4.21 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 32534ec6..38925491 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.20" +__version__ = "4.21" diff --git a/setup.py b/setup.py index 933b7b2e..ea4952ee 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.20", + version="4.21", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", From 7394588279129bba8942323867a65bbc950d1396 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Mon, 25 Nov 2024 05:26:03 +0200 Subject: [PATCH 06/84] fixed prometheus metrics --- kvmd/apps/kvmd/api/export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kvmd/apps/kvmd/api/export.py b/kvmd/apps/kvmd/api/export.py index bb048f53..fd672f7b 100644 --- a/kvmd/apps/kvmd/api/export.py +++ b/kvmd/apps/kvmd/api/export.py @@ -66,7 +66,7 @@ class ExportApi: self.__append_prometheus_rows(rows, atx_state["leds"]["power"], "pikvm_atx_power") # type: ignore for mode in sorted(UserGpioModes.ALL): - for (channel, ch_state) in gpio_state[f"{mode}s"].items(): # type: ignore + for (channel, ch_state) in gpio_state["state"][f"{mode}s"].items(): # type: ignore if not channel.startswith("__"): # Hide special GPIOs for key in ["online", "state"]: self.__append_prometheus_rows(rows, ch_state["state"], f"pikvm_gpio_{mode}_{key}_{channel}") From 0cf5f8de9e1ef67cfe6b00dd35204665fbd4e54c Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Mon, 25 Nov 2024 05:29:49 +0200 Subject: [PATCH 07/84] =?UTF-8?q?Bump=20version:=204.21=20=E2=86=92=204.22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- PKGBUILD | 2 +- kvmd/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6c9a03fe..99542aaf 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.21 +current_version = 4.22 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/PKGBUILD b/PKGBUILD index 23f9a3a6..d8e2e6eb 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.21 +pkgver=4.22 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 38925491..ac1d1017 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.21" +__version__ = "4.22" diff --git a/setup.py b/setup.py index ea4952ee..670baf56 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.21", + version="4.22", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", From 7fd4dae3c6e8007fdc6a6bf1d127271288650cf8 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 26 Nov 2024 19:11:59 +0200 Subject: [PATCH 08/84] pikvm/pikvm#1408: Additional colors for GPIO --- kvmd/apps/kvmd/ugpio.py | 2 +- web/share/css/led.css | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/kvmd/apps/kvmd/ugpio.py b/kvmd/apps/kvmd/ugpio.py index e4735c61..a3c453f5 100644 --- a/kvmd/apps/kvmd/ugpio.py +++ b/kvmd/apps/kvmd/ugpio.py @@ -408,7 +408,7 @@ class UserGpio: def __make_item_input(self, parts: list[str]) -> dict: assert len(parts) >= 1 color = (parts[1] if len(parts) > 1 else None) - if color not in ["green", "yellow", "red"]: + if color not in ["green", "yellow", "red", "blue", "cyan", "magenta", "pink", "white"]: color = "green" return { "type": UserGpioModes.INPUT, diff --git a/web/share/css/led.css b/web/share/css/led.css index b7e00116..4f5a437e 100644 --- a/web/share/css/led.css +++ b/web/share/css/led.css @@ -41,6 +41,13 @@ --led-spin-slow: spin 6s linear infinite; --led-spin-medium: spin 3s linear infinite; --led-spin-fast: spin 2s linear infinite; + + /* Additional colors for GPIO */ + --led-filter-blue: invert(0.5) sepia(1) saturate(5) hue-rotate(170deg); + --led-filter-cyan: invert(0.5) sepia(1) saturate(5) hue-rotate(130deg); + --led-filter-magenta: invert(0.5) sepia(1) saturate(5) hue-rotate(200deg); + --led-filter-pink: invert(0.5) sepia(1) saturate(5) hue-rotate(300deg); + --led-filter-white: invert(1) sepia(1); } img.led-gray { @@ -48,19 +55,16 @@ img.led-gray { -webkit-filter: var(--led-filter-gray); filter: var(--led-filter-gray); } - img.led-green { -webkit-transform: translateZ(0); -webkit-filter: var(--led-filter-green); filter: var(--led-filter-green); } - img.led-red { -webkit-transform: translateZ(0); -webkit-filter: var(--led-filter-red); filter: var(--led-filter-red); } - img.led-yellow { -webkit-transform: translateZ(0); -webkit-filter: var(--led-filter-yellow); @@ -73,10 +77,36 @@ img.led-red-rotating-fast { -webkit-animation: var(--led-spin-fast); animation: var(--led-spin-fast); } - img.led-yellow-rotating-fast { -webkit-filter: var(--led-filter-yellow); filter: var(--led-filter-yellow); -webkit-animation: var(--led-spin-fast); animation: var(--led-spin-fast); } + +/* Additional colors for GPIO */ +img.led-blue { + -webkit-transform: translateZ(0); + -webkit-filter: var(--led-filter-blue); + filter: var(--led-filter-blue); +} +img.led-cyan { + -webkit-transform: translateZ(0); + -webkit-filter: var(--led-filter-cyan); + filter: var(--led-filter-cyan); +} +img.led-magenta { + -webkit-transform: translateZ(0); + -webkit-filter: var(--led-filter-magenta); + filter: var(--led-filter-magenta); +} +img.led-pink { + -webkit-transform: translateZ(0); + -webkit-filter: var(--led-filter-pink); + filter: var(--led-filter-pink); +} +img.led-white { + -webkit-transform: translateZ(0); + -webkit-filter: var(--led-filter-white); + filter: var(--led-filter-white); +} From 85a2f2367d3c7e29f6e1857e110d47dfd5297814 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 26 Nov 2024 19:12:40 +0200 Subject: [PATCH 09/84] =?UTF-8?q?Bump=20version:=204.22=20=E2=86=92=204.23?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- PKGBUILD | 2 +- kvmd/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 99542aaf..2def9800 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.22 +current_version = 4.23 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/PKGBUILD b/PKGBUILD index d8e2e6eb..ffb2dbcf 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.22 +pkgver=4.23 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index ac1d1017..91381c25 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.22" +__version__ = "4.23" diff --git a/setup.py b/setup.py index 670baf56..965a75f3 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.22", + version="4.23", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", From 870af902a139d590f8715667be1f45e35dc91fc8 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 3 Dec 2024 19:08:18 +0200 Subject: [PATCH 10/84] fix --- kvmd/apps/kvmd/snapshoter.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kvmd/apps/kvmd/snapshoter.py b/kvmd/apps/kvmd/snapshoter.py index 197e16c4..e9391306 100644 --- a/kvmd/apps/kvmd/snapshoter.py +++ b/kvmd/apps/kvmd/snapshoter.py @@ -123,10 +123,10 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes if self.__wakeup_key: logger.info("Waking up using key %r ...", self.__wakeup_key) - self.__hid.send_key_events([ - (self.__wakeup_key, True), - (self.__wakeup_key, False), - ]) + await self.__hid.send_key_events( + keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)], + no_ignore_keys=True, + ) if self.__wakeup_move: logger.info("Waking up using mouse move for %d units ...", self.__wakeup_move) From 8a09505baf8c85a07d1e08ead6397d17f84e210a Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 3 Dec 2024 19:15:00 +0200 Subject: [PATCH 11/84] pikvm/pikvm#1432: web: Fixed OCR region --- web/share/js/kvm/ocr.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/share/js/kvm/ocr.js b/web/share/js/kvm/ocr.js index 87ef58d8..b490956b 100644 --- a/web/share/js/kvm/ocr.js +++ b/web/share/js/kvm/ocr.js @@ -184,7 +184,7 @@ export function Ocr(__getGeometry) { "ocr_left": __sel.left, "ocr_top": __sel.top, "ocr_right": __sel.right, - "orc_bottom": __sel.bottom, + "ocr_bottom": __sel.bottom, }; tools.httpGet("/api/streamer/snapshot", params, function(http) { if (http.status === 200) { From e337e8d45c3989b93bbaa03782bd5d34ae21341b Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 3 Dec 2024 19:23:38 +0200 Subject: [PATCH 12/84] switch: Added udev rule for /dev/kvmd-switch --- configs/os/udev/common.rules | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/os/udev/common.rules b/configs/os/udev/common.rules index 1a0ccded..5abb1aa7 100644 --- a/configs/os/udev/common.rules +++ b/configs/os/udev/common.rules @@ -1,3 +1,4 @@ # Here are described some bindings for PiKVM devices. # Do not edit this file. KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="eda3", SYMLINK+="kvmd-hid-bridge" +KERNEL=="ttyACM[0-9]*", SUBSYSTEM=="tty", SUBSYSTEMS=="usb", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="1080", SYMLINK+="kvmd-switch" From be21a420a0eae7c2f86006ad06e1eaa83db4ffb7 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 3 Dec 2024 19:25:13 +0200 Subject: [PATCH 13/84] fix --- kvmd/apps/kvmd/snapshoter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kvmd/apps/kvmd/snapshoter.py b/kvmd/apps/kvmd/snapshoter.py index e9391306..76885c90 100644 --- a/kvmd/apps/kvmd/snapshoter.py +++ b/kvmd/apps/kvmd/snapshoter.py @@ -123,7 +123,7 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes if self.__wakeup_key: logger.info("Waking up using key %r ...", self.__wakeup_key) - await self.__hid.send_key_events( + self.__hid.send_key_events( keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)], no_ignore_keys=True, ) From 70452f048b87918dca164aedf191d79087aa51b6 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 3 Dec 2024 19:25:50 +0200 Subject: [PATCH 14/84] =?UTF-8?q?Bump=20version:=204.23=20=E2=86=92=204.24?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- PKGBUILD | 2 +- kvmd/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2def9800..e878b3f9 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.23 +current_version = 4.24 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/PKGBUILD b/PKGBUILD index ffb2dbcf..4b50ac69 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.23 +pkgver=4.24 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 91381c25..4f55dea3 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.23" +__version__ = "4.24" diff --git a/setup.py b/setup.py index 965a75f3..8c9d35ec 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.23", + version="4.24", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", From 8cca5a8cc7843355f933946d9b73f97007a5b1c4 Mon Sep 17 00:00:00 2001 From: No0ne Date: Thu, 5 Dec 2024 12:41:54 +0100 Subject: [PATCH 15/84] Bump version: ps2x2pico-2.0 (#184) --- hid/pico/Makefile | 2 +- hid/pico/src/CMakeLists.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hid/pico/Makefile b/hid/pico/Makefile index cd1703d9..86b79b0f 100644 --- a/hid/pico/Makefile +++ b/hid/pico/Makefile @@ -31,7 +31,7 @@ endef .tinyusb: $(call libdep,tinyusb,hathach/tinyusb,d713571cd44f05d2fc72efc09c670787b74106e0) .ps2x2pico: - $(call libdep,ps2x2pico,No0ne/ps2x2pico,404aaf02949d5bee8013e3b5d0b3239abf6e13bd) + $(call libdep,ps2x2pico,No0ne/ps2x2pico,26ce89d597e598bb0ac636622e064202d91a9efc) deps: .pico-sdk .tinyusb .ps2x2pico diff --git a/hid/pico/src/CMakeLists.txt b/hid/pico/src/CMakeLists.txt index 7eeffa18..0986741e 100644 --- a/hid/pico/src/CMakeLists.txt +++ b/hid/pico/src/CMakeLists.txt @@ -19,7 +19,7 @@ target_sources(${target_name} PRIVATE ${PS2_PATH}/ps2in.c ${PS2_PATH}/ps2kb.c ${PS2_PATH}/ps2ms.c - ${PS2_PATH}/scancodesets.c + ${PS2_PATH}/scancodes.c ) target_link_options(${target_name} PRIVATE -Xlinker --print-memory-usage) target_compile_options(${target_name} PRIVATE -Wall -Wextra) From 2649a2fa01a45ba3766cced3b244a8376df5091a Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Sun, 8 Dec 2024 20:17:26 +0200 Subject: [PATCH 16/84] web: Enabled secure paste text for Firefox --- web/kvm/index.html | 2 +- web/kvm/navbar-text.pug | 2 +- web/share/js/kvm/paste.js | 6 ------ 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/web/kvm/index.html b/web/kvm/index.html index d698cea9..5e18ac31 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -736,7 +736,7 @@ - + Hide input text:
    diff --git a/web/kvm/navbar-text.pug b/web/kvm/navbar-text.pug index 1c68da40..4b344964 100644 --- a/web/kvm/navbar-text.pug +++ b/web/kvm/navbar-text.pug @@ -19,7 +19,7 @@ li(id="text-dropdown" class="right") table(class="kv") tr +menu_switch_notable("hid-pak-ask-switch", "Ask paste confirmation", true, true) - tr(id="hid-pak-secure" class="feature-disabled") + tr +menu_switch_notable("hid-pak-secure-switch", "Hide input text", true, false) div(id="stream-ocr" class="feature-disabled") hr diff --git a/web/share/js/kvm/paste.js b/web/share/js/kvm/paste.js index 8b770122..3fe1d62c 100644 --- a/web/share/js/kvm/paste.js +++ b/web/share/js/kvm/paste.js @@ -37,12 +37,6 @@ export function Paste(__recorder) { tools.storage.bindSimpleSwitch($("hid-pak-secure-switch"), "hid.pak.secure", false, function(value) { $("hid-pak-text").style.setProperty("-webkit-text-security", (value ? "disc" : "none")); }); - tools.feature.setEnabled($("hid-pak-secure"), ( - tools.browser.is_chrome - || tools.browser.is_safari - || tools.browser.is_opera - )); - $("hid-pak-keymap-selector").addEventListener("change", function() { tools.storage.set("hid.pak.keymap", $("hid-pak-keymap-selector").value); }); From adbd4f242b4478dd1a67de1afb3ba799de57f2d5 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 11 Dec 2024 17:50:58 +0200 Subject: [PATCH 17/84] pikvm/pikvm#1437: Don't reset absolute mouse position on clear --- hid/pico/src/ph_usb.c | 4 ++-- kvmd/plugins/hid/otg/mouse.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/hid/pico/src/ph_usb.c b/hid/pico/src/ph_usb.c index a3d1da00..b56e2cc5 100644 --- a/hid/pico/src/ph_usb.c +++ b/hid/pico/src/ph_usb.c @@ -53,7 +53,7 @@ static u8 _kbd_keys[6] = {0}; static u8 _mouse_buttons = 0; static s16 _mouse_abs_x = 0; static s16 _mouse_abs_y = 0; -#define _MOUSE_CLEAR { _mouse_buttons = 0; _mouse_abs_x = 0; _mouse_abs_y = 0; } +#define _MOUSE_CLEAR { _mouse_buttons = 0; } static void _kbd_sync_report(bool new); @@ -193,7 +193,7 @@ void ph_usb_send_clear(void) { if (PH_O_IS_MOUSE_USB) { _MOUSE_CLEAR; if (PH_O_IS_MOUSE_USB_ABS) { - _mouse_abs_send_report(0, 0); + _mouse_abs_send_report(_mouse_abs_x, _mouse_abs_y); } else { // PH_O_IS_MOUSE_USB_REL _mouse_rel_send_report(0, 0, 0, 0); } diff --git a/kvmd/plugins/hid/otg/mouse.py b/kvmd/plugins/hid/otg/mouse.py index 7d3bd2e6..ae8d53b0 100644 --- a/kvmd/plugins/hid/otg/mouse.py +++ b/kvmd/plugins/hid/otg/mouse.py @@ -153,7 +153,6 @@ class MouseProcess(BaseDeviceProcess): move_x = self.__x move_y = self.__y else: - assert self.__x == self.__y == 0 if relative_event is not None: move_x = relative_event.delta_x move_y = relative_event.delta_y @@ -177,5 +176,3 @@ class MouseProcess(BaseDeviceProcess): def __clear_state(self) -> None: self.__pressed_buttons = 0 - self.__x = 0 - self.__y = 0 From e014cbcedff6d7e96a1d5d6417ed5b9362d927c5 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 11 Dec 2024 21:09:49 +0200 Subject: [PATCH 18/84] pikvm/pikvm#858, pikvm/pikvm#1249: Added slow typing mode for /api/hid/print --- kvmd/apps/kvmd/api/hid.py | 5 +++-- kvmd/apps/kvmd/snapshoter.py | 2 +- kvmd/plugins/hid/__init__.py | 10 +++++++++- web/kvm/index.html | 15 ++++++++++++--- web/kvm/navbar-text.pug | 4 +++- web/share/js/kvm/paste.js | 6 ++++-- web/share/js/kvm/recorder.js | 14 ++++++++++---- 7 files changed, 42 insertions(+), 14 deletions(-) diff --git a/kvmd/apps/kvmd/api/hid.py b/kvmd/apps/kvmd/api/hid.py index 51b9dc00..5ba22abb 100644 --- a/kvmd/apps/kvmd/api/hid.py +++ b/kvmd/apps/kvmd/api/hid.py @@ -123,7 +123,8 @@ class HidApi: if limit > 0: text = text[:limit] symmap = self.__ensure_symmap(req.query.get("keymap", self.__default_keymap_name)) - self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True) + slow = valid_bool(req.query.get("slow", False)) + await self.__hid.send_key_events(text_to_web_keys(text, symmap), no_ignore_keys=True, slow=slow) return make_json_response() def __ensure_symmap(self, keymap_name: str) -> dict[int, dict[int, str]]: @@ -250,7 +251,7 @@ class HidApi: state = valid_bool(req.query["state"]) self.__hid.send_key_event(key, state) else: - self.__hid.send_key_events([(key, True), (key, False)]) + await self.__hid.send_key_events([(key, True), (key, False)], slow=True) return make_json_response() @exposed_http("POST", "/hid/events/send_mouse_button") diff --git a/kvmd/apps/kvmd/snapshoter.py b/kvmd/apps/kvmd/snapshoter.py index 76885c90..e9391306 100644 --- a/kvmd/apps/kvmd/snapshoter.py +++ b/kvmd/apps/kvmd/snapshoter.py @@ -123,7 +123,7 @@ class Snapshoter: # pylint: disable=too-many-instance-attributes if self.__wakeup_key: logger.info("Waking up using key %r ...", self.__wakeup_key) - self.__hid.send_key_events( + await self.__hid.send_key_events( keys=[(self.__wakeup_key, True), (self.__wakeup_key, False)], no_ignore_keys=True, ) diff --git a/kvmd/plugins/hid/__init__.py b/kvmd/plugins/hid/__init__.py index 73ff5d04..447b4d07 100644 --- a/kvmd/plugins/hid/__init__.py +++ b/kvmd/plugins/hid/__init__.py @@ -137,9 +137,17 @@ class BaseHid(BasePlugin): # pylint: disable=too-many-instance-attributes # ===== - def send_key_events(self, keys: Iterable[tuple[str, bool]], no_ignore_keys: bool=False) -> None: + async def send_key_events( + self, + keys: Iterable[tuple[str, bool]], + no_ignore_keys: bool=False, + slow: bool=False, + ) -> None: + for (key, state) in keys: if no_ignore_keys or key not in self.__ignore_keys: + if slow: + await asyncio.sleep(0.02) self.send_key_event(key, state) def send_key_event(self, key: str, state: bool) -> None: diff --git a/web/kvm/index.html b/web/kvm/index.html index 5e18ac31..08093288 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -728,11 +728,11 @@ - + @@ -745,6 +745,15 @@ + + + +
    Ask paste confirmation:Slow typing:
    - - + +
    Ask paste confirmation: +
    + + +
    +


    diff --git a/web/kvm/navbar-text.pug b/web/kvm/navbar-text.pug index 4b344964..e4a37919 100644 --- a/web/kvm/navbar-text.pug +++ b/web/kvm/navbar-text.pug @@ -18,9 +18,11 @@ li(id="text-dropdown" class="right") select(id="hid-pak-keymap-selector") table(class="kv") tr - +menu_switch_notable("hid-pak-ask-switch", "Ask paste confirmation", true, true) + +menu_switch_notable("hid-pak-slow-switch", "Slow typing", true, false) tr +menu_switch_notable("hid-pak-secure-switch", "Hide input text", true, false) + tr + +menu_switch_notable("hid-pak-ask-switch", "Ask paste confirmation", true, true) div(id="stream-ocr" class="feature-disabled") hr br diff --git a/web/share/js/kvm/paste.js b/web/share/js/kvm/paste.js index 3fe1d62c..f42fa22c 100644 --- a/web/share/js/kvm/paste.js +++ b/web/share/js/kvm/paste.js @@ -34,6 +34,7 @@ export function Paste(__recorder) { var __init__ = function() { tools.storage.bindSimpleSwitch($("hid-pak-ask-switch"), "hid.pak.ask", true); + tools.storage.bindSimpleSwitch($("hid-pak-slow-switch"), "hid.pak.slow", false); tools.storage.bindSimpleSwitch($("hid-pak-secure-switch"), "hid.pak.secure", false, function(value) { $("hid-pak-text").style.setProperty("-webkit-text-security", (value ? "disc" : "none")); }); @@ -67,10 +68,11 @@ export function Paste(__recorder) { tools.el.setEnabled($("hid-pak-keymap-selector"), false); let keymap = $("hid-pak-keymap-selector").value; + let slow = $("hid-pak-slow-switch").checked; tools.debug(`HID: paste-as-keys ${keymap}: ${text}`); - tools.httpPost("/api/hid/print", {"limit": 0, "keymap": keymap}, function(http) { + tools.httpPost("/api/hid/print", {"limit": 0, "keymap": keymap, "slow": slow}, function(http) { tools.el.setEnabled($("hid-pak-text"), true); tools.el.setEnabled($("hid-pak-button"), true); tools.el.setEnabled($("hid-pak-keymap-selector"), true); @@ -80,7 +82,7 @@ export function Paste(__recorder) { } else if (http.status !== 200) { wm.error("HID paste error", http.responseText); } else if (http.status === 200) { - __recorder.recordPrintEvent(text, keymap); + __recorder.recordPrintEvent(text, keymap, slow); } }, text, "text/plain"); }; diff --git a/web/share/js/kvm/recorder.js b/web/share/js/kvm/recorder.js index 822aa972..5d9d1553 100644 --- a/web/share/js/kvm/recorder.js +++ b/web/share/js/kvm/recorder.js @@ -67,8 +67,8 @@ export function Recorder() { __recordEvent(event); }; - self.recordPrintEvent = function(text, keymap) { - __recordEvent({"event_type": "print", "event": {"text": text, "keymap": keymap}}); + self.recordPrintEvent = function(text, keymap, slow) { + __recordEvent({"event_type": "print", "event": {"text": text, "keymap": keymap, "slow": slow}}); }; self.recordAtxButtonEvent = function(button) { @@ -159,9 +159,12 @@ export function Recorder() { } else if (event.event_type === "print") { __checkType(event.event.text, "string", "Non-string print text"); - if (event.event.keymap) { + if (event.event.keymap !== undefined) { __checkType(event.event.keymap, "string", "Non-string keymap"); } + if (event.event.slow !== undefined) { + __checkType(event.event.slow, "boolean", "Non-bool slow"); + } } else if (event.event_type === "key") { __checkType(event.event.key, "string", "Non-string key code"); @@ -284,9 +287,12 @@ export function Recorder() { } else if (event.event_type === "print") { let params = {"limit": 0}; - if (event.event.keymap) { + if (event.event.keymap !== undefined) { params["keymap"] = event.event.keymap; } + if (event.event.slow !== undefined) { + params["slow"] = event.event.slow; + } tools.httpPost("/api/hid/print", params, function(http) { if (http.status === 413) { wm.error("Too many text for paste!"); From ada1c39eef2085275934b0f128a47e856be93eb6 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 11 Dec 2024 21:10:24 +0200 Subject: [PATCH 19/84] =?UTF-8?q?Bump=20version:=204.24=20=E2=86=92=204.25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- PKGBUILD | 2 +- kvmd/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e878b3f9..51d07c84 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.24 +current_version = 4.25 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/PKGBUILD b/PKGBUILD index 4b50ac69..e1795bcf 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.24 +pkgver=4.25 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 4f55dea3..425490e0 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.24" +__version__ = "4.25" diff --git a/setup.py b/setup.py index 8c9d35ec..294efed6 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.24", + version="4.25", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", From e0bbf6968ef8295274793a564e717f95f42983d7 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Mon, 16 Dec 2024 19:19:31 +0200 Subject: [PATCH 20/84] testenv: Use memsink for VNC --- Makefile | 2 ++ testenv/v2-hdmi-rpi4.override.yaml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 86d6e8bb..cc15aaa8 100644 --- a/Makefile +++ b/Makefile @@ -102,6 +102,7 @@ $(TESTENV_GPIO): run: testenv $(TESTENV_GPIO) - $(DOCKER) run --rm --name kvmd \ + --ipc=shareable \ --privileged \ --volume `pwd`/testenv/run:/run/kvmd:rw \ --volume `pwd`/testenv:/testenv:ro \ @@ -187,6 +188,7 @@ run-ipmi: testenv run-vnc: testenv - $(DOCKER) run --rm --name kvmd-vnc \ + --ipc=container:kvmd \ --volume `pwd`/testenv/run:/run/kvmd:rw \ --volume `pwd`/testenv:/testenv:ro \ --volume `pwd`/kvmd:/kvmd:ro \ diff --git a/testenv/v2-hdmi-rpi4.override.yaml b/testenv/v2-hdmi-rpi4.override.yaml index f8a301f1..5de7f63c 100644 --- a/testenv/v2-hdmi-rpi4.override.yaml +++ b/testenv/v2-hdmi-rpi4.override.yaml @@ -35,6 +35,8 @@ kvmd: - "--process-name-prefix={process_name_prefix}" - "--notify-parent" - "--no-log-colors" + - "--jpeg-sink=kvmd::ustreamer::jpeg" + - "--jpeg-sink-mode=0660" gpio: drivers: @@ -148,8 +150,6 @@ vnc: enabled: true memsink: - jpeg: - sink: "" h264: sink: "" From 630610bc532299f15ff7ee12d40f617de450aae0 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Mon, 8 Jul 2024 03:41:29 +0300 Subject: [PATCH 21/84] switch --- Makefile | 5 + PKGBUILD | 6 +- configs/kvmd/edid/_1080p-by-default.hex | 2 +- configs/kvmd/edid/_no-1920x1200.hex | 2 +- kvmd/aiotools.py | 5 + kvmd/apps/__init__.py | 5 + kvmd/apps/kvmd/__init__.py | 5 + kvmd/apps/kvmd/api/switch.py | 164 +++++ kvmd/apps/kvmd/server.py | 7 +- kvmd/apps/kvmd/switch/__init__.py | 400 ++++++++++++ kvmd/apps/kvmd/switch/chain.py | 440 +++++++++++++ kvmd/apps/kvmd/switch/device.py | 196 ++++++ kvmd/apps/kvmd/switch/lib.py | 35 + kvmd/apps/kvmd/switch/proto.py | 295 +++++++++ kvmd/apps/kvmd/switch/state.py | 355 ++++++++++ kvmd/apps/kvmd/switch/storage.py | 186 ++++++ kvmd/apps/kvmd/switch/types.py | 308 +++++++++ kvmd/apps/pst/server.py | 12 + kvmd/clients/pst.py | 93 +++ kvmd/validators/__init__.py | 8 + kvmd/validators/os.py | 10 +- kvmd/validators/switch.py | 67 ++ setup.py | 1 + testenv/linters/pylint.ini | 1 + testenv/linters/vulture-wl.py | 25 + testenv/tests/validators/test_switch.py | 180 ++++++ web/kvm/index.html | 208 +++++- web/kvm/navbar-shortcuts.pug | 2 +- web/kvm/navbar-switch.pug | 19 + web/kvm/navbar-system.pug | 2 +- web/kvm/navbar.pug | 1 + web/kvm/window-keyboard.pug | 2 +- web/kvm/window-switch.pug | 95 +++ web/kvm/windows.pug | 1 + web/login/index.html | 2 +- web/login/index.pug | 2 +- web/share/css/kvm/msd.css | 4 + web/share/css/main.css | 56 +- web/share/css/modal.css | 2 + web/share/css/navbar.css | 1 + web/share/css/slider.css | 22 +- web/share/css/x-desktop.css | 10 +- web/share/css/x-mobile.css | 2 +- web/share/js/kvm/atx.js | 10 +- web/share/js/kvm/session.js | 20 +- web/share/js/kvm/switch.js | 606 ++++++++++++++++++ web/share/js/tools.js | 6 +- web/share/svg/led-beacon.svg | 4 + web/share/svg/led-usb.svg | 22 + .../svg/{led-stream.svg => led-video.svg} | 0 50 files changed, 3835 insertions(+), 77 deletions(-) create mode 100644 kvmd/apps/kvmd/api/switch.py create mode 100644 kvmd/apps/kvmd/switch/__init__.py create mode 100644 kvmd/apps/kvmd/switch/chain.py create mode 100644 kvmd/apps/kvmd/switch/device.py create mode 100644 kvmd/apps/kvmd/switch/lib.py create mode 100644 kvmd/apps/kvmd/switch/proto.py create mode 100644 kvmd/apps/kvmd/switch/state.py create mode 100644 kvmd/apps/kvmd/switch/storage.py create mode 100644 kvmd/apps/kvmd/switch/types.py create mode 100644 kvmd/clients/pst.py create mode 100644 kvmd/validators/switch.py create mode 100644 testenv/tests/validators/test_switch.py create mode 100644 web/kvm/navbar-switch.pug create mode 100644 web/kvm/window-switch.pug create mode 100644 web/share/js/kvm/switch.js create mode 100644 web/share/svg/led-beacon.svg create mode 100644 web/share/svg/led-usb.svg rename web/share/svg/{led-stream.svg => led-video.svg} (100%) diff --git a/Makefile b/Makefile index cc15aaa8..06af1636 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,7 @@ tox: testenv && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && mkdir -p /etc/kvmd/override.d \ && cp /src/testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ @@ -129,6 +130,7 @@ run: testenv $(TESTENV_GPIO) && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && ln -s /testenv/web.css /etc/kvmd/web.css \ && mkdir -p /etc/kvmd/override.d \ @@ -156,6 +158,7 @@ run-cfg: testenv && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && mkdir -p /etc/kvmd/override.d \ && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ @@ -179,6 +182,7 @@ run-ipmi: testenv && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && mkdir -p /etc/kvmd/override.d \ && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ @@ -203,6 +207,7 @@ run-vnc: testenv && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /usr/share/kvmd/configs.default/kvmd/*.secret /etc/kvmd \ + && cp /usr/share/kvmd/configs.default/kvmd/edid/v2.hex /etc/kvmd/switch-edid.hex \ && cp /usr/share/kvmd/configs.default/kvmd/main/$(if $(P),$(P),$(DEFAULT_PLATFORM)).yaml /etc/kvmd/main.yaml \ && mkdir -p /etc/kvmd/override.d \ && cp /testenv/$(if $(P),$(P),$(DEFAULT_PLATFORM)).override.yaml /etc/kvmd/override.yaml \ diff --git a/PKGBUILD b/PKGBUILD index e1795bcf..f1ca2d4c 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -253,8 +253,12 @@ for _variant in "${_variants[@]}"; do fi if [[ $_platform =~ ^.*-hdmi$ ]]; then - backup=(\"\${backup[@]}\" etc/kvmd/tc358743-edid.hex) + backup=(\"\${backup[@]}\" etc/kvmd/tc358743-edid.hex etc/kvmd/switch-edid.hex) install -DTm444 configs/kvmd/edid/$_base.hex \"\$pkgdir/etc/kvmd/tc358743-edid.hex\" + ln -s tc358743-edid.hex /etc/kvmd/switch-edid.hex + else + backup=(\"\${backup[@]}\" etc/kvmd/switch-edid.hex) + install -DTm444 configs/kvmd/edid/_no-1920x1200.hex \"\$pkgdir/etc/kvmd/switch-edid.hex\" fi mkdir -p \"\$pkgdir/usr/share/kvmd\" diff --git a/configs/kvmd/edid/_1080p-by-default.hex b/configs/kvmd/edid/_1080p-by-default.hex index f4238bec..9998cb8b 100644 --- a/configs/kvmd/edid/_1080p-by-default.hex +++ b/configs/kvmd/edid/_1080p-by-default.hex @@ -5,7 +5,7 @@ 3500404421000002000000FF00434146 45424142452020202020000000FD0032 4B0F5211000A202020202020000000FC -0050694B564D2056330A20202020012B +0050694B564D0A202020202020200174 020317314A049F13223E213D203C0167 030C001000802DEE2C80A070381A4030 203500404421000002011D007251D01E diff --git a/configs/kvmd/edid/_no-1920x1200.hex b/configs/kvmd/edid/_no-1920x1200.hex index c89278e5..00c745d3 100644 --- a/configs/kvmd/edid/_no-1920x1200.hex +++ b/configs/kvmd/edid/_no-1920x1200.hex @@ -5,7 +5,7 @@ 45000F282100001E000000FF00434146 45424142452020202020000000FD0032 4B0F5211000A202020202020000000FC -0050694B564D20563420506C7573012D +0050694B564D0A2020202020202001B1 020320714B90041F13223E213D203C01 67030C001000802D23097F0783010000 023A801871382D40582C45000F282100 diff --git a/kvmd/aiotools.py b/kvmd/aiotools.py index a47c94c6..6183690f 100644 --- a/kvmd/aiotools.py +++ b/kvmd/aiotools.py @@ -45,6 +45,11 @@ async def read_file(path: str) -> str: return (await file.read()) +async def write_file(path: str, text: str) -> None: + async with aiofiles.open(path, "w") as file: + await file.write(text) + + # ===== def run(coro: Coroutine, final: (Coroutine | None)=None) -> None: # https://github.com/aio-libs/aiohttp/blob/a1d4dac1d/aiohttp/web.py#L515 diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index cfc39499..2090e5c6 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -502,6 +502,11 @@ def _get_config_scheme() -> dict: "table": Option([], type=valid_ugpio_view_table), }, }, + + "switch": { + "device": Option("/dev/kvmd-switch", type=valid_abs_path, unpack_as="device_path"), + "default_edid": Option("/etc/kvmd/switch-edid.hex", type=valid_abs_path, unpack_as="default_edid_path"), + }, }, "pst": { diff --git a/kvmd/apps/kvmd/__init__.py b/kvmd/apps/kvmd/__init__.py index 495a320f..088a62ef 100644 --- a/kvmd/apps/kvmd/__init__.py +++ b/kvmd/apps/kvmd/__init__.py @@ -35,6 +35,7 @@ from .ugpio import UserGpio from .streamer import Streamer from .snapshoter import Snapshoter from .ocr import Ocr +from .switch import Switch from .server import KvmdServer @@ -90,6 +91,10 @@ def main(argv: (list[str] | None)=None) -> None: log_reader=(LogReader() if config.log_reader.enabled else None), user_gpio=UserGpio(config.gpio, global_config.otg), ocr=Ocr(**config.ocr._unpack()), + switch=Switch( + pst_unix_path=global_config.pst.server.unix, + **config.switch._unpack(), + ), hid=hid, atx=get_atx_class(config.atx.type)(**config.atx._unpack(ignore=["type"])), diff --git a/kvmd/apps/kvmd/api/switch.py b/kvmd/apps/kvmd/api/switch.py new file mode 100644 index 00000000..bf91b83e --- /dev/null +++ b/kvmd/apps/kvmd/api/switch.py @@ -0,0 +1,164 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +from aiohttp.web import Request +from aiohttp.web import Response + +from ....htserver import exposed_http +from ....htserver import make_json_response + +from ....validators.basic import valid_bool +from ....validators.basic import valid_int_f0 +from ....validators.basic import valid_stripped_string_not_empty +from ....validators.kvm import valid_atx_power_action +from ....validators.kvm import valid_atx_button +from ....validators.switch import valid_switch_port_name +from ....validators.switch import valid_switch_edid_id +from ....validators.switch import valid_switch_edid_data +from ....validators.switch import valid_switch_color +from ....validators.switch import valid_switch_atx_click_delay + +from ..switch import Switch +from ..switch import Colors + + +# ===== +class SwitchApi: + def __init__(self, switch: Switch) -> None: + self.__switch = switch + + # ===== + + @exposed_http("GET", "/switch") + async def __state_handler(self, _: Request) -> Response: + return make_json_response(await self.__switch.get_state()) + + @exposed_http("POST", "/switch/set_active") + async def __set_active_port_handler(self, req: Request) -> Response: + port = valid_int_f0(req.query.get("port")) + await self.__switch.set_active_port(port) + return make_json_response() + + @exposed_http("POST", "/switch/set_beacon") + async def __set_beacon_handler(self, req: Request) -> Response: + on = valid_bool(req.query.get("state")) + if "port" in req.query: + port = valid_int_f0(req.query.get("port")) + await self.__switch.set_port_beacon(port, on) + elif "uplink" in req.query: + unit = valid_int_f0(req.query.get("uplink")) + await self.__switch.set_uplink_beacon(unit, on) + else: # Downlink + unit = valid_int_f0(req.query.get("downlink")) + await self.__switch.set_downlink_beacon(unit, on) + return make_json_response() + + @exposed_http("POST", "/switch/set_port_params") + async def __set_port_params(self, req: Request) -> Response: + port = valid_int_f0(req.query.get("port")) + params = { + param: validator(req.query.get(param)) + for (param, validator) in [ + ("edid_id", (lambda arg: valid_switch_edid_id(arg, allow_default=True))), + ("name", valid_switch_port_name), + ("atx_click_power_delay", valid_switch_atx_click_delay), + ("atx_click_power_long_delay", valid_switch_atx_click_delay), + ("atx_click_reset_delay", valid_switch_atx_click_delay), + ] + if req.query.get(param) is not None + } + await self.__switch.set_port_params(port, **params) # type: ignore + return make_json_response() + + @exposed_http("POST", "/switch/set_colors") + async def __set_colors(self, req: Request) -> Response: + params = { + param: valid_switch_color(req.query.get(param), allow_default=True) + for param in Colors.ROLES + if req.query.get(param) is not None + } + await self.__switch.set_colors(**params) + return make_json_response() + + # ===== + + @exposed_http("POST", "/switch/reset") + async def __reset(self, req: Request) -> Response: + unit = valid_int_f0(req.query.get("unit")) + bootloader = valid_bool(req.query.get("bootloader", False)) + await self.__switch.reboot_unit(unit, bootloader) + return make_json_response() + + # ===== + + @exposed_http("POST", "/switch/edids/create") + async def __create_edid(self, req: Request) -> Response: + name = valid_stripped_string_not_empty(req.query.get("name")) + data_hex = valid_switch_edid_data(req.query.get("data")) + edid_id = await self.__switch.create_edid(name, data_hex) + return make_json_response({"id": edid_id}) + + @exposed_http("POST", "/switch/edids/change") + async def __change_edid(self, req: Request) -> Response: + edid_id = valid_switch_edid_id(req.query.get("id"), allow_default=False) + params = { + param: validator(req.query.get(param)) + for (param, validator) in [ + ("name", valid_switch_port_name), + ("data", valid_switch_edid_data), + ] + if req.query.get(param) is not None + } + if params: + await self.__switch.change_edid(edid_id, **params) + return make_json_response() + + @exposed_http("POST", "/switch/edids/remove") + async def __remove_edid(self, req: Request) -> Response: + edid_id = valid_switch_edid_id(req.query.get("id"), allow_default=False) + await self.__switch.remove_edid(edid_id) + return make_json_response() + + # ===== + + @exposed_http("POST", "/switch/atx/power") + async def __power_handler(self, req: Request) -> Response: + port = valid_int_f0(req.query.get("port")) + action = valid_atx_power_action(req.query.get("action")) + await ({ + "on": self.__switch.atx_power_on, + "off": self.__switch.atx_power_off, + "off_hard": self.__switch.atx_power_off_hard, + "reset_hard": self.__switch.atx_power_reset_hard, + }[action])(port) + return make_json_response() + + @exposed_http("POST", "/switch/atx/click") + async def __click_handler(self, req: Request) -> Response: + port = valid_int_f0(req.query.get("port")) + button = valid_atx_button(req.query.get("button")) + await ({ + "power": self.__switch.atx_click_power, + "power_long": self.__switch.atx_click_power_long, + "reset": self.__switch.atx_click_reset, + }[button])(port) + return make_json_response() diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index ed85bb24..92eb496c 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -66,6 +66,7 @@ from .ugpio import UserGpio from .streamer import Streamer from .snapshoter import Snapshoter from .ocr import Ocr +from .switch import Switch from .api.auth import AuthApi from .api.auth import check_request_auth @@ -77,6 +78,7 @@ from .api.hid import HidApi from .api.atx import AtxApi from .api.msd import MsdApi from .api.streamer import StreamerApi +from .api.switch import SwitchApi from .api.export import ExportApi from .api.redfish import RedfishApi @@ -125,7 +127,6 @@ class _Subsystem: cleanup=getattr(obj, "cleanup", None), trigger_state=getattr(obj, "trigger_state", None), poll_state=getattr(obj, "poll_state", None), - ) @@ -137,6 +138,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins __EV_STREAMER_STATE = "streamer_state" __EV_OCR_STATE = "ocr_state" __EV_INFO_STATE = "info_state" + __EV_SWITCH_STATE = "switch_state" def __init__( # pylint: disable=too-many-arguments,too-many-locals self, @@ -145,6 +147,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins log_reader: (LogReader | None), user_gpio: UserGpio, ocr: Ocr, + switch: Switch, hid: BaseHid, atx: BaseAtx, @@ -177,6 +180,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins AtxApi(atx), MsdApi(msd), StreamerApi(streamer, ocr), + SwitchApi(switch), ExportApi(info_manager, atx, user_gpio), RedfishApi(info_manager, atx), ] @@ -189,6 +193,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins _Subsystem.make(streamer, "Streamer", self.__EV_STREAMER_STATE), _Subsystem.make(ocr, "OCR", self.__EV_OCR_STATE), _Subsystem.make(info_manager, "Info manager", self.__EV_INFO_STATE), + _Subsystem.make(switch, "Switch", self.__EV_SWITCH_STATE), ] self.__streamer_notifier = aiotools.AioNotifier() diff --git a/kvmd/apps/kvmd/switch/__init__.py b/kvmd/apps/kvmd/switch/__init__.py new file mode 100644 index 00000000..49bfbd7d --- /dev/null +++ b/kvmd/apps/kvmd/switch/__init__.py @@ -0,0 +1,400 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import os +import asyncio + +from typing import AsyncGenerator + +from .lib import OperationError +from .lib import get_logger +from .lib import aiotools +from .lib import Inotify + +from .types import Edid +from .types import Edids +from .types import Color +from .types import Colors +from .types import PortNames +from .types import AtxClickPowerDelays +from .types import AtxClickPowerLongDelays +from .types import AtxClickResetDelays + +from .chain import DeviceFoundEvent +from .chain import ChainTruncatedEvent +from .chain import PortActivatedEvent +from .chain import UnitStateEvent +from .chain import UnitAtxLedsEvent +from .chain import Chain + +from .state import StateCache + +from .storage import Storage + + +# ===== +class SwitchError(Exception): + pass + + +class SwitchOperationError(OperationError, SwitchError): + pass + + +class SwitchUnknownEdidError(SwitchOperationError): + def __init__(self) -> None: + super().__init__("No specified EDID ID found") + + +# ===== +class Switch: # pylint: disable=too-many-public-methods + __X_EDIDS = "edids" + __X_COLORS = "colors" + __X_PORT_NAMES = "port_names" + __X_ATX_CP_DELAYS = "atx_cp_delays" + __X_ATX_CPL_DELAYS = "atx_cpl_delays" + __X_ATX_CR_DELAYS = "atx_cr_delays" + + __X_ALL = frozenset([ + __X_EDIDS, __X_COLORS, __X_PORT_NAMES, + __X_ATX_CP_DELAYS, __X_ATX_CPL_DELAYS, __X_ATX_CR_DELAYS, + ]) + + def __init__( + self, + device_path: str, + default_edid_path: str, + pst_unix_path: str, + ) -> None: + + self.__default_edid_path = default_edid_path + + self.__chain = Chain(device_path) + self.__cache = StateCache() + self.__storage = Storage(pst_unix_path) + + self.__lock = asyncio.Lock() + + self.__save_notifier = aiotools.AioNotifier() + + # ===== + + def __x_set_edids(self, edids: Edids, save: bool=True) -> None: + self.__chain.set_edids(edids) + self.__cache.set_edids(edids) + if save: + self.__save_notifier.notify() + + def __x_set_colors(self, colors: Colors, save: bool=True) -> None: + self.__chain.set_colors(colors) + self.__cache.set_colors(colors) + if save: + self.__save_notifier.notify() + + def __x_set_port_names(self, port_names: PortNames, save: bool=True) -> None: + self.__cache.set_port_names(port_names) + if save: + self.__save_notifier.notify() + + def __x_set_atx_cp_delays(self, delays: AtxClickPowerDelays, save: bool=True) -> None: + self.__cache.set_atx_cp_delays(delays) + if save: + self.__save_notifier.notify() + + def __x_set_atx_cpl_delays(self, delays: AtxClickPowerLongDelays, save: bool=True) -> None: + self.__cache.set_atx_cpl_delays(delays) + if save: + self.__save_notifier.notify() + + def __x_set_atx_cr_delays(self, delays: AtxClickResetDelays, save: bool=True) -> None: + self.__cache.set_atx_cr_delays(delays) + if save: + self.__save_notifier.notify() + + # ===== + + async def set_active_port(self, port: int) -> None: + self.__chain.set_active_port(port) + + # ===== + + async def set_port_beacon(self, port: int, on: bool) -> None: + self.__chain.set_port_beacon(port, on) + + async def set_uplink_beacon(self, unit: int, on: bool) -> None: + self.__chain.set_uplink_beacon(unit, on) + + async def set_downlink_beacon(self, unit: int, on: bool) -> None: + self.__chain.set_downlink_beacon(unit, on) + + # ===== + + async def atx_power_on(self, port: int) -> None: + self.__inner_atx_cp(port, False, self.__X_ATX_CP_DELAYS) + + async def atx_power_off(self, port: int) -> None: + self.__inner_atx_cp(port, True, self.__X_ATX_CP_DELAYS) + + async def atx_power_off_hard(self, port: int) -> None: + self.__inner_atx_cp(port, True, self.__X_ATX_CPL_DELAYS) + + async def atx_power_reset_hard(self, port: int) -> None: + self.__inner_atx_cr(port, True) + + async def atx_click_power(self, port: int) -> None: + self.__inner_atx_cp(port, None, self.__X_ATX_CP_DELAYS) + + async def atx_click_power_long(self, port: int) -> None: + self.__inner_atx_cp(port, None, self.__X_ATX_CPL_DELAYS) + + async def atx_click_reset(self, port: int) -> None: + self.__inner_atx_cr(port, None) + + def __inner_atx_cp(self, port: int, if_powered: (bool | None), x_delay: str) -> None: + assert x_delay in [self.__X_ATX_CP_DELAYS, self.__X_ATX_CPL_DELAYS] + delay = getattr(self.__cache, f"get_{x_delay}")()[port] + self.__chain.click_power(port, delay, if_powered) + + def __inner_atx_cr(self, port: int, if_powered: (bool | None)) -> None: + delay = self.__cache.get_atx_cr_delays()[port] + self.__chain.click_reset(port, delay, if_powered) + + # ===== + + async def create_edid(self, name: str, data_hex: str) -> str: + async with self.__lock: + edids = self.__cache.get_edids() + edid_id = edids.add(Edid.from_data(name, data_hex)) + self.__x_set_edids(edids) + return edid_id + + async def change_edid( + self, + edid_id: str, + name: (str | None)=None, + data_hex: (str | None)=None, + ) -> None: + + assert edid_id != Edids.DEFAULT_ID + async with self.__lock: + edids = self.__cache.get_edids() + if not edids.has_id(edid_id): + raise SwitchUnknownEdidError() + old = edids.get(edid_id) + name = (name or old.name) + data_hex = (data_hex or old.as_text()) + edids.set(edid_id, Edid.from_data(name, data_hex)) + self.__x_set_edids(edids) + + async def remove_edid(self, edid_id: str) -> None: + assert edid_id != Edids.DEFAULT_ID + async with self.__lock: + edids = self.__cache.get_edids() + if not edids.has_id(edid_id): + raise SwitchUnknownEdidError() + edids.remove(edid_id) + self.__x_set_edids(edids) + + # ===== + + async def set_colors(self, **values: str) -> None: + async with self.__lock: + old = self.__cache.get_colors() + new = {} + for role in Colors.ROLES: + if role in values: + if values[role] != "default": + new[role] = Color.from_text(values[role]) + # else reset to default + else: + new[role] = getattr(old, role) + self.__x_set_colors(Colors(**new)) # type: ignore + + # ===== + + async def set_port_params( + self, + port: int, + edid_id: (str | None)=None, + name: (str | None)=None, + atx_click_power_delay: (float | None)=None, + atx_click_power_long_delay: (float | None)=None, + atx_click_reset_delay: (float | None)=None, + ) -> None: + + async with self.__lock: + if edid_id is not None: + edids = self.__cache.get_edids() + if not edids.has_id(edid_id): + raise SwitchUnknownEdidError() + edids.assign(port, edid_id) + self.__x_set_edids(edids) + + for (key, value) in [ + (self.__X_PORT_NAMES, name), + (self.__X_ATX_CP_DELAYS, atx_click_power_delay), + (self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay), + (self.__X_ATX_CR_DELAYS, atx_click_reset_delay), + ]: + if value is not None: + new = getattr(self.__cache, f"get_{key}")() + new[port] = (value or None) # None == reset to default + getattr(self, f"_Switch__x_set_{key}")(new) + + # ===== + + async def reboot_unit(self, unit: int, bootloader: bool) -> None: + self.__chain.reboot_unit(unit, bootloader) + + # ===== + + async def get_state(self) -> dict: + return self.__cache.get_state() + + async def trigger_state(self) -> None: + await self.__cache.trigger_state() + + async def poll_state(self) -> AsyncGenerator[dict, None]: + async for state in self.__cache.poll_state(): + yield state + + # ===== + + async def systask(self) -> None: + tasks = [ + asyncio.create_task(self.__systask_events()), + asyncio.create_task(self.__systask_default_edid()), + asyncio.create_task(self.__systask_storage()), + ] + try: + await asyncio.gather(*tasks) + except Exception: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + + async def __systask_events(self) -> None: + async for event in self.__chain.poll_events(): + match event: + case DeviceFoundEvent(): + await self.__load_configs() + case ChainTruncatedEvent(): + self.__cache.truncate(event.units) + case PortActivatedEvent(): + self.__cache.update_active_port(event.port) + case UnitStateEvent(): + self.__cache.update_unit_state(event.unit, event.state) + case UnitAtxLedsEvent(): + self.__cache.update_unit_atx_leds(event.unit, event.atx_leds) + + async def __load_configs(self) -> None: + async with self.__lock: + try: + async with self.__storage.readable() as ctx: + values = { + key: await getattr(ctx, f"read_{key}")() + for key in self.__X_ALL + } + data_hex = await aiotools.read_file(self.__default_edid_path) + values["edids"].set_default(data_hex) + except Exception: + get_logger(0).exception("Can't load configs") + else: + for (key, value) in values.items(): + func = getattr(self, f"_Switch__x_set_{key}") + if isinstance(value, tuple): + func(*value, save=False) + else: + func(value, save=False) + self.__chain.set_actual(True) + + async def __systask_default_edid(self) -> None: + logger = get_logger(0) + async for _ in self.__poll_default_edid(): + async with self.__lock: + edids = self.__cache.get_edids() + try: + data_hex = await aiotools.read_file(self.__default_edid_path) + edids.set_default(data_hex) + except Exception: + logger.exception("Can't read default EDID, ignoring ...") + else: + self.__x_set_edids(edids, save=False) + + async def __poll_default_edid(self) -> AsyncGenerator[None, None]: + logger = get_logger(0) + while True: + while not os.path.exists(self.__default_edid_path): + await asyncio.sleep(5) + try: + with Inotify() as inotify: + await inotify.watch_all_changes(self.__default_edid_path) + if os.path.islink(self.__default_edid_path): + await inotify.watch_all_changes(os.path.realpath(self.__default_edid_path)) + yield None + while True: + need_restart = False + need_notify = False + for event in (await inotify.get_series(timeout=1)): + need_notify = True + if event.restart: + logger.warning("Got fatal inotify event: %s; reinitializing ...", event) + need_restart = True + break + if need_restart: + break + if need_notify: + yield None + except Exception: + logger.exception("Unexpected watcher error") + await asyncio.sleep(1) + + async def __systask_storage(self) -> None: + # При остановке KVMD можем не успеть записать, ну да пофиг + prevs = dict.fromkeys(self.__X_ALL) + while True: + await self.__save_notifier.wait() + while (await self.__save_notifier.wait(5)): + pass + while True: + try: + async with self.__lock: + write = { + key: new + for (key, old) in prevs.items() + if (new := getattr(self.__cache, f"get_{key}")()) != old + } + if write: + async with self.__storage.writable() as ctx: + for (key, new) in write.items(): + func = getattr(ctx, f"write_{key}") + if isinstance(new, tuple): + await func(*new) + else: + await func(new) + prevs[key] = new + except Exception: + get_logger(0).exception("Unexpected storage error") + await asyncio.sleep(5) + else: + break diff --git a/kvmd/apps/kvmd/switch/chain.py b/kvmd/apps/kvmd/switch/chain.py new file mode 100644 index 00000000..8e4d94eb --- /dev/null +++ b/kvmd/apps/kvmd/switch/chain.py @@ -0,0 +1,440 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import multiprocessing +import queue +import select +import dataclasses +import time + +from typing import AsyncGenerator + +from .lib import get_logger +from .lib import tools +from .lib import aiotools +from .lib import aioproc + +from .types import Edids +from .types import Colors + +from .proto import Response +from .proto import UnitState +from .proto import UnitAtxLeds + +from .device import Device +from .device import DeviceError + + +# ===== +class _BaseCmd: + pass + + +@dataclasses.dataclass(frozen=True) +class _CmdSetActual(_BaseCmd): + actual: bool + + +@dataclasses.dataclass(frozen=True) +class _CmdSetActivePort(_BaseCmd): + port: int + + def __post_init__(self) -> None: + assert self.port >= 0 + + +@dataclasses.dataclass(frozen=True) +class _CmdSetPortBeacon(_BaseCmd): + port: int + on: bool + + +@dataclasses.dataclass(frozen=True) +class _CmdSetUnitBeacon(_BaseCmd): + unit: int + on: bool + downlink: bool + + +@dataclasses.dataclass(frozen=True) +class _CmdSetEdids(_BaseCmd): + edids: Edids + + +@dataclasses.dataclass(frozen=True) +class _CmdSetColors(_BaseCmd): + colors: Colors + + +@dataclasses.dataclass(frozen=True) +class _CmdAtxClick(_BaseCmd): + port: int + delay: float + reset: bool + if_powered: (bool | None) + + def __post_init__(self) -> None: + assert self.port >= 0 + assert 0.001 <= self.delay <= (0xFFFF / 1000) + + +@dataclasses.dataclass(frozen=True) +class _CmdRebootUnit(_BaseCmd): + unit: int + bootloader: bool + + def __post_init__(self) -> None: + assert self.unit >= 0 + + +class _UnitContext: + __TIMEOUT = 5.0 + + def __init__(self) -> None: + self.state: (UnitState | None) = None + self.atx_leds: (UnitAtxLeds | None) = None + self.__rid = -1 + self.__deadline_ts = -1.0 + + def can_be_changed(self) -> bool: + return ( + self.state is not None + and not self.state.flags.changing_busy + and self.changing_rid < 0 + ) + + # ===== + + @property + def changing_rid(self) -> int: + if self.__deadline_ts >= 0 and self.__deadline_ts < time.monotonic(): + self.__rid = -1 + self.__deadline_ts = -1 + return self.__rid + + @changing_rid.setter + def changing_rid(self, rid: int) -> None: + self.__rid = rid + self.__deadline_ts = ((time.monotonic() + self.__TIMEOUT) if rid >= 0 else -1) + + # ===== + + def is_atx_allowed(self, ch: int) -> tuple[bool, bool]: # (allowed, power_led) + if self.state is None or self.atx_leds is None: + return (False, False) + return ((not self.state.atx_busy[ch]), self.atx_leds.power[ch]) + + +# ===== +class BaseEvent: + pass + + +class DeviceFoundEvent(BaseEvent): + pass + + +@dataclasses.dataclass(frozen=True) +class ChainTruncatedEvent(BaseEvent): + units: int + + +@dataclasses.dataclass(frozen=True) +class PortActivatedEvent(BaseEvent): + port: int + + +@dataclasses.dataclass(frozen=True) +class UnitStateEvent(BaseEvent): + unit: int + state: UnitState + + +@dataclasses.dataclass(frozen=True) +class UnitAtxLedsEvent(BaseEvent): + unit: int + atx_leds: UnitAtxLeds + + +# ===== +class Chain: # pylint: disable=too-many-instance-attributes + def __init__(self, device_path: str) -> None: + self.__device = Device(device_path) + + self.__actual = False + + self.__edids = Edids() + + self.__colors = Colors() + + self.__units: list[_UnitContext] = [] + self.__active_port = -1 + + self.__cmd_queue: "multiprocessing.Queue[_BaseCmd]" = multiprocessing.Queue() + self.__events_queue: "multiprocessing.Queue[BaseEvent]" = multiprocessing.Queue() + + self.__stop_event = multiprocessing.Event() + + def set_actual(self, actual: bool) -> None: + # Флаг разрешения синхронизации EDID и прочих чувствительных вещей + self.__queue_cmd(_CmdSetActual(actual)) + + # ===== + + def set_active_port(self, port: int) -> None: + self.__queue_cmd(_CmdSetActivePort(port)) + + # ===== + + def set_port_beacon(self, port: int, on: bool) -> None: + self.__queue_cmd(_CmdSetPortBeacon(port, on)) + + def set_uplink_beacon(self, unit: int, on: bool) -> None: + self.__queue_cmd(_CmdSetUnitBeacon(unit, on, downlink=False)) + + def set_downlink_beacon(self, unit: int, on: bool) -> None: + self.__queue_cmd(_CmdSetUnitBeacon(unit, on, downlink=True)) + + # ===== + + def set_edids(self, edids: Edids) -> None: + self.__queue_cmd(_CmdSetEdids(edids)) # Will be copied because of multiprocessing.Queue() + + def set_colors(self, colors: Colors) -> None: + self.__queue_cmd(_CmdSetColors(colors)) + + # ===== + + def click_power(self, port: int, delay: float, if_powered: (bool | None)) -> None: + self.__queue_cmd(_CmdAtxClick(port, delay, reset=False, if_powered=if_powered)) + + def click_reset(self, port: int, delay: float, if_powered: (bool | None)) -> None: + self.__queue_cmd(_CmdAtxClick(port, delay, reset=True, if_powered=if_powered)) + + # ===== + + def reboot_unit(self, unit: int, bootloader: bool) -> None: + self.__queue_cmd(_CmdRebootUnit(unit, bootloader)) + + # ===== + + async def poll_events(self) -> AsyncGenerator[BaseEvent, None]: + proc = multiprocessing.Process(target=self.__subprocess, daemon=True) + try: + proc.start() + while True: + try: + yield (await aiotools.run_async(self.__events_queue.get, True, 0.1)) + except queue.Empty: + pass + finally: + if proc.is_alive(): + self.__stop_event.set() + if proc.is_alive() or proc.exitcode is not None: + await aiotools.run_async(proc.join) + + # ===== + + def __queue_cmd(self, cmd: _BaseCmd) -> None: + if not self.__stop_event.is_set(): + self.__cmd_queue.put_nowait(cmd) + + def __queue_event(self, event: BaseEvent) -> None: + if not self.__stop_event.is_set(): + self.__events_queue.put_nowait(event) + + def __subprocess(self) -> None: + logger = aioproc.settle("Switch", "switch") + no_device_reported = False + while True: + try: + if self.__device.has_device(): + no_device_reported = False + with self.__device: + logger.info("Switch found") + self.__queue_event(DeviceFoundEvent()) + self.__main_loop() + elif not no_device_reported: + self.__queue_event(ChainTruncatedEvent(0)) + logger.info("Switch is missing") + no_device_reported = True + except DeviceError as ex: + logger.error("%s", tools.efmt(ex)) + except Exception: + logger.exception("Unexpected error in the Switch loop") + tools.clear_queue(self.__cmd_queue) + if self.__stop_event.is_set(): + break + time.sleep(1) + + def __main_loop(self) -> None: + self.__device.request_state() + self.__device.request_atx_leds() + while not self.__stop_event.is_set(): + if self.__select(): + for resp in self.__device.read_all(): + self.__update_units(resp) + self.__adjust_start_port() + self.__finish_changing_request(resp) + self.__consume_commands() + self.__ensure_config() + + def __select(self) -> bool: + try: + return bool(select.select([ + self.__device.get_fd(), + self.__cmd_queue._reader, # type: ignore # pylint: disable=protected-access + ], [], [], 1)[0]) + except Exception as ex: + raise DeviceError(ex) + + def __consume_commands(self) -> None: + while not self.__cmd_queue.empty(): + cmd = self.__cmd_queue.get() + match cmd: + case _CmdSetActual(): + self.__actual = cmd.actual + + case _CmdSetActivePort(): + # Может быть вызвано изнутри при синхронизации + self.__active_port = cmd.port + self.__queue_event(PortActivatedEvent(self.__active_port)) + + case _CmdSetPortBeacon(): + (unit, ch) = self.get_real_unit_channel(cmd.port) + self.__device.request_beacon(unit, ch, cmd.on) + + case _CmdSetUnitBeacon(): + ch = (4 if cmd.downlink else 5) + self.__device.request_beacon(cmd.unit, ch, cmd.on) + + case _CmdAtxClick(): + (unit, ch) = self.get_real_unit_channel(cmd.port) + if unit < len(self.__units): + (allowed, powered) = self.__units[unit].is_atx_allowed(ch) + if allowed and (cmd.if_powered is None or cmd.if_powered == powered): + delay_ms = min(int(cmd.delay * 1000), 0xFFFF) + if cmd.reset: + self.__device.request_atx_cr(unit, ch, delay_ms) + else: + self.__device.request_atx_cp(unit, ch, delay_ms) + + case _CmdSetEdids(): + self.__edids = cmd.edids + + case _CmdSetColors(): + self.__colors = cmd.colors + + case _CmdRebootUnit(): + self.__device.request_reboot(cmd.unit, cmd.bootloader) + + def __update_units(self, resp: Response) -> None: + units = resp.header.unit + 1 + while len(self.__units) < units: + self.__units.append(_UnitContext()) + + match resp.body: + case UnitState(): + if not resp.body.flags.has_downlink and len(self.__units) > units: + del self.__units[units:] + self.__queue_event(ChainTruncatedEvent(units)) + self.__units[resp.header.unit].state = resp.body + self.__queue_event(UnitStateEvent(resp.header.unit, resp.body)) + + case UnitAtxLeds(): + self.__units[resp.header.unit].atx_leds = resp.body + self.__queue_event(UnitAtxLedsEvent(resp.header.unit, resp.body)) + + def __adjust_start_port(self) -> None: + if self.__active_port < 0: + for (unit, ctx) in enumerate(self.__units): + if ctx.state is not None and ctx.state.ch < 4: + # Trigger queue select() + port = self.get_virtual_port(unit, ctx.state.ch) + get_logger().info("Found an active port %d on [%d:%d]: Syncing ...", + port, unit, ctx.state.ch) + self.set_active_port(port) + break + + def __finish_changing_request(self, resp: Response) -> None: + if self.__units[resp.header.unit].changing_rid == resp.header.rid: + self.__units[resp.header.unit].changing_rid = -1 + + # ===== + + def __ensure_config(self) -> None: + for (unit, ctx) in enumerate(self.__units): + if ctx.state is not None: + self.__ensure_config_port(unit, ctx) + if self.__actual: + self.__ensure_config_edids(unit, ctx) + self.__ensure_config_colors(unit, ctx) + + def __ensure_config_port(self, unit: int, ctx: _UnitContext) -> None: + assert ctx.state is not None + if self.__active_port >= 0 and ctx.can_be_changed(): + ch = self.get_unit_target_channel(unit, self.__active_port) + if ctx.state.ch != ch: + get_logger().info("Switching for active port %d: [%d:%d] -> [%d:%d] ...", + self.__active_port, unit, ctx.state.ch, unit, ch) + ctx.changing_rid = self.__device.request_switch(unit, ch) + + def __ensure_config_edids(self, unit: int, ctx: _UnitContext) -> None: + assert self.__actual + assert ctx.state is not None + if ctx.can_be_changed(): + for ch in range(4): + port = self.get_virtual_port(unit, ch) + edid = self.__edids.get_edid_for_port(port) + if not ctx.state.compare_edid(ch, edid): + get_logger().info("Changing EDID on port %d on [%d:%d]: %d/%d -> %d/%d (%s) ...", + port, unit, ch, + ctx.state.video_crc[ch], ctx.state.video_edid[ch], + edid.crc, edid.valid, edid.name) + ctx.changing_rid = self.__device.request_set_edid(unit, ch, edid) + break # Busy globally + + def __ensure_config_colors(self, unit: int, ctx: _UnitContext) -> None: + assert self.__actual + assert ctx.state is not None + for np in range(6): + if self.__colors.crc != ctx.state.np_crc[np]: + # get_logger().info("Changing colors on NP [%d:%d]: %d -> %d ...", + # unit, np, ctx.state.np_crc[np], self.__colors.crc) + self.__device.request_set_colors(unit, np, self.__colors) + + # ===== + + @classmethod + def get_real_unit_channel(cls, port: int) -> tuple[int, int]: + return (port // 4, port % 4) + + @classmethod + def get_unit_target_channel(cls, unit: int, port: int) -> int: + (t_unit, t_ch) = cls.get_real_unit_channel(port) + if unit != t_unit: + t_ch = 4 + return t_ch + + @classmethod + def get_virtual_port(cls, unit: int, ch: int) -> int: + return (unit * 4) + ch diff --git a/kvmd/apps/kvmd/switch/device.py b/kvmd/apps/kvmd/switch/device.py new file mode 100644 index 00000000..b56cc406 --- /dev/null +++ b/kvmd/apps/kvmd/switch/device.py @@ -0,0 +1,196 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import os +import random +import types + +import serial + +from .lib import tools + +from .types import Edid +from .types import Colors + +from .proto import Packable +from .proto import Request +from .proto import Response +from .proto import Header + +from .proto import BodySwitch +from .proto import BodySetBeacon +from .proto import BodyAtxClick +from .proto import BodySetEdid +from .proto import BodyClearEdid +from .proto import BodySetColors + + +# ===== +class DeviceError(Exception): + def __init__(self, ex: Exception): + super().__init__(tools.efmt(ex)) + + +class Device: + __SPEED = 115200 + __TIMEOUT = 5.0 + + def __init__(self, device_path: str) -> None: + self.__device_path = device_path + self.__rid = random.randint(1, 0xFFFF) + self.__tty: (serial.Serial | None) = None + self.__buf: bytes = b"" + + def __enter__(self) -> "Device": + try: + self.__tty = serial.Serial( + self.__device_path, + baudrate=self.__SPEED, + timeout=self.__TIMEOUT, + ) + except Exception as ex: + raise DeviceError(ex) + return self + + def __exit__( + self, + _exc_type: type[BaseException], + _exc: BaseException, + _tb: types.TracebackType, + ) -> None: + + if self.__tty is not None: + try: + self.__tty.close() + except Exception: + pass + self.__tty = None + + def has_device(self) -> bool: + return os.path.exists(self.__device_path) + + def get_fd(self) -> int: + assert self.__tty is not None + return self.__tty.fd + + def read_all(self) -> list[Response]: + assert self.__tty is not None + try: + if not self.__tty.in_waiting: + return [] + self.__buf += self.__tty.read_all() + except Exception as ex: + raise DeviceError(ex) + + results: list[Response] = [] + while True: + try: + begin = self.__buf.index(0xF1) + except ValueError: + break + try: + end = self.__buf.index(0xF2, begin) + except ValueError: + break + msg = self.__buf[begin + 1:end] + if 0xF1 in msg: + # raise RuntimeError(f"Found 0xF1 inside the message: {msg!r}") + break + self.__buf = self.__buf[end + 1:] + msg = self.__unescape(msg) + resp = Response.unpack(msg) + if resp is not None: + results.append(resp) + return results + + def __unescape(self, msg: bytes) -> bytes: + if 0xF0 not in msg: + return msg + unesc: list[int] = [] + esc = False + for ch in msg: + if ch == 0xF0: + esc = True + else: + if esc: + ch ^= 0xFF + esc = False + unesc.append(ch) + return bytes(unesc) + + def request_reboot(self, unit: int, bootloader: bool) -> int: + return self.__send_request((Header.BOOTLOADER if bootloader else Header.REBOOT), unit, None) + + def request_state(self) -> int: + return self.__send_request(Header.STATE, 0xFF, None) + + def request_switch(self, unit: int, ch: int) -> int: + return self.__send_request(Header.SWITCH, unit, BodySwitch(ch)) + + def request_beacon(self, unit: int, ch: int, on: bool) -> int: + return self.__send_request(Header.BEACON, unit, BodySetBeacon(ch, on)) + + def request_atx_leds(self) -> int: + return self.__send_request(Header.ATX_LEDS, 0xFF, None) + + def request_atx_cp(self, unit: int, ch: int, delay_ms: int) -> int: + return self.__send_request(Header.ATX_CLICK, unit, BodyAtxClick(ch, BodyAtxClick.POWER, delay_ms)) + + def request_atx_cr(self, unit: int, ch: int, delay_ms: int) -> int: + return self.__send_request(Header.ATX_CLICK, unit, BodyAtxClick(ch, BodyAtxClick.RESET, delay_ms)) + + def request_set_edid(self, unit: int, ch: int, edid: Edid) -> int: + if edid.valid: + return self.__send_request(Header.SET_EDID, unit, BodySetEdid(ch, edid)) + return self.__send_request(Header.CLEAR_EDID, unit, BodyClearEdid(ch)) + + def request_set_colors(self, unit: int, ch: int, colors: Colors) -> int: + return self.__send_request(Header.SET_COLORS, unit, BodySetColors(ch, colors)) + + def __send_request(self, op: int, unit: int, body: (Packable | None)) -> int: + assert self.__tty is not None + req = Request(Header( + proto=1, + rid=self.__get_next_rid(), + op=op, + unit=unit, + ), body) + data: list[int] = [0xF1] + for ch in req.pack(): + if 0xF0 <= ch <= 0xF2: + data.append(0xF0) + ch ^= 0xFF + data.append(ch) + data.append(0xF2) + try: + self.__tty.write(bytes(data)) + self.__tty.flush() + except Exception as ex: + raise DeviceError(ex) + return req.header.rid + + def __get_next_rid(self) -> int: + rid = self.__rid + self.__rid += 1 + if self.__rid > 0xFFFF: + self.__rid = 1 + return rid diff --git a/kvmd/apps/kvmd/switch/lib.py b/kvmd/apps/kvmd/switch/lib.py new file mode 100644 index 00000000..4ef2647e --- /dev/null +++ b/kvmd/apps/kvmd/switch/lib.py @@ -0,0 +1,35 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +# pylint: disable=unused-import + +from ....logging import get_logger # noqa: F401 + +from .... import tools # noqa: F401 +from .... import aiotools # noqa: F401 +from .... import aioproc # noqa: F401 +from .... import bitbang # noqa: F401 +from .... import htclient # noqa: F401 +from ....inotify import Inotify # noqa: F401 +from ....errors import OperationError # noqa: F401 +from ....edid import EdidNoBlockError as ParsedEdidNoBlockError # noqa: F401 +from ....edid import Edid as ParsedEdid # noqa: F401 diff --git a/kvmd/apps/kvmd/switch/proto.py b/kvmd/apps/kvmd/switch/proto.py new file mode 100644 index 00000000..d4f43f84 --- /dev/null +++ b/kvmd/apps/kvmd/switch/proto.py @@ -0,0 +1,295 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import struct +import dataclasses + +from typing import Optional + +from .types import Edid +from .types import Colors + + +# ===== +class Packable: + def pack(self) -> bytes: + raise NotImplementedError() + + +class Unpackable: + @classmethod + def unpack(cls, data: bytes, offset: int=0) -> "Unpackable": + raise NotImplementedError() + + +# ===== +@dataclasses.dataclass(frozen=True) +class Header(Packable, Unpackable): + proto: int + rid: int + op: int + unit: int + + NAK = 0 + BOOTLOADER = 2 + REBOOT = 3 + STATE = 4 + SWITCH = 5 + BEACON = 6 + ATX_LEDS = 7 + ATX_CLICK = 8 + SET_EDID = 9 + CLEAR_EDID = 10 + SET_COLORS = 12 + + __struct = struct.Struct(" bytes: + return self.__struct.pack(self.proto, self.rid, self.op, self.unit) + + @classmethod + def unpack(cls, data: bytes, offset: int=0) -> "Header": + return Header(*cls.__struct.unpack_from(data, offset=offset)) + + +@dataclasses.dataclass(frozen=True) +class Nak(Unpackable): + reason: int + + INVALID_COMMAND = 0 + BUSY = 1 + NO_DOWNLINK = 2 + DOWNLINK_OVERFLOW = 3 + + __struct = struct.Struct(" "Nak": + return Nak(*cls.__struct.unpack_from(data, offset=offset)) + + +@dataclasses.dataclass(frozen=True) +class UnitFlags: + changing_busy: bool + flashing_busy: bool + has_downlink: bool + + +@dataclasses.dataclass(frozen=True) +class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes + sw_version: int + hw_version: int + flags: UnitFlags + ch: int + beacons: tuple[bool, bool, bool, bool, bool, bool] + np_crc: tuple[int, int, int, int, int, int] + video_5v_sens: tuple[bool, bool, bool, bool, bool] + video_hpd: tuple[bool, bool, bool, bool, bool] + video_edid: tuple[bool, bool, bool, bool] + video_crc: tuple[int, int, int, int] + usb_5v_sens: tuple[bool, bool, bool, bool] + atx_busy: tuple[bool, bool, bool, bool] + + __struct = struct.Struct(" bool: + if edid is None: + # Сойдет любой невалидный EDID + return (not self.video_edid[ch]) + return ( + self.video_edid[ch] == edid.valid + and self.video_crc[ch] == edid.crc + ) + + @classmethod + def unpack(cls, data: bytes, offset: int=0) -> "UnitState": # pylint: disable=too-many-locals + ( + sw_version, hw_version, flags, ch, + beacons, nc0, nc1, nc2, nc3, nc4, nc5, + video_5v_sens, video_hpd, video_edid, vc0, vc1, vc2, vc3, + usb_5v_sens, atx_busy, + ) = cls.__struct.unpack_from(data, offset=offset) + return UnitState( + sw_version, + hw_version, + flags=UnitFlags( + changing_busy=bool(flags & 0x80), + flashing_busy=bool(flags & 0x40), + has_downlink=bool(flags & 0x02), + ), + ch=ch, + beacons=cls.__make_flags6(beacons), + np_crc=(nc0, nc1, nc2, nc3, nc4, nc5), + video_5v_sens=cls.__make_flags5(video_5v_sens), + video_hpd=cls.__make_flags5(video_hpd), + video_edid=cls.__make_flags4(video_edid), + video_crc=(vc0, vc1, vc2, vc3), + usb_5v_sens=cls.__make_flags4(usb_5v_sens), + atx_busy=cls.__make_flags4(atx_busy), + ) + + @classmethod + def __make_flags6(cls, mask: int) -> tuple[bool, bool, bool, bool, bool, bool]: + return ( + bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), + bool(mask & 0x08), bool(mask & 0x10), bool(mask & 0x20), + ) + + @classmethod + def __make_flags5(cls, mask: int) -> tuple[bool, bool, bool, bool, bool]: + return ( + bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), + bool(mask & 0x08), bool(mask & 0x10), + ) + + @classmethod + def __make_flags4(cls, mask: int) -> tuple[bool, bool, bool, bool]: + return (bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), bool(mask & 0x08)) + + +@dataclasses.dataclass(frozen=True) +class UnitAtxLeds(Unpackable): + power: tuple[bool, bool, bool, bool] + hdd: tuple[bool, bool, bool, bool] + + __struct = struct.Struct(" "UnitAtxLeds": + (mask,) = cls.__struct.unpack_from(data, offset=offset) + return UnitAtxLeds( + power=(bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), bool(mask & 0x08)), + hdd=(bool(mask & 0x10), bool(mask & 0x20), bool(mask & 0x40), bool(mask & 0x80)), + ) + + +# ===== +@dataclasses.dataclass(frozen=True) +class BodySwitch(Packable): + ch: int + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 4 + + def pack(self) -> bytes: + return self.ch.to_bytes() + + +@dataclasses.dataclass(frozen=True) +class BodySetBeacon(Packable): + ch: int + on: bool + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 5 + + def pack(self) -> bytes: + return self.ch.to_bytes() + self.on.to_bytes() + + +@dataclasses.dataclass(frozen=True) +class BodyAtxClick(Packable): + ch: int + action: int + delay_ms: int + + POWER = 0 + RESET = 1 + + __struct = struct.Struct(" None: + assert 0 <= self.ch <= 3 + assert self.action in [self.POWER, self.RESET] + assert 1 <= self.delay_ms <= 0xFFFF + + def pack(self) -> bytes: + return self.__struct.pack(self.ch, self.action, self.delay_ms) + + +@dataclasses.dataclass(frozen=True) +class BodySetEdid(Packable): + ch: int + edid: Edid + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 3 + + def pack(self) -> bytes: + return self.ch.to_bytes() + self.edid.pack() + + +@dataclasses.dataclass(frozen=True) +class BodyClearEdid(Packable): + ch: int + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 3 + + def pack(self) -> bytes: + return self.ch.to_bytes() + + +@dataclasses.dataclass(frozen=True) +class BodySetColors(Packable): + ch: int + colors: Colors + + def __post_init__(self) -> None: + assert 0 <= self.ch <= 5 + + def pack(self) -> bytes: + return self.ch.to_bytes() + self.colors.pack() + + +# ===== +@dataclasses.dataclass(frozen=True) +class Request: + header: Header + body: (Packable | None) = dataclasses.field(default=None) + + def pack(self) -> bytes: + msg = self.header.pack() + if self.body is not None: + msg += self.body.pack() + return msg + + +@dataclasses.dataclass(frozen=True) +class Response: + header: Header + body: Unpackable + + @classmethod + def unpack(cls, msg: bytes) -> Optional["Response"]: + header = Header.unpack(msg) + match header.op: + case Header.NAK: + return Response(header, Nak.unpack(msg, Header.SIZE)) + case Header.STATE: + return Response(header, UnitState.unpack(msg, Header.SIZE)) + case Header.ATX_LEDS: + return Response(header, UnitAtxLeds.unpack(msg, Header.SIZE)) + # raise RuntimeError(f"Unknown OP in the header: {header!r}") + return None diff --git a/kvmd/apps/kvmd/switch/state.py b/kvmd/apps/kvmd/switch/state.py new file mode 100644 index 00000000..626cdfe1 --- /dev/null +++ b/kvmd/apps/kvmd/switch/state.py @@ -0,0 +1,355 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import asyncio +import dataclasses +import time + +from typing import AsyncGenerator + +from .types import Edids +from .types import Color +from .types import Colors +from .types import PortNames +from .types import AtxClickPowerDelays +from .types import AtxClickPowerLongDelays +from .types import AtxClickResetDelays + +from .proto import UnitState +from .proto import UnitAtxLeds + +from .chain import Chain + + +# ===== +@dataclasses.dataclass +class _UnitInfo: + state: (UnitState | None) = dataclasses.field(default=None) + atx_leds: (UnitAtxLeds | None) = dataclasses.field(default=None) + + +# ===== +class StateCache: # pylint: disable=too-many-instance-attributes + __FULL = 0xFFFF + __SUMMARY = 0x01 + __EDIDS = 0x02 + __COLORS = 0x04 + __VIDEO = 0x08 + __USB = 0x10 + __BEACONS = 0x20 + __ATX = 0x40 + + def __init__(self) -> None: + self.__edids = Edids() + self.__colors = Colors() + self.__port_names = PortNames({}) + self.__atx_cp_delays = AtxClickPowerDelays({}) + self.__atx_cpl_delays = AtxClickPowerLongDelays({}) + self.__atx_cr_delays = AtxClickResetDelays({}) + + self.__units: list[_UnitInfo] = [] + self.__active_port = -1 + self.__synced = True + + self.__queue: "asyncio.Queue[int]" = asyncio.Queue() + + def get_edids(self) -> Edids: + return self.__edids.copy() + + def get_colors(self) -> Colors: + return self.__colors + + def get_port_names(self) -> PortNames: + return self.__port_names.copy() + + def get_atx_cp_delays(self) -> AtxClickPowerDelays: + return self.__atx_cp_delays.copy() + + def get_atx_cpl_delays(self) -> AtxClickPowerLongDelays: + return self.__atx_cpl_delays.copy() + + def get_atx_cr_delays(self) -> AtxClickResetDelays: + return self.__atx_cr_delays.copy() + + # ===== + + def get_state(self) -> dict: + return self.__inner_get_state(self.__FULL) + + async def trigger_state(self) -> None: + self.__bump_state(self.__FULL) + + async def poll_state(self) -> AsyncGenerator[dict, None]: + atx_ts: float = 0 + while True: + try: + mask = await asyncio.wait_for(self.__queue.get(), timeout=0.1) + except TimeoutError: + mask = 0 + + if mask == self.__ATX: + # Откладываем единичное новое событие ATX, чтобы аккумулировать с нескольких свичей + if atx_ts == 0: + atx_ts = time.monotonic() + 0.2 + continue + elif atx_ts >= time.monotonic(): + continue + # ... Ну или разрешаем отправить, если оно уже достаточно мариновалось + elif mask == 0 and atx_ts > time.monotonic(): + # Разрешаем отправить отложенное + mask = self.__ATX + atx_ts = 0 + elif mask & self.__ATX: + # Комплексное событие всегда должно обрабатываться сразу + atx_ts = 0 + + if mask != 0: + yield self.__inner_get_state(mask) + + def __inner_get_state(self, mask: int) -> dict: # pylint: disable=too-many-branches,too-many-statements,too-many-locals + assert mask != 0 + x_model = (mask == self.__FULL) + x_summary = (mask & self.__SUMMARY) + x_edids = (mask & self.__EDIDS) + x_colors = (mask & self.__COLORS) + x_video = (mask & self.__VIDEO) + x_usb = (mask & self.__USB) + x_beacons = (mask & self.__BEACONS) + x_atx = (mask & self.__ATX) + + state: dict = {} + if x_model: + state["model"] = { + "units": [], + "ports": [], + "limits": { + "atx": { + "click_delays": { + key: {"default": value, "min": 0, "max": 10} + for (key, value) in [ + ("power", self.__atx_cp_delays.default), + ("power_long", self.__atx_cpl_delays.default), + ("reset", self.__atx_cr_delays.default), + ] + }, + }, + }, + } + if x_summary: + state["summary"] = {"active_port": self.__active_port, "synced": self.__synced} + if x_edids: + state["edids"] = { + "all": { + edid_id: { + "name": edid.name, + "data": edid.as_text(), + "parsed": (dataclasses.asdict(edid.info) if edid.info is not None else None), + } + for (edid_id, edid) in self.__edids.all.items() + }, + "used": [], + } + if x_colors: + state["colors"] = { + role: { + comp: getattr(getattr(self.__colors, role), comp) + for comp in Color.COMPONENTS + } + for role in Colors.ROLES + } + if x_video: + state["video"] = {"links": []} + if x_usb: + state["usb"] = {"links": []} + if x_beacons: + state["beacons"] = {"uplinks": [], "downlinks": [], "ports": []} + if x_atx: + state["atx"] = {"busy": [], "leds": {"power": [], "hdd": []}} + + if not self.__is_units_ready(): + return state + + for (unit, ui) in enumerate(self.__units): + assert ui.state is not None + assert ui.atx_leds is not None + if x_model: + state["model"]["units"].append({"firmware": {"version": ui.state.sw_version}}) + if x_video: + state["video"]["links"].extend(ui.state.video_5v_sens[:4]) + if x_usb: + state["usb"]["links"].extend(ui.state.usb_5v_sens) + if x_beacons: + state["beacons"]["uplinks"].append(ui.state.beacons[5]) + state["beacons"]["downlinks"].append(ui.state.beacons[4]) + state["beacons"]["ports"].extend(ui.state.beacons[:4]) + if x_atx: + state["atx"]["busy"].extend(ui.state.atx_busy) + state["atx"]["leds"]["power"].extend(ui.atx_leds.power) + state["atx"]["leds"]["hdd"].extend(ui.atx_leds.hdd) + if x_model or x_edids: + for ch in range(4): + port = Chain.get_virtual_port(unit, ch) + if x_model: + state["model"]["ports"].append({ + "unit": unit, + "channel": ch, + "name": self.__port_names[port], + "atx": { + "click_delays": { + "power": self.__atx_cp_delays[port], + "power_long": self.__atx_cpl_delays[port], + "reset": self.__atx_cr_delays[port], + }, + }, + }) + if x_edids: + state["edids"]["used"].append(self.__edids.get_id_for_port(port)) + return state + + def __inner_check_synced(self) -> bool: + for (unit, ui) in enumerate(self.__units): + if ui.state is None or ui.state.flags.changing_busy: + return False + if ( + self.__active_port >= 0 + and ui.state.ch != Chain.get_unit_target_channel(unit, self.__active_port) + ): + return False + for ch in range(4): + port = Chain.get_virtual_port(unit, ch) + edid = self.__edids.get_edid_for_port(port) + if not ui.state.compare_edid(ch, edid): + return False + for ch in range(6): + if ui.state.np_crc[ch] != self.__colors.crc: + return False + return True + + def __recache_synced(self) -> bool: + synced = self.__inner_check_synced() + if self.__synced != synced: + self.__synced = synced + return True + return False + + def truncate(self, units: int) -> None: + if len(self.__units) > units: + del self.__units[units:] + self.__bump_state(self.__FULL) + + def update_active_port(self, port: int) -> None: + changed = (self.__active_port != port) + self.__active_port = port + changed = (self.__recache_synced() or changed) + if changed: + self.__bump_state(self.__SUMMARY) + + def update_unit_state(self, unit: int, new: UnitState) -> None: + ui = self.__ensure_unit(unit) + (prev, ui.state) = (ui.state, new) + if not self.__is_units_ready(): + return + mask = 0 + if prev is None: + mask = self.__FULL + else: + if self.__recache_synced(): + mask |= self.__SUMMARY + if prev.video_5v_sens != new.video_5v_sens: + mask |= self.__VIDEO + if prev.usb_5v_sens != new.usb_5v_sens: + mask |= self.__USB + if prev.beacons != new.beacons: + mask |= self.__BEACONS + if prev.atx_busy != new.atx_busy: + mask |= self.__ATX + if mask: + self.__bump_state(mask) + + def update_unit_atx_leds(self, unit: int, new: UnitAtxLeds) -> None: + ui = self.__ensure_unit(unit) + (prev, ui.atx_leds) = (ui.atx_leds, new) + if not self.__is_units_ready(): + return + if prev is None: + self.__bump_state(self.__FULL) + elif prev != new: + self.__bump_state(self.__ATX) + + def __is_units_ready(self) -> bool: + for ui in self.__units: + if ui.state is None or ui.atx_leds is None: + return False + return True + + def __ensure_unit(self, unit: int) -> _UnitInfo: + while len(self.__units) < unit + 1: + self.__units.append(_UnitInfo()) + return self.__units[unit] + + def __bump_state(self, mask: int) -> None: + assert mask != 0 + self.__queue.put_nowait(mask) + + # ===== + + def set_edids(self, edids: Edids) -> None: + changed = ( + self.__edids.all != edids.all + or not self.__edids.compare_on_ports(edids, self.__get_ports()) + ) + self.__edids = edids.copy() + if changed: + self.__bump_state(self.__EDIDS) + + def set_colors(self, colors: Colors) -> None: + changed = (self.__colors != colors) + self.__colors = colors + if changed: + self.__bump_state(self.__COLORS) + + def set_port_names(self, port_names: PortNames) -> None: + changed = (not self.__port_names.compare_on_ports(port_names, self.__get_ports())) + self.__port_names = port_names.copy() + if changed: + self.__bump_state(self.__FULL) + + def set_atx_cp_delays(self, delays: AtxClickPowerDelays) -> None: + changed = (not self.__atx_cp_delays.compare_on_ports(delays, self.__get_ports())) + self.__atx_cp_delays = delays.copy() + if changed: + self.__bump_state(self.__FULL) + + def set_atx_cpl_delays(self, delays: AtxClickPowerLongDelays) -> None: + changed = (not self.__atx_cpl_delays.compare_on_ports(delays, self.__get_ports())) + self.__atx_cpl_delays = delays.copy() + if changed: + self.__bump_state(self.__FULL) + + def set_atx_cr_delays(self, delays: AtxClickResetDelays) -> None: + changed = (not self.__atx_cr_delays.compare_on_ports(delays, self.__get_ports())) + self.__atx_cr_delays = delays.copy() + if changed: + self.__bump_state(self.__FULL) + + def __get_ports(self) -> int: + return (len(self.__units) * 4) diff --git a/kvmd/apps/kvmd/switch/storage.py b/kvmd/apps/kvmd/switch/storage.py new file mode 100644 index 00000000..6e3a0a76 --- /dev/null +++ b/kvmd/apps/kvmd/switch/storage.py @@ -0,0 +1,186 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import os +import asyncio +import json +import contextlib + +from typing import AsyncGenerator + +try: + from ....clients.pst import PstClient +except ImportError: + PstClient = None # type: ignore + +# from .lib import get_logger +from .lib import aiotools +from .lib import htclient +from .lib import get_logger + +from .types import Edid +from .types import Edids +from .types import Color +from .types import Colors +from .types import PortNames +from .types import AtxClickPowerDelays +from .types import AtxClickPowerLongDelays +from .types import AtxClickResetDelays + + +# ===== +class StorageContext: + __F_EDIDS_ALL = "edids_all.json" + __F_EDIDS_PORT = "edids_port.json" + + __F_COLORS = "colors.json" + + __F_PORT_NAMES = "port_names.json" + + __F_ATX_CP_DELAYS = "atx_click_power_delays.json" + __F_ATX_CPL_DELAYS = "atx_click_power_long_delays.json" + __F_ATX_CR_DELAYS = "atx_click_reset_delays.json" + + def __init__(self, path: str, rw: bool) -> None: + self.__path = path + self.__rw = rw + + # ===== + + async def write_edids(self, edids: Edids) -> None: + await self.__write_json_keyvals(self.__F_EDIDS_ALL, { + edid_id.lower(): {"name": edid.name, "data": edid.as_text()} + for (edid_id, edid) in edids.all.items() + if edid_id != Edids.DEFAULT_ID + }) + await self.__write_json_keyvals(self.__F_EDIDS_PORT, edids.port) + + async def write_colors(self, colors: Colors) -> None: + await self.__write_json_keyvals(self.__F_COLORS, { + role: { + comp: getattr(getattr(colors, role), comp) + for comp in Color.COMPONENTS + } + for role in Colors.ROLES + }) + + async def write_port_names(self, port_names: PortNames) -> None: + await self.__write_json_keyvals(self.__F_PORT_NAMES, port_names.kvs) + + async def write_atx_cp_delays(self, delays: AtxClickPowerDelays) -> None: + await self.__write_json_keyvals(self.__F_ATX_CP_DELAYS, delays.kvs) + + async def write_atx_cpl_delays(self, delays: AtxClickPowerLongDelays) -> None: + await self.__write_json_keyvals(self.__F_ATX_CPL_DELAYS, delays.kvs) + + async def write_atx_cr_delays(self, delays: AtxClickResetDelays) -> None: + await self.__write_json_keyvals(self.__F_ATX_CR_DELAYS, delays.kvs) + + async def __write_json_keyvals(self, name: str, kvs: dict) -> None: + if len(self.__path) == 0: + return + assert self.__rw + kvs = {str(key): value for (key, value) in kvs.items()} + if (await self.__read_json_keyvals(name)) == kvs: + return # Don't write the same data + path = os.path.join(self.__path, name) + get_logger(0).info("Writing '%s' ...", name) + await aiotools.write_file(path, json.dumps(kvs)) + + # ===== + + async def read_edids(self) -> Edids: + all_edids = { + edid_id.lower(): Edid.from_data(edid["name"], edid["data"]) + for (edid_id, edid) in (await self.__read_json_keyvals(self.__F_EDIDS_ALL)).items() + } + port_edids = await self.__read_json_keyvals_int(self.__F_EDIDS_PORT) + return Edids(all_edids, port_edids) + + async def read_colors(self) -> Colors: + raw = await self.__read_json_keyvals(self.__F_COLORS) + return Colors(**{ # type: ignore + role: Color(**{comp: raw[role][comp] for comp in Color.COMPONENTS}) + for role in Colors.ROLES + if role in raw + }) + + async def read_port_names(self) -> PortNames: + return PortNames(await self.__read_json_keyvals_int(self.__F_PORT_NAMES)) + + async def read_atx_cp_delays(self) -> AtxClickPowerDelays: + return AtxClickPowerDelays(await self.__read_json_keyvals_int(self.__F_ATX_CP_DELAYS)) + + async def read_atx_cpl_delays(self) -> AtxClickPowerLongDelays: + return AtxClickPowerLongDelays(await self.__read_json_keyvals_int(self.__F_ATX_CPL_DELAYS)) + + async def read_atx_cr_delays(self) -> AtxClickResetDelays: + return AtxClickResetDelays(await self.__read_json_keyvals_int(self.__F_ATX_CR_DELAYS)) + + async def __read_json_keyvals_int(self, name: str) -> dict: + return (await self.__read_json_keyvals(name, int_keys=True)) + + async def __read_json_keyvals(self, name: str, int_keys: bool=False) -> dict: + if len(self.__path) == 0: + return {} + path = os.path.join(self.__path, name) + try: + kvs: dict = json.loads(await aiotools.read_file(path)) + except FileNotFoundError: + kvs = {} + if int_keys: + kvs = {int(key): value for (key, value) in kvs.items()} + return kvs + + +class Storage: + __SUBDIR = "__switch__" + __TIMEOUT = 5.0 + + def __init__(self, unix_path: str) -> None: + self.__pst: (PstClient | None) = None + if len(unix_path) > 0 and PstClient is not None: + self.__pst = PstClient( + subdir=self.__SUBDIR, + unix_path=unix_path, + timeout=self.__TIMEOUT, + user_agent=htclient.make_user_agent("KVMD"), + ) + self.__lock = asyncio.Lock() + + @contextlib.asynccontextmanager + async def readable(self) -> AsyncGenerator[StorageContext, None]: + async with self.__lock: + if self.__pst is None: + yield StorageContext("", False) + else: + path = await self.__pst.get_path() + yield StorageContext(path, False) + + @contextlib.asynccontextmanager + async def writable(self) -> AsyncGenerator[StorageContext, None]: + async with self.__lock: + if self.__pst is None: + yield StorageContext("", True) + else: + async with self.__pst.writable() as path: + yield StorageContext(path, True) diff --git a/kvmd/apps/kvmd/switch/types.py b/kvmd/apps/kvmd/switch/types.py new file mode 100644 index 00000000..32225f06 --- /dev/null +++ b/kvmd/apps/kvmd/switch/types.py @@ -0,0 +1,308 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import re +import struct +import uuid +import dataclasses + +from typing import TypeVar +from typing import Generic + +from .lib import bitbang +from .lib import ParsedEdidNoBlockError +from .lib import ParsedEdid + + +# ===== +@dataclasses.dataclass(frozen=True) +class EdidInfo: + mfc_id: str + product_id: int + serial: int + monitor_name: (str | None) + monitor_serial: (str | None) + audio: bool + + @classmethod + def from_data(cls, data: bytes) -> "EdidInfo": + parsed = ParsedEdid(data) + + monitor_name: (str | None) = None + try: + monitor_name = parsed.get_monitor_name() + except ParsedEdidNoBlockError: + pass + + monitor_serial: (str | None) = None + try: + monitor_serial = parsed.get_monitor_serial() + except ParsedEdidNoBlockError: + pass + + return EdidInfo( + mfc_id=parsed.get_mfc_id(), + product_id=parsed.get_product_id(), + serial=parsed.get_serial(), + monitor_name=monitor_name, + monitor_serial=monitor_serial, + audio=parsed.get_audio(), + ) + + +@dataclasses.dataclass(frozen=True) +class Edid: + name: str + data: bytes + crc: int = dataclasses.field(default=0) + valid: bool = dataclasses.field(default=False) + info: (EdidInfo | None) = dataclasses.field(default=None) + + __HEADER = b"\x00\xFF\xFF\xFF\xFF\xFF\xFF\x00" + + def __post_init__(self) -> None: + assert len(self.name) > 0 + assert len(self.data) == 256 + object.__setattr__(self, "crc", bitbang.make_crc16(self.data)) + object.__setattr__(self, "valid", self.data.startswith(self.__HEADER)) + try: + object.__setattr__(self, "info", EdidInfo.from_data(self.data)) + except Exception: + pass + + def as_text(self) -> str: + return "".join(f"{item:0{2}X}" for item in self.data) + + def pack(self) -> bytes: + return self.data + + @classmethod + def from_data(cls, name: str, data: (str | bytes | None)) -> "Edid": + if data is None: # Пустой едид + return Edid(name, b"\x00" * 256) + + if isinstance(data, bytes): + if data.startswith(cls.__HEADER): + return Edid(name, data) # Бинарный едид + data_hex = data.decode() # Текстовый едид, прочитанный как бинарный из файла + else: # isinstance(data, str) + data_hex = str(data) # Текстовый едид + + data_hex = re.sub(r"\s", "", data_hex) + assert len(data_hex) == 512 + data = bytes([ + int(data_hex[index:index + 2], 16) + for index in range(0, len(data_hex), 2) + ]) + return Edid(name, data) + + +@dataclasses.dataclass +class Edids: + DEFAULT_NAME = "Default" + DEFAULT_ID = "default" + + all: dict[str, Edid] = dataclasses.field(default_factory=dict) + port: dict[int, str] = dataclasses.field(default_factory=dict) + + def __post_init__(self) -> None: + if self.DEFAULT_ID not in self.all: + self.set_default(None) + + def set_default(self, data: (str | bytes | None)) -> None: + self.all[self.DEFAULT_ID] = Edid.from_data(self.DEFAULT_NAME, data) + + def copy(self) -> "Edids": + return Edids(dict(self.all), dict(self.port)) + + def compare_on_ports(self, other: "Edids", ports: int) -> bool: + for port in range(ports): + if self.get_id_for_port(port) != other.get_id_for_port(port): + return False + return True + + def add(self, edid: Edid) -> str: + edid_id = str(uuid.uuid4()).lower() + self.all[edid_id] = edid + return edid_id + + def set(self, edid_id: str, edid: Edid) -> None: + assert edid_id in self.all + self.all[edid_id] = edid + + def get(self, edid_id: str) -> Edid: + return self.all[edid_id] + + def remove(self, edid_id: str) -> None: + assert edid_id in self.all + self.all.pop(edid_id) + for port in list(self.port): + if self.port[port] == edid_id: + self.port.pop(port) + + def has_id(self, edid_id: str) -> bool: + return (edid_id in self.all) + + def assign(self, port: int, edid_id: str) -> None: + assert edid_id in self.all + if edid_id == Edids.DEFAULT_ID: + self.port.pop(port, None) + else: + self.port[port] = edid_id + + def get_id_for_port(self, port: int) -> str: + return self.port.get(port, self.DEFAULT_ID) + + def get_edid_for_port(self, port: int) -> Edid: + return self.all[self.get_id_for_port(port)] + + +# ===== +@dataclasses.dataclass(frozen=True) +class Color: + COMPONENTS = frozenset(["red", "green", "blue", "brightness", "blink_ms"]) + + red: int + green: int + blue: int + brightness: int + blink_ms: int + crc: int = dataclasses.field(default=0) + _packed: bytes = dataclasses.field(default=b"") + + __struct = struct.Struct(" None: + assert 0 <= self.red <= 0xFF + assert 0 <= self.green <= 0xFF + assert 0 <= self.blue <= 0xFF + assert 0 <= self.brightness <= 0xFF + assert 0 <= self.blink_ms <= 0xFFFF + data = self.__struct.pack(self.red, self.green, self.blue, self.brightness, self.blink_ms) + object.__setattr__(self, "crc", bitbang.make_crc16(data)) + object.__setattr__(self, "_packed", data) + + def pack(self) -> bytes: + return self._packed + + @classmethod + def from_text(cls, text: str) -> "Color": + match = cls.__rx.match(text) + assert match is not None, text + return Color( + red=int(match.group(1), 16), + green=int(match.group(2), 16), + blue=int(match.group(3), 16), + brightness=int(match.group(4), 16), + blink_ms=int(match.group(5), 16), + ) + + +@dataclasses.dataclass(frozen=True) +class Colors: + ROLES = frozenset(["inactive", "active", "flashing", "beacon", "bootloader"]) + + inactive: Color = dataclasses.field(default=Color(255, 0, 0, 64, 0)) + active: Color = dataclasses.field(default=Color(0, 255, 0, 128, 0)) + flashing: Color = dataclasses.field(default=Color(0, 170, 255, 128, 0)) + beacon: Color = dataclasses.field(default=Color(228, 44, 156, 255, 250)) + bootloader: Color = dataclasses.field(default=Color(255, 170, 0, 128, 0)) + crc: int = dataclasses.field(default=0) + _packed: bytes = dataclasses.field(default=b"") + + __crc_struct = struct.Struct(" None: + crcs: list[int] = [] + packed: bytes = b"" + for color in [self.inactive, self.active, self.flashing, self.beacon, self.bootloader]: + crcs.append(color.crc) + packed += color.pack() + object.__setattr__(self, "crc", bitbang.make_crc16(self.__crc_struct.pack(*crcs))) + object.__setattr__(self, "_packed", packed) + + def pack(self) -> bytes: + return self._packed + + +# ===== +_T = TypeVar("_T") + + +class _PortsDict(Generic[_T]): + def __init__(self, default: _T, kvs: dict[int, _T]) -> None: + self.default = default + self.kvs = { + port: value + for (port, value) in kvs.items() + if value != default + } + + def compare_on_ports(self, other: "_PortsDict[_T]", ports: int) -> bool: + for port in range(ports): + if self[port] != other[port]: + return False + return True + + def __getitem__(self, port: int) -> _T: + return self.kvs.get(port, self.default) + + def __setitem__(self, port: int, value: (_T | None)) -> None: + if value is None: + value = self.default + if value == self.default: + self.kvs.pop(port, None) + else: + self.kvs[port] = value + + +class PortNames(_PortsDict[str]): + def __init__(self, kvs: dict[int, str]) -> None: + super().__init__("", kvs) + + def copy(self) -> "PortNames": + return PortNames(self.kvs) + + +class AtxClickPowerDelays(_PortsDict[float]): + def __init__(self, kvs: dict[int, float]) -> None: + super().__init__(0.5, kvs) + + def copy(self) -> "AtxClickPowerDelays": + return AtxClickPowerDelays(self.kvs) + + +class AtxClickPowerLongDelays(_PortsDict[float]): + def __init__(self, kvs: dict[int, float]) -> None: + super().__init__(5.5, kvs) + + def copy(self) -> "AtxClickPowerLongDelays": + return AtxClickPowerLongDelays(self.kvs) + + +class AtxClickResetDelays(_PortsDict[float]): + def __init__(self, kvs: dict[int, float]) -> None: + super().__init__(0.5, kvs) + + def copy(self) -> "AtxClickResetDelays": + return AtxClickResetDelays(self.kvs) diff --git a/kvmd/apps/pst/server.py b/kvmd/apps/pst/server.py index 79bbf7c8..8d8bf9d4 100644 --- a/kvmd/apps/pst/server.py +++ b/kvmd/apps/pst/server.py @@ -24,6 +24,7 @@ import os import asyncio from aiohttp.web import Request +from aiohttp.web import Response from aiohttp.web import WebSocketResponse from ...logging import get_logger @@ -35,6 +36,7 @@ from ... import fstab from ...htserver import exposed_http from ...htserver import exposed_ws +from ...htserver import make_json_response from ...htserver import WsSession from ...htserver import HttpServer @@ -65,6 +67,16 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst await ws.send_event("loop", {}) return (await self._ws_loop(ws)) + @exposed_http("GET", "/state") + async def __state_handler(self, _: Request) -> Response: + return make_json_response({ + "clients": len(self._get_wss()), + "data": { + "path": self.__data_path, + "write_allowed": self.__is_write_available(), + }, + }) + @exposed_ws("ping") async def __ws_ping_handler(self, ws: WsSession, _: dict) -> None: await ws.send_event("pong", {}) diff --git a/kvmd/clients/pst.py b/kvmd/clients/pst.py new file mode 100644 index 00000000..6b9f5234 --- /dev/null +++ b/kvmd/clients/pst.py @@ -0,0 +1,93 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import os +import contextlib + +from typing import AsyncGenerator + +import aiohttp + +from .. import htclient +from .. import htserver + + +# ===== +class PstError(Exception): + pass + + +# ===== +class PstClient: + def __init__( + self, + subdir: str, + unix_path: str, + timeout: float, + user_agent: str, + ) -> None: + + self.__subdir = subdir + self.__unix_path = unix_path + self.__timeout = timeout + self.__user_agent = user_agent + + async def get_path(self) -> str: + async with self.__make_http_session() as session: + async with session.get("http://localhost:0/state") as resp: + htclient.raise_not_200(resp) + path = (await resp.json())["result"]["data"]["path"] + return os.path.join(path, self.__subdir) + + @contextlib.asynccontextmanager + async def writable(self) -> AsyncGenerator[str, None]: + async with self.__inner_writable() as path: + path = os.path.join(path, self.__subdir) + if not os.path.exists(path): + os.mkdir(path) + yield path + + @contextlib.asynccontextmanager + async def __inner_writable(self) -> AsyncGenerator[str, None]: + async with self.__make_http_session() as session: + async with session.ws_connect("http://localhost:0/ws") as ws: + path = "" + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + raise PstError(f"Unexpected message type: {msg!r}") + (event_type, event) = htserver.parse_ws_event(msg.data) + if event_type == "storage_state": + if not event["data"]["write_allowed"]: + raise PstError("Write is not allowed") + path = event["data"]["path"] + break + if not path: + raise PstError("WS loop broken without write_allowed=True flag") + # TODO: Actually we should follow ws events, but for fast writing we can safely ignore them + yield path + + def __make_http_session(self) -> aiohttp.ClientSession: + return aiohttp.ClientSession( + headers={"User-Agent": self.__user_agent}, + connector=aiohttp.UnixConnector(path=self.__unix_path), + timeout=aiohttp.ClientTimeout(total=self.__timeout), + ) diff --git a/kvmd/validators/__init__.py b/kvmd/validators/__init__.py index 39ff60aa..aa997ab9 100644 --- a/kvmd/validators/__init__.py +++ b/kvmd/validators/__init__.py @@ -99,3 +99,11 @@ def check_any(arg: Any, name: str, validators: list[Callable[[Any], Any]]) -> An except Exception: pass raise_error(arg, name) + + +# ===== +def filter_printable(arg: str, replace: str, limit: int) -> str: + return "".join( + (ch if ch.isprintable() else replace) + for ch in arg[:limit] + ) diff --git a/kvmd/validators/os.py b/kvmd/validators/os.py index 94d3a40f..b2381d0b 100644 --- a/kvmd/validators/os.py +++ b/kvmd/validators/os.py @@ -26,6 +26,7 @@ import stat from typing import Any from . import raise_error +from . import filter_printable from .basic import valid_number from .basic import valid_string_list @@ -75,9 +76,7 @@ def valid_abs_dir(arg: Any, name: str="") -> str: def valid_printable_filename(arg: Any, name: str="") -> str: if not name: name = "printable filename" - arg = valid_stripped_string_not_empty(arg, name) - if ( "/" in arg or "\0" in arg @@ -85,12 +84,7 @@ def valid_printable_filename(arg: Any, name: str="") -> str: or arg == "lost+found" ): raise_error(arg, name) - - arg = "".join( - (ch if ch.isprintable() else "_") - for ch in arg[:255] - ) - return arg + return filter_printable(arg, "_", 255) # ===== diff --git a/kvmd/validators/switch.py b/kvmd/validators/switch.py new file mode 100644 index 00000000..d4f3ab2f --- /dev/null +++ b/kvmd/validators/switch.py @@ -0,0 +1,67 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import re + +from typing import Any + +from . import filter_printable +from . import check_re_match + +from .basic import valid_stripped_string +from .basic import valid_number + + +# ===== +def valid_switch_port_name(arg: Any) -> str: + arg = valid_stripped_string(arg, name="switch port name") + arg = filter_printable(arg, " ", 255) + arg = re.sub(r"\s+", " ", arg) + return arg.strip() + + +def valid_switch_edid_id(arg: Any, allow_default: bool) -> str: + pattern = "(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + if allow_default: + pattern += "|^default$" + return check_re_match(arg, "switch EDID ID", pattern).lower() + + +def valid_switch_edid_data(arg: Any) -> str: + name = "switch EDID data" + arg = valid_stripped_string(arg, name=name) + arg = re.sub(r"\s", "", arg) + return check_re_match(arg, name, "(?i)^[0-9a-f]{512}$").upper() + + +def valid_switch_color(arg: Any, allow_default: bool) -> str: + pattern = "(?i)^[0-9a-f]{6}:[0-9a-f]{2}:[0-9a-f]{4}$" + if allow_default: + pattern += "|^default$" + arg = check_re_match(arg, "switch color", pattern).upper() + if arg == "DEFAULT": + arg = "default" + return arg + + +def valid_switch_atx_click_delay(arg: Any) -> float: + return valid_number(arg, min=0, max=10, type=float, name="ATX delay") diff --git a/setup.py b/setup.py index 294efed6..3036d1e7 100755 --- a/setup.py +++ b/setup.py @@ -83,6 +83,7 @@ def main() -> None: "kvmd.clients", "kvmd.apps", "kvmd.apps.kvmd", + "kvmd.apps.kvmd.switch", "kvmd.apps.kvmd.info", "kvmd.apps.kvmd.api", "kvmd.apps.pst", diff --git a/testenv/linters/pylint.ini b/testenv/linters/pylint.ini index fd2d8b35..835f8ad2 100644 --- a/testenv/linters/pylint.ini +++ b/testenv/linters/pylint.ini @@ -39,6 +39,7 @@ disable = consider-using-f-string, unnecessary-lambda-assignment, too-many-positional-arguments, + no-else-continue, # https://github.com/PyCQA/pylint/issues/3882 [CLASSES] diff --git a/testenv/linters/vulture-wl.py b/testenv/linters/vulture-wl.py index d8efd97a..32cfdf88 100644 --- a/testenv/linters/vulture-wl.py +++ b/testenv/linters/vulture-wl.py @@ -57,3 +57,28 @@ Dumper.ignore_aliases _auth_server_port_fixture _test_user + +Switch.__x_set_port_names +Switch.__x_set_atx_cp_delays +Switch.__x_set_atx_cpl_delays +Switch.__x_set_atx_cr_delays +Nak.INVALID_COMMAND +Nak.BUSY +Nak.NO_DOWNLINK +Nak.DOWNLINK_OVERFLOW +UnitFlags.flashing_busy +StateCache.get_port_names +StateCache.get_atx_cp_delays +StateCache.get_atx_cpl_delays +StorageContext.write_edids +StorageContext.write_colors +StorageContext.write_port_names +StorageContext.write_atx_cp_delays +StorageContext.write_atx_cpl_delays +StorageContext.write_atx_cr_delays +StorageContext.read_edids +StorageContext.read_colors +StorageContext.read_port_names +StorageContext.read_atx_cp_delays +StorageContext.read_atx_cpl_delays +StorageContext.read_atx_cr_delays diff --git a/testenv/tests/validators/test_switch.py b/testenv/tests/validators/test_switch.py new file mode 100644 index 00000000..6f41c6cf --- /dev/null +++ b/testenv/tests/validators/test_switch.py @@ -0,0 +1,180 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +from typing import Any + +import pytest + +from kvmd.validators import ValidatorError +from kvmd.validators.switch import valid_switch_port_name +from kvmd.validators.switch import valid_switch_edid_id +from kvmd.validators.switch import valid_switch_edid_data +from kvmd.validators.switch import valid_switch_color +from kvmd.validators.switch import valid_switch_atx_click_delay + + +# ===== +@pytest.mark.parametrize("arg, retval", [ + ("\tMac OS Host #1/..", "Mac OS Host #1/.."), + ("\t", ""), + ("", ""), +]) +def test_ok__valid_msd_image_name(arg: Any, retval: str) -> None: + assert valid_switch_port_name(arg) == retval + + +@pytest.mark.parametrize("arg", [None]) +def test_fail__valid_msd_image_name(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_port_name(arg) + + +# ===== +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-446655440000", + " 00000000-0000-0000-C000-000000000046 ", + " 00000000-0000-0000-0000-000000000000 ", +]) +def test_ok__valid_switch_edid_id__no_default(arg: Any) -> None: + assert valid_switch_edid_id(arg, allow_default=False) == arg.strip().lower() # type: ignore + + +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-44665544", + "ffffuuuu-0000-0000-C000-000000000046", + "default", + "", + None, +]) +def test_fail__valid_switch_edid_id__no_default(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_edid_id(arg, allow_default=False) + + +# ===== +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-446655440000", + " 00000000-0000-0000-C000-000000000046 ", + " 00000000-0000-0000-0000-000000000000 ", + " Default", +]) +def test_ok__valid_switch_edid_id__allowed_default(arg: Any) -> None: + assert valid_switch_edid_id(arg, allow_default=True) == arg.strip().lower() # type: ignore + + +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-44665544", + "ffffuuuu-0000-0000-C000-000000000046", + "", + None, +]) +def test_fail__valid_switch_edid_id__allowed_default(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_edid_id(arg, allow_default=True) + + +# ===== +@pytest.mark.parametrize("arg", [ + "f" * 512, + "0" * 512, + "1a" * 256, +]) +def test_ok__valid_switch_edid_data(arg: Any) -> None: + assert valid_switch_edid_data(arg) == arg.upper() # type: ignore + + +@pytest.mark.parametrize("arg", [ + "f" * 511, + "0" * 511, + "1a" * 255, + "F" * 513, + "0" * 513, + "1A" * 257, + "", + None, +]) +def test_fail__valid_switch_edid_data(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_edid_data(arg) + + +# ===== +@pytest.mark.parametrize("arg, retval", [ + ("000000:00:0000", "000000:00:0000"), + (" 0f0f0f:0f:0f0f ", "0F0F0F:0F:0F0F"), +]) +def test_ok__valid_switch_color__no_default(arg: Any, retval: str) -> None: + assert valid_switch_color(arg, allow_default=False) == retval + + +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-44665544", + "ffffuuuu-0000-0000-C000-000000000046", + "000000:00:000000000:00:000G", + "000000:00:000", + "000000:00:000G", + "default", + " Default", + "", + None, +]) +def test_fail__valid_switch_color__no_default(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_color(arg, allow_default=False) + + +# ===== +@pytest.mark.parametrize("arg, retval", [ + ("000000:00:0000", "000000:00:0000"), + (" 0f0f0f:0f:0f0f ", "0F0F0F:0F:0F0F"), + (" Default", "default"), +]) +def test_ok__valid_switch_color__allow_default(arg: Any, retval: str) -> None: + assert valid_switch_color(arg, allow_default=True) == retval + + +@pytest.mark.parametrize("arg", [ + "550e8400-e29b-41d4-a716-44665544", + "ffffuuuu-0000-0000-C000-000000000046", + "000000:00:000000000:00:000G", + "000000:00:000", + "000000:00:000G", + "", + None, +]) +def test_fail__valid_switch_color__allow_default(arg: Any) -> None: + with pytest.raises(ValidatorError): + valid_switch_color(arg, allow_default=True) + + +# ===== +@pytest.mark.parametrize("arg", [0, 1, 5, "5 ", "5.0 ", " 10"]) +def test_ok__valid_switch_atx_click_delay(arg: Any) -> None: + value = valid_switch_atx_click_delay(arg) + assert type(value) is float # pylint: disable=unidiomatic-typecheck + assert value == float(str(arg).strip()) + + +@pytest.mark.parametrize("arg", ["test", "", None, -6, "-6", "10.1"]) +def test_fail__valid_switch_atx_click_delay(arg: Any) -> None: + with pytest.raises(ValidatorError): + print(valid_switch_atx_click_delay(arg)) diff --git a/web/kvm/index.html b/web/kvm/index.html index 08093288..fbf9c5df 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -139,7 +139,7 @@
    -
  • System +
  • System +
  • @@ -1150,7 +1180,7 @@
    -

    Caps Lock +

    Caps Lock
    @@ -1325,7 +1355,7 @@
    -

    ScrLk +

    ScrLk
    @@ -1421,7 +1451,7 @@
    -

    NmLk +

    NmLk
    @@ -1627,7 +1657,7 @@
    -

    ScrLk +

    ScrLk
    @@ -1800,7 +1830,7 @@
    -

    Caps Lock +

    Caps Lock
    @@ -1999,6 +2029,170 @@
    +
    +
    +
    Switch settings
    + +
    +
    + + +
    + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Manufacturer:
    Product ID:
    Serial:
    Monitor name:
    Extra serial:
    Audio enabled:
    Data: + +
    +
    + + + +
    +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Selected port: + + + +     + +
    Inactive port: + + + +     + +
    Blinking beacon: + + + +     + +
    +
    +
    +
    +
    +
    +
    +
    +
    Flashing downlink: + + + +     + +
    Bootloader mode: + + + +     + +
    +
    +
    +
    About
    diff --git a/web/kvm/navbar-shortcuts.pug b/web/kvm/navbar-shortcuts.pug index 378fdc09..d020b415 100644 --- a/web/kvm/navbar-shortcuts.pug +++ b/web/kvm/navbar-shortcuts.pug @@ -9,7 +9,7 @@ li(id="shortcuts-dropdown" class="right") div(class="buttons-row") button(data-force-hide-menu data-shortcut="CapsLock" class="row50") | • Caps Lock   - img(class="inline-lamp hid-keyboard-caps-led led-gray" src=`${svg_dir}/led-square.svg`) + img(class="inline-lamp-small hid-keyboard-caps-led led-gray" src=`${svg_dir}/led-square.svg`) button(data-force-hide-menu data-shortcut="MetaLeft" class="row50") • Left Win hr div(class="buttons-row") diff --git a/web/kvm/navbar-switch.pug b/web/kvm/navbar-switch.pug new file mode 100644 index 00000000..455daa6f --- /dev/null +++ b/web/kvm/navbar-switch.pug @@ -0,0 +1,19 @@ +li(id="switch-dropdown" class="right feature-disabled") + a(class="menu-button" id="switch-menu-button" href="#") + +navbar_led("switch-atx-power-led", "led-atx-power") + +navbar_led("switch-atx-hdd-led", "led-atx-hdd") + span Switch #[i #[sub(id="switch-active-port") ]] + div(id="switch-menu" class="menu") + table(style="border-spacing: 0px;") + tr + td + div(class="text") + b #[a(target="_blank" href="https://docs.pikvm.org/switch") PiKVM Switch] is attached#[br] + sub Select a port or perform any available action like ATX click + td + div(class="text") + button(data-force-hide-menu data-show-window="switch-window" class="small") • Settings + hr + +menu_switch("switch-atx-ask-switch", "Ask ATX click confirmation", true, true) + hr + table(id="switch-chain" class="kv") diff --git a/web/kvm/navbar-system.pug b/web/kvm/navbar-system.pug index 8112d441..62cbda25 100644 --- a/web/kvm/navbar-system.pug +++ b/web/kvm/navbar-system.pug @@ -1,7 +1,7 @@ li(id="system-dropdown" class="right") a(class="menu-button" href="#") +navbar_led("link-led", "led-link") - +navbar_led("stream-led", "led-stream") + +navbar_led("stream-led", "led-video") +navbar_led("hid-keyboard-led", "led-hid-keyboard") +navbar_led("hid-mouse-led", "led-hid-mouse") span System diff --git a/web/kvm/navbar.pug b/web/kvm/navbar.pug index b1c6b5eb..a9189b7d 100644 --- a/web/kvm/navbar.pug +++ b/web/kvm/navbar.pug @@ -51,3 +51,4 @@ ul(id="navbar") include navbar-text.pug include navbar-shortcuts.pug include navbar-gpio.pug + include navbar-switch.pug diff --git a/web/kvm/window-keyboard.pug b/web/kvm/window-keyboard.pug index fdd00b15..ae1a1e1f 100644 --- a/web/kvm/window-keyboard.pug +++ b/web/kvm/window-keyboard.pug @@ -26,7 +26,7 @@ mixin empty(spacer, classes="", width=0) div(class="spacer-fixed") mixin lamp(cls) - img(class=`inline-lamp ${cls} led-gray` src=`${svg_dir}/led-square.svg`) + img(class=`inline-lamp-small ${cls} led-gray` src=`${svg_dir}/led-square.svg`) div(id="keyboard-window" class="window") div(id="keyboard-window-header" class="window-header") diff --git a/web/kvm/window-switch.pug b/web/kvm/window-switch.pug new file mode 100644 index 00000000..71c0e152 --- /dev/null +++ b/web/kvm/window-switch.pug @@ -0,0 +1,95 @@ +mixin switch_tab(name, title, checked=false) + - let button_id = `switch-tab-${name}-button` + input(checked=checked type="radio" name="switch-tab-button", id=button_id) + label(for=button_id) #{title} + div(class="tab") + block + +div(id="switch-window" class="window" style="width:min-content") + div(class="window-header") + div(class="window-grab") Switch settings + button(class="window-button-close") #[b ×] + + div(class="tabs-box") + +switch_tab("edid", "EDIDs collection", true) + table + tr + td(colspan="2") + select(id="switch-edid-selector" size="8") + td(rowspan="2" style="vertical-align:top") + table(class="kv") + tr + td Manufacturer: + td(id="switch-edid-info-mfc-id" class="value") + tr + td Product ID: + td(id="switch-edid-info-product-id" class="value") + tr + td Serial: + td(id="switch-edid-info-serial" class="value") + tr + td Monitor name: + td(id="switch-edid-info-monitor-name" class="value") + tr + td Extra serial: + td(id="switch-edid-info-monitor-serial" class="value") + tr + td Audio enabled: + td(id="switch-edid-info-audio" class="value") + tr + td Data: + td #[button(disabled id="switch-edid-copy-data-button" class="small") Copy] + tr + td #[button(id="switch-edid-add-button") Add new] + td(style="float:right") #[button(disabled id="switch-edid-remove-button") Remove] + + +switch_tab("colors", "Color scheme") + table + //tr + td Role + td Color + td Brightness + td + td Reset + //tr + td #[hr] + td #[hr] + td #[hr] + td + td #[hr] + tr + td(style="white-space: nowrap") Selected port: + td #[input(type="color" id="switch-color-active-input")] + td #[input(type="range" id="switch-color-active-brightness-slider" style="min-width:150px")] + td     + td #[button(id="switch-color-active-default-button" class="small" title="Reset default") ↻] + tr + td(style="white-space: nowrap") Inactive port: + td #[input(type="color" id="switch-color-inactive-input")] + td #[input(type="range" id="switch-color-inactive-brightness-slider" style="min-width:150px")] + td     + td #[button(id="switch-color-inactive-default-button" class="small" title="Reset default") ↻] + tr + td(style="white-space: nowrap") Blinking beacon: + td #[input(type="color" id="switch-color-beacon-input")] + td #[input(type="range" id="switch-color-beacon-brightness-slider" style="min-width:150px")] + td     + td #[button(id="switch-color-beacon-default-button" class="small" title="Reset default") ↻] + tr + td #[hr] + td #[hr] + td #[hr] + td + td #[hr] + tr + td(style="white-space: nowrap") Flashing downlink: + td #[input(type="color" id="switch-color-flashing-input")] + td #[input(type="range" id="switch-color-flashing-brightness-slider" style="min-width:150px")] + td     + td #[button(id="switch-color-flashing-default-button" class="small" title="Reset default") ↻] + tr + td(style="white-space: nowrap") Bootloader mode: + td #[input(type="color" id="switch-color-bootloader-input")] + td #[input(type="range" id="switch-color-bootloader-brightness-slider" style="min-width:150px")] + td     + td #[button(id="switch-color-bootloader-default-button" class="small" title="Reset default") ↻] diff --git a/web/kvm/windows.pug b/web/kvm/windows.pug index b2d32dad..7b20bc22 100644 --- a/web/kvm/windows.pug +++ b/web/kvm/windows.pug @@ -1,4 +1,5 @@ include window-stream.pug include window-keyboard.pug +include window-switch.pug include window-about.pug include window-webterm.pug diff --git a/web/login/index.html b/web/login/index.html index 99fa2aed..90a840dd 100644 --- a/web/login/index.html +++ b/web/login/index.html @@ -74,7 +74,7 @@ - + diff --git a/web/login/index.pug b/web/login/index.pug index 26b955af..aabb47ae 100644 --- a/web/login/index.pug +++ b/web/login/index.pug @@ -24,7 +24,7 @@ block body hr tr td - td #[button(id="login-button" class="key") Login] + td #[button(id="login-button" class="key" style="width:100%") Login] ul(class="footer") li(class="left") diff --git a/web/share/css/kvm/msd.css b/web/share/css/kvm/msd.css index 5d262fb5..e0a24b4c 100644 --- a/web/share/css/kvm/msd.css +++ b/web/share/css/kvm/msd.css @@ -28,3 +28,7 @@ div#msd-menu div.msd-message, div#msd-menu input.msd-message { display: none; } + +div#msd-menu select#msd-image-selector { + width: 100%; +} diff --git a/web/share/css/main.css b/web/share/css/main.css index 8a074aa7..a543b06f 100644 --- a/web/share/css/main.css +++ b/web/share/css/main.css @@ -88,12 +88,17 @@ img.svg-gray { } img.inline-lamp { + vertical-align: middle; + height: 1em; + margin-left: 2px; + margin-right: 2px; +} +img.inline-lamp-small { vertical-align: middle; height: 8px; margin-left: 2px; margin-right: 2px; } - img.inline-lamp-big { vertical-align: middle; height: 20px; @@ -104,7 +109,8 @@ img.inline-lamp-big { button, select, input[type=file]::-webkit-file-selector-button, -input[type=file]::file-selector-button { +input[type=file]::file-selector-button, +input[type=color] { border: none; border-radius: 4px; color: var(--cs-control-default-fg); @@ -117,11 +123,9 @@ input[type=file]::file-selector-button { } button { display: block; - width: 100%; } select { display: block; - width: 100%; padding-left: 5px; } select[size] { @@ -194,6 +198,7 @@ select:not([size]) option.comment { input[type=text], input[type=password] { overflow-x: auto; font-family: monospace; + box-sizing: border-box; border-radius: 4px; border: var(--border-default-thin); color: var(--cs-code-default-fg); @@ -223,42 +228,35 @@ textarea::-webkit-input-placeholder { } div.buttons-row { + display: flex; margin: 0; padding: 0; font-size: 0; } - -.row50 { - display: inline-block; - width: 50%; -} -.row33 { - display: inline-block; - width: 33.33%; -} -.row25 { - display: inline-block; - width: 25%; -} -.row16 { - display: inline-block; - width: 16.66%; -} -.row50:not(:first-child), -.row33:not(:first-child), -.row25:not(:first-child), -.row16:not(:first-child) { +div.buttons-row button:not(:first-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; border-left: var(--border-control-thin) !important; } -.row50:not(:last-child), -.row33:not(:last-child), -.row25:not(:last-child), -.row16:not(:last-child) { +div.buttons-row button:not(:last-child) { border-top-right-radius: 0; border-bottom-right-radius: 0; } +button.row100 { + width: 100% !important; +} +button.row50 { + width: 50% !important; +} +button.row33 { + width: 33.33% !important; +} +button.row25 { + width: 25% !important; +} +button.row16 { + width: 16.66% !important; +} table.kv { border-spacing: 5px; diff --git a/web/share/css/modal.css b/web/share/css/modal.css index 48010a6c..cf2f350d 100644 --- a/web/share/css/modal.css +++ b/web/share/css/modal.css @@ -63,9 +63,11 @@ div.modal div.modal-window div.modal-content { div.modal div.modal-window div.modal-buttons { border-top: var(--border-control-thin); + display: flex; margin: 0; padding: 0; font-size: 0; + width: 100%; } div.modal div.modal-window div.modal-buttons button { diff --git a/web/share/css/navbar.css b/web/share/css/navbar.css index af704add..f3e7c0cc 100644 --- a/web/share/css/navbar.css +++ b/web/share/css/navbar.css @@ -172,6 +172,7 @@ ul#navbar li div.menu div.buttons select { border-radius: 0; text-align: left; padding: 0 16px; + width: 100%; } ul#navbar li div.menu input[type=text] { diff --git a/web/share/css/slider.css b/web/share/css/slider.css index 2669c9a4..db743289 100644 --- a/web/share/css/slider.css +++ b/web/share/css/slider.css @@ -21,7 +21,7 @@ @supports (-webkit-appearance:none) { - input[type=range].slider { + input[type=range] { cursor: pointer; outline: none; width: 100%; @@ -33,7 +33,7 @@ } } @supports not (-webkit-appearance:none) { - input[type=range].slider { + input[type=range] { cursor: pointer; outline: none; width: 100%; @@ -42,20 +42,20 @@ margin-right: 0; } } -input[type=range].slider:disabled { +input[type=range]:disabled { cursor: default; } -input[type=range].slider::-webkit-slider-runnable-track { +input[type=range]::-webkit-slider-runnable-track { height: 5px; background: var(--cs-control-default-bg); border-radius: 3px; } -input[type=range].slider:disabled::-webkit-slider-runnable-track { +input[type=range]:disabled::-webkit-slider-runnable-track { cursor: default; } -input[type=range].slider::-webkit-slider-thumb { +input[type=range]::-webkit-slider-thumb { border: var(--border-intensive-2px); height: 18px; width: 18px; @@ -64,29 +64,29 @@ input[type=range].slider::-webkit-slider-thumb { -webkit-appearance: none; margin-top: -7px; } -input[type=range].slider:disabled::-webkit-slider-thumb { +input[type=range]:disabled::-webkit-slider-thumb { cursor: default; border: var(--border-default-2px); background: var(--cs-thumb-disabled-bg); } -input[type=range].slider::-moz-range-track { +input[type=range]::-moz-range-track { height: 5px; background: var(--cs-control-default-bg); border-radius: 3px; } -input[type=range].slider:disabled::-moz-range-track { +input[type=range]:disabled::-moz-range-track { cursor: default; } -input[type=range].slider::-moz-range-thumb { +input[type=range]::-moz-range-thumb { border: var(--border-intensive-2px); height: 18px; width: 18px; border-radius: 25px; background: var(--cs-thumb-default-bg); } -input[type=range].slider:disabled::-moz-range-thumb { +input[type=range]:disabled::-moz-range-thumb { cursor: default; border: var(--border-default-2px); background: var(--cs-thumb-disabled-bg); diff --git a/web/share/css/x-desktop.css b/web/share/css/x-desktop.css index 732f8aa2..56a27fb5 100644 --- a/web/share/css/x-desktop.css +++ b/web/share/css/x-desktop.css @@ -25,7 +25,8 @@ button:enabled:hover, select:not([size]):enabled:hover, input[type=file]:enabled:hover::-webkit-file-selector-button, -input[type=file]:enabled:hover::file-selector-button { +input[type=file]:enabled:hover::file-selector-button, +input[type=color]:enabled:hover { color: var(--cs-control-hovered-fg); background-color: var(--cs-control-hovered-bg); } @@ -33,7 +34,8 @@ input[type=file]:enabled:hover::file-selector-button { button:active, select:not([size]):active, input[type=file]:active::-webkit-file-selector-button, -input[type=file]:active::file-selector-button { +input[type=file]:active::file-selector-button, +input[type=color]:active { color: var(--cs-control-pressed-fg) !important; background-color: var(--cs-control-pressed-bg) !important; } @@ -60,12 +62,12 @@ div.radio-box input[type=radio]:not(:checked):not(:disabled) + label:hover { /* ===== slider.css ===== */ /*div.switch-box label span.switch-inner:not(:disabled):hover::before {*/ -input[type=range].slider:not(:disabled):hover::-webkit-slider-runnable-track { +input[type=range]:not(:disabled):hover::-webkit-slider-runnable-track { background-color: var(--cs-control-hovered-bg); } /*div.switch-box label span.switch-inner:not(:disabled):hover::before {*/ -input[type=range].slider:not(:disabled):hover::-moz-range-track { +input[type=range]:not(:disabled):hover::-moz-range-track { background-color: var(--cs-control-hovered-bg); } diff --git a/web/share/css/x-mobile.css b/web/share/css/x-mobile.css index fef5f4ba..dfb8b1a7 100644 --- a/web/share/css/x-mobile.css +++ b/web/share/css/x-mobile.css @@ -92,7 +92,7 @@ ul#navbar li a.menu-button:hover:not(.active) { /*@media only screen and (orientation: portrait) { @supports (-webkit-appearance: none) { - input[type=range].slider { + input[type=range] { margin: 20px 0 20px 0 !important; } } diff --git a/web/share/js/kvm/atx.js b/web/share/js/kvm/atx.js index 796a4eeb..0065e73c 100644 --- a/web/share/js/kvm/atx.js +++ b/web/share/js/kvm/atx.js @@ -32,6 +32,7 @@ export function Atx(__recorder) { /************************************************************************/ + var __has_switch = null; // Or true/false var __state = null; var __init__ = function() { @@ -54,12 +55,12 @@ export function Atx(__recorder) { } if (state.enabled !== undefined) { __state.enabled = state.enabled; - tools.feature.setEnabled($("atx-dropdown"), __state.enabled); + tools.feature.setEnabled($("atx-dropdown"), (__state.enabled && !__has_switch)); } if (__state.enabled !== undefined) { if (state.busy !== undefined) { + __updateButtons(!state.busy); __state.busy = state.busy; - __updateButtons(!__state.busy); } if (state.leds !== undefined) { __state.leds = state.leds; @@ -75,6 +76,11 @@ export function Atx(__recorder) { } }; + self.setHasSwitch = function(has_switch) { + __has_switch = has_switch; + self.setState(__state); + }; + var __updateLeds = function(power, hdd, busy) { $("atx-power-led").className = (busy ? "led-yellow" : (power ? "led-green" : "led-gray")); $("atx-hdd-led").className = (hdd ? "led-red" : "led-gray"); diff --git a/web/share/js/kvm/session.js b/web/share/js/kvm/session.js index 27b18b21..c2f13342 100644 --- a/web/share/js/kvm/session.js +++ b/web/share/js/kvm/session.js @@ -34,6 +34,7 @@ import {Msd} from "./msd.js"; import {Streamer} from "./stream.js"; import {Gpio} from "./gpio.js"; import {Ocr} from "./ocr.js"; +import {Switch} from "./switch.js"; export function Session() { @@ -54,6 +55,7 @@ export function Session() { var __msd = new Msd(); var __gpio = new Gpio(__recorder); var __ocr = new Ocr(__streamer.getGeometry); + var __switch = new Switch(); var __info_hw_state = null; var __info_fan_state = null; @@ -368,9 +370,24 @@ export function Session() { case "hid_state": __hid.setState(data.event); break; case "hid_keymaps_state": __paste.setState(data.event); break; case "atx_state": __atx.setState(data.event); break; - case "msd_state": __msd.setState(data.event); break; case "streamer_state": __streamer.setState(data.event); break; case "ocr_state": __ocr.setState(data.event); break; + + case "msd_state": + if (data.event.online === false) { + __switch.setMsdConnected(false); + } else if (data.event.drive !== undefined) { + __switch.setMsdConnected(data.event.drive.connected); + } + __msd.setState(data.event); + break; + + case "switch_state": + if (data.event.model) { + __atx.setHasSwitch(data.event.model.ports.length > 0); + } + __switch.setState(data.event); + break; } }; @@ -401,6 +418,7 @@ export function Session() { __streamer.setState(null); __ocr.setState(null); __recorder.setSocket(null); + __switch.setState(null); __ws = null; setTimeout(function() { diff --git a/web/share/js/kvm/switch.js b/web/share/js/kvm/switch.js new file mode 100644 index 00000000..112d8f15 --- /dev/null +++ b/web/share/js/kvm/switch.js @@ -0,0 +1,606 @@ +/***************************************************************************** +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +*****************************************************************************/ + + +"use strict"; + + +import {tools, $} from "../tools.js"; +import {wm} from "../wm.js"; + + +export function Switch() { + var self = this; + + /************************************************************************/ + + var __state = null; + var __msd_connected = false; + + var __init__ = function() { + tools.selector.addOption($("switch-edid-selector"), "Default", "default"); + $("switch-edid-selector").onchange = __selectEdid; + + tools.el.setOnClick($("switch-edid-add-button"), __clickAddEdidButton); + tools.el.setOnClick($("switch-edid-remove-button"), __clickRemoveEdidButton); + tools.el.setOnClick($("switch-edid-copy-data-button"), __clickCopyEdidDataButton); + + tools.storage.bindSimpleSwitch($("switch-atx-ask-switch"), "switch.atx.ask", true); + + for (let role of ["inactive", "active", "flashing", "beacon", "bootloader"]) { + let el_brightness = $(`switch-color-${role}-brightness-slider`); + tools.slider.setParams(el_brightness, 0, 255, 1, 0); + el_brightness.onchange = $(`switch-color-${role}-input`).onchange = tools.partial(__selectColor, role); + tools.el.setOnClick($(`switch-color-${role}-default-button`), tools.partial(__clickSetDefaultColorButton, role)); + } + }; + + /************************************************************************/ + + self.setMsdConnected = function(connected) { + __msd_connected = connected; + }; + + self.setState = function(state) { + if (state) { + if (!__state) { + __state = {}; + } + if (state.model) { + __state = {}; + __applyModel(state.model); + } + if (__state.model) { + if (state.summary) { + __applySummary(state.summary); + } + if (state.beacons) { + __applyBeacons(state.beacons); + } + if (state.usb) { + __applyUsb(state.usb); + } + if (state.video) { + __applyVideo(state.video); + } + if (state.atx) { + __applyAtx(state.atx); + } + if (state.edids) { + __applyEdids(state.edids); + } + if (state.colors) { + __applyColors(state.colors); + } + } + } else { + tools.feature.setEnabled($("switch-dropdown"), false); + $("switch-chain").innerText = ""; + $("switch-active-port").innerText = "N/A"; + __setPowerLedState($("switch-atx-power-led"), false, false); + __setLedState($("switch-atx-hdd-led"), "red", false); + __state = null; + } + }; + + var __applyColors = function(colors) { + for (let role in colors) { + let color = colors[role]; + $(`switch-color-${role}-input`).value = ( + "#" + + color.red.toString(16).padStart(2, "0") + + color.green.toString(16).padStart(2, "0") + + color.blue.toString(16).padStart(2, "0") + ); + $(`switch-color-${role}-brightness-slider`).value = color.brightness; + } + __state.colors = colors; + }; + + var __selectColor = function(role) { + let el_color = $(`switch-color-${role}-input`); + let el_brightness = $(`switch-color-${role}-brightness-slider`); + let color = __state.colors[role]; + let brightness = parseInt(el_brightness.value); + let rgbx = ( + el_color.value.slice(1) + + ":" + brightness.toString(16).padStart(2, "0") + + ":" + color.blink_ms.toString(16).padStart(4, "0") + ); + __sendPost("/api/switch/set_colors", {[role]: rgbx}, function() { + el_color.value = ( + "#" + + color.red.toString(16).padStart(2, "0") + + color.green.toString(16).padStart(2, "0") + + color.blue.toString(16).padStart(2, "0") + ); + el_brightness.value = color.brightness; + }); + }; + + var __clickSetDefaultColorButton = function(role) { + __sendPost("/api/switch/set_colors", {[role]: "default"}); + }; + + var __applyEdids = function(edids) { + let el = $("switch-edid-selector"); + let old_edid_id = el.value; + el.options.length = 1; + for (let kv of Object.entries(edids.all)) { + if (kv[0] !== "default") { + tools.selector.addOption(el, kv[1].name, kv[0]); + } + } + el.value = (old_edid_id in edids.all ? old_edid_id : "default"); + + for (let port in __state.model.ports) { + let custom = (edids.used[port] !== "default"); + $(`__switch-custom-edid-p${port}`).style.visibility = (custom ? "unset" : "hidden"); + } + + __state.edids = edids; + __selectEdid(); + }; + + var __selectEdid = function() { + let edid_id = $("switch-edid-selector").value; + let edid = null; + try { edid = __state.edids.all[edid_id]; } catch { edid_id = ""; } + let parsed = (edid ? edid.parsed : null); + let na = "<Not Available>"; + $("switch-edid-info-mfc-id").innerHTML = (parsed ? tools.escape(parsed.mfc_id) : na); + $("switch-edid-info-product-id").innerHTML = (parsed ? tools.escape(`0x${parsed.product_id.toString(16).toUpperCase()}`) : na); + $("switch-edid-info-serial").innerHTML = (parsed ? tools.escape(`0x${parsed.serial.toString(16).toUpperCase()}`) : na); + $("switch-edid-info-monitor-name").innerHTML = ((parsed && parsed.monitor_name) ? tools.escape(parsed.monitor_name) : na); + $("switch-edid-info-monitor-serial").innerHTML = ((parsed && parsed.monitor_serial) ? tools.escape(parsed.monitor_serial) : na); + $("switch-edid-info-audio").innerHTML = (parsed ? (parsed.audio ? "Yes" : "No") : na); + tools.el.setEnabled($("switch-edid-remove-button"), (edid_id && (edid_id !== "default"))); + tools.el.setEnabled($("switch-edid-copy-data-button"), !!edid_id); + }; + + var __clickAddEdidButton = function() { + let create_content = function(el_parent, el_ok_button) { + tools.el.setEnabled(el_ok_button, false); + el_parent.innerHTML = ` + + + + + + + +
    Name:
    HEX data:
    +
    + `; + let el_name = $("__switch-edid-new-name-input"); + let el_data = $("__switch-edid-new-data-text"); + el_name.oninput = el_data.oninput = function() { + let name = el_name.value.replace(/\s+/g, ""); + let data = el_data.value.replace(/\s+/g, ""); + tools.el.setEnabled(el_ok_button, ((name.length > 0) && /[0-9a-fA-F]{512}/.test(data))); + }; + }; + + wm.modal("Add new EDID", create_content, true, true).then(function(ok) { + if (ok) { + let name = $("__switch-edid-new-name-input").value; + let data = $("__switch-edid-new-data-text").value; + __sendPost("/api/switch/edids/create", {"name": name, "data": data}); + } + }); + }; + + var __clickRemoveEdidButton = function() { + let edid_id = $("switch-edid-selector").value; + if (edid_id && __state && __state.edids) { + let name = __state.edids.all[edid_id].name; + let html = "Are you sure to remove this EDID?
    Ports that used it will change it to the default."; + wm.confirm(html, name).then(function(ok) { + if (ok) { + __sendPost("/api/switch/edids/remove", {"id": edid_id}); + } + }); + } + }; + + var __clickCopyEdidDataButton = function() { + let edid_id = $("switch-edid-selector").value; + if (edid_id && __state && __state.edids) { + let data = __state.edids.all[edid_id].data; + data = data.replace(/(.{32})/g, "$1\n"); + wm.copyTextToClipboard(data); + } + }; + + var __applyUsb = function(usb) { + for (let port = 0; port < __state.model.ports.length; ++port) { + if (!__state.usb || __state.usb.links[port] !== usb.links[port]) { + __setLedState($(`__switch-usb-led-p${port}`), "green", usb.links[port]); + } + } + __state.usb = usb; + }; + + var __applyVideo = function(video) { + for (let port = 0; port < __state.model.ports.length; ++port) { + if (!__state.video || __state.video.links[port] !== video.links[port]) { + __setLedState($(`__switch-video-led-p${port}`), "green", video.links[port]); + } + } + __state.video = video; + }; + + var __applyAtx = function(atx) { + for (let port = 0; port < __state.model.ports.length; ++port) { + let busy = atx.busy[port]; + if (!__state.atx || __state.atx.leds.power[port] !== atx.leds.power[port] || __state.atx.busy[port] !== busy) { + let power = atx.leds.power[port]; + __setPowerLedState($(`__switch-atx-power-led-p${port}`), power, busy); + if (port === __state.summary.active_port) { + // summary есть всегда, если есть model, и atx обновляется последним в setState() + __setPowerLedState($("switch-atx-power-led"), power, busy); + } + } + if (!__state.atx || __state.atx.leds.hdd[port] !== atx.leds.hdd[port]) { + let hdd = atx.leds.hdd[port]; + __setLedState($(`__switch-atx-hdd-led-p${port}`), "red", hdd); + if (port === __state.summary.active_port) { + __setLedState($("switch-atx-hdd-led"), "red", hdd); + } + } + if (!__state.atx || __state.atx.busy[port] !== busy) { + tools.el.setEnabled($(`__switch-atx-power-button-p${port}`), !busy); + tools.el.setEnabled($(`__switch-atx-power-long-button-p${port}`), !busy); + tools.el.setEnabled($(`__switch-atx-reset-button-p${port}`), !busy); + } + } + __state.atx = atx; + }; + + var __applyBeacons = function(beacons) { + for (let unit = 0; unit < __state.model.units.length; ++unit) { + if (!__state.beacons || __state.beacons.uplinks[unit] !== beacons.uplinks[unit]) { + __setLedState($(`__switch-beacon-led-u${unit}`), "green", beacons.uplinks[unit]); + } + if (!__state.beacons || __state.beacons.downlinks[unit] !== beacons.downlinks[unit]) { + __setLedState($(`__switch-beacon-led-d${unit}`), "green", beacons.downlinks[unit]); + } + } + for (let port = 0; port < __state.model.ports.length; ++port) { + if (!__state.beacons || __state.beacons.ports[port] !== beacons.ports[port]) { + __setLedState($(`__switch-beacon-led-p${port}`), "green", beacons.ports[port]); + } + } + __state.beacons = beacons; + }; + + var __applySummary = function(summary) { + let active = summary.active_port; + if (!__state.summary || __state.summary.active_port !== active) { + if (active < 0 || active >= __state.model.ports.length) { + $("switch-active-port").innerText = "N/A"; + } else { + $("switch-active-port").innerText = "p" + __formatPort(__state.model, active); + } + for (let port = 0; port < __state.model.ports.length; ++port) { + __setLedState($(`__switch-port-led-p${port}`), "green", (port === active)); + } + } + if (__state.atx) { + // Синхронизация светодиодов ATX при смене порта + let power = false; + let busy = false; + let hdd = false; + if (active >= 0 && active < __state.model.ports.length) { + power = __state.atx.leds.power[active]; + hdd = __state.atx.leds.hdd[active]; + busy = __state.atx.busy[active]; + } + __setPowerLedState($("switch-atx-power-led"), power, busy); + __setLedState($("switch-atx-hdd-led"), "red", hdd); + } + __state.summary = summary; + }; + + var __applyModel = function(model) { + tools.feature.setEnabled($("switch-dropdown"), model.ports.length); + + let content = ""; + let unit = -1; + for (let port = 0; port < model.ports.length; ++port) { + let pa = model.ports[port]; // pa == port attrs + if (unit !== pa.unit) { + unit = pa.unit; + content += `${unit > 0 ? "
    " : ""} + + + Unit: ${unit + 1} + + +
    + + +
    + + +
    + `; + } + content += ` + + Port: + ${__formatPort(model, port)} +    + +
    + + +
    + + + +      + ${pa.name.length > 0 ? tools.escape(pa.name) : ("Host " + (port + 1))} +       + + + + + + + + + + + +
    + + + +
    + + + `; + } + $("switch-chain").innerHTML = content; + + for (let unit = 0; unit < model.units.length; ++unit) { + tools.el.setOnClick($(`__switch-beacon-button-u${unit}`), tools.partial(__switchUplinkBeacon, unit)); + tools.el.setOnClick($(`__switch-beacon-button-d${unit}`), tools.partial(__switchDownlinkBeacon, unit)); + } + + for (let port = 0; port < model.ports.length; ++port) { + tools.el.setOnClick($(`__switch-port-button-p${port}`), tools.partial(__switchActivePort, port)); + tools.el.setOnClick($(`__switch-params-button-p${port}`), tools.partial(__showParamsDialog, port)); + tools.el.setOnClick($(`__switch-beacon-button-p${port}`), tools.partial(__switchPortBeacon, port)); + tools.el.setOnClick($(`__switch-atx-power-button-p${port}`), tools.partial(__atxClick, port, "power")); + tools.el.setOnClick($(`__switch-atx-power-long-button-p${port}`), tools.partial(__atxClick, port, "power_long")); + tools.el.setOnClick($(`__switch-atx-reset-button-p${port}`), tools.partial(__atxClick, port, "reset")); + } + + __setPowerLedState($("switch-atx-power-led"), false, false); + __setLedState($("switch-atx-hdd-led"), "red", false); + + __state.model = model; + }; + + var __showParamsDialog = function(port) { + if (!__state || !__state.model || !__state.edids) { + return; + } + + let model = __state.model; + let edids = __state.edids; + + let atx_actions = { + "power": "ATX power click", + "power_long": "Power long", + "reset": "Reset click", + }; + + let add_edid_option = function(el, attrs, id) { + tools.selector.addOption(el, attrs.name, id, (edids.used[port] === id)); + if (attrs.parsed !== null) { + let parsed = attrs.parsed; + let text = "\xA0\xA0\xA0\xA0\xA0\u2570 "; + text += (parsed.monitor_name !== null ? parsed.monitor_name : parsed.mfc_id); + text += (parsed.audio ? "; +Audio" : "; -Audio"); + tools.selector.addComment(el, text); + } + }; + + let create_content = function(el_parent) { + let html = ` + + + + + + + + + +
    Port name:
    EDID:
    +
    + + `; + for (let kv of Object.entries(atx_actions)) { + html += ` + + + + + + + + `; + } + html += "
    ${tools.escape(kv[1])}:   
    "; + el_parent.innerHTML = html; + + let el_selector = $("__switch-port-edid-selector"); + add_edid_option(el_selector, edids.all["default"], "default"); + for (let kv of Object.entries(edids.all)) { + if (kv[0] !== "default") { + tools.selector.addSeparator(el_selector, 20); + add_edid_option(el_selector, kv[1], kv[0]); + } + } + + for (let action of Object.keys(atx_actions)) { + let limits = model.limits.atx.click_delays[action]; + let el_slider = $(`__switch-port-atx-click-${action}-delay-slider`); + let display_value = tools.partial(function(action, value) { + $(`__switch-port-atx-click-${action}-delay-value`).innerText = `${value.toFixed(1)}`; + }, action); + let reset_default = tools.partial(function(el_slider, limits) { + tools.slider.setValue(el_slider, limits["default"]); + }, el_slider, limits); + tools.slider.setParams(el_slider, limits.min, limits.max, 0.5, model.ports[port].atx.click_delays[action], display_value); + tools.el.setOnClick($(`__switch-port-atx-click-${action}-delay-default-button`), reset_default); + } + }; + + wm.modal(`Port ${__formatPort(__state.model, port)} settings`, create_content, true, true).then(function(ok) { + if (ok) { + let params = { + "port": port, + "edid_id": $("__switch-port-edid-selector").value, + "name": $("__switch-port-name-input").value, + }; + for (let action of Object.keys(atx_actions)) { + params[`atx_click_${action}_delay`] = tools.slider.getValue($(`__switch-port-atx-click-${action}-delay-slider`)); + }; + __sendPost("/api/switch/set_port_params", params); + } + }); + }; + + var __formatPort = function(model, port) { + if (model.units.length > 1) { + return `${model.ports[port].unit + 1}.${model.ports[port].channel + 1}`; + } else { + return `${port + 1}`; + } + }; + + var __setLedState = function(el, color, on) { + el.classList.toggle(`led-${color}`, on); + el.classList.toggle("led-gray", !on); + }; + + var __setPowerLedState = function(el, power, busy) { + el.classList.toggle("led-green", (power && !busy)); + el.classList.toggle("led-yellow", busy); + el.classList.toggle("led-gray", !(power || busy)); + }; + + var __switchActivePort = function(port) { + if (__msd_connected) { + wm.error(` + Oops! Before port switching, please disconnect an active Mass Storage Drive image first. + Otherwise, it will break a current USB operation (OS installation, Live CD, or whatever). + `); + } else { + __sendPost("/api/switch/set_active", {"port": port}); + } + }; + + var __switchUplinkBeacon = function(unit) { + let state = false; + try { state = !__state.beacons.uplinks[unit]; } catch {}; // eslint-disable-line no-empty + __sendPost("/api/switch/set_beacon", {"uplink": unit, "state": state}); + }; + + var __switchDownlinkBeacon = function(unit) { + let state = false; + try { state = !__state.beacons.downlinks[unit]; } catch {}; // eslint-disable-line no-empty + __sendPost("/api/switch/set_beacon", {"downlink": unit, "state": state}); + }; + + var __switchPortBeacon = function(port) { + let state = false; + try { state = !__state.beacons.ports[port]; } catch {}; // eslint-disable-line no-empty + __sendPost("/api/switch/set_beacon", {"port": port, "state": state}); + }; + + var __atxClick = function(port, button) { + let click_button = function() { + __sendPost("/api/switch/atx/click", {"port": port, "button": button}); + }; + if ($("switch-atx-ask-switch").checked) { + wm.confirm(` + Are you sure you want to press the ${button} button?
    + Warning! This could case data loss on the server. + `).then(function(ok) { + if (ok) { + click_button(); + } + }); + } else { + click_button(); + } + }; + + var __sendPost = function(url, params, error_callback=null) { + tools.httpPost(url, params, function(http) { + if (http.status !== 200) { + if (error_callback) { + error_callback(); + } + wm.error("Switch error", http.responseText); + } + }); + }; + + __init__(); +} diff --git a/web/share/js/tools.js b/web/share/js/tools.js index 046813c6..f5ddae8b 100644 --- a/web/share/js/tools.js +++ b/web/share/js/tools.js @@ -78,7 +78,7 @@ export var tools = new function() { }; self.partial = function(func, ...args) { - return () => func(...args); + return (...rest) => func(...args, ...rest); }; self.upperFirst = function(text) { @@ -104,10 +104,6 @@ export var tools = new function() { return Math.floor(Math.random() * (max - min + 1)) + min; }; - self.formatHex = function(value) { - return `0x${value.toString(16).toUpperCase()}`; - }; - self.formatSize = function(size) { if (size > 0) { let index = Math.floor( Math.log(size) / Math.log(1024) ); diff --git a/web/share/svg/led-beacon.svg b/web/share/svg/led-beacon.svg new file mode 100644 index 00000000..cf266c74 --- /dev/null +++ b/web/share/svg/led-beacon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/share/svg/led-usb.svg b/web/share/svg/led-usb.svg new file mode 100644 index 00000000..a38bcbc7 --- /dev/null +++ b/web/share/svg/led-usb.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/share/svg/led-stream.svg b/web/share/svg/led-video.svg similarity index 100% rename from web/share/svg/led-stream.svg rename to web/share/svg/led-video.svg From 7caa695d79eae3d24be298c80a3dc36b60e24965 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 17 Dec 2024 18:21:13 +0200 Subject: [PATCH 22/84] =?UTF-8?q?Bump=20version:=204.25=20=E2=86=92=204.26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- PKGBUILD | 2 +- kvmd/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 51d07c84..154f9d11 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.25 +current_version = 4.26 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/PKGBUILD b/PKGBUILD index f1ca2d4c..00628807 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.25 +pkgver=4.26 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 425490e0..4e7526ee 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.25" +__version__ = "4.26" diff --git a/setup.py b/setup.py index 3036d1e7..9143271c 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.25", + version="4.26", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", From c0099852472acd371061d8671b3819eb65d968fa Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 17 Dec 2024 18:28:17 +0200 Subject: [PATCH 23/84] build fix --- PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PKGBUILD b/PKGBUILD index 00628807..22c5eef3 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -255,7 +255,7 @@ for _variant in "${_variants[@]}"; do if [[ $_platform =~ ^.*-hdmi$ ]]; then backup=(\"\${backup[@]}\" etc/kvmd/tc358743-edid.hex etc/kvmd/switch-edid.hex) install -DTm444 configs/kvmd/edid/$_base.hex \"\$pkgdir/etc/kvmd/tc358743-edid.hex\" - ln -s tc358743-edid.hex /etc/kvmd/switch-edid.hex + ln -s tc358743-edid.hex \"\$pkgdir/etc/kvmd/switch-edid.hex\" else backup=(\"\${backup[@]}\" etc/kvmd/switch-edid.hex) install -DTm444 configs/kvmd/edid/_no-1920x1200.hex \"\$pkgdir/etc/kvmd/switch-edid.hex\" From c8385213cc29db91151620a478945d51b47fd106 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Tue, 17 Dec 2024 18:28:52 +0200 Subject: [PATCH 24/84] =?UTF-8?q?Bump=20version:=204.26=20=E2=86=92=204.27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- PKGBUILD | 2 +- kvmd/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 154f9d11..6d717464 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.26 +current_version = 4.27 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/PKGBUILD b/PKGBUILD index 22c5eef3..7e68d71b 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.26 +pkgver=4.27 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 4e7526ee..47ef1f2b 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.26" +__version__ = "4.27" diff --git a/setup.py b/setup.py index 9143271c..8d36376b 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.26", + version="4.27", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", From 596334735e1e7a0ebd685ff7df2a8dbd776763f0 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 18 Dec 2024 06:00:13 +0200 Subject: [PATCH 25/84] removed legacy generic configs --- configs/kvmd/main/v2-hdmiusb-generic.yaml | 59 ----------------------- configs/os/udev/v2-hdmiusb-generic.rules | 6 --- 2 files changed, 65 deletions(-) delete mode 100644 configs/kvmd/main/v2-hdmiusb-generic.yaml delete mode 100644 configs/os/udev/v2-hdmiusb-generic.rules diff --git a/configs/kvmd/main/v2-hdmiusb-generic.yaml b/configs/kvmd/main/v2-hdmiusb-generic.yaml deleted file mode 100644 index 400d6038..00000000 --- a/configs/kvmd/main/v2-hdmiusb-generic.yaml +++ /dev/null @@ -1,59 +0,0 @@ -# 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 - - hid: - type: otg - - atx: - type: disabled - - msd: - type: otg - - streamer: - quality: 0 - resolution: - default: 1920x1080 - available: - - 1920x1080 - - 1600x1200 - - 1360x768 - - 1280x1024 - - 1280x960 - - 1280x720 - - 1024x768 - - 800x600 - - 720x576 - - 720x480 - - 640x480 - cmd: - - "/usr/bin/ustreamer" - - "--device=/dev/kvmd-video" - - "--persistent" - - "--format=mjpeg" - - "--resolution={resolution}" - - "--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" - - -vnc: - memsink: - jpeg: - sink: "kvmd::ustreamer::jpeg" diff --git a/configs/os/udev/v2-hdmiusb-generic.rules b/configs/os/udev/v2-hdmiusb-generic.rules deleted file mode 100644 index 8ac0f80f..00000000 --- a/configs/os/udev/v2-hdmiusb-generic.rules +++ /dev/null @@ -1,6 +0,0 @@ -# https://unix.stackexchange.com/questions/66901/how-to-bind-usb-device-under-a-static-name -# https://wiki.archlinux.org/index.php/Udev#Setting_static_device_names -KERNEL=="video[0-9]*", SUBSYSTEM=="video4linux", SUBSYSTEMS=="usb", ATTR{index}=="0", GROUP="kvmd", SYMLINK+="kvmd-video" -KERNEL=="hidg0", GROUP="kvmd", SYMLINK+="kvmd-hid-keyboard" -KERNEL=="hidg1", GROUP="kvmd", SYMLINK+="kvmd-hid-mouse" -KERNEL=="hidg2", GROUP="kvmd", SYMLINK+="kvmd-hid-mouse-alt" From af2ee26a2f022bff01ca446814a35c4aea14d5ca Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 18 Dec 2024 06:39:18 +0200 Subject: [PATCH 26/84] kvmd-media server --- configs/kvmd/main/v0-hdmi-zero2w.yaml | 6 + configs/kvmd/main/v1-hdmi-rpi3.yaml | 6 + configs/kvmd/main/v1-hdmi-zero2w.yaml | 6 + configs/kvmd/main/v2-hdmi-rpi3.yaml | 6 + configs/kvmd/main/v2-hdmi-rpi4.yaml | 6 + configs/kvmd/main/v2-hdmi-zero2w.yaml | 6 + configs/kvmd/main/v3-hdmi-rpi4.yaml | 6 + configs/kvmd/main/v4mini-hdmi-rpi4.yaml | 6 + configs/kvmd/main/v4plus-hdmi-rpi4.yaml | 6 + configs/os/services/kvmd-media.service | 16 ++ configs/os/sysusers.conf | 6 + extras/media/manifest.yaml | 5 + extras/media/nginx.ctx-http.conf | 3 + extras/media/nginx.ctx-server.conf | 7 + kvmd/aiotools.py | 2 +- kvmd/apps/__init__.py | 26 ++++ kvmd/apps/kvmd/server.py | 4 +- kvmd/apps/media/__init__.py | 48 ++++++ kvmd/apps/media/__main__.py | 24 +++ kvmd/apps/media/server.py | 190 ++++++++++++++++++++++++ kvmd/apps/pst/server.py | 4 +- kvmd/clients/streamer.py | 4 + kvmd/htserver.py | 37 +++-- kvmd/tools.py | 5 +- setup.py | 2 + 25 files changed, 419 insertions(+), 18 deletions(-) create mode 100644 configs/os/services/kvmd-media.service create mode 100644 extras/media/manifest.yaml create mode 100644 extras/media/nginx.ctx-http.conf create mode 100644 extras/media/nginx.ctx-server.conf create mode 100644 kvmd/apps/media/__init__.py create mode 100644 kvmd/apps/media/__main__.py create mode 100644 kvmd/apps/media/server.py diff --git a/configs/kvmd/main/v0-hdmi-zero2w.yaml b/configs/kvmd/main/v0-hdmi-zero2w.yaml index 96e89761..a187ff97 100644 --- a/configs/kvmd/main/v0-hdmi-zero2w.yaml +++ b/configs/kvmd/main/v0-hdmi-zero2w.yaml @@ -47,6 +47,12 @@ kvmd: - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v1-hdmi-rpi3.yaml b/configs/kvmd/main/v1-hdmi-rpi3.yaml index e8e442f3..cf427f2f 100644 --- a/configs/kvmd/main/v1-hdmi-rpi3.yaml +++ b/configs/kvmd/main/v1-hdmi-rpi3.yaml @@ -56,6 +56,12 @@ kvmd: - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v1-hdmi-zero2w.yaml b/configs/kvmd/main/v1-hdmi-zero2w.yaml index e8e442f3..cf427f2f 100644 --- a/configs/kvmd/main/v1-hdmi-zero2w.yaml +++ b/configs/kvmd/main/v1-hdmi-zero2w.yaml @@ -56,6 +56,12 @@ kvmd: - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v2-hdmi-rpi3.yaml b/configs/kvmd/main/v2-hdmi-rpi3.yaml index 3bfc000f..55de21db 100644 --- a/configs/kvmd/main/v2-hdmi-rpi3.yaml +++ b/configs/kvmd/main/v2-hdmi-rpi3.yaml @@ -47,6 +47,12 @@ kvmd: - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v2-hdmi-rpi4.yaml b/configs/kvmd/main/v2-hdmi-rpi4.yaml index ea3c8c10..532b30b0 100644 --- a/configs/kvmd/main/v2-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v2-hdmi-rpi4.yaml @@ -48,6 +48,12 @@ kvmd: - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v2-hdmi-zero2w.yaml b/configs/kvmd/main/v2-hdmi-zero2w.yaml index 3bfc000f..55de21db 100644 --- a/configs/kvmd/main/v2-hdmi-zero2w.yaml +++ b/configs/kvmd/main/v2-hdmi-zero2w.yaml @@ -47,6 +47,12 @@ kvmd: - "--h264-gop={h264_gop}" +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v3-hdmi-rpi4.yaml b/configs/kvmd/main/v3-hdmi-rpi4.yaml index 50b140b0..6494d327 100644 --- a/configs/kvmd/main/v3-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v3-hdmi-rpi4.yaml @@ -60,6 +60,12 @@ kvmd: pulse: false +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v4mini-hdmi-rpi4.yaml b/configs/kvmd/main/v4mini-hdmi-rpi4.yaml index 410544d7..0ab4412f 100644 --- a/configs/kvmd/main/v4mini-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v4mini-hdmi-rpi4.yaml @@ -85,6 +85,12 @@ kvmd: pulse: false +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/kvmd/main/v4plus-hdmi-rpi4.yaml b/configs/kvmd/main/v4plus-hdmi-rpi4.yaml index c59be781..484b728a 100644 --- a/configs/kvmd/main/v4plus-hdmi-rpi4.yaml +++ b/configs/kvmd/main/v4plus-hdmi-rpi4.yaml @@ -88,6 +88,12 @@ kvmd: pulse: false +media: + memsink: + h264: + sink: "kvmd::ustreamer::h264" + + vnc: memsink: jpeg: diff --git a/configs/os/services/kvmd-media.service b/configs/os/services/kvmd-media.service new file mode 100644 index 00000000..610d4859 --- /dev/null +++ b/configs/os/services/kvmd-media.service @@ -0,0 +1,16 @@ +[Unit] +Description=PiKVM - Media proxy server +After=kvmd.service + +[Service] +User=kvmd-media +Group=kvmd-media +Type=simple +Restart=always +RestartSec=3 + +ExecStart=/usr/bin/kvmd-media --run +TimeoutStopSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/configs/os/sysusers.conf b/configs/os/sysusers.conf index 0359974d..4ab263b5 100644 --- a/configs/os/sysusers.conf +++ b/configs/os/sysusers.conf @@ -1,4 +1,5 @@ g kvmd - - +g kvmd-media - - g kvmd-pst - - g kvmd-ipmi - - g kvmd-vnc - - @@ -7,6 +8,7 @@ g kvmd-janus - - g kvmd-certbot - - u kvmd - "PiKVM - The main daemon" - +u kvmd-media - "PiKVM - The media proxy" u kvmd-pst - "PiKVM - Persistent storage" - u kvmd-ipmi - "PiKVM - IPMI to KVMD proxy" - u kvmd-vnc - "PiKVM - VNC to KVMD/Streamer proxy" - @@ -19,8 +21,11 @@ m kvmd gpio m kvmd uucp m kvmd spi m kvmd systemd-journal +m kvmd kvmd-media m kvmd kvmd-pst +m kvmd-media kvmd + m kvmd-pst kvmd m kvmd-ipmi kvmd @@ -32,6 +37,7 @@ m kvmd-janus kvmd m kvmd-janus audio m kvmd-nginx kvmd +m kvmd-nginx kvmd-media m kvmd-nginx kvmd-janus m kvmd-nginx kvmd-certbot diff --git a/extras/media/manifest.yaml b/extras/media/manifest.yaml new file mode 100644 index 00000000..f81c1bbf --- /dev/null +++ b/extras/media/manifest.yaml @@ -0,0 +1,5 @@ +name: Media +description: KVMD Media Proxy +path: media +daemon: kvmd-media +place: -1 diff --git a/extras/media/nginx.ctx-http.conf b/extras/media/nginx.ctx-http.conf new file mode 100644 index 00000000..d4ff7ac3 --- /dev/null +++ b/extras/media/nginx.ctx-http.conf @@ -0,0 +1,3 @@ +upstream media { + server unix:/run/kvmd/media.sock fail_timeout=0s max_fails=0; +} diff --git a/extras/media/nginx.ctx-server.conf b/extras/media/nginx.ctx-server.conf new file mode 100644 index 00000000..67f877fc --- /dev/null +++ b/extras/media/nginx.ctx-server.conf @@ -0,0 +1,7 @@ +location /media/ws { + rewrite ^/media/ws$ /ws break; + rewrite ^/media/ws\?(.*)$ /ws?$1 break; + proxy_pass http://media; + include /etc/kvmd/nginx/loc-proxy.conf; + include /etc/kvmd/nginx/loc-websocket.conf; +} diff --git a/kvmd/aiotools.py b/kvmd/aiotools.py index 6183690f..f400ad3c 100644 --- a/kvmd/aiotools.py +++ b/kvmd/aiotools.py @@ -171,7 +171,7 @@ def create_deadly_task(name: str, coro: Coroutine) -> asyncio.Task: except asyncio.CancelledError: pass except Exception: - logger.exception("Unhandled exception in deadly task, killing myself ...") + logger.exception("Unhandled exception in deadly task %r, killing myself ...", name) pid = os.getpid() if pid == 1: os._exit(1) # Docker workaround # pylint: disable=protected-access diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 2090e5c6..8a1fc96e 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -509,6 +509,32 @@ def _get_config_scheme() -> dict: }, }, + "media": { + "server": { + "unix": Option("/run/kvmd/media.sock", type=valid_abs_path, unpack_as="unix_path"), + "unix_rm": Option(True, type=valid_bool), + "unix_mode": Option(0o660, type=valid_unix_mode), + "heartbeat": Option(15.0, type=valid_float_f01), + "access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---" + " referer='%{Referer}i'; user_agent='%{User-Agent}i'"), + }, + + "memsink": { + "jpeg": { + "sink": Option("", unpack_as="obj"), + "lock_timeout": Option(1.0, type=valid_float_f01), + "wait_timeout": Option(1.0, type=valid_float_f01), + "drop_same_frames": Option(0.0, type=valid_float_f0), + }, + "h264": { + "sink": Option("", unpack_as="obj"), + "lock_timeout": Option(1.0, type=valid_float_f01), + "wait_timeout": Option(1.0, type=valid_float_f01), + "drop_same_frames": Option(0.0, type=valid_float_f0), + }, + }, + }, + "pst": { "server": { "unix": Option("/run/kvmd/pst.sock", type=valid_abs_path, unpack_as="unix_path"), diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index 92eb496c..8e1f4adc 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -298,10 +298,10 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins logger.exception("Cleanup error on %s", sub.name) logger.info("On-Cleanup complete") - async def _on_ws_opened(self) -> None: + async def _on_ws_opened(self, _: WsSession) -> None: self.__streamer_notifier.notify() - async def _on_ws_closed(self) -> None: + async def _on_ws_closed(self, _: WsSession) -> None: self.__hid.clear_events() self.__streamer_notifier.notify() diff --git a/kvmd/apps/media/__init__.py b/kvmd/apps/media/__init__.py new file mode 100644 index 00000000..325a817c --- /dev/null +++ b/kvmd/apps/media/__init__.py @@ -0,0 +1,48 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +from ...clients.streamer import StreamerFormats +from ...clients.streamer import MemsinkStreamerClient + +from .. import init + +from .server import MediaServer + + +# ===== +def main(argv: (list[str] | None)=None) -> None: + config = init( + prog="kvmd-media", + description="The media proxy", + check_run=True, + argv=argv, + )[2].media + + def make_streamer(name: str, fmt: int) -> (MemsinkStreamerClient | None): + if getattr(config.memsink, name).sink: + return MemsinkStreamerClient(name.upper(), fmt, **getattr(config.memsink, name)._unpack()) + return None + + MediaServer( + h264_streamer=make_streamer("h264", StreamerFormats.H264), + jpeg_streamer=make_streamer("jpeg", StreamerFormats.JPEG), + ).run(**config.server._unpack()) diff --git a/kvmd/apps/media/__main__.py b/kvmd/apps/media/__main__.py new file mode 100644 index 00000000..ab578e06 --- /dev/null +++ b/kvmd/apps/media/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/media/server.py b/kvmd/apps/media/server.py new file mode 100644 index 00000000..2763ffa0 --- /dev/null +++ b/kvmd/apps/media/server.py @@ -0,0 +1,190 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +# ========================================================================== # + + +import asyncio +import dataclasses + +from aiohttp.web import Request +from aiohttp.web import WebSocketResponse + +from ...logging import get_logger + +from ... import tools +from ... import aiotools + +from ...htserver import exposed_http +from ...htserver import exposed_ws +from ...htserver import WsSession +from ...htserver import HttpServer + +from ...clients.streamer import StreamerError +from ...clients.streamer import StreamerPermError +from ...clients.streamer import StreamerFormats +from ...clients.streamer import BaseStreamerClient + + +# ===== +@dataclasses.dataclass +class _Source: + kind: str + fmt: str + streamer: BaseStreamerClient + meta: dict = dataclasses.field(default_factory=dict) + clients: dict[WsSession, "_Client"] = dataclasses.field(default_factory=dict) + key_required: bool = dataclasses.field(default=False) + + +@dataclasses.dataclass +class _Client: + ws: WsSession + src: _Source + queue: asyncio.Queue[dict] + sender: (asyncio.Task | None) = dataclasses.field(default=None) + + +class MediaServer(HttpServer): + __K_VIDEO = "video" + + __F_H264 = "h264" + __F_JPEG = "jpeg" + + __Q_SIZE = 32 + + def __init__( + self, + h264_streamer: (BaseStreamerClient | None), + jpeg_streamer: (BaseStreamerClient | None), + ) -> None: + + super().__init__() + + self.__srcs: list[_Source] = [] + if h264_streamer: + self.__srcs.append(_Source(self.__K_VIDEO, self.__F_H264, h264_streamer, {"profile_level_id": "42E01F"})) + if jpeg_streamer: + self.__srcs.append(_Source(self.__K_VIDEO, self.__F_JPEG, jpeg_streamer)) + + # ===== + + @exposed_http("GET", "/ws") + async def __ws_handler(self, req: Request) -> WebSocketResponse: + async with self._ws_session(req) as ws: + media: dict = {self.__K_VIDEO: {}} + for src in self.__srcs: + media[src.kind][src.fmt] = src.meta + await ws.send_event("media", media) + return (await self._ws_loop(ws)) + + @exposed_ws(0) + async def __ws_bin_ping_handler(self, ws: WsSession, _: bytes) -> None: + await ws.send_bin(255, b"") # Ping-pong + + @exposed_ws("start") + async def __ws_start_handler(self, ws: WsSession, event: dict) -> None: + try: + kind = str(event.get("kind")) + fmt = str(event.get("format")) + except Exception: + return + src: (_Source | None) = None + for cand in self.__srcs: + if ws in cand.clients: + return # Don't allow any double streaming + if (cand.kind, cand.fmt) == (kind, fmt): + src = cand + if src: + client = _Client(ws, src, asyncio.Queue(self.__Q_SIZE)) + client.sender = aiotools.create_deadly_task(str(ws), self.__sender(client)) + src.clients[ws] = client + get_logger(0).info("Streaming %s to %s ...", src.streamer, ws) + + # ===== + + async def _init_app(self) -> None: + logger = get_logger(0) + for src in self.__srcs: + logger.info("Starting streamer %s ...", src.streamer) + aiotools.create_deadly_task(str(src.streamer), self.__streamer(src)) + self._add_exposed(self) + + async def _on_shutdown(self) -> None: + logger = get_logger(0) + logger.info("Stopping system tasks ...") + await aiotools.stop_all_deadly_tasks() + logger.info("Disconnecting clients ...") + await self._close_all_wss() + logger.info("On-Shutdown complete") + + async def _on_ws_closed(self, ws: WsSession) -> None: + for src in self.__srcs: + client = src.clients.pop(ws, None) + if client and client.sender: + get_logger(0).info("Closed stream for %s", ws) + client.sender.cancel() + return + + # ===== + + async def __sender(self, client: _Client) -> None: + need_key = StreamerFormats.is_diff(client.src.streamer.get_format()) + if need_key: + client.src.key_required = True + has_key = False + while True: + frame = await client.queue.get() + has_key = (not need_key or has_key or frame["key"]) + if has_key: + try: + await client.ws.send_bin(1, frame["key"].to_bytes() + frame["data"]) + except Exception: + pass + + async def __streamer(self, src: _Source) -> None: + logger = get_logger(0) + while True: + if len(src.clients) == 0: + await asyncio.sleep(1) + continue + try: + async with src.streamer.reading() as read_frame: + while len(src.clients) > 0: + frame = await read_frame(src.key_required) + if frame["key"]: + src.key_required = False + for client in src.clients.values(): + try: + client.queue.put_nowait(frame) + except asyncio.QueueFull: + # Если какой-то из клиентов не справляется, очищаем ему очередь и запрашиваем кейфрейм. + # Я вижу у такой логики кучу минусов, хз как себя покажет, но лучше пока ничего не придумал. + tools.clear_queue(client.queue) + src.key_required = True + except Exception: + pass + except StreamerError as ex: + if isinstance(ex, StreamerPermError): + logger.exception("Streamer failed: %s", src.streamer) + else: + logger.error("Streamer error: %s: %s", src.streamer, tools.efmt(ex)) + except Exception: + get_logger(0).exception("Unexpected streamer error: %s", src.streamer) + await asyncio.sleep(1) diff --git a/kvmd/apps/pst/server.py b/kvmd/apps/pst/server.py index 8d8bf9d4..d96043b3 100644 --- a/kvmd/apps/pst/server.py +++ b/kvmd/apps/pst/server.py @@ -104,10 +104,10 @@ class PstServer(HttpServer): # pylint: disable=too-many-arguments,too-many-inst await self.__remount_storage(rw=False) logger.info("On-Cleanup complete") - async def _on_ws_opened(self) -> None: + async def _on_ws_opened(self, _: WsSession) -> None: self.__notifier.notify() - async def _on_ws_closed(self) -> None: + async def _on_ws_closed(self, _: WsSession) -> None: self.__notifier.notify() # ===== SYSTEM TASKS diff --git a/kvmd/clients/streamer.py b/kvmd/clients/streamer.py index 5369892e..cd5f03ec 100644 --- a/kvmd/clients/streamer.py +++ b/kvmd/clients/streamer.py @@ -63,6 +63,10 @@ class StreamerFormats: H264 = 875967048 # V4L2_PIX_FMT_H264 _MJPEG = 1196444237 # V4L2_PIX_FMT_MJPEG + @classmethod + def is_diff(cls, fmt: int) -> bool: + return (fmt == cls.H264) + class BaseStreamerClient: def get_format(self) -> int: diff --git a/kvmd/htserver.py b/kvmd/htserver.py index 351c1328..63c82fcb 100644 --- a/kvmd/htserver.py +++ b/kvmd/htserver.py @@ -232,6 +232,16 @@ async def send_ws_event( })) +async def send_ws_bin( + wsr: (ClientWebSocketResponse | WebSocketResponse), + op: int, + data: bytes, +) -> None: + + assert 0 <= op <= 255 + await wsr.send_bytes(op.to_bytes() + data) + + def parse_ws_event(msg: str) -> tuple[str, dict]: data = json.loads(msg) if not isinstance(data, dict): @@ -264,14 +274,24 @@ def set_request_auth_info(req: BaseRequest, info: str) -> None: @dataclasses.dataclass(frozen=True) class WsSession: wsr: WebSocketResponse - kwargs: dict[str, Any] + kwargs: dict[str, Any] = dataclasses.field(hash=False) def __str__(self) -> str: return f"WsSession(id={id(self)}, {self.kwargs})" + def is_alive(self) -> bool: + return ( + not self.wsr.closed + and self.wsr._req is not None # pylint: disable=protected-access + and self.wsr._req.transport is not None # pylint: disable=protected-access + ) + async def send_event(self, event_type: str, event: (dict | None)) -> None: await send_ws_event(self.wsr, event_type, event) + async def send_bin(self, op: int, data: bytes) -> None: + await send_ws_bin(self.wsr, op, data) + class HttpServer: def __init__(self) -> None: @@ -353,7 +373,7 @@ class HttpServer: get_logger(2).info("Registered new client session: %s; clients now: %d", ws, len(self.__ws_sessions)) try: - await self._on_ws_opened() + await self._on_ws_opened(ws) yield ws finally: await aiotools.shield_fg(self.__close_ws(ws)) @@ -389,12 +409,7 @@ class HttpServer: await asyncio.gather(*[ ws.send_event(event_type, event) for ws in self.__ws_sessions - if ( - not ws.wsr.closed - and ws.wsr._req is not None # pylint: disable=protected-access - and ws.wsr._req.transport is not None # pylint: disable=protected-access - and (legacy is None or ws.kwargs.get("legacy") == legacy) - ) + if ws.is_alive() and (legacy is None or ws.kwargs.get("legacy") == legacy) ], return_exceptions=True) async def _close_all_wss(self) -> bool: @@ -414,7 +429,7 @@ class HttpServer: await ws.wsr.close() except Exception: pass - await self._on_ws_closed() + await self._on_ws_closed(ws) # ===== @@ -430,10 +445,10 @@ class HttpServer: async def _on_cleanup(self) -> None: pass - async def _on_ws_opened(self) -> None: + async def _on_ws_opened(self, ws: WsSession) -> None: pass - async def _on_ws_closed(self) -> None: + async def _on_ws_closed(self, ws: WsSession) -> None: pass # ===== diff --git a/kvmd/tools.py b/kvmd/tools.py index 14a58ab3..6dd7d2f9 100644 --- a/kvmd/tools.py +++ b/kvmd/tools.py @@ -20,6 +20,7 @@ # ========================================================================== # +import asyncio import operator import functools import multiprocessing.queues @@ -64,11 +65,11 @@ def swapped_kvs(dct: dict[_DictKeyT, _DictValueT]) -> dict[_DictValueT, _DictKey # ===== -def clear_queue(q: multiprocessing.queues.Queue) -> None: # pylint: disable=invalid-name +def clear_queue(q: (multiprocessing.queues.Queue | asyncio.Queue)) -> None: # pylint: disable=invalid-name for _ in range(q.qsize()): try: q.get_nowait() - except queue.Empty: + except (queue.Empty, asyncio.QueueEmpty): break diff --git a/setup.py b/setup.py index 8d36376b..b75e41f7 100755 --- a/setup.py +++ b/setup.py @@ -86,6 +86,7 @@ def main() -> None: "kvmd.apps.kvmd.switch", "kvmd.apps.kvmd.info", "kvmd.apps.kvmd.api", + "kvmd.apps.media", "kvmd.apps.pst", "kvmd.apps.pstrun", "kvmd.apps.otg", @@ -116,6 +117,7 @@ def main() -> None: entry_points={ "console_scripts": [ "kvmd = kvmd.apps.kvmd:main", + "kvmd-media = kvmd.apps.media:main", "kvmd-pst = kvmd.apps.pst:main", "kvmd-pstrun = kvmd.apps.pstrun:main", "kvmd-otg = kvmd.apps.otg:main", From eda7ab3a49efeee6a55546e2ec51364c8dc81307 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 18 Dec 2024 06:42:17 +0200 Subject: [PATCH 27/84] =?UTF-8?q?Bump=20version:=204.27=20=E2=86=92=204.28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- PKGBUILD | 2 +- kvmd/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 6d717464..c8792b90 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 4.27 +current_version = 4.28 parse = (?P\d+)\.(?P\d+)(\.(?P\d+)(\-(?P[a-z]+))?)? serialize = {major}.{minor} diff --git a/PKGBUILD b/PKGBUILD index 7e68d71b..7941a668 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -39,7 +39,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=4.27 +pkgver=4.28 pkgrel=1 pkgdesc="The main PiKVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 47ef1f2b..ec94d9fa 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "4.27" +__version__ = "4.28" diff --git a/setup.py b/setup.py index b75e41f7..0279b304 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def main() -> None: setup( name="kvmd", - version="4.27", + version="4.28", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", From ab08d823c4feeb58e37591adf1ac40a07362733b Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Wed, 25 Dec 2024 09:16:59 +0200 Subject: [PATCH 28/84] pikvm/pikvm#1440: Websocket-based transport and decoding for H.264 --- extras/media/nginx.ctx-server.conf | 6 +- kvmd.install | 6 + web/kvm/index.html | 20 ++- web/kvm/navbar-system.pug | 11 +- web/kvm/window-stream.pug | 1 + web/share/css/kvm/stream.css | 3 +- web/share/js/kvm/stream.js | 45 ++++-- web/share/js/kvm/stream_janus.js | 16 +- web/share/js/kvm/stream_media.js | 240 +++++++++++++++++++++++++++++ web/share/js/kvm/stream_mjpeg.js | 2 +- 10 files changed, 314 insertions(+), 36 deletions(-) create mode 100644 web/share/js/kvm/stream_media.js diff --git a/extras/media/nginx.ctx-server.conf b/extras/media/nginx.ctx-server.conf index 67f877fc..cf1d157c 100644 --- a/extras/media/nginx.ctx-server.conf +++ b/extras/media/nginx.ctx-server.conf @@ -1,6 +1,6 @@ -location /media/ws { - rewrite ^/media/ws$ /ws break; - rewrite ^/media/ws\?(.*)$ /ws?$1 break; +location /api/media/ws { + rewrite ^/api/media/ws$ /ws break; + rewrite ^/api/media/ws\?(.*)$ /ws?$1 break; proxy_pass http://media; include /etc/kvmd/nginx/loc-proxy.conf; include /etc/kvmd/nginx/loc-websocket.conf; diff --git a/kvmd.install b/kvmd.install index 469fba8c..9f7c5f95 100644 --- a/kvmd.install +++ b/kvmd.install @@ -102,6 +102,12 @@ EOF touch -t 200701011000 /etc/fstab fi + if [[ "$(vercmp "$2" 4.29)" -lt 0 ]]; then + if [ "$(systemctl is-enabled kvmd-janus || true)" = enabled ]; then + systemctl enable kvmd-media || 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 diff --git a/web/kvm/index.html b/web/kvm/index.html index fbf9c5df..1db915ee 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -170,6 +170,17 @@

    +