Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions kvmd/apps/_scheme.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,10 @@ def make_config_scheme() -> dict:
"post_stop_cmd_append": Option([], type=valid_options),
},

"presence": {
"enabled": Option(False, type=valid_bool),
},

"ocr": {
"langs": Option(["eng"], type=valid_string_list, unpack_as="default_langs"),
"tessdata": Option("/usr/share/tessdata", type=valid_stripped_string_not_empty, unpack_as="data_dir_path")
Expand Down
1 change: 1 addition & 0 deletions kvmd/apps/kvmd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,5 @@ def main() -> None:
keymap_path=config.hid.keymap,

stream_forever=config.streamer.forever,
presence_enabled=config.presence.enabled,
).run(**config.server._unpack())
32 changes: 22 additions & 10 deletions kvmd/apps/kvmd/api/hid.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@

from ....plugins.hid import BaseHid

from .. import presence

from ....validators import raise_error
from ....validators.basic import valid_bool
from ....validators.basic import valid_number
Expand Down Expand Up @@ -173,7 +175,7 @@ def __inner_ensure_symmap(self, path: str, mod_ts: int) -> dict[int, dict[int, i
# =====

@exposed_ws(1)
async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None:
async def __ws_bin_key_handler(self, ws: WsSession, data: bytes) -> None:
try:
state = bool(data[0] & 0b01)
finish = bool(data[0] & 0b10)
Expand All @@ -184,9 +186,10 @@ async def __ws_bin_key_handler(self, _: WsSession, data: bytes) -> None:
except Exception:
return
self.__hid.send_key_event(key, state, finish)
presence.record_input(ws.token)

@exposed_ws(2)
async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None:
async def __ws_bin_mouse_button_handler(self, ws: WsSession, data: bytes) -> None:
try:
state = bool(data[0] & 0b01)
if data[0] & 0b10000000:
Expand All @@ -197,24 +200,28 @@ async def __ws_bin_mouse_button_handler(self, _: WsSession, data: bytes) -> None
except Exception:
return
self.__hid.send_mouse_button_event(button, state)
presence.record_input(ws.token)

@exposed_ws(3)
async def __ws_bin_mouse_move_handler(self, _: WsSession, data: bytes) -> None:
async def __ws_bin_mouse_move_handler(self, ws: WsSession, data: bytes) -> None:
try:
(to_x, to_y) = struct.unpack(">hh", data)
to_x = valid_hid_mouse_move(to_x)
to_y = valid_hid_mouse_move(to_y)
except Exception:
return
self.__hid.send_mouse_move_event(to_x, to_y)
presence.record_input(ws.token)

@exposed_ws(4)
async def __ws_bin_mouse_relative_handler(self, _: WsSession, data: bytes) -> None:
async def __ws_bin_mouse_relative_handler(self, ws: WsSession, data: bytes) -> None:
self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_relative_events)
presence.record_input(ws.token)

@exposed_ws(5)
async def __ws_bin_mouse_wheel_handler(self, _: WsSession, data: bytes) -> None:
async def __ws_bin_mouse_wheel_handler(self, ws: WsSession, data: bytes) -> None:
self.__process_ws_bin_delta_request(data, self.__hid.send_mouse_wheel_events)
presence.record_input(ws.token)

def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None:
try:
Expand All @@ -231,40 +238,45 @@ def __process_ws_bin_delta_request(self, data: bytes, handler: Callable[[Iterabl
# =====

@exposed_ws("key")
async def __ws_key_handler(self, _: WsSession, event: dict) -> None:
async def __ws_key_handler(self, ws: WsSession, event: dict) -> None:
try:
key = WEB_TO_EVDEV[valid_hid_key(event["key"])]
state = valid_bool(event["state"])
finish = valid_bool(event.get("finish", False))
except Exception:
return
self.__hid.send_key_event(key, state, finish)
presence.record_input(ws.token)

@exposed_ws("mouse_button")
async def __ws_mouse_button_handler(self, _: WsSession, event: dict) -> None:
async def __ws_mouse_button_handler(self, ws: WsSession, event: dict) -> None:
try:
button = MOUSE_TO_EVDEV[valid_hid_mouse_button(event["button"])]
state = valid_bool(event["state"])
except Exception:
return
self.__hid.send_mouse_button_event(button, state)
presence.record_input(ws.token)

@exposed_ws("mouse_move")
async def __ws_mouse_move_handler(self, _: WsSession, event: dict) -> None:
async def __ws_mouse_move_handler(self, ws: WsSession, event: dict) -> None:
try:
to_x = valid_hid_mouse_move(event["to"]["x"])
to_y = valid_hid_mouse_move(event["to"]["y"])
except Exception:
return
self.__hid.send_mouse_move_event(to_x, to_y)
presence.record_input(ws.token)

@exposed_ws("mouse_relative")
async def __ws_mouse_relative_handler(self, _: WsSession, event: dict) -> None:
async def __ws_mouse_relative_handler(self, ws: WsSession, event: dict) -> None:
self.__process_ws_delta_event(event, self.__hid.send_mouse_relative_events)
presence.record_input(ws.token)

@exposed_ws("mouse_wheel")
async def __ws_mouse_wheel_handler(self, _: WsSession, event: dict) -> None:
async def __ws_mouse_wheel_handler(self, ws: WsSession, event: dict) -> None:
self.__process_ws_delta_event(event, self.__hid.send_mouse_wheel_events)
presence.record_input(ws.token)

def __process_ws_delta_event(self, event: dict, handler: Callable[[Iterable[tuple[int, int]], bool], None]) -> None:
try:
Expand Down
126 changes: 126 additions & 0 deletions kvmd/apps/kvmd/presence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #


"""
User presence awareness registry for PiKVM.

Design:
This module uses module-level state to track which users are connected
(via WebSocket) and which users are actively sending HID input events.
This is safe because kvmd runs as a single-threaded asyncio application;
all calls happen on the same event loop with no concurrent mutations.

Rate-limiting:
record_input() is called from HID handlers on every key/mouse event,
which can reach ~1kHz during mouse drags. To avoid unnecessary overhead,
each call is rate-limited per user: the time.monotonic() syscall and
dict write are skipped if the last recorded timestamp for that user is
less than 0.25 seconds old. On the common (throttled) path, the function
performs only a dict lookup, a subtraction, and a comparison.

Memory bounds:
_last_input entries are auto-pruned when older than 1 hour. Pruning
runs inside get_controllers() and get_active(), which are called every
0.5 seconds by the presence broadcast loop. The _users dict is bounded
by the number of concurrent WebSocket connections.
"""


import time

from ...logging import get_logger


# =====
# token -> username
_users: dict[str, str] = {}

# username -> last input monotonic timestamp
_last_input: dict[str, float] = {}

# username -> last record_input monotonic timestamp (for rate-limiting)
_last_record_ts: dict[str, float] = {}

_RATE_LIMIT_INTERVAL = 0.25 # seconds
_PRUNE_AGE = 3600.0 # 1 hour


# =====
def set_user(token: str, user: str) -> None:
if user:
_users[token] = user
get_logger(0).info("Presence: user %r connected (token=%s...)", user, token[:8])


def unset_user(token: str) -> None:
user = _users.pop(token, None)
if user:
get_logger(0).info("Presence: user %r disconnected (token=%s...)", user, token[:8])
# Clean up input tracking if no other sessions for this user
if user not in _users.values():
_last_input.pop(user, None)
_last_record_ts.pop(user, None)


def record_input(token: str) -> None:
user = _users.get(token)
if not user:
return
now = time.monotonic()
prev = _last_record_ts.get(user, 0.0)
if (now - prev) < _RATE_LIMIT_INTERVAL:
return
_last_record_ts[user] = now
_last_input[user] = now


def get_controllers(window: float=10.0) -> list[str]:
_prune()
now = time.monotonic()
return sorted(
user for (user, ts) in _last_input.items()
if (now - ts) <= window
)


def get_active(idle: float=300.0) -> list[str]:
_prune()
now = time.monotonic()
return sorted(
user for (user, ts) in _last_input.items()
if (now - ts) <= idle
)


def get_connected_users() -> list[str]:
return sorted(set(_users.values()))


def _prune() -> None:
now = time.monotonic()
stale = [
user for (user, ts) in _last_input.items()
if (now - ts) > _PRUNE_AGE
]
for user in stale:
_last_input.pop(user, None)
_last_record_ts.pop(user, None)
46 changes: 46 additions & 0 deletions kvmd/apps/kvmd/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
# ========================================================================== #


import asyncio
import dataclasses

from typing import Callable
Expand Down Expand Up @@ -84,6 +85,8 @@
from .api.redfish.atx import RedfishAtxApi
from .api.redfish.msd import RedfishMsdApi

from . import presence


# =====
class StreamerQualityNotSupported(OperationError):
Expand Down Expand Up @@ -143,6 +146,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
__EV_INFO_STATE = "info"
__EV_SWITCH_STATE = "switch"
__EV_CLIENTS_STATE = "clients" # FIXME
__EV_PRESENCE_STATE = "presence"

def __init__( # pylint: disable=too-many-arguments,too-many-locals
self,
Expand All @@ -164,6 +168,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals
keymap_path: str,

stream_forever: bool,
presence_enabled: bool=False,
) -> None:

super().__init__()
Expand All @@ -174,6 +179,9 @@ def __init__( # pylint: disable=too-many-arguments,too-many-locals
self.__snapshoter = snapshoter # Not a component: No state or cleanup

self.__stream_forever = stream_forever
self.__presence_enabled = presence_enabled
self.__presence_loop_task: (asyncio.Task | None) = None
self.__prev_presence_state: (dict | None) = None

self.__hid_api = HidApi(hid, keymap_path) # Ugly hack to get keymaps state
self.__apis: list[object] = [
Expand Down Expand Up @@ -316,18 +324,56 @@ def _on_ws_added(self, ws: WsSession) -> None:
self.__auth.start_ws_session(ws.token)
self.__hid.clear_events()
self.__streamer_notifier.notify()
if self.__presence_enabled:
user = self.__auth.check(ws.token)
if user:
presence.set_user(ws.token, user)
if self.__presence_loop_task is None:
self.__presence_loop_task = asyncio.ensure_future(self.__presence_loop())
asyncio.ensure_future(self.__broadcast_presence())

def _on_ws_removed(self, ws: WsSession) -> None:
self.__auth.stop_ws_session(ws.token)
self.__hid.clear_events()
self.__streamer_notifier.notify()
if self.__presence_enabled:
presence.unset_user(ws.token)
asyncio.ensure_future(self.__broadcast_presence())

def __get_stream_clients(self) -> int:
return sum(map(
(lambda ws: ws.kwargs["stream"]),
self._get_wss(),
))

# ===== PRESENCE

async def __broadcast_presence(self) -> None:
try:
connected = presence.get_connected_users()
controllers = presence.get_controllers()
active = presence.get_active()
state = {
"connected": connected,
"controllers": controllers,
"active": active,
}
if state != self.__prev_presence_state:
self.__prev_presence_state = state
await self._broadcast_ws_event(self.__EV_PRESENCE_STATE, state)
except Exception:
get_logger(0).exception("Presence broadcast error")

async def __presence_loop(self) -> None:
try:
while True:
await asyncio.sleep(0.5)
await self.__broadcast_presence()
except asyncio.CancelledError:
pass
except Exception:
get_logger(0).exception("Presence loop error")

# ===== SYSTEM TASKS

async def __stream_controller(self) -> None:
Expand Down
10 changes: 10 additions & 0 deletions web/kvm/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
<link rel="stylesheet" href="../share/css/keypad.css">
<link rel="stylesheet" href="../share/css/tabs.css">
<link rel="stylesheet" href="../share/css/kvm/stream.css">
<link rel="stylesheet" href="../share/css/kvm/presence.css">
<link rel="stylesheet" href="../share/css/kvm/msd.css">
<link rel="stylesheet" href="../share/css/kvm/system.css">
<link rel="stylesheet" href="../share/css/kvm/keyboard.css">
Expand Down Expand Up @@ -502,6 +503,15 @@
</div>
</td>
</tr>
<tr>
<td>Show who is watching/controlling:</td>
<td align="right">
<div class="switch-box">
<input checked type="checkbox" id="presence-overlay-switch">
<label for="presence-overlay-switch"><span class="switch-inner"></span><span class="switch"></span></label>
</div>
</td>
</tr>
</table>
</div>
</details>
Expand Down
Loading