重构更新

This commit is contained in:
mofeng-git
2024-06-18 18:29:24 +08:00
parent fdf58ea6f7
commit 99f2a1b09a
51 changed files with 5125 additions and 1895 deletions

Binary file not shown.

769
patches/__init__.py Normal file
View File

@@ -0,0 +1,769 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2023 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/>. #
# #
# ========================================================================== #
import sys
import os
import functools
import argparse
import logging
import logging.config
import pygments
import pygments.lexers.data
import pygments.formatters
from .. import tools
from ..mouse import MouseRange
from ..plugins import UnknownPluginError
from ..plugins.auth import get_auth_service_class
from ..plugins.hid import get_hid_class
from ..plugins.atx import get_atx_class
from ..plugins.msd import get_msd_class
from ..plugins.ugpio import UserGpioModes
from ..plugins.ugpio import BaseUserGpioDriver
from ..plugins.ugpio import get_ugpio_driver_class
from ..yamlconf import ConfigError
from ..yamlconf import manual_validated
from ..yamlconf import make_config
from ..yamlconf import Section
from ..yamlconf import Option
from ..yamlconf import build_raw_from_options
from ..yamlconf.dumper import make_config_dump
from ..yamlconf.loader import load_yaml_file
from ..yamlconf.merger import yaml_merge
from ..validators.basic import valid_stripped_string
from ..validators.basic import valid_stripped_string_not_empty
from ..validators.basic import valid_bool
from ..validators.basic import valid_number
from ..validators.basic import valid_int_f0
from ..validators.basic import valid_int_f1
from ..validators.basic import valid_float_f0
from ..validators.basic import valid_float_f01
from ..validators.basic import valid_string_list
from ..validators.auth import valid_user
from ..validators.auth import valid_users_list
from ..validators.os import valid_abs_path
from ..validators.os import valid_abs_file
from ..validators.os import valid_abs_dir
from ..validators.os import valid_unix_mode
from ..validators.os import valid_options
from ..validators.os import valid_command
from ..validators.net import valid_ip_or_host
from ..validators.net import valid_net
from ..validators.net import valid_port
from ..validators.net import valid_ports_list
from ..validators.net import valid_mac
from ..validators.net import valid_ssl_ciphers
from ..validators.hid import valid_hid_key
from ..validators.hid import valid_hid_mouse_output
from ..validators.hid import valid_hid_mouse_move
from ..validators.kvm import valid_stream_quality
from ..validators.kvm import valid_stream_fps
from ..validators.kvm import valid_stream_resolution
from ..validators.kvm import valid_stream_h264_bitrate
from ..validators.kvm import valid_stream_h264_gop
from ..validators.ugpio import valid_ugpio_driver
from ..validators.ugpio import valid_ugpio_channel
from ..validators.ugpio import valid_ugpio_mode
from ..validators.ugpio import valid_ugpio_view_title
from ..validators.ugpio import valid_ugpio_view_table
from ..validators.hw import valid_tty_speed
from ..validators.hw import valid_otg_gadget
from ..validators.hw import valid_otg_id
from ..validators.hw import valid_otg_ethernet
# =====
def init(
prog: (str | None)=None,
description: (str | None)=None,
add_help: bool=True,
check_run: bool=False,
cli_logging: bool=False,
argv: (list[str] | None)=None,
**load: bool,
) -> tuple[argparse.ArgumentParser, list[str], Section]:
argv = (argv or sys.argv)
assert len(argv) > 0
parser = argparse.ArgumentParser(
prog=(prog or argv[0]),
description=description,
add_help=add_help,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("-c", "--config", default="/etc/kvmd/main.yaml", type=valid_abs_file,
help="Set config file path", metavar="<file>")
parser.add_argument("-o", "--set-options", default=[], nargs="+",
help="Override config options list (like sec/sub/opt=value)", metavar="<k=v>",)
parser.add_argument("-m", "--dump-config", action="store_true",
help="View current configuration (include all overrides)")
if check_run:
parser.add_argument("--run", dest="run", action="store_true",
help="Run the service")
(options, remaining) = parser.parse_known_args(argv)
if options.dump_config:
_dump_config(_init_config(
config_path=options.config,
override_options=options.set_options,
load_auth=True,
load_hid=True,
load_atx=True,
load_msd=True,
load_gpio=True,
))
raise SystemExit()
config = _init_config(options.config, options.set_options, **load)
logging.captureWarnings(True)
logging.config.dictConfig(config.logging)
if cli_logging:
logging.getLogger().handlers[0].setFormatter(logging.Formatter(
"-- {levelname:>7} -- {message}",
style="{",
))
if check_run and not options.run:
raise SystemExit(
"To prevent accidental startup, you must specify the --run option to start.\n"
"Try the --help option to find out what this service does.\n"
"Make sure you understand exactly what you are doing!"
)
return (parser, remaining, config)
# =====
def _init_config(config_path: str, override_options: list[str], **load_flags: bool) -> Section:
config_path = os.path.expanduser(config_path)
try:
raw_config: dict = load_yaml_file(config_path)
except Exception as err:
raise SystemExit(f"ConfigError: Can't read config file {config_path!r}:\n{tools.efmt(err)}")
if not isinstance(raw_config, dict):
raise SystemExit(f"ConfigError: Top-level of the file {config_path!r} must be a dictionary")
scheme = _get_config_scheme()
try:
yaml_merge(raw_config, (raw_config.pop("override", {}) or {}))
yaml_merge(raw_config, build_raw_from_options(override_options), "raw CLI options")
_patch_raw(raw_config)
config = make_config(raw_config, scheme)
if _patch_dynamic(raw_config, config, scheme, **load_flags):
config = make_config(raw_config, scheme)
return config
except (ConfigError, UnknownPluginError) as err:
raise SystemExit(f"ConfigError: {err}")
def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches
if isinstance(raw_config.get("otg"), dict):
for (old, new) in [
("msd", "msd"),
("acm", "serial"),
("drives", "drives"),
]:
if old in raw_config["otg"]:
if not isinstance(raw_config["otg"].get("devices"), dict):
raw_config["otg"]["devices"] = {}
raw_config["otg"]["devices"][new] = raw_config["otg"].pop(old)
if isinstance(raw_config.get("kvmd"), dict) and isinstance(raw_config["kvmd"].get("wol"), dict):
if not isinstance(raw_config["kvmd"].get("gpio"), dict):
raw_config["kvmd"]["gpio"] = {}
for section in ["drivers", "scheme"]:
if not isinstance(raw_config["kvmd"]["gpio"].get(section), dict):
raw_config["kvmd"]["gpio"][section] = {}
raw_config["kvmd"]["gpio"]["drivers"]["__wol__"] = {
"type": "wol",
**raw_config["kvmd"].pop("wol"),
}
raw_config["kvmd"]["gpio"]["scheme"]["__wol__"] = {
"driver": "__wol__",
"pin": 0,
"mode": "output",
"switch": False,
}
if isinstance(raw_config.get("kvmd"), dict) and isinstance(raw_config["kvmd"].get("streamer"), dict):
streamer_config = raw_config["kvmd"]["streamer"]
desired_fps = streamer_config.get("desired_fps")
if desired_fps is not None and not isinstance(desired_fps, dict):
streamer_config["desired_fps"] = {"default": desired_fps}
max_fps = streamer_config.get("max_fps")
if max_fps is not None:
if not isinstance(streamer_config.get("desired_fps"), dict):
streamer_config["desired_fps"] = {}
streamer_config["desired_fps"]["max"] = max_fps
del streamer_config["max_fps"]
resolution = streamer_config.get("resolution")
if resolution is not None and not isinstance(resolution, dict):
streamer_config["resolution"] = {"default": resolution}
available_resolutions = streamer_config.get("available_resolutions")
if available_resolutions is not None:
if not isinstance(streamer_config.get("resolution"), dict):
streamer_config["resolution"] = {}
streamer_config["resolution"]["available"] = available_resolutions
del streamer_config["available_resolutions"]
def _patch_dynamic( # pylint: disable=too-many-locals
raw_config: dict,
config: Section,
scheme: dict,
load_auth: bool=False,
load_hid: bool=False,
load_atx: bool=False,
load_msd: bool=False,
load_gpio: bool=False,
) -> bool:
rebuild = False
if load_auth:
scheme["kvmd"]["auth"]["internal"].update(get_auth_service_class(config.kvmd.auth.internal.type).get_plugin_options())
if config.kvmd.auth.external.type:
scheme["kvmd"]["auth"]["external"].update(get_auth_service_class(config.kvmd.auth.external.type).get_plugin_options())
rebuild = True
for (load, section, get_class) in [
(load_hid, "hid", get_hid_class),
(load_atx, "atx", get_atx_class),
(load_msd, "msd", get_msd_class),
]:
if load:
scheme["kvmd"][section].update(get_class(getattr(config.kvmd, section).type).get_plugin_options())
rebuild = True
if load_gpio:
driver: str
drivers: dict[str, type[BaseUserGpioDriver]] = {} # Name to drivers
for (driver, params) in { # type: ignore
"__gpio__": {},
**tools.rget(raw_config, "kvmd", "gpio", "drivers"),
}.items():
with manual_validated(driver, "kvmd", "gpio", "drivers", "<key>"):
driver = valid_ugpio_driver(driver)
driver_type = valid_stripped_string_not_empty(params.get("type", "gpio"))
driver_class = get_ugpio_driver_class(driver_type)
drivers[driver] = driver_class
scheme["kvmd"]["gpio"]["drivers"][driver] = {
"type": Option(driver_type, type=valid_stripped_string_not_empty),
**driver_class.get_plugin_options()
}
path = ("kvmd", "gpio", "scheme")
for (channel, params) in tools.rget(raw_config, *path).items():
with manual_validated(channel, *path, "<key>"):
channel = valid_ugpio_channel(channel)
driver = params.get("driver", "__gpio__")
with manual_validated(driver, *path, channel, "driver"):
driver = valid_ugpio_driver(driver, set(drivers))
mode: str = params.get("mode", "")
with manual_validated(mode, *path, channel, "mode"):
mode = valid_ugpio_mode(mode, drivers[driver].get_modes())
if params.get("pulse") == False: # noqa: E712 # pylint: disable=singleton-comparison
params["pulse"] = {"delay": 0}
scheme["kvmd"]["gpio"]["scheme"][channel] = {
"driver": Option("__gpio__", type=functools.partial(valid_ugpio_driver, variants=set(drivers))),
"pin": Option(None, type=drivers[driver].get_pin_validator()),
"mode": Option("", type=functools.partial(valid_ugpio_mode, variants=drivers[driver].get_modes())),
"inverted": Option(False, type=valid_bool),
**({
"busy_delay": Option(0.2, type=valid_float_f01),
"initial": Option(False, type=(lambda arg: (valid_bool(arg) if arg is not None else None))),
"switch": Option(True, type=valid_bool),
"pulse": { # type: ignore
"delay": Option(0.1, type=valid_float_f0),
"min_delay": Option(0.1, type=valid_float_f01),
"max_delay": Option(0.1, type=valid_float_f01),
},
} if mode == UserGpioModes.OUTPUT else { # input
"debounce": Option(0.1, type=valid_float_f0),
})
}
rebuild = True
return rebuild
def _dump_config(config: Section) -> None:
dump = make_config_dump(config)
if sys.stdout.isatty():
dump = pygments.highlight(
dump,
pygments.lexers.data.YamlLexer(),
pygments.formatters.TerminalFormatter(bg="dark"), # pylint: disable=no-member
)
print(dump)
def _get_config_scheme() -> dict:
return {
"logging": Option({}),
"kvmd": {
"server": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"unix_rm": Option(True, type=valid_bool),
"unix_mode": Option(0o660, type=valid_unix_mode),
"heartbeat": Option(15.0, type=valid_float_f01),
"access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---"
" referer='%{Referer}i'; user_agent='%{User-Agent}i'"),
},
"auth": {
"enabled": Option(True, type=valid_bool),
"internal": {
"type": Option("htpasswd"),
"force_users": Option([], type=valid_users_list),
# Dynamic content
},
"external": {
"type": Option("", type=valid_stripped_string),
# Dynamic content
},
"totp": {
"secret": {
"file": Option("/etc/kvmd/totp.secret", type=valid_abs_path, if_empty=""),
},
},
},
"info": { # Accessed via global config, see kvmd/info for details
"meta": Option("/etc/kvmd/meta.yaml", type=valid_abs_file),
"extras": Option("/usr/share/kvmd/extras", type=valid_abs_dir),
"hw": {
"vcgencmd_cmd": Option(["/opt/vc/bin/vcgencmd"], type=valid_command),
"ignore_past": Option(False, type=valid_bool),
"state_poll": Option(10.0, type=valid_float_f01),
},
"fan": {
"daemon": Option("kvmd-fan", type=valid_stripped_string),
"unix": Option("", type=valid_abs_path, if_empty="", unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
"state_poll": Option(5.0, type=valid_float_f01),
},
},
"log_reader": {
"enabled": Option(True, type=valid_bool),
},
"prometheus": {
"auth": {
"enabled": Option(True, type=valid_bool),
},
},
"hid": {
"type": Option("", type=valid_stripped_string_not_empty),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"ignore_keys": Option([], type=functools.partial(valid_string_list, subval=valid_hid_key)),
"mouse_x_range": {
"min": Option(MouseRange.MIN, type=valid_hid_mouse_move),
"max": Option(MouseRange.MAX, type=valid_hid_mouse_move),
},
"mouse_y_range": {
"min": Option(MouseRange.MIN, type=valid_hid_mouse_move),
"max": Option(MouseRange.MAX, type=valid_hid_mouse_move),
},
# Dynamic content
},
"atx": {
"type": Option("", type=valid_stripped_string_not_empty),
# Dynamic content
},
"msd": {
"type": Option("", type=valid_stripped_string_not_empty),
# Dynamic content
},
"streamer": {
"forever": Option(False, type=valid_bool),
"reset_delay": Option(1.0, type=valid_float_f0),
"shutdown_delay": Option(10.0, type=valid_float_f01),
"state_poll": Option(1.0, type=valid_float_f01),
"quality": Option(80, type=valid_stream_quality, if_empty=0),
"resolution": {
"default": Option("", type=valid_stream_resolution, if_empty="", unpack_as="resolution"),
"available": Option(
[],
type=functools.partial(valid_string_list, subval=valid_stream_resolution),
unpack_as="available_resolutions",
),
},
"desired_fps": {
"default": Option(40, type=valid_stream_fps, unpack_as="desired_fps"),
"min": Option(0, type=valid_stream_fps, unpack_as="desired_fps_min"),
"max": Option(70, type=valid_stream_fps, unpack_as="desired_fps_max"),
},
"h264_bitrate": {
"default": Option(0, type=valid_stream_h264_bitrate, if_empty=0, unpack_as="h264_bitrate"),
"min": Option(25, type=valid_stream_h264_bitrate, unpack_as="h264_bitrate_min"),
"max": Option(20000, type=valid_stream_h264_bitrate, unpack_as="h264_bitrate_max"),
},
"h264_gop": {
"default": Option(30, type=valid_stream_h264_gop, unpack_as="h264_gop"),
"min": Option(0, type=valid_stream_h264_gop, unpack_as="h264_gop_min"),
"max": Option(60, type=valid_stream_h264_gop, unpack_as="h264_gop_max"),
},
"unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(2.0, type=valid_float_f01),
"process_name_prefix": Option("kvmd/streamer"),
"cmd": Option(["/bin/true"], type=valid_command),
"cmd_remove": Option([], type=valid_options),
"cmd_append": Option([], type=valid_options),
},
"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")
},
"snapshot": {
"idle_interval": Option(0.0, type=valid_float_f0),
"live_interval": Option(0.0, type=valid_float_f0),
"wakeup_key": Option("", type=valid_hid_key, if_empty=""),
"wakeup_move": Option(0, type=valid_hid_mouse_move),
"online_delay": Option(5.0, type=valid_float_f0),
"retries": Option(10, type=valid_int_f1),
"retries_delay": Option(3.0, type=valid_float_f01),
},
"gpio": {
"state_poll": Option(0.1, type=valid_float_f01),
"drivers": {}, # Dynamic content
"scheme": {}, # Dymanic content
"view": {
"header": {
"title": Option("GPIO", type=valid_ugpio_view_title),
},
"table": Option([], type=valid_ugpio_view_table),
},
},
},
"pst": {
"server": {
"unix": Option("/run/kvmd/pst.sock", type=valid_abs_path, unpack_as="unix_path"),
"unix_rm": Option(True, type=valid_bool),
"unix_mode": Option(0o660, type=valid_unix_mode),
"heartbeat": Option(15.0, type=valid_float_f01),
"access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---"
" referer='%{Referer}i'; user_agent='%{User-Agent}i'"),
},
"ro_retries_delay": Option(10.0, type=valid_float_f01),
"ro_cleanup_delay": Option(3.0, type=valid_float_f01),
"remount_cmd": Option([
"/usr/bin/sudo", "--non-interactive",
"/usr/bin/kvmd-helper-pst-remount", "{mode}",
], type=valid_command),
},
"otg": {
"vendor_id": Option(0x1D6B, type=valid_otg_id), # Linux Foundation
"product_id": Option(0x0104, type=valid_otg_id), # Multifunction Composite Gadget
"manufacturer": Option("PiKVM", type=valid_stripped_string),
"product": Option("Composite KVM Device", type=valid_stripped_string),
"serial": Option("CAFEBABE", type=valid_stripped_string, if_none=None),
"device_version": Option(-1, type=functools.partial(valid_number, min=-1, max=0xFFFF)),
"usb_version": Option(0x0200, type=valid_otg_id),
"max_power": Option(250, type=functools.partial(valid_number, min=50, max=500)),
"remote_wakeup": Option(False, type=valid_bool),
"gadget": Option("kvmd", type=valid_otg_gadget),
"config": Option("PiKVM device", type=valid_stripped_string_not_empty),
"udc": Option("", type=valid_stripped_string),
"init_delay": Option(3.0, type=valid_float_f01),
"user": Option("kvmd", type=valid_user),
"meta": Option("/run/kvmd/otg", type=valid_abs_path),
"devices": {
"hid": {
"keyboard": {
"start": Option(True, type=valid_bool),
},
"mouse": {
"start": Option(True, type=valid_bool),
},
},
"msd": {
"start": Option(True, type=valid_bool),
"default": {
"stall": Option(False, type=valid_bool),
"cdrom": Option(True, type=valid_bool),
"rw": Option(False, type=valid_bool),
"removable": Option(True, type=valid_bool),
"fua": Option(True, type=valid_bool),
},
},
"serial": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
},
"ethernet": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
"driver": Option("ecm", type=valid_otg_ethernet),
"host_mac": Option("", type=valid_mac, if_empty=""),
"kvm_mac": Option("", type=valid_mac, if_empty=""),
},
"drives": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
"count": Option(1, type=valid_int_f1),
"default": {
"stall": Option(False, type=valid_bool),
"cdrom": Option(False, type=valid_bool),
"rw": Option(True, type=valid_bool),
"removable": Option(True, type=valid_bool),
"fua": Option(True, type=valid_bool),
},
},
},
},
"otgnet": {
"iface": {
"net": Option("172.30.30.0/24", type=functools.partial(valid_net, v6=False)),
"ip_cmd": Option(["/usr/bin/ip"], type=valid_command),
},
"firewall": {
"allow_icmp": Option(True, type=valid_bool),
"allow_tcp": Option([], type=valid_ports_list),
"allow_udp": Option([67], type=valid_ports_list),
"forward_iface": Option("", type=valid_stripped_string),
"iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command),
},
"commands": {
"pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command),
"pre_start_cmd_remove": Option([], type=valid_options),
"pre_start_cmd_append": Option([], type=valid_options),
"post_start_cmd": Option([
"/usr/bin/systemd-run",
"--unit=kvmd-otgnet-dnsmasq",
"/usr/sbin/dnsmasq",
"--conf-file=/dev/null",
"--pid-file",
"--user=dnsmasq",
"--interface={iface}",
"--port=0",
"--dhcp-range={dhcp_ip_begin},{dhcp_ip_end},24h",
"--dhcp-leasefile=/run/kvmd/dnsmasq.lease",
"--dhcp-option={dhcp_option_3}",
"--dhcp-option=6",
"--keep-in-foreground",
], type=valid_command),
"post_start_cmd_remove": Option([], type=valid_options),
"post_start_cmd_append": Option([], type=valid_options),
"pre_stop_cmd": Option([
"/usr/bin/systemctl",
"stop",
"kvmd-otgnet-dnsmasq",
], type=valid_command),
"pre_stop_cmd_remove": Option([], type=valid_options),
"pre_stop_cmd_append": Option([], type=valid_options),
"post_stop_cmd": Option(["/bin/true", "post-stop"], type=valid_command),
"post_stop_cmd_remove": Option([], type=valid_options),
"post_stop_cmd_append": Option([], type=valid_options),
},
},
"ipmi": {
"server": {
"host": Option("::", type=valid_ip_or_host),
"port": Option(623, type=valid_port),
"timeout": Option(10.0, type=valid_float_f01),
},
"kvmd": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
"auth": {
"file": Option("/etc/kvmd/ipmipasswd", type=valid_abs_file, unpack_as="path"),
},
"sol": {
"device": Option("", type=valid_abs_path, if_empty="", unpack_as="sol_device_path"),
"speed": Option(115200, type=valid_tty_speed, unpack_as="sol_speed"),
"select_timeout": Option(0.1, type=valid_float_f01, unpack_as="sol_select_timeout"),
"proxy_port": Option(0, type=valid_port, unpack_as="sol_proxy_port"),
},
},
"vnc": {
"desired_fps": Option(30, type=valid_stream_fps),
"mouse_output": Option("usb", type=valid_hid_mouse_output),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"server": {
"host": Option("::", type=valid_ip_or_host),
"port": Option(5900, type=valid_port),
"max_clients": Option(10, type=valid_int_f1),
"no_delay": Option(True, type=valid_bool),
"keepalive": {
"enabled": Option(True, type=valid_bool, unpack_as="keepalive_enabled"),
"idle": Option(10, type=functools.partial(valid_number, min=1, max=3600), unpack_as="keepalive_idle"),
"interval": Option(3, type=functools.partial(valid_number, min=1, max=60), unpack_as="keepalive_interval"),
"count": Option(3, type=functools.partial(valid_number, min=1, max=10), unpack_as="keepalive_count"),
},
"tls": {
"ciphers": Option("ALL:@SECLEVEL=0", type=valid_ssl_ciphers, if_empty=""),
"timeout": Option(30.0, type=valid_float_f01),
"x509": {
"cert": Option("/etc/kvmd/vnc/ssl/server.crt", type=valid_abs_file, if_empty=""),
"key": Option("/etc/kvmd/vnc/ssl/server.key", type=valid_abs_file, if_empty=""),
},
},
},
"kvmd": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
"streamer": {
"unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
"memsink": {
"jpeg": {
"sink": Option("", unpack_as="obj"),
"lock_timeout": Option(1.0, type=valid_float_f01),
"wait_timeout": Option(1.0, type=valid_float_f01),
"drop_same_frames": Option(1.0, type=valid_float_f0),
},
"h264": {
"sink": Option("", unpack_as="obj"),
"lock_timeout": Option(1.0, type=valid_float_f01),
"wait_timeout": Option(1.0, type=valid_float_f01),
"drop_same_frames": Option(0.0, type=valid_float_f0),
},
},
"auth": {
"vncauth": {
"enabled": Option(False, type=valid_bool),
"file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="path"),
},
"vencrypt": {
"enabled": Option(True, type=valid_bool, unpack_as="vencrypt_enabled"),
},
},
},
"janus": {
"stun": {
"host": Option("stun.l.google.com", type=valid_ip_or_host, unpack_as="stun_host"),
"port": Option(19302, type=valid_port, unpack_as="stun_port"),
"timeout": Option(5.0, type=valid_float_f01, unpack_as="stun_timeout"),
"retries": Option(5, type=valid_int_f1, unpack_as="stun_retries"),
"retries_delay": Option(5.0, type=valid_float_f01, unpack_as="stun_retries_delay"),
},
"check": {
"interval": Option(10.0, type=valid_float_f01, unpack_as="check_interval"),
"retries": Option(5, type=valid_int_f1, unpack_as="check_retries"),
"retries_delay": Option(5.0, type=valid_float_f01, unpack_as="check_retries_delay"),
},
"cmd": Option([
"/usr/bin/janus",
"--disable-colors",
"--plugins-folder=/usr/lib/ustreamer/janus",
"--configs-folder=/etc/kvmd/janus",
"--interface={src_ip}",
"{o_stun_server}",
], type=valid_command),
"cmd_remove": Option([], type=valid_options),
"cmd_append": Option([], type=valid_options),
},
"watchdog": {
"rtc": Option(0, type=valid_int_f0),
"timeout": Option(300, type=valid_int_f1),
"interval": Option(30, type=valid_int_f1),
},
}

