From 2621f7deadf353c423793454d327b5e9c21ee40f Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 13 Aug 2026 02:09:52 -0300 Subject: [PATCH] fix(server): own capture relay runtime --- Cargo.lock | 6 +-- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- docs/architecture-and-media-readiness.md | 25 ++++++++-- scripts/daemon/lesavka-relay-hold.py | 47 +++++++++++++++++++ scripts/install/server.sh | 38 +++++++++++++++ server/Cargo.toml | 2 +- .../install/server_install_script_contract.rs | 38 +++++++++++++++ 8 files changed, 151 insertions(+), 9 deletions(-) create mode 100755 scripts/daemon/lesavka-relay-hold.py diff --git a/Cargo.lock b/Cargo.lock index 850481f..0a38005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.27.8" +version = "0.27.9" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.27.8" +version = "0.27.9" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.27.8" +version = "0.27.9" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index c974668..b0b6f17 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.27.8" +version = "0.27.9" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 3e7bcd9..88bfbf9 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.27.8" +version = "0.27.9" edition = "2024" build = "build.rs" diff --git a/docs/architecture-and-media-readiness.md b/docs/architecture-and-media-readiness.md index a33978d..6ce1a9f 100644 --- a/docs/architecture-and-media-readiness.md +++ b/docs/architecture-and-media-readiness.md @@ -304,6 +304,16 @@ modern `key-int-max` property for `vah264enc` and `vah265enc`. The hardware smoke harness now exercises the same usable route and retains encoded streams plus hardware-decoded proof frames. +### The `0.27.9` Capture Power Fix + +The controlled 2026-08-13 Theia reboot made the downstream outage concrete: +`relay.service` could not import `gpiod` from an obsolete home-directory +virtualenv, so GPIO27 never powered either GC311 capture card. The system +`libgpiod` Python binding remained healthy. Release 0.27.9 moves the helper +into the Lesavka tree, installs it with the server, requires `libgpiod`, and +runs it with `/usr/bin/python` before capture discovery. This removes the +unversioned virtualenv from the physical eye-feed power path. + ## 7. The Current Downstream Video Failure The current blank downstream feeds fail before transport or decoding. @@ -351,6 +361,15 @@ hardware itself is healthy. If the devices remain absent after reboot, the next checks are the `relay.service` failure reason, capture-card power, USB enumeration, V4L2 nodes, udev path tags/rules, and physical card/cable state. +The 2026-08-13 controlled reboot completed that branch of the diagnosis. SSH +returned, but both cards remained absent and `relay.service` failed with +`ModuleNotFoundError: No module named 'gpiod'`. Its unit referenced a private +Python 3.14 virtualenv that had also lost `pip`; meanwhile `/usr/bin/python` +successfully imported the installed `libgpiod` binding. This proves the common +downstream failure was capture-card power automation, not two simultaneous +card failures, H.264 transport, or client decoding. Release 0.27.9 makes the +working system binding and a versioned helper the installed service contract. + ## 8. Recovery And Deployment Sequence The safe completion sequence for this incident is: @@ -367,7 +386,7 @@ The safe completion sequence for this incident is: 6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the already-attached USB gadget unless a controlled rebuild is explicitly required. -7. Confirm Theia reports server version `0.27.8`, the pushed release revision, +7. Confirm Theia reports server version `0.27.9`, the pushed release revision, direct MJPEG normalizer timeout `0`, and a coherent UVC contract. 8. Open both downstream RPCs and prove that each emits changing, decodable H.264 frames. @@ -385,7 +404,7 @@ hardware contract is repeatable. The remaining work falls into five groups. ### A. Install And Version Parity -- Push and deploy `0.27.8` through the client/server install scripts. +- Push and deploy `0.27.9` through the client/server install scripts. - Confirm client/server version and revision in every hardware probe artifact. - Eliminate the current state where a fixed client talks to an unfixed server. @@ -451,6 +470,6 @@ host repair: 9. disconnect/reconnect and device changes recover without stale media; and 10. diagnostics identify the failed physical stage when any item breaks. -Until that sequence passes on the installed `0.27.8` client/server pair, the +Until that sequence passes on the installed `0.27.9` client/server pair, the current release should be described as a validated code correction awaiting hardware deployment and end-to-end acceptance, not as a completed product fix. diff --git a/scripts/daemon/lesavka-relay-hold.py b/scripts/daemon/lesavka-relay-hold.py new file mode 100755 index 0000000..c0192fb --- /dev/null +++ b/scripts/daemon/lesavka-relay-hold.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Hold the capture-card power relay active until systemd stops the service.""" + +from __future__ import annotations + +import os +import signal +import time + +import gpiod +from gpiod.line import Direction, Value + + +CHIP = os.environ.get("LESAVKA_CAPTURE_RELAY_CHIP", "/dev/gpiochip0") +LINE = int(os.environ.get("LESAVKA_CAPTURE_RELAY_LINE", "27")) + + +def main() -> None: + stop_requested = False + + def request_stop(_signum: int, _frame: object) -> None: + nonlocal stop_requested + stop_requested = True + + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + + with gpiod.Chip(CHIP) as chip: + request = chip.request_lines( + consumer="lesavka-capture-relay", + config={ + LINE: gpiod.LineSettings( + direction=Direction.OUTPUT, + output_value=Value.ACTIVE, + ) + }, + ) + try: + while not stop_requested: + time.sleep(0.25) + finally: + request.set_value(LINE, Value.INACTIVE) + request.release() + + +if __name__ == "__main__": + main() diff --git a/scripts/install/server.sh b/scripts/install/server.sh index c2afcbf..7791ed4 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -1435,6 +1435,42 @@ UNIT sudo systemctl enable lesavka-recovery-ladder.timer } +install_capture_power_relay_unit() { + local helper_source="$SCRIPT_REPO_ROOT/scripts/daemon/lesavka-relay-hold.py" + if [[ ! -x $helper_source ]]; then + echo "Capture relay helper is missing or not executable: $helper_source" >&2 + exit 1 + fi + + install_verified_executable \ + "$helper_source" \ + /usr/local/lib/lesavka/lesavka-relay-hold.py \ + "lesavka-relay-hold.py" + + cat <<'UNIT' | sudo tee /etc/systemd/system/relay.service >/dev/null +[Unit] +Description=Lesavka capture-card power relay on GPIO27 + +[Service] +Type=simple +User=theia +Group=gpio +SupplementaryGroups=gpio +Environment=LESAVKA_CAPTURE_RELAY_CHIP=/dev/gpiochip0 +Environment=LESAVKA_CAPTURE_RELAY_LINE=27 +ExecStart=/usr/bin/python /usr/local/lib/lesavka/lesavka-relay-hold.py +Restart=on-failure +RestartSec=2s +TimeoutStopSec=5s + +[Install] +WantedBy=multi-user.target +UNIT + + sudo systemctl daemon-reload + sudo systemctl reset-failed relay.service >/dev/null 2>&1 || true +} + CAPTURE_DISCOVERY_RELAY_PRESENT=0 CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE=0 CAPTURE_DISCOVERY_POWER_BORROWED=0 @@ -1498,6 +1534,7 @@ pacman_install git \ abseil-cpp \ gcc \ alsa-utils \ + libgpiod \ "${PIPEWIRE_PACKAGES[@]}" \ tailscale \ base-devel \ @@ -1525,6 +1562,7 @@ fi echo "==> 1c. GPIO permissions for relay" echo 'z /dev/gpiochip* 0660 root gpio -' | sudo tee /etc/tmpfiles.d/gpiochip.conf >/dev/null sudo systemd-tmpfiles --create /etc/tmpfiles.d/gpiochip.conf || true +install_capture_power_relay_unit echo "==> 1d. Audio permissions for diagnostics" if getent group audio >/dev/null 2>&1 && [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then diff --git a/server/Cargo.toml b/server/Cargo.toml index 05bb98e..15a0781 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.27.8" +version = "0.27.9" edition = "2024" autobins = false diff --git a/tests/contract/scripts/install/server_install_script_contract.rs b/tests/contract/scripts/install/server_install_script_contract.rs index b58a9d1..d792acb 100644 --- a/tests/contract/scripts/install/server_install_script_contract.rs +++ b/tests/contract/scripts/install/server_install_script_contract.rs @@ -9,6 +9,44 @@ const SERVER_INSTALL: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/scripts/install/server.sh" )); +const CAPTURE_RELAY_HELPER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/daemon/lesavka-relay-hold.py" +)); + +#[test] +fn server_install_owns_capture_relay_runtime_and_uses_system_gpiod() { + for expected in [ + "libgpiod", + "install_capture_power_relay_unit", + "/usr/local/lib/lesavka/lesavka-relay-hold.py", + "ExecStart=/usr/bin/python /usr/local/lib/lesavka/lesavka-relay-hold.py", + "Restart=on-failure", + ] { + assert!( + SERVER_INSTALL.contains(expected), + "server installer should preserve the capture relay dependency: {expected}" + ); + } + assert!( + !SERVER_INSTALL.contains("/home/theia/scripts/gpio/venv/bin/python"), + "capture relay power must not depend on an unmaintained home-directory virtualenv" + ); + assert!(CAPTURE_RELAY_HELPER.contains("import gpiod")); + assert!(CAPTURE_RELAY_HELPER.contains("output_value=Value.ACTIVE")); + assert!(CAPTURE_RELAY_HELPER.contains("request.set_value(LINE, Value.INACTIVE)")); + + let install_position = SERVER_INSTALL + .find("install_capture_power_relay_unit\n") + .expect("relay unit install call"); + let discovery_position = SERVER_INSTALL + .find("prepare_capture_power_for_discovery\n") + .expect("capture discovery call"); + assert!( + install_position < discovery_position, + "the fixed relay runtime must be installed before capture discovery starts it" + ); +} #[test] fn server_install_infers_checkout_owner_when_root_wrapper_hides_sudo_user() {