diff options
author | Maxim Devaev <[email protected]> | 2024-10-19 08:59:52 +0300 |
---|---|---|
committer | Maxim Devaev <[email protected]> | 2024-10-19 08:59:52 +0300 |
commit | 90d8e745e39b5e49fcb1d50c1efa95148593496b (patch) | |
tree | f098894e16275d8fe80b8cb16bb615ff3a106e5e | |
parent | 3852d0a4568c3726d0182f3b00a9a3e8bb7e8af9 (diff) |
gpio diff events mode
-rw-r--r-- | kvmd/apps/kvmd/api/ugpio.py | 5 | ||||
-rw-r--r-- | kvmd/apps/kvmd/server.py | 49 | ||||
-rw-r--r-- | kvmd/apps/kvmd/ugpio.py | 122 | ||||
-rw-r--r-- | kvmd/htserver.py | 3 | ||||
-rw-r--r-- | web/share/js/kvm/gpio.js | 60 | ||||
-rw-r--r-- | web/share/js/kvm/session.js | 3 |
6 files changed, 155 insertions, 87 deletions
diff --git a/kvmd/apps/kvmd/api/ugpio.py b/kvmd/apps/kvmd/api/ugpio.py index ddaefefa..b2ac02be 100644 --- a/kvmd/apps/kvmd/api/ugpio.py +++ b/kvmd/apps/kvmd/api/ugpio.py @@ -42,10 +42,7 @@ class UserGpioApi: @exposed_http("GET", "/gpio") async def __state_handler(self, _: Request) -> Response: - return make_json_response({ - "model": (await self.__user_gpio.get_model()), - "state": (await self.__user_gpio.get_state()), - }) + return make_json_response(await self.__user_gpio.get_state()) @exposed_http("POST", "/gpio/switch") async def __switch_handler(self, req: Request) -> Response: diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index 366958ce..7e3c7d48 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -100,8 +100,9 @@ class StreamerH264NotSupported(OperationError): # ===== @dataclasses.dataclass(frozen=True) class _SubsystemEventSource: - get_state: (Callable[[], Coroutine[Any, Any, dict]] | None) = None - poll_state: (Callable[[], AsyncGenerator[dict, None]] | None) = None + get_state: (Callable[[], Coroutine[Any, Any, dict]] | None) = None + trigger_state: (Callable[[], Coroutine[Any, Any, None]] | None) = None + poll_state: (Callable[[], AsyncGenerator[dict, None]] | None) = None @dataclasses.dataclass @@ -127,6 +128,7 @@ class _Subsystem: sub.add_source( event_type=event_type, get_state=getattr(obj, "get_state", None), + trigger_state=getattr(obj, "trigger_state", None), poll_state=getattr(obj, "poll_state", None), ) return sub @@ -135,17 +137,20 @@ class _Subsystem: self, event_type: str, get_state: (Callable[[], Coroutine[Any, Any, dict]] | None), + trigger_state: (Callable[[], Coroutine[Any, Any, None]] | None), poll_state: (Callable[[], AsyncGenerator[dict, None]] | None), ) -> "_Subsystem": assert event_type assert event_type not in self.sources, (self, event_type) assert get_state or poll_state, (self, event_type) - self.sources[event_type] = _SubsystemEventSource(get_state, poll_state) + self.sources[event_type] = _SubsystemEventSource(get_state, trigger_state, poll_state) return self class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-instance-attributes + __EV_GPIO_STATE = "gpio_state" + def __init__( # pylint: disable=too-many-arguments,too-many-locals self, auth_manager: AuthManager, @@ -195,11 +200,11 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins self.__subsystems = [ _Subsystem.make(auth_manager, "Auth manager"), - _Subsystem.make(user_gpio, "User-GPIO", "gpio_state").add_source("gpio_model_state", user_gpio.get_model, None), - _Subsystem.make(hid, "HID", "hid_state").add_source("hid_keymaps_state", self.__hid_api.get_keymaps, None), + _Subsystem.make(user_gpio, "User-GPIO", self.__EV_GPIO_STATE), + _Subsystem.make(hid, "HID", "hid_state").add_source("hid_keymaps_state", self.__hid_api.get_keymaps, None, None), _Subsystem.make(atx, "ATX", "atx_state"), _Subsystem.make(msd, "MSD", "msd_state"), - _Subsystem.make(streamer, "Streamer", "streamer_state").add_source("streamer_ocr_state", self.__streamer_api.get_ocr, None), + _Subsystem.make(streamer, "Streamer", "streamer_state").add_source("streamer_ocr_state", self.__streamer_api.get_ocr, None, None), *[ _Subsystem.make(info_manager.get_submanager(sub), f"Info manager ({sub})", f"info_{sub}_state",) for sub in sorted(info_manager.get_subs()) @@ -244,12 +249,13 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins @exposed_http("GET", "/ws") async def __ws_handler(self, req: Request) -> WebSocketResponse: stream = valid_bool(req.query.get("stream", True)) - async with self._ws_session(req, stream=stream) as ws: + legacy = valid_bool(req.query.get("legacy", True)) + async with self._ws_session(req, stream=stream, legacy=legacy) as ws: states = [ (event_type, src.get_state()) for sub in self.__subsystems for (event_type, src) in sub.sources.items() - if src.get_state + if src.get_state and not src.trigger_state ] events = dict(zip( map(operator.itemgetter(0), states), @@ -259,6 +265,10 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins ws.send_event(event_type, events.pop(event_type)) for (event_type, _) in states ]) + for sub in self.__subsystems: + for src in sub.sources.values(): + if src.trigger_state: + await src.trigger_state() await ws.send_event("loop", {}) return (await self._ws_loop(ws)) @@ -347,12 +357,27 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins prev = cur await self.__streamer_notifier.wait() - async def __poll_state(self, event_type: str, poller: AsyncGenerator[dict, None]) -> None: - async for state in poller: - await self._broadcast_ws_event(event_type, state) - async def __stream_snapshoter(self) -> None: await self.__snapshoter.run( is_live=self.__has_stream_clients, notifier=self.__streamer_notifier, ) + + async def __poll_state(self, event_type: str, poller: AsyncGenerator[dict, None]) -> None: + if event_type == self.__EV_GPIO_STATE: + await self.__poll_gpio_state(poller) + else: + async for state in poller: + await self._broadcast_ws_event(event_type, state) + + async def __poll_gpio_state(self, poller: AsyncGenerator[dict, None]) -> None: + prev: dict = {"state": {"inputs": {}, "outputs": {}}} + async for state in poller: + await self._broadcast_ws_event(self.__EV_GPIO_STATE, state, legacy=False) + if "model" in state: # We have only "model"+"state" or "model" event + prev = state + await self._broadcast_ws_event("gpio_model_state", prev["model"], legacy=True) + else: + prev["state"]["inputs"].update(state["state"].get("inputs", {})) + prev["state"]["outputs"].update(state["state"].get("outputs", {})) + await self._broadcast_ws_event(self.__EV_GPIO_STATE, prev["state"], legacy=True) diff --git a/kvmd/apps/kvmd/ugpio.py b/kvmd/apps/kvmd/ugpio.py index 872d5f6c..d893d2a0 100644 --- a/kvmd/apps/kvmd/ugpio.py +++ b/kvmd/apps/kvmd/ugpio.py @@ -21,6 +21,7 @@ import asyncio +import copy from typing import AsyncGenerator from typing import Callable @@ -68,12 +69,12 @@ class GpioChannelIsBusyError(IsBusyError, GpioError): class _GpioInput: def __init__( self, - channel: str, + ch: str, config: Section, driver: BaseUserGpioDriver, ) -> None: - self.__channel = channel + self.__ch = ch self.__pin: str = str(config.pin) self.__inverted: bool = config.inverted @@ -100,7 +101,7 @@ class _GpioInput: } def __str__(self) -> str: - return f"Input({self.__channel}, driver={self.__driver}, pin={self.__pin})" + return f"Input({self.__ch}, driver={self.__driver}, pin={self.__pin})" __repr__ = __str__ @@ -108,13 +109,13 @@ class _GpioInput: class _GpioOutput: # pylint: disable=too-many-instance-attributes def __init__( self, - channel: str, + ch: str, config: Section, driver: BaseUserGpioDriver, notifier: aiotools.AioNotifier, ) -> None: - self.__channel = channel + self.__ch = ch self.__pin: str = str(config.pin) self.__inverted: bool = config.inverted @@ -224,7 +225,7 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes await self.__driver.write(self.__pin, (state ^ self.__inverted)) def __str__(self) -> str: - return f"Output({self.__channel}, driver={self.__driver}, pin={self.__pin})" + return f"Output({self.__ch}, driver={self.__driver}, pin={self.__pin})" __repr__ = __str__ @@ -232,9 +233,8 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes # ===== class UserGpio: def __init__(self, config: Section, otg_config: Section) -> None: - self.__view = config.view - self.__notifier = aiotools.AioNotifier() + self.__full_state_requested = True self.__drivers = { driver: get_ugpio_driver_class(drv_config.type)( @@ -249,45 +249,64 @@ class UserGpio: self.__inputs: dict[str, _GpioInput] = {} self.__outputs: dict[str, _GpioOutput] = {} - for (channel, ch_config) in tools.sorted_kvs(config.scheme): + for (ch, ch_config) in tools.sorted_kvs(config.scheme): driver = self.__drivers[ch_config.driver] if ch_config.mode == UserGpioModes.INPUT: - self.__inputs[channel] = _GpioInput(channel, ch_config, driver) + self.__inputs[ch] = _GpioInput(ch, ch_config, driver) else: # output: - self.__outputs[channel] = _GpioOutput(channel, ch_config, driver, self.__notifier) + self.__outputs[ch] = _GpioOutput(ch, ch_config, driver, self.__notifier) + + self.__scheme = self.__make_scheme() + self.__view = self.__make_view(config.view) - async def get_model(self) -> dict: + async def get_state(self) -> dict: return { - "scheme": { - "inputs": {channel: gin.get_scheme() for (channel, gin) in self.__inputs.items()}, - "outputs": { - channel: gout.get_scheme() - for (channel, gout) in self.__outputs.items() - if not gout.is_const() - }, + "model": { + "scheme": copy.deepcopy(self.__scheme), + "view": copy.deepcopy(self.__view), }, - "view": self.__make_view(), + "state": (await self.__get_io_state()), } - async def get_state(self) -> dict: + async def trigger_state(self) -> None: + self.__full_state_requested = True + self.__notifier.notify() + + async def poll_state(self) -> AsyncGenerator[dict, None]: + prev: dict = {"inputs": {}, "outputs": {}} + while True: # pylint: disable=too-many-nested-blocks + if self.__full_state_requested: + self.__full_state_requested = False + full = await self.get_state() + prev = copy.deepcopy(full["state"]) + yield full + else: + new = await self.__get_io_state() + diff: dict = {} + for sub in ["inputs", "outputs"]: + for ch in new[sub]: + if new[sub][ch] != prev[sub][ch]: + if sub not in diff: + diff[sub] = {} + diff[sub][ch] = new[sub][ch] + if diff: + prev = copy.deepcopy(new) + yield {"state": diff} + await self.__notifier.wait() + + async def __get_io_state(self) -> dict: return { - "inputs": {channel: await gin.get_state() for (channel, gin) in self.__inputs.items()}, + "inputs": { + ch: (await gin.get_state()) + for (ch, gin) in self.__inputs.items() + }, "outputs": { - channel: await gout.get_state() - for (channel, gout) in self.__outputs.items() + ch: (await gout.get_state()) + for (ch, gout) in self.__outputs.items() if not gout.is_const() }, } - async def poll_state(self) -> AsyncGenerator[dict, None]: - prev_state: dict = {} - while True: - state = await self.get_state() - if state != prev_state: - yield state - prev_state = state - await self.__notifier.wait() - def sysprep(self) -> None: get_logger(0).info("Preparing User-GPIO drivers ...") for (_, driver) in tools.sorted_kvs(self.__drivers): @@ -307,28 +326,43 @@ class UserGpio: except Exception: get_logger().exception("Can't cleanup driver %s", driver) - async def switch(self, channel: str, state: bool, wait: bool) -> None: - gout = self.__outputs.get(channel) + async def switch(self, ch: str, state: bool, wait: bool) -> None: + gout = self.__outputs.get(ch) if gout is None: raise GpioChannelNotFoundError() await gout.switch(state, wait) - async def pulse(self, channel: str, delay: float, wait: bool) -> None: - gout = self.__outputs.get(channel) + async def pulse(self, ch: str, delay: float, wait: bool) -> None: + gout = self.__outputs.get(ch) if gout is None: raise GpioChannelNotFoundError() await gout.pulse(delay, wait) # ===== - def __make_view(self) -> dict: + def __make_scheme(self) -> dict: + return { + "inputs": { + ch: gin.get_scheme() + for (ch, gin) in self.__inputs.items() + }, + "outputs": { + ch: gout.get_scheme() + for (ch, gout) in self.__outputs.items() + if not gout.is_const() + }, + } + + # ===== + + def __make_view(self, view: dict) -> dict: return { - "header": {"title": self.__make_view_title()}, - "table": self.__make_view_table(), + "header": {"title": self.__make_view_title(view)}, + "table": self.__make_view_table(view), } - def __make_view_title(self) -> list[dict]: - raw_title = self.__view["header"]["title"] + def __make_view_title(self, view: dict) -> list[dict]: + raw_title = view["header"]["title"] title: list[dict] = [] if isinstance(raw_title, list): for item in raw_title: @@ -342,9 +376,9 @@ class UserGpio: title.append(self.__make_item_label(f"#{raw_title}")) return title - def __make_view_table(self) -> list[list[dict] | None]: + def __make_view_table(self, view: dict) -> list[list[dict] | None]: table: list[list[dict] | None] = [] - for row in self.__view["table"]: + for row in view["table"]: if len(row) == 0: table.append(None) continue diff --git a/kvmd/htserver.py b/kvmd/htserver.py index b0d78e82..351c1328 100644 --- a/kvmd/htserver.py +++ b/kvmd/htserver.py @@ -384,7 +384,7 @@ class HttpServer: break return ws.wsr - async def _broadcast_ws_event(self, event_type: str, event: (dict | None)) -> None: + async def _broadcast_ws_event(self, event_type: str, event: (dict | None), legacy: (bool | None)=None) -> None: if self.__ws_sessions: await asyncio.gather(*[ ws.send_event(event_type, event) @@ -393,6 +393,7 @@ class HttpServer: not ws.wsr.closed and ws.wsr._req is not None # pylint: disable=protected-access and ws.wsr._req.transport is not None # pylint: disable=protected-access + and (legacy is None or ws.kwargs.get("legacy") == legacy) ) ], return_exceptions=True) diff --git a/web/share/js/kvm/gpio.js b/web/share/js/kvm/gpio.js index fea1af32..9e237a54 100644 --- a/web/share/js/kvm/gpio.js +++ b/web/share/js/kvm/gpio.js @@ -32,17 +32,41 @@ export function Gpio(__recorder) { /************************************************************************/ - var __state = null; + var __has_model = false; /************************************************************************/ self.setState = function(state) { if (state) { + if (state.model) { + __applyModel(state.model); + __has_model = true; + } + if (__has_model && state.state) { + __applyState(state.state); + } + } else { + for (let el of $$("__gpio-led")) { + __setLedState(el, false); + } + for (let selector of ["__gpio-switch", "__gpio-button"]) { + for (let el of $$(selector)) { + tools.el.setEnabled(el, false); + } + } + __has_model = false; + } + }; + + var __applyState = function(state) { + if (state.inputs) { for (let ch in state.inputs) { for (let el of $$(`__gpio-led-${ch}`)) { __setLedState(el, state.inputs[ch].state); } } + } + if (state.outputs) { for (let ch in state.outputs) { for (let type of ["switch", "button"]) { for (let el of $$(`__gpio-${type}-${ch}`)) { @@ -53,20 +77,10 @@ export function Gpio(__recorder) { el.checked = state.outputs[ch].state; } } - } else { - for (let el of $$("__gpio-led")) { - __setLedState(el, false); - } - for (let selector of ["__gpio-switch", "__gpio-button"]) { - for (let el of $$(selector)) { - tools.el.setEnabled(el, false); - } - } } - __state = state; }; - self.setModel = function(model) { + var __applyModel = function(model) { tools.feature.setEnabled($("gpio-dropdown"), model.view.table.length); if (model.view.table.length) { let title = []; @@ -81,23 +95,23 @@ export function Gpio(__recorder) { $("gpio-menu-button").innerHTML = title.join(" "); } - let content = "<table class=\"kv\">"; + let html = "<table class=\"kv\">"; for (let row of model.view.table) { if (row === null) { - content += "</table><hr><table class=\"kv\">"; + html += "</table><hr><table class=\"kv\">"; } else { - content += "<tr>"; + html += "<tr>"; for (let item of row) { if (item.type === "output") { item.scheme = model.scheme.outputs[item.channel]; } - content += `<td align="center">${__createItem(item)}</td>`; + html += `<td align="center">${__createItem(item)}</td>`; } - content += "</tr>"; + html += "</tr>"; } } - content += "</table>"; - $("gpio-menu").innerHTML = content; + html += "</table>"; + $("gpio-menu").innerHTML = html; for (let ch in model.scheme.outputs) { for (let el of $$(`__gpio-switch-${ch}`)) { @@ -111,8 +125,6 @@ export function Gpio(__recorder) { tools.feature.setEnabled($("v3-usb-breaker"), ("__v3_usb_breaker__" in model.scheme.outputs)); tools.feature.setEnabled($("v4-locator"), ("__v4_locator__" in model.scheme.outputs)); tools.feature.setEnabled($("system-tool-wol"), ("__wol__" in model.scheme.outputs)); - - self.setState(__state); }; var __createItem = function(item) { @@ -167,9 +179,9 @@ export function Gpio(__recorder) { } }; - var __setLedState = function(el, state) { + var __setLedState = function(el, on) { let color = el.getAttribute("data-color"); - if (state) { + if (on) { el.classList.add(`led-${color}`); el.classList.remove("led-gray"); } else { @@ -221,7 +233,7 @@ export function Gpio(__recorder) { var __sendPost = function(url, params) { tools.httpPost(url, params, function(http) { if (http.status === 409) { - wm.error("Performing another operation for this GPIO channel.<br>Please try again later"); + wm.error("Performing another operation for this GPIO channel.<br>Please try again later."); } else if (http.status !== 200) { wm.error("GPIO error", http.responseText); } diff --git a/web/share/js/kvm/session.js b/web/share/js/kvm/session.js index 8100f50c..353815da 100644 --- a/web/share/js/kvm/session.js +++ b/web/share/js/kvm/session.js @@ -282,7 +282,7 @@ export function Session() { tools.httpGet("/api/auth/check", null, function(http) { if (http.status === 200) { - __ws = new WebSocket(`${tools.is_https ? "wss" : "ws"}://${location.host}/api/ws`); + __ws = new WebSocket(`${tools.is_https ? "wss" : "ws"}://${location.host}/api/ws?legacy=0`); __ws.sendHidEvent = (event) => __sendHidEvent(__ws, event.event_type, event.event); __ws.onopen = __wsOpenHandler; __ws.onmessage = __wsMessageHandler; @@ -359,7 +359,6 @@ export function Session() { case "info_fan_state": __setAboutInfoFan(data.event); break; case "info_system_state": __setAboutInfoSystem(data.event); break; case "info_extras_state": __setExtras(data.event); break; - case "gpio_model_state": __gpio.setModel(data.event); break; case "gpio_state": __gpio.setState(data.event); break; case "hid_keymaps_state": __hid.setKeymaps(data.event); break; case "hid_state": __hid.setState(data.event); break; |