fix(server): own capture relay runtime
This commit is contained in:
parent
164ef497f0
commit
2621f7dead
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
47
scripts/daemon/lesavka-relay-hold.py
Executable file
47
scripts/daemon/lesavka-relay-hold.py
Executable 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()
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user