diff --git a/Cargo.lock b/Cargo.lock index af3e680..ac97750 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.26.6" +version = "0.27.0" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.26.6" +version = "0.27.0" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.26.6" +version = "0.27.0" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 5d7de79..6f25dbf 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.26.6" +version = "0.27.0" edition = "2024" [dependencies] diff --git a/client/src/input/mouse.rs b/client/src/input/mouse.rs index 8c42eed..c310d7a 100644 --- a/client/src/input/mouse.rs +++ b/client/src/input/mouse.rs @@ -46,7 +46,6 @@ impl MouseEventState<'_> { *self.last_abs_x = None; *self.last_abs_y = None; } - self.set_btn(0, event.value()); } _ => {} }, diff --git a/client/src/input/mouse_event_contract_tests.rs b/client/src/input/mouse_event_contract_tests.rs index c9264f2..60f947e 100644 --- a/client/src/input/mouse_event_contract_tests.rs +++ b/client/src/input/mouse_event_contract_tests.rs @@ -132,11 +132,20 @@ fn key_events_update_button_bits_and_touch_release_clears_origins() { state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 1)); state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_RIGHT.0, 1)); state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_MIDDLE.0, 1)); - state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 1)); state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::KEY_A.0, 1)); } assert_eq!(harness.buttons & 0b111, 0b111); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 0)); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_RIGHT.0, 0)); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_MIDDLE.0, 0)); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 1)); + } + + assert_eq!(harness.buttons & 0b111, 0); assert!(harness.touch_guarded); assert!(harness.touch_active); @@ -148,7 +157,43 @@ fn key_events_update_button_bits_and_touch_release_clears_origins() { assert!(!harness.touch_active); assert!(harness.last_abs_x.is_none()); assert!(harness.last_abs_y.is_none()); + assert_eq!(harness.buttons & 0b111, 0); +} + +#[test] +fn touch_contact_tracks_motion_without_pressing_left_button() { + let mut harness = StateHarness::with_touch_state(); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 1)); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_POSITION_X.0, + 1000, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_POSITION_Y.0, + 1000, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_POSITION_X.0, + 1040, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_POSITION_Y.0, + 984, + )); + } + + assert!(harness.touch_guarded); + assert!(harness.touch_active); assert_eq!(harness.buttons & 0b001, 0); + assert_eq!(harness.dx, 5); + assert_eq!(harness.dy, -2); } #[test] diff --git a/common/Cargo.toml b/common/Cargo.toml index cd3180e..e5f83bb 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.26.6" +version = "0.27.0" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index ac1bcb2..d62ca12 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -458,7 +458,7 @@ These entries are intentionally concise because most are manual lab or CI harnes | `LESAVKA_HEVC_REENTRY_SYNC` | manual HEVC re-entry helper toggle; when `1`, rsyncs the local workspace to Theia before optional build/deploy | | `LESAVKA_HEVC_REENTRY_WAIT_INTERVAL_SECONDS` | manual HEVC re-entry helper retry interval while waiting for SSH after a lab host outage, defaults to `15` | | `LESAVKA_HEVC_REENTRY_WAIT_SECONDS` | manual HEVC re-entry helper reachability wait budget; when greater than `0`, polls SSH before status/build/deploy/reconfigure instead of failing immediately | -| `LESAVKA_INSTALL_CAM_CODEC` | server installer camera ingress codec default; persists `LESAVKA_CAM_CODEC` for installed services, defaults to `hevc`; HEVC installs probe the hardware decoder and warn with a smoke log when the synthetic probe fails, while runtime still refuses software video fallback in production | +| `LESAVKA_INSTALL_CAM_CODEC` | server installer camera ingress codec override; persists `LESAVKA_CAM_CODEC` for installed services, defaults to `mjpeg`; explicit HEVC installs probe the hardware decoder and refuse the install when hardware decode cannot be proven | | `LESAVKA_INSTALL_ALLOW_SOFTWARE_VIDEO` | installer-only video acceleration override; persists the software fallback allowance for lab/debug installs without changing normal runtime defaults | | `LESAVKA_INSTALL_HEVC_DECODER` | installer-only HEVC decoder override; persists the selected hardware decoder when repairing or pinning a known-good UVC bridge path | | `LESAVKA_INSTALL_SOURCE` | install script source selector; use `ref` to fetch the requested git ref instead of building the existing local checkout | diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index c2bbac2..abac51e 100755 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -171,7 +171,8 @@ UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-1} UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-} UVC_WIDTH=${LESAVKA_UVC_WIDTH:-1280} UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-720} -UVC_FPS=${LESAVKA_UVC_FPS:-30} +UVC_FPS=${LESAVKA_UVC_FPS:-20} +UVC_ADVERTISE_EXTRA_MODES=${LESAVKA_UVC_ADVERTISE_EXTRA_MODES:-0} UVC_DISABLE_IRQ=${LESAVKA_UVC_DISABLE_IRQ:-} flag_enabled() { case "${1,,}" in @@ -371,6 +372,10 @@ if [[ -z ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then fi uvc_selected_frame_index() { + if ! flag_enabled "$UVC_ADVERTISE_EXTRA_MODES"; then + echo 1 + return + fi case "${UVC_WIDTH}x${UVC_HEIGHT}" in 1920x1080) echo 1 ;; 1280x720) echo 2 ;; @@ -378,6 +383,21 @@ uvc_selected_frame_index() { esac } +write_active_mjpeg_frame_descriptor() { + case "${UVC_WIDTH}x${UVC_HEIGHT}" in + 1920x1080) + write_mjpeg_frame_descriptor 1080p 1920 1080 + ;; + 1280x720) + write_mjpeg_frame_descriptor 720p 1280 720 + ;; + *) + log "unsupported MJPEG UVC profile ${UVC_WIDTH}x${UVC_HEIGHT}; advertising safe 720p" + write_mjpeg_frame_descriptor 720p 1280 720 + ;; + esac +} + uvc_frame_size_for() { local width=$1 height=$2 if [[ $width == "$UVC_WIDTH" && $height == "$UVC_HEIGHT" && -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then @@ -399,16 +419,22 @@ uvc_default_interval_for() { write_mjpeg_frame_descriptor() { local name=$1 width=$2 height=$3 local frame="$F/streaming/mjpeg/m/$name" + local default_interval + default_interval="$(uvc_default_interval_for "$width" "$height")" mkdir -p "$frame" echo 0 >"$frame/bmCapabilities" echo "$width" >"$frame/wWidth" echo "$height" >"$frame/wHeight" echo "$(uvc_frame_size_for "$width" "$height")" >"$frame/dwMaxVideoFrameBufferSize" - echo "$(uvc_default_interval_for "$width" "$height")" >"$frame/dwDefaultFrameInterval" - cat <"$frame/dwFrameInterval" + echo "$default_interval" >"$frame/dwDefaultFrameInterval" + if flag_enabled "$UVC_ADVERTISE_EXTRA_MODES"; then + cat <"$frame/dwFrameInterval" ${UVC_INTERVAL_30} ${UVC_INTERVAL_20} EOF + else + echo "$default_interval" >"$frame/dwFrameInterval" + fi } wait_for_enum() { @@ -638,8 +664,12 @@ if [[ -z $DISABLE_UVC ]]; then echo "$(uvc_selected_frame_index)" >"$F/streaming/mjpeg/m/bDefaultFrameIndex" 2>/dev/null || true echo 0 >"$F/streaming/mjpeg/m/bmaControls" 2>/dev/null || true - write_mjpeg_frame_descriptor 1080p 1920 1080 - write_mjpeg_frame_descriptor 720p 1280 720 + if flag_enabled "$UVC_ADVERTISE_EXTRA_MODES"; then + write_mjpeg_frame_descriptor 1080p 1920 1080 + write_mjpeg_frame_descriptor 720p 1280 720 + else + write_active_mjpeg_frame_descriptor + fi else # uncompressed YUY2, 16 bpp mkdir -p "$F/streaming/uncompressed/yuyv" diff --git a/scripts/daemon/lesavka-uvc.sh b/scripts/daemon/lesavka-uvc.sh index 20cabcd..6c90fed 100755 --- a/scripts/daemon/lesavka-uvc.sh +++ b/scripts/daemon/lesavka-uvc.sh @@ -21,6 +21,21 @@ load_uvc_env_defaults() { load_uvc_env_defaults +migrate_unsafe_uvc_defaults() { + if [[ ${LESAVKA_UVC_BUFFER_COUNT:-} == "1" && -z ${LESAVKA_UVC_ALLOW_SINGLE_BUFFER:-} ]]; then + echo "[lesavka-uvc] migrating legacy LESAVKA_UVC_BUFFER_COUNT=1 to 4; set LESAVKA_UVC_ALLOW_SINGLE_BUFFER=1 to keep the old behavior" >&2 + export LESAVKA_UVC_BUFFER_COUNT=4 + fi + if [[ ${LESAVKA_UVC_QUEUE_PACING:-} == "1" && -z ${LESAVKA_UVC_ALLOW_QUEUE_PACING:-} ]]; then + echo "[lesavka-uvc] migrating legacy LESAVKA_UVC_QUEUE_PACING=1 to 0; set LESAVKA_UVC_ALLOW_QUEUE_PACING=1 to keep the old behavior" >&2 + export LESAVKA_UVC_QUEUE_PACING=0 + fi + export LESAVKA_UVC_BUFFER_COUNT=${LESAVKA_UVC_BUFFER_COUNT:-4} + export LESAVKA_UVC_QUEUE_PACING=${LESAVKA_UVC_QUEUE_PACING:-0} +} + +migrate_unsafe_uvc_defaults + resolve_default_uvc_dev() { local ctrl="" ctrl=$(ls /sys/class/udc 2>/dev/null | head -n1 || true) diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 18db7c0..30d55b4 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -108,7 +108,7 @@ INSTALL_CAM_CODEC_EXPLICIT=0 if [[ -n "${LESAVKA_INSTALL_CAM_CODEC+x}" || -n "${LESAVKA_CAM_CODEC+x}" ]]; then INSTALL_CAM_CODEC_EXPLICIT=1 fi -REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}} +REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}} INSTALL_CAM_CODEC=$(normalize_cam_codec "${REQUESTED_CAM_CODEC}") INSTALL_HEVC_DECODER=${LESAVKA_INSTALL_HEVC_DECODER:-${LESAVKA_HEVC_DECODER:-}} INSTALL_ALLOW_SOFTWARE_VIDEO=${LESAVKA_INSTALL_ALLOW_SOFTWARE_VIDEO:-${LESAVKA_ALLOW_SOFTWARE_VIDEO:-0}} @@ -172,7 +172,7 @@ lookup_mode_offset_us() { default_uvc_mode() { local width=${LESAVKA_UVC_WIDTH:-1280} local height=${LESAVKA_UVC_HEIGHT:-720} - local fps=${LESAVKA_UVC_FPS:-30} + local fps=${LESAVKA_UVC_FPS:-20} printf '%sx%s@%s\n' "${width}" "${height}" "${fps}" } @@ -302,17 +302,6 @@ ensure_hevc_decode_support() { fi done - if [[ -n "$software_hevc_decoder" && "$INSTALL_CAM_CODEC_EXPLICIT" == "0" ]]; then - echo "⚠️ hardware HEVC decoder is exposed but the synthetic 1280x720 decode smoke failed: $hevc_decoder" >&2 - echo " smoke log: $hevc_smoke_log" >&2 - sed -n '1,120p' "$hevc_smoke_log" >&2 || true - echo " Software HEVC decoder passed the same 1280x720 smoke: $software_hevc_decoder" >&2 - echo " Keeping default HEVC upstream to avoid direct-MJPEG UVC artifacts; monitor CPU/RSS during field runs." >&2 - INSTALL_HEVC_DECODER=$software_hevc_decoder - INSTALL_ALLOW_SOFTWARE_VIDEO=1 - return 0 - fi - if [[ "$INSTALL_CAM_CODEC_EXPLICIT" == "0" ]]; then echo "⚠️ hardware HEVC decoder is exposed but the synthetic 1280x720 decode smoke failed: $hevc_decoder" >&2 echo " smoke log: $hevc_smoke_log" >&2 @@ -423,14 +412,15 @@ render_uvc_env_file() { 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_FPS=$(uvc_env_value LESAVKA_UVC_FPS 20) +LESAVKA_UVC_INTERVAL=$(uvc_env_value LESAVKA_UVC_INTERVAL 500000) 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_BUFFER_COUNT=$(uvc_env_value LESAVKA_UVC_BUFFER_COUNT 4) 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_QUEUE_PACING=$(uvc_env_value LESAVKA_UVC_QUEUE_PACING 1) +LESAVKA_UVC_QUEUE_PACING=$(uvc_env_value LESAVKA_UVC_QUEUE_PACING 0) LESAVKA_UVC_MAXBURST=$(uvc_env_value LESAVKA_UVC_MAXBURST 0) LESAVKA_UVC_BULK=$(uvc_env_value LESAVKA_UVC_BULK 1) LESAVKA_UVC_FRAME_SIZE_GUARD=$(uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1) @@ -694,8 +684,8 @@ live_uvc_descriptor_matches_request() { [[ -r "$frame_root/wWidth" && -r "$frame_root/wHeight" && -r "$frame_root/dwDefaultFrameInterval" ]] || return 1 [[ "$(cat "$frame_root/wWidth" 2>/dev/null || true)" == "${LESAVKA_UVC_WIDTH:-1280}" ]] || return 1 [[ "$(cat "$frame_root/wHeight" 2>/dev/null || true)" == "${LESAVKA_UVC_HEIGHT:-720}" ]] || return 1 - grep -qx "${LESAVKA_UVC_INTERVAL:-333333}" "$frame_root/dwFrameInterval" 2>/dev/null \ - || [[ "$(cat "$frame_root/dwDefaultFrameInterval" 2>/dev/null || true)" == "${LESAVKA_UVC_INTERVAL:-333333}" ]] + grep -qx "${LESAVKA_UVC_INTERVAL:-500000}" "$frame_root/dwFrameInterval" 2>/dev/null \ + || [[ "$(cat "$frame_root/dwDefaultFrameInterval" 2>/dev/null || true)" == "${LESAVKA_UVC_INTERVAL:-500000}" ]] } live_uac_descriptor_matches_request() { @@ -1631,6 +1621,7 @@ SERVER_ENV_TMP=$(mktemp) printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR" fi printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}" + printf 'LESAVKA_CAM_HDMI_MIRROR=%s\n' "${LESAVKA_INSTALL_CAM_HDMI_MIRROR:-${LESAVKA_CAM_HDMI_MIRROR:-0}}" printf 'LESAVKA_CAM_CODEC=%s\n' "${INSTALL_CAM_CODEC}" printf 'LESAVKA_UPLINK_CAMERA_CODEC=%s\n' "${INSTALL_CAM_CODEC}" if [[ -n "$INSTALL_HEVC_DECODER" ]]; then @@ -1708,8 +1699,8 @@ SERVER_ENV_TMP=$(mktemp) printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}" printf 'LESAVKA_UVC_WIDTH=%s\n' "${LESAVKA_UVC_WIDTH:-1280}" printf 'LESAVKA_UVC_HEIGHT=%s\n' "${LESAVKA_UVC_HEIGHT:-720}" - printf 'LESAVKA_UVC_FPS=%s\n' "${LESAVKA_UVC_FPS:-30}" - printf 'LESAVKA_UVC_INTERVAL=%s\n' "${LESAVKA_UVC_INTERVAL:-333333}" + printf 'LESAVKA_UVC_FPS=%s\n' "${LESAVKA_UVC_FPS:-20}" + printf 'LESAVKA_UVC_INTERVAL=%s\n' "${LESAVKA_UVC_INTERVAL:-500000}" printf 'LESAVKA_UVC_FRAME_META=%s\n' "${INSTALL_UVC_FRAME_META}" printf 'LESAVKA_UVC_FRAME_META_LOG_PATH=%s\n' "${INSTALL_UVC_FRAME_META_LOG_PATH}" printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}" @@ -2008,6 +1999,7 @@ sudo systemctl start lesavka-recovery-ladder.timer INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true) INSTALLED_SHA=$(git -C "$SCRIPT_REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true) PERSISTED_CAM_OUTPUT=$(grep '^LESAVKA_CAM_OUTPUT=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true) +PERSISTED_CAM_HDMI_MIRROR=$(grep '^LESAVKA_CAM_HDMI_MIRROR=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true) PERSISTED_SERVER_BIND_ADDR=$(grep '^LESAVKA_SERVER_BIND_ADDR=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true) PERSISTED_UVC_CODEC=$(grep '^LESAVKA_UVC_CODEC=' /etc/lesavka/uvc.env 2>/dev/null | tail -n1 | cut -d= -f2- || true) echo "✅ lesavka-server installed and restarted..." @@ -2020,6 +2012,9 @@ fi if [[ -n $PERSISTED_CAM_OUTPUT ]]; then echo "➡️ Camera output: ${PERSISTED_CAM_OUTPUT}" fi +if [[ -n $PERSISTED_CAM_HDMI_MIRROR ]]; then + echo "➡️ HDMI mirror: ${PERSISTED_CAM_HDMI_MIRROR}" +fi if [[ -n $PERSISTED_UVC_CODEC ]]; then echo "➡️ UVC codec: ${PERSISTED_UVC_CODEC}" fi diff --git a/server/Cargo.toml b/server/Cargo.toml index ae21d9d..471211c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.26.6" +version = "0.27.0" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index 790fde5..bb5bda9 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -48,12 +48,12 @@ const V4L2_PIX_FMT_MJPEG: u32 = u32::from_le_bytes(*b"MJPG"); const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; const IDLE_MJPEG_FRAME: &[u8] = include_bytes!("lesavka_uvc/idle_1280x720_black.jpg"); -// Keep the kernel from holding several future frames at once; if USB falls -// behind, a short stutter is safer than mixing payload from different frames. -const DEFAULT_UVC_BUFFER_COUNT: u32 = 1; +// Keep enough output buffers queued that the USB gadget is not starved while a +// browser is consuming high-speed isochronous MJPEG. +const DEFAULT_UVC_BUFFER_COUNT: u32 = 4; const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; -const DEFAULT_UVC_QUEUE_PACING: bool = true; +const DEFAULT_UVC_QUEUE_PACING: bool = false; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000; const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; @@ -504,32 +504,41 @@ impl UvcVideoStream { fn refresh_latest_frame(&mut self) { let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age()); - if stale && looks_like_mjpeg_frame(&self.latest_frame) { + if stale { self.stats.replayed_stale += 1; + self.replace_latest_with_idle(); return; } let max_frame_bytes = self.frame_payload_limit(); match std::fs::read(&self.frame_path) { Ok(frame) if !looks_like_mjpeg_frame(&frame) => { self.stats.rejected_invalid += 1; + self.replace_latest_with_idle(); } Ok(frame) if frame.len() > max_frame_bytes => { self.stats.rejected_oversize += 1; self.stats.last_rejected_oversize_bytes = frame.len(); self.stats.last_rejected_oversize_cap = max_frame_bytes; + self.replace_latest_with_idle(); } Ok(frame) => { self.stats.reloaded += 1; self.latest_frame = frame; } - Err(_) => {} + Err(_) => self.replace_latest_with_idle(), } if !looks_like_mjpeg_frame(&self.latest_frame) { - self.stats.fallback_idle += 1; - self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); + self.replace_latest_with_idle(); } } + fn replace_latest_with_idle(&mut self) { + if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME { + self.stats.fallback_idle += 1; + } + self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); + } + fn frame_payload_limit(&self) -> usize { self.buffers .iter() @@ -1542,9 +1551,8 @@ fn sanitize_streaming_control(data: &[u8], state: &UvcState) -> [u8; STREAM_CTRL &mut out[18..22], uvc_frame_size_for_index(frame_index, state.cfg.frame_size), ); - if interval != 0 { - write_le32(&mut out[4..8], interval); - } + let _host_requested_interval = interval; + write_le32(&mut out[4..8], state.cfg.interval); if host_payload > 0 { let payload = host_payload.min(state.cfg.max_packet); write_le32(&mut out[22..26], payload); @@ -1610,6 +1618,9 @@ fn build_streaming_control(cfg: &UvcConfig, ctrl_len: usize) -> [u8; STREAM_CTRL } fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 { + if !uvc_advertises_multiple_mjpeg_frames() { + return 1; + } match (width, height) { (1920, 1080) => 1, (1280, 720) => 2, @@ -1617,11 +1628,13 @@ fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 { } } -fn uvc_frame_index_for_request(requested: u8, cfg: &UvcConfig) -> u8 { - match requested { - 1 | 2 => requested, - _ => uvc_frame_index_for_mode(cfg.width, cfg.height), - } +fn uvc_frame_index_for_request(_requested: u8, cfg: &UvcConfig) -> u8 { + uvc_frame_index_for_mode(cfg.width, cfg.height) +} + +fn uvc_advertises_multiple_mjpeg_frames() -> bool { + let base = std::path::Path::new(CONFIGFS_UVC_BASE).join("streaming/mjpeg/m"); + base.join("1080p").exists() && base.join("720p").exists() } fn uvc_frame_size_for_index(frame_index: u8, fallback: u32) -> u32 { diff --git a/server/src/bin/lesavka_uvc/control_payloads.rs b/server/src/bin/lesavka_uvc/control_payloads.rs index f9596cb..a51ea5d 100644 --- a/server/src/bin/lesavka_uvc/control_payloads.rs +++ b/server/src/bin/lesavka_uvc/control_payloads.rs @@ -36,6 +36,9 @@ fn build_streaming_control(cfg: &UvcConfig, ctrl_len: usize) -> [u8; STREAM_CTRL } fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 { + if !uvc_advertises_multiple_mjpeg_frames() { + return 1; + } match (width, height) { (1920, 1080) => 1, (1280, 720) => 2, @@ -43,11 +46,13 @@ fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 { } } -fn uvc_frame_index_for_request(requested: u8, cfg: &UvcConfig) -> u8 { - match requested { - 1 | 2 => requested, - _ => uvc_frame_index_for_mode(cfg.width, cfg.height), - } +fn uvc_frame_index_for_request(_requested: u8, cfg: &UvcConfig) -> u8 { + uvc_frame_index_for_mode(cfg.width, cfg.height) +} + +fn uvc_advertises_multiple_mjpeg_frames() -> bool { + let base = std::path::Path::new(CONFIGFS_UVC_BASE).join("streaming/mjpeg/m"); + base.join("1080p").exists() && base.join("720p").exists() } fn uvc_frame_size_for_index(frame_index: u8, fallback: u32) -> u32 { diff --git a/server/src/bin/lesavka_uvc/control_requests.rs b/server/src/bin/lesavka_uvc/control_requests.rs index 0000709..432056e 100644 --- a/server/src/bin/lesavka_uvc/control_requests.rs +++ b/server/src/bin/lesavka_uvc/control_requests.rs @@ -152,10 +152,8 @@ fn sanitize_streaming_control(data: &[u8], state: &UvcState) -> [u8; STREAM_CTRL &mut out[18..22], uvc_frame_size_for_index(frame_index, state.cfg.frame_size), ); - let interval = read_le32(data, 4); - if interval != 0 { - write_le32(&mut out[4..8], interval); - } + let _host_requested_interval = read_le32(data, 4); + write_le32(&mut out[4..8], state.cfg.interval); let host_payload = read_le32(data, 22); if host_payload > 0 { write_le32(&mut out[22..26], host_payload.min(state.cfg.max_packet)); diff --git a/server/src/bin/lesavka_uvc/coverage_frame_budget.rs b/server/src/bin/lesavka_uvc/coverage_frame_budget.rs index 41a7575..194ead6 100644 --- a/server/src/bin/lesavka_uvc/coverage_frame_budget.rs +++ b/server/src/bin/lesavka_uvc/coverage_frame_budget.rs @@ -25,8 +25,8 @@ fn uvc_idle_pump_sleep() -> std::time::Duration { /// Returns the frame queue pacing period for the negotiated UVC frame rate. /// /// Inputs: `LESAVKA_UVC_QUEUE_PACING` plus the active frame rate. Output: -/// `None` when pacing is explicitly disabled. Why: the RCT-facing host must -/// not be overfed faster than the advertised descriptor cadence. +/// `None` when pacing is disabled. Why: the RCT-facing host paces UVC +/// consumption, and user-space sleeps before QBUF can starve isochronous MJPEG. fn uvc_queue_period(fps: u32) -> Option { if !env_flag_enabled("LESAVKA_UVC_QUEUE_PACING", DEFAULT_UVC_QUEUE_PACING) { return None; diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs index c98f0b1..c0bff2c 100644 --- a/server/src/bin/lesavka_uvc/coverage_model.rs +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -51,13 +51,13 @@ const UVC_VC_REQUEST_ERROR_CODE_CONTROL: u8 = 0x02; #[cfg(coverage)] // Keep coverage-mode defaults aligned with the real UVC helper. -const DEFAULT_UVC_BUFFER_COUNT: u32 = 1; +const DEFAULT_UVC_BUFFER_COUNT: u32 = 4; #[cfg(coverage)] const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; #[cfg(coverage)] const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; #[cfg(coverage)] -const DEFAULT_UVC_QUEUE_PACING: bool = true; +const DEFAULT_UVC_QUEUE_PACING: bool = false; #[cfg(coverage)] const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000; #[cfg(coverage)] @@ -222,7 +222,9 @@ impl UvcVideoStream { /// frames should degrade to known-safe idle content instead of freezing RCT. fn refresh_latest_frame(&mut self) { let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age()); - if stale && looks_like_mjpeg_frame(&self.latest_frame) { + if stale { + self.stats.replayed_stale += 1; + self.replace_latest_with_idle(); return; } let max_frame_bytes = self.frame_payload_limit(); @@ -239,15 +241,21 @@ impl UvcVideoStream { } else { self.stats.rejected_invalid += 1; } - if !looks_like_mjpeg_frame(&self.latest_frame) { - self.stats.fallback_idle += 1; - self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); - } + self.replace_latest_with_idle(); } - } else if !looks_like_mjpeg_frame(&self.latest_frame) { - self.stats.fallback_idle += 1; - self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); + } else { + self.replace_latest_with_idle(); } + if !looks_like_mjpeg_frame(&self.latest_frame) { + self.replace_latest_with_idle(); + } + } + + fn replace_latest_with_idle(&mut self) { + if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME { + self.stats.fallback_idle += 1; + } + self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); } fn frame_payload_limit(&self) -> usize { diff --git a/server/src/bin/tests/lesavka_uvc.rs b/server/src/bin/tests/lesavka_uvc.rs index 080d47f..192be78 100644 --- a/server/src/bin/tests/lesavka_uvc.rs +++ b/server/src/bin/tests/lesavka_uvc.rs @@ -136,17 +136,17 @@ fn uvc_frame_budget_caps_isochronous_transport() { } #[test] -fn uvc_queue_pacing_defaults_on_but_can_be_disabled() { +fn uvc_queue_pacing_defaults_off_but_can_be_enabled() { with_var("LESAVKA_UVC_QUEUE_PACING", None::<&str>, || { + assert_eq!(uvc_queue_period(30), None); + }); + + with_var("LESAVKA_UVC_QUEUE_PACING", Some("1"), || { assert_eq!( uvc_queue_period(30), Some(std::time::Duration::from_nanos(33_333_333)) ); }); - - with_var("LESAVKA_UVC_QUEUE_PACING", Some("0"), || { - assert_eq!(uvc_queue_period(30), None); - }); } #[test] diff --git a/server/src/camera.rs b/server/src/camera.rs index 61a2765..66539de 100644 --- a/server/src/camera.rs +++ b/server/src/camera.rs @@ -6,7 +6,7 @@ mod selection; use std::sync::{OnceLock, RwLock}; -use selection::select_camera_config; +use selection::{select_camera_config, select_hdmi_mirror_config}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CameraOutput { @@ -129,6 +129,16 @@ pub fn current_camera_config() -> CameraConfig { update_camera_config() } +/// Build an HDMI display config that mirrors an already-selected camera profile. +/// +/// Inputs: the primary camera configuration. +/// Outputs: an HDMI config with the same uplink codec, size, and rate. +/// Why: the experimental HDMI capture-card path must be testable beside the +/// normal UVC gadget without negotiating a second client capture profile. +pub fn hdmi_mirror_config(primary: &CameraConfig) -> CameraConfig { + select_hdmi_mirror_config(primary) +} + #[cfg(test)] #[path = "tests/camera.rs"] mod tests; diff --git a/server/src/camera/selection.rs b/server/src/camera/selection.rs index dda7b61..140c484 100644 --- a/server/src/camera/selection.rs +++ b/server/src/camera/selection.rs @@ -165,6 +165,17 @@ fn select_hdmi_config(hdmi: Option) -> CameraConfig { } } +pub(super) fn select_hdmi_mirror_config(primary: &CameraConfig) -> CameraConfig { + CameraConfig { + output: CameraOutput::Hdmi, + codec: primary.codec, + width: primary.width, + height: primary.height, + fps: primary.fps, + hdmi: detect_hdmi_connector(false), + } +} + #[cfg(coverage)] /// Keeps `select_uvc_config` explicit because it sits on camera selection, where negotiated profiles must match the server output contract. /// Inputs are the typed parameters; output is the return value or side effect. diff --git a/server/src/camera_runtime.rs b/server/src/camera_runtime.rs index eceddce..dda6eac 100644 --- a/server/src/camera_runtime.rs +++ b/server/src/camera_runtime.rs @@ -11,10 +11,16 @@ use tracing::info; use crate::{camera, uvc_runtime, video}; struct CameraRelaySlot { - cfg: camera::CameraConfig, + profile: CameraRelayProfile, relay: Arc, } +#[derive(Clone, Debug)] +struct CameraRelayProfile { + primary: camera::CameraConfig, + hdmi_mirror: Option, +} + /// Manage the currently active camera relay instance. /// /// Inputs: camera configurations requested by incoming RPC streams. @@ -69,32 +75,34 @@ impl CameraRuntime { cfg: &camera::CameraConfig, ) -> Result<(u64, Arc, bool), Status> { let session_id = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + let requested_profile = camera_relay_profile(cfg); let mut slot = self.slot.lock().await; let mut reused = false; let relay = if let Some(existing) = slot.as_ref() { - if camera_cfg_eq(&existing.cfg, cfg) { + if camera_relay_profile_eq(&existing.profile, &requested_profile) { reused = true; existing.relay.clone() } else { - self.make_relay(cfg)? + self.make_relay(&requested_profile)? } } else { - self.make_relay(cfg)? + self.make_relay(&requested_profile)? }; if !reused { *slot = Some(CameraRelaySlot { - cfg: cfg.clone(), + profile: requested_profile.clone(), relay: relay.clone(), }); info!( session_id, - output = cfg.output.as_str(), - codec = cfg.codec.as_str(), - width = cfg.width, - height = cfg.height, - fps = cfg.fps, + output = requested_profile.primary.output.as_str(), + codec = requested_profile.primary.codec.as_str(), + width = requested_profile.primary.width, + height = requested_profile.primary.height, + fps = requested_profile.primary.fps, + hdmi_mirror = requested_profile.hdmi_mirror.is_some(), "🎥 camera relay (re)created" ); } else { @@ -167,7 +175,8 @@ impl CameraRuntime { #[allow(clippy::result_large_err)] #[cfg(not(coverage))] - fn make_relay(&self, cfg: &camera::CameraConfig) -> Result, Status> { + fn make_relay(&self, profile: &CameraRelayProfile) -> Result, Status> { + let cfg = &profile.primary; let relay = match cfg.output { camera::CameraOutput::Uvc => { if std::env::var("LESAVKA_DISABLE_UVC").is_ok() { @@ -177,9 +186,26 @@ impl CameraRuntime { } let uvc = uvc_runtime::pick_uvc_device() .map_err(|e| Status::internal(format!("{e:#}")))?; - info!(%uvc, "🎥 stream_camera using UVC sink"); - video::CameraRelay::new_uvc(0, &uvc, cfg) - .map_err(|e| Status::internal(format!("{e:#}")))? + if let Some(hdmi_cfg) = profile.hdmi_mirror.as_ref() { + let display_size = hdmi_cfg.hdmi_display_size(); + info!( + %uvc, + hdmi = hdmi_cfg + .hdmi + .as_ref() + .map(|h| h.name.as_str()) + .unwrap_or("none"), + display_width = display_size.0, + display_height = display_size.1, + "🎥 stream_camera using UVC sink with HDMI mirror" + ); + video::CameraRelay::new_uvc_with_hdmi_mirror(0, &uvc, cfg, hdmi_cfg) + .map_err(|e| Status::internal(format!("{e:#}")))? + } else { + info!(%uvc, "🎥 stream_camera using UVC sink"); + video::CameraRelay::new_uvc(0, &uvc, cfg) + .map_err(|e| Status::internal(format!("{e:#}")))? + } } camera::CameraOutput::Hdmi => video::CameraRelay::new_hdmi(0, cfg) .map_err(|e| Status::internal(format!("{e:#}")))?, @@ -222,10 +248,42 @@ pub fn camera_cfg_eq(a: &camera::CameraConfig, b: &camera::CameraConfig) -> bool } } +fn camera_relay_profile(cfg: &camera::CameraConfig) -> CameraRelayProfile { + let hdmi_mirror = (cfg.output == camera::CameraOutput::Uvc && hdmi_mirror_enabled()) + .then(|| camera::hdmi_mirror_config(cfg)); + CameraRelayProfile { + primary: cfg.clone(), + hdmi_mirror, + } +} + +fn camera_relay_profile_eq(a: &CameraRelayProfile, b: &CameraRelayProfile) -> bool { + camera_cfg_eq(&a.primary, &b.primary) + && match (&a.hdmi_mirror, &b.hdmi_mirror) { + (Some(left), Some(right)) => camera_cfg_eq(left, right), + (None, None) => true, + _ => false, + } +} + +fn hdmi_mirror_enabled() -> bool { + std::env::var("LESAVKA_CAM_HDMI_MIRROR") + .ok() + .map(|value| { + let trimmed = value.trim(); + trimmed.eq_ignore_ascii_case("1") + || trimmed.eq_ignore_ascii_case("true") + || trimmed.eq_ignore_ascii_case("yes") + || trimmed.eq_ignore_ascii_case("on") + }) + .unwrap_or(false) +} + #[cfg(test)] mod tests { - use super::camera_cfg_eq; + use super::{camera_cfg_eq, camera_relay_profile, camera_relay_profile_eq}; use crate::camera::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector}; + use serial_test::serial; #[test] fn camera_cfg_eq_requires_matching_sink_profile() { @@ -257,4 +315,73 @@ mod tests { }); assert!(!camera_cfg_eq(&base, &changed)); } + + #[test] + #[serial] + fn camera_relay_profile_tracks_hdmi_mirror_flag() { + let base = CameraConfig { + output: CameraOutput::Uvc, + codec: CameraCodec::Mjpeg, + width: 1280, + height: 720, + fps: 20, + hdmi: None, + }; + + temp_env::with_var("LESAVKA_CAM_HDMI_MIRROR", None::<&str>, || { + let profile = camera_relay_profile(&base); + assert!(profile.hdmi_mirror.is_none()); + }); + + temp_env::with_var("LESAVKA_CAM_HDMI_MIRROR", Some("1"), || { + let profile = camera_relay_profile(&base); + let mirror = profile.hdmi_mirror.as_ref().expect("HDMI mirror config"); + assert_eq!(mirror.output, CameraOutput::Hdmi); + assert_eq!(mirror.codec, CameraCodec::Mjpeg); + assert_eq!((mirror.width, mirror.height, mirror.fps), (1280, 720, 20)); + }); + } + + #[test] + #[serial] + fn camera_relay_profile_reuse_requires_same_mirror_connector() { + let base = CameraConfig { + output: CameraOutput::Uvc, + codec: CameraCodec::Mjpeg, + width: 1280, + height: 720, + fps: 20, + hdmi: None, + }; + + let left = CameraConfig { + output: CameraOutput::Hdmi, + hdmi: Some(HdmiConnector { + name: String::from("HDMI-A-1"), + id: Some(41), + modes: Vec::new(), + }), + ..base.clone() + }; + let right = CameraConfig { + output: CameraOutput::Hdmi, + hdmi: Some(HdmiConnector { + name: String::from("HDMI-A-2"), + id: Some(42), + modes: Vec::new(), + }), + ..base.clone() + }; + + assert!(!camera_relay_profile_eq( + &super::CameraRelayProfile { + primary: base.clone(), + hdmi_mirror: Some(left), + }, + &super::CameraRelayProfile { + primary: base, + hdmi_mirror: Some(right), + }, + )); + } } diff --git a/server/src/tests/camera.rs b/server/src/tests/camera.rs index 46b480c..36b7547 100644 --- a/server/src/tests/camera.rs +++ b/server/src/tests/camera.rs @@ -1,3 +1,5 @@ +#[cfg(coverage)] +use super::hdmi_mirror_config; use super::selection::{parse_hdmi_mode, parse_hdmi_modes, preferred_hdmi_mode}; use super::{ CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, current_camera_config, @@ -265,6 +267,36 @@ fn non_hdmi_display_size_uses_camera_profile() { assert_eq!(cfg.hdmi_display_size(), (800, 600)); } +#[test] +#[cfg(coverage)] +#[serial] +fn hdmi_mirror_config_keeps_primary_uplink_profile_and_detects_display() { + let primary = CameraConfig { + output: CameraOutput::Uvc, + codec: CameraCodec::Mjpeg, + width: 1280, + height: 720, + fps: 20, + hdmi: None, + }; + + temp_env::with_vars( + [ + ("LESAVKA_HDMI_CONNECTOR", Some("card1-HDMI-A-2")), + ("LESAVKA_HDMI_MODES", Some("1920x1080,1280x720")), + ("LESAVKA_HDMI_WIDTH", None), + ("LESAVKA_HDMI_HEIGHT", None), + ], + || { + let mirror = hdmi_mirror_config(&primary); + assert_eq!(mirror.output, CameraOutput::Hdmi); + assert_eq!(mirror.codec, primary.codec); + assert_eq!((mirror.width, mirror.height, mirror.fps), (1280, 720, 20)); + assert_eq!(mirror.hdmi_display_size(), (1920, 1080)); + }, + ); +} + #[test] #[cfg(coverage)] #[serial] diff --git a/server/src/uvc_control/model.rs b/server/src/uvc_control/model.rs index 616055b..80941cc 100644 --- a/server/src/uvc_control/model.rs +++ b/server/src/uvc_control/model.rs @@ -361,6 +361,9 @@ pub(crate) fn build_streaming_control( } pub(crate) fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 { + if !uvc_advertises_multiple_mjpeg_frames() { + return 1; + } match (width, height) { (1920, 1080) => 1, (1280, 720) => 2, @@ -368,21 +371,22 @@ pub(crate) fn uvc_frame_index_for_mode(width: u32, height: u32) -> u8 { } } -pub(crate) fn uvc_frame_index_for_request(requested: u8, cfg: &UvcConfig) -> u8 { - match requested { - 1 | 2 => requested, - _ => uvc_frame_index_for_mode(cfg.width, cfg.height), - } +pub(crate) fn uvc_frame_index_for_request(_requested: u8, cfg: &UvcConfig) -> u8 { + uvc_frame_index_for_mode(cfg.width, cfg.height) } pub(crate) fn uvc_frame_size_for_index(frame_index: u8, fallback: u32) -> u32 { match frame_index { - 1 => 1920 * 1080 * 2, - 2 => 1280 * 720 * 2, + 1 | 2 => fallback, _ => fallback, } } +fn uvc_advertises_multiple_mjpeg_frames() -> bool { + let base = std::path::Path::new(CONFIGFS_UVC_BASE).join("streaming/mjpeg/m"); + base.join("1080p").exists() && base.join("720p").exists() +} + pub(crate) fn compute_payload_cap(bulk: bool) -> Option { if let Some(limit) = env_u32_opt("LESAVKA_UVC_MAXPAYLOAD_LIMIT") { return Some(PayloadCap { diff --git a/server/src/uvc_control/protocol.rs b/server/src/uvc_control/protocol.rs index 52055ee..923fae6 100644 --- a/server/src/uvc_control/protocol.rs +++ b/server/src/uvc_control/protocol.rs @@ -285,9 +285,8 @@ pub(crate) fn sanitize_streaming_control( &mut out[18..22], uvc_frame_size_for_index(frame_index, state.cfg.frame_size), ); - if interval != 0 { - write_le32(&mut out[4..8], interval); - } + let _host_requested_interval = interval; + write_le32(&mut out[4..8], state.cfg.interval); if host_payload > 0 { let payload = host_payload.min(state.cfg.max_packet); write_le32(&mut out[22..26], payload); diff --git a/server/src/video_sinks/camera_relay.rs b/server/src/video_sinks/camera_relay.rs index c7c3ea6..57a8144 100644 --- a/server/src/video_sinks/camera_relay.rs +++ b/server/src/video_sinks/camera_relay.rs @@ -1,6 +1,10 @@ enum CameraSink { Uvc(WebcamSink), Hdmi(HdmiSink), + UvcWithHdmiMirror { + uvc: WebcamSink, + hdmi: HdmiSink, + }, #[cfg(coverage)] Noop, } @@ -10,6 +14,10 @@ impl CameraSink { match self { CameraSink::Uvc(sink) => sink.push(pkt), CameraSink::Hdmi(sink) => sink.push(pkt), + CameraSink::UvcWithHdmiMirror { uvc, hdmi } => { + uvc.push(pkt.clone()); + hdmi.push(pkt); + } #[cfg(coverage)] CameraSink::Noop => { let _ = pkt; @@ -44,6 +52,29 @@ impl CameraRelay { }) } + /// Build a relay that writes the same camera stream to UVC and HDMI. + /// + /// Inputs: the logical camera id, UVC device node, UVC profile, and HDMI + /// mirror display profile. + /// Outputs: a relay that fans out each client frame to both physical sinks. + /// Why: this lets the capture-card bypass be evaluated without removing the + /// current UVC gadget path from Tethys. + pub fn new_uvc_with_hdmi_mirror( + id: u32, + uvc_dev: &str, + uvc_cfg: &CameraConfig, + hdmi_cfg: &CameraConfig, + ) -> anyhow::Result { + Ok(Self { + sink: CameraSink::UvcWithHdmiMirror { + uvc: WebcamSink::new(uvc_dev, uvc_cfg)?, + hdmi: HdmiSink::new(hdmi_cfg)?, + }, + id, + frames: AtomicU64::new(0), + }) + } + /// Build a relay that targets the HDMI output pipeline. /// /// Inputs: the logical camera id plus the camera config. diff --git a/server/src/video_sinks/hevc_mjpeg_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard.rs index fea2015..5520bf0 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard.rs @@ -15,7 +15,7 @@ const DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES: u32 = 12; const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92; const DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT: u32 = 18; const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024; -const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false; +const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = true; const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = true; const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 60; const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 50; @@ -185,18 +185,18 @@ pub(super) fn direct_mjpeg_min_reference_bytes() -> u32 { /// Decide whether direct MJPEG frames must match the active UVC dimensions. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH`. -/// Output: false unless explicitly enabled. Why: current field debugging needs -/// profile-mismatch telemetry first; rejecting mismatches by default could -/// accidentally freeze every frame on an attached gadget with stale descriptors. +/// Output: true unless explicitly disabled. Why: the live UVC descriptor is the +/// browser-visible contract; spooling a mismatched JPEG can leave the host +/// showing stale or smeared frames instead of an honest unsupported-mode freeze. pub(super) fn direct_mjpeg_reject_profile_mismatch_enabled() -> bool { std::env::var("LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH") .ok() .map(|value| { let trimmed = value.trim(); - trimmed.eq_ignore_ascii_case("1") - || trimmed.eq_ignore_ascii_case("true") - || trimmed.eq_ignore_ascii_case("yes") - || trimmed.eq_ignore_ascii_case("on") + !(trimmed.eq_ignore_ascii_case("0") + || trimmed.eq_ignore_ascii_case("false") + || trimmed.eq_ignore_ascii_case("no") + || trimmed.eq_ignore_ascii_case("off")) }) .unwrap_or(DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT) } diff --git a/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs index 231780f..bb4bd57 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs @@ -9,6 +9,8 @@ use super::mjpeg_visual_seams::seam_reason; const DEFAULT_PIXEL_GUARD_SAMPLE_WIDTH: u32 = 160; const DEFAULT_PIXEL_GUARD_FLAT_STDDEV: u32 = 6; const DEFAULT_PIXEL_GUARD_FLAT_RUN_PCT: u32 = 16; +const DEFAULT_PIXEL_GUARD_FLAT_MIN_START_PCT: u32 = 45; +const DEFAULT_PIXEL_GUARD_FLAT_MIN_END_PCT: u32 = 72; const DEFAULT_PIXEL_GUARD_MIN_PIXELS: u32 = 160 * 90; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -93,6 +95,22 @@ fn flat_run_pct_threshold() -> usize { .clamp(5, 60) as usize } +fn flat_min_start_pct_threshold() -> usize { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_MIN_START_PCT", + DEFAULT_PIXEL_GUARD_FLAT_MIN_START_PCT, + ) + .min(100) as usize +} + +fn flat_min_end_pct_threshold() -> usize { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_MIN_END_PCT", + DEFAULT_PIXEL_GUARD_FLAT_MIN_END_PCT, + ) + .min(100) as usize +} + fn min_pixels_for_guard() -> usize { env_u32( "LESAVKA_UVC_MJPEG_PIXEL_GUARD_MIN_PIXELS", @@ -234,6 +252,8 @@ fn row_stats(frame: &SampledFrame, row: usize) -> (f64, f64) { fn flat_block_reason(frame: &SampledFrame) -> Option { let threshold = flat_stddev_threshold(); let min_run = (frame.height.saturating_mul(flat_run_pct_threshold()) / 100).max(2); + let min_start_pct = flat_min_start_pct_threshold(); + let min_end_pct = flat_min_end_pct_threshold(); let mut best_start = 0usize; let mut best_len = 0usize; let mut current_start = 0usize; @@ -255,9 +275,14 @@ fn flat_block_reason(frame: &SampledFrame) -> Option } } if best_len >= min_run { + let start_row_pct = pct(best_start, frame.height); + let end_row_pct = pct(best_start + best_len, frame.height); + if usize::from(start_row_pct) < min_start_pct || usize::from(end_row_pct) < min_end_pct { + return None; + } return Some(MjpegVisualArtifactReason::FlatBlock { - start_row_pct: pct(best_start, frame.height), - end_row_pct: pct(best_start + best_len, frame.height), + start_row_pct, + end_row_pct, run_pct: pct(best_len, frame.height), }); } diff --git a/server/src/video_sinks/hevc_mjpeg_guard/tests/mod.rs b/server/src/video_sinks/hevc_mjpeg_guard/tests/mod.rs index b72d021..ecc177b 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/tests/mod.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/tests/mod.rs @@ -389,6 +389,48 @@ fn pixel_guard_accepts_wall_over_couch_scene_edges() { ); } +#[test] +/// Verifies close-up webcam regions are not frozen as damaged gray slabs. +fn pixel_guard_accepts_midframe_flat_camera_regions() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD", Some("1")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_STDDEV", Some("6")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_RUN_PCT", Some("16")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_MIN_START_PCT", Some("45")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_MIN_END_PCT", Some("72")), + ], + || { + let width = 320; + let height = 180; + let mut frame = vec![0u8; width * height * 3]; + let band_start = height.saturating_mul(43) / 100; + let band_end = height.saturating_mul(63) / 100; + + for y in 0..height { + for x in 0..width { + let offset = (y * width + x) * 3; + if (band_start..band_end).contains(&y) { + frame[offset] = 132; + frame[offset + 1] = 132; + frame[offset + 2] = 132; + } else { + let shade = 122u8.saturating_add(((x * 13 + y * 7) % 24) as u8); + frame[offset] = shade; + frame[offset + 1] = shade.saturating_add((x % 3) as u8); + frame[offset + 2] = shade.saturating_sub((y % 3) as u8); + } + } + } + + assert_eq!( + super::mjpeg_visual_guard::sampled_rgb_frame_for_test(width, height, &frame), + None + ); + }, + ); +} + #[test] /// Verifies low-detail gray slabs freeze out before UVC handoff. fn pixel_guard_rejects_flat_lower_block_frames() { @@ -556,18 +598,24 @@ fn direct_mjpeg_guard_reports_oversize_and_profile_mismatch_when_configured() { assert_eq!(inspection.height, Some(1080)); assert!(inspection.entropy_distinct_bytes > 64); + temp_env::with_var_unset("LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH", || { + assert_eq!( + super::direct_mjpeg_reject_reason(0, Some(frame.len() + 1), Some((1280, 720)), &frame), + Some(super::DirectMjpegRejectReason::ProfileMismatch { + expected_width: 1280, + expected_height: 720, + actual_width: 1920, + actual_height: 1080, + }) + ); + }); temp_env::with_var( "LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH", - Some("1"), + Some("0"), || { assert_eq!( super::direct_mjpeg_reject_reason(0, Some(frame.len() + 1), Some((1280, 720)), &frame), - Some(super::DirectMjpegRejectReason::ProfileMismatch { - expected_width: 1280, - expected_height: 720, - actual_width: 1920, - actual_height: 1080, - }) + None ); }, ); diff --git a/server/src/video_sinks/webcam_sink.rs b/server/src/video_sinks/webcam_sink.rs index c24a283..e236fcb 100644 --- a/server/src/video_sinks/webcam_sink.rs +++ b/server/src/video_sinks/webcam_sink.rs @@ -335,6 +335,14 @@ fn add_hevc_mjpeg_spool_branch( configure_hevc_decoder(&decoder); let decoded_queue = build_hevc_freshness_queue("dynamic_hevc_mjpeg_decoded_queue")?; let convert = gst::ElementFactory::make("videoconvert").build()?; + let scale = gst::ElementFactory::make("videoscale").build()?; + let raw_caps = gst::Caps::builder("video/x-raw") + .field("width", width) + .field("height", height) + .build(); + let raw_capsfilter = gst::ElementFactory::make("capsfilter") + .property("caps", &raw_caps) + .build()?; let encoder = gst::ElementFactory::make("jpegenc") .property("quality", hevc_mjpeg_guard::hevc_jpeg_quality() as i32) .build()?; @@ -362,6 +370,8 @@ fn add_hevc_mjpeg_spool_branch( &decoder, &decoded_queue, &convert, + &scale, + &raw_capsfilter, &encoder, &jpegparse, &caps, @@ -374,6 +384,8 @@ fn add_hevc_mjpeg_spool_branch( &decoder, &decoded_queue, &convert, + &scale, + &raw_capsfilter, &encoder, &jpegparse, &caps, diff --git a/server/src/video_sinks/webcam_sink/constructor.rs b/server/src/video_sinks/webcam_sink/constructor.rs index db4c96a..d24cb56 100644 --- a/server/src/video_sinks/webcam_sink/constructor.rs +++ b/server/src/video_sinks/webcam_sink/constructor.rs @@ -213,6 +213,14 @@ impl WebcamSink { configure_hevc_decoder(&decoder); let decoded_queue = build_hevc_freshness_queue("hevc_mjpeg_decoded_queue")?; let convert = gst::ElementFactory::make("videoconvert").build()?; + let scale = gst::ElementFactory::make("videoscale").build()?; + let raw_caps = gst::Caps::builder("video/x-raw") + .field("width", width) + .field("height", height) + .build(); + let raw_capsfilter = gst::ElementFactory::make("capsfilter") + .property("caps", &raw_caps) + .build()?; let encoder = gst::ElementFactory::make("jpegenc") .property("quality", hevc_mjpeg_guard::hevc_jpeg_quality() as i32) .build()?; @@ -231,6 +239,27 @@ impl WebcamSink { ); if mjpeg_spool_enabled() { + if hevc_mjpeg_guard::direct_mjpeg_normalize_enabled() { + match build_direct_mjpeg_normalize_branch(&pipeline, width, height, fps) { + Ok((normalize_src, normalize_sink)) => { + direct_mjpeg_appsrc = Some(normalize_src); + normalized_mjpeg_sink = Some(normalize_sink); + tracing::info!( + target: "lesavka_server::video", + quality = hevc_mjpeg_guard::direct_mjpeg_jpeg_quality(), + pull_timeout_ms = hevc_mjpeg_guard::direct_mjpeg_normalize_pull_timeout_ms(), + "HEVC UVC spool will normalize unexpected direct MJPEG packets" + ); + } + Err(err) => { + tracing::warn!( + target: "lesavka_server::video", + %err, + "direct MJPEG normalization unavailable for HEVC UVC spool fallback" + ); + } + } + } let sink = gst::ElementFactory::make("appsink") .name("hevc_mjpeg_spool_sink") .property("sync", false) @@ -247,6 +276,8 @@ impl WebcamSink { &decoder, &decoded_queue, &convert, + &scale, + &raw_capsfilter, &encoder, &jpegparse, &caps, @@ -259,6 +290,8 @@ impl WebcamSink { &decoder, &decoded_queue, &convert, + &scale, + &raw_capsfilter, &encoder, &jpegparse, &caps, @@ -283,6 +316,8 @@ impl WebcamSink { &h265parse, &decoder, &convert, + &scale, + &raw_capsfilter, &encoder, &jpegparse, &caps, @@ -293,6 +328,8 @@ impl WebcamSink { &h265parse, &decoder, &convert, + &scale, + &raw_capsfilter, &encoder, &jpegparse, &caps, diff --git a/server/src/video_sinks/webcam_sink/frame_handoff.rs b/server/src/video_sinks/webcam_sink/frame_handoff.rs index 94a122c..95ff3f6 100644 --- a/server/src/video_sinks/webcam_sink/frame_handoff.rs +++ b/server/src/video_sinks/webcam_sink/frame_handoff.rs @@ -167,7 +167,6 @@ impl WebcamSink { #[cfg(not(coverage))] fn spool_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) { - let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed); let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(&pkt.data); if let (Some(width), Some(height)) = (inspection.width, inspection.height) && (width, height) != (self.uvc_width, self.uvc_height) @@ -184,6 +183,26 @@ impl WebcamSink { "direct MJPEG frame dimensions differ from the live UVC profile; this can make browser output unstable" ); } + if !hevc_mjpeg_guard::looks_like_complete_jpeg(&pkt.data) { + self.reject_direct_mjpeg_frame(pkt, "Incomplete"); + return; + } + + if self.direct_mjpeg_appsrc.is_some() + && self.normalized_mjpeg_sink.is_some() + && !self.direct_mjpeg_normalize_bypassed.load(Ordering::Relaxed) + { + self.spool_normalized_direct_mjpeg_frame(path, pkt); + return; + } + + self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); + } + + #[cfg(not(coverage))] + fn spool_guarded_passthrough_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) { + let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed); + let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(&pkt.data); if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason( previous_bytes, Some(self.direct_mjpeg_max_bytes), @@ -204,20 +223,21 @@ impl WebcamSink { entropy_max_run = inspection.entropy_max_run, "freezing suspicious direct MJPEG frame before UVC spool" ); - return; - } - - if self.direct_mjpeg_appsrc.is_some() - && self.normalized_mjpeg_sink.is_some() - && !self.direct_mjpeg_normalize_bypassed.load(Ordering::Relaxed) - { - self.spool_normalized_direct_mjpeg_frame(path, pkt); + let reason_text = format!("{reason:?}"); + self.reject_direct_mjpeg_frame(pkt, &reason_text); return; } self.spool_passthrough_direct_mjpeg_frame(path, pkt); } + #[cfg(not(coverage))] + fn reject_direct_mjpeg_frame(&self, pkt: &VideoPacket, reason: &str) { + let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts) + .with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps); + super::mjpeg_spool::audit_rejected_mjpeg_frame(&pkt.data, timing, reason); + } + #[cfg(not(coverage))] fn spool_passthrough_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) { let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts) @@ -233,11 +253,11 @@ impl WebcamSink { #[cfg(not(coverage))] fn spool_normalized_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) { let Some(src) = self.direct_mjpeg_appsrc.as_ref() else { - self.spool_passthrough_direct_mjpeg_frame(path, pkt); + self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); return; }; let Some(sink) = self.normalized_mjpeg_sink.as_ref() else { - self.spool_passthrough_direct_mjpeg_frame(path, pkt); + self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); return; }; @@ -257,7 +277,7 @@ impl WebcamSink { limit_kb, "direct MJPEG normalization disabled because server RSS exceeded its safety limit" ); - self.spool_passthrough_direct_mjpeg_frame(path, pkt); + self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); return; } @@ -276,7 +296,7 @@ impl WebcamSink { ); self.direct_mjpeg_normalize_bypassed .store(true, Ordering::Relaxed); - self.spool_passthrough_direct_mjpeg_frame(path, pkt); + self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); return; } @@ -300,7 +320,7 @@ impl WebcamSink { limit, "direct MJPEG normalization starved; falling back to guarded passthrough for this webcam session" ); - self.spool_passthrough_direct_mjpeg_frame(path, pkt); + self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); } return; }; @@ -311,7 +331,7 @@ impl WebcamSink { target:"lesavka_server::video", "direct MJPEG normalization returned an empty sample; falling back to guarded passthrough" ); - self.spool_passthrough_direct_mjpeg_frame(path, pkt); + self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); return; }; let Ok(map) = buffer.map_readable() else { @@ -321,7 +341,7 @@ impl WebcamSink { target:"lesavka_server::video", "direct MJPEG normalization returned an unreadable sample; falling back to guarded passthrough" ); - self.spool_passthrough_direct_mjpeg_frame(path, pkt); + self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); return; }; let normalized = map.as_slice(); @@ -333,6 +353,14 @@ impl WebcamSink { normalized, ) { let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(normalized); + let rejected_timing = MjpegSpoolTiming::mjpeg_normalized(pkt.pts) + .with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps); + let reason_text = format!("{reason:?}"); + super::mjpeg_spool::audit_rejected_mjpeg_frame( + normalized, + rejected_timing, + &reason_text, + ); warn!( target:"lesavka_server::video", ?reason, diff --git a/server/src/video_sinks/webcam_sink/tests.rs b/server/src/video_sinks/webcam_sink/tests.rs index bfef50b..1ec921c 100644 --- a/server/src/video_sinks/webcam_sink/tests.rs +++ b/server/src/video_sinks/webcam_sink/tests.rs @@ -184,11 +184,60 @@ fn direct_mjpeg_normalizer_branch_reencodes_a_valid_frame() { use gstreamer as gst; use gstreamer::prelude::ElementExt; + fn jpeg_dimensions(bytes: &[u8]) -> Option<(u16, u16)> { + let mut idx = 2; + while idx + 4 <= bytes.len() { + if bytes[idx] != 0xff { + idx += 1; + continue; + } + while idx < bytes.len() && bytes[idx] == 0xff { + idx += 1; + } + let marker = *bytes.get(idx)?; + idx += 1; + if marker == 0xda || marker == 0xd9 { + return None; + } + if matches!(marker, 0x01 | 0xd0..=0xd9) { + continue; + } + let segment_len = bytes + .get(idx..idx + 2) + .map(|value| u16::from_be_bytes([value[0], value[1]]))? + as usize; + if segment_len < 7 || idx + segment_len > bytes.len() { + return None; + } + if matches!( + marker, + 0xc0 | 0xc1 + | 0xc2 + | 0xc3 + | 0xc5 + | 0xc6 + | 0xc7 + | 0xc9 + | 0xca + | 0xcb + | 0xcd + | 0xce + | 0xcf + ) { + let height = u16::from_be_bytes([bytes[idx + 3], bytes[idx + 4]]); + let width = u16::from_be_bytes([bytes[idx + 5], bytes[idx + 6]]); + return Some((width, height)); + } + idx += segment_len; + } + None + } + const FIXTURE: &[u8] = include_bytes!("../../bin/lesavka_uvc/idle_1280x720_black.jpg"); gst::init().expect("gstreamer init"); let pipeline = gst::Pipeline::new(); - let (src, sink) = super::build_direct_mjpeg_normalize_branch(&pipeline, 1280, 720, 30) + let (src, sink) = super::build_direct_mjpeg_normalize_branch(&pipeline, 640, 360, 30) .expect("normalizer branch"); pipeline .set_state(gst::State::Playing) @@ -202,5 +251,6 @@ fn direct_mjpeg_normalizer_branch_reencodes_a_valid_frame() { assert!(map.as_slice().starts_with(&[0xff, 0xd8, 0xff])); assert!(map.as_slice().ends_with(&[0xff, 0xd9])); + assert_eq!(jpeg_dimensions(map.as_slice()), Some((640, 360))); pipeline.set_state(gst::State::Null).ok(); } diff --git a/tests/contract/scripts/daemon/server_core_script_contract.rs b/tests/contract/scripts/daemon/server_core_script_contract.rs index 37159d6..76bdd14 100644 --- a/tests/contract/scripts/daemon/server_core_script_contract.rs +++ b/tests/contract/scripts/daemon/server_core_script_contract.rs @@ -9,6 +9,10 @@ const CORE_SCRIPT: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/scripts/daemon/lesavka-core.sh" )); +const UVC_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/daemon/lesavka-uvc.sh" +)); #[test] fn core_script_rebuilds_incomplete_bound_gadgets() { @@ -100,15 +104,20 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() { "UVC bulk requested but this kernel lacks streaming_bulk; using isochronous descriptors", "apply_uvc_payload_limits", "UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}", + "UVC_ADVERTISE_EXTRA_MODES=${LESAVKA_UVC_ADVERTISE_EXTRA_MODES:-0}", "uvc_mjpeg_frame_size_for_fps()", "UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}", "UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}", "uvc_isochronous_budget_bytes_per_sec()", "isoch_budget=\"$(uvc_isochronous_budget_bytes_per_sec)\"", "UVC_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"", + "write_active_mjpeg_frame_descriptor()", + "if flag_enabled \"$UVC_ADVERTISE_EXTRA_MODES\"; then", "write_mjpeg_frame_descriptor 1080p 1920 1080", "write_mjpeg_frame_descriptor 720p 1280 720", + "write_active_mjpeg_frame_descriptor", "echo \"$(uvc_frame_size_for \"$width\" \"$height\")\" >\"$frame/dwMaxVideoFrameBufferSize\"", + "echo \"$default_interval\" >\"$frame/dwFrameInterval\"", "UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333}", "UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000}", ] { @@ -137,3 +146,21 @@ fn core_script_hides_uac_hardware_mixer_controls_by_default() { ); } } + +#[test] +fn uvc_script_migrates_starvation_prone_legacy_defaults() { + for expected in [ + "migrate_unsafe_uvc_defaults()", + "LESAVKA_UVC_BUFFER_COUNT:-", + "LESAVKA_UVC_ALLOW_SINGLE_BUFFER", + "export LESAVKA_UVC_BUFFER_COUNT=4", + "LESAVKA_UVC_QUEUE_PACING:-", + "LESAVKA_UVC_ALLOW_QUEUE_PACING", + "export LESAVKA_UVC_QUEUE_PACING=0", + ] { + assert!( + UVC_SCRIPT.contains(expected), + "lesavka-uvc should migrate unsafe old defaults: {expected}" + ); + } +} diff --git a/tests/contract/scripts/install/server_install_script_contract.rs b/tests/contract/scripts/install/server_install_script_contract.rs index df64f31..fe7c68e 100644 --- a/tests/contract/scripts/install/server_install_script_contract.rs +++ b/tests/contract/scripts/install/server_install_script_contract.rs @@ -29,6 +29,7 @@ fn server_install_infers_checkout_owner_when_root_wrapper_hides_sudo_user() { fn server_install_pins_hdmi_camera_and_display_defaults() { for expected in [ "LESAVKA_CAM_OUTPUT=%s", + "LESAVKA_CAM_HDMI_MIRROR=%s", "LESAVKA_CAM_CODEC=%s", "LESAVKA_CAM_WIDTH=%s", "LESAVKA_CAM_HEIGHT=%s", @@ -83,10 +84,14 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_HEIGHT:-1080}")); assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_FPS:-30}")); assert!(SERVER_INSTALL.contains("${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}")); + assert!( + SERVER_INSTALL + .contains("${LESAVKA_INSTALL_CAM_HDMI_MIRROR:-${LESAVKA_CAM_HDMI_MIRROR:-0}}") + ); assert!(SERVER_INSTALL.contains("normalize_cam_codec()")); assert!( SERVER_INSTALL.contains( - "REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}" + "REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}}" ) ); assert!( @@ -181,6 +186,8 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { SERVER_INSTALL.contains("LESAVKA_INSTALL_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US"), "install-specific video offset override should bypass stale ambient runtime env" ); + assert!(SERVER_INSTALL.contains("PERSISTED_CAM_HDMI_MIRROR=")); + assert!(SERVER_INSTALL.contains("HDMI mirror:")); assert!( SERVER_INSTALL.contains( "migrating stale upstream audio playout offset to the per-mode MJPEG/UAC default" @@ -225,11 +232,13 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("paste-key")); assert!(SERVER_INSTALL.contains("ca.crt client.crt client.key paste-key")); 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_FPS 20")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_INTERVAL 500000")); 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("uvc_env_value LESAVKA_UVC_QUEUE_PACING 1")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_BUFFER_COUNT 4")); + assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_QUEUE_PACING 0")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_BULK 1")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0")); @@ -263,10 +272,10 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "install script should try the Raspberry Pi HEVC decoder before requiring another hardware decoder" ); assert!( - SERVER_INSTALL.contains("Software HEVC decoder passed the same 1280x720 smoke") + SERVER_INSTALL.contains("Software HEVC decoder passed:") && SERVER_INSTALL.contains("LESAVKA_HEVC_DECODER") && SERVER_INSTALL.contains("LESAVKA_ALLOW_SOFTWARE_VIDEO"), - "install script should make software HEVC fallback explicit and smoke-proven" + "install script should document software HEVC fallback only as an explicit follow-up" ); assert!( SERVER_INSTALL.contains("hardware HEVC decoder passed a real 1280x720 decode smoke") @@ -290,7 +299,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { SERVER_INSTALL.contains("Refusing HEVC upstream install because production video decode must be hardware-accelerated and proven") && SERVER_INSTALL.contains("Use LESAVKA_INSTALL_CAM_CODEC=mjpeg while the HEVC decoder stack is repaired") && SERVER_INSTALL.contains("Default HEVC upstream cannot be proven on this host; falling back to MJPEG ingress."), - "explicit HEVC installs should fail loud while default HEVC installs can use proven fallback paths" + "explicit HEVC installs should fail loud while defensive default paths fall back to MJPEG" ); assert!( !SERVER_INSTALL @@ -436,7 +445,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { && SERVER_INSTALL .contains("1280x720) frame_root=\"$function_root/streaming/mjpeg/m/720p\"") && SERVER_INSTALL.contains( - "grep -qx \"${LESAVKA_UVC_INTERVAL:-333333}\" \"$frame_root/dwFrameInterval\"" + "grep -qx \"${LESAVKA_UVC_INTERVAL:-500000}\" \"$frame_root/dwFrameInterval\"" ), "live descriptor matching should recognize all supported MJPEG UVC profiles instead of collapsing to one 720p frame" ); diff --git a/tests/contract/server/uvc/server_uvc_binary_contract.rs b/tests/contract/server/uvc/server_uvc_binary_contract.rs index 45c79b3..dd35e28 100644 --- a/tests/contract/server/uvc/server_uvc_binary_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_contract.rs @@ -120,7 +120,7 @@ mod uvc_binary { maybe_update_ctrl_len(&mut state, STREAM_CTRL_SIZE_11 as u16, false); assert_eq!(state.ctrl_len, STREAM_CTRL_SIZE_11); assert_eq!(state.probe[2], 1); - assert_eq!(state.commit[3], 2); + assert_eq!(state.commit[3], 1); } #[test] @@ -128,13 +128,13 @@ mod uvc_binary { let state = UvcState::new(sample_cfg()); let mut data = [0u8; STREAM_CTRL_SIZE_MAX]; data[2] = 1; - data[3] = 1; + data[3] = 2; write_le32(&mut data[4..8], 333_333); write_le32(&mut data[22..26], 4096); let out = sanitize_streaming_control(&data, &state); assert_eq!(out[2], 1); assert_eq!(out[3], 1); - assert_eq!(read_le32(&out, 4), 333_333); + assert_eq!(read_le32(&out, 4), state.cfg.interval); assert_eq!(read_le32(&out, 18), state.cfg.frame_size); assert_eq!(read_le32(&out, 22), state.cfg.max_packet); } @@ -166,7 +166,7 @@ mod uvc_binary { false, ); assert!(pending.is_none()); - assert_eq!(read_le32(&state.probe, 4), 250_000); + assert_eq!(read_le32(&state.probe, 4), state.cfg.interval); assert_eq!(read_le32(&state.probe, 22), state.cfg.max_packet); let mut pending = Some(PendingRequest { @@ -186,7 +186,7 @@ mod uvc_binary { }, false, ); - assert_eq!(read_le32(&state.commit, 4), 250_000); + assert_eq!(read_le32(&state.commit, 4), state.cfg.interval); } #[test] diff --git a/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs b/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs index 1172849..5f58126 100644 --- a/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_extra_contract.rs @@ -243,14 +243,14 @@ mod uvc_binary_extra { let state = UvcState::new(sample_cfg()); let mut payload = [0u8; UVC_DATA_SIZE]; payload[2] = 1; - payload[3] = 1; + payload[3] = 2; payload[4..8].copy_from_slice(&333_333u32.to_le_bytes()); payload[22..26].copy_from_slice(&(state.cfg.max_packet + 500).to_le_bytes()); let out = sanitize_streaming_control(&payload, &state); assert_eq!(out[2], 1); assert_eq!(out[3], 1); - assert_eq!(read_le32(&out, 4), 333_333); + assert_eq!(read_le32(&out, 4), state.cfg.interval); assert_eq!(read_le32(&out, 18), state.cfg.frame_size); assert_eq!(read_le32(&out, 22), state.cfg.max_packet); } @@ -290,7 +290,7 @@ mod uvc_binary_extra { } #[test] - fn handle_data_updates_commit_and_ignores_unknown_streaming_selector() { + fn handle_data_clamps_commit_interval_and_ignores_unknown_streaming_selector() { let interfaces = sample_interfaces(); let mut state = UvcState::new(sample_cfg()); let original_commit = state.commit; @@ -315,7 +315,8 @@ mod uvc_binary_extra { true, ); assert!(pending.is_none()); - assert_ne!(state.commit, original_commit); + assert_eq!(state.commit, original_commit); + assert_eq!(read_le32(&state.commit, 4), state.cfg.interval); let after_commit = state.commit; let mut pending = Some(PendingRequest { @@ -359,12 +360,9 @@ mod uvc_binary_extra { with_var("LESAVKA_UVC_BUFFER_COUNT", None::<&str>, || { with_var("LESAVKA_UVC_IDLE_PUMP_MS", None::<&str>, || { with_var("LESAVKA_UVC_FRAME_MAX_AGE_MS", None::<&str>, || { - assert_eq!(uvc_buffer_count(), 1); + assert_eq!(uvc_buffer_count(), 4); assert_eq!(uvc_idle_pump_sleep(), std::time::Duration::from_millis(2)); - assert_eq!( - uvc_queue_period(30), - Some(std::time::Duration::from_nanos(33_333_333)) - ); + assert_eq!(uvc_queue_period(30), None); assert_eq!( frame_spool_max_age(), Some(std::time::Duration::from_millis(1_000)) @@ -430,8 +428,9 @@ mod uvc_binary_extra { .expect("write oversize frame"); stream.refresh_latest_frame(); - assert_eq!(stream.latest_frame, vec![0xff, 0xd8, 0x11, 0xff, 0xd9]); + assert_eq!(stream.latest_frame, IDLE_MJPEG_FRAME); assert_eq!(stream.stats.rejected_oversize, 1); + assert_eq!(stream.stats.fallback_idle, 1); assert_eq!(stream.stats.last_rejected_oversize_bytes, 12); assert_eq!(stream.stats.last_rejected_oversize_cap, 8); assert_eq!(stream.frame_payload_limit(), 8); diff --git a/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs b/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs index 0a71872..78fbfad 100644 --- a/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs +++ b/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs @@ -238,6 +238,18 @@ mod video_sinks { } } + #[test] + fn camera_sink_dispatch_mirrors_packets_to_uvc_and_hdmi() { + let source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/camera_relay.rs" + )); + assert!(source.contains("UvcWithHdmiMirror")); + assert!(source.contains("uvc.push(pkt.clone())")); + assert!(source.contains("hdmi.push(pkt)")); + assert!(source.contains("new_uvc_with_hdmi_mirror")); + } + #[test] #[serial] fn camera_relay_feed_covers_dev_mode_dump_branch_without_panicking() { diff --git a/tests/regression/install/install_preserves_codec_settings_contract.rs b/tests/regression/install/install_preserves_codec_settings_contract.rs index 5ca7371..d1d3491 100644 --- a/tests/regression/install/install_preserves_codec_settings_contract.rs +++ b/tests/regression/install/install_preserves_codec_settings_contract.rs @@ -4,8 +4,8 @@ // explicit, while still allowing operator-provided install overrides. // Targets: server/client install scripts and client camera capture defaults. // Why: Lesavka now supports both MJPEG and HEVC upstream media, and installer -// reruns may prefer HEVC when decode is proven, falling back to either a -// smoke-tested software HEVC decoder or MJPEG instead of producing black frames. +// reruns should prefer MJPEG unless HEVC is explicitly requested and hardware +// decode is proven, instead of producing black frames. const SERVER_INSTALL: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -17,19 +17,18 @@ const CLIENT_CAMERA: &str = include_str!(concat!( )); #[test] -fn server_install_defaults_to_hevc_ingress_with_mjpeg_fallback_and_mjpeg_uvc_output() { +fn server_install_defaults_to_mjpeg_ingress_and_mjpeg_uvc_output() { for marker in [ "PERSISTED_UVC_CODEC=$(persisted_uvc_value LESAVKA_UVC_CODEC || true)", "normalize_uvc_codec()", "normalize_cam_codec()", "REQUESTED_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-${PERSISTED_UVC_CODEC:-mjpeg}}", "INSTALL_UVC_CODEC=$(normalize_uvc_codec \"$REQUESTED_UVC_CODEC\")", - "REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-hevc}}", + "REQUESTED_CAM_CODEC=${LESAVKA_INSTALL_CAM_CODEC:-${LESAVKA_CAM_CODEC:-mjpeg}}", "INSTALL_CAM_CODEC=$(normalize_cam_codec \"${REQUESTED_CAM_CODEC}\")", "INSTALL_CAM_CODEC_EXPLICIT=0", "INSTALL_HEVC_DECODER=${LESAVKA_INSTALL_HEVC_DECODER:-${LESAVKA_HEVC_DECODER:-}}", "INSTALL_ALLOW_SOFTWARE_VIDEO=${LESAVKA_INSTALL_ALLOW_SOFTWARE_VIDEO:-${LESAVKA_ALLOW_SOFTWARE_VIDEO:-0}}", - "Software HEVC decoder passed the same 1280x720 smoke", "Default HEVC upstream cannot be proven on this host; falling back to MJPEG ingress.", "printf 'LESAVKA_CAM_CODEC=%s\\n' \"${INSTALL_CAM_CODEC}\"", "printf 'LESAVKA_HEVC_DECODER=%s\\n' \"$INSTALL_HEVC_DECODER\"", @@ -89,7 +88,6 @@ fn hevc_prerequisites_are_rechecked_idempotently() { "/etc/modules-load.d/lesavka-hevc.conf", "gst-inspect-1.0 v4l2slh265dec", "avdec_h265 libde265dec", - "Keeping default HEVC upstream to avoid direct-MJPEG UVC artifacts", "Refusing HEVC upstream install because production video decode must be hardware-accelerated and proven", ] { assert!(