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]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.27.8" version = "0.27.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.27.8" version = "0.27.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.27.8" version = "0.27.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

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

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.27.8" version = "0.27.9"
edition = "2024" edition = "2024"
build = "build.rs" 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 smoke harness now exercises the same usable route and retains encoded streams
plus hardware-decoded proof frames. 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 ## 7. The Current Downstream Video Failure
The current blank downstream feeds fail before transport or decoding. 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 checks are the `relay.service` failure reason, capture-card power, USB
enumeration, V4L2 nodes, udev path tags/rules, and physical card/cable state. 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 ## 8. Recovery And Deployment Sequence
The safe completion sequence for this incident is: 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 6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the
already-attached USB gadget unless a controlled rebuild is explicitly already-attached USB gadget unless a controlled rebuild is explicitly
required. 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. direct MJPEG normalizer timeout `0`, and a coherent UVC contract.
8. Open both downstream RPCs and prove that each emits changing, decodable H.264 8. Open both downstream RPCs and prove that each emits changing, decodable H.264
frames. frames.
@ -385,7 +404,7 @@ hardware contract is repeatable. The remaining work falls into five groups.
### A. Install And Version Parity ### 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. - Confirm client/server version and revision in every hardware probe artifact.
- Eliminate the current state where a fixed client talks to an unfixed server. - 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 9. disconnect/reconnect and device changes recover without stale media; and
10. diagnostics identify the failed physical stage when any item breaks. 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 current release should be described as a validated code correction awaiting
hardware deployment and end-to-end acceptance, not as a completed product fix. 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 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_PRESENT=0
CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE=0 CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE=0
CAPTURE_DISCOVERY_POWER_BORROWED=0 CAPTURE_DISCOVERY_POWER_BORROWED=0
@ -1498,6 +1534,7 @@ pacman_install git \
abseil-cpp \ abseil-cpp \
gcc \ gcc \
alsa-utils \ alsa-utils \
libgpiod \
"${PIPEWIRE_PACKAGES[@]}" \ "${PIPEWIRE_PACKAGES[@]}" \
tailscale \ tailscale \
base-devel \ base-devel \
@ -1525,6 +1562,7 @@ fi
echo "==> 1c. GPIO permissions for relay" echo "==> 1c. GPIO permissions for relay"
echo 'z /dev/gpiochip* 0660 root gpio -' | sudo tee /etc/tmpfiles.d/gpiochip.conf >/dev/null 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 sudo systemd-tmpfiles --create /etc/tmpfiles.d/gpiochip.conf || true
install_capture_power_relay_unit
echo "==> 1d. Audio permissions for diagnostics" echo "==> 1d. Audio permissions for diagnostics"
if getent group audio >/dev/null 2>&1 && [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then if getent group audio >/dev/null 2>&1 && [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then

View File

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

View File

@ -9,6 +9,44 @@ const SERVER_INSTALL: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/scripts/install/server.sh" "/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] #[test]
fn server_install_infers_checkout_owner_when_root_wrapper_hides_sudo_user() { fn server_install_infers_checkout_owner_when_root_wrapper_hides_sudo_user() {