777
patches/__init__.py.2 Normal file
View File

@@ -0,0 +1,777 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2023 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/>. #
# #
# ========================================================================== #
import sys
import os
import functools
import argparse
import logging
import logging.config
import pygments
import pygments.lexers.data
import pygments.formatters
from .. import tools
from ..mouse import MouseRange
from ..plugins import UnknownPluginError
from ..plugins.auth import get_auth_service_class
from ..plugins.hid import get_hid_class
from ..plugins.atx import get_atx_class
from ..plugins.msd import get_msd_class
from ..plugins.ugpio import UserGpioModes
from ..plugins.ugpio import BaseUserGpioDriver
from ..plugins.ugpio import get_ugpio_driver_class
from ..yamlconf import ConfigError
from ..yamlconf import manual_validated
from ..yamlconf import make_config
from ..yamlconf import Section
from ..yamlconf import Option
from ..yamlconf import build_raw_from_options
from ..yamlconf.dumper import make_config_dump
from ..yamlconf.loader import load_yaml_file
from ..yamlconf.merger import yaml_merge
from ..validators.basic import valid_stripped_string
from ..validators.basic import valid_stripped_string_not_empty
from ..validators.basic import valid_bool
from ..validators.basic import valid_number
from ..validators.basic import valid_int_f0
from ..validators.basic import valid_int_f1
from ..validators.basic import valid_float_f0
from ..validators.basic import valid_float_f01
from ..validators.basic import valid_string_list
from ..validators.auth import valid_user
from ..validators.auth import valid_users_list
from ..validators.os import valid_abs_path
from ..validators.os import valid_abs_file
from ..validators.os import valid_abs_dir
from ..validators.os import valid_unix_mode
from ..validators.os import valid_options
from ..validators.os import valid_command
from ..validators.net import valid_ip_or_host
from ..validators.net import valid_net
from ..validators.net import valid_port
from ..validators.net import valid_ports_list
from ..validators.net import valid_mac
from ..validators.net import valid_ssl_ciphers
from ..validators.hid import valid_hid_key
from ..validators.hid import valid_hid_mouse_output
from ..validators.hid import valid_hid_mouse_move
from ..validators.kvm import valid_stream_quality
from ..validators.kvm import valid_stream_fps
from ..validators.kvm import valid_stream_resolution
from ..validators.kvm import valid_stream_h264_bitrate
from ..validators.kvm import valid_stream_h264_gop
from ..validators.ugpio import valid_ugpio_driver
from ..validators.ugpio import valid_ugpio_channel
from ..validators.ugpio import valid_ugpio_mode
from ..validators.ugpio import valid_ugpio_view_title
from ..validators.ugpio import valid_ugpio_view_table
from ..validators.hw import valid_tty_speed
from ..validators.hw import valid_otg_gadget
from ..validators.hw import valid_otg_id
from ..validators.hw import valid_otg_ethernet
# =====
def init(
prog: (str | None)=None,
description: (str | None)=None,
add_help: bool=True,
check_run: bool=False,
cli_logging: bool=False,
argv: (list[str] | None)=None,
**load: bool,
) -> tuple[argparse.ArgumentParser, list[str], Section]:
argv = (argv or sys.argv)
assert len(argv) > 0
parser = argparse.ArgumentParser(
prog=(prog or argv[0]),
description=description,
add_help=add_help,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("-c", "--config", default="/etc/kvmd/main.yaml", type=valid_abs_file,
help="Set config file path", metavar="<file>")
parser.add_argument("-o", "--set-options", default=[], nargs="+",
help="Override config options list (like sec/sub/opt=value)", metavar="<k=v>",)
parser.add_argument("-m", "--dump-config", action="store_true",
help="View current configuration (include all overrides)")
if check_run:
parser.add_argument("--run", dest="run", action="store_true",
help="Run the service")
(options, remaining) = parser.parse_known_args(argv)
if options.dump_config:
_dump_config(_init_config(
config_path=options.config,
override_options=options.set_options,
load_auth=True,
load_hid=True,
load_atx=True,
load_msd=True,
load_gpio=True,
))
raise SystemExit()
config = _init_config(options.config, options.set_options, **load)
logging.captureWarnings(True)
logging.config.dictConfig(config.logging)
if cli_logging:
logging.getLogger().handlers[0].setFormatter(logging.Formatter(
"-- {levelname:>7} -- {message}",
style="{",
))
if check_run and not options.run:
raise SystemExit(
"To prevent accidental startup, you must specify the --run option to start.\n"
"Try the --help option to find out what this service does.\n"
"Make sure you understand exactly what you are doing!"
)
return (parser, remaining, config)
# =====
def _init_config(config_path: str, override_options: list[str], **load_flags: bool) -> Section:
config_path = os.path.expanduser(config_path)
try:
raw_config: dict = load_yaml_file(config_path)
except Exception as err:
raise SystemExit(f"ConfigError: Can't read config file {config_path!r}:\n{tools.efmt(err)}")
if not isinstance(raw_config, dict):
raise SystemExit(f"ConfigError: Top-level of the file {config_path!r} must be a dictionary")
scheme = _get_config_scheme()
try:
yaml_merge(raw_config, (raw_config.pop("override", {}) or {}))
yaml_merge(raw_config, build_raw_from_options(override_options), "raw CLI options")
_patch_raw(raw_config)
config = make_config(raw_config, scheme)
if _patch_dynamic(raw_config, config, scheme, **load_flags):
config = make_config(raw_config, scheme)
return config
except (ConfigError, UnknownPluginError) as err:
raise SystemExit(f"ConfigError: {err}")
def _patch_raw(raw_config: dict) -> None: # pylint: disable=too-many-branches
if isinstance(raw_config.get("otg"), dict):
for (old, new) in [
("msd", "msd"),
("acm", "serial"),
("drives", "drives"),
]:
if old in raw_config["otg"]:
if not isinstance(raw_config["otg"].get("devices"), dict):
raw_config["otg"]["devices"] = {}
raw_config["otg"]["devices"][new] = raw_config["otg"].pop(old)
if isinstance(raw_config.get("kvmd"), dict) and isinstance(raw_config["kvmd"].get("wol"), dict):
if not isinstance(raw_config["kvmd"].get("gpio"), dict):
raw_config["kvmd"]["gpio"] = {}
for section in ["drivers", "scheme"]:
if not isinstance(raw_config["kvmd"]["gpio"].get(section), dict):
raw_config["kvmd"]["gpio"][section] = {}
raw_config["kvmd"]["gpio"]["drivers"]["__wol__"] = {
"type": "wol",
**raw_config["kvmd"].pop("wol"),
}
raw_config["kvmd"]["gpio"]["scheme"]["__wol__"] = {
"driver": "__wol__",
"pin": 0,
"mode": "output",
"switch": False,
}
if isinstance(raw_config.get("kvmd"), dict) and isinstance(raw_config["kvmd"].get("streamer"), dict):
streamer_config = raw_config["kvmd"]["streamer"]
desired_fps = streamer_config.get("desired_fps")
if desired_fps is not None and not isinstance(desired_fps, dict):
streamer_config["desired_fps"] = {"default": desired_fps}
max_fps = streamer_config.get("max_fps")
if max_fps is not None:
if not isinstance(streamer_config.get("desired_fps"), dict):
streamer_config["desired_fps"] = {}
streamer_config["desired_fps"]["max"] = max_fps
del streamer_config["max_fps"]
resolution = streamer_config.get("resolution")
if resolution is not None and not isinstance(resolution, dict):
streamer_config["resolution"] = {"default": resolution}
available_resolutions = streamer_config.get("available_resolutions")
if available_resolutions is not None:
if not isinstance(streamer_config.get("resolution"), dict):
streamer_config["resolution"] = {}
streamer_config["resolution"]["available"] = available_resolutions
del streamer_config["available_resolutions"]
def _patch_dynamic( # pylint: disable=too-many-locals
raw_config: dict,
config: Section,
scheme: dict,
load_auth: bool=False,
load_hid: bool=False,
load_atx: bool=False,
load_msd: bool=False,
load_gpio: bool=False,
) -> bool:
rebuild = False
if load_auth:
scheme["kvmd"]["auth"]["internal"].update(get_auth_service_class(config.kvmd.auth.internal.type).get_plugin_options())
if config.kvmd.auth.external.type:
scheme["kvmd"]["auth"]["external"].update(get_auth_service_class(config.kvmd.auth.external.type).get_plugin_options())
rebuild = True
for (load, section, get_class) in [
(load_hid, "hid", get_hid_class),
(load_atx, "atx", get_atx_class),
(load_msd, "msd", get_msd_class),
]:
if load:
scheme["kvmd"][section].update(get_class(getattr(config.kvmd, section).type).get_plugin_options())
rebuild = True
if load_gpio:
driver: str
drivers: dict[str, type[BaseUserGpioDriver]] = {} # Name to drivers
for (driver, params) in { # type: ignore
"__gpio__": {},
**tools.rget(raw_config, "kvmd", "gpio", "drivers"),
}.items():
with manual_validated(driver, "kvmd", "gpio", "drivers", "<key>"):
driver = valid_ugpio_driver(driver)
driver_type = valid_stripped_string_not_empty(params.get("type", "gpio"))
driver_class = get_ugpio_driver_class(driver_type)
drivers[driver] = driver_class
scheme["kvmd"]["gpio"]["drivers"][driver] = {
"type": Option(driver_type, type=valid_stripped_string_not_empty),
**driver_class.get_plugin_options()
}
path = ("kvmd", "gpio", "scheme")
for (channel, params) in tools.rget(raw_config, *path).items():
with manual_validated(channel, *path, "<key>"):
channel = valid_ugpio_channel(channel)
driver = params.get("driver", "__gpio__")
with manual_validated(driver, *path, channel, "driver"):
driver = valid_ugpio_driver(driver, set(drivers))
mode: str = params.get("mode", "")
with manual_validated(mode, *path, channel, "mode"):
mode = valid_ugpio_mode(mode, drivers[driver].get_modes())
if params.get("pulse") == False: # noqa: E712 # pylint: disable=singleton-comparison
params["pulse"] = {"delay": 0}
scheme["kvmd"]["gpio"]["scheme"][channel] = {
"driver": Option("__gpio__", type=functools.partial(valid_ugpio_driver, variants=set(drivers))),
"pin": Option(None, type=drivers[driver].get_pin_validator()),
"mode": Option("", type=functools.partial(valid_ugpio_mode, variants=drivers[driver].get_modes())),
"inverted": Option(False, type=valid_bool),
**({
"busy_delay": Option(0.2, type=valid_float_f01),
"initial": Option(False, type=(lambda arg: (valid_bool(arg) if arg is not None else None))),
"switch": Option(True, type=valid_bool),
"pulse": { # type: ignore
"delay": Option(0.1, type=valid_float_f0),
"min_delay": Option(0.1, type=valid_float_f01),
"max_delay": Option(0.1, type=valid_float_f01),
},
} if mode == UserGpioModes.OUTPUT else { # input
"debounce": Option(0.1, type=valid_float_f0),
})
}
rebuild = True
return rebuild
def _dump_config(config: Section) -> None:
dump = make_config_dump(config)
if sys.stdout.isatty():
dump = pygments.highlight(
dump,
pygments.lexers.data.YamlLexer(),
pygments.formatters.TerminalFormatter(bg="dark"), # pylint: disable=no-member
)
print(dump)
def _get_config_scheme() -> dict:
return {
"logging": Option({}),
"kvmd": {
"server": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"unix_rm": Option(True, type=valid_bool),
"unix_mode": Option(0o660, type=valid_unix_mode),
"heartbeat": Option(15.0, type=valid_float_f01),
"access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---"
" referer='%{Referer}i'; user_agent='%{User-Agent}i'"),
},
"auth": {
"enabled": Option(True, type=valid_bool),
"internal": {
"type": Option("htpasswd"),
"force_users": Option([], type=valid_users_list),
# Dynamic content
},
"external": {
"type": Option("", type=valid_stripped_string),
# Dynamic content
},
"totp": {
"secret": {
"file": Option("/etc/kvmd/totp.secret", type=valid_abs_path, if_empty=""),
},
},
},
"info": { # Accessed via global config, see kvmd/info for details
"meta": Option("/etc/kvmd/meta.yaml", type=valid_abs_file),
"extras": Option("/usr/share/kvmd/extras", type=valid_abs_dir),
"hw": {
"vcgencmd_cmd": Option(["/usr/bin/vcgencmd"], type=valid_command),
"ignore_past": Option(False, type=valid_bool),
"state_poll": Option(10.0, type=valid_float_f01),
},
"fan": {
"daemon": Option("kvmd-fan", type=valid_stripped_string),
"unix": Option("", type=valid_abs_path, if_empty="", unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
"state_poll": Option(5.0, type=valid_float_f01),
},
},
"log_reader": {
"enabled": Option(True, type=valid_bool),
},
"prometheus": {
"auth": {
"enabled": Option(True, type=valid_bool),
},
},
"hid": {
"type": Option("", type=valid_stripped_string_not_empty),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"ignore_keys": Option([], type=functools.partial(valid_string_list, subval=valid_hid_key)),
"mouse_x_range": {
"min": Option(MouseRange.MIN, type=valid_hid_mouse_move),
"max": Option(MouseRange.MAX, type=valid_hid_mouse_move),
},
"mouse_y_range": {
"min": Option(MouseRange.MIN, type=valid_hid_mouse_move),
"max": Option(MouseRange.MAX, type=valid_hid_mouse_move),
},
# Dynamic content
},
"atx": {
"type": Option("", type=valid_stripped_string_not_empty),
# Dynamic content
},
"msd": {
"type": Option("", type=valid_stripped_string_not_empty),
# Dynamic content
},
"streamer": {
"forever": Option(False, type=valid_bool),
"reset_delay": Option(1.0, type=valid_float_f0),
"shutdown_delay": Option(10.0, type=valid_float_f01),
"state_poll": Option(1.0, type=valid_float_f01),
"quality": Option(80, type=valid_stream_quality, if_empty=0),
"resolution": {
"default": Option("", type=valid_stream_resolution, if_empty="", unpack_as="resolution"),
"available": Option(
[],
type=functools.partial(valid_string_list, subval=valid_stream_resolution),
unpack_as="available_resolutions",
),
},
"desired_fps": {
"default": Option(40, type=valid_stream_fps, unpack_as="desired_fps"),
"min": Option(0, type=valid_stream_fps, unpack_as="desired_fps_min"),
"max": Option(70, type=valid_stream_fps, unpack_as="desired_fps_max"),
},
"h264_bitrate": {
"default": Option(0, type=valid_stream_h264_bitrate, if_empty=0, unpack_as="h264_bitrate"),
"min": Option(25, type=valid_stream_h264_bitrate, unpack_as="h264_bitrate_min"),
"max": Option(20000, type=valid_stream_h264_bitrate, unpack_as="h264_bitrate_max"),
},
"h264_gop": {
"default": Option(30, type=valid_stream_h264_gop, unpack_as="h264_gop"),
"min": Option(0, type=valid_stream_h264_gop, unpack_as="h264_gop_min"),
"max": Option(60, type=valid_stream_h264_gop, unpack_as="h264_gop_max"),
},
"unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(2.0, type=valid_float_f01),
"process_name_prefix": Option("kvmd/streamer"),
"pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command),
"pre_start_cmd_remove": Option([], type=valid_options),
"pre_start_cmd_append": Option([], type=valid_options),
"cmd": Option(["/bin/true"], type=valid_command),
"cmd_remove": Option([], type=valid_options),
"cmd_append": Option([], type=valid_options),
"post_stop_cmd": Option(["/bin/true", "post-stop"], type=valid_command),
"post_stop_cmd_remove": Option([], type=valid_options),
"post_stop_cmd_append": Option([], type=valid_options),
},
"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")
},
"snapshot": {
"idle_interval": Option(0.0, type=valid_float_f0),
"live_interval": Option(0.0, type=valid_float_f0),
"wakeup_key": Option("", type=valid_hid_key, if_empty=""),
"wakeup_move": Option(0, type=valid_hid_mouse_move),
"online_delay": Option(5.0, type=valid_float_f0),
"retries": Option(10, type=valid_int_f1),
"retries_delay": Option(3.0, type=valid_float_f01),
},
"gpio": {
"state_poll": Option(0.1, type=valid_float_f01),
"drivers": {}, # Dynamic content
"scheme": {}, # Dymanic content
"view": {
"header": {
"title": Option("GPIO", type=valid_ugpio_view_title),
},
"table": Option([], type=valid_ugpio_view_table),
},
},
},
"pst": {
"server": {
"unix": Option("/run/kvmd/pst.sock", type=valid_abs_path, unpack_as="unix_path"),
"unix_rm": Option(True, type=valid_bool),
"unix_mode": Option(0o660, type=valid_unix_mode),
"heartbeat": Option(15.0, type=valid_float_f01),
"access_log_format": Option("[%P / %{X-Real-IP}i] '%r' => %s; size=%b ---"
" referer='%{Referer}i'; user_agent='%{User-Agent}i'"),
},
"ro_retries_delay": Option(10.0, type=valid_float_f01),
"ro_cleanup_delay": Option(3.0, type=valid_float_f01),
"remount_cmd": Option([
"/usr/bin/sudo", "--non-interactive",
"/usr/bin/kvmd-helper-pst-remount", "{mode}",
], type=valid_command),
},
"otg": {
"vendor_id": Option(0x1D6B, type=valid_otg_id), # Linux Foundation
"product_id": Option(0x0104, type=valid_otg_id), # Multifunction Composite Gadget
"manufacturer": Option("PiKVM", type=valid_stripped_string),
"product": Option("Composite KVM Device", type=valid_stripped_string),
"serial": Option("CAFEBABE", type=valid_stripped_string, if_none=None),
"device_version": Option(-1, type=functools.partial(valid_number, min=-1, max=0xFFFF)),
"usb_version": Option(0x0200, type=valid_otg_id),
"max_power": Option(250, type=functools.partial(valid_number, min=50, max=500)),
"remote_wakeup": Option(False, type=valid_bool),
"gadget": Option("kvmd", type=valid_otg_gadget),
"config": Option("PiKVM device", type=valid_stripped_string_not_empty),
"udc": Option("", type=valid_stripped_string),
"init_delay": Option(3.0, type=valid_float_f01),
"user": Option("kvmd", type=valid_user),
"meta": Option("/run/kvmd/otg", type=valid_abs_path),
"devices": {
"hid": {
"keyboard": {
"start": Option(True, type=valid_bool),
},
"mouse": {
"start": Option(True, type=valid_bool),
},
},
"msd": {
"start": Option(True, type=valid_bool),
"default": {
"stall": Option(False, type=valid_bool),
"cdrom": Option(True, type=valid_bool),
"rw": Option(False, type=valid_bool),
"removable": Option(True, type=valid_bool),
"fua": Option(True, type=valid_bool),
},
},
"serial": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
},
"ethernet": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
"driver": Option("ecm", type=valid_otg_ethernet),
"host_mac": Option("", type=valid_mac, if_empty=""),
"kvm_mac": Option("", type=valid_mac, if_empty=""),
},
"drives": {
"enabled": Option(False, type=valid_bool),
"start": Option(True, type=valid_bool),
"count": Option(1, type=valid_int_f1),
"default": {
"stall": Option(False, type=valid_bool),
"cdrom": Option(False, type=valid_bool),
"rw": Option(True, type=valid_bool),
"removable": Option(True, type=valid_bool),
"fua": Option(True, type=valid_bool),
},
},
},
},
"otgnet": {
"iface": {
"net": Option("172.30.30.0/24", type=functools.partial(valid_net, v6=False)),
"ip_cmd": Option(["/usr/bin/ip"], type=valid_command),
},
"firewall": {
"allow_icmp": Option(True, type=valid_bool),
"allow_tcp": Option([], type=valid_ports_list),
"allow_udp": Option([67], type=valid_ports_list),
"forward_iface": Option("", type=valid_stripped_string),
"iptables_cmd": Option(["/usr/sbin/iptables", "--wait=5"], type=valid_command),
},
"commands": {
"pre_start_cmd": Option(["/bin/true", "pre-start"], type=valid_command),
"pre_start_cmd_remove": Option([], type=valid_options),
"pre_start_cmd_append": Option([], type=valid_options),
"post_start_cmd": Option([
"/usr/bin/systemd-run",
"--unit=kvmd-otgnet-dnsmasq",
"/usr/sbin/dnsmasq",
"--conf-file=/dev/null",
"--pid-file",
"--user=dnsmasq",
"--interface={iface}",
"--port=0",
"--dhcp-range={dhcp_ip_begin},{dhcp_ip_end},24h",
"--dhcp-leasefile=/run/kvmd/dnsmasq.lease",
"--dhcp-option={dhcp_option_3}",
"--dhcp-option=6",
"--keep-in-foreground",
], type=valid_command),
"post_start_cmd_remove": Option([], type=valid_options),
"post_start_cmd_append": Option([], type=valid_options),
"pre_stop_cmd": Option([
"/usr/bin/systemctl",
"stop",
"kvmd-otgnet-dnsmasq",
], type=valid_command),
"pre_stop_cmd_remove": Option([], type=valid_options),
"pre_stop_cmd_append": Option([], type=valid_options),
"post_stop_cmd": Option(["/bin/true", "post-stop"], type=valid_command),
"post_stop_cmd_remove": Option([], type=valid_options),
"post_stop_cmd_append": Option([], type=valid_options),
},
},
"ipmi": {
"server": {
"host": Option("::", type=valid_ip_or_host),
"port": Option(623, type=valid_port),
"timeout": Option(10.0, type=valid_float_f01),
},
"kvmd": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
"auth": {
"file": Option("/etc/kvmd/ipmipasswd", type=valid_abs_file, unpack_as="path"),
},
"sol": {
"device": Option("", type=valid_abs_path, if_empty="", unpack_as="sol_device_path"),
"speed": Option(115200, type=valid_tty_speed, unpack_as="sol_speed"),
"select_timeout": Option(0.1, type=valid_float_f01, unpack_as="sol_select_timeout"),
"proxy_port": Option(0, type=valid_port, unpack_as="sol_proxy_port"),
},
},
"vnc": {
"desired_fps": Option(30, type=valid_stream_fps),
"mouse_output": Option("usb", type=valid_hid_mouse_output),
"keymap": Option("/usr/share/kvmd/keymaps/en-us", type=valid_abs_file),
"server": {
"host": Option("::", type=valid_ip_or_host),
"port": Option(5900, type=valid_port),
"max_clients": Option(10, type=valid_int_f1),
"no_delay": Option(True, type=valid_bool),
"keepalive": {
"enabled": Option(True, type=valid_bool, unpack_as="keepalive_enabled"),
"idle": Option(10, type=functools.partial(valid_number, min=1, max=3600), unpack_as="keepalive_idle"),
"interval": Option(3, type=functools.partial(valid_number, min=1, max=60), unpack_as="keepalive_interval"),
"count": Option(3, type=functools.partial(valid_number, min=1, max=10), unpack_as="keepalive_count"),
},
"tls": {
"ciphers": Option("ALL:@SECLEVEL=0", type=valid_ssl_ciphers, if_empty=""),
"timeout": Option(30.0, type=valid_float_f01),
"x509": {
"cert": Option("/etc/kvmd/vnc/ssl/server.crt", type=valid_abs_file, if_empty=""),
"key": Option("/etc/kvmd/vnc/ssl/server.key", type=valid_abs_file, if_empty=""),
},
},
},
"kvmd": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
"streamer": {
"unix": Option("/run/kvmd/ustreamer.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
"memsink": {
"jpeg": {
"sink": Option("", unpack_as="obj"),
"lock_timeout": Option(1.0, type=valid_float_f01),
"wait_timeout": Option(1.0, type=valid_float_f01),
"drop_same_frames": Option(1.0, type=valid_float_f0),
},
"h264": {
"sink": Option("", unpack_as="obj"),
"lock_timeout": Option(1.0, type=valid_float_f01),
"wait_timeout": Option(1.0, type=valid_float_f01),
"drop_same_frames": Option(0.0, type=valid_float_f0),
},
},
"auth": {
"vncauth": {
"enabled": Option(False, type=valid_bool),
"file": Option("/etc/kvmd/vncpasswd", type=valid_abs_file, unpack_as="path"),
},
"vencrypt": {
"enabled": Option(True, type=valid_bool, unpack_as="vencrypt_enabled"),
},
},
},
"janus": {
"stun": {
"host": Option("stun.l.google.com", type=valid_ip_or_host, unpack_as="stun_host"),
"port": Option(19302, type=valid_port, unpack_as="stun_port"),
"timeout": Option(5.0, type=valid_float_f01, unpack_as="stun_timeout"),
"retries": Option(5, type=valid_int_f1, unpack_as="stun_retries"),
"retries_delay": Option(5.0, type=valid_float_f01, unpack_as="stun_retries_delay"),
},
"check": {
"interval": Option(10.0, type=valid_float_f01, unpack_as="check_interval"),
"retries": Option(5, type=valid_int_f1, unpack_as="check_retries"),
"retries_delay": Option(5.0, type=valid_float_f01, unpack_as="check_retries_delay"),
},
"cmd": Option([
"/usr/bin/janus",
"--disable-colors",
"--plugins-folder=/usr/lib/ustreamer/janus",
"--configs-folder=/etc/kvmd/janus",
"--interface={src_ip}",
"{o_stun_server}",
], type=valid_command),
"cmd_remove": Option([], type=valid_options),
"cmd_append": Option([], type=valid_options),
},
"watchdog": {
"rtc": Option(0, type=valid_int_f0),
"timeout": Option(300, type=valid_int_f1),
"interval": Option(30, type=valid_int_f1),
},
}

3364
patches/adapter.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
From 3d137882ac38ac046b7d09cada1883b304b04319 Mon Sep 17 00:00:00 2001
From: xe5700 <9338143+xe5700@users.noreply.github.com>
Date: Fri, 20 May 2022 18:34:21 +0800
Subject: [PATCH] Revert force eject feature to unlock helper
---
kvmd/aiohelpers.py | 31 ++++++++++++-----
kvmd/apps/otg/__init__.py | 3 +-
kvmd/apps/otgmsd/__init__.py | 25 +++++++++++++-
kvmd/helpers/unlock/__init__.py | 58 ++++++++++++++++++++++++++++++++
kvmd/helpers/unlock/__main__.py | 24 +++++++++++++
kvmd/plugins/msd/otg/__init__.py | 19 ++++++++---
kvmd/plugins/msd/otg/drive.py | 5 +--
7 files changed, 145 insertions(+), 20 deletions(-)
create mode 100644 kvmd/helpers/unlock/__init__.py
create mode 100644 kvmd/helpers/unlock/__main__.py
diff --git a/kvmd/aiohelpers.py b/kvmd/aiohelpers.py
index 6357764c..37a5d4b9 100644
--- a/kvmd/aiohelpers.py
+++ b/kvmd/aiohelpers.py
@@ -40,11 +40,26 @@ async def remount(name: str, base_cmd: List[str], rw: bool) -> bool:
]
logger.info("Remounting %s storage to %s: %s ...", name, mode.upper(), cmd)
try:
- proc = await aioproc.log_process(cmd, logger)
- if proc.returncode != 0:
- assert proc.returncode is not None
- raise subprocess.CalledProcessError(proc.returncode, cmd)
- except Exception as err:
- logger.error("Can't remount %s storage: %s", name, tools.efmt(err))
- return False
- return True
+ await _run_helper(cmd)
+ except Exception:
+ logger.error("Can't remount internal storage")
+ raise
+
+
+async def unlock_drive(base_cmd: List[str]) -> None:
+ logger = get_logger(0)
+ logger.info("Unlocking the drive ...")
+ try:
+ await _run_helper(base_cmd)
+ except Exception:
+ logger.error("Can't unlock the drive")
+ raise
+
+
+# =====
+async def _run_helper(cmd: List[str]) -> None:
+ logger = get_logger(0)
+ logger.info("Executing helper %s ...", cmd)
+ proc = await aioproc.log_process(cmd, logger)
+ if proc.returncode != 0:
+ raise MsdError(f"Error while helper execution: pid={proc.pid}; retcode={proc.returncode}")
diff --git a/kvmd/apps/otg/__init__.py b/kvmd/apps/otg/__init__.py
index cbf7a197..d0ed0554 100644
--- a/kvmd/apps/otg/__init__.py
+++ b/kvmd/apps/otg/__init__.py
@@ -182,7 +182,6 @@ class _GadgetConfig:
_chown(join(func_path, "lun.0/cdrom"), user)
_chown(join(func_path, "lun.0/ro"), user)
_chown(join(func_path, "lun.0/file"), user)
- _chown(join(func_path, "lun.0/forced_eject"), user)
_symlink(func_path, join(self.__profile_path, func))
name = ("Mass Storage Drive" if self.__msd_instance == 0 else f"Extra Drive #{self.__msd_instance}")
self.__create_meta(func, name)
@@ -291,7 +290,7 @@ def _cmd_stop(config: Section) -> None:
logger.info("Disabling gadget %r ...", config.otg.gadget)
_write(join(gadget_path, "UDC"), "\n")
- _unlink(join(gadget_path, "os_desc", usb.G_PROFILE_NAME), optional=True)
+ _unlink(join(gadget_path, "os_desc", usb.G_PROFILE_NAME), True)
profile_path = join(gadget_path, usb.G_PROFILE)
for func in os.listdir(profile_path):
diff --git a/kvmd/apps/otgmsd/__init__.py b/kvmd/apps/otgmsd/__init__.py
index f57b3107..78f8e3c7 100644
--- a/kvmd/apps/otgmsd/__init__.py
+++ b/kvmd/apps/otgmsd/__init__.py
@@ -21,12 +21,15 @@
import os
+import signal
import errno
import argparse
from typing import List
from typing import Optional
+import psutil
+
from ...validators.basic import valid_bool
from ...validators.basic import valid_int_f0
from ...validators.os import valid_abs_file
@@ -56,6 +59,21 @@ def _set_param(gadget: str, instance: int, param: str, value: str) -> None:
raise
+def _unlock() -> None:
+ # https://github.com/torvalds/linux/blob/3039fad/drivers/usb/gadget/function/f_mass_storage.c#L2924
+ found = False
+ for proc in psutil.process_iter():
+ attrs = proc.as_dict(attrs=["name", "exe", "pid"])
+ if attrs.get("name") == "file-storage" and not attrs.get("exe"):
+ try:
+ proc.send_signal(signal.SIGUSR1)
+ found = True
+ except Exception as err:
+ raise SystemExit(f"Can't send SIGUSR1 to MSD kernel thread with pid={attrs['pid']}: {err}")
+ if not found:
+ raise SystemExit("Can't find MSD kernel thread")
+
+
# =====
def main(argv: Optional[List[str]]=None) -> None:
(parent_parser, argv, config) = init(
@@ -70,6 +88,8 @@ def main(argv: Optional[List[str]]=None) -> None:
)
parser.add_argument("-i", "--instance", default=0, type=valid_int_f0,
metavar="<N>", help="Drive instance (0 for KVMD drive)")
+ parser.add_argument("--unlock", action="store_true",
+ help="Send SIGUSR1 to MSD kernel thread")
parser.add_argument("--set-cdrom", default=None, type=valid_bool,
metavar="<1|0|yes|no>", help="Set CD-ROM flag")
parser.add_argument("--set-rw", default=None, type=valid_bool,
@@ -89,8 +109,11 @@ def main(argv: Optional[List[str]]=None) -> None:
set_param = (lambda param, value: _set_param(config.otg.gadget, options.instance, param, value))
get_param = (lambda param: _get_param(config.otg.gadget, options.instance, param))
+ if options.unlock:
+ _unlock()
+
if options.eject:
- set_param("forced_eject", "")
+ set_param("file", "")
if options.set_cdrom is not None:
set_param("cdrom", str(int(options.set_cdrom)))
diff --git a/kvmd/helpers/unlock/__init__.py b/kvmd/helpers/unlock/__init__.py
new file mode 100644
index 00000000..140e0e7c
--- /dev/null
+++ b/kvmd/helpers/unlock/__init__.py
@@ -0,0 +1,58 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2022 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/>. #
+# #
+# ========================================================================== #
+
+
+import sys
+import signal
+
+import psutil
+
+
+# =====
+_PROCESS_NAME = "file-storage"
+
+
+# =====
+def _log(msg: str) -> None:
+ print(msg, file=sys.stderr)
+
+
+def _unlock() -> None:
+ # https://github.com/torvalds/linux/blob/3039fad/drivers/usb/gadget/function/f_mass_storage.c#L2924
+ found = False
+ for proc in psutil.process_iter():
+ attrs = proc.as_dict(attrs=["name", "exe", "pid"])
+ if attrs.get("name") == _PROCESS_NAME and not attrs.get("exe"):
+ _log(f"Sending SIGUSR1 to MSD {_PROCESS_NAME!r} kernel thread with pid={attrs['pid']} ...")
+ try:
+ proc.send_signal(signal.SIGUSR1)
+ found = True
+ except Exception as err:
+ raise SystemExit(f"Can't send SIGUSR1 to MSD kernel thread with pid={attrs['pid']}: {err}")
+ if not found:
+ raise SystemExit(f"Can't find MSD kernel thread {_PROCESS_NAME!r}")
+
+
+# =====
+def main() -> None:
+ if len(sys.argv) != 2 or sys.argv[1] != "unlock":
+ raise SystemExit(f"Usage: {sys.argv[0]} [unlock]")
+ _unlock()
diff --git a/kvmd/helpers/unlock/__main__.py b/kvmd/helpers/unlock/__main__.py
new file mode 100644
index 00000000..3849d1b9
--- /dev/null
+++ b/kvmd/helpers/unlock/__main__.py
@@ -0,0 +1,24 @@
+# ========================================================================== #
+# #
+# KVMD - The main PiKVM daemon. #
+# #
+# Copyright (C) 2018-2022 Maxim Devaev <mdevaev@gmail.com> #
+# #
+# This program is free software: you can redistribute it and/or modify #
+# it under the terms of the GNU General Public License as published by #
+# the Free Software Foundation, either version 3 of the License, or #
+# (at your option) any later version. #
+# #
+# This program is distributed in the hope that it will be useful, #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
+# GNU General Public License for more details. #
+# #
+# You should have received a copy of the GNU General Public License #
+# along with this program. If not, see <https://www.gnu.org/licenses/>. #
+# #
+# ========================================================================== #
+
+
+from . import main
+main()
diff --git a/kvmd/plugins/msd/otg/__init__.py b/kvmd/plugins/msd/otg/__init__.py
index 409b899a..1342c6b4 100644
--- a/kvmd/plugins/msd/otg/__init__.py
+++ b/kvmd/plugins/msd/otg/__init__.py
@@ -140,6 +140,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
storage_path: str,
remount_cmd: List[str],
+ unlock_cmd: List[str],
initial: Dict,
@@ -154,6 +155,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
self.__meta_path = os.path.join(self.__storage_path, "meta")
self.__remount_cmd = remount_cmd
+ self.__unlock_cmd = unlock_cmd
self.__initial_image: str = initial["image"]
self.__initial_cdrom: bool = initial["cdrom"]
@@ -178,10 +180,8 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
"storage": Option("/var/lib/kvmd/msd", type=valid_abs_dir, unpack_as="storage_path"),
- "remount_cmd": Option([
- "/usr/bin/sudo", "--non-interactive",
- "/usr/bin/kvmd-helper-otgmsd-remount", "{mode}",
- ], type=valid_command),
+ "remount_cmd": Option([*sudo, "/usr/bin/kvmd-helper-otgmsd-remount", "{mode}"], type=valid_command),
+ "unlock_cmd": Option([*sudo, "/usr/bin/kvmd-helper-otgmsd-unlock", "unlock"], type=valid_command),
"initial": {
"image": Option("", type=valid_printable_filename, if_empty=""),
@@ -241,6 +241,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
async def reset(self) -> None:
async with self.__state.busy(check_online=False):
try:
+ await self.__unlock_drive()
self.__drive.set_image_path("")
self.__drive.set_rw_flag(False)
self.__drive.set_cdrom_flag(False)
@@ -290,12 +291,15 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
if not os.path.exists(self.__state.vd.image.path):
raise MsdUnknownImageError()
+ await self.__unlock_drive()
self.__drive.set_cdrom_flag(self.__state.vd.cdrom)
self.__drive.set_image_path(self.__state.vd.image.path)
else:
if not (self.__state.vd.connected or self.__drive.get_image_path()):
raise MsdDisconnectedError()
+
+ await self.__unlock_drive()
self.__drive.set_image_path("")
self.__state.vd.connected = connected
@@ -474,6 +478,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
if os.path.exists(path):
logger.info("Setting up initial image %r ...", self.__initial_image)
try:
+ await self.__unlock_drive()
self.__drive.set_cdrom_flag(self.__initial_cdrom)
self.__drive.set_image_path(path)
except Exception:
@@ -541,4 +546,8 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
async def __remount_storage(self, rw: bool) -> None:
if not (await aiohelpers.remount("MSD", self.__remount_cmd, rw)):
- raise MsdError("Can't execute remount helper")
+ pass
+ #raise MsdError("Can't execute remount helper")
+
+ async def __unlock_drive(self) -> None:
+ await helpers.unlock_drive(self.__unlock_cmd)
\ No newline at end of file
diff --git a/kvmd/plugins/msd/otg/drive.py b/kvmd/plugins/msd/otg/drive.py
index 11af7f81..ee54e5e9 100644
--- a/kvmd/plugins/msd/otg/drive.py
+++ b/kvmd/plugins/msd/otg/drive.py
@@ -53,10 +53,7 @@ class Drive:
# =====
def set_image_path(self, path: str) -> None:
- if path:
- self.__set_param("file", path)
- else:
- self.__set_param("forced_eject", "")
+ self.__set_param("file", path)
def get_image_path(self) -> str:
return self.__get_param("file")
--
2.34.1.windows.1

View File

@@ -0,0 +1,11 @@
#!/bin/bash
APP_PATH=$(readlink -f $(dirname $0))
echo "-> Apply patches"
cd /usr/lib/python3.10/site-packages/
git apply ${APP_PATH}/*.patch
cd ${APP_PATH}
echo "-> Add otgmsd unlock link"
cp kvmd-helper-otgmsd-unlock /usr/bin/
echo "-> Add sudoer"
echo "kvmd ALL=(ALL) NOPASSWD: /usr/bin/kvmd-helper-otgmsd-unlock" >> /etc/sudoers.d/99_kvmd
echo "-> Apply old kernel msd patch done."

View File

@@ -0,0 +1,7 @@
#!/usr/sbin/python
# KVMD-ARMBIAN
from kvmd.helpers.unlock import main
if __name__ == "__main__":
main()

10
patches/display.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
#tty清屏
echo -e "\033c" > /dev/tty1
#隐藏光标
echo -e "\033[?25l" > /dev/tty1
#在HDMI显示器输出采集卡画面
ustreamer-dump --sink=kvmd::ustreamer::jpeg --output - | ffmpeg -use_wallclock_as_timestamps 1 -i pipe:c:v -an -pix_fmt bgr24 -f fbdev /dev/fb0

147
patches/hw.py Normal file
View File

@@ -0,0 +1,147 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2023 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/>. #
# #
# ========================================================================== #
import os
import asyncio
from typing import Callable
from typing import AsyncGenerator
from typing import TypeVar
from ....logging import get_logger
from .... import env
from .... import tools
from .... import aiotools
from .... import aioproc
from .base import BaseInfoSubmanager
# =====
_RetvalT = TypeVar("_RetvalT")
# =====
class HwInfoSubmanager(BaseInfoSubmanager):
def __init__(
self,
vcgencmd_cmd: list[str],
ignore_past: bool,
state_poll: float,
) -> None:
self.__vcgencmd_cmd = vcgencmd_cmd
self.__ignore_past = ignore_past
self.__state_poll = state_poll
self.__dt_cache: dict[str, str] = {}
#async def get_state(self) -> dict:
# (model, serial, cpu_temp, throttling) = await asyncio.gather(
# self.__read_dt_file("model"),
# self.__read_dt_file("serial-number"),
# self.__get_cpu_temp(),
# self.__get_throttling(),
# )
# return {
# "platform": {
# "type": "rpi",
# "base": model,
# "serial": serial,
# },
# "health": {
# "temp": {
# "cpu": cpu_temp,
# },
# "throttling": throttling,
# },
# }
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 asyncio.sleep(self.__state_poll)
# =====
async def __read_dt_file(self, name: str) -> (str | None):
if name not in self.__dt_cache:
path = os.path.join(f"{env.PROCFS_PREFIX}/proc/device-tree", name)
try:
self.__dt_cache[name] = (await aiotools.read_file(path)).strip(" \t\r\n\0")
except Exception as err:
get_logger(0).error("Can't read DT %s from %s: %s", name, path, err)
return None
return self.__dt_cache[name]
async def __get_cpu_temp(self) -> (float | None):
temp_path = f"{env.SYSFS_PREFIX}/sys/class/thermal/thermal_zone0/temp"
try:
return int((await aiotools.read_file(temp_path)).strip()) / 1000
except Exception as err:
get_logger(0).error("Can't read CPU temp from %s: %s", temp_path, err)
return None
async def __get_throttling(self) -> (dict | None):
# https://www.raspberrypi.org/forums/viewtopic.php?f=63&t=147781&start=50#p972790
flags = await self.__parse_vcgencmd(
arg="get_throttled",
parser=(lambda text: int(text.split("=")[-1].strip(), 16)),
)
if flags is not None:
return {
"raw_flags": flags,
"parsed_flags": {
"undervoltage": {
"now": bool(flags & (1 << 0)),
"past": bool(flags & (1 << 16)),
},
"freq_capped": {
"now": bool(flags & (1 << 1)),
"past": bool(flags & (1 << 17)),
},
"throttled": {
"now": bool(flags & (1 << 2)),
"past": bool(flags & (1 << 18)),
},
},
"ignore_past": self.__ignore_past,
}
return None
async def __parse_vcgencmd(self, arg: str, parser: Callable[[str], _RetvalT]) -> (_RetvalT | None):
cmd = [*self.__vcgencmd_cmd, arg]
try:
text = (await aioproc.read_process(cmd, err_to_null=True))[1]
except Exception:
get_logger(0).exception("Error while executing: %s", tools.cmdfmt(cmd))
return None
try:
return parser(text)
except Exception as err:
get_logger(0).error("Can't parse [ %s ] output: %r: %s", tools.cmdfmt(cmd), text, tools.efmt(err))
return None

3651
patches/janus.js Normal file

File diff suppressed because it is too large Load Diff

13
patches/onecloud_gpio.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
case $1 in
short)
gpioset -m time -s 1 gpiochip1 7=0
gpioset gpiochip1 7=1
;;
long)
gpioset -m time -s 5 gpiochip1 7=0
gpioset gpiochip1 7=1
;;
*)
echo "No thing."
esac

417
patches/session.js Normal file
View File

@@ -0,0 +1,417 @@
/*****************************************************************************
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2023 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/>. #
# #
*****************************************************************************/
"use strict";
import {tools, $} from "../tools.js";
import {wm} from "../wm.js";
import {Recorder} from "./recorder.js";
import {Hid} from "./hid.js";
import {Atx} from "./atx.js";
import {Msd} from "./msd.js";
import {Streamer} from "./stream.js";
import {Gpio} from "./gpio.js";
import {Ocr} from "./ocr.js";
export function Session() {
// var self = this;
/************************************************************************/
var __ws = null;
var __ping_timer = null;
var __missed_heartbeats = 0;
var __streamer = new Streamer();
var __recorder = new Recorder();
var __hid = new Hid(__streamer.getGeometry, __recorder);
var __atx = new Atx(__recorder);
var __msd = new Msd();
var __gpio = new Gpio(__recorder);
var __ocr = new Ocr(__streamer.getGeometry);
var __info_hw_state = null;
var __info_fan_state = null;
var __init__ = function() {
__startSession();
};
/************************************************************************/
var __setAboutInfoMeta = function(state) {
if (state !== null) {
let text = JSON.stringify(state, undefined, 4).replace(/ /g, "&nbsp;").replace(/\n/g, "<br>");
$("about-meta").innerHTML = `
<span class="code-comment">// The PiKVM metadata.<br>
// You can get this JSON using handle <a target="_blank" href="/api/info?fields=meta">/api/info?fields=meta</a>.<br>
// In the standard configuration this data<br>
// is specified in the file /etc/kvmd/meta.yaml.</span><br>
<br>
${text}
`;
if (state.server && state.server.host) {
$("kvmd-meta-server-host").innerHTML = `Server: ${state.server.host}`;
document.title = `PiKVM Session: ${state.server.host}`;
} else {
$("kvmd-meta-server-host").innerHTML = "";
document.title = "PiKVM Session";
}
// Don't use this option, it may be removed in any time
if (state.web && state.web.confirm_session_exit === false) {
window.onbeforeunload = null; // See main.js
}
}
};
var __setAboutInfoHw = function(state) {
if (state.health.throttling !== null) {
let flags = state.health.throttling.parsed_flags;
let ignore_past = state.health.throttling.ignore_past;
let undervoltage = (flags.undervoltage.now || (flags.undervoltage.past && !ignore_past));
let freq_capped = (flags.freq_capped.now || (flags.freq_capped.past && !ignore_past));
tools.hidden.setVisible($("hw-health-dropdown"), (undervoltage || freq_capped));
$("hw-health-undervoltage-led").className = (undervoltage ? (flags.undervoltage.now ? "led-red" : "led-yellow") : "hidden");
$("hw-health-overheating-led").className = (freq_capped ? (flags.freq_capped.now ? "led-red" : "led-yellow") : "hidden");
tools.hidden.setVisible($("hw-health-message-undervoltage"), undervoltage);
tools.hidden.setVisible($("hw-health-message-overheating"), freq_capped);
}
__info_hw_state = state;
__renderAboutInfoHardware();
};
var __setAboutInfoFan = function(state) {
let failed = false;
let failed_past = false;
if (state.monitored) {
if (state.state === null) {
failed = true;
} else {
if (!state.state.fan.ok) {
failed = true;
} else if (state.state.fan.last_fail_ts >= 0) {
failed = true;
failed_past = true;
}
}
}
tools.hidden.setVisible($("fan-health-dropdown"), failed);
$("fan-health-led").className = (failed ? (failed_past ? "led-yellow" : "led-red") : "hidden");
__info_fan_state = state;
__renderAboutInfoHardware();
};
var __renderAboutInfoHardware = function() {
let html = "";
if (__info_hw_state !== null) {
html += `
Platform:
${__formatPlatform(__info_hw_state.platform)}
<hr>
Temperature:
${__formatTemp(__info_hw_state.health.temp)}
<hr>
Throttling:
${__formatThrottling(__info_hw_state.health.throttling)}
`;
}
if (__info_fan_state !== null) {
if (html.length > 0) {
html += "<hr>";
}
html += `
Fan:
${__formatFan(__info_fan_state)}
`;
}
$("about-hardware").innerHTML = html;
};
var __formatPlatform = function(state) {
return __formatUl([["Base", state.base], ["Serial", state.serial]]);
};
var __formatFan = function(state) {
if (!state.monitored) {
return __formatUl([["Status", "Not monitored"]]);
} else if (state.state === null) {
return __formatUl([["Status", __colored("red", "Not available")]]);
} else {
state = state.state;
let pairs = [
["Status", (state.fan.ok ? __colored("green", "Ok") : __colored("red", "Failed"))],
["Desired speed", `${state.fan.speed}%`],
["PWM", `${state.fan.pwm}`],
];
if (state.hall.available) {
pairs.push(["RPM", __colored((state.fan.ok ? "green" : "red"), state.hall.rpm)]);
}
return __formatUl(pairs);
}
};
var __formatTemp = function(temp) {
let pairs = [];
for (let field of Object.keys(temp).sort()) {
pairs.push([field.toUpperCase(), `${temp[field]}&deg;C`]);
}
return __formatUl(pairs);
};
var __formatThrottling = function(throttling) {
if (throttling !== null) {
let pairs = [];
for (let field of Object.keys(throttling.parsed_flags).sort()) {
let flags = throttling.parsed_flags[field];
let key = tools.upperFirst(field).replace("_", " ");
let value = (flags["now"] ? __colored("red", "RIGHT NOW") : __colored("green", "No"));
if (!throttling.ignore_past) {
value += "; " + (flags["past"] ? __colored("red", "In the past") : __colored("green", "Never"));
}
pairs.push([key, value]);
}
return __formatUl(pairs);
} else {
return "NO DATA";
}
};
var __colored = function(color, text) {
return `<font color="${color}">${text}</font>`;
};
var __setAboutInfoSystem = function(state) {
$("about-version").innerHTML = `
KVMD: <span class="code-comment">${state.kvmd.version}</span><br>
<hr>
Streamer: <span class="code-comment">${state.streamer.version} (${state.streamer.app})</span>
${__formatStreamerFeatures(state.streamer.features)}
<hr>
${state.kernel.system} kernel:
${__formatUname(state.kernel)}
`;
$("kvmd-version-kvmd").innerHTML = state.kvmd.version;
$("kvmd-version-streamer").innerHTML = state.streamer.version;
};
var __formatStreamerFeatures = function(features) {
let pairs = [];
for (let field of Object.keys(features).sort()) {
pairs.push([field, (features[field] ? "Yes" : "No")]);
}
return __formatUl(pairs);
};
var __formatUname = function(kernel) {
let pairs = [];
for (let field of Object.keys(kernel).sort()) {
if (field !== "system") {
pairs.push([tools.upperFirst(field), kernel[field]]);
}
}
return __formatUl(pairs);
};
var __formatUl = function(pairs) {
let text = "<ul>";
for (let pair of pairs) {
text += `<li>${pair[0]}: <span class="code-comment">${pair[1]}</span></li>`;
}
return text + "</ul>";
};
var __setExtras = function(state) {
let show_hook = null;
let close_hook = null;
let has_webterm = (state.webterm && (state.webterm.enabled || state.webterm.started));
if (has_webterm) {
let path = "/" + state.webterm.path;
show_hook = function() {
tools.info("Terminal opened: ", path);
$("webterm-iframe").src = path;
};
close_hook = function() {
tools.info("Terminal closed");
$("webterm-iframe").src = "";
};
}
tools.feature.setEnabled($("system-tool-webterm"), has_webterm);
$("webterm-window").show_hook = show_hook;
$("webterm-window").close_hook = close_hook;
__streamer.setJanusEnabled(
(state.janus && (state.janus.enabled || state.janus.started))
|| (state.janus_static && (state.janus_static.enabled || state.janus_static.started))
);
};
var __startSession = function() {
$("link-led").className = "led-yellow";
$("link-led").title = "Connecting...";
let http = tools.makeRequest("GET", "/api/auth/check", function() {
if (http.readyState === 4) {
if (http.status === 200) {
__ws = new WebSocket(`${tools.is_https ? "wss" : "ws"}://${location.host}/api/ws`);
__ws.sendHidEvent = (event) => __sendHidEvent(__ws, event.event_type, event.event);
__ws.onopen = __wsOpenHandler;
__ws.onmessage = __wsMessageHandler;
__ws.onerror = __wsErrorHandler;
__ws.onclose = __wsCloseHandler;
} else if (http.status === 401 || http.status === 403) {
window.onbeforeunload = () => null;
wm.error("Unexpected logout occured, please login again").then(function() {
document.location.href = "/login";
});
} else {
__wsCloseHandler(null);
}
}
});
};
var __ascii_encoder = new TextEncoder("ascii");
var __sendHidEvent = function(ws, event_type, event) {
if (event_type == "key") {
let data = __ascii_encoder.encode("\x01\x00" + event.key);
data[1] = (event.state ? 1 : 0);
ws.send(data);
} else if (event_type == "mouse_button") {
let data = __ascii_encoder.encode("\x02\x00" + event.button);
data[1] = (event.state ? 1 : 0);
ws.send(data);
} else if (event_type == "mouse_move") {
let data = new Uint8Array([
3,
(event.to.x >> 8) & 0xFF, event.to.x & 0xFF,
(event.to.y >> 8) & 0xFF, event.to.y & 0xFF,
]);
ws.send(data);
} else if (event_type == "mouse_relative" || event_type == "mouse_wheel") {
let data;
if (Array.isArray(event.delta)) {
data = new Int8Array(2 + event.delta.length * 2);
let index = 0;
for (let delta of event.delta) {
data[index + 2] = delta["x"];
data[index + 3] = delta["y"];
index += 2;
}
} else {
data = new Int8Array([0, 0, event.delta.x, event.delta.y]);
}
data[0] = (event_type == "mouse_relative" ? 4 : 5);
data[1] = (event.squash ? 1 : 0);
ws.send(data);
}
};
var __wsOpenHandler = function(event) {
tools.debug("Session: socket opened:", event);
$("link-led").className = "led-green";
$("link-led").title = "Connected";
__recorder.setSocket(__ws);
__hid.setSocket(__ws);
__missed_heartbeats = 0;
__ping_timer = setInterval(__pingServer, 1000);
};
var __wsMessageHandler = function(event) {
// tools.debug("Session: received socket data:", event.data);
let data = JSON.parse(event.data);
switch (data.event_type) {
case "pong": __missed_heartbeats = 0; break;
case "info_meta_state": __setAboutInfoMeta(data.event); break;
case "info_hw_state": __setAboutInfoHw(data.event); break;
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;
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 "streamer_ocr_state": __ocr.setState(data.event); break;
}
};
var __wsErrorHandler = function(event) {
tools.error("Session: socket error:", event);
if (__ws) {
__ws.onclose = null;
__ws.close();
__wsCloseHandler(null);
}
};
var __wsCloseHandler = function(event) {
tools.debug("Session: socket closed:", event);
$("link-led").className = "led-gray";
if (__ping_timer) {
clearInterval(__ping_timer);
__ping_timer = null;
}
__ocr.setState(null);
__gpio.setState(null);
__hid.setSocket(null);
__recorder.setSocket(null);
__atx.setState(null);
__msd.setState(null);
__streamer.setState(null);
__ws = null;
setTimeout(function() {
$("link-led").className = "led-yellow";
setTimeout(__startSession, 500);
}, 500);
};
var __pingServer = function() {
try {
__missed_heartbeats += 1;
if (__missed_heartbeats >= 15) {
throw new Error("Too many missed heartbeats");
}
__ws.send("{\"event_type\": \"ping\", \"event\": {}}");
} catch (err) {
__wsErrorHandler(err.message);
}
};
__init__();
}

3
patches/stream.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
/usr/bin/ustreamer-dump --sink kvmd::ustreamer::jpeg --output - | /usr/bin/ffmpeg -re -use_wallclock_as_timestamps 1 -i pipe: -rtbufsize 10M -c:v libx264 -pix_fmt yuv420p -preset:v ultrafast -tune:v zerolatency -profile:v baseline -bf 0 -b:v 3M -maxrate:v 5M -r 10 -g 10 -an -f rtp rtp://127.0.0.1:5004

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# 存储 stream.sh 的进程 ID
b_pid=""
cleanup() {
echo "Received SIGINT. Terminating the process group..."
[ -n "$b_pid" ] && pkill -9 -g $b_pid # 终止整个进程组
exit 0
}
# 捕获 SIGINT 信号
trap cleanup SIGINT
while true; do
# 检测是否有包含 "ustreamer" 的进程
if pgrep -f "/usr/bin/ustreamer " > /dev/null; then
# 如果存在,但是 stream.sh 进程不存在,执行 stream.sh 并记录其进程 ID
if [ -z "$b_pid" ]; then
echo "Found a process with 'ustreamer' in the command. Executing stream.sh in the background..."
setsid /usr/share/kvmd/stream.sh &
b_pid=$(ps -o pgid= $!)
echo "stream.sh started with PID: $b_pid"
else
echo "Process with 'ustreamer' is already running. Skipping..."
fi
else
# 如果不存在 "ustreamer" 进程,但是 stream.sh 进程存在,终止 stream.sh 并清除进程 ID
if [ -n "$b_pid" ]; then
echo "No process with 'ustreamer' found. Terminating stream.sh (PID: $b_pid)..."
pkill -9 -g $b_pid
b_pid=""
else
echo "No process with 'ustreamer' found. Waiting for the next check..."
fi
fi
# 等待一段时间,可以根据需要调整等待的时间间隔
sleep 1
done

482
patches/streamer.py.1 Normal file
View File

@@ -0,0 +1,482 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2023 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/>. #
# #
# ========================================================================== #
import io
import signal
import asyncio
import asyncio.subprocess
import dataclasses
import functools
from typing import AsyncGenerator
from typing import Any
import aiohttp
from PIL import Image as PilImage
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ... import aioproc
from ... import htclient
# =====
@dataclasses.dataclass(frozen=True)
class StreamerSnapshot:
online: bool
width: int
height: int
headers: tuple[tuple[str, str], ...]
data: bytes
async def make_preview(self, max_width: int, max_height: int, quality: int) -> bytes:
assert max_width >= 0
assert max_height >= 0
assert quality > 0
if max_width == 0 and max_height == 0:
max_width = self.width // 5
max_height = self.height // 5
else:
max_width = min((max_width or self.width), self.width)
max_height = min((max_height or self.height), self.height)
if (max_width, max_height) == (self.width, self.height):
return self.data
return (await aiotools.run_async(self.__inner_make_preview, max_width, max_height, quality))
@functools.lru_cache(maxsize=1)
def __inner_make_preview(self, max_width: int, max_height: int, quality: int) -> bytes:
with io.BytesIO(self.data) as snapshot_bio:
with io.BytesIO() as preview_bio:
with PilImage.open(snapshot_bio) as image:
image.thumbnail((max_width, max_height), PilImage.Resampling.LANCZOS)
image.save(preview_bio, format="jpeg", quality=quality)
return preview_bio.getvalue()
class _StreamerParams:
__DESIRED_FPS = "desired_fps"
__QUALITY = "quality"
__RESOLUTION = "resolution"
__AVAILABLE_RESOLUTIONS = "available_resolutions"
__H264_BITRATE = "h264_bitrate"
__H264_GOP = "h264_gop"
def __init__( # pylint: disable=too-many-arguments
self,
quality: int,
resolution: str,
available_resolutions: list[str],
desired_fps: int,
desired_fps_min: int,
desired_fps_max: int,
h264_bitrate: int,
h264_bitrate_min: int,
h264_bitrate_max: int,
h264_gop: int,
h264_gop_min: int,
h264_gop_max: int,
) -> None:
self.__has_quality = bool(quality)
self.__has_resolution = bool(resolution)
self.__has_h264 = bool(h264_bitrate)
self.__params: dict = {self.__DESIRED_FPS: min(max(desired_fps, desired_fps_min), desired_fps_max)}
self.__limits: dict = {self.__DESIRED_FPS: {"min": desired_fps_min, "max": desired_fps_max}}
if self.__has_quality:
self.__params[self.__QUALITY] = quality
if self.__has_resolution:
self.__params[self.__RESOLUTION] = resolution
self.__limits[self.__AVAILABLE_RESOLUTIONS] = available_resolutions
if self.__has_h264:
self.__params[self.__H264_BITRATE] = min(max(h264_bitrate, h264_bitrate_min), h264_bitrate_max)
self.__limits[self.__H264_BITRATE] = {"min": h264_bitrate_min, "max": h264_bitrate_max}
self.__params[self.__H264_GOP] = min(max(h264_gop, h264_gop_min), h264_gop_max)
self.__limits[self.__H264_GOP] = {"min": h264_gop_min, "max": h264_gop_max}
def get_features(self) -> dict:
return {
self.__QUALITY: self.__has_quality,
self.__RESOLUTION: self.__has_resolution,
"h264": self.__has_h264,
}
def get_limits(self) -> dict:
limits = dict(self.__limits)
if self.__has_resolution:
limits[self.__AVAILABLE_RESOLUTIONS] = list(limits[self.__AVAILABLE_RESOLUTIONS])
return limits
def get_params(self) -> dict:
return dict(self.__params)
def set_params(self, params: dict) -> None:
new_params = dict(self.__params)
if self.__QUALITY in params and self.__has_quality:
new_params[self.__QUALITY] = min(max(params[self.__QUALITY], 1), 100)
if self.__RESOLUTION in params and self.__has_resolution:
if params[self.__RESOLUTION] in self.__limits[self.__AVAILABLE_RESOLUTIONS]:
new_params[self.__RESOLUTION] = params[self.__RESOLUTION]
for (key, enabled) in [
(self.__DESIRED_FPS, True),
(self.__H264_BITRATE, self.__has_h264),
(self.__H264_GOP, self.__has_h264),
]:
if key in params and enabled:
if self.__check_limits_min_max(key, params[key]):
new_params[key] = params[key]
self.__params = new_params
def __check_limits_min_max(self, key: str, value: int) -> bool:
return (self.__limits[key]["min"] <= value <= self.__limits[key]["max"])
class Streamer: # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=too-many-arguments,too-many-locals
self,
reset_delay: float,
shutdown_delay: float,
state_poll: float,
unix_path: str,
timeout: float,
process_name_prefix: str,
pre_start_cmd: list[str],
pre_start_cmd_remove: list[str],
pre_start_cmd_append: list[str],
cmd: list[str],
cmd_remove: list[str],
cmd_append: list[str],
post_stop_cmd: list[str],
post_stop_cmd_remove: list[str],
post_stop_cmd_append: list[str],
**params_kwargs: Any,
) -> None:
self.__reset_delay = reset_delay
self.__shutdown_delay = shutdown_delay
self.__state_poll = state_poll
self.__unix_path = unix_path
self.__timeout = timeout
self.__process_name_prefix = process_name_prefix
self.__pre_start_cmd = tools.build_cmd(pre_start_cmd, pre_start_cmd_remove, pre_start_cmd_append)
self.__cmd = tools.build_cmd(cmd, cmd_remove, cmd_append)
self.__post_stop_cmd = tools.build_cmd(post_stop_cmd, post_stop_cmd_remove, post_stop_cmd_append)
self.__params = _StreamerParams(**params_kwargs)
self.__stop_task: (asyncio.Task | None) = None
self.__stop_wip = False
self.__streamer_task: (asyncio.Task | None) = None
self.__streamer_proc: (asyncio.subprocess.Process | None) = None # pylint: disable=no-member
self.__http_session: (aiohttp.ClientSession | None) = None
self.__snapshot: (StreamerSnapshot | None) = None
self.__notifier = aiotools.AioNotifier()
# =====
@aiotools.atomic_fg
async def ensure_start(self, reset: bool) -> None:
if not self.__streamer_task or self.__stop_task:
logger = get_logger(0)
if self.__stop_task:
if not self.__stop_wip:
self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info("Streamer stop cancelled")
return
else:
await asyncio.gather(self.__stop_task, return_exceptions=True)
if reset and self.__reset_delay > 0:
logger.info("Waiting %.2f seconds for reset delay ...", self.__reset_delay)
await asyncio.sleep(self.__reset_delay)
logger.info("Starting streamer ...")
await self.__inner_start()
@aiotools.atomic_fg
async def ensure_stop(self, immediately: bool) -> None:
if self.__streamer_task:
logger = get_logger(0)
if immediately:
if self.__stop_task:
if not self.__stop_wip:
self.__stop_task.cancel()
await asyncio.gather(self.__stop_task, return_exceptions=True)
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
else:
await asyncio.gather(self.__stop_task, return_exceptions=True)
else:
logger.info("Stopping streamer immediately ...")
await self.__inner_stop()
elif not self.__stop_task:
async def delayed_stop() -> None:
try:
await asyncio.sleep(self.__shutdown_delay)
self.__stop_wip = True
logger.info("Stopping streamer after delay ...")
await self.__inner_stop()
finally:
self.__stop_task = None
self.__stop_wip = False
logger.info("Planning to stop streamer in %.2f seconds ...", self.__shutdown_delay)
self.__stop_task = asyncio.create_task(delayed_stop())
def is_working(self) -> bool:
# Запущено и не планирует останавливаться
return bool(self.__streamer_task and not self.__stop_task)
# =====
def set_params(self, params: dict) -> None:
assert not self.__streamer_task
return self.__params.set_params(params)
def get_params(self) -> dict:
return self.__params.get_params()
# =====
async def get_state(self) -> dict:
streamer_state = None
if self.__streamer_task:
session = self.__ensure_http_session()
try:
async with session.get(self.__make_url("state")) as response:
htclient.raise_not_200(response)
streamer_state = (await response.json())["result"]
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError):
pass
except Exception:
get_logger().exception("Invalid streamer response from /state")
snapshot: (dict | None) = None
if self.__snapshot:
snapshot = dataclasses.asdict(self.__snapshot)
del snapshot["headers"]
del snapshot["data"]
return {
"limits": self.__params.get_limits(),
"params": self.__params.get_params(),
"snapshot": {"saved": snapshot},
"streamer": streamer_state,
"features": self.__params.get_features(),
}
async def poll_state(self) -> AsyncGenerator[dict, None]:
def signal_handler(*_: Any) -> None:
get_logger(0).info("Got SIGUSR2, checking the stream state ...")
self.__notifier.notify()
get_logger(0).info("Installing SIGUSR2 streamer handler ...")
asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler)
waiter_task: (asyncio.Task | None) = None
prev_state: dict = {}
while True:
state = await self.get_state()
if state != prev_state:
yield state
prev_state = state
if waiter_task is None:
waiter_task = asyncio.create_task(self.__notifier.wait())
if waiter_task in (await aiotools.wait_first(
asyncio.ensure_future(asyncio.sleep(self.__state_poll)),
waiter_task,
))[0]:
waiter_task = None
# =====
async def take_snapshot(self, save: bool, load: bool, allow_offline: bool) -> (StreamerSnapshot | None):
if load:
return self.__snapshot
else:
logger = get_logger()
session = self.__ensure_http_session()
try:
async with session.get(self.__make_url("snapshot")) as response:
htclient.raise_not_200(response)
online = (response.headers["X-UStreamer-Online"] == "true")
if online or allow_offline:
snapshot = StreamerSnapshot(
online=online,
width=int(response.headers["X-UStreamer-Width"]),
height=int(response.headers["X-UStreamer-Height"]),
headers=tuple(
(key, value)
for (key, value) in tools.sorted_kvs(dict(response.headers))
if key.lower().startswith("x-ustreamer-") or key.lower() in [
"x-timestamp",
"access-control-allow-origin",
"cache-control",
"pragma",
"expires",
]
),
data=bytes(await response.read()),
)
if save:
self.__snapshot = snapshot
self.__notifier.notify()
return snapshot
logger.error("Stream is offline, no signal or so")
except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as err:
logger.error("Can't connect to streamer: %s", tools.efmt(err))
except Exception:
logger.exception("Invalid streamer response from /snapshot")
return None
def remove_snapshot(self) -> None:
self.__snapshot = None
# =====
@aiotools.atomic_fg
async def cleanup(self) -> None:
await self.ensure_stop(immediately=True)
if self.__http_session:
await self.__http_session.close()
self.__http_session = None
# =====
def __ensure_http_session(self) -> aiohttp.ClientSession:
if not self.__http_session:
kwargs: dict = {
"headers": {"User-Agent": htclient.make_user_agent("KVMD")},
"connector": aiohttp.UnixConnector(path=self.__unix_path),
"timeout": aiohttp.ClientTimeout(total=self.__timeout),
}
self.__http_session = aiohttp.ClientSession(**kwargs)
return self.__http_session
def __make_url(self, handle: str) -> str:
assert not handle.startswith("/"), handle
return f"http://localhost:0/{handle}"
# =====
@aiotools.atomic_fg
async def __inner_start(self) -> None:
assert not self.__streamer_task
await self.__run_hook("PRE-START-CMD", self.__pre_start_cmd)
self.__streamer_task = asyncio.create_task(self.__streamer_task_loop())
@aiotools.atomic_fg
async def __inner_stop(self) -> None:
assert self.__streamer_task
self.__streamer_task.cancel()
await asyncio.gather(self.__streamer_task, return_exceptions=True)
await self.__kill_streamer_proc()
await self.__run_hook("POST-STOP-CMD", self.__post_stop_cmd)
self.__streamer_task = None
# =====
async def __streamer_task_loop(self) -> None: # pylint: disable=too-many-branches
logger = get_logger(0)
while True: # pylint: disable=too-many-nested-blocks
try:
await self.__start_streamer_proc()
assert self.__streamer_proc is not None
await aioproc.log_stdout_infinite(self.__streamer_proc, logger)
raise RuntimeError("Streamer unexpectedly died")
except asyncio.CancelledError:
break
except Exception:
if self.__streamer_proc:
logger.exception("Unexpected streamer error: pid=%d", self.__streamer_proc.pid)
else:
logger.exception("Can't start streamer")
await self.__kill_streamer_proc()
await asyncio.sleep(1)
def __make_cmd(self, cmd: list[str]) -> list[str]:
return [
part.format(
unix=self.__unix_path,
process_name_prefix=self.__process_name_prefix,
**self.__params.get_params(),
)
for part in cmd
]
async def __run_hook(self, name: str, cmd: list[str]) -> None:
logger = get_logger()
cmd = self.__make_cmd(cmd)
logger.info("%s: %s", name, tools.cmdfmt(cmd))
try:
await aioproc.log_process(cmd, logger, prefix=name)
except Exception as err:
logger.exception("Can't execute command: %s", err)
async def __start_streamer_proc(self) -> None:
assert self.__streamer_proc is None
cmd = self.__make_cmd(self.__cmd)
self.__streamer_proc = await aioproc.run_process(cmd)
get_logger(0).info("Started streamer pid=%d: %s", self.__streamer_proc.pid, tools.cmdfmt(cmd))
async def __kill_streamer_proc(self) -> None:
if self.__streamer_proc:
await aioproc.kill_process(self.__streamer_proc, 1, get_logger(0))
self.__streamer_proc = None