fix(server): own capture relay runtime

This commit is contained in:
Brad Stein 2026-08-13 02:09:52 -03:00
parent 164ef497f0
commit 2621f7dead
8 changed files with 151 additions and 9 deletions

6
Cargo.lock generated
View File

@ -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",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.27.8"
version = "0.27.9"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.27.8"
version = "0.27.9"
edition = "2024"
build = "build.rs"

View File

@ -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.

View File

@ -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()

View File

@ -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

View File

@ -16,7 +16,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.27.8"
version = "0.27.9"
edition = "2024"
autobins = false

View File

@ -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() {