fix(uvc): enforce negotiated payload integrity
This commit is contained in:
parent
d5b1ecaae1
commit
75df8c94e1
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.6"
|
version = "0.27.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1692,7 +1692,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.27.6"
|
version = "0.27.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1704,7 +1704,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.27.6"
|
version = "0.27.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -485,6 +485,10 @@ path = "tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs"
|
|||||||
name = "synthetic_rct_uvc_probe_manual_contract"
|
name = "synthetic_rct_uvc_probe_manual_contract"
|
||||||
path = "tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs"
|
path = "tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "uvc_mjpeg_integrity_manual_contract"
|
||||||
|
path = "tests/manual/server/rct/uvc_mjpeg_integrity_manual_contract.rs"
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "google_meet_observer_manual_contract"
|
name = "google_meet_observer_manual_contract"
|
||||||
path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs"
|
path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs"
|
||||||
|
|||||||
@ -74,6 +74,9 @@ Useful entry points:
|
|||||||
|
|
||||||
## Operational Notes
|
## Operational Notes
|
||||||
|
|
||||||
|
- The full topology, media paths, current incident evidence, and remaining
|
||||||
|
completion gates are documented in
|
||||||
|
[`docs/architecture-and-media-readiness.md`](docs/architecture-and-media-readiness.md).
|
||||||
- Runtime and test environment variables are indexed in `docs/operational-env.md`.
|
- Runtime and test environment variables are indexed in `docs/operational-env.md`.
|
||||||
- Gate criteria and evidence paths are documented in `docs/quality-gate.md`.
|
- Gate criteria and evidence paths are documented in `docs/quality-gate.md`.
|
||||||
- Manual hardware checks belong in clearly marked manual scripts, not hidden in CI-only assumptions.
|
- Manual hardware checks belong in clearly marked manual scripts, not hidden in CI-only assumptions.
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.27.6"
|
version = "0.27.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -185,5 +185,60 @@ fn print_upstream_sync(state: lesavka_common::lesavka::UpstreamSyncState) {
|
|||||||
"planner_sink_handoff_window_samples={}",
|
"planner_sink_handoff_window_samples={}",
|
||||||
state.sink_handoff_window_samples
|
state.sink_handoff_window_samples
|
||||||
);
|
);
|
||||||
|
println!("uvc_contract_status={}", state.uvc_contract_status);
|
||||||
|
println!("uvc_transport={}", state.uvc_transport);
|
||||||
|
println!(
|
||||||
|
"uvc_enforced_frame_cap_bytes={}",
|
||||||
|
state
|
||||||
|
.uvc_enforced_frame_cap_bytes
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "unavailable".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"uvc_advertised_frame_bytes={}",
|
||||||
|
state
|
||||||
|
.uvc_advertised_frame_bytes
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "unavailable".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"uvc_committed_frame_bytes={}",
|
||||||
|
state
|
||||||
|
.uvc_committed_frame_bytes
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "unavailable".to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"uvc_committed_payload_bytes={}",
|
||||||
|
state
|
||||||
|
.uvc_committed_payload_bytes
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "unavailable".to_string())
|
||||||
|
);
|
||||||
|
println!("uvc_contract_divergent={}", state.uvc_contract_divergent);
|
||||||
|
println!(
|
||||||
|
"uvc_contract_missing_reads={}",
|
||||||
|
state.uvc_contract_missing_reads
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"uvc_contract_divergent_reads={}",
|
||||||
|
state.uvc_contract_divergent_reads
|
||||||
|
);
|
||||||
|
println!("uvc_contract_detail={}", state.uvc_contract_detail);
|
||||||
|
println!("uvc_integrity_status={}", state.uvc_integrity_status);
|
||||||
|
println!("uvc_integrity_detail={}", state.uvc_integrity_detail);
|
||||||
|
println!("uvc_rejected_oversize={}", state.uvc_rejected_oversize);
|
||||||
|
println!("uvc_rejected_invalid={}", state.uvc_rejected_invalid);
|
||||||
|
println!("uvc_fallback_idle={}", state.uvc_fallback_idle);
|
||||||
|
println!("uvc_held_last_good={}", state.uvc_held_last_good);
|
||||||
|
println!("uvc_stale_replay={}", state.uvc_stale_replay);
|
||||||
|
println!("uvc_read_errors={}", state.uvc_read_errors);
|
||||||
|
println!(
|
||||||
|
"uvc_strict_validation_failures={}",
|
||||||
|
state.uvc_strict_validation_failures
|
||||||
|
);
|
||||||
|
println!("uvc_dqbuf_errors={}", state.uvc_dqbuf_errors);
|
||||||
|
println!("uvc_qbuf_errors={}", state.uvc_qbuf_errors);
|
||||||
|
println!("uvc_kernel_errors={}", state.uvc_kernel_errors);
|
||||||
println!("planner_detail={}", state.last_reason);
|
println!("planner_detail={}", state.last_reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -321,6 +321,30 @@ fn print_upstream_sync_accepts_complete_and_pending_payloads() {
|
|||||||
microphone_sink_late_p95_ms: Some(18.5),
|
microphone_sink_late_p95_ms: Some(18.5),
|
||||||
client_timing_window_samples: 19,
|
client_timing_window_samples: 19,
|
||||||
sink_handoff_window_samples: 20,
|
sink_handoff_window_samples: 20,
|
||||||
|
uvc_contract_status: "ready".to_string(),
|
||||||
|
uvc_transport: "isochronous".to_string(),
|
||||||
|
uvc_enforced_frame_cap_bytes: Some(150_000),
|
||||||
|
uvc_advertised_frame_bytes: Some(150_000),
|
||||||
|
uvc_committed_frame_bytes: Some(150_000),
|
||||||
|
uvc_committed_payload_bytes: Some(1024),
|
||||||
|
uvc_contract_divergent: false,
|
||||||
|
uvc_contract_detail: "live contract agrees".to_string(),
|
||||||
|
uvc_contract_generated_unix_ms: Some(1_777_777_777_000),
|
||||||
|
uvc_contract_missing_reads: 0,
|
||||||
|
uvc_contract_divergent_reads: 0,
|
||||||
|
uvc_integrity_status: "ready".to_string(),
|
||||||
|
uvc_integrity_detail: "no helper or kernel faults".to_string(),
|
||||||
|
uvc_rejected_oversize: 0,
|
||||||
|
uvc_rejected_invalid: 0,
|
||||||
|
uvc_fallback_idle: 0,
|
||||||
|
uvc_held_last_good: 0,
|
||||||
|
uvc_strict_validation_failures: 0,
|
||||||
|
uvc_dqbuf_errors: 0,
|
||||||
|
uvc_qbuf_errors: 0,
|
||||||
|
uvc_kernel_errors: 0,
|
||||||
|
uvc_integrity_generated_unix_ms: Some(1_777_777_777_000),
|
||||||
|
uvc_stale_replay: 0,
|
||||||
|
uvc_read_errors: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
super::print_upstream_sync(UpstreamSyncState {
|
super::print_upstream_sync(UpstreamSyncState {
|
||||||
|
|||||||
@ -85,6 +85,13 @@ pub struct UpstreamSyncStatus {
|
|||||||
pub freshness_reanchors: u64,
|
pub freshness_reanchors: u64,
|
||||||
pub startup_timeouts: u64,
|
pub startup_timeouts: u64,
|
||||||
pub video_freezes: u64,
|
pub video_freezes: u64,
|
||||||
|
pub uvc_contract_status: String,
|
||||||
|
pub uvc_transport: String,
|
||||||
|
pub uvc_enforced_frame_cap_bytes: Option<u32>,
|
||||||
|
pub uvc_contract_divergent: bool,
|
||||||
|
pub uvc_contract_detail: String,
|
||||||
|
pub uvc_integrity_status: String,
|
||||||
|
pub uvc_integrity_detail: String,
|
||||||
pub detail: String,
|
pub detail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,6 +116,13 @@ impl UpstreamSyncStatus {
|
|||||||
freshness_reanchors: reply.freshness_reanchors,
|
freshness_reanchors: reply.freshness_reanchors,
|
||||||
startup_timeouts: reply.startup_timeouts,
|
startup_timeouts: reply.startup_timeouts,
|
||||||
video_freezes: reply.video_freezes,
|
video_freezes: reply.video_freezes,
|
||||||
|
uvc_contract_status: reply.uvc_contract_status,
|
||||||
|
uvc_transport: reply.uvc_transport,
|
||||||
|
uvc_enforced_frame_cap_bytes: reply.uvc_enforced_frame_cap_bytes,
|
||||||
|
uvc_contract_divergent: reply.uvc_contract_divergent,
|
||||||
|
uvc_contract_detail: reply.uvc_contract_detail,
|
||||||
|
uvc_integrity_status: reply.uvc_integrity_status,
|
||||||
|
uvc_integrity_detail: reply.uvc_integrity_detail,
|
||||||
detail: reply.last_reason,
|
detail: reply.last_reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -146,6 +160,13 @@ impl Default for UpstreamSyncStatus {
|
|||||||
freshness_reanchors: 0,
|
freshness_reanchors: 0,
|
||||||
startup_timeouts: 0,
|
startup_timeouts: 0,
|
||||||
video_freezes: 0,
|
video_freezes: 0,
|
||||||
|
uvc_contract_status: "unavailable".to_string(),
|
||||||
|
uvc_transport: "unknown".to_string(),
|
||||||
|
uvc_enforced_frame_cap_bytes: None,
|
||||||
|
uvc_contract_divergent: true,
|
||||||
|
uvc_contract_detail: "UVC payload contract unavailable".to_string(),
|
||||||
|
uvc_integrity_status: "unavailable".to_string(),
|
||||||
|
uvc_integrity_detail: "UVC helper integrity telemetry unavailable".to_string(),
|
||||||
detail: "upstream sync planner unavailable".to_string(),
|
detail: "upstream sync planner unavailable".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -906,6 +906,50 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uvc_chip_uses_helper_integrity_and_payload_contract_evidence() {
|
||||||
|
let mut state = LauncherState::new();
|
||||||
|
state.set_server_available(true);
|
||||||
|
state.set_server_media_caps(
|
||||||
|
Some(true),
|
||||||
|
Some(true),
|
||||||
|
Some("uvc".to_string()),
|
||||||
|
Some("mjpeg".to_string()),
|
||||||
|
);
|
||||||
|
state.upstream_sync.available = true;
|
||||||
|
state.upstream_sync.uvc_contract_status = "ready".to_string();
|
||||||
|
state.upstream_sync.uvc_contract_divergent = false;
|
||||||
|
state.upstream_sync.uvc_integrity_status = "ready".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
recovery_uvc_health(&state, false, None),
|
||||||
|
(StatusLightState::Live, "MJPEG".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
state.upstream_sync.uvc_integrity_status = "holding".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
recovery_uvc_health(&state, false, None),
|
||||||
|
(StatusLightState::Caution, "Holding".to_string())
|
||||||
|
);
|
||||||
|
state.upstream_sync.uvc_integrity_status = "fault".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
recovery_uvc_health(&state, false, None),
|
||||||
|
(StatusLightState::Warning, "Integrity fault".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
state.upstream_sync.uvc_integrity_status = "ready".to_string();
|
||||||
|
state.upstream_sync.uvc_contract_status = "unverified".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
recovery_uvc_health(&state, false, None),
|
||||||
|
(StatusLightState::Caution, "Unverified".to_string())
|
||||||
|
);
|
||||||
|
state.upstream_sync.uvc_contract_status = "divergent".to_string();
|
||||||
|
state.upstream_sync.uvc_contract_divergent = true;
|
||||||
|
assert_eq!(
|
||||||
|
recovery_uvc_health(&state, false, None),
|
||||||
|
(StatusLightState::Warning, "Contract fault".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capture_power_detail_mentions_detected_eyes_when_powered() {
|
fn capture_power_detail_mentions_detected_eyes_when_powered() {
|
||||||
let power = CapturePowerStatus {
|
let power = CapturePowerStatus {
|
||||||
|
|||||||
@ -216,6 +216,21 @@ fn recovery_uvc_health(
|
|||||||
.unwrap_or(codec);
|
.unwrap_or(codec);
|
||||||
return (StatusLightState::Caution, value);
|
return (StatusLightState::Caution, value);
|
||||||
}
|
}
|
||||||
|
if state.upstream_sync.available {
|
||||||
|
match state.upstream_sync.uvc_integrity_status.as_str() {
|
||||||
|
"ready" => {}
|
||||||
|
"holding" => return (StatusLightState::Caution, "Holding".to_string()),
|
||||||
|
"fault" => return (StatusLightState::Warning, "Integrity fault".to_string()),
|
||||||
|
_ => return (StatusLightState::Caution, "Unverified".to_string()),
|
||||||
|
}
|
||||||
|
match state.upstream_sync.uvc_contract_status.as_str() {
|
||||||
|
"ready" if !state.upstream_sync.uvc_contract_divergent => {}
|
||||||
|
"" | "unavailable" | "unverified" => {
|
||||||
|
return (StatusLightState::Caution, "Unverified".to_string());
|
||||||
|
}
|
||||||
|
_ => return (StatusLightState::Warning, "Contract fault".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
if !relay_live {
|
if !relay_live {
|
||||||
return (StatusLightState::Live, codec);
|
return (StatusLightState::Live, codec);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,8 +139,18 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
|||||||
set_status_light(&widgets.summary.uvc_light, uvc_state);
|
set_status_light(&widgets.summary.uvc_light, uvc_state);
|
||||||
widgets.summary.uvc_value.set_text(&uvc_value);
|
widgets.summary.uvc_value.set_text(&uvc_value);
|
||||||
widgets.summary.uvc_value.set_tooltip_text(Some(&format!(
|
widgets.summary.uvc_value.set_tooltip_text(Some(&format!(
|
||||||
"Upstream webcam transport: {}. Server calibration is profile-specific.",
|
"Upstream webcam transport: {}. UVC payload contract: {} via {}, cap {} bytes. {} Integrity: {}. {}",
|
||||||
state.effective_webcam_transport().label()
|
state.effective_webcam_transport().label(),
|
||||||
|
state.upstream_sync.uvc_contract_status,
|
||||||
|
state.upstream_sync.uvc_transport,
|
||||||
|
state
|
||||||
|
.upstream_sync
|
||||||
|
.uvc_enforced_frame_cap_bytes
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "unavailable".to_string()),
|
||||||
|
state.upstream_sync.uvc_contract_detail,
|
||||||
|
state.upstream_sync.uvc_integrity_status,
|
||||||
|
state.upstream_sync.uvc_integrity_detail,
|
||||||
)));
|
)));
|
||||||
let (lag_state, lag_value, lag_tooltip) =
|
let (lag_state, lag_value, lag_tooltip) =
|
||||||
upstream_lag_health(&state.upstream_sync, relay_live);
|
upstream_lag_health(&state.upstream_sync, relay_live);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.27.6"
|
version = "0.27.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -195,6 +195,30 @@ message UpstreamSyncState {
|
|||||||
optional float microphone_sink_late_p95_ms = 33;
|
optional float microphone_sink_late_p95_ms = 33;
|
||||||
uint64 client_timing_window_samples = 34;
|
uint64 client_timing_window_samples = 34;
|
||||||
uint64 sink_handoff_window_samples = 35;
|
uint64 sink_handoff_window_samples = 35;
|
||||||
|
string uvc_contract_status = 36;
|
||||||
|
string uvc_transport = 37;
|
||||||
|
optional uint32 uvc_enforced_frame_cap_bytes = 38;
|
||||||
|
optional uint32 uvc_advertised_frame_bytes = 39;
|
||||||
|
optional uint32 uvc_committed_frame_bytes = 40;
|
||||||
|
optional uint32 uvc_committed_payload_bytes = 41;
|
||||||
|
bool uvc_contract_divergent = 42;
|
||||||
|
string uvc_contract_detail = 43;
|
||||||
|
optional uint64 uvc_contract_generated_unix_ms = 44;
|
||||||
|
uint64 uvc_contract_missing_reads = 45;
|
||||||
|
uint64 uvc_contract_divergent_reads = 46;
|
||||||
|
string uvc_integrity_status = 47;
|
||||||
|
string uvc_integrity_detail = 48;
|
||||||
|
uint64 uvc_rejected_oversize = 49;
|
||||||
|
uint64 uvc_rejected_invalid = 50;
|
||||||
|
uint64 uvc_fallback_idle = 51;
|
||||||
|
uint64 uvc_held_last_good = 52;
|
||||||
|
uint64 uvc_strict_validation_failures = 53;
|
||||||
|
uint64 uvc_dqbuf_errors = 54;
|
||||||
|
uint64 uvc_qbuf_errors = 55;
|
||||||
|
uint64 uvc_kernel_errors = 56;
|
||||||
|
optional uint64 uvc_integrity_generated_unix_ms = 57;
|
||||||
|
uint64 uvc_stale_replay = 58;
|
||||||
|
uint64 uvc_read_errors = 59;
|
||||||
}
|
}
|
||||||
|
|
||||||
message HandshakeSet {
|
message HandshakeSet {
|
||||||
|
|||||||
445
docs/architecture-and-media-readiness.md
Normal file
445
docs/architecture-and-media-readiness.md
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
# Lesavka Architecture And Media Readiness
|
||||||
|
|
||||||
|
Status snapshot: 2026-08-12
|
||||||
|
|
||||||
|
This document explains what Lesavka is, how traffic moves through it, why the
|
||||||
|
webcam uplink has been unreliable, why the downstream eye feeds are currently
|
||||||
|
blank, and what evidence is still required before the system can be considered
|
||||||
|
finished.
|
||||||
|
|
||||||
|
## 1. What Lesavka Is Supposed To Do
|
||||||
|
|
||||||
|
Lesavka makes a physically attached lab computer usable from an operator
|
||||||
|
workstation as though its important peripherals were local. It is not merely a
|
||||||
|
screen-sharing application. It joins network transport to real USB, HDMI,
|
||||||
|
audio, camera, and input hardware.
|
||||||
|
|
||||||
|
There are three roles:
|
||||||
|
|
||||||
|
1. **Operator workstation (client)** - runs the Lesavka launcher and relay,
|
||||||
|
displays the two remote eye feeds, plays remote audio, captures the selected
|
||||||
|
webcam and microphone, and captures keyboard and mouse input.
|
||||||
|
2. **Theia / `titan-jh` (server)** - terminates the authenticated gRPC session,
|
||||||
|
reads the two HDMI capture cards and remote audio device, and presents the
|
||||||
|
operator's camera, microphone, keyboard, and mouse to the controlled host as
|
||||||
|
physical devices.
|
||||||
|
3. **Remote controlled host (RCT)** - sees Theia as USB HID, UVC webcam, UAC
|
||||||
|
microphone/speaker, and display-related hardware. Applications such as a
|
||||||
|
browser or Google Meet should be able to use those devices without knowing
|
||||||
|
that the operator is elsewhere.
|
||||||
|
|
||||||
|
The intended result is:
|
||||||
|
|
||||||
|
- two live, low-latency remote display feeds at the operator workstation;
|
||||||
|
- remote speaker audio at the operator workstation;
|
||||||
|
- keyboard and mouse control of the RCT;
|
||||||
|
- the operator's selected webcam and microphone appearing on the RCT;
|
||||||
|
- device/profile controls in the launcher remaining authoritative; and
|
||||||
|
- bounded latency, explicit health information, and recoverable failures.
|
||||||
|
|
||||||
|
## 2. Direction Names
|
||||||
|
|
||||||
|
The direction names are from the operator workstation's point of view.
|
||||||
|
|
||||||
|
### Downstream: Theia To The Operator
|
||||||
|
|
||||||
|
Downstream carries the RCT's outputs back to the operator:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RCT display outputs
|
||||||
|
-> GC311 capture cards on Theia
|
||||||
|
-> /dev/lesavka_l_eye and /dev/lesavka_r_eye
|
||||||
|
-> CaptureVideo gRPC streams
|
||||||
|
-> client H.264 decoder
|
||||||
|
-> launcher previews / breakout windows
|
||||||
|
|
||||||
|
RCT USB speaker output
|
||||||
|
-> Theia UAC capture
|
||||||
|
-> CaptureAudio gRPC stream
|
||||||
|
-> client audio sink
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upstream: The Operator To Theia And The RCT
|
||||||
|
|
||||||
|
Upstream carries the operator's inputs toward the RCT:
|
||||||
|
|
||||||
|
```text
|
||||||
|
operator webcam + microphone
|
||||||
|
-> one client capture timeline
|
||||||
|
-> StreamWebcamMedia bundled gRPC stream
|
||||||
|
-> Theia freshness/sync planner
|
||||||
|
-> UVC webcam + UAC microphone presented to the RCT
|
||||||
|
|
||||||
|
operator keyboard + mouse
|
||||||
|
-> streaming gRPC input RPCs
|
||||||
|
-> Theia HID gadget
|
||||||
|
-> RCT input
|
||||||
|
```
|
||||||
|
|
||||||
|
The upstream and downstream video systems are separate. A healthy webcam
|
||||||
|
uplink does not prove the two downstream eye feeds work, and healthy eye feeds
|
||||||
|
do not prove that webcam video reaches the RCT.
|
||||||
|
|
||||||
|
## 3. Connection, Security, And Session Shape
|
||||||
|
|
||||||
|
The client connects to Theia over TLS with a private CA and a client
|
||||||
|
certificate. The server exposes a handshake service plus the relay service.
|
||||||
|
The handshake reports versions and physical capabilities; the relay service
|
||||||
|
owns the live streams and control RPCs.
|
||||||
|
|
||||||
|
Important RPCs are:
|
||||||
|
|
||||||
|
- `Handshake/GetCapabilities` for version, profile, and feature discovery;
|
||||||
|
- `Relay/CaptureVideo` for each downstream eye;
|
||||||
|
- `Relay/CaptureAudio` for downstream speaker audio;
|
||||||
|
- `Relay/StreamKeyboard` and `Relay/StreamMouse` for input;
|
||||||
|
- `Relay/StreamWebcamMedia` for bundled upstream camera and microphone;
|
||||||
|
- `Relay/StreamMicrophone` for the explicit microphone-only path;
|
||||||
|
- capture-power, calibration, recovery, and diagnostics RPCs.
|
||||||
|
|
||||||
|
The client starts long-running tasks for the requested media and input paths.
|
||||||
|
Queues are deliberately bounded. Live interaction is more important than
|
||||||
|
draining every packet that was once captured.
|
||||||
|
|
||||||
|
## 4. How Downstream Video Works
|
||||||
|
|
||||||
|
Theia expects two stable udev-created device names:
|
||||||
|
|
||||||
|
- `/dev/lesavka_l_eye`
|
||||||
|
- `/dev/lesavka_r_eye`
|
||||||
|
|
||||||
|
The server installer discovers the two GC311 capture devices, identifies them
|
||||||
|
by their physical udev path tags, and writes `/etc/udev/rules.d/85-gc311.rules`
|
||||||
|
so left and right do not change when `/dev/videoN` numbering changes.
|
||||||
|
|
||||||
|
When a client opens `CaptureVideo`, the server:
|
||||||
|
|
||||||
|
1. validates the requested eye and source;
|
||||||
|
2. acquires a capture-power lease;
|
||||||
|
3. waits a bounded time for the expected V4L2 device;
|
||||||
|
4. opens the capture card with GStreamer;
|
||||||
|
5. preserves the card's H.264 elementary stream instead of decoding and
|
||||||
|
re-encoding it on Theia;
|
||||||
|
6. publishes packets through a shared per-source hub; and
|
||||||
|
7. drops packets under pressure rather than building interactive latency.
|
||||||
|
|
||||||
|
The client opens one RPC for each eye. It sends packets to a bounded display
|
||||||
|
queue. If that queue overflows, it drops predicted frames and waits for an IDR
|
||||||
|
frame before resuming, which prevents a corrupt decoder recovery sequence.
|
||||||
|
The installed workstation currently exposes `vah264dec`, backed by Intel's iHD
|
||||||
|
VA-API driver, and also has `avdec_h264` for explicit software diagnostics.
|
||||||
|
|
||||||
|
## 5. How Bundled Upstream Webcam Media Works
|
||||||
|
|
||||||
|
Webcam sessions use the v2 bundled path by default. The camera and microphone
|
||||||
|
are captured by one client-owned media pipeline and share the client's capture
|
||||||
|
clock. A bundle contains an optional video frame and the audio packets captured
|
||||||
|
beside it.
|
||||||
|
|
||||||
|
The important rules are:
|
||||||
|
|
||||||
|
- camera-enabled sessions imply microphone capture when UAC is available;
|
||||||
|
- camera audio is not flushed as an unrelated standalone stream;
|
||||||
|
- source and handoff queues keep fresh media rather than old media;
|
||||||
|
- client capture timestamps are the A/V sync truth;
|
||||||
|
- Theia maps that timeline to one new local playout epoch;
|
||||||
|
- only explicit UVC/UAC device-path offsets may move one output relative to the
|
||||||
|
other; and
|
||||||
|
- stale or internally inconsistent mixed bundles are dropped coherently.
|
||||||
|
|
||||||
|
Theia's planner schedules audio and video from the shared timeline, then hands
|
||||||
|
approved packets to UAC and UVC sinks. The sinks should be simple consumers:
|
||||||
|
they must not accumulate private multi-second backlogs or create a second sync
|
||||||
|
model.
|
||||||
|
|
||||||
|
Microphone-only operation remains a separate supported mode. The old split
|
||||||
|
camera/microphone webcam uplink exists only as a compatibility escape hatch.
|
||||||
|
|
||||||
|
## 6. Why The Upstream Webcam Feed Has Been A Long-Running Problem
|
||||||
|
|
||||||
|
The original upstream implementation sent camera and microphone through
|
||||||
|
independent streams. Each side could establish or retain its own timing anchor,
|
||||||
|
queue, recovery state, and output delay. That made several failure modes
|
||||||
|
possible:
|
||||||
|
|
||||||
|
- a new session could inherit an old stream's playout epoch;
|
||||||
|
- one side could start while the other waited;
|
||||||
|
- stale audio or video could be drained to "catch up";
|
||||||
|
- capture timestamps could be replaced by late enqueue or network time;
|
||||||
|
- independent freshness decisions could preserve smooth playback while losing
|
||||||
|
lip sync; and
|
||||||
|
- large static calibration values could hide architectural backlog.
|
||||||
|
|
||||||
|
Those failures produced seconds-scale delay and A/V skew, including observed
|
||||||
|
browser-visible failures near eight to ten seconds. The bundled v2 redesign
|
||||||
|
removed the independent-stream timing contract, added latest/freshness-bounded
|
||||||
|
queues, reset timing state on session replacement, made audio-master/video-
|
||||||
|
follower behavior explicit, and added device-level sync probes.
|
||||||
|
|
||||||
|
That redesign fixed the class of problem, but a newer sink handoff regression
|
||||||
|
reintroduced a different kind of video backlog.
|
||||||
|
|
||||||
|
### The Latest Confirmed Upstream Failure
|
||||||
|
|
||||||
|
Before the current patch, live diagnostics showed:
|
||||||
|
|
||||||
|
- client camera and microphone capture was connected and streaming;
|
||||||
|
- client delivery age was roughly `40-79 ms`;
|
||||||
|
- Theia's microphone sink lateness p95 was about `1.4 ms`; but
|
||||||
|
- Theia's camera sink lateness p95 was about `7661.5 ms`.
|
||||||
|
|
||||||
|
This proves the webcam, bundled network transport, and shared capture clock were
|
||||||
|
not the bottleneck. Video was becoming late inside Theia at the sink handoff.
|
||||||
|
|
||||||
|
The immediate cause was the newly auto-enabled optional HDMI camera mirror.
|
||||||
|
The camera relay synchronously performed the equivalent of:
|
||||||
|
|
||||||
|
```text
|
||||||
|
push frame to UVC
|
||||||
|
push the same frame to HDMI/KMS
|
||||||
|
```
|
||||||
|
|
||||||
|
If the KMS/HDMI mirror blocked, the task could not return to feed the next UVC
|
||||||
|
frame. A 32-frame FIFO handoff and a direct-MJPEG normalization pull that could
|
||||||
|
wait up to 50 ms amplified the stall into a deep video backlog. Audio continued
|
||||||
|
through its own sink handoff, so the result was missing or many-seconds-late
|
||||||
|
video rather than a transport disconnect.
|
||||||
|
|
||||||
|
### The `0.27.6` Correction
|
||||||
|
|
||||||
|
The correction is pushed to Gitea `master` at revision `d5b1eca`:
|
||||||
|
|
||||||
|
- UVC remains the synchronous primary camera output;
|
||||||
|
- HDMI mirroring runs in an isolated worker with only one pending latest frame;
|
||||||
|
- a blocked mirror can replace/drop its own stale frame but cannot stall UVC;
|
||||||
|
- the v2 scheduled-video handoff is a latest-value watch channel rather than a
|
||||||
|
32-frame FIFO; and
|
||||||
|
- direct MJPEG normalization is nonblocking by default (`0 ms` pull timeout).
|
||||||
|
|
||||||
|
The client installer also now accepts a kernel module that is already loaded in
|
||||||
|
`/sys/module` even when `modinfo` cannot find metadata for the running kernel.
|
||||||
|
That installer fix is why the complete patch release is `0.27.6` rather than
|
||||||
|
`0.27.5`.
|
||||||
|
|
||||||
|
At the start of the 0.27.7 integrity work, the operator workstation was running
|
||||||
|
`lesavka-client 0.27.6` and Theia was still running `lesavka-server 0.27.4` at
|
||||||
|
revision `7011b5c`. Release artifacts and live `lesavka-relayctl version`
|
||||||
|
output, rather than this historical incident snapshot, are authoritative for
|
||||||
|
the current deployment state.
|
||||||
|
|
||||||
|
### The Post-Spool UVC Integrity Failure
|
||||||
|
|
||||||
|
The seconds-late sink handoff was not the only upstream video defect. A
|
||||||
|
simultaneous boundary capture on 2026-07-19 proved that
|
||||||
|
`/run/lesavka-uvc-frame.mjpg` was clean while the RCT-side UVC capture contained
|
||||||
|
black frames, grey lower bands, multi-frame splices, and a 184-second freeze.
|
||||||
|
The corruption was therefore introduced after the spool, in the helper,
|
||||||
|
gadget, USB, or host-consumption leg. The preserved evidence is under
|
||||||
|
`artifacts/webm-tear-analysis/20260719-014411/boundary-spool-vs-uvc/`.
|
||||||
|
|
||||||
|
The 0.27.6 code had five concrete contract defects that could create or hide
|
||||||
|
that failure:
|
||||||
|
|
||||||
|
- bulk sizing was selected from the existence of configfs `streaming_bulk`,
|
||||||
|
even when its value was `0`;
|
||||||
|
- the gadget script swallowed a failed `streaming_bulk=1` write;
|
||||||
|
- explicit frame-size overrides could exceed the descriptor/wire cap;
|
||||||
|
- invalid, oversized, stale, or unreadable frames immediately replaced the
|
||||||
|
last good frame with black idle video; and
|
||||||
|
- core, spool, and helper sizing calculations could silently drift apart.
|
||||||
|
|
||||||
|
### The `0.27.7` Integrity Contract
|
||||||
|
|
||||||
|
Release 0.27.7 makes the helper-published live contract authoritative. The UVC
|
||||||
|
helper reads configfs values and the browser's actual UVC probe/commit, then
|
||||||
|
atomically publishes `/run/lesavka-uvc-contract.json`. Both helper and server
|
||||||
|
spool enforce the smallest of the requested cap, descriptor cap, committed cap,
|
||||||
|
transport budget, and hard safety maximum. Missing configfs is isochronous and
|
||||||
|
unverified, never implicitly bulk. `lesavka-core.sh` now verifies bulk writeback
|
||||||
|
and restores isochronous packet sizing if the requested mode does not stick.
|
||||||
|
|
||||||
|
The output behavior is also explicit:
|
||||||
|
|
||||||
|
- a transient invalid, oversized, stale, or unreadable spool update freezes the
|
||||||
|
last verified frame;
|
||||||
|
- black idle appears only after `LESAVKA_UVC_IDLE_AFTER_MS`, default `2000`;
|
||||||
|
- every thirtieth accepted frame receives a complete JPEG structure and APP4
|
||||||
|
CRC check by default; and
|
||||||
|
- helper DQBUF/QBUF failures, `V4L2_BUF_FLAG_ERROR`, substitutions, strict
|
||||||
|
validation failures, and kernel UVC/UDC events are counted.
|
||||||
|
|
||||||
|
Operational evidence is available without an RCT:
|
||||||
|
|
||||||
|
- `/run/lesavka-uvc-contract.json` is the live negotiated payload contract;
|
||||||
|
- `/run/lesavka-uvc-video-stats.json` contains helper and kernel counters;
|
||||||
|
- `relayctl upstream-sync` prints the contract and integrity status;
|
||||||
|
- the launcher's UVC chip reports `Holding`, `Integrity fault`, `Contract
|
||||||
|
fault`, or `Unverified` from real server evidence; and
|
||||||
|
- `LESAVKA_UVC_HANDOFF_AUDIT=1` enables a bounded frame-by-frame JSONL ring at
|
||||||
|
`/run/lesavka-uvc-handoff.jsonl` for lab diagnosis.
|
||||||
|
|
||||||
|
Marked synthetic frames now carry sequence/band blocks every 64 rows and an
|
||||||
|
APP4 sequence plus payload CRC. `scripts/manual/analyze_marked_capture.py`
|
||||||
|
classifies native MJPEG frames as intact, truncated, spliced, stale repeat, or
|
||||||
|
idle black and reports the first bad row or splice seam when recoverable. The
|
||||||
|
synthetic RCT probe's `--deep-capture` mode preserves native MJPEG, uvcvideo
|
||||||
|
debug logs, usbmon traffic, USB power state, and before/after device snapshots.
|
||||||
|
|
||||||
|
These changes close the known software defects and observability gaps. They do
|
||||||
|
not by themselves prove the RCT-side USB path is clean. The 0.27.7 hardware A/B,
|
||||||
|
mode-matrix soak, browser probe, and Google Meet validation remain release
|
||||||
|
gates. A host-controller drop or host decoder bug can still occur after the
|
||||||
|
gadget has successfully queued a verified frame. The honest long-term answer is
|
||||||
|
to retain a small Linux verification host in the lab when possible; otherwise
|
||||||
|
the calibrated server telemetry is the available integrity contract.
|
||||||
|
|
||||||
|
## 7. The Current Downstream Video Failure
|
||||||
|
|
||||||
|
The current blank downstream feeds fail before transport or decoding.
|
||||||
|
|
||||||
|
On 2026-08-12, direct mutually authenticated `CaptureVideo` probes returned:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eye-l device /dev/lesavka_l_eye was not ready within 5000 ms:
|
||||||
|
No such file or directory
|
||||||
|
|
||||||
|
eye-r device /dev/lesavka_r_eye was not ready within 5000 ms:
|
||||||
|
No such file or directory
|
||||||
|
```
|
||||||
|
|
||||||
|
Both RPCs reached the live server and produced a precise host-side error. The
|
||||||
|
server acquired the normal capture path but neither expected V4L2 device became
|
||||||
|
available. No H.264 packets were sent to the workstation. The workstation's
|
||||||
|
hardware decoder is present and working, so changing the local renderer cannot
|
||||||
|
fix this incident.
|
||||||
|
|
||||||
|
The capture-power control provides a second independent confirmation. Theia
|
||||||
|
reports the configured GPIO unit as `relay.service`, with state
|
||||||
|
`failed/failed`. A `ForceOn` control request momentarily changed the unit to
|
||||||
|
`active/running`, but it returned to `failed/failed` within twelve seconds. A
|
||||||
|
second attempt followed immediately by a `CaptureVideo` request still produced
|
||||||
|
no left-eye device during the five-second readiness window, and the power unit
|
||||||
|
again ended in `failed/failed`. This is not simply the normal auto-power idle
|
||||||
|
state: the unit cannot remain healthy long enough for either card to enumerate.
|
||||||
|
|
||||||
|
Theia's SSH listener is also unhealthy. TCP reaches the real Theia SSH port
|
||||||
|
`2208` over the configured route, but every connection is reset before the SSH
|
||||||
|
banner/authentication exchange. At the same time:
|
||||||
|
|
||||||
|
- Lesavka gRPC on `50051` remains available;
|
||||||
|
- node exporter on `9100` remains available;
|
||||||
|
- the host reports ample free memory, negligible load, and no socket/resource
|
||||||
|
exhaustion; and
|
||||||
|
- the NVMe filesystem and normal network interface remain visible.
|
||||||
|
|
||||||
|
This combination points to stuck host service/GPIO/device state, not a dead
|
||||||
|
host or a client connectivity problem. A controlled Theia reboot is justified
|
||||||
|
because it can restore the SSH daemon, capture-power unit, and capture-card
|
||||||
|
enumeration in one bounded operation. It is not yet proof that the capture
|
||||||
|
hardware itself is healthy. If the devices remain absent after reboot, the next
|
||||||
|
checks are the `relay.service` failure reason, capture-card power, USB
|
||||||
|
enumeration, V4L2 nodes, udev path tags/rules, and physical card/cable state.
|
||||||
|
|
||||||
|
## 8. Recovery And Deployment Sequence
|
||||||
|
|
||||||
|
The safe completion sequence for this incident is:
|
||||||
|
|
||||||
|
1. Perform a controlled reboot of Theia. Do not hard-reset the UVC/UAC gadget
|
||||||
|
separately unless later evidence specifically requires it.
|
||||||
|
2. Wait for SSH, gRPC, and node-exporter health.
|
||||||
|
3. Over SSH, inspect `lsusb`, `v4l2-ctl --list-devices`, `/dev/video*`, and the
|
||||||
|
two `/dev/lesavka_*_eye` symlinks.
|
||||||
|
4. Use `ssh theia`, whose alias targets `38.28.125.112:2208`. Port `2277` is a
|
||||||
|
jump route for other titan hosts and is not a Theia health probe.
|
||||||
|
5. Pull Gitea `master` into Theia's existing checkout without overwriting
|
||||||
|
unrelated local work.
|
||||||
|
6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the
|
||||||
|
already-attached USB gadget unless a controlled rebuild is explicitly
|
||||||
|
required.
|
||||||
|
7. Confirm Theia reports server version `0.27.7`, the pushed release revision,
|
||||||
|
direct MJPEG normalizer timeout `0`, and a coherent UVC contract.
|
||||||
|
8. Open both downstream RPCs and prove that each emits changing, decodable H.264
|
||||||
|
frames.
|
||||||
|
9. Run a real bundled webcam session and confirm that camera sink lateness no
|
||||||
|
longer grows into seconds.
|
||||||
|
10. Run marked native-UVC A/B capture and the four-mode soak with zero
|
||||||
|
truncated, spliced, or unexpected-black frames.
|
||||||
|
11. Verify the RCT receives live UVC video and UAC audio, then run the mirrored
|
||||||
|
browser probe and a manual Google Meet check.
|
||||||
|
|
||||||
|
## 9. What Is Still Missing Before Lesavka Is Done
|
||||||
|
|
||||||
|
Lesavka is feature-rich and heavily tested, but it is not done until the live
|
||||||
|
hardware contract is repeatable. The remaining work falls into five groups.
|
||||||
|
|
||||||
|
### A. Install And Version Parity
|
||||||
|
|
||||||
|
- Push and deploy `0.27.7` through the client/server install scripts.
|
||||||
|
- Confirm client/server version and revision in every hardware probe artifact.
|
||||||
|
- Eliminate the current state where a fixed client talks to an unfixed server.
|
||||||
|
|
||||||
|
### B. End-To-End Media Acceptance
|
||||||
|
|
||||||
|
- Both downstream eye feeds must open, keep changing, decode without a black
|
||||||
|
screen, and recover from an interrupted capture source.
|
||||||
|
- The webcam must appear on the RCT as nonblack, supported UVC video.
|
||||||
|
- Microphone audio must remain intelligible and synchronized with camera video.
|
||||||
|
- The mirrored browser probe must meet the project thresholds: preferred median
|
||||||
|
near `35 ms`, acceptable p95 absolute skew within `80 ms`, and no sustained
|
||||||
|
split near `1000 ms`.
|
||||||
|
- Manual Google Meet testing must agree with the device-level probes.
|
||||||
|
|
||||||
|
### C. Remaining Runtime/Diagnostics Work
|
||||||
|
|
||||||
|
- Add first-bundle, first-video-feed, first-audio-push, stale-drop, and queue-age
|
||||||
|
counters for bundled sessions.
|
||||||
|
- Add concise per-session first camera/microphone packet and first UVC/UAC sink
|
||||||
|
handoff evidence.
|
||||||
|
- Surface `Starting`, `Healing`, `Flowing`, `Lagging`, `Dropping`, and `Stale`
|
||||||
|
from real path evidence rather than inferred UI state.
|
||||||
|
- Flush and stop UAC cleanly on close, replacement, and recovery.
|
||||||
|
- Prove disconnect closes all media tasks owned by that session.
|
||||||
|
|
||||||
|
### D. Compatibility And Profile Closure
|
||||||
|
|
||||||
|
- Prove normal bundled-capable webcam sessions never use the legacy split RPCs.
|
||||||
|
- Complete focused server bundled-stream acceptance tests.
|
||||||
|
- Run and record the Theia/Tethys mode matrix for `1280x720@20/30` and
|
||||||
|
`1920x1080@20/30` before changing advertised profiles.
|
||||||
|
- Preserve the small operator calibration nudges after the static hardware
|
||||||
|
baselines are locked.
|
||||||
|
|
||||||
|
### E. Operational Reliability
|
||||||
|
|
||||||
|
- Restore and verify Theia SSH, since the required installer and incident
|
||||||
|
diagnostics depend on it in this lab.
|
||||||
|
- Add a bounded, documented way to recover a wedged SSH daemon without turning
|
||||||
|
an application outage into an improvised host power operation.
|
||||||
|
- Make capture-card absence a first-class health state visible before the user
|
||||||
|
opens a blank preview.
|
||||||
|
- Keep device-level probe artifacts as release evidence; internal queue and
|
||||||
|
planner telemetry alone cannot declare the product fixed.
|
||||||
|
- Calibrate the helper/kernel integrity counters against simultaneous RCT
|
||||||
|
fault-injection captures so every visible corruption class has a server-side
|
||||||
|
signal and documented residual blind spot.
|
||||||
|
|
||||||
|
## 10. Definition Of Done
|
||||||
|
|
||||||
|
Lesavka can be called done when a freshly installed client and server can pass
|
||||||
|
this sequence without source edits, secret manual timing adjustments, or hidden
|
||||||
|
host repair:
|
||||||
|
|
||||||
|
1. handshake and versions match;
|
||||||
|
2. both eye feeds show changing remote video;
|
||||||
|
3. downstream audio is audible;
|
||||||
|
4. keyboard and mouse control the RCT;
|
||||||
|
5. selected webcam/microphone settings remain authoritative;
|
||||||
|
6. RCT applications receive live UVC/UAC media;
|
||||||
|
7. mirrored-probe sync/freshness/smoothness gates pass;
|
||||||
|
8. Google Meet passes manual audio, video, and lip-sync validation;
|
||||||
|
9. disconnect/reconnect and device changes recover without stale media; and
|
||||||
|
10. diagnostics identify the failed physical stage when any item breaks.
|
||||||
|
|
||||||
|
Until that sequence passes on the installed `0.27.7` client/server pair, the
|
||||||
|
current release should be described as a validated code correction awaiting
|
||||||
|
hardware deployment and end-to-end acceptance, not as a completed product fix.
|
||||||
@ -340,6 +340,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
|
|||||||
| `LESAVKA_UVC_EXTERNAL` | server hardware/device override |
|
| `LESAVKA_UVC_EXTERNAL` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_FALLBACK` | server hardware/device override |
|
| `LESAVKA_UVC_FALLBACK` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_FPS` | server hardware/device override |
|
| `LESAVKA_UVC_FPS` | server hardware/device override |
|
||||||
|
| `LESAVKA_UVC_CONTRACT_PATH` | Atomic helper-published UVC payload contract consumed by the server spool and diagnostics; defaults to `/run/lesavka-uvc-contract.json` |
|
||||||
| `LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH` | UVC helper boundary-audit runtime control path; defaults to `/tmp/lesavka-uvc-frame-audit.control`, where a path value enables exact-frame audit capture and `off`/`0` disables it without restarting the server |
|
| `LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH` | UVC helper boundary-audit runtime control path; defaults to `/tmp/lesavka-uvc-frame-audit.control`, where a path value enables exact-frame audit capture and `off`/`0` disables it without restarting the server |
|
||||||
| `LESAVKA_UVC_FRAME_AUDIT_DIR` | UVC helper boundary-audit directory; when set, the server saves exact MJPEG frames published to the UVC helper plus a JSONL index so recordings can be compared against pre-UVC payloads |
|
| `LESAVKA_UVC_FRAME_AUDIT_DIR` | UVC helper boundary-audit directory; when set, the server saves exact MJPEG frames published to the UVC helper plus a JSONL index so recordings can be compared against pre-UVC payloads |
|
||||||
| `LESAVKA_UVC_FRAME_AUDIT_EVERY` | UVC helper boundary-audit sampling interval; saves every Nth spooled frame, defaults to `1` for short repros |
|
| `LESAVKA_UVC_FRAME_AUDIT_EVERY` | UVC helper boundary-audit sampling interval; saves every Nth spooled frame, defaults to `1` for short repros |
|
||||||
@ -350,13 +351,17 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
|
|||||||
| `LESAVKA_UVC_FRAME_META_LOG_PATH` | UVC helper diagnostic override; when set with `LESAVKA_UVC_FRAME_META=1`, append every MJPEG spool timing record as JSONL for full-probe HEVC/RCT correlation; summarize with `scripts/manual/summarize_uvc_frame_meta_log.py` |
|
| `LESAVKA_UVC_FRAME_META_LOG_PATH` | UVC helper diagnostic override; when set with `LESAVKA_UVC_FRAME_META=1`, append every MJPEG spool timing record as JSONL for full-probe HEVC/RCT correlation; summarize with `scripts/manual/summarize_uvc_frame_meta_log.py` |
|
||||||
| `LESAVKA_UVC_FRAME_META_PATH` | UVC helper diagnostic override; explicit path for the optional MJPEG spool metadata sidecar |
|
| `LESAVKA_UVC_FRAME_META_PATH` | UVC helper diagnostic override; explicit path for the optional MJPEG spool metadata sidecar |
|
||||||
| `LESAVKA_UVC_FRAME_MAX_AGE_MS` | UVC helper freshness override; stale spooled MJPEG frames older than this are not replayed, defaults to `1000`; `0` disables TTL |
|
| `LESAVKA_UVC_FRAME_MAX_AGE_MS` | UVC helper freshness override; stale spooled MJPEG frames older than this are not replayed, defaults to `1000`; `0` disables TTL |
|
||||||
| `LESAVKA_UVC_FRAME_MAX_BYTES` | UVC helper MJPEG frame-size guard; explicit maximum accepted frame bytes. Unset or `0` uses the live-call byte budget so oversized frames freeze instead of tearing on the host |
|
| `LESAVKA_UVC_FRAME_MAX_BYTES` | Requested UVC MJPEG frame cap. The effective value is always clamped to the live descriptor, browser commit, and transport budget; unset or `0` uses that physical contract directly |
|
||||||
| `LESAVKA_UVC_FRAME_SIZE` | UVC advertised maximum MJPEG frame bytes; defaults to the live-call byte budget instead of raw uncompressed frame size so the gadget/host do not schedule oversized phantom frames |
|
| `LESAVKA_UVC_FRAME_SIZE` | UVC advertised maximum MJPEG frame bytes; defaults to the live-call byte budget instead of raw uncompressed frame size so the gadget/host do not schedule oversized phantom frames |
|
||||||
| `LESAVKA_UVC_FRAME_SIZE_GUARD` | UVC helper MJPEG frame-size guard toggle; defaults to `1`; set `0` only for diagnostics when oversized MJPEG frames must be allowed through |
|
| `LESAVKA_UVC_FRAME_SIZE_GUARD` | Legacy diagnostic compatibility knob. It no longer permits frames to exceed the live physical UVC payload contract |
|
||||||
|
| `LESAVKA_UVC_HANDOFF_AUDIT` | Enables the bounded helper-to-gadget JSONL handoff ring when set to `1`; defaults off |
|
||||||
|
| `LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS` | Maximum retained helper handoff records; defaults to `1024` and is capped at `16384` |
|
||||||
|
| `LESAVKA_UVC_HANDOFF_AUDIT_PATH` | Helper handoff audit path; defaults to `/run/lesavka-uvc-handoff.jsonl` |
|
||||||
| `LESAVKA_UVC_HEIGHT` | server hardware/device override |
|
| `LESAVKA_UVC_HEIGHT` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `20` and is capped at `50` |
|
| `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `20` and is capped at `50` |
|
||||||
| `LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS` | server HEVC decode-to-MJPEG branch queue depth; defaults to `2` and is capped at `4` so decode/JPEG scheduling jitter does not starve the UVC helper while stale frames still get dropped |
|
| `LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS` | server HEVC decode-to-MJPEG branch queue depth; defaults to `2` and is capped at `4` so decode/JPEG scheduling jitter does not starve the UVC helper while stale frames still get dropped |
|
||||||
| `LESAVKA_UVC_IDLE_PUMP_MS` | UVC helper freshness override; idle poll sleep while pumping host-returned buffers, defaults to `2` |
|
| `LESAVKA_UVC_IDLE_PUMP_MS` | UVC helper freshness override; idle poll sleep while pumping host-returned buffers, defaults to `2` |
|
||||||
|
| `LESAVKA_UVC_IDLE_AFTER_MS` | Sustained failure/staleness window before the helper replaces its last verified good frame with idle black; defaults to `2000` |
|
||||||
| `LESAVKA_UVC_INTERVAL` | server hardware/device override |
|
| `LESAVKA_UVC_INTERVAL` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT` | UVC MJPEG isochronous safety cap; defaults to `85`, limiting advertised/runtime frame bytes to a safe fraction of high-speed isochronous payload capacity when bulk UVC is unavailable |
|
| `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT` | UVC MJPEG isochronous safety cap; defaults to `85`, limiting advertised/runtime frame bytes to a safe fraction of high-speed isochronous payload capacity when bulk UVC is unavailable |
|
||||||
| `LESAVKA_UVC_LIMIT_PCT` | server hardware/device override |
|
| `LESAVKA_UVC_LIMIT_PCT` | server hardware/device override |
|
||||||
@ -378,11 +383,13 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
|
|||||||
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SAMPLE_WIDTH` | server MJPEG pixel-artifact guard sampling width; defaults to `160` pixels and is clamped to `64..320` |
|
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SAMPLE_WIDTH` | server MJPEG pixel-artifact guard sampling width; defaults to `160` pixels and is clamped to `64..320` |
|
||||||
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_COVERAGE_PCT` | server MJPEG pixel-artifact guard threshold; minimum sampled-row coverage for hard horizontal tile seams, default `42` |
|
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_COVERAGE_PCT` | server MJPEG pixel-artifact guard threshold; minimum sampled-row coverage for hard horizontal tile seams, default `42` |
|
||||||
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_DELTA` | server MJPEG pixel-artifact guard threshold; sampled luma jump that marks a row as suspicious, default `34` |
|
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_DELTA` | server MJPEG pixel-artifact guard threshold; sampled luma jump that marks a row as suspicious, default `34` |
|
||||||
|
| `LESAVKA_UVC_KERNEL_STATS_PATH` | Kernel UVC/UDC watcher JSON path embedded in helper stats; defaults to `/run/lesavka-uvc-kernel-stats.json` |
|
||||||
| `LESAVKA_UVC_QUEUE_PACING` | UVC helper queue pacing override; defaults to `0` because the RCT host already paces UVC consumption, and delaying returned buffer requeueing can starve isochronous gadget transfers |
|
| `LESAVKA_UVC_QUEUE_PACING` | UVC helper queue pacing override; defaults to `0` because the RCT host already paces UVC consumption, and delaying returned buffer requeueing can starve isochronous gadget transfers |
|
||||||
| `LESAVKA_UVC_RESTART_DELAY_MS` | UVC control helper supervisor restart delay after helper exit or failure; defaults to `1000` |
|
| `LESAVKA_UVC_RESTART_DELAY_MS` | UVC control helper supervisor restart delay after helper exit or failure; defaults to `1000` |
|
||||||
| `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override |
|
| `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_STATS_INTERVAL_MS` | UVC helper telemetry interval for queued/reloaded/rejected MJPEG frame counters; defaults to `5000`, `0` disables |
|
| `LESAVKA_UVC_STATS_INTERVAL_MS` | UVC helper telemetry interval for queued/reloaded/rejected MJPEG frame counters; defaults to `5000`, `0` disables |
|
||||||
| `LESAVKA_UVC_STATS_PATH` | UVC helper JSON stats snapshot path for queued/reloaded/rejected MJPEG frame counters; defaults to `/run/lesavka-uvc-video-stats.json`, set `0` or empty to disable file snapshots |
|
| `LESAVKA_UVC_STATS_PATH` | UVC helper JSON stats snapshot path for queued/reloaded/rejected MJPEG frame counters; defaults to `/run/lesavka-uvc-video-stats.json`, set `0` or empty to disable file snapshots |
|
||||||
|
| `LESAVKA_UVC_STRICT_VALIDATE_EVERY` | Sample interval for complete JPEG structure and Lesavka APP4 CRC validation before QBUF; defaults to every `30` accepted frames, `0` disables sampling |
|
||||||
| `LESAVKA_UVC_STREAMING_INTERVAL` | server hardware/device override |
|
| `LESAVKA_UVC_STREAMING_INTERVAL` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_STREAM_INTF` | server hardware/device override |
|
| `LESAVKA_UVC_STREAM_INTF` | server hardware/device override |
|
||||||
| `LESAVKA_UVC_WIDTH` | server hardware/device override |
|
| `LESAVKA_UVC_WIDTH` | server hardware/device override |
|
||||||
|
|||||||
@ -319,7 +319,7 @@ MAX_SPEED=${LESAVKA_MAX_SPEED:-high-speed}
|
|||||||
if [[ -z $UVC_INTERVAL ]]; then
|
if [[ -z $UVC_INTERVAL ]]; then
|
||||||
UVC_INTERVAL=$((10000000 / UVC_FPS))
|
UVC_INTERVAL=$((10000000 / UVC_FPS))
|
||||||
fi
|
fi
|
||||||
UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))}
|
UVC_FRAME_SIZE=
|
||||||
UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333}
|
UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333}
|
||||||
UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000}
|
UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000}
|
||||||
UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}
|
UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}
|
||||||
@ -350,8 +350,8 @@ uvc_mjpeg_frame_size_for_fps() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
local per_frame=$((budget / fps))
|
local per_frame=$((budget / fps))
|
||||||
if ((per_frame < 65536)); then
|
if ((per_frame < 1)); then
|
||||||
per_frame=65536
|
per_frame=1
|
||||||
elif ((per_frame > 8388608)); then
|
elif ((per_frame > 8388608)); then
|
||||||
per_frame=8388608
|
per_frame=8388608
|
||||||
fi
|
fi
|
||||||
@ -367,8 +367,15 @@ uvc_fps_for_interval() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ -z ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
|
UVC_DERIVED_FRAME_SIZE="$(uvc_mjpeg_frame_size_for_fps "$UVC_FPS")"
|
||||||
UVC_FRAME_SIZE="$(uvc_mjpeg_frame_size_for_fps "$UVC_FPS")"
|
if [[ -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
|
||||||
|
UVC_FRAME_SIZE=$LESAVKA_UVC_FRAME_SIZE
|
||||||
|
if ((UVC_FRAME_SIZE > UVC_DERIVED_FRAME_SIZE)); then
|
||||||
|
log "clamping requested UVC frame size $UVC_FRAME_SIZE -> $UVC_DERIVED_FRAME_SIZE (wire budget)"
|
||||||
|
UVC_FRAME_SIZE=$UVC_DERIVED_FRAME_SIZE
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
UVC_FRAME_SIZE=$UVC_DERIVED_FRAME_SIZE
|
||||||
fi
|
fi
|
||||||
|
|
||||||
uvc_selected_frame_index() {
|
uvc_selected_frame_index() {
|
||||||
@ -400,10 +407,16 @@ write_active_mjpeg_frame_descriptor() {
|
|||||||
|
|
||||||
uvc_frame_size_for() {
|
uvc_frame_size_for() {
|
||||||
local width=$1 height=$2
|
local width=$1 height=$2
|
||||||
|
local derived
|
||||||
|
derived="$(uvc_mjpeg_frame_size_for_fps "$(uvc_fps_for_interval "$(uvc_default_interval_for "$width" "$height")")")"
|
||||||
if [[ $width == "$UVC_WIDTH" && $height == "$UVC_HEIGHT" && -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
|
if [[ $width == "$UVC_WIDTH" && $height == "$UVC_HEIGHT" && -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
|
||||||
echo "$LESAVKA_UVC_FRAME_SIZE"
|
if ((LESAVKA_UVC_FRAME_SIZE < derived)); then
|
||||||
|
echo "$LESAVKA_UVC_FRAME_SIZE"
|
||||||
|
else
|
||||||
|
echo "$derived"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
uvc_mjpeg_frame_size_for_fps "$(uvc_fps_for_interval "$(uvc_default_interval_for "$width" "$height")")"
|
echo "$derived"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -640,7 +653,13 @@ if [[ -z $DISABLE_UVC ]]; then
|
|||||||
F="$G/functions/uvc.usb0"
|
F="$G/functions/uvc.usb0"
|
||||||
if [[ -n $UVC_BULK_REQUESTED ]]; then
|
if [[ -n $UVC_BULK_REQUESTED ]]; then
|
||||||
if [[ -e "$F/streaming_bulk" ]]; then
|
if [[ -e "$F/streaming_bulk" ]]; then
|
||||||
UVC_BULK=1
|
if echo 1 >"$F/streaming_bulk" 2>/dev/null && [[ $(cat "$F/streaming_bulk" 2>/dev/null) == 1 ]]; then
|
||||||
|
UVC_BULK=1
|
||||||
|
else
|
||||||
|
log "UVC bulk request did not stick; using isochronous descriptors"
|
||||||
|
UVC_BULK=
|
||||||
|
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
# Some kernels do not expose the patched bulk-transfer knob. Falling
|
# Some kernels do not expose the patched bulk-transfer knob. Falling
|
||||||
# back to isochronous must also avoid the 512-byte bulk packet clamp;
|
# back to isochronous must also avoid the 512-byte bulk packet clamp;
|
||||||
@ -649,13 +668,22 @@ if [[ -z $DISABLE_UVC ]]; then
|
|||||||
UVC_BULK=
|
UVC_BULK=
|
||||||
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
||||||
fi
|
fi
|
||||||
|
elif [[ -e "$F/streaming_bulk" ]]; then
|
||||||
|
if ! echo 0 >"$F/streaming_bulk" 2>/dev/null || [[ $(cat "$F/streaming_bulk" 2>/dev/null) != 0 ]]; then
|
||||||
|
log "UVC streaming_bulk could not be disabled; refusing to infer transport from attribute existence"
|
||||||
|
UVC_BULK=
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
apply_uvc_payload_limits
|
apply_uvc_payload_limits
|
||||||
echo "$UVC_STREAMING_INTERVAL" >"$F/streaming_interval"
|
echo "$UVC_STREAMING_INTERVAL" >"$F/streaming_interval"
|
||||||
echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket"
|
echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket"
|
||||||
echo "$UVC_MAXBURST" >"$F/streaming_maxburst"
|
echo "$UVC_MAXBURST" >"$F/streaming_maxburst"
|
||||||
if [[ -n $UVC_BULK ]]; then
|
if [[ -n $UVC_BULK && $(cat "$F/streaming_bulk" 2>/dev/null) != 1 ]]; then
|
||||||
echo 1 >"$F/streaming_bulk" 2>/dev/null || true
|
log "UVC bulk readback diverged after descriptor setup; falling back to isochronous sizing"
|
||||||
|
UVC_BULK=
|
||||||
|
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
||||||
|
apply_uvc_payload_limits
|
||||||
|
echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 1. FORMAT DESCRIPTOR ──────────────────────────────────────────
|
# ── 1. FORMAT DESCRIPTOR ──────────────────────────────────────────
|
||||||
|
|||||||
162
scripts/daemon/lesavka-uvc-kernel-watch.py
Executable file
162
scripts/daemon/lesavka-uvc-kernel-watch.py
Executable file
@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish bounded kernel-side UVC/UDC error counters for Lesavka diagnostics."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from collections import Counter, deque
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
PATTERNS = {
|
||||||
|
"dwc2_errors": re.compile(r"\bdwc2\b.*\b(error|failed|timeout|incomplete|shutdown|disconnect)", re.I),
|
||||||
|
"uvc_errors": re.compile(
|
||||||
|
r"\buvc(video)?\b.*\b(error|failed|timeout|corrupt|invalid|overflow|incomplete|non-zero status)",
|
||||||
|
re.I,
|
||||||
|
),
|
||||||
|
"udc_errors": re.compile(r"\b(udc|gadget)\b.*\b(error|failed|timeout|shutdown|disconnect|overflow)", re.I),
|
||||||
|
"urb_errors": re.compile(r"\burb\b.*\b(error|failed|timeout|resubmit|non-zero|overflow)", re.I),
|
||||||
|
"usb_resets": re.compile(r"\busb\b.*\b(reset|disconnect|re-enumerat)", re.I),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--stats-path", default=os.environ.get("LESAVKA_UVC_KERNEL_STATS_PATH", "/run/lesavka-uvc-kernel-stats.json"))
|
||||||
|
parser.add_argument("--recent-lines", type=int, default=500)
|
||||||
|
parser.add_argument("--flush-seconds", type=float, default=5.0)
|
||||||
|
parser.add_argument("--self-test", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomic(path: pathlib.Path, payload: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(f".tmp.{os.getpid()}")
|
||||||
|
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def journal_process(lines: int, follow: bool) -> subprocess.Popen[str]:
|
||||||
|
command = ["journalctl", "-k", "--no-pager", "-o", "json", "-n", str(max(0, lines))]
|
||||||
|
if follow:
|
||||||
|
command.insert(2, "-f")
|
||||||
|
return subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_message(message: str) -> list[str]:
|
||||||
|
return [name for name, pattern in PATTERNS.items() if pattern.search(message)]
|
||||||
|
|
||||||
|
|
||||||
|
def self_test() -> int:
|
||||||
|
cases = {
|
||||||
|
"dwc2 error: incomplete request": {"dwc2_errors"},
|
||||||
|
"uvcvideo error: Non-zero status (-75) in video completion handler": {"uvc_errors"},
|
||||||
|
"usb 1-1: reset high-speed USB device": {"usb_resets"},
|
||||||
|
"ordinary unrelated kernel line": set(),
|
||||||
|
}
|
||||||
|
for message, expected in cases.items():
|
||||||
|
actual = set(classify_message(message))
|
||||||
|
if actual != expected:
|
||||||
|
raise AssertionError(f"{message!r}: expected {expected}, got {actual}")
|
||||||
|
print("self-test: pass")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
if args.self_test:
|
||||||
|
return self_test()
|
||||||
|
path = pathlib.Path(args.stats_path)
|
||||||
|
counters: Counter[str] = Counter()
|
||||||
|
recent: deque[dict[str, Any]] = deque(maxlen=40)
|
||||||
|
baseline_counters: dict[str, int] = {}
|
||||||
|
baseline_recent: list[dict[str, Any]] = []
|
||||||
|
started = int(time.time() * 1000)
|
||||||
|
running = True
|
||||||
|
|
||||||
|
def stop(_signum: int, _frame: Any) -> None:
|
||||||
|
nonlocal running
|
||||||
|
running = False
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, stop)
|
||||||
|
signal.signal(signal.SIGINT, stop)
|
||||||
|
|
||||||
|
def consume(raw: str) -> None:
|
||||||
|
try:
|
||||||
|
entry = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
message = str(entry.get("MESSAGE") or "")
|
||||||
|
matched = classify_message(message)
|
||||||
|
if not matched:
|
||||||
|
return
|
||||||
|
counters["matched_lines"] += 1
|
||||||
|
counters.update(matched)
|
||||||
|
recent.append(
|
||||||
|
{
|
||||||
|
"unix_us": int(entry.get("__REALTIME_TIMESTAMP") or 0),
|
||||||
|
"categories": matched,
|
||||||
|
"message": message[:800],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshot() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema": "lesavka.uvc-kernel-watch.v1",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"started_unix_ms": started,
|
||||||
|
"updated_unix_ms": int(time.time() * 1000),
|
||||||
|
"counters": {
|
||||||
|
name: counters[name]
|
||||||
|
for name in ["matched_lines", "watcher_restarts", *PATTERNS]
|
||||||
|
},
|
||||||
|
"recent": list(recent),
|
||||||
|
"startup_baseline_counters": baseline_counters,
|
||||||
|
"startup_baseline_recent": baseline_recent,
|
||||||
|
}
|
||||||
|
|
||||||
|
initial = journal_process(args.recent_lines, False)
|
||||||
|
stdout, _stderr = initial.communicate(timeout=15)
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
consume(line)
|
||||||
|
baseline_counters = {
|
||||||
|
name: counters[name] for name in ["matched_lines", *PATTERNS]
|
||||||
|
}
|
||||||
|
baseline_recent = list(recent)
|
||||||
|
counters.clear()
|
||||||
|
recent.clear()
|
||||||
|
write_atomic(path, snapshot())
|
||||||
|
|
||||||
|
follow = journal_process(0, True)
|
||||||
|
assert follow.stdout is not None
|
||||||
|
try:
|
||||||
|
while running:
|
||||||
|
line = follow.stdout.readline()
|
||||||
|
if line:
|
||||||
|
consume(line)
|
||||||
|
write_atomic(path, snapshot())
|
||||||
|
elif follow.poll() is not None:
|
||||||
|
counters["watcher_restarts"] += 1
|
||||||
|
time.sleep(max(0.1, args.flush_seconds))
|
||||||
|
follow = journal_process(0, True)
|
||||||
|
assert follow.stdout is not None
|
||||||
|
else:
|
||||||
|
time.sleep(0.1)
|
||||||
|
finally:
|
||||||
|
follow.terminate()
|
||||||
|
try:
|
||||||
|
follow.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
follow.kill()
|
||||||
|
write_atomic(path, snapshot())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -428,6 +428,12 @@ LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0)
|
|||||||
LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}
|
LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}
|
||||||
LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85)
|
LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85)
|
||||||
LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json)
|
LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json)
|
||||||
|
LESAVKA_UVC_CONTRACT_PATH=$(uvc_env_value LESAVKA_UVC_CONTRACT_PATH /run/lesavka-uvc-contract.json)
|
||||||
|
LESAVKA_UVC_IDLE_AFTER_MS=$(uvc_env_value LESAVKA_UVC_IDLE_AFTER_MS 2000)
|
||||||
|
LESAVKA_UVC_STRICT_VALIDATE_EVERY=$(uvc_env_value LESAVKA_UVC_STRICT_VALIDATE_EVERY 30)
|
||||||
|
LESAVKA_UVC_HANDOFF_AUDIT=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT 0)
|
||||||
|
LESAVKA_UVC_HANDOFF_AUDIT_PATH=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT_PATH /run/lesavka-uvc-handoff.jsonl)
|
||||||
|
LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS 1024)
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1040,6 +1046,26 @@ WantedBy=multi-user.target
|
|||||||
UNIT
|
UNIT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_uvc_kernel_watch_unit() {
|
||||||
|
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-uvc-kernel-watch.service >/dev/null
|
||||||
|
[Unit]
|
||||||
|
Description=lesavka UVC/UDC kernel error watcher
|
||||||
|
After=systemd-journald.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/local/lib/lesavka/lesavka-uvc-kernel-watch.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
User=root
|
||||||
|
Environment=LESAVKA_UVC_KERNEL_STATS_PATH=/run/lesavka-uvc-kernel-stats.json
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now lesavka-uvc-kernel-watch
|
||||||
|
}
|
||||||
|
|
||||||
restart_lesavka_server_only() {
|
restart_lesavka_server_only() {
|
||||||
sudo truncate -s 0 /var/log/lesavka/server.stderr
|
sudo truncate -s 0 /var/log/lesavka/server.stderr
|
||||||
sudo systemctl stop lesavka-server >/dev/null 2>&1 || true
|
sudo systemctl stop lesavka-server >/dev/null 2>&1 || true
|
||||||
@ -1599,6 +1625,8 @@ install_verified_executable "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin
|
|||||||
install_verified_executable "$SRC_DIR/target/release/lesavka-synthetic-uplink" /usr/local/bin/lesavka-synthetic-uplink "lesavka-synthetic-uplink"
|
install_verified_executable "$SRC_DIR/target/release/lesavka-synthetic-uplink" /usr/local/bin/lesavka-synthetic-uplink "lesavka-synthetic-uplink"
|
||||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh "lesavka-core.sh"
|
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh "lesavka-core.sh"
|
||||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh "lesavka-uvc.sh"
|
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh "lesavka-uvc.sh"
|
||||||
|
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc-kernel-watch.py" /usr/local/lib/lesavka/lesavka-uvc-kernel-watch.py "lesavka-uvc-kernel-watch.py"
|
||||||
|
install_uvc_kernel_watch_unit
|
||||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh" /usr/local/bin/lesavka-recovery-ladder "lesavka-recovery-ladder"
|
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh" /usr/local/bin/lesavka-recovery-ladder "lesavka-recovery-ladder"
|
||||||
install_verified_executable "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity "lesavka-uac-sanity"
|
install_verified_executable "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity "lesavka-uac-sanity"
|
||||||
install_recovery_ladder_units
|
install_recovery_ladder_units
|
||||||
@ -1820,12 +1848,18 @@ if [[ "$ATTACHED_UVC_RESTART_DEFERRED" == "1" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ No active gRPC clients detected; updated UVC env matches the live descriptor, restarting lesavka-server only." >&2
|
echo "✅ No active gRPC clients detected; updated UVC env matches the live descriptor, restarting lesavka-server only." >&2
|
||||||
echo " Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled." >&2
|
echo " Preserving lesavka-core so the attached USB gadget is not cycled." >&2
|
||||||
sudo install -d -m 0755 /var/log/lesavka
|
sudo install -d -m 0755 /var/log/lesavka
|
||||||
sudo truncate -s 0 /var/log/lesavka/server.log
|
sudo truncate -s 0 /var/log/lesavka/server.log
|
||||||
install_lesavka_server_unit_file
|
install_lesavka_server_unit_file
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable lesavka-server >/dev/null 2>&1 || true
|
sudo systemctl enable lesavka-server >/dev/null 2>&1 || true
|
||||||
|
if [[ "${LESAVKA_INSTALL_RESTART_UVC_HELPER:-0}" == "1" ]]; then
|
||||||
|
restart_lesavka_uvc_helper_only
|
||||||
|
echo "✅ lesavka-uvc helper restarted in-place by explicit request; lesavka-core and the attached USB gadget were preserved."
|
||||||
|
else
|
||||||
|
echo " Preserving lesavka-uvc; set LESAVKA_INSTALL_RESTART_UVC_HELPER=1 when a helper-only refresh is required." >&2
|
||||||
|
fi
|
||||||
restart_lesavka_server_only
|
restart_lesavka_server_only
|
||||||
sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true
|
sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true
|
||||||
INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true)
|
INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true)
|
||||||
|
|||||||
8
scripts/manual/analyze_marked_capture.py
Executable file
8
scripts/manual/analyze_marked_capture.py
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compatibility entry point for the marked UVC capture integrity analyzer."""
|
||||||
|
|
||||||
|
from analyze_uvc_mjpeg_integrity import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -90,13 +90,11 @@ def inspect_jpeg(data: bytes) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def derived_cap(fps: int, budget: int, explicit: Optional[int], guard: bool) -> int:
|
def derived_cap(fps: int, budget: int, explicit: Optional[int], _guard: bool) -> int:
|
||||||
if not guard:
|
physical_cap = min(max(1, budget // max(fps, 1)), MAX_MJPEG_FRAME_BYTES)
|
||||||
return MAX_MJPEG_FRAME_BYTES
|
|
||||||
if explicit and explicit > 0:
|
if explicit and explicit > 0:
|
||||||
return min(explicit, MAX_MJPEG_FRAME_BYTES)
|
return min(explicit, physical_cap)
|
||||||
per_frame = max(budget // max(fps, 1), 64 * 1024)
|
return physical_cap
|
||||||
return min(per_frame, MAX_MJPEG_FRAME_BYTES)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
402
scripts/manual/analyze_uvc_mjpeg_integrity.py
Executable file
402
scripts/manual/analyze_uvc_mjpeg_integrity.py
Executable file
@ -0,0 +1,402 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Classify raw UVC MJPEG frames without relying on an RCT browser."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
import zlib
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
APP4_MAGIC = b"LSVK"
|
||||||
|
BAND_ROWS = 64
|
||||||
|
BAND_BITS = 32
|
||||||
|
BAND_CELL = 4
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("inputs", nargs="*", help="MJPEG files or directories containing .jpg/.mjpg frames")
|
||||||
|
parser.add_argument("--output-dir", default="", help="write report.json, report.txt, and frames.jsonl")
|
||||||
|
parser.add_argument("--idle-mean-max", type=float, default=8.0)
|
||||||
|
parser.add_argument("--idle-stddev-max", type=float, default=4.0)
|
||||||
|
parser.add_argument("--self-test", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def input_files(values: list[str]) -> list[pathlib.Path]:
|
||||||
|
files: list[pathlib.Path] = []
|
||||||
|
for value in values:
|
||||||
|
path = pathlib.Path(value)
|
||||||
|
if path.is_dir():
|
||||||
|
files.extend(
|
||||||
|
candidate
|
||||||
|
for candidate in sorted(path.rglob("*"))
|
||||||
|
if candidate.suffix.lower() in {".jpg", ".jpeg", ".mjpg", ".mjpeg"}
|
||||||
|
)
|
||||||
|
elif path.is_file():
|
||||||
|
files.append(path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def split_mjpeg(data: bytes) -> list[bytes]:
|
||||||
|
starts: list[int] = []
|
||||||
|
cursor = 0
|
||||||
|
while True:
|
||||||
|
start = data.find(b"\xff\xd8", cursor)
|
||||||
|
if start < 0:
|
||||||
|
break
|
||||||
|
starts.append(start)
|
||||||
|
cursor = start + 2
|
||||||
|
if not starts:
|
||||||
|
return [data]
|
||||||
|
frames: list[bytes] = []
|
||||||
|
for index, start in enumerate(starts):
|
||||||
|
next_start = starts[index + 1] if index + 1 < len(starts) else len(data)
|
||||||
|
end = data.find(b"\xff\xd9", start + 2, next_start)
|
||||||
|
frames.append(data[start : end + 2] if end >= 0 else data[start:next_start])
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_jpeg(frame: bytes) -> dict[str, Any]:
|
||||||
|
if len(frame) < 4 or not frame.startswith(b"\xff\xd8"):
|
||||||
|
return {"valid": False, "reason": "missing_soi", "app4": None}
|
||||||
|
pos = 2
|
||||||
|
saw_scan = False
|
||||||
|
app4: dict[str, Any] | None = None
|
||||||
|
app4_range: tuple[int, int] | None = None
|
||||||
|
while pos < len(frame):
|
||||||
|
if frame[pos] != 0xFF:
|
||||||
|
return {"valid": False, "reason": "marker_alignment", "app4": app4}
|
||||||
|
marker_start = pos
|
||||||
|
while pos < len(frame) and frame[pos] == 0xFF:
|
||||||
|
pos += 1
|
||||||
|
if pos >= len(frame):
|
||||||
|
return {"valid": False, "reason": "truncated_marker", "app4": app4}
|
||||||
|
marker = frame[pos]
|
||||||
|
pos += 1
|
||||||
|
if marker == 0xD9:
|
||||||
|
valid = saw_scan and pos == len(frame)
|
||||||
|
result = {"valid": valid, "reason": "ok" if valid else "trailing_or_no_scan", "app4": app4}
|
||||||
|
if app4 is not None and app4_range is not None:
|
||||||
|
start, end = app4_range
|
||||||
|
original = frame[:start] + frame[end:]
|
||||||
|
app4["actual_crc32"] = zlib.crc32(original) & 0xFFFFFFFF
|
||||||
|
app4["crc_ok"] = app4["actual_crc32"] == app4["expected_crc32"]
|
||||||
|
return result
|
||||||
|
if marker in {0xD8, 0x00}:
|
||||||
|
return {"valid": False, "reason": "nested_soi_or_stuffing", "app4": app4}
|
||||||
|
if marker == 0x01 or 0xD0 <= marker <= 0xD7:
|
||||||
|
continue
|
||||||
|
if pos + 2 > len(frame):
|
||||||
|
return {"valid": False, "reason": "truncated_length", "app4": app4}
|
||||||
|
segment_len = int.from_bytes(frame[pos : pos + 2], "big")
|
||||||
|
segment_end = pos + segment_len
|
||||||
|
if segment_len < 2 or segment_end > len(frame):
|
||||||
|
return {"valid": False, "reason": "truncated_segment", "app4": app4}
|
||||||
|
payload = frame[pos + 2 : segment_end]
|
||||||
|
if marker == 0xE4 and payload.startswith(APP4_MAGIC) and len(payload) >= 17:
|
||||||
|
app4 = {
|
||||||
|
"version": payload[4],
|
||||||
|
"sequence": int.from_bytes(payload[5:13], "big"),
|
||||||
|
"expected_crc32": int.from_bytes(payload[13:17], "big"),
|
||||||
|
}
|
||||||
|
app4_range = (marker_start, segment_end)
|
||||||
|
pos = segment_end
|
||||||
|
if marker != 0xDA:
|
||||||
|
continue
|
||||||
|
saw_scan = True
|
||||||
|
while pos < len(frame):
|
||||||
|
if frame[pos] != 0xFF:
|
||||||
|
pos += 1
|
||||||
|
continue
|
||||||
|
entropy_marker = pos
|
||||||
|
while pos < len(frame) and frame[pos] == 0xFF:
|
||||||
|
pos += 1
|
||||||
|
if pos >= len(frame):
|
||||||
|
return {"valid": False, "reason": "truncated_entropy", "app4": app4}
|
||||||
|
if frame[pos] == 0x00 or 0xD0 <= frame[pos] <= 0xD7:
|
||||||
|
pos += 1
|
||||||
|
continue
|
||||||
|
pos = entropy_marker
|
||||||
|
break
|
||||||
|
return {"valid": False, "reason": "missing_eoi", "app4": app4}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_image(frame: bytes, allow_truncated: bool = False) -> Any | None:
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageFile
|
||||||
|
|
||||||
|
previous = ImageFile.LOAD_TRUNCATED_IMAGES
|
||||||
|
ImageFile.LOAD_TRUNCATED_IMAGES = allow_truncated
|
||||||
|
try:
|
||||||
|
image = Image.open(io.BytesIO(frame)).convert("L")
|
||||||
|
image.load()
|
||||||
|
return image
|
||||||
|
finally:
|
||||||
|
ImageFile.LOAD_TRUNCATED_IMAGES = previous
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def image_integrity_metrics(frame: bytes, allow_truncated: bool = False) -> dict[str, Any]:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
image = decode_image(frame, allow_truncated=allow_truncated)
|
||||||
|
decode_warnings = [str(item.message) for item in caught]
|
||||||
|
if image is None:
|
||||||
|
return {
|
||||||
|
"width": None,
|
||||||
|
"height": None,
|
||||||
|
"luma_mean": None,
|
||||||
|
"luma_stddev": None,
|
||||||
|
"bands": [],
|
||||||
|
"first_bad_row": None,
|
||||||
|
"decode_warnings": decode_warnings,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
width, height = image.size
|
||||||
|
histogram = image.resize((64, 36)).histogram()
|
||||||
|
count = sum(histogram)
|
||||||
|
mean = sum(value * occurrences for value, occurrences in enumerate(histogram)) / max(1, count)
|
||||||
|
variance = (
|
||||||
|
sum(((value - mean) ** 2) * occurrences for value, occurrences in enumerate(histogram))
|
||||||
|
/ max(1, count)
|
||||||
|
)
|
||||||
|
marker_width = BAND_BITS * BAND_CELL
|
||||||
|
bands: list[dict[str, Any]] = []
|
||||||
|
first_bad_row: int | None = None
|
||||||
|
if width >= marker_width + 8 and height >= BAND_ROWS:
|
||||||
|
x0 = width - marker_width - 4
|
||||||
|
pixels = image.load()
|
||||||
|
expected_bands = (height + BAND_ROWS - 1) // BAND_ROWS
|
||||||
|
for band in range(expected_bands):
|
||||||
|
y0 = band * BAND_ROWS + 4
|
||||||
|
if y0 + BAND_CELL * 2 > height:
|
||||||
|
if first_bad_row is None:
|
||||||
|
first_bad_row = band * BAND_ROWS
|
||||||
|
break
|
||||||
|
marker = 0
|
||||||
|
confidence_sum = 0.0
|
||||||
|
for bit in range(BAND_BITS):
|
||||||
|
values = [
|
||||||
|
pixels[x, y]
|
||||||
|
for y in range(y0, y0 + BAND_CELL * 2)
|
||||||
|
for x in range(x0 + bit * BAND_CELL, x0 + (bit + 1) * BAND_CELL)
|
||||||
|
]
|
||||||
|
average = sum(values) / len(values)
|
||||||
|
if average >= 128.0:
|
||||||
|
marker |= 1 << bit
|
||||||
|
confidence_sum += abs(average - 128.0) / 127.0
|
||||||
|
decoded_band = (marker >> 24) & 0xFF
|
||||||
|
valid_band = decoded_band == (band & 0xFF)
|
||||||
|
if not valid_band and first_bad_row is None:
|
||||||
|
first_bad_row = band * BAND_ROWS
|
||||||
|
bands.append(
|
||||||
|
{
|
||||||
|
"band": band,
|
||||||
|
"row": band * BAND_ROWS,
|
||||||
|
"marker": marker,
|
||||||
|
"sequence": marker & 0x00FFFFFF,
|
||||||
|
"decoded_band": decoded_band,
|
||||||
|
"valid_band": valid_band,
|
||||||
|
"confidence": round(confidence_sum / BAND_BITS, 4),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"luma_mean": mean,
|
||||||
|
"luma_stddev": variance**0.5,
|
||||||
|
"bands": bands,
|
||||||
|
"first_bad_row": first_bad_row,
|
||||||
|
"decode_warnings": decode_warnings,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"width": None,
|
||||||
|
"height": None,
|
||||||
|
"luma_mean": None,
|
||||||
|
"luma_stddev": None,
|
||||||
|
"bands": [],
|
||||||
|
"first_bad_row": None,
|
||||||
|
"decode_warnings": decode_warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_frame(frame: bytes, previous_sequence: int | None, args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
inspection = inspect_jpeg(frame)
|
||||||
|
app4 = inspection.get("app4")
|
||||||
|
sequence = app4.get("sequence") if isinstance(app4, dict) else None
|
||||||
|
metrics = image_integrity_metrics(frame, allow_truncated=not inspection["valid"])
|
||||||
|
mean = metrics["luma_mean"]
|
||||||
|
stddev = metrics["luma_stddev"]
|
||||||
|
bands = metrics["bands"]
|
||||||
|
valid_band_sequences = [band["sequence"] for band in bands if band["valid_band"]]
|
||||||
|
distinct_band_sequences = sorted(set(valid_band_sequences))
|
||||||
|
expected_sequence = sequence & 0x00FFFFFF if sequence is not None else None
|
||||||
|
mismatched_band = next(
|
||||||
|
(
|
||||||
|
band
|
||||||
|
for band in bands
|
||||||
|
if not band["valid_band"]
|
||||||
|
or (expected_sequence is not None and band["sequence"] != expected_sequence)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
band_splice = isinstance(app4, dict) and (
|
||||||
|
len(distinct_band_sequences) > 1 or mismatched_band is not None
|
||||||
|
)
|
||||||
|
idle_black = mean is not None and stddev is not None and mean <= args.idle_mean_max and stddev <= args.idle_stddev_max
|
||||||
|
if not inspection["valid"]:
|
||||||
|
classification = "truncated"
|
||||||
|
elif (isinstance(app4, dict) and not app4.get("crc_ok", False)) or band_splice:
|
||||||
|
classification = "spliced"
|
||||||
|
elif sequence is not None and previous_sequence is not None and sequence <= previous_sequence:
|
||||||
|
classification = "stale_repeat"
|
||||||
|
elif idle_black:
|
||||||
|
classification = "idle_black"
|
||||||
|
else:
|
||||||
|
classification = "intact"
|
||||||
|
first_bad_row = metrics["first_bad_row"] if classification == "truncated" else None
|
||||||
|
seam_row = mismatched_band["row"] if classification == "spliced" and mismatched_band else None
|
||||||
|
if classification == "truncated":
|
||||||
|
verdict = f"truncated(first_bad_row={first_bad_row})"
|
||||||
|
elif classification == "spliced":
|
||||||
|
verdict = f"spliced(seqs={distinct_band_sequences}, seam_row={seam_row})"
|
||||||
|
else:
|
||||||
|
verdict = classification
|
||||||
|
return {
|
||||||
|
"classification": classification,
|
||||||
|
"verdict": verdict,
|
||||||
|
"bytes": len(frame),
|
||||||
|
"sha256": hashlib.sha256(frame).hexdigest(),
|
||||||
|
"jpeg_valid": inspection["valid"],
|
||||||
|
"jpeg_reason": inspection["reason"],
|
||||||
|
"sequence": sequence,
|
||||||
|
"app4_crc_ok": app4.get("crc_ok") if isinstance(app4, dict) else None,
|
||||||
|
"width": metrics["width"],
|
||||||
|
"height": metrics["height"],
|
||||||
|
"band_markers": bands,
|
||||||
|
"band_sequences": distinct_band_sequences,
|
||||||
|
"first_bad_row": first_bad_row,
|
||||||
|
"seam_row": seam_row,
|
||||||
|
"decode_warnings": metrics["decode_warnings"],
|
||||||
|
"luma_mean": round(mean, 3) if mean is not None else None,
|
||||||
|
"luma_stddev": round(stddev, 3) if stddev is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_app4(frame: bytes, sequence: int) -> bytes:
|
||||||
|
payload = APP4_MAGIC + bytes([1]) + sequence.to_bytes(8, "big") + (zlib.crc32(frame) & 0xFFFFFFFF).to_bytes(4, "big")
|
||||||
|
return frame[:2] + b"\xff\xe4" + (len(payload) + 2).to_bytes(2, "big") + payload + frame[2:]
|
||||||
|
|
||||||
|
|
||||||
|
def self_test(args: argparse.Namespace) -> int:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
def marked_image(top_sequence: int, bottom_sequence: int | None = None) -> Any:
|
||||||
|
image = Image.new("RGB", (256, 192))
|
||||||
|
pixels = image.load()
|
||||||
|
x0 = image.width - BAND_BITS * BAND_CELL - 4
|
||||||
|
for y in range(image.height):
|
||||||
|
for x in range(image.width):
|
||||||
|
pixels[x, y] = ((x * 3) & 0xFF, (y * 5) & 0xFF, ((x + y) * 2) & 0xFF)
|
||||||
|
for band in range((image.height + BAND_ROWS - 1) // BAND_ROWS):
|
||||||
|
sequence = top_sequence if bottom_sequence is None or band < 2 else bottom_sequence
|
||||||
|
marker = (sequence & 0x00FFFFFF) | ((band & 0xFF) << 24)
|
||||||
|
for bit in range(BAND_BITS):
|
||||||
|
value = 245 if marker & (1 << bit) else 12
|
||||||
|
for y in range(band * BAND_ROWS + 4, band * BAND_ROWS + 12):
|
||||||
|
for x in range(x0 + bit * BAND_CELL, x0 + (bit + 1) * BAND_CELL):
|
||||||
|
pixels[x, y] = (value, value, value)
|
||||||
|
return image
|
||||||
|
|
||||||
|
image = marked_image(7)
|
||||||
|
encoded = io.BytesIO()
|
||||||
|
image.save(encoded, format="JPEG", quality=90)
|
||||||
|
intact = add_app4(encoded.getvalue(), 7)
|
||||||
|
spliced = bytearray(intact)
|
||||||
|
entropy_index = max(24, len(spliced) - 12)
|
||||||
|
while entropy_index > 24 and spliced[entropy_index] == 0xFF:
|
||||||
|
entropy_index -= 1
|
||||||
|
spliced[entropy_index] ^= 0x01
|
||||||
|
visibly_spliced = io.BytesIO()
|
||||||
|
marked_image(7, 8).save(visibly_spliced, format="JPEG", quality=95)
|
||||||
|
visibly_spliced_frame = add_app4(visibly_spliced.getvalue(), 7)
|
||||||
|
black = io.BytesIO()
|
||||||
|
Image.new("RGB", (256, 192), (0, 0, 0)).save(black, format="JPEG", quality=90)
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
(intact, None, "intact"),
|
||||||
|
(intact, 7, "stale_repeat"),
|
||||||
|
(bytes(spliced), 6, "spliced"),
|
||||||
|
(visibly_spliced_frame, 6, "spliced"),
|
||||||
|
(intact[:-9], 6, "truncated"),
|
||||||
|
(black.getvalue(), None, "idle_black"),
|
||||||
|
]
|
||||||
|
for frame, previous, expected in cases:
|
||||||
|
actual = classify_frame(frame, previous, args)["classification"]
|
||||||
|
if actual != expected:
|
||||||
|
raise AssertionError(f"expected {expected}, got {actual}")
|
||||||
|
localized = classify_frame(visibly_spliced_frame, 6, args)
|
||||||
|
if localized["seam_row"] != 128 or localized["band_sequences"] != [7, 8]:
|
||||||
|
raise AssertionError(f"splice localization failed: {localized}")
|
||||||
|
print("self-test: pass")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
if args.self_test:
|
||||||
|
return self_test(args)
|
||||||
|
files = input_files(args.inputs)
|
||||||
|
if not files:
|
||||||
|
raise SystemExit("no MJPEG inputs found")
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
previous_sequence: int | None = None
|
||||||
|
for path in files:
|
||||||
|
for index, frame in enumerate(split_mjpeg(path.read_bytes())):
|
||||||
|
record = classify_frame(frame, previous_sequence, args)
|
||||||
|
record.update({"source": str(path), "source_frame_index": index})
|
||||||
|
records.append(record)
|
||||||
|
if record["sequence"] is not None:
|
||||||
|
previous_sequence = int(record["sequence"])
|
||||||
|
counts = Counter(record["classification"] for record in records)
|
||||||
|
report = {
|
||||||
|
"schema": "lesavka.uvc-mjpeg-integrity.v1",
|
||||||
|
"generated_unix_ms": int(time.time() * 1000),
|
||||||
|
"frames": len(records),
|
||||||
|
"classification_counts": dict(sorted(counts.items())),
|
||||||
|
"intact": counts["intact"],
|
||||||
|
"truncated": counts["truncated"],
|
||||||
|
"spliced": counts["spliced"],
|
||||||
|
"stale_repeat": counts["stale_repeat"],
|
||||||
|
"idle_black": counts["idle_black"],
|
||||||
|
"verdict": "pass" if len(records) > 0 and counts["intact"] == len(records) else "fail",
|
||||||
|
}
|
||||||
|
text = (
|
||||||
|
f"verdict: {report['verdict']}\nframes: {report['frames']}\n"
|
||||||
|
f"intact: {report['intact']}\ntruncated: {report['truncated']}\n"
|
||||||
|
f"spliced: {report['spliced']}\nstale_repeat: {report['stale_repeat']}\n"
|
||||||
|
f"idle_black: {report['idle_black']}\n"
|
||||||
|
)
|
||||||
|
if args.output_dir:
|
||||||
|
output = pathlib.Path(args.output_dir)
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||||
|
(output / "report.txt").write_text(text)
|
||||||
|
(output / "frames.jsonl").write_text("".join(json.dumps(record) + "\n" for record in records))
|
||||||
|
sys.stdout.write(text)
|
||||||
|
return 0 if report["verdict"] == "pass" else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -25,6 +25,9 @@ DEFAULT_MEDIA_CONTROL_PATH = "/tmp/lesavka-media.control"
|
|||||||
DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH = "/tmp/lesavka-uvc-frame-audit.control"
|
DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH = "/tmp/lesavka-uvc-frame-audit.control"
|
||||||
MARKER_BITS = 32
|
MARKER_BITS = 32
|
||||||
MARKER_COLUMNS = 16
|
MARKER_COLUMNS = 16
|
||||||
|
BAND_MARKER_ROWS = 64
|
||||||
|
BAND_MARKER_BITS = 32
|
||||||
|
BAND_MARKER_CELL = 4
|
||||||
CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"}
|
CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"}
|
||||||
NON_VISUAL_REASONS = CADENCE_REASONS | {"sequence_marker_mismatch"}
|
NON_VISUAL_REASONS = CADENCE_REASONS | {"sequence_marker_mismatch"}
|
||||||
|
|
||||||
@ -267,6 +270,11 @@ def parse_args() -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="debug path: analyze ffmpeg stdout directly instead of spooling raw frames first",
|
help="debug path: analyze ffmpeg stdout directly instead of spooling raw frames first",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--deep-capture",
|
||||||
|
action="store_true",
|
||||||
|
help="preserve native MJPEG plus UVC/kernel/USB power evidence; UVC source only",
|
||||||
|
)
|
||||||
parser.add_argument("--capture-only", action="store_true", help=argparse.SUPPRESS)
|
parser.add_argument("--capture-only", action="store_true", help=argparse.SUPPRESS)
|
||||||
parser.add_argument("--self-test", action="store_true")
|
parser.add_argument("--self-test", action="store_true")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
@ -305,7 +313,7 @@ def default_inject_max_frame_bytes(fps: int) -> int:
|
|||||||
* DEFAULT_ISOCHRONOUS_LIMIT_PCT
|
* DEFAULT_ISOCHRONOUS_LIMIT_PCT
|
||||||
// 100
|
// 100
|
||||||
)
|
)
|
||||||
return max(64 * 1024, bytes_per_second // max(1, fps))
|
return max(1, bytes_per_second // max(1, fps))
|
||||||
|
|
||||||
|
|
||||||
def default_artifact_dir(mode: str) -> pathlib.Path:
|
def default_artifact_dir(mode: str) -> pathlib.Path:
|
||||||
@ -791,6 +799,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
]
|
]
|
||||||
if args.stream_analyze:
|
if args.stream_analyze:
|
||||||
capture_cmd.append("--stream-analyze")
|
capture_cmd.append("--stream-analyze")
|
||||||
|
if args.deep_capture:
|
||||||
|
capture_cmd.append("--deep-capture")
|
||||||
inject_cmd = [
|
inject_cmd = [
|
||||||
args.inject_binary,
|
args.inject_binary,
|
||||||
"--server",
|
"--server",
|
||||||
@ -929,6 +939,20 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
local_server_audit = artifact_dir / "server-uvc-audit"
|
local_server_audit = artifact_dir / "server-uvc-audit"
|
||||||
if capture is not None:
|
if capture is not None:
|
||||||
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False)
|
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False)
|
||||||
|
if args.deep_capture:
|
||||||
|
integrity_script = pathlib.Path(__file__).with_name("analyze_marked_capture.py")
|
||||||
|
native_capture = local_capture / "capture.mjpg"
|
||||||
|
if integrity_script.exists() and native_capture.exists():
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(integrity_script),
|
||||||
|
str(native_capture),
|
||||||
|
"--output-dir",
|
||||||
|
str(local_capture / "mjpeg-integrity"),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
if args.local_inject:
|
if args.local_inject:
|
||||||
if pathlib.Path(remote_inject_dir).exists():
|
if pathlib.Path(remote_inject_dir).exists():
|
||||||
if local_inject.exists():
|
if local_inject.exists():
|
||||||
@ -1175,9 +1199,28 @@ def synthetic_luma(width: int, height: int, sequence: int, x: int, y: int) -> in
|
|||||||
marker = synthetic_marker_luma(width, height, sequence, x, y)
|
marker = synthetic_marker_luma(width, height, sequence, x, y)
|
||||||
if marker is not None:
|
if marker is not None:
|
||||||
return marker
|
return marker
|
||||||
|
band_marker = synthetic_band_marker_luma(width, height, sequence, x, y)
|
||||||
|
if band_marker is not None:
|
||||||
|
return band_marker
|
||||||
return synthetic_base_luma(width, height, sequence, x, y)
|
return synthetic_base_luma(width, height, sequence, x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def synthetic_band_marker_luma(width: int, height: int, sequence: int, x: int, y: int) -> int | None:
|
||||||
|
marker_width = BAND_MARKER_BITS * BAND_MARKER_CELL
|
||||||
|
if width < marker_width + 8 or height < BAND_MARKER_ROWS:
|
||||||
|
return None
|
||||||
|
band = y // BAND_MARKER_ROWS
|
||||||
|
band_y = band * BAND_MARKER_ROWS + 4
|
||||||
|
if not band_y <= y < band_y + BAND_MARKER_CELL * 2:
|
||||||
|
return None
|
||||||
|
x0 = width - marker_width - 4
|
||||||
|
if not x0 <= x < x0 + marker_width:
|
||||||
|
return None
|
||||||
|
bit = (x - x0) // BAND_MARKER_CELL
|
||||||
|
marker = (sequence & 0x00FFFFFF) | ((band & 0xFF) << 24)
|
||||||
|
return 245 if marker & (1 << bit) else 12
|
||||||
|
|
||||||
|
|
||||||
def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
|
def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
|
||||||
data = bytearray(width * height)
|
data = bytearray(width * height)
|
||||||
for y in range(height):
|
for y in range(height):
|
||||||
@ -1524,11 +1567,119 @@ def write_pgm(path: pathlib.Path, frame: bytes, width: int, height: int) -> None
|
|||||||
path.write_bytes(f"P5\n{width} {height}\n255\n".encode() + frame)
|
path.write_bytes(f"P5\n{width} {height}\n255\n".encode() + frame)
|
||||||
|
|
||||||
|
|
||||||
|
def command_text(command: list[str]) -> str:
|
||||||
|
try:
|
||||||
|
return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False).stdout
|
||||||
|
except (FileNotFoundError, PermissionError, OSError) as error:
|
||||||
|
return f"unavailable: {error}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def write_deep_capture_snapshot(artifact_dir: pathlib.Path, label: str, device: str) -> None:
|
||||||
|
snapshot = artifact_dir / "deep-capture" / label
|
||||||
|
snapshot.mkdir(parents=True, exist_ok=True)
|
||||||
|
commands = {
|
||||||
|
"uname.txt": ["uname", "-a"],
|
||||||
|
"v4l2-all.txt": ["v4l2-ctl", "--device", device, "--all"],
|
||||||
|
"v4l2-formats.txt": ["v4l2-ctl", "--device", device, "--list-formats-ext"],
|
||||||
|
"udev.txt": ["udevadm", "info", "--query=all", "--name", device],
|
||||||
|
"uvcvideo-parameters.txt": ["bash", "-lc", "for f in /sys/module/uvcvideo/parameters/*; do printf '%s=' \"$f\"; cat \"$f\" 2>&1; done"],
|
||||||
|
"usb-power.txt": [
|
||||||
|
"bash",
|
||||||
|
"-lc",
|
||||||
|
(
|
||||||
|
f"p=$(udevadm info -q path -n {shlex.quote(device)} 2>/dev/null); "
|
||||||
|
"d=/sys$p; while [[ $d != /sys && ! -e $d/idVendor ]]; do d=${d%/*}; done; "
|
||||||
|
"echo sysfs=$d; for f in power/control power/autosuspend power/autosuspend_delay_ms "
|
||||||
|
"power/runtime_status busnum devnum idVendor idProduct product serial; do "
|
||||||
|
"printf '%s=' \"$f\"; cat \"$d/$f\" 2>&1; done"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"kernel.txt": ["journalctl", "-k", "--no-pager", "-n", "1200"],
|
||||||
|
}
|
||||||
|
for name, command in commands.items():
|
||||||
|
(snapshot / name).write_text(command_text(command))
|
||||||
|
|
||||||
|
|
||||||
|
def usbmon_interface(device: str) -> str:
|
||||||
|
try:
|
||||||
|
sysfs = pathlib.Path("/sys" + subprocess.check_output(["udevadm", "info", "-q", "path", "-n", device], text=True).strip())
|
||||||
|
for parent in [sysfs, *sysfs.parents]:
|
||||||
|
busnum = parent / "busnum"
|
||||||
|
if busnum.exists():
|
||||||
|
return f"usbmon{int(busnum.read_text().strip())}"
|
||||||
|
except (FileNotFoundError, OSError, ValueError, subprocess.CalledProcessError):
|
||||||
|
pass
|
||||||
|
return "usbmon0"
|
||||||
|
|
||||||
|
|
||||||
|
def set_uvc_dynamic_debug(artifact_dir: pathlib.Path, enabled: bool) -> bool:
|
||||||
|
action = "+p" if enabled else "-p"
|
||||||
|
command = [
|
||||||
|
"bash",
|
||||||
|
"-lc",
|
||||||
|
(
|
||||||
|
"c=/sys/kernel/debug/dynamic_debug/control; "
|
||||||
|
f"line='module uvcvideo {action}'; "
|
||||||
|
"if [[ -w $c ]]; then echo \"$line\" >\"$c\"; "
|
||||||
|
"elif sudo -n test -w \"$c\" 2>/dev/null; then echo \"$line\" | sudo -n tee \"$c\" >/dev/null; "
|
||||||
|
"else echo 'dynamic debug control unavailable' >&2; exit 1; fi"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
|
||||||
|
(artifact_dir / f"uvcvideo-dynamic-debug-{'enable' if enabled else 'disable'}.txt").write_text(
|
||||||
|
f"rc={result.returncode}\n{result.stdout}"
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def start_usbmon_capture(artifact_dir: pathlib.Path, device: str) -> subprocess.Popen[Any] | None:
|
||||||
|
if not shutil.which("tshark"):
|
||||||
|
(artifact_dir / "usbmon-status.txt").write_text("tshark unavailable\n")
|
||||||
|
return None
|
||||||
|
interface = usbmon_interface(device)
|
||||||
|
output = artifact_dir / "usbmon.pcapng"
|
||||||
|
stderr = (artifact_dir / "usbmon.stderr").open("wb")
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
["tshark", "-i", interface, "-w", str(output)],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=stderr,
|
||||||
|
)
|
||||||
|
except (OSError, PermissionError) as error:
|
||||||
|
stderr.close()
|
||||||
|
(artifact_dir / "usbmon-status.txt").write_text(f"usbmon unavailable: {error}\n")
|
||||||
|
return None
|
||||||
|
setattr(process, "_lesavka_stderr", stderr)
|
||||||
|
return process
|
||||||
|
|
||||||
|
|
||||||
|
def stop_usbmon_capture(process: subprocess.Popen[Any] | None) -> None:
|
||||||
|
if process is None:
|
||||||
|
return
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
stderr = getattr(process, "_lesavka_stderr", None)
|
||||||
|
if stderr is not None:
|
||||||
|
stderr.close()
|
||||||
|
|
||||||
|
|
||||||
def run_capture(args: argparse.Namespace) -> int:
|
def run_capture(args: argparse.Namespace) -> int:
|
||||||
width, height, fps = mode_dimensions(args)
|
width, height, fps = mode_dimensions(args)
|
||||||
command, capture_width, capture_height, device = ffmpeg_cmd(args, width, height)
|
command, capture_width, capture_height, device = ffmpeg_cmd(args, width, height)
|
||||||
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else pathlib.Path("/tmp") / f"lesavka-synthetic-rct-capture-{timestamp()}"
|
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else pathlib.Path("/tmp") / f"lesavka-synthetic-rct-capture-{timestamp()}"
|
||||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
if args.deep_capture and args.source != "uvc":
|
||||||
|
raise SystemExit("--deep-capture requires --source uvc")
|
||||||
|
if args.deep_capture:
|
||||||
|
write_deep_capture_snapshot(artifact_dir, "before", device)
|
||||||
|
dynamic_debug_enabled = (
|
||||||
|
set_uvc_dynamic_debug(artifact_dir / "deep-capture", True) if args.deep_capture else False
|
||||||
|
)
|
||||||
|
usbmon = start_usbmon_capture(artifact_dir / "deep-capture", device) if args.deep_capture else None
|
||||||
frame_size = capture_width * capture_height
|
frame_size = capture_width * capture_height
|
||||||
stderr_path = artifact_dir / "ffmpeg.stderr"
|
stderr_path = artifact_dir / "ffmpeg.stderr"
|
||||||
metrics_path = artifact_dir / "frame-metrics.jsonl"
|
metrics_path = artifact_dir / "frame-metrics.jsonl"
|
||||||
@ -1610,8 +1761,9 @@ def run_capture(args: argparse.Namespace) -> int:
|
|||||||
if frame_index % args.progress_every == 0:
|
if frame_index % args.progress_every == 0:
|
||||||
print(f"frames={frame_index} suspicious={suspicious_count} latest={result}", file=sys.stderr)
|
print(f"frames={frame_index} suspicious={suspicious_count} latest={result}", file=sys.stderr)
|
||||||
|
|
||||||
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
|
try:
|
||||||
if args.stream_analyze:
|
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
|
||||||
|
if args.stream_analyze and not args.deep_capture:
|
||||||
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in command) + "\n")
|
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in command) + "\n")
|
||||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=err)
|
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=err)
|
||||||
assert proc.stdout is not None
|
assert proc.stdout is not None
|
||||||
@ -1633,18 +1785,40 @@ def run_capture(args: argparse.Namespace) -> int:
|
|||||||
analysis_elapsed = capture_elapsed
|
analysis_elapsed = capture_elapsed
|
||||||
else:
|
else:
|
||||||
raw_path = artifact_dir / "capture.raw"
|
raw_path = artifact_dir / "capture.raw"
|
||||||
capture_command = command[:]
|
if args.deep_capture:
|
||||||
if "-an" in capture_command:
|
native_mjpeg = artifact_dir / "capture.mjpg"
|
||||||
capture_command[capture_command.index("-an") : capture_command.index("-an")] = ["-t", str(args.duration)]
|
input_end = command.index("-an")
|
||||||
|
capture_command = command[:input_end] + [
|
||||||
|
"-t",
|
||||||
|
str(args.duration),
|
||||||
|
"-an",
|
||||||
|
"-c:v",
|
||||||
|
"copy",
|
||||||
|
"-f",
|
||||||
|
"mjpeg",
|
||||||
|
str(native_mjpeg),
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
capture_command[-1:-1] = ["-t", str(args.duration)]
|
native_mjpeg = None
|
||||||
capture_command[-1] = str(raw_path)
|
capture_command = command[:]
|
||||||
|
if "-an" in capture_command:
|
||||||
|
capture_command[capture_command.index("-an") : capture_command.index("-an")] = ["-t", str(args.duration)]
|
||||||
|
else:
|
||||||
|
capture_command[-1:-1] = ["-t", str(args.duration)]
|
||||||
|
capture_command[-1] = str(raw_path)
|
||||||
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in capture_command) + "\n")
|
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in capture_command) + "\n")
|
||||||
print(f"capturing raw RCT frames before analysis: {raw_path}", file=sys.stderr)
|
print(f"capturing raw RCT frames before analysis: {raw_path}", file=sys.stderr)
|
||||||
capture_started = time.monotonic()
|
capture_started = time.monotonic()
|
||||||
proc = subprocess.run(capture_command, stdout=subprocess.DEVNULL, stderr=err, check=False)
|
proc = subprocess.run(capture_command, stdout=subprocess.DEVNULL, stderr=err, check=False)
|
||||||
capture_elapsed = time.monotonic() - capture_started
|
capture_elapsed = time.monotonic() - capture_started
|
||||||
ffmpeg_rc = proc.returncode
|
ffmpeg_rc = proc.returncode
|
||||||
|
if args.deep_capture and native_mjpeg is not None and native_mjpeg.exists():
|
||||||
|
decode_command = [
|
||||||
|
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "warning",
|
||||||
|
"-i", str(native_mjpeg), "-an", "-pix_fmt", "gray", "-f", "rawvideo", str(raw_path),
|
||||||
|
]
|
||||||
|
(artifact_dir / "decode-command.txt").write_text(" ".join(shlex.quote(part) for part in decode_command) + "\n")
|
||||||
|
subprocess.run(decode_command, stdout=subprocess.DEVNULL, stderr=err, check=False)
|
||||||
raw_capture_bytes = raw_path.stat().st_size if raw_path.exists() else 0
|
raw_capture_bytes = raw_path.stat().st_size if raw_path.exists() else 0
|
||||||
print(
|
print(
|
||||||
f"analyzing captured raw RCT frames bytes={raw_capture_bytes} capture_s={capture_elapsed:.3f}",
|
f"analyzing captured raw RCT frames bytes={raw_capture_bytes} capture_s={capture_elapsed:.3f}",
|
||||||
@ -1661,13 +1835,19 @@ def run_capture(args: argparse.Namespace) -> int:
|
|||||||
finally:
|
finally:
|
||||||
raw_path.unlink(missing_ok=True)
|
raw_path.unlink(missing_ok=True)
|
||||||
analysis_elapsed = time.monotonic() - analysis_started
|
analysis_elapsed = time.monotonic() - analysis_started
|
||||||
|
finally:
|
||||||
|
stop_usbmon_capture(usbmon)
|
||||||
|
if args.deep_capture:
|
||||||
|
write_deep_capture_snapshot(artifact_dir, "after", device)
|
||||||
|
if dynamic_debug_enabled:
|
||||||
|
set_uvc_dynamic_debug(artifact_dir / "deep-capture", False)
|
||||||
elapsed = max(0.001, capture_elapsed)
|
elapsed = max(0.001, capture_elapsed)
|
||||||
summary = {
|
summary = {
|
||||||
"schema": "lesavka.synthetic-rct-capture.v1",
|
"schema": "lesavka.synthetic-rct-capture.v1",
|
||||||
"source": args.source,
|
"source": args.source,
|
||||||
"device": device,
|
"device": device,
|
||||||
"mode": args.mode,
|
"mode": args.mode,
|
||||||
"capture_mode": "stream" if args.stream_analyze else "rawfile",
|
"capture_mode": "deep-mjpeg" if args.deep_capture else ("stream" if args.stream_analyze else "rawfile"),
|
||||||
"width": capture_width,
|
"width": capture_width,
|
||||||
"height": capture_height,
|
"height": capture_height,
|
||||||
"fps_requested": fps,
|
"fps_requested": fps,
|
||||||
|
|||||||
@ -16,7 +16,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.27.6"
|
version = "0.27.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,9 @@ const DEFAULT_CHANNELS: u32 = 2;
|
|||||||
const DEFAULT_JPEG_QUALITY: i32 = 82;
|
const DEFAULT_JPEG_QUALITY: i32 = 82;
|
||||||
const MARKER_BITS: usize = 32;
|
const MARKER_BITS: usize = 32;
|
||||||
const MARKER_COLUMNS: usize = 16;
|
const MARKER_COLUMNS: usize = 16;
|
||||||
|
const BAND_MARKER_ROWS: usize = 64;
|
||||||
|
const BAND_MARKER_BITS: usize = 32;
|
||||||
|
const BAND_MARKER_CELL: usize = 4;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct Args {
|
struct Args {
|
||||||
@ -258,7 +261,7 @@ impl MjpegEncoder {
|
|||||||
let map = buffer
|
let map = buffer
|
||||||
.map_readable()
|
.map_readable()
|
||||||
.context("mapping encoded synthetic frame")?;
|
.context("mapping encoded synthetic frame")?;
|
||||||
Ok(map.as_slice().to_vec())
|
add_jpeg_app4_integrity(map.as_slice(), sequence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
// lesavka-uvc - minimal UVC control handler for the gadget node.
|
// lesavka-uvc - minimal UVC control handler for the gadget node.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use lesavka_server::uvc_contract::{self, UvcContractInput, UvcPayloadContract};
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
@ -56,9 +58,13 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
|
|||||||
const DEFAULT_UVC_QUEUE_PACING: bool = false;
|
const DEFAULT_UVC_QUEUE_PACING: bool = false;
|
||||||
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
|
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
|
||||||
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
||||||
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
|
|
||||||
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
|
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
|
||||||
const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json";
|
const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json";
|
||||||
|
const DEFAULT_UVC_IDLE_AFTER_STALE_MS: u64 = 2_000;
|
||||||
|
const DEFAULT_UVC_HANDOFF_AUDIT_PATH: &str = "/run/lesavka-uvc-handoff.jsonl";
|
||||||
|
const DEFAULT_UVC_HANDOFF_AUDIT_MAX_RECORDS: usize = 1_024;
|
||||||
|
const DEFAULT_UVC_KERNEL_STATS_PATH: &str = "/run/lesavka-uvc-kernel-stats.json";
|
||||||
|
const V4L2_BUF_FLAG_ERROR: u32 = 0x0040;
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
struct V4l2EventSubscription {
|
struct V4l2EventSubscription {
|
||||||
@ -214,6 +220,7 @@ struct UvcState {
|
|||||||
probe: [u8; STREAM_CTRL_SIZE_MAX],
|
probe: [u8; STREAM_CTRL_SIZE_MAX],
|
||||||
commit: [u8; STREAM_CTRL_SIZE_MAX],
|
commit: [u8; STREAM_CTRL_SIZE_MAX],
|
||||||
cfg_snapshot: Option<ConfigfsSnapshot>,
|
cfg_snapshot: Option<ConfigfsSnapshot>,
|
||||||
|
commit_observed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@ -235,8 +242,10 @@ struct ConfigfsSnapshot {
|
|||||||
height: u32,
|
height: u32,
|
||||||
default_interval: u32,
|
default_interval: u32,
|
||||||
frame_interval: u32,
|
frame_interval: u32,
|
||||||
|
frame_size: u32,
|
||||||
maxpacket: u32,
|
maxpacket: u32,
|
||||||
maxburst: u32,
|
maxburst: u32,
|
||||||
|
streaming_bulk: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MmapBuffer {
|
struct MmapBuffer {
|
||||||
@ -255,6 +264,12 @@ struct UvcVideoStream {
|
|||||||
streaming: bool,
|
streaming: bool,
|
||||||
transport_bulk: bool,
|
transport_bulk: bool,
|
||||||
max_packet: u32,
|
max_packet: u32,
|
||||||
|
has_verified_frame: bool,
|
||||||
|
last_verified_mtime: Option<SystemTime>,
|
||||||
|
last_spool_mtime_ns: Option<u128>,
|
||||||
|
last_refresh_disposition: &'static str,
|
||||||
|
handoff_sequence: u64,
|
||||||
|
handoff_audit: VecDeque<String>,
|
||||||
stats: UvcVideoStats,
|
stats: UvcVideoStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,6 +281,12 @@ struct UvcVideoStats {
|
|||||||
rejected_oversize: u64,
|
rejected_oversize: u64,
|
||||||
rejected_invalid: u64,
|
rejected_invalid: u64,
|
||||||
fallback_idle: u64,
|
fallback_idle: u64,
|
||||||
|
held_last_good: u64,
|
||||||
|
read_errors: u64,
|
||||||
|
strict_validation_failures: u64,
|
||||||
|
dqbuf_ioctl_errors: u64,
|
||||||
|
dqbuf_flag_errors: u64,
|
||||||
|
qbuf_ioctl_errors: u64,
|
||||||
latest_bytes: usize,
|
latest_bytes: usize,
|
||||||
last_rejected_oversize_bytes: usize,
|
last_rejected_oversize_bytes: usize,
|
||||||
last_rejected_oversize_cap: usize,
|
last_rejected_oversize_cap: usize,
|
||||||
@ -287,6 +308,12 @@ impl UvcVideoStream {
|
|||||||
streaming: false,
|
streaming: false,
|
||||||
transport_bulk: false,
|
transport_bulk: false,
|
||||||
max_packet: 0,
|
max_packet: 0,
|
||||||
|
has_verified_frame: false,
|
||||||
|
last_verified_mtime: None,
|
||||||
|
last_spool_mtime_ns: None,
|
||||||
|
last_refresh_disposition: "idle_startup",
|
||||||
|
handoff_sequence: 0,
|
||||||
|
handoff_audit: VecDeque::new(),
|
||||||
stats: UvcVideoStats::default(),
|
stats: UvcVideoStats::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -301,7 +328,7 @@ impl UvcVideoStream {
|
|||||||
self.set_format(cfg)?;
|
self.set_format(cfg)?;
|
||||||
self.request_buffers(uvc_buffer_count())?;
|
self.request_buffers(uvc_buffer_count())?;
|
||||||
for index in 0..self.buffers.len() {
|
for index in 0..self.buffers.len() {
|
||||||
self.queue_buffer(index as u32)?;
|
self.queue_buffer(index as u32, 0)?;
|
||||||
}
|
}
|
||||||
let req = ioctl_write::<libc::c_int>(b'V', 18);
|
let req = ioctl_write::<libc::c_int>(b'V', 18);
|
||||||
let mut type_ = V4L2_BUF_TYPE_VIDEO_OUTPUT as libc::c_int;
|
let mut type_ = V4L2_BUF_TYPE_VIDEO_OUTPUT as libc::c_int;
|
||||||
@ -361,10 +388,16 @@ impl UvcVideoStream {
|
|||||||
let err = std::io::Error::last_os_error();
|
let err = std::io::Error::last_os_error();
|
||||||
match err.raw_os_error() {
|
match err.raw_os_error() {
|
||||||
Some(libc::EAGAIN) | Some(libc::EINTR) => return Ok(()),
|
Some(libc::EAGAIN) | Some(libc::EINTR) => return Ok(()),
|
||||||
_ => return Err(err).context("VIDIOC_DQBUF"),
|
_ => {
|
||||||
|
self.stats.dqbuf_ioctl_errors += 1;
|
||||||
|
return Err(err).context("VIDIOC_DQBUF");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.queue_buffer(buf.index)?;
|
if buf.flags & V4L2_BUF_FLAG_ERROR != 0 {
|
||||||
|
self.stats.dqbuf_flag_errors += 1;
|
||||||
|
}
|
||||||
|
self.queue_buffer(buf.index, buf.flags)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -446,7 +479,7 @@ impl UvcVideoStream {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue_buffer(&mut self, index: u32) -> Result<()> {
|
fn queue_buffer(&mut self, index: u32, dqbuf_flags: u32) -> Result<()> {
|
||||||
if self.streaming {
|
if self.streaming {
|
||||||
self.pace_queue_if_needed();
|
self.pace_queue_if_needed();
|
||||||
}
|
}
|
||||||
@ -456,6 +489,8 @@ impl UvcVideoStream {
|
|||||||
};
|
};
|
||||||
let frame = self.frame_for_buffer(buffer.len);
|
let frame = self.frame_for_buffer(buffer.len);
|
||||||
let bytes = frame.len();
|
let bytes = frame.len();
|
||||||
|
let frame_hash = fnv1a64(frame);
|
||||||
|
let frame_sequence = uvc_contract::lesavka_app4_sequence(frame);
|
||||||
if bytes > 0 {
|
if bytes > 0 {
|
||||||
unsafe {
|
unsafe {
|
||||||
std::ptr::copy_nonoverlapping(frame.as_ptr(), buffer.ptr, bytes);
|
std::ptr::copy_nonoverlapping(frame.as_ptr(), buffer.ptr, bytes);
|
||||||
@ -478,10 +513,12 @@ impl UvcVideoStream {
|
|||||||
let req = ioctl_readwrite::<V4l2Buffer>(b'V', 15);
|
let req = ioctl_readwrite::<V4l2Buffer>(b'V', 15);
|
||||||
let rc = unsafe { libc::ioctl(self.fd, req, &mut buf) };
|
let rc = unsafe { libc::ioctl(self.fd, req, &mut buf) };
|
||||||
if rc < 0 {
|
if rc < 0 {
|
||||||
|
self.stats.qbuf_ioctl_errors += 1;
|
||||||
return Err(std::io::Error::last_os_error()).context("VIDIOC_QBUF");
|
return Err(std::io::Error::last_os_error()).context("VIDIOC_QBUF");
|
||||||
}
|
}
|
||||||
self.stats.queued += 1;
|
self.stats.queued += 1;
|
||||||
self.stats.latest_bytes = bytes;
|
self.stats.latest_bytes = bytes;
|
||||||
|
self.record_handoff(index, dqbuf_flags, bytes, frame_hash, frame_sequence);
|
||||||
self.report_stats_if_due();
|
self.report_stats_if_due();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -503,35 +540,63 @@ impl UvcVideoStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_latest_frame(&mut self) {
|
fn refresh_latest_frame(&mut self) {
|
||||||
|
self.last_spool_mtime_ns = frame_spool_mtime_ns(&self.frame_path);
|
||||||
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
|
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
|
||||||
if stale {
|
if stale {
|
||||||
self.stats.replayed_stale += 1;
|
self.stats.replayed_stale += 1;
|
||||||
self.replace_latest_with_idle();
|
self.hold_last_good_or_idle("hold_stale", "idle_stale");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let max_frame_bytes = self.frame_payload_limit();
|
let max_frame_bytes = self.frame_payload_limit();
|
||||||
match std::fs::read(&self.frame_path) {
|
match std::fs::read(&self.frame_path) {
|
||||||
Ok(frame) if !looks_like_mjpeg_frame(&frame) => {
|
Ok(frame) if !looks_like_mjpeg_frame(&frame) => {
|
||||||
self.stats.rejected_invalid += 1;
|
self.stats.rejected_invalid += 1;
|
||||||
self.replace_latest_with_idle();
|
self.hold_last_good_or_idle("hold_invalid", "idle_invalid");
|
||||||
}
|
}
|
||||||
Ok(frame) if frame.len() > max_frame_bytes => {
|
Ok(frame) if frame.len() > max_frame_bytes => {
|
||||||
self.stats.rejected_oversize += 1;
|
self.stats.rejected_oversize += 1;
|
||||||
self.stats.last_rejected_oversize_bytes = frame.len();
|
self.stats.last_rejected_oversize_bytes = frame.len();
|
||||||
self.stats.last_rejected_oversize_cap = max_frame_bytes;
|
self.stats.last_rejected_oversize_cap = max_frame_bytes;
|
||||||
self.replace_latest_with_idle();
|
self.hold_last_good_or_idle("hold_oversize", "idle_oversize");
|
||||||
}
|
}
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
|
if strict_validation_due(self.stats.reloaded)
|
||||||
|
&& (!uvc_contract::validate_jpeg_structure(&frame)
|
||||||
|
|| uvc_contract::lesavka_app4_integrity_valid(&frame) == Some(false))
|
||||||
|
{
|
||||||
|
self.stats.rejected_invalid += 1;
|
||||||
|
self.stats.strict_validation_failures += 1;
|
||||||
|
self.hold_last_good_or_idle("hold_strict_invalid", "idle_strict_invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.stats.reloaded += 1;
|
self.stats.reloaded += 1;
|
||||||
|
self.stats.latest_bytes = frame.len();
|
||||||
self.latest_frame = frame;
|
self.latest_frame = frame;
|
||||||
|
self.has_verified_frame = true;
|
||||||
|
self.last_verified_mtime = frame_spool_modified(&self.frame_path);
|
||||||
|
self.last_refresh_disposition = "fresh";
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.stats.read_errors += 1;
|
||||||
|
self.hold_last_good_or_idle("hold_read_error", "idle_read_error");
|
||||||
}
|
}
|
||||||
Err(_) => self.replace_latest_with_idle(),
|
|
||||||
}
|
}
|
||||||
if !looks_like_mjpeg_frame(&self.latest_frame) {
|
if !looks_like_mjpeg_frame(&self.latest_frame) {
|
||||||
self.replace_latest_with_idle();
|
self.replace_latest_with_idle();
|
||||||
|
self.last_refresh_disposition = "idle_invalid_cache";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hold_last_good_or_idle(&mut self, hold_reason: &'static str, idle_reason: &'static str) {
|
||||||
|
if self.has_verified_frame && !verified_frame_idle_timeout_elapsed(self.last_verified_mtime) {
|
||||||
|
self.stats.held_last_good += 1;
|
||||||
|
self.last_refresh_disposition = hold_reason;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.replace_latest_with_idle();
|
||||||
|
self.last_refresh_disposition = idle_reason;
|
||||||
|
}
|
||||||
|
|
||||||
fn replace_latest_with_idle(&mut self) {
|
fn replace_latest_with_idle(&mut self) {
|
||||||
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
|
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
|
||||||
self.stats.fallback_idle += 1;
|
self.stats.fallback_idle += 1;
|
||||||
@ -539,6 +604,53 @@ impl UvcVideoStream {
|
|||||||
self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
|
self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_handoff(
|
||||||
|
&mut self,
|
||||||
|
buffer_index: u32,
|
||||||
|
dqbuf_flags: u32,
|
||||||
|
bytes: usize,
|
||||||
|
hash: u64,
|
||||||
|
frame_sequence: Option<u64>,
|
||||||
|
) {
|
||||||
|
let Some(path) = uvc_handoff_audit_path() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.handoff_sequence += 1;
|
||||||
|
let record = format!(
|
||||||
|
"{{\"schema\":\"lesavka.uvc-handoff.v1\",\"sequence\":{},\"frame_sequence\":{},\"unix_ns\":{},\"spool_mtime_ns\":{},\"bytes\":{},\"fnv1a64\":\"{:016x}\",\"frame_cap\":{},\"buffer_index\":{},\"dqbuf_flags\":{},\"v4l2_buffer_error\":{},\"disposition\":\"{}\"}}\n",
|
||||||
|
self.handoff_sequence,
|
||||||
|
frame_sequence
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "null".to_string()),
|
||||||
|
unix_now_ns(),
|
||||||
|
self.last_spool_mtime_ns
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "null".to_string()),
|
||||||
|
bytes,
|
||||||
|
hash,
|
||||||
|
self.frame_payload_limit(),
|
||||||
|
buffer_index,
|
||||||
|
dqbuf_flags,
|
||||||
|
dqbuf_flags & V4L2_BUF_FLAG_ERROR != 0,
|
||||||
|
self.last_refresh_disposition,
|
||||||
|
);
|
||||||
|
let max_records = uvc_handoff_audit_max_records();
|
||||||
|
if max_records == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.handoff_audit.len() == max_records {
|
||||||
|
self.handoff_audit.pop_front();
|
||||||
|
}
|
||||||
|
self.handoff_audit.push_back(record);
|
||||||
|
let payload = self.handoff_audit.iter().map(String::as_str).collect::<String>();
|
||||||
|
if let Err(err) = write_atomic_text(&path, &payload) {
|
||||||
|
eprintln!(
|
||||||
|
"[lesavka-uvc] failed to write handoff audit {}: {err:#}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn frame_payload_limit(&self) -> usize {
|
fn frame_payload_limit(&self) -> usize {
|
||||||
self.buffers
|
self.buffers
|
||||||
.iter()
|
.iter()
|
||||||
@ -572,13 +684,19 @@ impl UvcVideoStream {
|
|||||||
}
|
}
|
||||||
self.stats.last_report = Some(now);
|
self.stats.last_report = Some(now);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} latest_bytes={} frame_cap={} last_rejected_oversize_bytes={} last_rejected_oversize_cap={} paced_sleeps={} paced_sleep_ms={}",
|
"[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} held_last_good={} read_errors={} strict_validation_failures={} dqbuf_ioctl_errors={} dqbuf_flag_errors={} qbuf_ioctl_errors={} latest_bytes={} frame_cap={} last_rejected_oversize_bytes={} last_rejected_oversize_cap={} paced_sleeps={} paced_sleep_ms={}",
|
||||||
self.stats.queued,
|
self.stats.queued,
|
||||||
self.stats.reloaded,
|
self.stats.reloaded,
|
||||||
self.stats.replayed_stale,
|
self.stats.replayed_stale,
|
||||||
self.stats.rejected_oversize,
|
self.stats.rejected_oversize,
|
||||||
self.stats.rejected_invalid,
|
self.stats.rejected_invalid,
|
||||||
self.stats.fallback_idle,
|
self.stats.fallback_idle,
|
||||||
|
self.stats.held_last_good,
|
||||||
|
self.stats.read_errors,
|
||||||
|
self.stats.strict_validation_failures,
|
||||||
|
self.stats.dqbuf_ioctl_errors,
|
||||||
|
self.stats.dqbuf_flag_errors,
|
||||||
|
self.stats.qbuf_ioctl_errors,
|
||||||
self.stats.latest_bytes,
|
self.stats.latest_bytes,
|
||||||
self.frame_payload_limit(),
|
self.frame_payload_limit(),
|
||||||
self.stats.last_rejected_oversize_bytes,
|
self.stats.last_rejected_oversize_bytes,
|
||||||
@ -683,18 +801,17 @@ fn uvc_queue_period(fps: u32) -> Option<Duration> {
|
|||||||
/// half-frame grey smears, and freezing the last good frame is preferable to
|
/// half-frame grey smears, and freezing the last good frame is preferable to
|
||||||
/// queueing a frame that is likely to arrive incomplete.
|
/// queueing a frame that is likely to arrive incomplete.
|
||||||
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||||
if !uvc_frame_size_guard_enabled() {
|
if let Ok(contract) = uvc_contract::read_contract(&uvc_contract::contract_path()) {
|
||||||
return MAX_MJPEG_FRAME_BYTES;
|
return contract
|
||||||
|
.enforced_frame_cap_bytes
|
||||||
|
.min(cfg.frame_size)
|
||||||
|
.min(MAX_MJPEG_FRAME_BYTES as u32) as usize;
|
||||||
}
|
}
|
||||||
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") {
|
let derived = derived_uvc_frame_max_bytes(cfg).min(cfg.frame_size as usize);
|
||||||
return if limit == 0 {
|
env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
|
||||||
derived_uvc_frame_max_bytes(cfg)
|
.filter(|limit| *limit > 0)
|
||||||
} else {
|
.map(|limit| derived.min(limit as usize))
|
||||||
(limit as usize).min(MAX_MJPEG_FRAME_BYTES)
|
.unwrap_or(derived)
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
derived_uvc_frame_max_bytes(cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||||
@ -702,10 +819,8 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize {
|
fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize {
|
||||||
let fps = fps.max(1);
|
|
||||||
let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk);
|
let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk);
|
||||||
let per_frame = (budget_per_sec / fps).max(64 * 1024);
|
uvc_contract::frame_cap_from_budget(budget_per_sec, fps) as usize
|
||||||
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 {
|
fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 {
|
||||||
@ -714,26 +829,11 @@ fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32
|
|||||||
DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||||
)
|
)
|
||||||
.max(1);
|
.max(1);
|
||||||
if bulk {
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
configured
|
|
||||||
.min(uvc_isochronous_budget_bytes_per_sec(max_packet))
|
|
||||||
.max(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uvc_isochronous_budget_bytes_per_sec(max_packet: u32) -> u32 {
|
|
||||||
let pct = env_u32(
|
let pct = env_u32(
|
||||||
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
|
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
|
||||||
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
|
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
|
||||||
)
|
);
|
||||||
.clamp(1, 100);
|
uvc_contract::effective_mjpeg_budget_bytes_per_sec(configured, max_packet, bulk, pct)
|
||||||
let bytes = u64::from(max_packet)
|
|
||||||
.saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC))
|
|
||||||
.saturating_mul(u64::from(pct))
|
|
||||||
/ 100;
|
|
||||||
bytes.min(u64::from(u32::MAX)) as u32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn env_flag_enabled(name: &str, default: bool) -> bool {
|
fn env_flag_enabled(name: &str, default: bool) -> bool {
|
||||||
@ -760,22 +860,19 @@ fn env_flag_enabled(name: &str, default: bool) -> bool {
|
|||||||
.unwrap_or(default)
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uvc_frame_size_guard_enabled() -> bool {
|
fn uvc_bulk_transfer_enabled() -> bool {
|
||||||
env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true)
|
uvc_bulk_transfer_enabled_for_base(std::path::Path::new(CONFIGFS_UVC_BASE))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uvc_bulk_transfer_enabled() -> bool {
|
fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool {
|
||||||
if !env_flag_enabled("LESAVKA_UVC_BULK", true) {
|
let requested = env_flag_enabled("LESAVKA_UVC_BULK", true);
|
||||||
return false;
|
let bulk = uvc_contract::bulk_enabled_from_configfs(base, requested);
|
||||||
}
|
if requested && !bulk {
|
||||||
let base = std::path::Path::new(CONFIGFS_UVC_BASE);
|
|
||||||
if base.exists() && !base.join("streaming_bulk").exists() {
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[lesavka-uvc] UVC bulk requested but live configfs has no streaming_bulk; using isochronous payload sizing"
|
"[lesavka-uvc] UVC bulk requested but live configfs streaming_bulk is not 1; using isochronous payload sizing"
|
||||||
);
|
);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
true
|
bulk
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uvc_frame_size_for_active_mode(
|
fn uvc_frame_size_for_active_mode(
|
||||||
@ -785,12 +882,12 @@ fn uvc_frame_size_for_active_mode(
|
|||||||
max_packet: u32,
|
max_packet: u32,
|
||||||
bulk: bool,
|
bulk: bool,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
env_u32_opt("LESAVKA_UVC_FRAME_SIZE")
|
let derived = derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
|
||||||
.unwrap_or_else(|| {
|
.min(u32::MAX as usize) as u32;
|
||||||
derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
|
let requested = env_u32_opt("LESAVKA_UVC_FRAME_SIZE").filter(|value| *value > 0);
|
||||||
.min(u32::MAX as usize) as u32
|
let frame_size = requested.unwrap_or(derived).min(derived);
|
||||||
})
|
let minimum = (width.saturating_mul(height) / 32).max(64 * 1024);
|
||||||
.max((width.saturating_mul(height) / 32).max(64 * 1024))
|
frame_size.max(minimum.min(derived))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
||||||
@ -804,23 +901,43 @@ fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String {
|
fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String {
|
||||||
|
let kernel = uvc_kernel_stats_json();
|
||||||
format!(
|
format!(
|
||||||
"{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n",
|
"{{\"generated_unix_ms\":{},\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"held_last_good\":{},\"read_errors\":{},\"strict_validation_failures\":{},\"dqbuf_ioctl_errors\":{},\"dqbuf_flag_errors\":{},\"qbuf_ioctl_errors\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{},\"kernel\":{}}}\n",
|
||||||
|
unix_now_ns() / 1_000_000,
|
||||||
stats.queued,
|
stats.queued,
|
||||||
stats.reloaded,
|
stats.reloaded,
|
||||||
stats.replayed_stale,
|
stats.replayed_stale,
|
||||||
stats.rejected_oversize,
|
stats.rejected_oversize,
|
||||||
stats.rejected_invalid,
|
stats.rejected_invalid,
|
||||||
stats.fallback_idle,
|
stats.fallback_idle,
|
||||||
|
stats.held_last_good,
|
||||||
|
stats.read_errors,
|
||||||
|
stats.strict_validation_failures,
|
||||||
|
stats.dqbuf_ioctl_errors,
|
||||||
|
stats.dqbuf_flag_errors,
|
||||||
|
stats.qbuf_ioctl_errors,
|
||||||
stats.latest_bytes,
|
stats.latest_bytes,
|
||||||
frame_cap,
|
frame_cap,
|
||||||
stats.last_rejected_oversize_bytes,
|
stats.last_rejected_oversize_bytes,
|
||||||
stats.last_rejected_oversize_cap,
|
stats.last_rejected_oversize_cap,
|
||||||
stats.paced_sleeps,
|
stats.paced_sleeps,
|
||||||
stats.paced_sleep_ms
|
stats.paced_sleep_ms,
|
||||||
|
kernel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn uvc_kernel_stats_json() -> String {
|
||||||
|
let path = env::var("LESAVKA_UVC_KERNEL_STATS_PATH")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from(DEFAULT_UVC_KERNEL_STATS_PATH));
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "null".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn uvc_stats_interval() -> Option<Duration> {
|
fn uvc_stats_interval() -> Option<Duration> {
|
||||||
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
|
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
|
||||||
0 => None,
|
0 => None,
|
||||||
@ -838,6 +955,80 @@ fn frame_spool_max_age() -> Option<Duration> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn uvc_idle_after_stale() -> Duration {
|
||||||
|
let value = env::var("LESAVKA_UVC_IDLE_AFTER_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.or_else(|| {
|
||||||
|
env::var("LESAVKA_UVC_IDLE_AFTER_STALE_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
})
|
||||||
|
.unwrap_or(DEFAULT_UVC_IDLE_AFTER_STALE_MS);
|
||||||
|
Duration::from_millis(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verified_frame_idle_timeout_elapsed(modified: Option<SystemTime>) -> bool {
|
||||||
|
let Some(modified) = modified else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(modified)
|
||||||
|
.map(|age| age >= uvc_idle_after_stale())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_spool_modified(path: &std::path::Path) -> Option<SystemTime> {
|
||||||
|
std::fs::metadata(path).ok()?.modified().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_spool_mtime_ns(path: &std::path::Path) -> Option<u128> {
|
||||||
|
frame_spool_modified(path)?
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.map(|duration| duration.as_nanos())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strict_validation_due(reloaded: u64) -> bool {
|
||||||
|
match env_u64("LESAVKA_UVC_STRICT_VALIDATE_EVERY", 30) {
|
||||||
|
0 => false,
|
||||||
|
every => reloaded % every == 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uvc_handoff_audit_path() -> Option<std::path::PathBuf> {
|
||||||
|
if let Ok(value) = env::var("LESAVKA_UVC_HANDOFF_AUDIT_PATH") {
|
||||||
|
let value = value.trim();
|
||||||
|
return (!value.is_empty() && value != "0").then(|| std::path::PathBuf::from(value));
|
||||||
|
}
|
||||||
|
env_flag_enabled("LESAVKA_UVC_HANDOFF_AUDIT", false)
|
||||||
|
.then(|| std::path::PathBuf::from(DEFAULT_UVC_HANDOFF_AUDIT_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uvc_handoff_audit_max_records() -> usize {
|
||||||
|
env_u64(
|
||||||
|
"LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS",
|
||||||
|
DEFAULT_UVC_HANDOFF_AUDIT_MAX_RECORDS as u64,
|
||||||
|
)
|
||||||
|
.min(16_384) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fnv1a64(data: &[u8]) -> u64 {
|
||||||
|
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||||
|
for byte in data {
|
||||||
|
hash ^= u64::from(*byte);
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now_ns() -> u128 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
}
|
||||||
|
|
||||||
fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<Duration>) -> bool {
|
fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<Duration>) -> bool {
|
||||||
let Some(max_age) = max_age else {
|
let Some(max_age) = max_age else {
|
||||||
return false;
|
return false;
|
||||||
@ -1069,7 +1260,8 @@ impl UvcConfig {
|
|||||||
} else {
|
} else {
|
||||||
requested_interval
|
requested_interval
|
||||||
};
|
};
|
||||||
if let Some(snapshot) = read_configfs_snapshot() {
|
let live_snapshot = read_configfs_snapshot();
|
||||||
|
if let Some(snapshot) = live_snapshot {
|
||||||
let live_interval = if snapshot.default_interval == 0 {
|
let live_interval = if snapshot.default_interval == 0 {
|
||||||
snapshot.frame_interval
|
snapshot.frame_interval
|
||||||
} else {
|
} else {
|
||||||
@ -1149,7 +1341,13 @@ impl UvcConfig {
|
|||||||
} else {
|
} else {
|
||||||
max_packet = max_packet.min(1024);
|
max_packet = max_packet.min(1024);
|
||||||
}
|
}
|
||||||
let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk);
|
let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk)
|
||||||
|
.min(
|
||||||
|
live_snapshot
|
||||||
|
.map(|snapshot| snapshot.frame_size)
|
||||||
|
.filter(|frame_size| *frame_size > 0)
|
||||||
|
.unwrap_or(u32::MAX),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
width,
|
width,
|
||||||
@ -1168,14 +1366,77 @@ impl UvcState {
|
|||||||
let _profile_hint = (cfg.width, cfg.height, cfg.fps);
|
let _profile_hint = (cfg.width, cfg.height, cfg.fps);
|
||||||
let ctrl_len = stream_ctrl_len();
|
let ctrl_len = stream_ctrl_len();
|
||||||
let default = build_streaming_control(&cfg, ctrl_len);
|
let default = build_streaming_control(&cfg, ctrl_len);
|
||||||
Self {
|
let state = Self {
|
||||||
cfg,
|
cfg,
|
||||||
ctrl_len,
|
ctrl_len,
|
||||||
default,
|
default,
|
||||||
probe: default,
|
probe: default,
|
||||||
commit: default,
|
commit: default,
|
||||||
cfg_snapshot: None,
|
cfg_snapshot: None,
|
||||||
|
commit_observed: false,
|
||||||
|
};
|
||||||
|
state.publish_payload_contract("startup");
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_contract(&self) -> UvcPayloadContract {
|
||||||
|
let snapshot = read_configfs_snapshot();
|
||||||
|
let streaming_bulk_value = snapshot
|
||||||
|
.and_then(|snapshot| snapshot.streaming_bulk)
|
||||||
|
.or_else(|| {
|
||||||
|
uvc_contract::read_streaming_bulk_value(std::path::Path::new(CONFIGFS_UVC_BASE))
|
||||||
|
});
|
||||||
|
UvcPayloadContract::from_input(UvcContractInput {
|
||||||
|
configfs_available: snapshot.is_some(),
|
||||||
|
commit_observed: self.commit_observed,
|
||||||
|
bulk: self.cfg.bulk,
|
||||||
|
streaming_bulk_value,
|
||||||
|
width: self.cfg.width,
|
||||||
|
height: self.cfg.height,
|
||||||
|
fps: self.cfg.fps,
|
||||||
|
frame_interval_100ns: read_le32(&self.commit, 4).max(self.cfg.interval),
|
||||||
|
streaming_maxpacket: snapshot
|
||||||
|
.map(|value| value.maxpacket)
|
||||||
|
.unwrap_or(self.cfg.max_packet),
|
||||||
|
streaming_maxburst: snapshot.map(|value| value.maxburst).unwrap_or(0),
|
||||||
|
configured_budget_bytes_per_sec: env_u32(
|
||||||
|
"LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC",
|
||||||
|
DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||||
|
),
|
||||||
|
isochronous_limit_pct: env_u32(
|
||||||
|
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
|
||||||
|
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
|
||||||
|
),
|
||||||
|
advertised_frame_bytes: snapshot
|
||||||
|
.map(|value| value.frame_size)
|
||||||
|
.unwrap_or(self.cfg.frame_size),
|
||||||
|
committed_frame_bytes: read_le32(&self.commit, 18),
|
||||||
|
committed_payload_bytes: read_le32(&self.commit, 22),
|
||||||
|
requested_frame_cap_bytes: env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_payload_contract(&self, reason: &str) {
|
||||||
|
let contract = self.payload_contract();
|
||||||
|
let path = uvc_contract::contract_path();
|
||||||
|
if let Err(err) = uvc_contract::write_contract(&path, &contract) {
|
||||||
|
eprintln!(
|
||||||
|
"[lesavka-uvc] failed to publish UVC payload contract {}: {err:#}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
eprintln!(
|
||||||
|
"[lesavka-uvc] payload contract reason={} transport={} frame_cap={} advertised={} committed={} payload={} divergent={} path={}",
|
||||||
|
reason,
|
||||||
|
contract.transport,
|
||||||
|
contract.enforced_frame_cap_bytes,
|
||||||
|
contract.advertised_frame_bytes,
|
||||||
|
contract.committed_frame_bytes,
|
||||||
|
contract.committed_payload_bytes,
|
||||||
|
contract.divergent,
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1456,6 +1717,7 @@ fn handle_data(
|
|||||||
let sanitized = sanitize_streaming_control(slice, state);
|
let sanitized = sanitize_streaming_control(slice, state);
|
||||||
if p.selector == UVC_VS_PROBE_CONTROL {
|
if p.selector == UVC_VS_PROBE_CONTROL {
|
||||||
state.probe = sanitized;
|
state.probe = sanitized;
|
||||||
|
state.publish_payload_contract("probe");
|
||||||
if debug {
|
if debug {
|
||||||
let interval = read_le32(&state.probe, 4);
|
let interval = read_le32(&state.probe, 4);
|
||||||
let payload = read_le32(&state.probe, 22);
|
let payload = read_le32(&state.probe, 22);
|
||||||
@ -1467,6 +1729,8 @@ fn handle_data(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.commit = sanitized;
|
state.commit = sanitized;
|
||||||
|
state.commit_observed = true;
|
||||||
|
state.publish_payload_contract("commit");
|
||||||
if debug {
|
if debug {
|
||||||
let interval = read_le32(&state.commit, 4);
|
let interval = read_le32(&state.commit, 4);
|
||||||
let payload = read_le32(&state.commit, 22);
|
let payload = read_le32(&state.commit, 22);
|
||||||
@ -1716,15 +1980,20 @@ fn read_configfs_snapshot() -> Option<ConfigfsSnapshot> {
|
|||||||
"{frame_root}/dwDefaultFrameInterval"
|
"{frame_root}/dwDefaultFrameInterval"
|
||||||
))?;
|
))?;
|
||||||
let frame_interval = read_u32_first(&format!("{frame_root}/dwFrameInterval")).unwrap_or(0);
|
let frame_interval = read_u32_first(&format!("{frame_root}/dwFrameInterval")).unwrap_or(0);
|
||||||
|
let frame_size = read_u32_file(&format!("{frame_root}/dwMaxVideoFrameBufferSize"))?;
|
||||||
let maxpacket = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket"))?;
|
let maxpacket = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket"))?;
|
||||||
let maxburst = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxburst")).unwrap_or(0);
|
let maxburst = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxburst")).unwrap_or(0);
|
||||||
|
let streaming_bulk =
|
||||||
|
uvc_contract::read_streaming_bulk_value(std::path::Path::new(CONFIGFS_UVC_BASE));
|
||||||
Some(ConfigfsSnapshot {
|
Some(ConfigfsSnapshot {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
default_interval,
|
default_interval,
|
||||||
frame_interval,
|
frame_interval,
|
||||||
|
frame_size,
|
||||||
maxpacket,
|
maxpacket,
|
||||||
maxburst,
|
maxburst,
|
||||||
|
streaming_bulk,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1747,13 +2016,15 @@ fn log_configfs_snapshot(state: &mut UvcState, label: &str) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[lesavka-uvc] configfs {label}: {}x{} default_interval={} frame_interval={} maxpacket={} maxburst={}",
|
"[lesavka-uvc] configfs {label}: {}x{} default_interval={} frame_interval={} frame_size={} maxpacket={} maxburst={} streaming_bulk={:?}",
|
||||||
current.width,
|
current.width,
|
||||||
current.height,
|
current.height,
|
||||||
current.default_interval,
|
current.default_interval,
|
||||||
current.frame_interval,
|
current.frame_interval,
|
||||||
|
current.frame_size,
|
||||||
current.maxpacket,
|
current.maxpacket,
|
||||||
current.maxburst
|
current.maxburst,
|
||||||
|
current.streaming_bulk
|
||||||
);
|
);
|
||||||
state.cfg_snapshot = Some(current);
|
state.cfg_snapshot = Some(current);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,9 @@ fn synthetic_luma(
|
|||||||
let (moving_width, moving_offset) = moving_bar;
|
let (moving_width, moving_offset) = moving_bar;
|
||||||
let width = width.max(1);
|
let width = width.max(1);
|
||||||
let height = height.max(1);
|
let height = height.max(1);
|
||||||
|
if let Some(value) = synthetic_band_marker_luma(width, height, x, y, sequence) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
let block_w = (width / 24).max(24);
|
let block_w = (width / 24).max(24);
|
||||||
let block_h = (height / 18).max(18);
|
let block_h = (height / 18).max(18);
|
||||||
let base = 44
|
let base = 44
|
||||||
@ -107,6 +110,36 @@ fn synthetic_luma(
|
|||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn synthetic_band_marker_luma(
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
x: usize,
|
||||||
|
y: usize,
|
||||||
|
sequence: u64,
|
||||||
|
) -> Option<u8> {
|
||||||
|
let marker_width = BAND_MARKER_BITS * BAND_MARKER_CELL;
|
||||||
|
if width < marker_width + 8 || height < BAND_MARKER_ROWS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let band = y / BAND_MARKER_ROWS;
|
||||||
|
let band_y = band * BAND_MARKER_ROWS + 4;
|
||||||
|
if !(band_y..band_y + BAND_MARKER_CELL * 2).contains(&y) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let x0 = width - marker_width - 4;
|
||||||
|
if !(x0..x0 + marker_width).contains(&x) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bit = (x - x0) / BAND_MARKER_CELL;
|
||||||
|
let marker = (sequence as u32 & 0x00ff_ffff) | ((band as u32 & 0xff) << 24);
|
||||||
|
Some(if marker & (1 << bit) != 0 { 245 } else { 12 })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_jpeg_app4_integrity(jpeg: &[u8], sequence: u64) -> Result<Vec<u8>> {
|
||||||
|
lesavka_server::uvc_contract::add_lesavka_app4_integrity(jpeg, sequence)
|
||||||
|
.context("synthetic encoder returned an incomplete JPEG")
|
||||||
|
}
|
||||||
|
|
||||||
fn marker_cell(width: usize, height: usize) -> usize {
|
fn marker_cell(width: usize, height: usize) -> usize {
|
||||||
(width.min(height) / 80).clamp(6, 16)
|
(width.min(height) / 80).clamp(6, 16)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,27 @@ fn synthetic_frame_helpers_handle_markers_bounds_and_silence() {
|
|||||||
fill_rect(&mut frame, 6, 4, 2, 8, 8, 77);
|
fill_rect(&mut frame, 6, 4, 2, 8, 8, 77);
|
||||||
assert_eq!(&frame[(2 * 6 + 4) * 3..(2 * 6 + 5) * 3], &[77, 77, 77]);
|
assert_eq!(&frame[(2 * 6 + 4) * 3..(2 * 6 + 5) * 3], &[77, 77, 77]);
|
||||||
assert_eq!(&frame[(3 * 6 + 5) * 3..(3 * 6 + 6) * 3], &[77, 77, 77]);
|
assert_eq!(&frame[(3 * 6 + 5) * 3..(3 * 6 + 6) * 3], &[77, 77, 77]);
|
||||||
|
|
||||||
|
let first_band = synthetic_band_marker_luma(1280, 720, 1244, 4, 0x55aa);
|
||||||
|
let second_band = synthetic_band_marker_luma(1280, 720, 1244, 68, 0x55aa);
|
||||||
|
assert!(first_band.is_some());
|
||||||
|
assert_ne!(first_band, second_band);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app4_integrity_marker_carries_sequence_and_original_jpeg_crc() {
|
||||||
|
let jpeg = [0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0x11, 0xff, 0xd9];
|
||||||
|
let marked = add_jpeg_app4_integrity(&jpeg, 0x0102_0304_0506_0708).expect("APP4 marker");
|
||||||
|
|
||||||
|
assert_eq!(&marked[..4], &[0xff, 0xd8, 0xff, 0xe4]);
|
||||||
|
assert_eq!(&marked[6..10], b"LSVK");
|
||||||
|
assert_eq!(marked[10], 1);
|
||||||
|
assert_eq!(&marked[11..19], &0x0102_0304_0506_0708u64.to_be_bytes());
|
||||||
|
assert_eq!(
|
||||||
|
&marked[19..23],
|
||||||
|
&lesavka_server::uvc_contract::crc32_ieee(&jpeg).to_be_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(&marked[23..], &jpeg[2..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -100,6 +100,7 @@ fn handle_data(
|
|||||||
state.probe = sanitized;
|
state.probe = sanitized;
|
||||||
} else {
|
} else {
|
||||||
state.commit = sanitized;
|
state.commit = sanitized;
|
||||||
|
state.commit_observed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,7 @@ impl UvcState {
|
|||||||
probe: default,
|
probe: default,
|
||||||
commit: default,
|
commit: default,
|
||||||
cfg_snapshot: None,
|
cfg_snapshot: None,
|
||||||
|
commit_observed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,10 +123,10 @@ fn uvc_bulk_transfer_enabled() -> bool {
|
|||||||
/// Inputs: configfs function root. Output: effective bulk capability. Why:
|
/// Inputs: configfs function root. Output: effective bulk capability. Why:
|
||||||
/// tests need this branch without depending on the host's real gadget tree.
|
/// tests need this branch without depending on the host's real gadget tree.
|
||||||
fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool {
|
fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool {
|
||||||
if base.exists() && !base.join("streaming_bulk").exists() {
|
lesavka_server::uvc_contract::bulk_enabled_from_configfs(
|
||||||
return false;
|
base,
|
||||||
}
|
env_flag_enabled("LESAVKA_UVC_BULK", true),
|
||||||
true
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
@ -136,10 +137,11 @@ fn uvc_frame_size_for_active_mode(
|
|||||||
max_packet: u32,
|
max_packet: u32,
|
||||||
bulk: bool,
|
bulk: bool,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
|
let derived = derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
|
||||||
|
.min(u32::MAX as usize) as u32;
|
||||||
env_u32_opt("LESAVKA_UVC_FRAME_SIZE")
|
env_u32_opt("LESAVKA_UVC_FRAME_SIZE")
|
||||||
.unwrap_or_else(|| {
|
.filter(|value| *value > 0)
|
||||||
derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
|
.unwrap_or(derived)
|
||||||
.min(u32::MAX as usize) as u32
|
.min(derived)
|
||||||
})
|
.max((width.saturating_mul(height) / 32).max(64 * 1024).min(derived))
|
||||||
.max((width.saturating_mul(height) / 32).max(64 * 1024))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,18 +43,19 @@ fn uvc_queue_period(fps: u32) -> Option<std::time::Duration> {
|
|||||||
/// byte length. Why: coverage tests should lock the artifact-prevention budget
|
/// byte length. Why: coverage tests should lock the artifact-prevention budget
|
||||||
/// that turns oversized UVC frames into freezes instead of grey half-frames.
|
/// that turns oversized UVC frames into freezes instead of grey half-frames.
|
||||||
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||||
if !uvc_frame_size_guard_enabled() {
|
if let Ok(contract) = lesavka_server::uvc_contract::read_contract(
|
||||||
return MAX_MJPEG_FRAME_BYTES;
|
&lesavka_server::uvc_contract::contract_path(),
|
||||||
|
) {
|
||||||
|
return contract
|
||||||
|
.enforced_frame_cap_bytes
|
||||||
|
.min(cfg.frame_size)
|
||||||
|
.min(MAX_MJPEG_FRAME_BYTES as u32) as usize;
|
||||||
}
|
}
|
||||||
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") {
|
let derived = derived_uvc_frame_max_bytes(cfg).min(cfg.frame_size as usize);
|
||||||
return if limit == 0 {
|
env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
|
||||||
derived_uvc_frame_max_bytes(cfg)
|
.filter(|limit| *limit > 0)
|
||||||
} else {
|
.map(|limit| derived.min(limit as usize))
|
||||||
(limit as usize).min(MAX_MJPEG_FRAME_BYTES)
|
.unwrap_or(derived)
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
derived_uvc_frame_max_bytes(cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
@ -64,10 +65,8 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
|||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize {
|
fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize {
|
||||||
let fps = fps.max(1);
|
|
||||||
let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk);
|
let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk);
|
||||||
let per_frame = (budget_per_sec / fps).max(64 * 1024);
|
lesavka_server::uvc_contract::frame_cap_from_budget(budget_per_sec, fps) as usize
|
||||||
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
@ -81,31 +80,16 @@ fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32
|
|||||||
DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||||
)
|
)
|
||||||
.max(1);
|
.max(1);
|
||||||
if bulk {
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
configured
|
|
||||||
.min(uvc_isochronous_budget_bytes_per_sec(max_packet))
|
|
||||||
.max(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(coverage)]
|
|
||||||
/// Computes the high-speed isochronous payload budget.
|
|
||||||
///
|
|
||||||
/// Inputs: endpoint packet size and `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT`.
|
|
||||||
/// Output: capped bytes per second. Why: coverage locks the safety margin.
|
|
||||||
fn uvc_isochronous_budget_bytes_per_sec(max_packet: u32) -> u32 {
|
|
||||||
let pct = env_u32(
|
let pct = env_u32(
|
||||||
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
|
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
|
||||||
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
|
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
|
||||||
|
);
|
||||||
|
lesavka_server::uvc_contract::effective_mjpeg_budget_bytes_per_sec(
|
||||||
|
configured,
|
||||||
|
max_packet,
|
||||||
|
bulk,
|
||||||
|
pct,
|
||||||
)
|
)
|
||||||
.clamp(1, 100);
|
|
||||||
let bytes = u64::from(max_packet)
|
|
||||||
.saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC))
|
|
||||||
.saturating_mul(u64::from(pct))
|
|
||||||
/ 100;
|
|
||||||
bytes.min(u64::from(u32::MAX)) as u32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
|
use lesavka_server::uvc_contract;
|
||||||
|
#[cfg(coverage)]
|
||||||
use std::env;
|
use std::env;
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
@ -63,12 +65,14 @@ const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
|
|||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
|
|
||||||
#[cfg(coverage)]
|
|
||||||
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
|
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json";
|
const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json";
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
|
const DEFAULT_UVC_IDLE_AFTER_STALE_MS: u64 = 2_000;
|
||||||
|
#[cfg(coverage)]
|
||||||
|
const DEFAULT_UVC_KERNEL_STATS_PATH: &str = "/run/lesavka-uvc-kernel-stats.json";
|
||||||
|
#[cfg(coverage)]
|
||||||
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
|
const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
|
||||||
@ -130,6 +134,7 @@ struct UvcState {
|
|||||||
probe: [u8; STREAM_CTRL_SIZE_MAX],
|
probe: [u8; STREAM_CTRL_SIZE_MAX],
|
||||||
commit: [u8; STREAM_CTRL_SIZE_MAX],
|
commit: [u8; STREAM_CTRL_SIZE_MAX],
|
||||||
cfg_snapshot: Option<ConfigfsSnapshot>,
|
cfg_snapshot: Option<ConfigfsSnapshot>,
|
||||||
|
commit_observed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
@ -154,8 +159,10 @@ struct ConfigfsSnapshot {
|
|||||||
height: u32,
|
height: u32,
|
||||||
default_interval: u32,
|
default_interval: u32,
|
||||||
frame_interval: u32,
|
frame_interval: u32,
|
||||||
|
frame_size: u32,
|
||||||
maxpacket: u32,
|
maxpacket: u32,
|
||||||
maxburst: u32,
|
maxburst: u32,
|
||||||
|
streaming_bulk: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
@ -175,6 +182,8 @@ struct UvcVideoStream {
|
|||||||
next_queue_at: Option<std::time::Instant>,
|
next_queue_at: Option<std::time::Instant>,
|
||||||
transport_bulk: bool,
|
transport_bulk: bool,
|
||||||
max_packet: u32,
|
max_packet: u32,
|
||||||
|
has_verified_frame: bool,
|
||||||
|
last_verified_mtime: Option<std::time::SystemTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
@ -186,6 +195,12 @@ struct UvcVideoStats {
|
|||||||
rejected_oversize: u64,
|
rejected_oversize: u64,
|
||||||
rejected_invalid: u64,
|
rejected_invalid: u64,
|
||||||
fallback_idle: u64,
|
fallback_idle: u64,
|
||||||
|
held_last_good: u64,
|
||||||
|
read_errors: u64,
|
||||||
|
strict_validation_failures: u64,
|
||||||
|
dqbuf_ioctl_errors: u64,
|
||||||
|
dqbuf_flag_errors: u64,
|
||||||
|
qbuf_ioctl_errors: u64,
|
||||||
latest_bytes: usize,
|
latest_bytes: usize,
|
||||||
last_rejected_oversize_bytes: usize,
|
last_rejected_oversize_bytes: usize,
|
||||||
last_rejected_oversize_cap: usize,
|
last_rejected_oversize_cap: usize,
|
||||||
@ -212,6 +227,8 @@ impl UvcVideoStream {
|
|||||||
next_queue_at: None,
|
next_queue_at: None,
|
||||||
transport_bulk: false,
|
transport_bulk: false,
|
||||||
max_packet: 0,
|
max_packet: 0,
|
||||||
|
has_verified_frame: false,
|
||||||
|
last_verified_mtime: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +241,7 @@ impl UvcVideoStream {
|
|||||||
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
|
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
|
||||||
if stale {
|
if stale {
|
||||||
self.stats.replayed_stale += 1;
|
self.stats.replayed_stale += 1;
|
||||||
self.replace_latest_with_idle();
|
self.hold_last_good_or_idle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let max_frame_bytes = self.frame_payload_limit();
|
let max_frame_bytes = self.frame_payload_limit();
|
||||||
@ -233,6 +250,8 @@ impl UvcVideoStream {
|
|||||||
self.stats.reloaded += 1;
|
self.stats.reloaded += 1;
|
||||||
self.stats.latest_bytes = frame.len();
|
self.stats.latest_bytes = frame.len();
|
||||||
self.latest_frame = frame;
|
self.latest_frame = frame;
|
||||||
|
self.has_verified_frame = true;
|
||||||
|
self.last_verified_mtime = frame_spool_modified(&self.frame_path);
|
||||||
} else {
|
} else {
|
||||||
if frame.len() > max_frame_bytes && looks_like_mjpeg_frame(&frame) {
|
if frame.len() > max_frame_bytes && looks_like_mjpeg_frame(&frame) {
|
||||||
self.stats.rejected_oversize += 1;
|
self.stats.rejected_oversize += 1;
|
||||||
@ -241,16 +260,25 @@ impl UvcVideoStream {
|
|||||||
} else {
|
} else {
|
||||||
self.stats.rejected_invalid += 1;
|
self.stats.rejected_invalid += 1;
|
||||||
}
|
}
|
||||||
self.replace_latest_with_idle();
|
self.hold_last_good_or_idle();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.replace_latest_with_idle();
|
self.stats.read_errors += 1;
|
||||||
|
self.hold_last_good_or_idle();
|
||||||
}
|
}
|
||||||
if !looks_like_mjpeg_frame(&self.latest_frame) {
|
if !looks_like_mjpeg_frame(&self.latest_frame) {
|
||||||
self.replace_latest_with_idle();
|
self.replace_latest_with_idle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hold_last_good_or_idle(&mut self) {
|
||||||
|
if self.has_verified_frame && !verified_frame_idle_timeout_elapsed(self.last_verified_mtime) {
|
||||||
|
self.stats.held_last_good += 1;
|
||||||
|
} else {
|
||||||
|
self.replace_latest_with_idle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn replace_latest_with_idle(&mut self) {
|
fn replace_latest_with_idle(&mut self) {
|
||||||
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
|
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
|
||||||
self.stats.fallback_idle += 1;
|
self.stats.fallback_idle += 1;
|
||||||
|
|||||||
@ -38,23 +38,43 @@ fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
|||||||
/// Inputs: current counters and frame cap. Output: one JSON object string. Why:
|
/// Inputs: current counters and frame cap. Output: one JSON object string. Why:
|
||||||
/// shell probes need stable fields without depending on Rust serialization.
|
/// shell probes need stable fields without depending on Rust serialization.
|
||||||
fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String {
|
fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String {
|
||||||
|
let kernel = uvc_kernel_stats_json();
|
||||||
format!(
|
format!(
|
||||||
"{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n",
|
"{{\"generated_unix_ms\":0,\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"held_last_good\":{},\"read_errors\":{},\"strict_validation_failures\":{},\"dqbuf_ioctl_errors\":{},\"dqbuf_flag_errors\":{},\"qbuf_ioctl_errors\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{},\"kernel\":{}}}\n",
|
||||||
stats.queued,
|
stats.queued,
|
||||||
stats.reloaded,
|
stats.reloaded,
|
||||||
stats.replayed_stale,
|
stats.replayed_stale,
|
||||||
stats.rejected_oversize,
|
stats.rejected_oversize,
|
||||||
stats.rejected_invalid,
|
stats.rejected_invalid,
|
||||||
stats.fallback_idle,
|
stats.fallback_idle,
|
||||||
|
stats.held_last_good,
|
||||||
|
stats.read_errors,
|
||||||
|
stats.strict_validation_failures,
|
||||||
|
stats.dqbuf_ioctl_errors,
|
||||||
|
stats.dqbuf_flag_errors,
|
||||||
|
stats.qbuf_ioctl_errors,
|
||||||
stats.latest_bytes,
|
stats.latest_bytes,
|
||||||
frame_cap,
|
frame_cap,
|
||||||
stats.last_rejected_oversize_bytes,
|
stats.last_rejected_oversize_bytes,
|
||||||
stats.last_rejected_oversize_cap,
|
stats.last_rejected_oversize_cap,
|
||||||
stats.paced_sleeps,
|
stats.paced_sleeps,
|
||||||
stats.paced_sleep_ms
|
stats.paced_sleep_ms,
|
||||||
|
kernel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
fn uvc_kernel_stats_json() -> String {
|
||||||
|
let path = std::env::var("LESAVKA_UVC_KERNEL_STATS_PATH")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from(DEFAULT_UVC_KERNEL_STATS_PATH));
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_else(|| "null".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
/// Returns the optional periodic stats write interval.
|
/// Returns the optional periodic stats write interval.
|
||||||
///
|
///
|
||||||
@ -104,3 +124,26 @@ fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<std::time::Durat
|
|||||||
.map(|age| age > max_age)
|
.map(|age| age > max_age)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
fn frame_spool_modified(path: &std::path::Path) -> Option<std::time::SystemTime> {
|
||||||
|
std::fs::metadata(path).ok()?.modified().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(coverage)]
|
||||||
|
fn verified_frame_idle_timeout_elapsed(modified: Option<std::time::SystemTime>) -> bool {
|
||||||
|
let Some(modified) = modified else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(modified)
|
||||||
|
.map(|age| {
|
||||||
|
age >= std::time::Duration::from_millis(
|
||||||
|
std::env::var("LESAVKA_UVC_IDLE_AFTER_MS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.unwrap_or(DEFAULT_UVC_IDLE_AFTER_STALE_MS),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|||||||
@ -91,7 +91,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
|
|||||||
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
||||||
],
|
],
|
||||||
|| {
|
|| {
|
||||||
assert_eq!(uvc_frame_max_bytes(sample_cfg()), 123_456);
|
assert_eq!(uvc_frame_max_bytes(sample_cfg()), 65_536);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
|
|||||||
("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")),
|
("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")),
|
||||||
],
|
],
|
||||||
|| {
|
|| {
|
||||||
assert_eq!(uvc_frame_max_bytes(sample_cfg()), MAX_MJPEG_FRAME_BYTES);
|
assert_eq!(uvc_frame_max_bytes(sample_cfg()), 123_456);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -159,7 +159,9 @@ fn uvc_env_flags_and_bulk_configfs_edges_are_covered() {
|
|||||||
|
|
||||||
let configfs = tempfile::tempdir().expect("tempdir");
|
let configfs = tempfile::tempdir().expect("tempdir");
|
||||||
assert!(!uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
assert!(!uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
||||||
fs::create_dir(configfs.path().join("streaming_bulk")).expect("streaming_bulk dir");
|
fs::write(configfs.path().join("streaming_bulk"), "0\n").expect("streaming_bulk zero");
|
||||||
|
assert!(!uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
||||||
|
fs::write(configfs.path().join("streaming_bulk"), "1\n").expect("streaming_bulk one");
|
||||||
assert!(uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
assert!(uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,6 +245,50 @@ fn uvc_video_stream_refresh_covers_fresh_invalid_and_missing_frames() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uvc_video_stream_holds_last_good_frame_during_transient_rejection() {
|
||||||
|
let frame = NamedTempFile::new().expect("frame");
|
||||||
|
let good = vec![0xff, 0xd8, 1, 2, 3, 0xff, 0xd9];
|
||||||
|
fs::write(frame.path(), &good).expect("write good frame");
|
||||||
|
let mut stream = UvcVideoStream::new(-1);
|
||||||
|
stream.frame_path = frame.path().to_path_buf();
|
||||||
|
stream.frame_max_bytes = 8;
|
||||||
|
stream.refresh_latest_frame();
|
||||||
|
assert_eq!(stream.latest_frame, good);
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
frame.path(),
|
||||||
|
[0xff, 0xd8, 1, 2, 3, 4, 5, 6, 7, 8, 0xff, 0xd9],
|
||||||
|
)
|
||||||
|
.expect("write oversized frame");
|
||||||
|
stream.refresh_latest_frame();
|
||||||
|
|
||||||
|
assert_eq!(stream.latest_frame, good);
|
||||||
|
assert_eq!(stream.stats.rejected_oversize, 1);
|
||||||
|
assert_eq!(stream.stats.held_last_good, 1);
|
||||||
|
assert_eq!(stream.stats.fallback_idle, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uvc_video_stream_uses_idle_only_after_last_good_timeout() {
|
||||||
|
let frame = NamedTempFile::new().expect("frame");
|
||||||
|
let good = vec![0xff, 0xd8, 1, 2, 3, 0xff, 0xd9];
|
||||||
|
fs::write(frame.path(), &good).expect("write good frame");
|
||||||
|
let mut stream = UvcVideoStream::new(-1);
|
||||||
|
stream.frame_path = frame.path().to_path_buf();
|
||||||
|
stream.refresh_latest_frame();
|
||||||
|
assert_eq!(stream.latest_frame, good);
|
||||||
|
|
||||||
|
stream.last_verified_mtime = Some(std::time::SystemTime::UNIX_EPOCH);
|
||||||
|
fs::write(frame.path(), b"truncated").expect("write invalid frame");
|
||||||
|
stream.refresh_latest_frame();
|
||||||
|
|
||||||
|
assert_eq!(stream.stats.rejected_invalid, 1);
|
||||||
|
assert_eq!(stream.stats.held_last_good, 0);
|
||||||
|
assert_eq!(stream.stats.fallback_idle, 1);
|
||||||
|
assert_eq!(stream.latest_frame, IDLE_MJPEG_FRAME);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn main_coverage_mode_returns_error_for_non_uvc_node() {
|
fn main_coverage_mode_returns_error_for_non_uvc_node() {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ pub mod paste;
|
|||||||
pub mod runtime_support;
|
pub mod runtime_support;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod upstream_media_runtime;
|
pub mod upstream_media_runtime;
|
||||||
|
pub mod uvc_contract;
|
||||||
pub mod uvc_runtime;
|
pub mod uvc_runtime;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
pub(crate) mod video_sinks;
|
pub(crate) mod video_sinks;
|
||||||
|
|||||||
@ -39,7 +39,7 @@ use lesavka_server::{
|
|||||||
PlannedUpstreamPacket, UpstreamClientTiming, UpstreamMediaKind, UpstreamMediaRuntime,
|
PlannedUpstreamPacket, UpstreamClientTiming, UpstreamMediaKind, UpstreamMediaRuntime,
|
||||||
UpstreamPlanDecision,
|
UpstreamPlanDecision,
|
||||||
},
|
},
|
||||||
uvc_runtime, video,
|
uvc_contract, uvc_runtime, video,
|
||||||
};
|
};
|
||||||
|
|
||||||
/*──────────────── constants ────────────────*/
|
/*──────────────── constants ────────────────*/
|
||||||
|
|||||||
@ -1,3 +1,134 @@
|
|||||||
|
#[derive(Default)]
|
||||||
|
struct UvcIntegritySnapshot {
|
||||||
|
status: String,
|
||||||
|
detail: String,
|
||||||
|
rejected_oversize: u64,
|
||||||
|
rejected_invalid: u64,
|
||||||
|
fallback_idle: u64,
|
||||||
|
held_last_good: u64,
|
||||||
|
strict_validation_failures: u64,
|
||||||
|
dqbuf_errors: u64,
|
||||||
|
qbuf_errors: u64,
|
||||||
|
kernel_errors: u64,
|
||||||
|
stale_replay: u64,
|
||||||
|
read_errors: u64,
|
||||||
|
generated_unix_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_uvc_integrity_snapshot() -> UvcIntegritySnapshot {
|
||||||
|
let path = std::env::var("LESAVKA_UVC_STATS_PATH")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("/run/lesavka-uvc-video-stats.json"));
|
||||||
|
match std::fs::read(&path)
|
||||||
|
.map_err(anyhow::Error::from)
|
||||||
|
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).map_err(Into::into))
|
||||||
|
{
|
||||||
|
Ok(value) => parse_uvc_integrity_snapshot(&value),
|
||||||
|
Err(err) => {
|
||||||
|
UvcIntegritySnapshot {
|
||||||
|
status: "unavailable".to_string(),
|
||||||
|
detail: format!("{}: {err}", path.display()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uvc_integrity_snapshot(value: &serde_json::Value) -> UvcIntegritySnapshot {
|
||||||
|
let number = |name: &str| value.get(name).and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||||
|
let kernel_number = |name: &str| {
|
||||||
|
value
|
||||||
|
.pointer(&format!("/kernel/counters/{name}"))
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
let rejected_oversize = number("rejected_oversize");
|
||||||
|
let rejected_invalid = number("rejected_invalid");
|
||||||
|
let fallback_idle = number("fallback_idle");
|
||||||
|
let held_last_good = number("held_last_good");
|
||||||
|
let strict_validation_failures = number("strict_validation_failures");
|
||||||
|
let dqbuf_errors = number("dqbuf_ioctl_errors").saturating_add(number("dqbuf_flag_errors"));
|
||||||
|
let qbuf_errors = number("qbuf_ioctl_errors");
|
||||||
|
let stale_replay = number("stale_replay");
|
||||||
|
let read_errors = number("read_errors");
|
||||||
|
let kernel_errors = [
|
||||||
|
"dwc2_errors",
|
||||||
|
"uvc_errors",
|
||||||
|
"udc_errors",
|
||||||
|
"urb_errors",
|
||||||
|
"usb_resets",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(kernel_number)
|
||||||
|
.sum();
|
||||||
|
let hard_faults = rejected_oversize
|
||||||
|
.saturating_add(rejected_invalid)
|
||||||
|
.saturating_add(fallback_idle)
|
||||||
|
.saturating_add(strict_validation_failures)
|
||||||
|
.saturating_add(dqbuf_errors)
|
||||||
|
.saturating_add(qbuf_errors)
|
||||||
|
.saturating_add(kernel_errors)
|
||||||
|
.saturating_add(read_errors);
|
||||||
|
let status = if hard_faults > 0 {
|
||||||
|
"fault"
|
||||||
|
} else if held_last_good > 0 || stale_replay > 0 {
|
||||||
|
"holding"
|
||||||
|
} else {
|
||||||
|
"ready"
|
||||||
|
};
|
||||||
|
UvcIntegritySnapshot {
|
||||||
|
status: status.to_string(),
|
||||||
|
detail: format!(
|
||||||
|
"oversize={rejected_oversize} invalid={rejected_invalid} idle={fallback_idle} held={held_last_good} stale={stale_replay} strict={strict_validation_failures} dq={dqbuf_errors} q={qbuf_errors} read={read_errors} kernel={kernel_errors}"
|
||||||
|
),
|
||||||
|
rejected_oversize,
|
||||||
|
rejected_invalid,
|
||||||
|
fallback_idle,
|
||||||
|
held_last_good,
|
||||||
|
strict_validation_failures,
|
||||||
|
dqbuf_errors,
|
||||||
|
qbuf_errors,
|
||||||
|
kernel_errors,
|
||||||
|
stale_replay,
|
||||||
|
read_errors,
|
||||||
|
generated_unix_ms: value
|
||||||
|
.get("generated_unix_ms")
|
||||||
|
.and_then(serde_json::Value::as_u64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod uvc_integrity_snapshot_tests {
|
||||||
|
use super::parse_uvc_integrity_snapshot;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn helper_and_kernel_faults_drive_integrity_status() {
|
||||||
|
let ready = parse_uvc_integrity_snapshot(&serde_json::json!({
|
||||||
|
"generated_unix_ms": 123,
|
||||||
|
"held_last_good": 0,
|
||||||
|
"kernel": {"counters": {"dwc2_errors": 0}}
|
||||||
|
}));
|
||||||
|
assert_eq!(ready.status, "ready");
|
||||||
|
assert_eq!(ready.generated_unix_ms, Some(123));
|
||||||
|
|
||||||
|
let holding = parse_uvc_integrity_snapshot(&serde_json::json!({
|
||||||
|
"held_last_good": 2,
|
||||||
|
"stale_replay": 1
|
||||||
|
}));
|
||||||
|
assert_eq!(holding.status, "holding");
|
||||||
|
|
||||||
|
let fault = parse_uvc_integrity_snapshot(&serde_json::json!({
|
||||||
|
"rejected_oversize": 1,
|
||||||
|
"dqbuf_flag_errors": 2,
|
||||||
|
"kernel": {"counters": {"dwc2_errors": 3, "usb_resets": 4}}
|
||||||
|
}));
|
||||||
|
assert_eq!(fault.status, "fault");
|
||||||
|
assert_eq!(fault.dqbuf_errors, 2);
|
||||||
|
assert_eq!(fault.kernel_errors, 7);
|
||||||
|
assert!(fault.detail.contains("oversize=1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Handler {
|
impl Handler {
|
||||||
/// Reopen HID handles and verify enumeration without cycling the USB gadget.
|
/// Reopen HID handles and verify enumeration without cycling the USB gadget.
|
||||||
async fn recover_usb_reply(&self) -> Result<Response<ResetUsbReply>, Status> {
|
async fn recover_usb_reply(&self) -> Result<Response<ResetUsbReply>, Status> {
|
||||||
@ -175,6 +306,53 @@ impl Handler {
|
|||||||
/// Inputs are the typed parameters; output is the return value or side effect.
|
/// Inputs are the typed parameters; output is the return value or side effect.
|
||||||
async fn get_upstream_sync_reply(&self) -> Result<Response<UpstreamSyncState>, Status> {
|
async fn get_upstream_sync_reply(&self) -> Result<Response<UpstreamSyncState>, Status> {
|
||||||
let snapshot = self.upstream_media_rt.snapshot();
|
let snapshot = self.upstream_media_rt.snapshot();
|
||||||
|
let contract_path = uvc_contract::contract_path();
|
||||||
|
let contract = uvc_contract::read_contract(&contract_path);
|
||||||
|
let (
|
||||||
|
uvc_contract_status,
|
||||||
|
uvc_transport,
|
||||||
|
uvc_enforced_frame_cap_bytes,
|
||||||
|
uvc_advertised_frame_bytes,
|
||||||
|
uvc_committed_frame_bytes,
|
||||||
|
uvc_committed_payload_bytes,
|
||||||
|
uvc_contract_divergent,
|
||||||
|
uvc_contract_detail,
|
||||||
|
uvc_contract_generated_unix_ms,
|
||||||
|
) = match contract {
|
||||||
|
Ok(contract) => (
|
||||||
|
if !contract.configfs_available {
|
||||||
|
"missing_configfs"
|
||||||
|
} else if !contract.commit_observed {
|
||||||
|
"unverified"
|
||||||
|
} else if contract.divergent {
|
||||||
|
"divergent"
|
||||||
|
} else {
|
||||||
|
"ready"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
contract.transport,
|
||||||
|
Some(contract.enforced_frame_cap_bytes),
|
||||||
|
Some(contract.advertised_frame_bytes),
|
||||||
|
Some(contract.committed_frame_bytes),
|
||||||
|
Some(contract.committed_payload_bytes),
|
||||||
|
contract.divergent,
|
||||||
|
contract.detail,
|
||||||
|
Some(contract.generated_unix_ms),
|
||||||
|
),
|
||||||
|
Err(err) => (
|
||||||
|
"missing_or_invalid".to_string(),
|
||||||
|
"unknown".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
format!("{}: {err}", contract_path.display()),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let uvc_contract_read_stats = uvc_contract::contract_read_stats();
|
||||||
|
let uvc_integrity = read_uvc_integrity_snapshot();
|
||||||
Ok(Response::new(UpstreamSyncState {
|
Ok(Response::new(UpstreamSyncState {
|
||||||
session_id: snapshot.session_id,
|
session_id: snapshot.session_id,
|
||||||
phase: snapshot.phase.to_string(),
|
phase: snapshot.phase.to_string(),
|
||||||
@ -237,6 +415,30 @@ impl Handler {
|
|||||||
.map(|value| value as f32),
|
.map(|value| value as f32),
|
||||||
client_timing_window_samples: snapshot.client_timing_window_samples,
|
client_timing_window_samples: snapshot.client_timing_window_samples,
|
||||||
sink_handoff_window_samples: snapshot.sink_handoff_window_samples,
|
sink_handoff_window_samples: snapshot.sink_handoff_window_samples,
|
||||||
|
uvc_contract_status,
|
||||||
|
uvc_transport,
|
||||||
|
uvc_enforced_frame_cap_bytes,
|
||||||
|
uvc_advertised_frame_bytes,
|
||||||
|
uvc_committed_frame_bytes,
|
||||||
|
uvc_committed_payload_bytes,
|
||||||
|
uvc_contract_divergent,
|
||||||
|
uvc_contract_detail,
|
||||||
|
uvc_contract_generated_unix_ms,
|
||||||
|
uvc_contract_missing_reads: uvc_contract_read_stats.missing_or_invalid,
|
||||||
|
uvc_contract_divergent_reads: uvc_contract_read_stats.divergent,
|
||||||
|
uvc_integrity_status: uvc_integrity.status,
|
||||||
|
uvc_integrity_detail: uvc_integrity.detail,
|
||||||
|
uvc_rejected_oversize: uvc_integrity.rejected_oversize,
|
||||||
|
uvc_rejected_invalid: uvc_integrity.rejected_invalid,
|
||||||
|
uvc_fallback_idle: uvc_integrity.fallback_idle,
|
||||||
|
uvc_held_last_good: uvc_integrity.held_last_good,
|
||||||
|
uvc_strict_validation_failures: uvc_integrity.strict_validation_failures,
|
||||||
|
uvc_dqbuf_errors: uvc_integrity.dqbuf_errors,
|
||||||
|
uvc_qbuf_errors: uvc_integrity.qbuf_errors,
|
||||||
|
uvc_kernel_errors: uvc_integrity.kernel_errors,
|
||||||
|
uvc_integrity_generated_unix_ms: uvc_integrity.generated_unix_ms,
|
||||||
|
uvc_stale_replay: uvc_integrity.stale_replay,
|
||||||
|
uvc_read_errors: uvc_integrity.read_errors,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
566
server/src/uvc_contract.rs
Normal file
566
server/src/uvc_contract.rs
Normal file
@ -0,0 +1,566 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub const UVC_CONTRACT_SCHEMA: &str = "lesavka.uvc-payload-contract.v1";
|
||||||
|
pub const DEFAULT_UVC_CONTRACT_PATH: &str = "/run/lesavka-uvc-contract.json";
|
||||||
|
pub const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
|
||||||
|
pub const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
||||||
|
pub const MAX_MJPEG_FRAME_BYTES: u32 = 8 * 1024 * 1024;
|
||||||
|
pub const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
|
||||||
|
const LESAVKA_APP4_MAGIC: &[u8; 4] = b"LSVK";
|
||||||
|
static CONTRACT_MISSING_READS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static CONTRACT_DIVERGENT_READS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct UvcContractReadStats {
|
||||||
|
pub missing_or_invalid: u64,
|
||||||
|
pub divergent: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UvcPayloadContract {
|
||||||
|
pub schema: String,
|
||||||
|
pub generated_unix_ms: u64,
|
||||||
|
pub configfs_available: bool,
|
||||||
|
pub commit_observed: bool,
|
||||||
|
pub transport: String,
|
||||||
|
pub transport_source: String,
|
||||||
|
pub streaming_bulk_value: Option<u32>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fps: u32,
|
||||||
|
pub frame_interval_100ns: u32,
|
||||||
|
pub streaming_maxpacket: u32,
|
||||||
|
pub streaming_maxburst: u32,
|
||||||
|
pub configured_budget_bytes_per_sec: u32,
|
||||||
|
pub transport_budget_bytes_per_sec: u32,
|
||||||
|
pub advertised_frame_bytes: u32,
|
||||||
|
pub committed_frame_bytes: u32,
|
||||||
|
pub committed_payload_bytes: u32,
|
||||||
|
pub requested_frame_cap_bytes: Option<u32>,
|
||||||
|
pub enforced_frame_cap_bytes: u32,
|
||||||
|
pub divergent: bool,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct UvcContractInput {
|
||||||
|
pub configfs_available: bool,
|
||||||
|
pub commit_observed: bool,
|
||||||
|
pub bulk: bool,
|
||||||
|
pub streaming_bulk_value: Option<u32>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fps: u32,
|
||||||
|
pub frame_interval_100ns: u32,
|
||||||
|
pub streaming_maxpacket: u32,
|
||||||
|
pub streaming_maxburst: u32,
|
||||||
|
pub configured_budget_bytes_per_sec: u32,
|
||||||
|
pub isochronous_limit_pct: u32,
|
||||||
|
pub advertised_frame_bytes: u32,
|
||||||
|
pub committed_frame_bytes: u32,
|
||||||
|
pub committed_payload_bytes: u32,
|
||||||
|
pub requested_frame_cap_bytes: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UvcPayloadContract {
|
||||||
|
pub fn from_input(input: UvcContractInput) -> Self {
|
||||||
|
let fps = input.fps.max(1);
|
||||||
|
let maxpacket = input.streaming_maxpacket.max(1);
|
||||||
|
let configured_budget = input.configured_budget_bytes_per_sec.max(1);
|
||||||
|
let transport_budget = effective_mjpeg_budget_bytes_per_sec(
|
||||||
|
configured_budget,
|
||||||
|
maxpacket,
|
||||||
|
input.bulk,
|
||||||
|
input.isochronous_limit_pct,
|
||||||
|
);
|
||||||
|
let derived_frame_cap = frame_cap_from_budget(transport_budget, fps);
|
||||||
|
let advertised = nonzero_or(input.advertised_frame_bytes, derived_frame_cap);
|
||||||
|
let committed = nonzero_or(input.committed_frame_bytes, advertised);
|
||||||
|
let requested = input.requested_frame_cap_bytes.filter(|value| *value > 0);
|
||||||
|
let enforced = requested
|
||||||
|
.unwrap_or(derived_frame_cap)
|
||||||
|
.min(derived_frame_cap)
|
||||||
|
.min(advertised)
|
||||||
|
.min(committed)
|
||||||
|
.min(MAX_MJPEG_FRAME_BYTES)
|
||||||
|
.max(1);
|
||||||
|
let bulk_value_matches = if input.bulk {
|
||||||
|
input.streaming_bulk_value == Some(1)
|
||||||
|
} else {
|
||||||
|
input.streaming_bulk_value != Some(1)
|
||||||
|
};
|
||||||
|
let divergent = !input.configfs_available
|
||||||
|
|| !input.commit_observed
|
||||||
|
|| !bulk_value_matches
|
||||||
|
|| input.advertised_frame_bytes == 0
|
||||||
|
|| input.committed_frame_bytes == 0
|
||||||
|
|| input.committed_payload_bytes == 0
|
||||||
|
|| input.committed_payload_bytes > maxpacket;
|
||||||
|
let detail = if divergent {
|
||||||
|
format!(
|
||||||
|
"live UVC state differs: configfs_available={} commit_observed={} bulk_value={:?} advertised={} committed_frame={} committed_payload={} maxpacket={}",
|
||||||
|
input.configfs_available,
|
||||||
|
input.commit_observed,
|
||||||
|
input.streaming_bulk_value,
|
||||||
|
input.advertised_frame_bytes,
|
||||||
|
input.committed_frame_bytes,
|
||||||
|
input.committed_payload_bytes,
|
||||||
|
maxpacket
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"live configfs and committed UVC controls agree".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
schema: UVC_CONTRACT_SCHEMA.to_string(),
|
||||||
|
generated_unix_ms: unix_time_ms(),
|
||||||
|
configfs_available: input.configfs_available,
|
||||||
|
commit_observed: input.commit_observed,
|
||||||
|
transport: if input.bulk { "bulk" } else { "isochronous" }.to_string(),
|
||||||
|
transport_source: "configfs.streaming_bulk.value".to_string(),
|
||||||
|
streaming_bulk_value: input.streaming_bulk_value,
|
||||||
|
width: input.width,
|
||||||
|
height: input.height,
|
||||||
|
fps,
|
||||||
|
frame_interval_100ns: input.frame_interval_100ns,
|
||||||
|
streaming_maxpacket: maxpacket,
|
||||||
|
streaming_maxburst: input.streaming_maxburst,
|
||||||
|
configured_budget_bytes_per_sec: configured_budget,
|
||||||
|
transport_budget_bytes_per_sec: transport_budget,
|
||||||
|
advertised_frame_bytes: advertised,
|
||||||
|
committed_frame_bytes: committed,
|
||||||
|
committed_payload_bytes: input.committed_payload_bytes,
|
||||||
|
requested_frame_cap_bytes: requested,
|
||||||
|
enforced_frame_cap_bytes: enforced,
|
||||||
|
divergent,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_usable(&self) -> bool {
|
||||||
|
self.schema == UVC_CONTRACT_SCHEMA
|
||||||
|
&& self.enforced_frame_cap_bytes > 0
|
||||||
|
&& self.enforced_frame_cap_bytes <= MAX_MJPEG_FRAME_BYTES
|
||||||
|
&& self.enforced_frame_cap_bytes <= self.advertised_frame_bytes
|
||||||
|
&& self.enforced_frame_cap_bytes <= self.committed_frame_bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contract_path() -> PathBuf {
|
||||||
|
std::env::var("LESAVKA_UVC_CONTRACT_PATH")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from(DEFAULT_UVC_CONTRACT_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_contract(path: &Path) -> anyhow::Result<UvcPayloadContract> {
|
||||||
|
let bytes = std::fs::read(path)?;
|
||||||
|
let contract: UvcPayloadContract = serde_json::from_slice(&bytes)?;
|
||||||
|
anyhow::ensure!(contract.is_usable(), "unusable UVC payload contract");
|
||||||
|
Ok(contract)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_missing_contract_read() -> u64 {
|
||||||
|
CONTRACT_MISSING_READS.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_divergent_contract_read() -> u64 {
|
||||||
|
CONTRACT_DIVERGENT_READS.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contract_read_stats() -> UvcContractReadStats {
|
||||||
|
UvcContractReadStats {
|
||||||
|
missing_or_invalid: CONTRACT_MISSING_READS.load(Ordering::Relaxed),
|
||||||
|
divergent: CONTRACT_DIVERGENT_READS.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_contract(path: &Path, contract: &UvcPayloadContract) -> anyhow::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
|
||||||
|
let mut bytes = serde_json::to_vec_pretty(contract)?;
|
||||||
|
bytes.push(b'\n');
|
||||||
|
std::fs::write(&tmp, bytes)?;
|
||||||
|
std::fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_streaming_bulk_value(base: &Path) -> Option<u32> {
|
||||||
|
std::fs::read_to_string(base.join("streaming_bulk"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bulk_enabled_from_configfs(base: &Path, requested: bool) -> bool {
|
||||||
|
requested && read_streaming_bulk_value(base) == Some(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn isochronous_budget_bytes_per_sec(maxpacket: u32, pct: u32) -> u32 {
|
||||||
|
let bytes = u64::from(maxpacket.max(1))
|
||||||
|
.saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC))
|
||||||
|
.saturating_mul(u64::from(pct.clamp(1, 100)))
|
||||||
|
/ 100;
|
||||||
|
bytes.min(u64::from(u32::MAX)) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effective_mjpeg_budget_bytes_per_sec(
|
||||||
|
configured: u32,
|
||||||
|
maxpacket: u32,
|
||||||
|
bulk: bool,
|
||||||
|
isochronous_limit_pct: u32,
|
||||||
|
) -> u32 {
|
||||||
|
if bulk {
|
||||||
|
configured.max(1)
|
||||||
|
} else {
|
||||||
|
configured
|
||||||
|
.max(1)
|
||||||
|
.min(isochronous_budget_bytes_per_sec(
|
||||||
|
maxpacket,
|
||||||
|
isochronous_limit_pct,
|
||||||
|
))
|
||||||
|
.max(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame_cap_from_budget(budget_bytes_per_sec: u32, fps: u32) -> u32 {
|
||||||
|
(budget_bytes_per_sec / fps.max(1))
|
||||||
|
.max(1)
|
||||||
|
.min(MAX_MJPEG_FRAME_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clamp_frame_cap(
|
||||||
|
requested: Option<u32>,
|
||||||
|
advertised: u32,
|
||||||
|
budget_bytes_per_sec: u32,
|
||||||
|
fps: u32,
|
||||||
|
) -> u32 {
|
||||||
|
let derived = frame_cap_from_budget(budget_bytes_per_sec, fps);
|
||||||
|
requested
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.unwrap_or(derived)
|
||||||
|
.min(derived)
|
||||||
|
.min(nonzero_or(advertised, derived))
|
||||||
|
.min(MAX_MJPEG_FRAME_BYTES)
|
||||||
|
.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the complete marker and entropy structure of one JPEG frame.
|
||||||
|
///
|
||||||
|
/// This is intentionally stricter than checking SOI/EOI bytes: a truncated
|
||||||
|
/// entropy scan can still have those sentinels after a bad splice.
|
||||||
|
pub fn validate_jpeg_structure(frame: &[u8]) -> bool {
|
||||||
|
if frame.len() < 4 || !frame.starts_with(&[0xff, 0xd8]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut pos = 2usize;
|
||||||
|
let mut saw_scan = false;
|
||||||
|
while pos < frame.len() {
|
||||||
|
if frame[pos] != 0xff {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let marker_start = pos;
|
||||||
|
while pos < frame.len() && frame[pos] == 0xff {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
if pos >= frame.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let marker = frame[pos];
|
||||||
|
pos += 1;
|
||||||
|
match marker {
|
||||||
|
0xd9 => return saw_scan && pos == frame.len(),
|
||||||
|
0xd8 | 0x00 => return false,
|
||||||
|
0x01 | 0xd0..=0xd7 => continue,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if pos + 2 > frame.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let segment_len = usize::from(u16::from_be_bytes([frame[pos], frame[pos + 1]]));
|
||||||
|
if segment_len < 2 || pos + segment_len > frame.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pos += segment_len;
|
||||||
|
if marker != 0xda {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
saw_scan = true;
|
||||||
|
loop {
|
||||||
|
if pos >= frame.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if frame[pos] != 0xff {
|
||||||
|
pos += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entropy_marker_start = pos;
|
||||||
|
while pos < frame.len() && frame[pos] == 0xff {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
if pos >= frame.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match frame[pos] {
|
||||||
|
0x00 => pos += 1,
|
||||||
|
0xd0..=0xd7 => pos += 1,
|
||||||
|
_ => {
|
||||||
|
pos = entropy_marker_start;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug_assert!(pos >= marker_start);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lesavka_app4_sequence(frame: &[u8]) -> Option<u64> {
|
||||||
|
if !frame.starts_with(&[0xff, 0xd8]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut pos = 2usize;
|
||||||
|
while pos + 4 <= frame.len() && frame[pos] == 0xff {
|
||||||
|
while pos < frame.len() && frame[pos] == 0xff {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
let marker = *frame.get(pos)?;
|
||||||
|
pos += 1;
|
||||||
|
if marker == 0xda || marker == 0xd9 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let segment_len = usize::from(u16::from_be_bytes([*frame.get(pos)?, *frame.get(pos + 1)?]));
|
||||||
|
if segment_len < 2 || pos + segment_len > frame.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let payload = &frame[pos + 2..pos + segment_len];
|
||||||
|
if marker == 0xe4 && payload.starts_with(LESAVKA_APP4_MAGIC) && payload.len() >= 17 {
|
||||||
|
return Some(u64::from_be_bytes(payload[5..13].try_into().ok()?));
|
||||||
|
}
|
||||||
|
pos += segment_len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lesavka_app4_integrity_valid(frame: &[u8]) -> Option<bool> {
|
||||||
|
if !frame.starts_with(&[0xff, 0xd8]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut pos = 2usize;
|
||||||
|
while pos + 4 <= frame.len() && frame[pos] == 0xff {
|
||||||
|
let marker_start = pos;
|
||||||
|
while pos < frame.len() && frame[pos] == 0xff {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
let marker = *frame.get(pos)?;
|
||||||
|
pos += 1;
|
||||||
|
if marker == 0xda || marker == 0xd9 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let segment_len = usize::from(u16::from_be_bytes([*frame.get(pos)?, *frame.get(pos + 1)?]));
|
||||||
|
if segment_len < 2 || pos + segment_len > frame.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let segment_end = pos + segment_len;
|
||||||
|
let payload = &frame[pos + 2..segment_end];
|
||||||
|
if marker == 0xe4 && payload.starts_with(LESAVKA_APP4_MAGIC) && payload.len() >= 17 {
|
||||||
|
let expected = u32::from_be_bytes(payload[13..17].try_into().ok()?);
|
||||||
|
let mut original = Vec::with_capacity(frame.len() - (segment_end - marker_start));
|
||||||
|
original.extend_from_slice(&frame[..marker_start]);
|
||||||
|
original.extend_from_slice(&frame[segment_end..]);
|
||||||
|
return Some(crc32_ieee(&original) == expected);
|
||||||
|
}
|
||||||
|
pos = segment_end;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_lesavka_app4_integrity(frame: &[u8], sequence: u64) -> Option<Vec<u8>> {
|
||||||
|
if frame.len() < 4 || !frame.starts_with(&[0xff, 0xd8]) || !frame.ends_with(&[0xff, 0xd9]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut payload = Vec::with_capacity(17);
|
||||||
|
payload.extend_from_slice(LESAVKA_APP4_MAGIC);
|
||||||
|
payload.push(1);
|
||||||
|
payload.extend_from_slice(&sequence.to_be_bytes());
|
||||||
|
payload.extend_from_slice(&crc32_ieee(frame).to_be_bytes());
|
||||||
|
let segment_len = u16::try_from(payload.len() + 2).ok()?;
|
||||||
|
let mut marked = Vec::with_capacity(frame.len() + payload.len() + 4);
|
||||||
|
marked.extend_from_slice(&frame[..2]);
|
||||||
|
marked.extend_from_slice(&[0xff, 0xe4]);
|
||||||
|
marked.extend_from_slice(&segment_len.to_be_bytes());
|
||||||
|
marked.extend_from_slice(&payload);
|
||||||
|
marked.extend_from_slice(&frame[2..]);
|
||||||
|
Some(marked)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn crc32_ieee(data: &[u8]) -> u32 {
|
||||||
|
let mut crc = 0xffff_ffffu32;
|
||||||
|
for byte in data {
|
||||||
|
crc ^= u32::from(*byte);
|
||||||
|
for _ in 0..8 {
|
||||||
|
crc = (crc >> 1) ^ (0xedb8_8320 & 0u32.wrapping_sub(crc & 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!crc
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonzero_or(value: u32, fallback: u32) -> u32 {
|
||||||
|
if value == 0 { fallback } else { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_time_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
.min(u128::from(u64::MAX)) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bulk_requires_live_attribute_value_one() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
assert!(!bulk_enabled_from_configfs(dir.path(), true));
|
||||||
|
std::fs::write(dir.path().join("streaming_bulk"), "0\n").expect("write zero");
|
||||||
|
assert!(!bulk_enabled_from_configfs(dir.path(), true));
|
||||||
|
std::fs::write(dir.path().join("streaming_bulk"), "1\n").expect("write one");
|
||||||
|
assert!(bulk_enabled_from_configfs(dir.path(), true));
|
||||||
|
assert!(!bulk_enabled_from_configfs(dir.path(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_frame_cap_cannot_exceed_wire_or_descriptor_contract() {
|
||||||
|
assert_eq!(
|
||||||
|
clamp_frame_cap(Some(900_000), 200_000, 4_500_000, 30),
|
||||||
|
150_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clamp_frame_cap(Some(100_000), 200_000, 4_500_000, 30),
|
||||||
|
100_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clamp_frame_cap(Some(149_999), 200_000, 4_500_000, 30),
|
||||||
|
149_999
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clamp_frame_cap(Some(150_001), 200_000, 4_500_000, 30),
|
||||||
|
150_000
|
||||||
|
);
|
||||||
|
assert_eq!(clamp_frame_cap(None, 100_000, 30_000, 30), 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contract_round_trips_and_bounds_requested_override() {
|
||||||
|
let contract = UvcPayloadContract::from_input(UvcContractInput {
|
||||||
|
configfs_available: true,
|
||||||
|
commit_observed: true,
|
||||||
|
bulk: false,
|
||||||
|
streaming_bulk_value: Some(0),
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
fps: 30,
|
||||||
|
frame_interval_100ns: 333_333,
|
||||||
|
streaming_maxpacket: 1024,
|
||||||
|
streaming_maxburst: 0,
|
||||||
|
configured_budget_bytes_per_sec: 10_000_000,
|
||||||
|
isochronous_limit_pct: 85,
|
||||||
|
advertised_frame_bytes: 232_106,
|
||||||
|
committed_frame_bytes: 232_106,
|
||||||
|
committed_payload_bytes: 1024,
|
||||||
|
requested_frame_cap_bytes: Some(999_999),
|
||||||
|
});
|
||||||
|
assert!(contract.configfs_available);
|
||||||
|
assert!(contract.commit_observed);
|
||||||
|
assert_eq!(contract.transport, "isochronous");
|
||||||
|
assert_eq!(contract.enforced_frame_cap_bytes, 232_106);
|
||||||
|
assert!(!contract.divergent);
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("contract.json");
|
||||||
|
write_contract(&path, &contract).expect("write contract");
|
||||||
|
assert_eq!(read_contract(&path).expect("read contract"), contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contract_marks_missing_configfs_and_unobserved_commit_as_divergent() {
|
||||||
|
let contract = UvcPayloadContract::from_input(UvcContractInput {
|
||||||
|
configfs_available: false,
|
||||||
|
commit_observed: false,
|
||||||
|
bulk: false,
|
||||||
|
streaming_bulk_value: None,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
fps: 30,
|
||||||
|
frame_interval_100ns: 333_333,
|
||||||
|
streaming_maxpacket: 1024,
|
||||||
|
streaming_maxburst: 0,
|
||||||
|
configured_budget_bytes_per_sec: 4_500_000,
|
||||||
|
isochronous_limit_pct: 85,
|
||||||
|
advertised_frame_bytes: 150_000,
|
||||||
|
committed_frame_bytes: 150_000,
|
||||||
|
committed_payload_bytes: 1024,
|
||||||
|
requested_frame_cap_bytes: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contract.divergent);
|
||||||
|
assert!(contract.detail.contains("configfs_available=false"));
|
||||||
|
assert!(contract.detail.contains("commit_observed=false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_jpeg_validation_rejects_truncation_and_trailing_splices() {
|
||||||
|
let valid = include_bytes!("bin/lesavka_uvc/idle_1280x720_black.jpg");
|
||||||
|
assert!(validate_jpeg_structure(valid));
|
||||||
|
assert!(!validate_jpeg_structure(&valid[..valid.len() - 2]));
|
||||||
|
|
||||||
|
let mut trailing = valid.to_vec();
|
||||||
|
trailing.extend_from_slice(&[0xff, 0xd9]);
|
||||||
|
assert!(!validate_jpeg_structure(&trailing));
|
||||||
|
|
||||||
|
let mut broken_segment = valid.to_vec();
|
||||||
|
broken_segment[4] = 0xff;
|
||||||
|
broken_segment[5] = 0xff;
|
||||||
|
assert!(!validate_jpeg_structure(&broken_segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app4_integrity_marker_round_trips_sequence_and_keeps_jpeg_valid() {
|
||||||
|
let valid = include_bytes!("bin/lesavka_uvc/idle_1280x720_black.jpg");
|
||||||
|
let marked = add_lesavka_app4_integrity(valid, 0x0102_0304_0506_0708).expect("mark JPEG");
|
||||||
|
assert_eq!(lesavka_app4_sequence(&marked), Some(0x0102_0304_0506_0708));
|
||||||
|
assert_eq!(lesavka_app4_integrity_valid(&marked), Some(true));
|
||||||
|
assert!(validate_jpeg_structure(&marked));
|
||||||
|
|
||||||
|
let mut corrupted = marked;
|
||||||
|
let index = corrupted.len() - 10;
|
||||||
|
corrupted[index] ^= 1;
|
||||||
|
assert_eq!(lesavka_app4_integrity_valid(&corrupted), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downstream_uvc_consumers_use_shared_budget_math() {
|
||||||
|
let spool = include_str!("video_sinks/mjpeg_spool.rs");
|
||||||
|
let helper = include_str!("bin/lesavka-uvc.real.inc");
|
||||||
|
|
||||||
|
assert!(spool.contains("uvc_contract::effective_mjpeg_budget_bytes_per_sec"));
|
||||||
|
assert!(spool.contains("uvc_contract::clamp_frame_cap"));
|
||||||
|
assert!(helper.contains("uvc_contract::effective_mjpeg_budget_bytes_per_sec"));
|
||||||
|
assert!(helper.contains("uvc_contract::frame_cap_from_budget"));
|
||||||
|
assert!(!spool.contains("HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC"));
|
||||||
|
assert!(!helper.contains("saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,16 +6,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use gstreamer as gst;
|
use gstreamer as gst;
|
||||||
use gstreamer_app as gst_app;
|
use gstreamer_app as gst_app;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use crate::uvc_contract::{
|
||||||
|
self, DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||||
|
};
|
||||||
|
#[cfg(coverage)]
|
||||||
|
use lesavka_server::uvc_contract::{
|
||||||
|
self, DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||||
|
};
|
||||||
|
|
||||||
#[path = "mjpeg_spool/audit.rs"]
|
#[path = "mjpeg_spool/audit.rs"]
|
||||||
mod audit;
|
mod audit;
|
||||||
|
|
||||||
static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||||
static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||||
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
|
||||||
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;
|
|
||||||
const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0";
|
const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0";
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@ -173,11 +177,11 @@ fn read_u32_file(path: impl AsRef<Path>) -> Option<u32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn uvc_bulk_transfer_enabled() -> bool {
|
fn uvc_bulk_transfer_enabled() -> bool {
|
||||||
if !env_flag_enabled("LESAVKA_UVC_BULK", true) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let base = Path::new(CONFIGFS_UVC_BASE);
|
let base = Path::new(CONFIGFS_UVC_BASE);
|
||||||
!base.exists() || base.join("streaming_bulk").exists()
|
uvc_contract::bulk_enabled_from_configfs(
|
||||||
|
base,
|
||||||
|
env_flag_enabled("LESAVKA_UVC_BULK", true),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uvc_streaming_maxpacket(bulk: bool) -> u32 {
|
fn uvc_streaming_maxpacket(bulk: bool) -> u32 {
|
||||||
@ -192,28 +196,15 @@ fn uvc_streaming_maxpacket(bulk: bool) -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uvc_isochronous_budget_bytes_per_sec(maxpacket: u32) -> u32 {
|
|
||||||
let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT")
|
|
||||||
.unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT)
|
|
||||||
.clamp(1, 100);
|
|
||||||
let bytes = u64::from(maxpacket)
|
|
||||||
.saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC))
|
|
||||||
.saturating_mul(u64::from(pct))
|
|
||||||
/ 100;
|
|
||||||
bytes.min(u64::from(u32::MAX)) as u32
|
|
||||||
}
|
|
||||||
|
|
||||||
fn effective_mjpeg_budget_bytes_per_sec() -> u32 {
|
fn effective_mjpeg_budget_bytes_per_sec() -> u32 {
|
||||||
let configured = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC")
|
let configured = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC")
|
||||||
.unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC)
|
.unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC)
|
||||||
.max(1);
|
.max(1);
|
||||||
if uvc_bulk_transfer_enabled() {
|
let bulk = uvc_bulk_transfer_enabled();
|
||||||
configured
|
let maxpacket = uvc_streaming_maxpacket(bulk);
|
||||||
} else {
|
let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT")
|
||||||
configured
|
.unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT);
|
||||||
.min(uvc_isochronous_budget_bytes_per_sec(uvc_streaming_maxpacket(false)))
|
uvc_contract::effective_mjpeg_budget_bytes_per_sec(configured, maxpacket, bulk, pct)
|
||||||
.max(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the MJPEG byte budget used before publishing to the helper.
|
/// Resolve the MJPEG byte budget used before publishing to the helper.
|
||||||
@ -224,19 +215,62 @@ fn effective_mjpeg_budget_bytes_per_sec() -> u32 {
|
|||||||
/// Why: oversized MJPEG frames are a common source of host-visible UVC tearing;
|
/// Why: oversized MJPEG frames are a common source of host-visible UVC tearing;
|
||||||
/// a short freeze is better than letting the USB gadget emit partial pictures.
|
/// a short freeze is better than letting the USB gadget emit partial pictures.
|
||||||
pub(super) fn mjpeg_spool_frame_max_bytes(fps: u32) -> usize {
|
pub(super) fn mjpeg_spool_frame_max_bytes(fps: u32) -> usize {
|
||||||
if !env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) {
|
let path = uvc_contract::contract_path();
|
||||||
return MAX_MJPEG_FRAME_BYTES;
|
match uvc_contract::read_contract(&path) {
|
||||||
}
|
Ok(contract) => {
|
||||||
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
|
if contract.divergent {
|
||||||
&& limit > 0
|
let count = uvc_contract::record_divergent_contract_read();
|
||||||
{
|
warn_contract_issue(
|
||||||
return (limit as usize).min(MAX_MJPEG_FRAME_BYTES);
|
count,
|
||||||
|
"divergent",
|
||||||
|
&format!("{} ({})", path.display(), contract.detail),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return contract.enforced_frame_cap_bytes as usize;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let count = uvc_contract::record_missing_contract_read();
|
||||||
|
warn_contract_issue(
|
||||||
|
count,
|
||||||
|
"missing_or_invalid",
|
||||||
|
&format!("{} ({err})", path.display()),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fps = fps.max(1);
|
let fps = fps.max(1);
|
||||||
let budget_per_sec = effective_mjpeg_budget_bytes_per_sec();
|
let budget_per_sec = effective_mjpeg_budget_bytes_per_sec();
|
||||||
let per_frame = (budget_per_sec / fps).max(64 * 1024);
|
let requested = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES");
|
||||||
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
|
let advertised = live_advertised_frame_bytes().unwrap_or(0);
|
||||||
|
uvc_contract::clamp_frame_cap(requested, advertised, budget_per_sec, fps) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live_advertised_frame_bytes() -> Option<u32> {
|
||||||
|
let frame = match (
|
||||||
|
env_u32_opt("LESAVKA_UVC_WIDTH").unwrap_or(1280),
|
||||||
|
env_u32_opt("LESAVKA_UVC_HEIGHT").unwrap_or(720),
|
||||||
|
) {
|
||||||
|
(1920, 1080) => "1080p",
|
||||||
|
_ => "720p",
|
||||||
|
};
|
||||||
|
read_u32_file(
|
||||||
|
Path::new(CONFIGFS_UVC_BASE)
|
||||||
|
.join("streaming/mjpeg/m")
|
||||||
|
.join(frame)
|
||||||
|
.join("dwMaxVideoFrameBufferSize"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn_contract_issue(count: u64, kind: &str, detail: &str) {
|
||||||
|
if count <= 3 || count.is_power_of_two() {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "lesavka_server::video",
|
||||||
|
contract_issue = kind,
|
||||||
|
occurrences = count,
|
||||||
|
detail,
|
||||||
|
"UVC payload contract unavailable or divergent; using conservative live fallback"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether frame spool metadata should be published.
|
/// Decide whether frame spool metadata should be published.
|
||||||
|
|||||||
@ -55,7 +55,7 @@ fn mjpeg_spool_frame_budget_uses_live_budget_when_zero_or_unset() {
|
|||||||
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("9")),
|
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("9")),
|
||||||
],
|
],
|
||||||
|| {
|
|| {
|
||||||
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 65_536);
|
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -75,7 +75,7 @@ fn mjpeg_spool_frame_budget_uses_live_budget_when_zero_or_unset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() {
|
fn mjpeg_spool_frame_budget_clamps_explicit_limit_and_diagnostic_flags() {
|
||||||
temp_env::with_vars(
|
temp_env::with_vars(
|
||||||
[
|
[
|
||||||
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")),
|
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")),
|
||||||
@ -83,7 +83,7 @@ fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() {
|
|||||||
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
||||||
],
|
],
|
||||||
|| {
|
|| {
|
||||||
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 123_456);
|
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -94,10 +94,7 @@ fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() {
|
|||||||
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
||||||
],
|
],
|
||||||
|| {
|
|| {
|
||||||
assert_eq!(
|
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1);
|
||||||
super::mjpeg_spool_frame_max_bytes(30),
|
|
||||||
super::MAX_MJPEG_FRAME_BYTES
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ use super::mjpeg_spool::freshest_mjpeg_sample;
|
|||||||
use super::mjpeg_spool::{MjpegSpoolTiming, spool_mjpeg_frame_with_timing};
|
use super::mjpeg_spool::{MjpegSpoolTiming, spool_mjpeg_frame_with_timing};
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::video_support::{contains_hevc_irap, reserve_local_pts};
|
use crate::video_support::{contains_hevc_irap, reserve_local_pts};
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use crate::uvc_contract::{add_lesavka_app4_integrity, lesavka_app4_sequence};
|
||||||
|
|
||||||
impl WebcamSink {
|
impl WebcamSink {
|
||||||
/// Push one client frame into the UVC pipeline.
|
/// Push one client frame into the UVC pipeline.
|
||||||
@ -344,7 +346,9 @@ impl WebcamSink {
|
|||||||
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
|
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let normalized = map.as_slice();
|
let normalized_marked = lesavka_app4_sequence(&pkt.data)
|
||||||
|
.and_then(|sequence| add_lesavka_app4_integrity(map.as_slice(), sequence));
|
||||||
|
let normalized = normalized_marked.as_deref().unwrap_or_else(|| map.as_slice());
|
||||||
let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed);
|
let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed);
|
||||||
if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason(
|
if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason(
|
||||||
previous_bytes,
|
previous_bytes,
|
||||||
|
|||||||
@ -110,7 +110,10 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() {
|
|||||||
"UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}",
|
"UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}",
|
||||||
"uvc_isochronous_budget_bytes_per_sec()",
|
"uvc_isochronous_budget_bytes_per_sec()",
|
||||||
"isoch_budget=\"$(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\")\"",
|
"UVC_DERIVED_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"",
|
||||||
|
"clamping requested UVC frame size $UVC_FRAME_SIZE -> $UVC_DERIVED_FRAME_SIZE (wire budget)",
|
||||||
|
"UVC bulk request did not stick; using isochronous descriptors",
|
||||||
|
"UVC bulk readback diverged after descriptor setup; falling back to isochronous sizing",
|
||||||
"write_active_mjpeg_frame_descriptor()",
|
"write_active_mjpeg_frame_descriptor()",
|
||||||
"if flag_enabled \"$UVC_ADVERTISE_EXTRA_MODES\"; then",
|
"if flag_enabled \"$UVC_ADVERTISE_EXTRA_MODES\"; then",
|
||||||
"write_mjpeg_frame_descriptor 1080p 1920 1080",
|
"write_mjpeg_frame_descriptor 1080p 1920 1080",
|
||||||
|
|||||||
@ -431,10 +431,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
|
|||||||
assert!(
|
assert!(
|
||||||
SERVER_INSTALL.contains(
|
SERVER_INSTALL.contains(
|
||||||
"updated UVC env matches the live descriptor, restarting lesavka-server only"
|
"updated UVC env matches the live descriptor, restarting lesavka-server only"
|
||||||
) && SERVER_INSTALL.contains(
|
) && SERVER_INSTALL
|
||||||
"Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled"
|
.contains("Preserving lesavka-core so the attached USB gadget is not cycled")
|
||||||
),
|
&& SERVER_INSTALL.contains(
|
||||||
"safe env repairs should restart only the server process, not the attached gadget path"
|
"set LESAVKA_INSTALL_RESTART_UVC_HELPER=1 when a helper-only refresh is required"
|
||||||
|
),
|
||||||
|
"safe env repairs should preserve the gadget and require an explicit helper-only refresh"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
SERVER_INSTALL.contains("dwDefaultFrameInterval"),
|
SERVER_INSTALL.contains("dwDefaultFrameInterval"),
|
||||||
@ -470,7 +472,17 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
|
|||||||
< SERVER_INSTALL
|
< SERVER_INSTALL
|
||||||
.rfind("restart_lesavka_uvc_helper_only")
|
.rfind("restart_lesavka_uvc_helper_only")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
"safe env repair must exit before the later version-update helper refresh path"
|
"safe env repair must remain separate from the later version-update helper refresh path"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
SERVER_INSTALL.contains("install_uvc_kernel_watch_unit")
|
||||||
|
&& SERVER_INSTALL
|
||||||
|
.find("\ninstall_uvc_kernel_watch_unit\n")
|
||||||
|
.unwrap()
|
||||||
|
< SERVER_INSTALL
|
||||||
|
.find("if [[ \"$ATTACHED_UVC_RESTART_DEFERRED\" == \"1\" ]]")
|
||||||
|
.unwrap(),
|
||||||
|
"kernel UVC telemetry must be installed even when an attached-gadget branch exits early"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
SERVER_INSTALL
|
SERVER_INSTALL
|
||||||
|
|||||||
@ -315,7 +315,7 @@ mod uvc_binary {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn uvc_defaults_to_reliable_bulk_and_budgeted_mjpeg_frame_size() {
|
fn uvc_missing_configfs_falls_back_to_isochronous_budget() {
|
||||||
with_vars(
|
with_vars(
|
||||||
[
|
[
|
||||||
("LESAVKA_UVC_WIDTH", Some("1280")),
|
("LESAVKA_UVC_WIDTH", Some("1280")),
|
||||||
@ -331,10 +331,10 @@ mod uvc_binary {
|
|||||||
|| {
|
|| {
|
||||||
let cfg = UvcConfig::from_env();
|
let cfg = UvcConfig::from_env();
|
||||||
assert_eq!(cfg.interval, 10_000_000 / 30);
|
assert_eq!(cfg.interval, 10_000_000 / 30);
|
||||||
assert_eq!(cfg.frame_size, 300_000);
|
assert_eq!(cfg.frame_size, 232_106);
|
||||||
assert_eq!(cfg.max_packet, 512);
|
assert_eq!(cfg.max_packet, 1024);
|
||||||
assert!(cfg.bulk);
|
assert!(!cfg.bulk);
|
||||||
assert!(uvc_bulk_transfer_enabled());
|
assert!(!uvc_bulk_transfer_enabled());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -372,8 +372,8 @@ mod uvc_binary {
|
|||||||
fn uvc_bulk_mode_is_tied_to_live_configfs_support() {
|
fn uvc_bulk_mode_is_tied_to_live_configfs_support() {
|
||||||
let source = include_str!("../../../../server/src/bin/lesavka-uvc.real.inc");
|
let source = include_str!("../../../../server/src/bin/lesavka-uvc.real.inc");
|
||||||
|
|
||||||
assert!(source.contains("base.exists() && !base.join(\"streaming_bulk\").exists()"));
|
assert!(source.contains("bulk_enabled_from_configfs"));
|
||||||
assert!(source.contains("using isochronous payload sizing"));
|
assert!(source.contains("streaming_bulk is not 1; using isochronous payload sizing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -386,6 +386,12 @@ mod uvc_binary {
|
|||||||
rejected_oversize: 1,
|
rejected_oversize: 1,
|
||||||
rejected_invalid: 3,
|
rejected_invalid: 3,
|
||||||
fallback_idle: 4,
|
fallback_idle: 4,
|
||||||
|
held_last_good: 5,
|
||||||
|
read_errors: 6,
|
||||||
|
strict_validation_failures: 7,
|
||||||
|
dqbuf_ioctl_errors: 8,
|
||||||
|
dqbuf_flag_errors: 9,
|
||||||
|
qbuf_ioctl_errors: 10,
|
||||||
latest_bytes: 77_036,
|
latest_bytes: 77_036,
|
||||||
last_rejected_oversize_bytes: 300_001,
|
last_rejected_oversize_bytes: 300_001,
|
||||||
last_rejected_oversize_cap: 300_000,
|
last_rejected_oversize_cap: 300_000,
|
||||||
@ -402,6 +408,21 @@ mod uvc_binary {
|
|||||||
assert!(json.contains("\"paced_sleeps\":5"));
|
assert!(json.contains("\"paced_sleeps\":5"));
|
||||||
assert!(json.contains("\"paced_sleep_ms\":123"));
|
assert!(json.contains("\"paced_sleep_ms\":123"));
|
||||||
|
|
||||||
|
let kernel_path = tempfile::NamedTempFile::new().expect("kernel stats");
|
||||||
|
fs::write(
|
||||||
|
kernel_path.path(),
|
||||||
|
r#"{"schema":"lesavka.uvc-kernel-watch.v1","counters":{"dwc2_errors":2}}"#,
|
||||||
|
)
|
||||||
|
.expect("write kernel stats");
|
||||||
|
with_var(
|
||||||
|
"LESAVKA_UVC_KERNEL_STATS_PATH",
|
||||||
|
Some(kernel_path.path().to_str().expect("kernel stats path")),
|
||||||
|
|| {
|
||||||
|
let json = uvc_stats_snapshot_json(&stats, 333_333);
|
||||||
|
assert!(json.contains("\"kernel\":{\"counters\":{\"dwc2_errors\":2}"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let path = dir.path().join("uvc").join("stats.json");
|
let path = dir.path().join("uvc").join("stats.json");
|
||||||
write_atomic_text(&path, &json).expect("write stats");
|
write_atomic_text(&path, &json).expect("write stats");
|
||||||
@ -454,8 +475,10 @@ mod uvc_binary {
|
|||||||
height: 480,
|
height: 480,
|
||||||
default_interval: 333_333,
|
default_interval: 333_333,
|
||||||
frame_interval: 333_333,
|
frame_interval: 333_333,
|
||||||
|
frame_size: 150_000,
|
||||||
maxpacket: 1024,
|
maxpacket: 1024,
|
||||||
maxburst: 0,
|
maxburst: 0,
|
||||||
|
streaming_bulk: Some(0),
|
||||||
});
|
});
|
||||||
log_configfs_snapshot(&mut state, "contract");
|
log_configfs_snapshot(&mut state, "contract");
|
||||||
assert!(state.cfg_snapshot.is_some());
|
assert!(state.cfg_snapshot.is_some());
|
||||||
|
|||||||
@ -15,6 +15,10 @@ mod video_support {
|
|||||||
pub use lesavka_server::video_support::*;
|
pub use lesavka_server::video_support::*;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod uvc_contract {
|
||||||
|
pub use lesavka_server::uvc_contract::*;
|
||||||
|
}
|
||||||
|
|
||||||
#[path = "../../../../server/src/media_timing.rs"]
|
#[path = "../../../../server/src/media_timing.rs"]
|
||||||
#[allow(warnings)]
|
#[allow(warnings)]
|
||||||
mod media_timing;
|
mod media_timing;
|
||||||
|
|||||||
@ -10,6 +10,10 @@ use std::fs;
|
|||||||
|
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
mod uvc_contract {
|
||||||
|
pub use lesavka_server::uvc_contract::*;
|
||||||
|
}
|
||||||
|
|
||||||
mod video_support {
|
mod video_support {
|
||||||
pub fn env_u32(name: &str, default: u32) -> u32 {
|
pub fn env_u32(name: &str, default: u32) -> u32 {
|
||||||
std::env::var(name)
|
std::env::var(name)
|
||||||
|
|||||||
@ -58,6 +58,7 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
|||||||
"--server-uvc-audit-dir",
|
"--server-uvc-audit-dir",
|
||||||
"--server-uvc-audit-sample-frames",
|
"--server-uvc-audit-sample-frames",
|
||||||
"--stream-analyze",
|
"--stream-analyze",
|
||||||
|
"--deep-capture",
|
||||||
"--sequence-window",
|
"--sequence-window",
|
||||||
"--mix-mae-threshold",
|
"--mix-mae-threshold",
|
||||||
"--mix-improvement",
|
"--mix-improvement",
|
||||||
@ -138,6 +139,11 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
|||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"v4l2",
|
"v4l2",
|
||||||
"x11grab",
|
"x11grab",
|
||||||
|
"capture.mjpg",
|
||||||
|
"usbmon.pcapng",
|
||||||
|
"uvcvideo-dynamic-debug",
|
||||||
|
"power/autosuspend",
|
||||||
|
"analyze_marked_capture.py",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
PROBE_SRC.contains(expected),
|
PROBE_SRC.contains(expected),
|
||||||
@ -157,6 +163,8 @@ fn synthetic_injector_enters_the_public_bundled_media_rpc() {
|
|||||||
"silence_pcm(args.frame_step_us())",
|
"silence_pcm(args.frame_step_us())",
|
||||||
"synthetic_rgb_frame",
|
"synthetic_rgb_frame",
|
||||||
"draw_sequence_marker",
|
"draw_sequence_marker",
|
||||||
|
"synthetic_band_marker_luma",
|
||||||
|
"add_jpeg_app4_integrity",
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"jpegenc",
|
"jpegenc",
|
||||||
"client_capture_pts_us: pts_us",
|
"client_capture_pts_us: pts_us",
|
||||||
|
|||||||
@ -0,0 +1,82 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const ANALYZER: &str = include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/scripts/manual/analyze_uvc_mjpeg_integrity.py"
|
||||||
|
));
|
||||||
|
const KERNEL_WATCHER: &str = include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/scripts/daemon/lesavka-uvc-kernel-watch.py"
|
||||||
|
));
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uvc_integrity_analyzer_has_machine_readable_failure_classes() {
|
||||||
|
for expected in [
|
||||||
|
"lesavka.uvc-mjpeg-integrity.v1",
|
||||||
|
"intact",
|
||||||
|
"truncated",
|
||||||
|
"spliced",
|
||||||
|
"stale_repeat",
|
||||||
|
"idle_black",
|
||||||
|
"APP4_MAGIC",
|
||||||
|
"crc_ok",
|
||||||
|
"report.json",
|
||||||
|
"frames.jsonl",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
ANALYZER.contains(expected),
|
||||||
|
"missing analyzer contract {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uvc_integrity_analyzer_self_test_exercises_all_classes() {
|
||||||
|
let script = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/scripts/manual/analyze_uvc_mjpeg_integrity.py"
|
||||||
|
);
|
||||||
|
let output = Command::new("python3")
|
||||||
|
.args([script, "--self-test"])
|
||||||
|
.output()
|
||||||
|
.expect("run analyzer self-test");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&output.stdout).contains("self-test: pass"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uvc_kernel_watcher_has_bounded_machine_readable_counters() {
|
||||||
|
for expected in [
|
||||||
|
"lesavka.uvc-kernel-watch.v1",
|
||||||
|
"dwc2_errors",
|
||||||
|
"uvc_errors",
|
||||||
|
"udc_errors",
|
||||||
|
"urb_errors",
|
||||||
|
"usb_resets",
|
||||||
|
"deque(maxlen=40)",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
KERNEL_WATCHER.contains(expected),
|
||||||
|
"missing watcher contract {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let script = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/scripts/daemon/lesavka-uvc-kernel-watch.py"
|
||||||
|
);
|
||||||
|
let output = Command::new("python3")
|
||||||
|
.args([script, "--self-test"])
|
||||||
|
.output()
|
||||||
|
.expect("run kernel watcher self-test");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&output.stdout).contains("self-test: pass"));
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user