install: preserve live uvc profile on upgrades

This commit is contained in:
Brad Stein 2026-05-11 13:03:33 -03:00
parent 0f1fe89138
commit f9d23526c5
8 changed files with 121 additions and 43 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.22.4"
version = "0.22.5"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.22.4"
version = "0.22.5"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.22.4"
version = "0.22.5"
dependencies = [
"anyhow",
"base64",

View File

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

View File

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

View File

@ -7,11 +7,43 @@ SCRIPT_REPO_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd)
DEFAULT_REPO_URL=ssh://git@scm.bstein.dev:2242/bstein/lesavka.git
export TMPDIR=${TMPDIR:-/var/tmp}
persisted_env_value() {
local file=$1
local key=$2
[[ -r $file ]] || return 1
awk -F= -v key="$key" '
$0 ~ /^[[:space:]]*#/ { next }
$1 == key {
sub(/^[^=]*=/, "")
print
found=1
exit
}
END { if (!found) exit 1 }
' "$file"
}
persisted_uvc_value() {
persisted_env_value /etc/lesavka/uvc.env "$1"
}
uvc_env_value() {
local key=$1
local default=$2
if [[ -n ${!key+x} ]]; then
printf '%s\n' "${!key}"
return 0
fi
persisted_uvc_value "$key" && return 0
printf '%s\n' "$default"
}
REF=${LESAVKA_REF:-master} # fallback
REPO_URL=${LESAVKA_REPO_URL:-}
INSTALL_SOURCE=${LESAVKA_INSTALL_SOURCE:-auto}
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}
PERSISTED_UVC_CODEC=$(persisted_uvc_value LESAVKA_UVC_CODEC || true)
INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}}
INSTALL_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}
INSTALL_UPLINK_AUDIO_CODEC=${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}}
INSTALL_UVC_FRAME_META=${LESAVKA_INSTALL_UVC_FRAME_META:-${LESAVKA_UVC_FRAME_META:-0}}
@ -238,19 +270,19 @@ render_uvc_env_file() {
cat <<EOF
# generated by lesavka/scripts/install/server.sh
# Edit only for local UVC hardware overrides; rerunning the installer refreshes defaults.
LESAVKA_UVC_DEBUG=${LESAVKA_UVC_DEBUG:-1}
LESAVKA_UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
LESAVKA_UVC_LIMIT_PCT=${LESAVKA_UVC_LIMIT_PCT:-100}
LESAVKA_UVC_FPS=${LESAVKA_UVC_FPS:-30}
LESAVKA_UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-333333}
LESAVKA_UVC_WIDTH=${LESAVKA_UVC_WIDTH:-1280}
LESAVKA_UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-720}
LESAVKA_UVC_DEBUG=$(uvc_env_value LESAVKA_UVC_DEBUG 1)
LESAVKA_UVC_MAXPACKET=$(uvc_env_value LESAVKA_UVC_MAXPACKET 1024)
LESAVKA_UVC_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_LIMIT_PCT 100)
LESAVKA_UVC_FPS=$(uvc_env_value LESAVKA_UVC_FPS 30)
LESAVKA_UVC_INTERVAL=$(uvc_env_value LESAVKA_UVC_INTERVAL 333333)
LESAVKA_UVC_WIDTH=$(uvc_env_value LESAVKA_UVC_WIDTH 1280)
LESAVKA_UVC_HEIGHT=$(uvc_env_value LESAVKA_UVC_HEIGHT 720)
LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}
LESAVKA_UVC_BLOCKING=${LESAVKA_UVC_BLOCKING:-1}
LESAVKA_UVC_CONTROL_READ_ONLY=${LESAVKA_UVC_CONTROL_READ_ONLY:-0}
LESAVKA_UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-0}
LESAVKA_UVC_FRAME_MAX_BYTES=${LESAVKA_UVC_FRAME_MAX_BYTES:-0}
LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-10000000}
LESAVKA_UVC_BLOCKING=$(uvc_env_value LESAVKA_UVC_BLOCKING 1)
LESAVKA_UVC_CONTROL_READ_ONLY=$(uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0)
LESAVKA_UVC_MAXBURST=$(uvc_env_value LESAVKA_UVC_MAXBURST 0)
LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0)
LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=$(uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC 10000000)
EOF
}
@ -439,6 +471,10 @@ uvc_gadget_present() {
[[ -d /sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0 ]]
}
live_gadget_bound() {
[[ -s /sys/kernel/config/usb_gadget/lesavka/UDC ]]
}
live_uvc_descriptor_codec() {
local function_root=/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0
if [[ -e "$function_root/streaming/header/h/mjpeg" ]]; then
@ -1365,26 +1401,34 @@ if sudo test -f /etc/lesavka/uvc.env && sudo cmp -s "$UVC_ENV_TMP" /etc/lesavka/
UVC_ENV_CHANGED=0
fi
UDC_STATE=$(udc_state)
LIVE_GADGET_BOUND=0
if live_gadget_bound; then
LIVE_GADGET_BOUND=1
fi
HOST_GADGET_PROTECTED=0
if is_attached_state "$UDC_STATE" || [[ "$LIVE_GADGET_BOUND" == "1" ]]; then
HOST_GADGET_PROTECTED=1
fi
EXPLICIT_GADGET_REBUILD=0
if [[ -n ${LESAVKA_FORCE_GADGET_REBUILD:-} ]]; then
EXPLICIT_GADGET_REBUILD=1
fi
LIVE_UVC_DESCRIPTOR_MISMATCH=0
if is_attached_state "$UDC_STATE" && uvc_gadget_present && ! live_uvc_descriptor_matches_request; then
if [[ "$HOST_GADGET_PROTECTED" == "1" ]] && uvc_gadget_present && ! live_uvc_descriptor_matches_request; then
LIVE_UVC_DESCRIPTOR_MISMATCH=1
fi
LIVE_UVC_FUNCTION_MISSING=0
if [[ -z ${LESAVKA_DISABLE_UVC:-} ]] && is_attached_state "$UDC_STATE" && ! uvc_gadget_present; then
if [[ -z ${LESAVKA_DISABLE_UVC:-} ]] && [[ "$HOST_GADGET_PROTECTED" == "1" ]] && ! uvc_gadget_present; then
LIVE_UVC_FUNCTION_MISSING=1
fi
ATTACHED_UVC_CHANGE_DEFERRED=0
if is_attached_state "$UDC_STATE" \
if [[ "$HOST_GADGET_PROTECTED" == "1" ]] \
&& { [[ "$LIVE_UVC_DESCRIPTOR_MISMATCH" == "1" ]] || [[ "$LIVE_UVC_FUNCTION_MISSING" == "1" ]]; } \
&& { [[ "$EXPLICIT_GADGET_REBUILD" != "1" ]] || [[ -z ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; }; then
ATTACHED_UVC_CHANGE_DEFERRED=1
fi
ATTACHED_UVC_RESTART_DEFERRED=0
if is_attached_state "$UDC_STATE" \
if [[ "$HOST_GADGET_PROTECTED" == "1" ]] \
&& [[ "$UVC_ENV_CHANGED" == "1" ]] \
&& [[ "$LIVE_UVC_DESCRIPTOR_MISMATCH" != "1" ]] \
&& [[ "$LIVE_UVC_FUNCTION_MISSING" != "1" ]] \
@ -1449,12 +1493,10 @@ cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-server.service >/dev/null
[Unit]
Description=lesavka gRPC relay
After=network.target lesavka-core.service lesavka-uvc.service
Wants=lesavka-uvc.service
StartLimitIntervalSec=30
StartLimitBurst=10
[Service]
ExecStartPre=/usr/local/bin/lesavka-core.sh --attach
ExecStart=/usr/local/bin/lesavka-server
TimeoutStopSec=10
KillSignal=SIGTERM
@ -1495,12 +1537,12 @@ if [[ -z ${LESAVKA_DISABLE_UVC:-} ]] && ! uvc_gadget_present; then
GADGET_REBUILD_REASON="UVC function is missing from the live gadget"
echo "⚠️ UVC function is missing from the live gadget; forcing a rebuild before server start."
fi
if [[ "$UVC_ENV_CHANGED" == "1" ]] && is_attached_state "$UDC_STATE"; then
if [[ "$UVC_ENV_CHANGED" == "1" ]] && [[ "$HOST_GADGET_PROTECTED" == "1" ]]; then
FORCE_GADGET_REBUILD=1
GADGET_REBUILD_REASON="UVC runtime settings changed while the host is attached"
echo "⚠️ UVC runtime settings changed while the host is attached; forcing a gadget rebuild so the new descriptors take effect."
fi
if [[ "$LIVE_UVC_DESCRIPTOR_MISMATCH" == "1" ]] && is_attached_state "$UDC_STATE"; then
if [[ "$LIVE_UVC_DESCRIPTOR_MISMATCH" == "1" ]] && [[ "$HOST_GADGET_PROTECTED" == "1" ]]; then
FORCE_GADGET_REBUILD=1
GADGET_REBUILD_REASON="live UVC descriptor does not match requested codec ${INSTALL_UVC_CODEC}"
echo "⚠️ live UVC descriptor does not match requested codec ${INSTALL_UVC_CODEC}; forcing a gadget rebuild so descriptors and runtime agree."
@ -1511,14 +1553,14 @@ if [[ -n ${LESAVKA_FORCE_GADGET_REBUILD:-} ]]; then
GADGET_REBUILD_REASON="explicit LESAVKA_FORCE_GADGET_REBUILD request"
echo "⚠️ explicit LESAVKA_FORCE_GADGET_REBUILD request; forcing a gadget rebuild before server start."
fi
if [[ "$FORCE_GADGET_REBUILD" == "1" ]] && is_attached_state "$UDC_STATE" \
if [[ "$FORCE_GADGET_REBUILD" == "1" ]] && [[ "$HOST_GADGET_PROTECTED" == "1" ]] \
&& { [[ "$EXPLICIT_GADGET_REBUILD" != "1" ]] || [[ -z ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; }; then
echo "⚠️ ${GADGET_REBUILD_REASON:-Gadget state} requires a rebuild, but UDC state is '$UDC_STATE' and attached-host hard reset was not explicitly allowed." >&2
echo "⚠️ ${GADGET_REBUILD_REASON:-Gadget state} requires a rebuild, but UDC state is '$UDC_STATE' (bound=${LIVE_GADGET_BOUND}) and attached-host hard reset was not explicitly allowed." >&2
echo " Preserving the attached gadget to avoid wedging the Pi USB controller." >&2
echo " Run during a maintenance window with LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1 if a hard rebuild is required." >&2
FORCE_GADGET_REBUILD=0
fi
if [[ "$FORCE_GADGET_REBUILD" == "1" ]] || ! is_attached_state "$UDC_STATE"; then
if [[ "$FORCE_GADGET_REBUILD" == "1" ]] || [[ "$HOST_GADGET_PROTECTED" != "1" ]]; then
echo "⚠️ UDC state is '$UDC_STATE' - forcing a Lesavka gadget rebuild before server start."
sudo systemctl stop lesavka-server >/dev/null 2>&1 || true
sudo systemctl stop lesavka-uvc >/dev/null 2>&1 || true
@ -1539,7 +1581,7 @@ if [[ "$FORCE_GADGET_REBUILD" == "1" ]] || ! is_attached_state "$UDC_STATE"; the
elif [[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; then
echo "✅ UDC state is '$UDC_STATE'; LESAVKA_ALLOW_GADGET_RESET permits recovery, but no hard gadget rebuild is needed."
else
echo "⚠️ UDC state is '$UDC_STATE' - skipping lesavka-core restart."
echo "⚠️ UDC state is '$UDC_STATE' (bound=${LIVE_GADGET_BOUND}) - skipping lesavka-core restart."
echo " Set LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1 to force during a maintenance window."
fi
@ -1579,7 +1621,16 @@ sudo rm -f /etc/systemd/system/lesavka-watchdog.timer \
echo "==> 6e. Systemd units - recovery ladder"
echo "✅ recovery ladder unit files installed and timer enabled; activation waits for successful service verification."
if [[ "$UVC_ENV_CHANGED" == "1" ]] && systemctl is-active --quiet lesavka-uvc; then
CAN_TOUCH_UVC_SERVICE=1
if [[ "$HOST_GADGET_PROTECTED" == "1" ]] \
&& { [[ "$EXPLICIT_GADGET_REBUILD" != "1" ]] || [[ -z ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; }; then
CAN_TOUCH_UVC_SERVICE=0
fi
if [[ "$CAN_TOUCH_UVC_SERVICE" != "1" ]] && systemctl is-active --quiet lesavka-uvc; then
echo "✅ lesavka-uvc already active; preserving it during attached-gadget version update."
elif [[ "$CAN_TOUCH_UVC_SERVICE" != "1" ]]; then
echo "⚠️ lesavka-uvc is inactive, but the host/gadget is protected; not starting it during a version-only install." >&2
elif [[ "$UVC_ENV_CHANGED" == "1" ]] && systemctl is-active --quiet lesavka-uvc; then
sudo systemctl restart lesavka-uvc
echo "✅ lesavka-uvc restarted with the refreshed UVC runtime settings."
elif systemctl is-active --quiet lesavka-uvc; then

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.22.4"
version = "0.22.5"
edition = "2024"
autobins = false

View File

@ -61,7 +61,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
SERVER_INSTALL
.contains("${LESAVKA_INSTALL_UPLINK_AUDIO_CODEC:-${LESAVKA_UPLINK_AUDIO_CODEC:-pcm}}")
);
assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}"));
assert!(
SERVER_INSTALL.contains(
"INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}}"
),
"plain upgrade installs should preserve the already-installed UVC codec"
);
assert!(
SERVER_INSTALL.contains("${LESAVKA_INSTALL_UVC_FRAME_META:-${LESAVKA_UVC_FRAME_META:-0}}")
);
@ -145,11 +150,11 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
"installer should persist HEVC-specific calibration maps"
);
assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_MAXPACKET:-1024}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_INTERVAL:-333333}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_WIDTH:-1280}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEIGHT:-720}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_CONTROL_READ_ONLY:-0}"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_MAXPACKET 1024"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_INTERVAL 333333"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_WIDTH 1280"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_HEIGHT 720"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0"));
assert!(
!SERVER_INSTALL.contains("LESAVKA_UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}"),
"install script should not let ambient LESAVKA_UVC_CODEC leak into persisted defaults"
@ -239,6 +244,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
SERVER_INSTALL.contains("LIVE_UVC_FUNCTION_MISSING"),
"installer should not rebuild a missing attached UVC function without explicit maintenance-window force"
);
assert!(
SERVER_INSTALL.contains("live_gadget_bound")
&& SERVER_INSTALL.contains("HOST_GADGET_PROTECTED=1")
&& SERVER_INSTALL.contains("bound=${LIVE_GADGET_BOUND}"),
"installer should protect a bound gadget even when the UDC state is ambiguous"
);
assert!(
SERVER_INSTALL.contains("ATTACHED_UVC_RESTART_DEFERRED=1"),
"attached UVC env changes that match the live descriptor should still defer service restarts"
@ -338,6 +349,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
SERVER_INSTALL.contains("lesavka-uvc already active; runtime settings unchanged."),
"install script should avoid unnecessary UVC restarts when nothing changed"
);
assert!(
SERVER_INSTALL.contains("CAN_TOUCH_UVC_SERVICE=0")
&& SERVER_INSTALL.contains("preserving it during attached-gadget version update")
&& SERVER_INSTALL.contains("not starting it during a version-only install"),
"ordinary attached-gadget version updates must not start or restart the UVC helper"
);
assert!(
SERVER_INSTALL.contains("sudo systemctl start lesavka-uvc"),
"install script should start the UVC helper so the host enumerates the UVC function"
@ -347,8 +364,9 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
"install script should report when it starts the UVC helper for enumeration"
);
assert!(
SERVER_INSTALL.contains("Wants=lesavka-uvc.service"),
"server unit should pull in the external UVC helper on UVC installs"
!SERVER_INSTALL.contains("Wants=lesavka-uvc.service")
&& !SERVER_INSTALL.contains("ExecStartPre=/usr/local/bin/lesavka-core.sh --attach"),
"server restarts during ordinary installs must not implicitly start UVC or touch the gadget"
);
assert!(
!SERVER_INSTALL.contains("Environment=LESAVKA_ALLOW_GADGET_CYCLE=1"),

View File

@ -18,11 +18,13 @@ const CLIENT_CAMERA: &str = include_str!(concat!(
#[test]
fn server_install_defaults_to_hevc_ingress_and_mjpeg_uvc_output() {
for marker in [
"INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}",
"PERSISTED_UVC_CODEC=$(persisted_uvc_value LESAVKA_UVC_CODEC || true)",
"INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}}",
"INSTALL_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}",
"printf 'LESAVKA_CAM_CODEC=%s\\n' \"${INSTALL_CAM_CODEC}\"",
"printf 'LESAVKA_UVC_CODEC=%s\\n' \"${INSTALL_UVC_CODEC}\"",
"\"LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}\"",
"uvc_env_value LESAVKA_UVC_WIDTH 1280",
] {
assert!(
SERVER_INSTALL.contains(marker),

View File

@ -16,10 +16,8 @@ fn generated_systemd_units_load_runtime_env_files_in_the_right_places() {
for marker in [
"EnvironmentFile=-/etc/lesavka/server.env",
"EnvironmentFile=-/etc/lesavka/uvc.env",
"ExecStartPre=/usr/local/bin/lesavka-core.sh --attach",
"ExecStart=/usr/local/bin/lesavka-server",
"ExecStart=/usr/local/bin/lesavka-uvc.sh",
"Wants=lesavka-uvc.service",
"Requires=lesavka-core.service",
] {
assert!(
@ -27,6 +25,15 @@ fn generated_systemd_units_load_runtime_env_files_in_the_right_places() {
"generated units should preserve marker {marker}"
);
}
for marker in [
"ExecStartPre=/usr/local/bin/lesavka-core.sh --attach",
"Wants=lesavka-uvc.service",
] {
assert!(
!SERVER_INSTALL.contains(marker),
"server restarts should not implicitly touch gadget/UVC setup through {marker}"
);
}
}
#[test]