summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaxim Devaev <[email protected]>2024-07-08 03:41:29 +0300
committerMaxim Devaev <[email protected]>2024-12-17 18:20:04 +0200
commit630610bc532299f15ff7ee12d40f617de450aae0 (patch)
treeca0a83f1aa5848a4605034c0394f1edfd0bea7ce
parente0bbf6968ef8295274793a564e717f95f42983d7 (diff)
switch
-rw-r--r--Makefile5
-rw-r--r--PKGBUILD6
-rw-r--r--configs/kvmd/edid/_1080p-by-default.hex2
-rw-r--r--configs/kvmd/edid/_no-1920x1200.hex2
-rw-r--r--kvmd/aiotools.py5
-rw-r--r--kvmd/apps/__init__.py5
-rw-r--r--kvmd/apps/kvmd/__init__.py5
-rw-r--r--kvmd/apps/kvmd/api/switch.py164
-rw-r--r--kvmd/apps/kvmd/server.py7
-rw-r--r--kvmd/apps/kvmd/switch/__init__.py400
-rw-r--r--kvmd/apps/kvmd/switch/chain.py440
-rw-r--r--kvmd/apps/kvmd/switch/device.py196
-rw-r--r--kvmd/apps/kvmd/switch/lib.py35
-rw-r--r--kvmd/apps/kvmd/switch/proto.py295
-rw-r--r--kvmd/apps/kvmd/switch/state.py355
-rw-r--r--kvmd/apps/kvmd/switch/storage.py186
-rw-r--r--kvmd/apps/kvmd/switch/types.py308
-rw-r--r--kvmd/apps/pst/server.py12
-rw-r--r--kvmd/clients/pst.py93
-rw-r--r--kvmd/validators/__init__.py8
-rw-r--r--kvmd/validators/os.py10
-rw-r--r--kvmd/validators/switch.py67
-rwxr-xr-xsetup.py1
-rw-r--r--testenv/linters/pylint.ini1
-rw-r--r--testenv/linters/vulture-wl.py25
-rw-r--r--testenv/tests/validators/test_switch.py180
-rw-r--r--web/kvm/index.html208
-rw-r--r--web/kvm/navbar-shortcuts.pug2
-rw-r--r--web/kvm/navbar-switch.pug19
-rw-r--r--web/kvm/navbar-system.pug2
-rw-r--r--web/kvm/navbar.pug1
-rw-r--r--web/kvm/window-keyboard.pug2
-rw-r--r--web/kvm/window-switch.pug95
-rw-r--r--web/kvm/windows.pug1
-rw-r--r--web/login/index.html2
-rw-r--r--web/login/index.pug2
-rw-r--r--web/share/css/kvm/msd.css4
-rw-r--r--web/share/css/main.css56
-rw-r--r--web/share/css/modal.css2
-rw-r--r--web/share/css/navbar.css1
-rw-r--r--web/share/css/slider.css22
-rw-r--r--web/share/css/x-desktop.css10
-rw-r--r--web/share/css/x-mobile.css2
-rw-r--r--web/share/js/kvm/atx.js10
-rw-r--r--web/share/js/kvm/session.js20
-rw-r--r--web/share/js/kvm/switch.js606
-rw-r--r--web/share/js/tools.js6
-rw-r--r--web/share/svg/led-beacon.svg4
-rw-r--r--web/share/svg/led-usb.svg22
-rw-r--r--web/share/svg/led-video.svg (renamed from web/share/svg/led-stream.svg)0
50 files changed, 3835 insertions, 77 deletions
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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+from aiohttp.web import Request
+from aiohttp.web import Response
+
+from ....htserver import exposed_http
+from ....htserver import make_json_response
+
+from ....validators.basic import valid_bool
+from ....validators.basic import valid_int_f0
+from ....validators.basic import valid_stripped_string_not_empty
+from ....validators.kvm import valid_atx_power_action
+from ....validators.kvm import valid_atx_button
+from ....validators.switch import valid_switch_port_name
+from ....validators.switch import valid_switch_edid_id
+from ....validators.switch import valid_switch_edid_data
+from ....validators.switch import valid_switch_color
+from ....validators.switch import valid_switch_atx_click_delay
+
+from ..switch import Switch
+from ..switch import Colors
+
+
+# =====
+class SwitchApi:
+ def __init__(self, switch: Switch) -> None:
+ self.__switch = switch
+
+ # =====
+
+ @exposed_http("GET", "/switch")
+ async def __state_handler(self, _: Request) -> Response:
+ return make_json_response(await self.__switch.get_state())
+
+ @exposed_http("POST", "/switch/set_active")
+ async def __set_active_port_handler(self, req: Request) -> Response:
+ port = valid_int_f0(req.query.get("port"))
+ await self.__switch.set_active_port(port)
+ return make_json_response()
+
+ @exposed_http("POST", "/switch/set_beacon")
+ async def __set_beacon_handler(self, req: Request) -> Response:
+ on = valid_bool(req.query.get("state"))
+ if "port" in req.query:
+ port = valid_int_f0(req.query.get("port"))
+ await self.__switch.set_port_beacon(port, on)
+ elif "uplink" in req.query:
+ unit = valid_int_f0(req.query.get("uplink"))
+ await self.__switch.set_uplink_beacon(unit, on)
+ else: # Downlink
+ unit = valid_int_f0(req.query.get("downlink"))
+ await self.__switch.set_downlink_beacon(unit, on)
+ return make_json_response()
+
+ @exposed_http("POST", "/switch/set_port_params")
+ async def __set_port_params(self, req: Request) -> Response:
+ port = valid_int_f0(req.query.get("port"))
+ params = {
+ param: validator(req.query.get(param))
+ for (param, validator) in [
+ ("edid_id", (lambda arg: valid_switch_edid_id(arg, allow_default=True))),
+ ("name", valid_switch_port_name),
+ ("atx_click_power_delay", valid_switch_atx_click_delay),
+ ("atx_click_power_long_delay", valid_switch_atx_click_delay),
+ ("atx_click_reset_delay", valid_switch_atx_click_delay),
+ ]
+ if req.query.get(param) is not None
+ }
+ await self.__switch.set_port_params(port, **params) # type: ignore
+ return make_json_response()
+
+ @exposed_http("POST", "/switch/set_colors")
+ async def __set_colors(self, req: Request) -> Response:
+ params = {
+ param: valid_switch_color(req.query.get(param), allow_default=True)
+ for param in Colors.ROLES
+ if req.query.get(param) is not None
+ }
+ await self.__switch.set_colors(**params)
+ return make_json_response()
+
+ # =====
+
+ @exposed_http("POST", "/switch/reset")
+ async def __reset(self, req: Request) -> Response:
+ unit = valid_int_f0(req.query.get("unit"))
+ bootloader = valid_bool(req.query.get("bootloader", False))
+ await self.__switch.reboot_unit(unit, bootloader)
+ return make_json_response()
+
+ # =====
+
+ @exposed_http("POST", "/switch/edids/create")
+ async def __create_edid(self, req: Request) -> Response:
+ name = valid_stripped_string_not_empty(req.query.get("name"))
+ data_hex = valid_switch_edid_data(req.query.get("data"))
+ edid_id = await self.__switch.create_edid(name, data_hex)
+ return make_json_response({"id": edid_id})
+
+ @exposed_http("POST", "/switch/edids/change")
+ async def __change_edid(self, req: Request) -> Response:
+ edid_id = valid_switch_edid_id(req.query.get("id"), allow_default=False)
+ params = {
+ param: validator(req.query.get(param))
+ for (param, validator) in [
+ ("name", valid_switch_port_name),
+ ("data", valid_switch_edid_data),
+ ]
+ if req.query.get(param) is not None
+ }
+ if params:
+ await self.__switch.change_edid(edid_id, **params)
+ return make_json_response()
+
+ @exposed_http("POST", "/switch/edids/remove")
+ async def __remove_edid(self, req: Request) -> Response:
+ edid_id = valid_switch_edid_id(req.query.get("id"), allow_default=False)
+ await self.__switch.remove_edid(edid_id)
+ return make_json_response()
+
+ # =====
+
+ @exposed_http("POST", "/switch/atx/power")
+ async def __power_handler(self, req: Request) -> Response:
+ port = valid_int_f0(req.query.get("port"))
+ action = valid_atx_power_action(req.query.get("action"))
+ await ({
+ "on": self.__switch.atx_power_on,
+ "off": self.__switch.atx_power_off,
+ "off_hard": self.__switch.atx_power_off_hard,
+ "reset_hard": self.__switch.atx_power_reset_hard,
+ }[action])(port)
+ return make_json_response()
+
+ @exposed_http("POST", "/switch/atx/click")
+ async def __click_handler(self, req: Request) -> Response:
+ port = valid_int_f0(req.query.get("port"))
+ button = valid_atx_button(req.query.get("button"))
+ await ({
+ "power": self.__switch.atx_click_power,
+ "power_long": self.__switch.atx_click_power_long,
+ "reset": self.__switch.atx_click_reset,
+ }[button])(port)
+ return make_json_response()
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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import os
+import asyncio
+
+from typing import AsyncGenerator
+
+from .lib import OperationError
+from .lib import get_logger
+from .lib import aiotools
+from .lib import Inotify
+
+from .types import Edid
+from .types import Edids
+from .types import Color
+from .types import Colors
+from .types import PortNames
+from .types import AtxClickPowerDelays
+from .types import AtxClickPowerLongDelays
+from .types import AtxClickResetDelays
+
+from .chain import DeviceFoundEvent
+from .chain import ChainTruncatedEvent
+from .chain import PortActivatedEvent
+from .chain import UnitStateEvent
+from .chain import UnitAtxLedsEvent
+from .chain import Chain
+
+from .state import StateCache
+
+from .storage import Storage
+
+
+# =====
+class SwitchError(Exception):
+ pass
+
+
+class SwitchOperationError(OperationError, SwitchError):
+ pass
+
+
+class SwitchUnknownEdidError(SwitchOperationError):
+ def __init__(self) -> None:
+ super().__init__("No specified EDID ID found")
+
+
+# =====
+class Switch: # pylint: disable=too-many-public-methods
+ __X_EDIDS = "edids"
+ __X_COLORS = "colors"
+ __X_PORT_NAMES = "port_names"
+ __X_ATX_CP_DELAYS = "atx_cp_delays"
+ __X_ATX_CPL_DELAYS = "atx_cpl_delays"
+ __X_ATX_CR_DELAYS = "atx_cr_delays"
+
+ __X_ALL = frozenset([
+ __X_EDIDS, __X_COLORS, __X_PORT_NAMES,
+ __X_ATX_CP_DELAYS, __X_ATX_CPL_DELAYS, __X_ATX_CR_DELAYS,
+ ])
+
+ def __init__(
+ self,
+ device_path: str,
+ default_edid_path: str,
+ pst_unix_path: str,
+ ) -> None:
+
+ self.__default_edid_path = default_edid_path
+
+ self.__chain = Chain(device_path)
+ self.__cache = StateCache()
+ self.__storage = Storage(pst_unix_path)
+
+ self.__lock = asyncio.Lock()
+
+ self.__save_notifier = aiotools.AioNotifier()
+
+ # =====
+
+ def __x_set_edids(self, edids: Edids, save: bool=True) -> None:
+ self.__chain.set_edids(edids)
+ self.__cache.set_edids(edids)
+ if save:
+ self.__save_notifier.notify()
+
+ def __x_set_colors(self, colors: Colors, save: bool=True) -> None:
+ self.__chain.set_colors(colors)
+ self.__cache.set_colors(colors)
+ if save:
+ self.__save_notifier.notify()
+
+ def __x_set_port_names(self, port_names: PortNames, save: bool=True) -> None:
+ self.__cache.set_port_names(port_names)
+ if save:
+ self.__save_notifier.notify()
+
+ def __x_set_atx_cp_delays(self, delays: AtxClickPowerDelays, save: bool=True) -> None:
+ self.__cache.set_atx_cp_delays(delays)
+ if save:
+ self.__save_notifier.notify()
+
+ def __x_set_atx_cpl_delays(self, delays: AtxClickPowerLongDelays, save: bool=True) -> None:
+ self.__cache.set_atx_cpl_delays(delays)
+ if save:
+ self.__save_notifier.notify()
+
+ def __x_set_atx_cr_delays(self, delays: AtxClickResetDelays, save: bool=True) -> None:
+ self.__cache.set_atx_cr_delays(delays)
+ if save:
+ self.__save_notifier.notify()
+
+ # =====
+
+ async def set_active_port(self, port: int) -> None:
+ self.__chain.set_active_port(port)
+
+ # =====
+
+ async def set_port_beacon(self, port: int, on: bool) -> None:
+ self.__chain.set_port_beacon(port, on)
+
+ async def set_uplink_beacon(self, unit: int, on: bool) -> None:
+ self.__chain.set_uplink_beacon(unit, on)
+
+ async def set_downlink_beacon(self, unit: int, on: bool) -> None:
+ self.__chain.set_downlink_beacon(unit, on)
+
+ # =====
+
+ async def atx_power_on(self, port: int) -> None:
+ self.__inner_atx_cp(port, False, self.__X_ATX_CP_DELAYS)
+
+ async def atx_power_off(self, port: int) -> None:
+ self.__inner_atx_cp(port, True, self.__X_ATX_CP_DELAYS)
+
+ async def atx_power_off_hard(self, port: int) -> None:
+ self.__inner_atx_cp(port, True, self.__X_ATX_CPL_DELAYS)
+
+ async def atx_power_reset_hard(self, port: int) -> None:
+ self.__inner_atx_cr(port, True)
+
+ async def atx_click_power(self, port: int) -> None:
+ self.__inner_atx_cp(port, None, self.__X_ATX_CP_DELAYS)
+
+ async def atx_click_power_long(self, port: int) -> None:
+ self.__inner_atx_cp(port, None, self.__X_ATX_CPL_DELAYS)
+
+ async def atx_click_reset(self, port: int) -> None:
+ self.__inner_atx_cr(port, None)
+
+ def __inner_atx_cp(self, port: int, if_powered: (bool | None), x_delay: str) -> None:
+ assert x_delay in [self.__X_ATX_CP_DELAYS, self.__X_ATX_CPL_DELAYS]
+ delay = getattr(self.__cache, f"get_{x_delay}")()[port]
+ self.__chain.click_power(port, delay, if_powered)
+
+ def __inner_atx_cr(self, port: int, if_powered: (bool | None)) -> None:
+ delay = self.__cache.get_atx_cr_delays()[port]
+ self.__chain.click_reset(port, delay, if_powered)
+
+ # =====
+
+ async def create_edid(self, name: str, data_hex: str) -> str:
+ async with self.__lock:
+ edids = self.__cache.get_edids()
+ edid_id = edids.add(Edid.from_data(name, data_hex))
+ self.__x_set_edids(edids)
+ return edid_id
+
+ async def change_edid(
+ self,
+ edid_id: str,
+ name: (str | None)=None,
+ data_hex: (str | None)=None,
+ ) -> None:
+
+ assert edid_id != Edids.DEFAULT_ID
+ async with self.__lock:
+ edids = self.__cache.get_edids()
+ if not edids.has_id(edid_id):
+ raise SwitchUnknownEdidError()
+ old = edids.get(edid_id)
+ name = (name or old.name)
+ data_hex = (data_hex or old.as_text())
+ edids.set(edid_id, Edid.from_data(name, data_hex))
+ self.__x_set_edids(edids)
+
+ async def remove_edid(self, edid_id: str) -> None:
+ assert edid_id != Edids.DEFAULT_ID
+ async with self.__lock:
+ edids = self.__cache.get_edids()
+ if not edids.has_id(edid_id):
+ raise SwitchUnknownEdidError()
+ edids.remove(edid_id)
+ self.__x_set_edids(edids)
+
+ # =====
+
+ async def set_colors(self, **values: str) -> None:
+ async with self.__lock:
+ old = self.__cache.get_colors()
+ new = {}
+ for role in Colors.ROLES:
+ if role in values:
+ if values[role] != "default":
+ new[role] = Color.from_text(values[role])
+ # else reset to default
+ else:
+ new[role] = getattr(old, role)
+ self.__x_set_colors(Colors(**new)) # type: ignore
+
+ # =====
+
+ async def set_port_params(
+ self,
+ port: int,
+ edid_id: (str | None)=None,
+ name: (str | None)=None,
+ atx_click_power_delay: (float | None)=None,
+ atx_click_power_long_delay: (float | None)=None,
+ atx_click_reset_delay: (float | None)=None,
+ ) -> None:
+
+ async with self.__lock:
+ if edid_id is not None:
+ edids = self.__cache.get_edids()
+ if not edids.has_id(edid_id):
+ raise SwitchUnknownEdidError()
+ edids.assign(port, edid_id)
+ self.__x_set_edids(edids)
+
+ for (key, value) in [
+ (self.__X_PORT_NAMES, name),
+ (self.__X_ATX_CP_DELAYS, atx_click_power_delay),
+ (self.__X_ATX_CPL_DELAYS, atx_click_power_long_delay),
+ (self.__X_ATX_CR_DELAYS, atx_click_reset_delay),
+ ]:
+ if value is not None:
+ new = getattr(self.__cache, f"get_{key}")()
+ new[port] = (value or None) # None == reset to default
+ getattr(self, f"_Switch__x_set_{key}")(new)
+
+ # =====
+
+ async def reboot_unit(self, unit: int, bootloader: bool) -> None:
+ self.__chain.reboot_unit(unit, bootloader)
+
+ # =====
+
+ async def get_state(self) -> dict:
+ return self.__cache.get_state()
+
+ async def trigger_state(self) -> None:
+ await self.__cache.trigger_state()
+
+ async def poll_state(self) -> AsyncGenerator[dict, None]:
+ async for state in self.__cache.poll_state():
+ yield state
+
+ # =====
+
+ async def systask(self) -> None:
+ tasks = [
+ asyncio.create_task(self.__systask_events()),
+ asyncio.create_task(self.__systask_default_edid()),
+ asyncio.create_task(self.__systask_storage()),
+ ]
+ try:
+ await asyncio.gather(*tasks)
+ except Exception:
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ raise
+
+ async def __systask_events(self) -> None:
+ async for event in self.__chain.poll_events():
+ match event:
+ case DeviceFoundEvent():
+ await self.__load_configs()
+ case ChainTruncatedEvent():
+ self.__cache.truncate(event.units)
+ case PortActivatedEvent():
+ self.__cache.update_active_port(event.port)
+ case UnitStateEvent():
+ self.__cache.update_unit_state(event.unit, event.state)
+ case UnitAtxLedsEvent():
+ self.__cache.update_unit_atx_leds(event.unit, event.atx_leds)
+
+ async def __load_configs(self) -> None:
+ async with self.__lock:
+ try:
+ async with self.__storage.readable() as ctx:
+ values = {
+ key: await getattr(ctx, f"read_{key}")()
+ for key in self.__X_ALL
+ }
+ data_hex = await aiotools.read_file(self.__default_edid_path)
+ values["edids"].set_default(data_hex)
+ except Exception:
+ get_logger(0).exception("Can't load configs")
+ else:
+ for (key, value) in values.items():
+ func = getattr(self, f"_Switch__x_set_{key}")
+ if isinstance(value, tuple):
+ func(*value, save=False)
+ else:
+ func(value, save=False)
+ self.__chain.set_actual(True)
+
+ async def __systask_default_edid(self) -> None:
+ logger = get_logger(0)
+ async for _ in self.__poll_default_edid():
+ async with self.__lock:
+ edids = self.__cache.get_edids()
+ try:
+ data_hex = await aiotools.read_file(self.__default_edid_path)
+ edids.set_default(data_hex)
+ except Exception:
+ logger.exception("Can't read default EDID, ignoring ...")
+ else:
+ self.__x_set_edids(edids, save=False)
+
+ async def __poll_default_edid(self) -> AsyncGenerator[None, None]:
+ logger = get_logger(0)
+ while True:
+ while not os.path.exists(self.__default_edid_path):
+ await asyncio.sleep(5)
+ try:
+ with Inotify() as inotify:
+ await inotify.watch_all_changes(self.__default_edid_path)
+ if os.path.islink(self.__default_edid_path):
+ await inotify.watch_all_changes(os.path.realpath(self.__default_edid_path))
+ yield None
+ while True:
+ need_restart = False
+ need_notify = False
+ for event in (await inotify.get_series(timeout=1)):
+ need_notify = True
+ if event.restart:
+ logger.warning("Got fatal inotify event: %s; reinitializing ...", event)
+ need_restart = True
+ break
+ if need_restart:
+ break
+ if need_notify:
+ yield None
+ except Exception:
+ logger.exception("Unexpected watcher error")
+ await asyncio.sleep(1)
+
+ async def __systask_storage(self) -> None:
+ # При остановке KVMD можем не успеть записать, ну да пофиг
+ prevs = dict.fromkeys(self.__X_ALL)
+ while True:
+ await self.__save_notifier.wait()
+ while (await self.__save_notifier.wait(5)):
+ pass
+ while True:
+ try:
+ async with self.__lock:
+ write = {
+ key: new
+ for (key, old) in prevs.items()
+ if (new := getattr(self.__cache, f"get_{key}")()) != old
+ }
+ if write:
+ async with self.__storage.writable() as ctx:
+ for (key, new) in write.items():
+ func = getattr(ctx, f"write_{key}")
+ if isinstance(new, tuple):
+ await func(*new)
+ else:
+ await func(new)
+ prevs[key] = new
+ except Exception:
+ get_logger(0).exception("Unexpected storage error")
+ await asyncio.sleep(5)
+ else:
+ break
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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import multiprocessing
+import queue
+import select
+import dataclasses
+import time
+
+from typing import AsyncGenerator
+
+from .lib import get_logger
+from .lib import tools
+from .lib import aiotools
+from .lib import aioproc
+
+from .types import Edids
+from .types import Colors
+
+from .proto import Response
+from .proto import UnitState
+from .proto import UnitAtxLeds
+
+from .device import Device
+from .device import DeviceError
+
+
+# =====
+class _BaseCmd:
+ pass
+
+
[email protected](frozen=True)
+class _CmdSetActual(_BaseCmd):
+ actual: bool
+
+
[email protected](frozen=True)
+class _CmdSetActivePort(_BaseCmd):
+ port: int
+
+ def __post_init__(self) -> None:
+ assert self.port >= 0
+
+
[email protected](frozen=True)
+class _CmdSetPortBeacon(_BaseCmd):
+ port: int
+ on: bool
+
+
[email protected](frozen=True)
+class _CmdSetUnitBeacon(_BaseCmd):
+ unit: int
+ on: bool
+ downlink: bool
+
+
[email protected](frozen=True)
+class _CmdSetEdids(_BaseCmd):
+ edids: Edids
+
+
[email protected](frozen=True)
+class _CmdSetColors(_BaseCmd):
+ colors: Colors
+
+
[email protected](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)
+
+
[email protected](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
+
+
[email protected](frozen=True)
+class ChainTruncatedEvent(BaseEvent):
+ units: int
+
+
[email protected](frozen=True)
+class PortActivatedEvent(BaseEvent):
+ port: int
+
+
[email protected](frozen=True)
+class UnitStateEvent(BaseEvent):
+ unit: int
+ state: UnitState
+
+
[email protected](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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import os
+import random
+import types
+
+import serial
+
+from .lib import tools
+
+from .types import Edid
+from .types import Colors
+
+from .proto import Packable
+from .proto import Request
+from .proto import Response
+from .proto import Header
+
+from .proto import BodySwitch
+from .proto import BodySetBeacon
+from .proto import BodyAtxClick
+from .proto import BodySetEdid
+from .proto import BodyClearEdid
+from .proto import BodySetColors
+
+
+# =====
+class DeviceError(Exception):
+ def __init__(self, ex: Exception):
+ super().__init__(tools.efmt(ex))
+
+
+class Device:
+ __SPEED = 115200
+ __TIMEOUT = 5.0
+
+ def __init__(self, device_path: str) -> None:
+ self.__device_path = device_path
+ self.__rid = random.randint(1, 0xFFFF)
+ self.__tty: (serial.Serial | None) = None
+ self.__buf: bytes = b""
+
+ def __enter__(self) -> "Device":
+ try:
+ self.__tty = serial.Serial(
+ self.__device_path,
+ baudrate=self.__SPEED,
+ timeout=self.__TIMEOUT,
+ )
+ except Exception as ex:
+ raise DeviceError(ex)
+ return self
+
+ def __exit__(
+ self,
+ _exc_type: type[BaseException],
+ _exc: BaseException,
+ _tb: types.TracebackType,
+ ) -> None:
+
+ if self.__tty is not None:
+ try:
+ self.__tty.close()
+ except Exception:
+ pass
+ self.__tty = None
+
+ def has_device(self) -> bool:
+ return os.path.exists(self.__device_path)
+
+ def get_fd(self) -> int:
+ assert self.__tty is not None
+ return self.__tty.fd
+
+ def read_all(self) -> list[Response]:
+ assert self.__tty is not None
+ try:
+ if not self.__tty.in_waiting:
+ return []
+ self.__buf += self.__tty.read_all()
+ except Exception as ex:
+ raise DeviceError(ex)
+
+ results: list[Response] = []
+ while True:
+ try:
+ begin = self.__buf.index(0xF1)
+ except ValueError:
+ break
+ try:
+ end = self.__buf.index(0xF2, begin)
+ except ValueError:
+ break
+ msg = self.__buf[begin + 1:end]
+ if 0xF1 in msg:
+ # raise RuntimeError(f"Found 0xF1 inside the message: {msg!r}")
+ break
+ self.__buf = self.__buf[end + 1:]
+ msg = self.__unescape(msg)
+ resp = Response.unpack(msg)
+ if resp is not None:
+ results.append(resp)
+ return results
+
+ def __unescape(self, msg: bytes) -> bytes:
+ if 0xF0 not in msg:
+ return msg
+ unesc: list[int] = []
+ esc = False
+ for ch in msg:
+ if ch == 0xF0:
+ esc = True
+ else:
+ if esc:
+ ch ^= 0xFF
+ esc = False
+ unesc.append(ch)
+ return bytes(unesc)
+
+ def request_reboot(self, unit: int, bootloader: bool) -> int:
+ return self.__send_request((Header.BOOTLOADER if bootloader else Header.REBOOT), unit, None)
+
+ def request_state(self) -> int:
+ return self.__send_request(Header.STATE, 0xFF, None)
+
+ def request_switch(self, unit: int, ch: int) -> int:
+ return self.__send_request(Header.SWITCH, unit, BodySwitch(ch))
+
+ def request_beacon(self, unit: int, ch: int, on: bool) -> int:
+ return self.__send_request(Header.BEACON, unit, BodySetBeacon(ch, on))
+
+ def request_atx_leds(self) -> int:
+ return self.__send_request(Header.ATX_LEDS, 0xFF, None)
+
+ def request_atx_cp(self, unit: int, ch: int, delay_ms: int) -> int:
+ return self.__send_request(Header.ATX_CLICK, unit, BodyAtxClick(ch, BodyAtxClick.POWER, delay_ms))
+
+ def request_atx_cr(self, unit: int, ch: int, delay_ms: int) -> int:
+ return self.__send_request(Header.ATX_CLICK, unit, BodyAtxClick(ch, BodyAtxClick.RESET, delay_ms))
+
+ def request_set_edid(self, unit: int, ch: int, edid: Edid) -> int:
+ if edid.valid:
+ return self.__send_request(Header.SET_EDID, unit, BodySetEdid(ch, edid))
+ return self.__send_request(Header.CLEAR_EDID, unit, BodyClearEdid(ch))
+
+ def request_set_colors(self, unit: int, ch: int, colors: Colors) -> int:
+ return self.__send_request(Header.SET_COLORS, unit, BodySetColors(ch, colors))
+
+ def __send_request(self, op: int, unit: int, body: (Packable | None)) -> int:
+ assert self.__tty is not None
+ req = Request(Header(
+ proto=1,
+ rid=self.__get_next_rid(),
+ op=op,
+ unit=unit,
+ ), body)
+ data: list[int] = [0xF1]
+ for ch in req.pack():
+ if 0xF0 <= ch <= 0xF2:
+ data.append(0xF0)
+ ch ^= 0xFF
+ data.append(ch)
+ data.append(0xF2)
+ try:
+ self.__tty.write(bytes(data))
+ self.__tty.flush()
+ except Exception as ex:
+ raise DeviceError(ex)
+ return req.header.rid
+
+ def __get_next_rid(self) -> int:
+ rid = self.__rid
+ self.__rid += 1
+ if self.__rid > 0xFFFF:
+ self.__rid = 1
+ return rid
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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+# pylint: disable=unused-import
+
+from ....logging import get_logger # noqa: F401
+
+from .... import tools # noqa: F401
+from .... import aiotools # noqa: F401
+from .... import aioproc # noqa: F401
+from .... import bitbang # noqa: F401
+from .... import htclient # noqa: F401
+from ....inotify import Inotify # noqa: F401
+from ....errors import OperationError # noqa: F401
+from ....edid import EdidNoBlockError as ParsedEdidNoBlockError # noqa: F401
+from ....edid import Edid as ParsedEdid # noqa: F401
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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import struct
+import dataclasses
+
+from typing import Optional
+
+from .types import Edid
+from .types import Colors
+
+
+# =====
+class Packable:
+ def pack(self) -> bytes:
+ raise NotImplementedError()
+
+
+class Unpackable:
+ @classmethod
+ def unpack(cls, data: bytes, offset: int=0) -> "Unpackable":
+ raise NotImplementedError()
+
+
+# =====
[email protected](frozen=True)
+class Header(Packable, Unpackable):
+ proto: int
+ rid: int
+ op: int
+ unit: int
+
+ NAK = 0
+ BOOTLOADER = 2
+ REBOOT = 3
+ STATE = 4
+ SWITCH = 5
+ BEACON = 6
+ ATX_LEDS = 7
+ ATX_CLICK = 8
+ SET_EDID = 9
+ CLEAR_EDID = 10
+ SET_COLORS = 12
+
+ __struct = struct.Struct("<BHBB")
+
+ SIZE = __struct.size
+
+ def pack(self) -> bytes:
+ return self.__struct.pack(self.proto, self.rid, self.op, self.unit)
+
+ @classmethod
+ def unpack(cls, data: bytes, offset: int=0) -> "Header":
+ return Header(*cls.__struct.unpack_from(data, offset=offset))
+
+
[email protected](frozen=True)
+class Nak(Unpackable):
+ reason: int
+
+ INVALID_COMMAND = 0
+ BUSY = 1
+ NO_DOWNLINK = 2
+ DOWNLINK_OVERFLOW = 3
+
+ __struct = struct.Struct("<B")
+
+ @classmethod
+ def unpack(cls, data: bytes, offset: int=0) -> "Nak":
+ return Nak(*cls.__struct.unpack_from(data, offset=offset))
+
+
[email protected](frozen=True)
+class UnitFlags:
+ changing_busy: bool
+ flashing_busy: bool
+ has_downlink: bool
+
+
[email protected](frozen=True)
+class UnitState(Unpackable): # pylint: disable=too-many-instance-attributes
+ sw_version: int
+ hw_version: int
+ flags: UnitFlags
+ ch: int
+ beacons: tuple[bool, bool, bool, bool, bool, bool]
+ np_crc: tuple[int, int, int, int, int, int]
+ video_5v_sens: tuple[bool, bool, bool, bool, bool]
+ video_hpd: tuple[bool, bool, bool, bool, bool]
+ video_edid: tuple[bool, bool, bool, bool]
+ video_crc: tuple[int, int, int, int]
+ usb_5v_sens: tuple[bool, bool, bool, bool]
+ atx_busy: tuple[bool, bool, bool, bool]
+
+ __struct = struct.Struct("<HHHBBHHHHHHBBBHHHHBxB30x")
+
+ def compare_edid(self, ch: int, edid: Optional["Edid"]) -> bool:
+ if edid is None:
+ # Сойдет любой невалидный EDID
+ return (not self.video_edid[ch])
+ return (
+ self.video_edid[ch] == edid.valid
+ and self.video_crc[ch] == edid.crc
+ )
+
+ @classmethod
+ def unpack(cls, data: bytes, offset: int=0) -> "UnitState": # pylint: disable=too-many-locals
+ (
+ sw_version, hw_version, flags, ch,
+ beacons, nc0, nc1, nc2, nc3, nc4, nc5,
+ video_5v_sens, video_hpd, video_edid, vc0, vc1, vc2, vc3,
+ usb_5v_sens, atx_busy,
+ ) = cls.__struct.unpack_from(data, offset=offset)
+ return UnitState(
+ sw_version,
+ hw_version,
+ flags=UnitFlags(
+ changing_busy=bool(flags & 0x80),
+ flashing_busy=bool(flags & 0x40),
+ has_downlink=bool(flags & 0x02),
+ ),
+ ch=ch,
+ beacons=cls.__make_flags6(beacons),
+ np_crc=(nc0, nc1, nc2, nc3, nc4, nc5),
+ video_5v_sens=cls.__make_flags5(video_5v_sens),
+ video_hpd=cls.__make_flags5(video_hpd),
+ video_edid=cls.__make_flags4(video_edid),
+ video_crc=(vc0, vc1, vc2, vc3),
+ usb_5v_sens=cls.__make_flags4(usb_5v_sens),
+ atx_busy=cls.__make_flags4(atx_busy),
+ )
+
+ @classmethod
+ def __make_flags6(cls, mask: int) -> tuple[bool, bool, bool, bool, bool, bool]:
+ return (
+ bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04),
+ bool(mask & 0x08), bool(mask & 0x10), bool(mask & 0x20),
+ )
+
+ @classmethod
+ def __make_flags5(cls, mask: int) -> tuple[bool, bool, bool, bool, bool]:
+ return (
+ bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04),
+ bool(mask & 0x08), bool(mask & 0x10),
+ )
+
+ @classmethod
+ def __make_flags4(cls, mask: int) -> tuple[bool, bool, bool, bool]:
+ return (bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), bool(mask & 0x08))
+
+
[email protected](frozen=True)
+class UnitAtxLeds(Unpackable):
+ power: tuple[bool, bool, bool, bool]
+ hdd: tuple[bool, bool, bool, bool]
+
+ __struct = struct.Struct("<B")
+
+ @classmethod
+ def unpack(cls, data: bytes, offset: int=0) -> "UnitAtxLeds":
+ (mask,) = cls.__struct.unpack_from(data, offset=offset)
+ return UnitAtxLeds(
+ power=(bool(mask & 0x01), bool(mask & 0x02), bool(mask & 0x04), bool(mask & 0x08)),
+ hdd=(bool(mask & 0x10), bool(mask & 0x20), bool(mask & 0x40), bool(mask & 0x80)),
+ )
+
+
+# =====
[email protected](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()
+
+
[email protected](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()
+
+
[email protected](frozen=True)
+class BodyAtxClick(Packable):
+ ch: int
+ action: int
+ delay_ms: int
+
+ POWER = 0
+ RESET = 1
+
+ __struct = struct.Struct("<BBH")
+
+ def __post_init__(self) -> None:
+ assert 0 <= self.ch <= 3
+ assert self.action in [self.POWER, self.RESET]
+ assert 1 <= self.delay_ms <= 0xFFFF
+
+ def pack(self) -> bytes:
+ return self.__struct.pack(self.ch, self.action, self.delay_ms)
+
+
[email protected](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()
+
+
[email protected](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()
+
+
[email protected](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()
+
+
+# =====
[email protected](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
+
+
[email protected](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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import asyncio
+import dataclasses
+import time
+
+from typing import AsyncGenerator
+
+from .types import Edids
+from .types import Color
+from .types import Colors
+from .types import PortNames
+from .types import AtxClickPowerDelays
+from .types import AtxClickPowerLongDelays
+from .types import AtxClickResetDelays
+
+from .proto import UnitState
+from .proto import UnitAtxLeds
+
+from .chain import Chain
+
+
+# =====
+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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import os
+import asyncio
+import json
+import contextlib
+
+from typing import AsyncGenerator
+
+try:
+ from ....clients.pst import PstClient
+except ImportError:
+ PstClient = None # type: ignore
+
+# from .lib import get_logger
+from .lib import aiotools
+from .lib import htclient
+from .lib import get_logger
+
+from .types import Edid
+from .types import Edids
+from .types import Color
+from .types import Colors
+from .types import PortNames
+from .types import AtxClickPowerDelays
+from .types import AtxClickPowerLongDelays
+from .types import AtxClickResetDelays
+
+
+# =====
+class StorageContext:
+ __F_EDIDS_ALL = "edids_all.json"
+ __F_EDIDS_PORT = "edids_port.json"
+
+ __F_COLORS = "colors.json"
+
+ __F_PORT_NAMES = "port_names.json"
+
+ __F_ATX_CP_DELAYS = "atx_click_power_delays.json"
+ __F_ATX_CPL_DELAYS = "atx_click_power_long_delays.json"
+ __F_ATX_CR_DELAYS = "atx_click_reset_delays.json"
+
+ def __init__(self, path: str, rw: bool) -> None:
+ self.__path = path
+ self.__rw = rw
+
+ # =====
+
+ async def write_edids(self, edids: Edids) -> None:
+ await self.__write_json_keyvals(self.__F_EDIDS_ALL, {
+ edid_id.lower(): {"name": edid.name, "data": edid.as_text()}
+ for (edid_id, edid) in edids.all.items()
+ if edid_id != Edids.DEFAULT_ID
+ })
+ await self.__write_json_keyvals(self.__F_EDIDS_PORT, edids.port)
+
+ async def write_colors(self, colors: Colors) -> None:
+ await self.__write_json_keyvals(self.__F_COLORS, {
+ role: {
+ comp: getattr(getattr(colors, role), comp)
+ for comp in Color.COMPONENTS
+ }
+ for role in Colors.ROLES
+ })
+
+ async def write_port_names(self, port_names: PortNames) -> None:
+ await self.__write_json_keyvals(self.__F_PORT_NAMES, port_names.kvs)
+
+ async def write_atx_cp_delays(self, delays: AtxClickPowerDelays) -> None:
+ await self.__write_json_keyvals(self.__F_ATX_CP_DELAYS, delays.kvs)
+
+ async def write_atx_cpl_delays(self, delays: AtxClickPowerLongDelays) -> None:
+ await self.__write_json_keyvals(self.__F_ATX_CPL_DELAYS, delays.kvs)
+
+ async def write_atx_cr_delays(self, delays: AtxClickResetDelays) -> None:
+ await self.__write_json_keyvals(self.__F_ATX_CR_DELAYS, delays.kvs)
+
+ async def __write_json_keyvals(self, name: str, kvs: dict) -> None:
+ if len(self.__path) == 0:
+ return
+ assert self.__rw
+ kvs = {str(key): value for (key, value) in kvs.items()}
+ if (await self.__read_json_keyvals(name)) == kvs:
+ return # Don't write the same data
+ path = os.path.join(self.__path, name)
+ get_logger(0).info("Writing '%s' ...", name)
+ await aiotools.write_file(path, json.dumps(kvs))
+
+ # =====
+
+ async def read_edids(self) -> Edids:
+ all_edids = {
+ edid_id.lower(): Edid.from_data(edid["name"], edid["data"])
+ for (edid_id, edid) in (await self.__read_json_keyvals(self.__F_EDIDS_ALL)).items()
+ }
+ port_edids = await self.__read_json_keyvals_int(self.__F_EDIDS_PORT)
+ return Edids(all_edids, port_edids)
+
+ async def read_colors(self) -> Colors:
+ raw = await self.__read_json_keyvals(self.__F_COLORS)
+ return Colors(**{ # type: ignore
+ role: Color(**{comp: raw[role][comp] for comp in Color.COMPONENTS})
+ for role in Colors.ROLES
+ if role in raw
+ })
+
+ async def read_port_names(self) -> PortNames:
+ return PortNames(await self.__read_json_keyvals_int(self.__F_PORT_NAMES))
+
+ async def read_atx_cp_delays(self) -> AtxClickPowerDelays:
+ return AtxClickPowerDelays(await self.__read_json_keyvals_int(self.__F_ATX_CP_DELAYS))
+
+ async def read_atx_cpl_delays(self) -> AtxClickPowerLongDelays:
+ return AtxClickPowerLongDelays(await self.__read_json_keyvals_int(self.__F_ATX_CPL_DELAYS))
+
+ async def read_atx_cr_delays(self) -> AtxClickResetDelays:
+ return AtxClickResetDelays(await self.__read_json_keyvals_int(self.__F_ATX_CR_DELAYS))
+
+ async def __read_json_keyvals_int(self, name: str) -> dict:
+ return (await self.__read_json_keyvals(name, int_keys=True))
+
+ async def __read_json_keyvals(self, name: str, int_keys: bool=False) -> dict:
+ if len(self.__path) == 0:
+ return {}
+ path = os.path.join(self.__path, name)
+ try:
+ kvs: dict = json.loads(await aiotools.read_file(path))
+ except FileNotFoundError:
+ kvs = {}
+ if int_keys:
+ kvs = {int(key): value for (key, value) in kvs.items()}
+ return kvs
+
+
+class Storage:
+ __SUBDIR = "__switch__"
+ __TIMEOUT = 5.0
+
+ def __init__(self, unix_path: str) -> None:
+ self.__pst: (PstClient | None) = None
+ if len(unix_path) > 0 and PstClient is not None:
+ self.__pst = PstClient(
+ subdir=self.__SUBDIR,
+ unix_path=unix_path,
+ timeout=self.__TIMEOUT,
+ user_agent=htclient.make_user_agent("KVMD"),
+ )
+ self.__lock = asyncio.Lock()
+
+ @contextlib.asynccontextmanager
+ async def readable(self) -> AsyncGenerator[StorageContext, None]:
+ async with self.__lock:
+ if self.__pst is None:
+ yield StorageContext("", False)
+ else:
+ path = await self.__pst.get_path()
+ yield StorageContext(path, False)
+
+ @contextlib.asynccontextmanager
+ async def writable(self) -> AsyncGenerator[StorageContext, None]:
+ async with self.__lock:
+ if self.__pst is None:
+ yield StorageContext("", True)
+ else:
+ async with self.__pst.writable() as path:
+ yield StorageContext(path, True)
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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import re
+import struct
+import uuid
+import dataclasses
+
+from typing import TypeVar
+from typing import Generic
+
+from .lib import bitbang
+from .lib import ParsedEdidNoBlockError
+from .lib import ParsedEdid
+
+
+# =====
[email protected](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(),
+ )
+
+
[email protected](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)
+
+
+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)]
+
+
+# =====
[email protected](frozen=True)
+class Color:
+ COMPONENTS = frozenset(["red", "green", "blue", "brightness", "blink_ms"])
+
+ red: int
+ green: int
+ blue: int
+ brightness: int
+ blink_ms: int
+ crc: int = dataclasses.field(default=0)
+ _packed: bytes = dataclasses.field(default=b"")
+
+ __struct = struct.Struct("<BBBBH")
+ __rx = re.compile(r"^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{4})$")
+
+ def __post_init__(self) -> None:
+ assert 0 <= self.red <= 0xFF
+ assert 0 <= self.green <= 0xFF
+ assert 0 <= self.blue <= 0xFF
+ assert 0 <= self.brightness <= 0xFF
+ assert 0 <= self.blink_ms <= 0xFFFF
+ data = self.__struct.pack(self.red, self.green, self.blue, self.brightness, self.blink_ms)
+ object.__setattr__(self, "crc", bitbang.make_crc16(data))
+ object.__setattr__(self, "_packed", data)
+
+ def pack(self) -> bytes:
+ return self._packed
+
+ @classmethod
+ def from_text(cls, text: str) -> "Color":
+ match = cls.__rx.match(text)
+ assert match is not None, text
+ return Color(
+ red=int(match.group(1), 16),
+ green=int(match.group(2), 16),
+ blue=int(match.group(3), 16),
+ brightness=int(match.group(4), 16),
+ blink_ms=int(match.group(5), 16),
+ )
+
+
[email protected](frozen=True)
+class Colors:
+ ROLES = frozenset(["inactive", "active", "flashing", "beacon", "bootloader"])
+
+ inactive: Color = dataclasses.field(default=Color(255, 0, 0, 64, 0))
+ active: Color = dataclasses.field(default=Color(0, 255, 0, 128, 0))
+ flashing: Color = dataclasses.field(default=Color(0, 170, 255, 128, 0))
+ beacon: Color = dataclasses.field(default=Color(228, 44, 156, 255, 250))
+ bootloader: Color = dataclasses.field(default=Color(255, 170, 0, 128, 0))
+ crc: int = dataclasses.field(default=0)
+ _packed: bytes = dataclasses.field(default=b"")
+
+ __crc_struct = struct.Struct("<HHHHH")
+
+ def __post_init__(self) -> None:
+ crcs: list[int] = []
+ packed: bytes = b""
+ for color in [self.inactive, self.active, self.flashing, self.beacon, self.bootloader]:
+ crcs.append(color.crc)
+ packed += color.pack()
+ object.__setattr__(self, "crc", bitbang.make_crc16(self.__crc_struct.pack(*crcs)))
+ object.__setattr__(self, "_packed", packed)
+
+ def pack(self) -> bytes:
+ return self._packed
+
+
+# =====
+_T = TypeVar("_T")
+
+
+class _PortsDict(Generic[_T]):
+ def __init__(self, default: _T, kvs: dict[int, _T]) -> None:
+ self.default = default
+ self.kvs = {
+ port: value
+ for (port, value) in kvs.items()
+ if value != default
+ }
+
+ def compare_on_ports(self, other: "_PortsDict[_T]", ports: int) -> bool:
+ for port in range(ports):
+ if self[port] != other[port]:
+ return False
+ return True
+
+ def __getitem__(self, port: int) -> _T:
+ return self.kvs.get(port, self.default)
+
+ def __setitem__(self, port: int, value: (_T | None)) -> None:
+ if value is None:
+ value = self.default
+ if value == self.default:
+ self.kvs.pop(port, None)
+ else:
+ self.kvs[port] = value
+
+
+class PortNames(_PortsDict[str]):
+ def __init__(self, kvs: dict[int, str]) -> None:
+ super().__init__("", kvs)
+
+ def copy(self) -> "PortNames":
+ return PortNames(self.kvs)
+
+
+class AtxClickPowerDelays(_PortsDict[float]):
+ def __init__(self, kvs: dict[int, float]) -> None:
+ super().__init__(0.5, kvs)
+
+ def copy(self) -> "AtxClickPowerDelays":
+ return AtxClickPowerDelays(self.kvs)
+
+
+class AtxClickPowerLongDelays(_PortsDict[float]):
+ def __init__(self, kvs: dict[int, float]) -> None:
+ super().__init__(5.5, kvs)
+
+ def copy(self) -> "AtxClickPowerLongDelays":
+ return AtxClickPowerLongDelays(self.kvs)
+
+
+class AtxClickResetDelays(_PortsDict[float]):
+ def __init__(self, kvs: dict[int, float]) -> None:
+ super().__init__(0.5, kvs)
+
+ def copy(self) -> "AtxClickResetDelays":
+ return AtxClickResetDelays(self.kvs)
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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import os
+import contextlib
+
+from typing import AsyncGenerator
+
+import aiohttp
+
+from .. import htclient
+from .. import htserver
+
+
+# =====
+class PstError(Exception):
+ pass
+
+
+# =====
+class PstClient:
+ def __init__(
+ self,
+ subdir: str,
+ unix_path: str,
+ timeout: float,
+ user_agent: str,
+ ) -> None:
+
+ self.__subdir = subdir
+ self.__unix_path = unix_path
+ self.__timeout = timeout
+ self.__user_agent = user_agent
+
+ async def get_path(self) -> str:
+ async with self.__make_http_session() as session:
+ async with session.get("http://localhost:0/state") as resp:
+ htclient.raise_not_200(resp)
+ path = (await resp.json())["result"]["data"]["path"]
+ return os.path.join(path, self.__subdir)
+
+ @contextlib.asynccontextmanager
+ async def writable(self) -> AsyncGenerator[str, None]:
+ async with self.__inner_writable() as path:
+ path = os.path.join(path, self.__subdir)
+ if not os.path.exists(path):
+ os.mkdir(path)
+ yield path
+
+ @contextlib.asynccontextmanager
+ async def __inner_writable(self) -> AsyncGenerator[str, None]:
+ async with self.__make_http_session() as session:
+ async with session.ws_connect("http://localhost:0/ws") as ws:
+ path = ""
+ async for msg in ws:
+ if msg.type != aiohttp.WSMsgType.TEXT:
+ raise PstError(f"Unexpected message type: {msg!r}")
+ (event_type, event) = htserver.parse_ws_event(msg.data)
+ if event_type == "storage_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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+import re
+
+from typing import Any
+
+from . import filter_printable
+from . import check_re_match
+
+from .basic import valid_stripped_string
+from .basic import valid_number
+
+
+# =====
+def valid_switch_port_name(arg: Any) -> str:
+ arg = valid_stripped_string(arg, name="switch port name")
+ arg = filter_printable(arg, " ", 255)
+ arg = re.sub(r"\s+", " ", arg)
+ return arg.strip()
+
+
+def valid_switch_edid_id(arg: Any, allow_default: bool) -> str:
+ pattern = "(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
+ if allow_default:
+ pattern += "|^default$"
+ return check_re_match(arg, "switch EDID ID", pattern).lower()
+
+
+def valid_switch_edid_data(arg: Any) -> str:
+ name = "switch EDID data"
+ arg = valid_stripped_string(arg, name=name)
+ arg = re.sub(r"\s", "", arg)
+ return check_re_match(arg, name, "(?i)^[0-9a-f]{512}$").upper()
+
+
+def valid_switch_color(arg: Any, allow_default: bool) -> str:
+ pattern = "(?i)^[0-9a-f]{6}:[0-9a-f]{2}:[0-9a-f]{4}$"
+ if allow_default:
+ pattern += "|^default$"
+ arg = check_re_match(arg, "switch color", pattern).upper()
+ if arg == "DEFAULT":
+ arg = "default"
+ return arg
+
+
+def valid_switch_atx_click_delay(arg: Any) -> float:
+ return valid_number(arg, min=0, max=10, type=float, name="ATX delay")
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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+from typing import Any
+
+import pytest
+
+from kvmd.validators import ValidatorError
+from kvmd.validators.switch import valid_switch_port_name
+from kvmd.validators.switch import valid_switch_edid_id
+from kvmd.validators.switch import valid_switch_edid_data
+from kvmd.validators.switch import valid_switch_color
+from kvmd.validators.switch import valid_switch_atx_click_delay
+
+
+# =====
[email protected]("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
+
+
[email protected]("arg", [None])
+def test_fail__valid_msd_image_name(arg: Any) -> None:
+ with pytest.raises(ValidatorError):
+ valid_switch_port_name(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
+
+
+ "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)
+
+
+# =====
+ "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
+
+
+ "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)
+
+
+# =====
+ "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
+
+
+ "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)
+
+
+# =====
[email protected]("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
+
+
+ "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)
+
+
+# =====
[email protected]("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
+
+
+ "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)
+
+
+# =====
[email protected]("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())
+
+
[email protected]("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 @@
</div>
</li>
</div>
- <li class="right" id="system-dropdown"><a class="menu-button" href="#"><img class="led-gray" id="link-led" src="/share/svg/led-link.svg"><img class="led-gray" id="stream-led" src="/share/svg/led-stream.svg"><img class="led-gray" id="hid-keyboard-led" src="/share/svg/led-hid-keyboard.svg"><img class="led-gray" id="hid-mouse-led" src="/share/svg/led-hid-mouse.svg"><span>System</span></a>
+ <li class="right" id="system-dropdown"><a class="menu-button" href="#"><img class="led-gray" id="link-led" src="/share/svg/led-link.svg"><img class="led-gray" id="stream-led" src="/share/svg/led-video.svg"><img class="led-gray" id="hid-keyboard-led" src="/share/svg/led-hid-keyboard.svg"><img class="led-gray" id="hid-mouse-led" src="/share/svg/led-hid-mouse.svg"><span>System</span></a>
<div class="menu" id="system-menu">
<table class="kv">
<tr>
@@ -792,7 +792,7 @@
<hr>
<div class="buttons">
<div class="buttons-row">
- <button class="row50" data-force-hide-menu data-shortcut="CapsLock">&bull; Caps Lock &nbsp;<img class="inline-lamp hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"></button>
+ <button class="row50" data-force-hide-menu data-shortcut="CapsLock">&bull; Caps Lock &nbsp;<img class="inline-lamp-small hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"></button>
<button class="row50" data-force-hide-menu data-shortcut="MetaLeft">&bull; Left Win</button>
</div>
<hr>
@@ -867,6 +867,36 @@
<li class="right feature-disabled" id="gpio-dropdown"><a class="menu-button" id="gpio-menu-button" href="#"><span>GPIO</span></a>
<div class="menu" id="gpio-menu"></div>
</li>
+ <li class="right feature-disabled" id="switch-dropdown"><a class="menu-button" id="switch-menu-button" href="#"><img class="led-gray" id="switch-atx-power-led" src="/share/svg/led-atx-power.svg"><img class="led-gray" id="switch-atx-hdd-led" src="/share/svg/led-atx-hdd.svg"><span>Switch <i><sub id="switch-active-port"></sub></i></span></a>
+ <div class="menu" id="switch-menu">
+ <table style="border-spacing: 0px;">
+ <tr>
+ <td>
+ <div class="text"><b><a target="_blank" href="https://docs.pikvm.org/switch">PiKVM Switch</a> is attached<br></b><sub>Select a port or perform any available action like ATX click</sub></div>
+ </td>
+ <td>
+ <div class="text">
+ <button class="small" data-force-hide-menu data-show-window="switch-window">&bull; Settings</button>
+ </div>
+ </td>
+ </tr>
+ </table>
+ <hr>
+ <table class="kv">
+ <tr>
+ <td>Ask ATX click confirmation:</td>
+ <td align="right">
+ <div class="switch-box">
+ <input checked type="checkbox" id="switch-atx-ask-switch">
+ <label for="switch-atx-ask-switch"><span class="switch-inner"></span><span class="switch"></span></label>
+ </div>
+ </td>
+ </tr>
+ </table>
+ <hr>
+ <table class="kv" id="switch-chain"></table>
+ </div>
+ </li>
</ul>
<div class="window" id="stream-ocr-window">
<div class="hidden" id="stream-ocr-selection"></div>
@@ -1150,7 +1180,7 @@
</div>
<div class="keypad-row">
<div class="key wide-2 left small" data-code="CapsLock">
- <div class="label"><img class="inline-lamp hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"><br> Caps Lock
+ <div class="label"><img class="inline-lamp-small hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"><br> Caps Lock
</div>
</div>
<div class="spacer"></div>
@@ -1325,7 +1355,7 @@
</div>
<div class="spacer-fixed"></div>
<div class="key small" data-code="ScrollLock">
- <div class="label"><img class="inline-lamp hid-keyboard-scroll-led led-gray" src="/share/svg/led-square.svg"><br> ScrLk
+ <div class="label"><img class="inline-lamp-small hid-keyboard-scroll-led led-gray" src="/share/svg/led-square.svg"><br> ScrLk
</div>
</div>
<div class="spacer-fixed"></div>
@@ -1421,7 +1451,7 @@
<hr>
<div class="keypad-row">
<div class="key small" data-code="NumLock">
- <div class="label"><img class="inline-lamp hid-keyboard-num-led led-gray" src="/share/svg/led-square.svg"><br> NmLk
+ <div class="label"><img class="inline-lamp-small hid-keyboard-num-led led-gray" src="/share/svg/led-square.svg"><br> NmLk
</div>
</div>
<div class="spacer-fixed"></div>
@@ -1627,7 +1657,7 @@
</div>
<div class="spacer"></div>
<div class="key small" data-code="ScrollLock">
- <div class="label"><img class="inline-lamp hid-keyboard-scroll-led led-gray" src="/share/svg/led-square.svg"><br> ScrLk
+ <div class="label"><img class="inline-lamp-small hid-keyboard-scroll-led led-gray" src="/share/svg/led-square.svg"><br> ScrLk
</div>
</div>
<div class="spacer"></div>
@@ -1800,7 +1830,7 @@
</div>
<div class="keypad-row">
<div class="key wide-2 left small" data-code="CapsLock">
- <div class="label"><img class="inline-lamp hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"><br> Caps Lock
+ <div class="label"><img class="inline-lamp-small hid-keyboard-caps-led led-gray" src="/share/svg/led-square.svg"><br> Caps Lock
</div>
</div>
<div class="spacer"></div>
@@ -1999,6 +2029,170 @@
</div>
</div>
</div>
+ <div class="window" id="switch-window" style="width:min-content">
+ <div class="window-header">
+ <div class="window-grab">Switch settings</div>
+ <button class="window-button-close"><b>&times;</b></button>
+ </div>
+ <div class="tabs-box">
+ <input checked type="radio" name="switch-tab-button" id="switch-tab-edid-button">
+ <label for="switch-tab-edid-button">EDIDs collection</label>
+ <div class="tab">
+ <table>
+ <tr>
+ <td colspan="2">
+ <select id="switch-edid-selector" size="8"></select>
+ </td>
+ <td rowspan="2" style="vertical-align:top">
+ <table class="kv">
+ <tr>
+ <td>Manufacturer:</td>
+ <td class="value" id="switch-edid-info-mfc-id"></td>
+ </tr>
+ <tr>
+ <td>Product ID:</td>
+ <td class="value" id="switch-edid-info-product-id"></td>
+ </tr>
+ <tr>
+ <td>Serial:</td>
+ <td class="value" id="switch-edid-info-serial"></td>
+ </tr>
+ <tr>
+ <td>Monitor name:</td>
+ <td class="value" id="switch-edid-info-monitor-name"></td>
+ </tr>
+ <tr>
+ <td>Extra serial:</td>
+ <td class="value" id="switch-edid-info-monitor-serial"></td>
+ </tr>
+ <tr>
+ <td>Audio enabled:</td>
+ <td class="value" id="switch-edid-info-audio"></td>
+ </tr>
+ <tr>
+ <td>Data:</td>
+ <td>
+ <button class="small" disabled id="switch-edid-copy-data-button">Copy</button>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <button id="switch-edid-add-button">Add new</button>
+ </td>
+ <td style="float:right">
+ <button disabled id="switch-edid-remove-button">Remove</button>
+ </td>
+ </tr>
+ </table>
+ </div>
+ <input type="radio" name="switch-tab-button" id="switch-tab-colors-button">
+ <label for="switch-tab-colors-button">Color scheme</label>
+ <div class="tab">
+ <table>
+ <!--tr
+ td Role
+ td Color
+ td Brightness
+ td
+ td Reset
+ -->
+ <!--trtd
+ <hr>
+ td
+ <hr>
+ td
+ <hr>
+ td
+ td
+ <hr>
+ -->
+ <tr>
+ <td style="white-space: nowrap">Selected port:</td>
+ <td>
+ <input type="color" id="switch-color-active-input">
+ </td>
+ <td>
+ <input type="range" id="switch-color-active-brightness-slider" style="min-width:150px">
+ </td>
+ <td>&nbsp;&nbsp;&nbsp;</td>
+ <td>
+ <button class="small" id="switch-color-active-default-button" title="Reset default">&#8635;</button>
+ </td>
+ </tr>
+ <tr>
+ <td style="white-space: nowrap">Inactive port:</td>
+ <td>
+ <input type="color" id="switch-color-inactive-input">
+ </td>
+ <td>
+ <input type="range" id="switch-color-inactive-brightness-slider" style="min-width:150px">
+ </td>
+ <td>&nbsp;&nbsp;&nbsp;</td>
+ <td>
+ <button class="small" id="switch-color-inactive-default-button" title="Reset default">&#8635;</button>
+ </td>
+ </tr>
+ <tr>
+ <td style="white-space: nowrap">Blinking beacon:</td>
+ <td>
+ <input type="color" id="switch-color-beacon-input">
+ </td>
+ <td>
+ <input type="range" id="switch-color-beacon-brightness-slider" style="min-width:150px">
+ </td>
+ <td>&nbsp;&nbsp;&nbsp;</td>
+ <td>
+ <button class="small" id="switch-color-beacon-default-button" title="Reset default">&#8635;</button>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <hr>
+ </td>
+ <td>
+ <hr>
+ </td>
+ <td>
+ <hr>
+ </td>
+ <td></td>
+ <td>
+ <hr>
+ </td>
+ </tr>
+ <tr>
+ <td style="white-space: nowrap">Flashing downlink:</td>
+ <td>
+ <input type="color" id="switch-color-flashing-input">
+ </td>
+ <td>
+ <input type="range" id="switch-color-flashing-brightness-slider" style="min-width:150px">
+ </td>
+ <td>&nbsp;&nbsp;&nbsp;</td>
+ <td>
+ <button class="small" id="switch-color-flashing-default-button" title="Reset default">&#8635;</button>
+ </td>
+ </tr>
+ <tr>
+ <td style="white-space: nowrap">Bootloader mode:</td>
+ <td>
+ <input type="color" id="switch-color-bootloader-input">
+ </td>
+ <td>
+ <input type="range" id="switch-color-bootloader-brightness-slider" style="min-width:150px">
+ </td>
+ <td>&nbsp;&nbsp;&nbsp;</td>
+ <td>
+ <button class="small" id="switch-color-bootloader-default-button" title="Reset default">&#8635;</button>
+ </td>
+ </tr>
+ </table>
+ </div>
+ </div>
+ </div>
<div class="window" id="about-window">
<div class="window-header">
<div class="window-grab">About</div>
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")
| &bull; Caps Lock &nbsp;
- img(class="inline-lamp hid-keyboard-caps-led led-gray" src=`${svg_dir}/led-square.svg`)
+ img(class="inline-lamp-small hid-keyboard-caps-led led-gray" src=`${svg_dir}/led-square.svg`)
button(data-force-hide-menu data-shortcut="MetaLeft" class="row50") &bull; Left Win
hr
div(class="buttons-row")
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") &bull; 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 &times;]
+
+ div(class="tabs-box")
+ +switch_tab("edid", "EDIDs collection", true)
+ table
+ tr
+ td(colspan="2")
+ select(id="switch-edid-selector" size="8")
+ td(rowspan="2" style="vertical-align:top")
+ table(class="kv")
+ tr
+ td Manufacturer:
+ td(id="switch-edid-info-mfc-id" class="value")
+ tr
+ td Product ID:
+ td(id="switch-edid-info-product-id" class="value")
+ tr
+ td Serial:
+ td(id="switch-edid-info-serial" class="value")
+ tr
+ td Monitor name:
+ td(id="switch-edid-info-monitor-name" class="value")
+ tr
+ td Extra serial:
+ td(id="switch-edid-info-monitor-serial" class="value")
+ tr
+ td Audio enabled:
+ td(id="switch-edid-info-audio" class="value")
+ tr
+ td Data:
+ td #[button(disabled id="switch-edid-copy-data-button" class="small") Copy]
+ tr
+ td #[button(id="switch-edid-add-button") Add new]
+ td(style="float:right") #[button(disabled id="switch-edid-remove-button") Remove]
+
+ +switch_tab("colors", "Color scheme")
+ table
+ //tr
+ td Role
+ td Color
+ td Brightness
+ td
+ td Reset
+ //tr
+ td #[hr]
+ td #[hr]
+ td #[hr]
+ td
+ td #[hr]
+ tr
+ td(style="white-space: nowrap") Selected port:
+ td #[input(type="color" id="switch-color-active-input")]
+ td #[input(type="range" id="switch-color-active-brightness-slider" style="min-width:150px")]
+ td &nbsp;&nbsp;&nbsp;
+ td #[button(id="switch-color-active-default-button" class="small" title="Reset default") &#8635;]
+ tr
+ td(style="white-space: nowrap") Inactive port:
+ td #[input(type="color" id="switch-color-inactive-input")]
+ td #[input(type="range" id="switch-color-inactive-brightness-slider" style="min-width:150px")]
+ td &nbsp;&nbsp;&nbsp;
+ td #[button(id="switch-color-inactive-default-button" class="small" title="Reset default") &#8635;]
+ tr
+ td(style="white-space: nowrap") Blinking beacon:
+ td #[input(type="color" id="switch-color-beacon-input")]
+ td #[input(type="range" id="switch-color-beacon-brightness-slider" style="min-width:150px")]
+ td &nbsp;&nbsp;&nbsp;
+ td #[button(id="switch-color-beacon-default-button" class="small" title="Reset default") &#8635;]
+ tr
+ td #[hr]
+ td #[hr]
+ td #[hr]
+ td
+ td #[hr]
+ tr
+ td(style="white-space: nowrap") Flashing downlink:
+ td #[input(type="color" id="switch-color-flashing-input")]
+ td #[input(type="range" id="switch-color-flashing-brightness-slider" style="min-width:150px")]
+ td &nbsp;&nbsp;&nbsp;
+ td #[button(id="switch-color-flashing-default-button" class="small" title="Reset default") &#8635;]
+ tr
+ td(style="white-space: nowrap") Bootloader mode:
+ td #[input(type="color" id="switch-color-bootloader-input")]
+ td #[input(type="range" id="switch-color-bootloader-brightness-slider" style="min-width:150px")]
+ td &nbsp;&nbsp;&nbsp;
+ td #[button(id="switch-color-bootloader-default-button" class="small" title="Reset default") &#8635;]
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 @@
<tr>
<td></td>
<td>
- <button class="key" id="login-button">Login</button>
+ <button class="key" id="login-button" style="width:100%">Login</button>
</td>
</tr>
</table>
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
@@ -89,11 +89,16 @@ 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 <[email protected]> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+*****************************************************************************/
+
+
+"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 = "<i>&lt;Not Available&gt;</i>";
+ $("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 = `
+ <table>
+ <tr>
+ <td>Name:</td>
+ <td><input
+ type="text" autocomplete="off" id="__switch-edid-new-name-input"
+ placeholder="Enter some meaningful name"
+ style="width:100%"
+ /></td>
+ </tr>
+ <tr><td colspan="2">HEX data:</td></tr>
+ <tr>
+ <td colspan="2"><textarea
+ id="__switch-edid-new-data-text" placeholder="Like 0123ABCD..."
+ style="min-width:350px"
+ ></textarea><td>
+ </table>
+ `;
+ 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?<br>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 ? "<tr><td colspan=100><hr></td></tr>" : ""}
+ <tr>
+ <td></td><td></td><td></td>
+ <td class="value">Unit: ${unit + 1}</td>
+ <td></td>
+ <td colspan=100>
+ <div class="buttons-row">
+ <button id="__switch-beacon-button-u${unit}" class="small" title="Toggle uplink Beacon Led">
+ <img id="__switch-beacon-led-u${unit}" class="inline-lamp led-gray" src="/share/svg/led-beacon.svg"/>
+ Uplink
+ </button>
+ <button id="__switch-beacon-button-d${unit}" class="small" title="Toggle downlink Beacon Led">
+ <img id="__switch-beacon-led-d${unit}" class="inline-lamp led-gray" src="/share/svg/led-beacon.svg"/>
+ Downlink
+ </button>
+ </div>
+ </td>
+ </tr>
+ <tr><td colspan=100><hr></td></tr>
+ `;
+ }
+ content += `
+ <tr>
+ <td>Port:</td>
+ <td class="value">${__formatPort(model, port)}</td>
+ <td>&nbsp;&nbsp;</td>
+ <td>
+ <div class="buttons-row">
+ <button id="__switch-port-button-p${port}" title="Activate this port">
+ <img id="__switch-port-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-circle.svg"/>
+ </button>
+ <button id="__switch-params-button-p${port}" title="Configure this port">
+ <img id="__switch-params-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-gear.svg"/>
+ </button>
+ </div>
+ </td>
+ <td>
+ <span
+ id="__switch-custom-edid-p${port}" style="visibility:hidden"
+ title="A non-default EDID is used on this port"
+ >
+ &#9913;
+ </span>
+ &nbsp;&nbsp;&nbsp;&nbsp;
+ ${pa.name.length > 0 ? tools.escape(pa.name) : ("Host " + (port + 1))}
+ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+ </td>
+ <td style="font-size:1em">
+ <button id="__switch-beacon-button-p${port}" class="small" title="Toggle Beacon Led on this port">
+ <img id="__switch-beacon-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-beacon.svg"/>
+ </button>
+ </td>
+ <td>
+ <img id="__switch-video-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-video.svg" title="Video Link"/>
+ <img id="__switch-usb-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-usb.svg" title="USB Link"/>
+ <img id="__switch-atx-power-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-atx-power.svg" title="Power Led"/>
+ <img id="__switch-atx-hdd-led-p${port}" class="inline-lamp led-gray" src="/share/svg/led-atx-hdd.svg" title="HDD Led"/>
+ </td>
+ <td>
+ <div class="buttons-row">
+ <button id="__switch-atx-power-button-p${port}" class="small">Power <sup><i>short</i></sup></button>
+ <button id="__switch-atx-power-long-button-p${port}" class="small"><sup><i>long</i></sup></button>
+ <button id="__switch-atx-reset-button-p${port}" class="small">Reset</button>
+ </div>
+ </td>
+ </tr>
+ `;
+ }
+ $("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 = `
+ <table>
+ <tr>
+ <td>Port name:</td>
+ <td><input
+ type="text" autocomplete="off" id="__switch-port-name-input"
+ value="${tools.escape(model.ports[port].name)}" placeholder="Host ${port + 1}"
+ style="width:100%"
+ /></td>
+ </tr>
+ <tr>
+ <td>EDID:</td>
+ <td><select id="__switch-port-edid-selector" style="width: 100%"></select></td>
+ </tr>
+ </table>
+ <hr>
+ <table>
+ `;
+ for (let kv of Object.entries(atx_actions)) {
+ html += `
+ <tr>
+ <td style="white-space: nowrap">${tools.escape(kv[1])}:</td>
+ <td style="width: 100%"><input type="range" id="__switch-port-atx-click-${kv[0]}-delay-slider"/></td>
+ <td id="__switch-port-atx-click-${kv[0]}-delay-value"></td>
+ <td>&nbsp;&nbsp;&nbsp;</td>
+ <td><button
+ id="__switch-port-atx-click-${kv[0]}-delay-default-button"
+ class="small" title="Reset default"
+ >&#8635;</button></td>
+ </tr>
+ `;
+ }
+ html += "</table>";
+ 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 <b>${button}</b> button?<br>
+ 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 @@
+<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.51472 0.514648C1.34424 2.68513 0 5.6865 0 8.99993C0 12.3134 1.34424 15.3147 3.51472 17.4852L4.92893 16.071C3.11819 14.2603 2 11.7616 2 8.99993C2 6.23823 3.11819 3.7396 4.92893 1.92886L3.51472 0.514648ZM6.34315 3.34308C4.89653 4.7897 4 6.79107 4 8.99993C4 11.2088 4.89653 13.2102 6.34315 14.6568L7.75736 13.2426C6.67048 12.1557 6 10.6571 6 8.99993C6 7.3428 6.67048 5.84417 7.75736 4.75729L6.34315 3.34308ZM12 4.99995C9.79086 4.99995 8 6.79081 8 8.99995C8 10.8638 9.27477 12.4299 11 12.8739V23H13V12.8739C14.7252 12.4299 16 10.8638 16 8.99995C16 6.79081 14.2091 4.99995 12 4.99995ZM10 8.99995C10 7.89538 10.8954 6.99995 12 6.99995C13.1046 6.99995 14 7.89538 14 8.99995C14 10.1045 13.1046 11 12 11C10.8954 11 10 10.1045 10 8.99995ZM17.6568 3.34308C19.1034 4.7897 20 6.79107 20 8.99993C20 11.2088 19.1034 13.2102 17.6568 14.6568L16.2426 13.2426C17.3295 12.1557 18 10.6571 18 8.99993C18 7.3428 17.3295 5.84417 16.2426 4.75729L17.6568 3.34308ZM20.4852 0.514648C22.6557 2.68513 23.9999 5.6865 23.9999 8.99993C23.9999 12.3134 22.6557 15.3147 20.4852 17.4852L19.071 16.071C20.8817 14.2603 21.9999 11.7616 21.9999 8.99993C21.9999 6.23823 20.8817 3.7396 19.071 1.92886L20.4852 0.514648Z" fill="#000000"/>
+</svg> \ 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 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#000000" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 512 512" xml:space="preserve">
+<g>
+ <g>
+ <g>
+ <rect x="191.996" y="68.27" width="17.067" height="17.067"/>
+ <rect x="123.729" y="68.27" width="17.067" height="17.067"/>
+ <path d="M448,0h-34.133c-4.719,0-8.533,3.814-8.533,8.533V409.6c0,28.237-22.972,51.2-51.2,51.2H243.2
+ c-28.237,0-51.2-22.963-51.2-51.2v-17.067c9.412,0,17.067-7.654,17.067-17.067v-34.133h51.2c9.412,0,17.067-7.654,17.067-17.067
+ V153.6c0-9.412-7.654-17.067-17.067-17.067v-128c0-4.719-3.823-8.533-8.533-8.533H81.067c-4.719,0-8.533,3.814-8.533,8.533v128
+ c-9.421,0-17.067,7.654-17.067,17.067v170.667c0,9.412,7.646,17.067,17.067,17.067h51.2v34.133
+ c0,9.412,7.646,17.067,17.067,17.067V409.6c0,56.465,45.935,102.4,102.4,102.4h110.933c56.457,0,102.4-45.935,102.4-102.4V8.533
+ C456.533,3.814,452.71,0,448,0z M174.933,59.733c0-4.719,3.814-8.533,8.533-8.533H217.6c4.71,0,8.533,3.814,8.533,8.533v34.133
+ c0,4.719-3.823,8.533-8.533,8.533h-34.133c-4.719,0-8.533-3.814-8.533-8.533V59.733z M115.2,102.4
+ c-4.719,0-8.533-3.814-8.533-8.533V59.733c0-4.719,3.814-8.533,8.533-8.533h34.133c4.71,0,8.533,3.814,8.533,8.533v34.133
+ c0,4.719-3.823,8.533-8.533,8.533H115.2z M149.333,375.467H140.8v-34.133H192l0.009,34.133h-8.542H149.333z"/>
+ </g>
+ </g>
+</g>
+</svg> \ No newline at end of file
diff --git a/web/share/svg/led-stream.svg b/web/share/svg/led-video.svg
index 8dfbfbe3..8dfbfbe3 100644
--- a/web/share/svg/led-stream.svg
+++ b/web/share/svg/led-video.svg