2026-01-28 17:52:00 -03:00
|
|
|
// server/src/camera.rs
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#![cfg_attr(coverage, allow(dead_code, unused_imports, unused_variables))]
|
|
|
|
|
|
2026-01-28 17:52:00 -03:00
|
|
|
use gstreamer as gst;
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::sync::{OnceLock, RwLock};
|
|
|
|
|
use tracing::{info, warn};
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
|
|
|
pub enum CameraOutput {
|
|
|
|
|
Uvc,
|
|
|
|
|
Hdmi,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CameraOutput {
|
2026-04-10 15:56:18 -03:00
|
|
|
/// Return the canonical string name for the transport.
|
2026-01-28 17:52:00 -03:00
|
|
|
pub fn as_str(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
CameraOutput::Uvc => "uvc",
|
|
|
|
|
CameraOutput::Hdmi => "hdmi",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
|
|
|
pub enum CameraCodec {
|
|
|
|
|
H264,
|
|
|
|
|
Mjpeg,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CameraCodec {
|
2026-04-10 15:56:18 -03:00
|
|
|
/// Return the canonical string name for the codec.
|
2026-01-28 17:52:00 -03:00
|
|
|
pub fn as_str(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
CameraCodec::H264 => "h264",
|
|
|
|
|
CameraCodec::Mjpeg => "mjpeg",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:50:28 -03:00
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
|
|
|
pub struct HdmiMode {
|
|
|
|
|
pub width: u32,
|
|
|
|
|
pub height: u32,
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 17:52:00 -03:00
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct HdmiConnector {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub id: Option<u32>,
|
2026-04-22 16:50:28 -03:00
|
|
|
pub modes: Vec<HdmiMode>,
|
2026-01-28 17:52:00 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct CameraConfig {
|
|
|
|
|
pub output: CameraOutput,
|
|
|
|
|
pub codec: CameraCodec,
|
|
|
|
|
pub width: u32,
|
|
|
|
|
pub height: u32,
|
|
|
|
|
pub fps: u32,
|
|
|
|
|
pub hdmi: Option<HdmiConnector>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:50:28 -03:00
|
|
|
impl CameraConfig {
|
|
|
|
|
/// Return the HDMI display mode that should be driven on the wire.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: the selected camera config and optional HDMI mode overrides.
|
|
|
|
|
/// Outputs: a width/height pair for the physical display pipeline.
|
|
|
|
|
/// Why: the client webcam uplink can stay at a known-good capture profile
|
|
|
|
|
/// while HDMI output scales to a mode the capture adapter actually locks.
|
|
|
|
|
pub fn hdmi_display_size(&self) -> (u32, u32) {
|
|
|
|
|
if self.output != CameraOutput::Hdmi {
|
|
|
|
|
return (self.width, self.height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let (Some(width), Some(height)) = (
|
|
|
|
|
read_u32_from_env("LESAVKA_HDMI_WIDTH"),
|
|
|
|
|
read_u32_from_env("LESAVKA_HDMI_HEIGHT"),
|
|
|
|
|
) {
|
|
|
|
|
return (width, height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.hdmi
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|hdmi| preferred_hdmi_mode(&hdmi.modes))
|
|
|
|
|
.map(|mode| (mode.width, mode.height))
|
|
|
|
|
.unwrap_or((self.width, self.height))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 17:52:00 -03:00
|
|
|
static LAST_CONFIG: OnceLock<RwLock<CameraConfig>> = OnceLock::new();
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
/// Refresh the cached camera config from the current environment.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: none; the selector consults the current environment and local
|
|
|
|
|
/// `/sys/class/drm` state.
|
|
|
|
|
/// Outputs: the newly selected camera configuration, which is also stored as
|
|
|
|
|
/// the last-known config for future reads.
|
|
|
|
|
/// Why: callers need a single entrypoint for changing the active output mode
|
|
|
|
|
/// without re-implementing the selection rules.
|
2026-01-28 17:52:00 -03:00
|
|
|
pub fn update_camera_config() -> CameraConfig {
|
|
|
|
|
let cfg = select_camera_config();
|
|
|
|
|
let lock = LAST_CONFIG.get_or_init(|| RwLock::new(cfg.clone()));
|
|
|
|
|
*lock.write().unwrap() = cfg.clone();
|
|
|
|
|
cfg
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
pub fn current_camera_config() -> CameraConfig {
|
|
|
|
|
update_camera_config()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
/// Return the last selected camera configuration.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: none.
|
|
|
|
|
/// Outputs: the cached camera configuration, or a freshly selected one when
|
|
|
|
|
/// the cache has not been initialized yet.
|
|
|
|
|
/// Why: call sites can read the active config without worrying about whether
|
|
|
|
|
/// initialization already happened in this process.
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-01-28 17:52:00 -03:00
|
|
|
pub fn current_camera_config() -> CameraConfig {
|
|
|
|
|
if let Some(lock) = LAST_CONFIG.get() {
|
|
|
|
|
return lock.read().unwrap().clone();
|
|
|
|
|
}
|
|
|
|
|
update_camera_config()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn select_camera_config() -> CameraConfig {
|
|
|
|
|
let output_override = std::env::var("LESAVKA_CAM_OUTPUT")
|
|
|
|
|
.ok()
|
|
|
|
|
.as_deref()
|
|
|
|
|
.and_then(parse_camera_output);
|
|
|
|
|
|
|
|
|
|
match output_override.unwrap_or(CameraOutput::Uvc) {
|
|
|
|
|
CameraOutput::Hdmi => select_hdmi_config(detect_hdmi_connector(false)),
|
|
|
|
|
CameraOutput::Uvc => select_uvc_config(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-01-28 17:52:00 -03:00
|
|
|
fn select_camera_config() -> CameraConfig {
|
|
|
|
|
let output_env = std::env::var("LESAVKA_CAM_OUTPUT").ok();
|
2026-04-08 20:00:14 -03:00
|
|
|
let output_override = output_env.as_deref().and_then(parse_camera_output);
|
2026-01-28 17:52:00 -03:00
|
|
|
|
|
|
|
|
let require_connected = output_override != Some(CameraOutput::Hdmi);
|
|
|
|
|
let hdmi = detect_hdmi_connector(require_connected);
|
|
|
|
|
if output_override == Some(CameraOutput::Hdmi) && hdmi.is_none() {
|
|
|
|
|
warn!("📷 HDMI output forced but no connector detected");
|
|
|
|
|
}
|
|
|
|
|
let output = match output_override {
|
|
|
|
|
Some(v) => v,
|
|
|
|
|
None => {
|
|
|
|
|
if hdmi.is_some() {
|
|
|
|
|
CameraOutput::Hdmi
|
|
|
|
|
} else {
|
|
|
|
|
CameraOutput::Uvc
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let cfg = match output {
|
|
|
|
|
CameraOutput::Hdmi => select_hdmi_config(hdmi),
|
|
|
|
|
CameraOutput::Uvc => select_uvc_config(),
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-22 16:50:28 -03:00
|
|
|
let (display_width, display_height) = cfg.hdmi_display_size();
|
2026-01-28 17:52:00 -03:00
|
|
|
info!(
|
|
|
|
|
output = cfg.output.as_str(),
|
|
|
|
|
codec = cfg.codec.as_str(),
|
|
|
|
|
width = cfg.width,
|
|
|
|
|
height = cfg.height,
|
|
|
|
|
fps = cfg.fps,
|
2026-04-22 16:50:28 -03:00
|
|
|
display_width,
|
|
|
|
|
display_height,
|
2026-01-28 17:52:00 -03:00
|
|
|
hdmi = cfg.hdmi.as_ref().map(|h| h.name.as_str()).unwrap_or("none"),
|
|
|
|
|
"📷 camera output selected"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
cfg
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_camera_output(raw: &str) -> Option<CameraOutput> {
|
|
|
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
"uvc" => Some(CameraOutput::Uvc),
|
|
|
|
|
"hdmi" => Some(CameraOutput::Hdmi),
|
|
|
|
|
"auto" | "" => None,
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn select_hdmi_config(hdmi: Option<HdmiConnector>) -> CameraConfig {
|
|
|
|
|
let hw_decode = has_hw_h264_decode();
|
2026-04-22 22:10:39 -03:00
|
|
|
let (default_width, default_height) = if hw_decode { (1920, 1080) } else { (1280, 720) };
|
|
|
|
|
let width = read_u32_from_env("LESAVKA_CAM_WIDTH").unwrap_or(default_width);
|
|
|
|
|
let height = read_u32_from_env("LESAVKA_CAM_HEIGHT").unwrap_or(default_height);
|
|
|
|
|
let fps = read_u32_from_env("LESAVKA_CAM_FPS").unwrap_or(30).max(1);
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-01-28 17:52:00 -03:00
|
|
|
if !hw_decode {
|
2026-04-22 22:10:39 -03:00
|
|
|
if width == default_width && height == default_height {
|
|
|
|
|
warn!(
|
|
|
|
|
"📷 HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink"
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
warn!(
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
fps,
|
|
|
|
|
"📷 HDMI output: hardware H264 decoder not detected; using configured camera uplink size"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-28 17:52:00 -03:00
|
|
|
}
|
|
|
|
|
CameraConfig {
|
|
|
|
|
output: CameraOutput::Hdmi,
|
|
|
|
|
codec: CameraCodec::H264,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
fps,
|
|
|
|
|
hdmi,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn select_uvc_config() -> CameraConfig {
|
|
|
|
|
let width = read_u32_from_env("LESAVKA_UVC_WIDTH").unwrap_or(1280);
|
|
|
|
|
let height = read_u32_from_env("LESAVKA_UVC_HEIGHT").unwrap_or(720);
|
|
|
|
|
let fps = read_u32_from_env("LESAVKA_UVC_FPS")
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
read_u32_from_env("LESAVKA_UVC_INTERVAL").and_then(|interval| {
|
|
|
|
|
if interval == 0 {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(10_000_000 / interval)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(25);
|
|
|
|
|
|
|
|
|
|
CameraConfig {
|
|
|
|
|
output: CameraOutput::Uvc,
|
|
|
|
|
codec: CameraCodec::Mjpeg,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
fps,
|
|
|
|
|
hdmi: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-01-28 17:52:00 -03:00
|
|
|
fn select_uvc_config() -> CameraConfig {
|
|
|
|
|
let mut uvc_env = HashMap::new();
|
|
|
|
|
if let Ok(text) = fs::read_to_string("/etc/lesavka/uvc.env") {
|
|
|
|
|
uvc_env = parse_env_file(&text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let width = read_u32_from_env("LESAVKA_UVC_WIDTH")
|
|
|
|
|
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_WIDTH"))
|
|
|
|
|
.unwrap_or(1280);
|
|
|
|
|
let height = read_u32_from_env("LESAVKA_UVC_HEIGHT")
|
|
|
|
|
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_HEIGHT"))
|
|
|
|
|
.unwrap_or(720);
|
|
|
|
|
let fps = read_u32_from_env("LESAVKA_UVC_FPS")
|
|
|
|
|
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_FPS"))
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
read_u32_from_env("LESAVKA_UVC_INTERVAL")
|
|
|
|
|
.or_else(|| read_u32_from_map(&uvc_env, "LESAVKA_UVC_INTERVAL"))
|
|
|
|
|
.and_then(|interval| {
|
|
|
|
|
if interval == 0 {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(10_000_000 / interval)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(25);
|
|
|
|
|
|
|
|
|
|
CameraConfig {
|
|
|
|
|
output: CameraOutput::Uvc,
|
|
|
|
|
codec: CameraCodec::Mjpeg,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
fps,
|
|
|
|
|
hdmi: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn has_hw_h264_decode() -> bool {
|
|
|
|
|
std::env::var("LESAVKA_HW_H264").is_ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-01-28 17:52:00 -03:00
|
|
|
fn has_hw_h264_decode() -> bool {
|
|
|
|
|
if gst::init().is_err() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
for name in ["v4l2h264dec", "v4l2slh264dec", "omxh264dec"] {
|
|
|
|
|
if gst::ElementFactory::find(name).is_some() {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn detect_hdmi_connector(require_connected: bool) -> Option<HdmiConnector> {
|
|
|
|
|
let _ = require_connected;
|
|
|
|
|
std::env::var("LESAVKA_HDMI_CONNECTOR")
|
|
|
|
|
.ok()
|
2026-04-22 16:50:28 -03:00
|
|
|
.map(|name| HdmiConnector {
|
|
|
|
|
name,
|
|
|
|
|
id: None,
|
|
|
|
|
modes: std::env::var("LESAVKA_HDMI_MODES")
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|raw| parse_hdmi_modes(&raw))
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
})
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-01-28 17:52:00 -03:00
|
|
|
fn detect_hdmi_connector(require_connected: bool) -> Option<HdmiConnector> {
|
|
|
|
|
let preferred = std::env::var("LESAVKA_HDMI_CONNECTOR").ok();
|
|
|
|
|
let entries = fs::read_dir("/sys/class/drm").ok()?;
|
|
|
|
|
let mut connectors = Vec::new();
|
|
|
|
|
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
|
|
|
if !name.contains("HDMI-A-") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let status_path = entry.path().join("status");
|
|
|
|
|
let status = fs::read_to_string(&status_path)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|v| v.trim().to_string())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let id = fs::read_to_string(entry.path().join("connector_id"))
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|v| v.trim().parse::<u32>().ok());
|
2026-04-22 16:50:28 -03:00
|
|
|
let modes = fs::read_to_string(entry.path().join("modes"))
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|raw| parse_hdmi_modes(&raw))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
connectors.push((name, status, id, modes));
|
2026-01-28 17:52:00 -03:00
|
|
|
}
|
2026-04-10 15:56:18 -03:00
|
|
|
connectors.sort_by(|a, b| a.0.cmp(&b.0));
|
2026-01-28 17:52:00 -03:00
|
|
|
|
2026-04-08 20:00:14 -03:00
|
|
|
let matches_preferred =
|
|
|
|
|
|name: &str, preferred: &str| name == preferred || name.ends_with(preferred);
|
2026-01-28 17:52:00 -03:00
|
|
|
|
|
|
|
|
if let Some(pref) = preferred.as_deref() {
|
2026-04-22 16:50:28 -03:00
|
|
|
for (name, status, id, modes) in &connectors {
|
2026-04-08 20:00:14 -03:00
|
|
|
if matches_preferred(name, pref) && (!require_connected || status == "connected") {
|
2026-01-28 17:52:00 -03:00
|
|
|
return Some(HdmiConnector {
|
|
|
|
|
name: name.clone(),
|
|
|
|
|
id: *id,
|
2026-04-22 16:50:28 -03:00
|
|
|
modes: modes.clone(),
|
2026-01-28 17:52:00 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
// Keep the previously-selected connector stable when no explicit override is set.
|
|
|
|
|
// This prevents connector flapping when multiple HDMI outputs are simultaneously connected.
|
|
|
|
|
if preferred.is_none() {
|
|
|
|
|
let previous = LAST_CONFIG
|
|
|
|
|
.get()
|
|
|
|
|
.and_then(|lock| lock.read().ok())
|
|
|
|
|
.and_then(|cfg| cfg.hdmi.as_ref().map(|h| h.name.clone()));
|
|
|
|
|
if let Some(prev) = previous {
|
2026-04-22 16:50:28 -03:00
|
|
|
for (name, status, id, modes) in &connectors {
|
2026-04-10 15:56:18 -03:00
|
|
|
if *name == prev && (!require_connected || status == "connected") {
|
|
|
|
|
return Some(HdmiConnector {
|
|
|
|
|
name: name.clone(),
|
|
|
|
|
id: *id,
|
2026-04-22 16:50:28 -03:00
|
|
|
modes: modes.clone(),
|
2026-04-10 15:56:18 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:50:28 -03:00
|
|
|
for (name, status, id, modes) in connectors {
|
2026-01-28 17:52:00 -03:00
|
|
|
if !require_connected || status == "connected" {
|
2026-04-22 16:50:28 -03:00
|
|
|
return Some(HdmiConnector { name, id, modes });
|
2026-01-28 17:52:00 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:50:28 -03:00
|
|
|
fn parse_hdmi_modes(raw: &str) -> Vec<HdmiMode> {
|
|
|
|
|
raw.lines()
|
|
|
|
|
.flat_map(|line| line.split(','))
|
|
|
|
|
.filter_map(parse_hdmi_mode)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_hdmi_mode(raw: &str) -> Option<HdmiMode> {
|
|
|
|
|
let raw = raw.trim();
|
|
|
|
|
let (width, rest) = raw.split_once('x')?;
|
|
|
|
|
let width = width.trim().parse::<u32>().ok()?;
|
|
|
|
|
let height_digits: String = rest
|
|
|
|
|
.trim()
|
|
|
|
|
.chars()
|
|
|
|
|
.take_while(|ch| ch.is_ascii_digit())
|
|
|
|
|
.collect();
|
|
|
|
|
let height = height_digits.parse::<u32>().ok()?;
|
|
|
|
|
(width > 0 && height > 0).then_some(HdmiMode { width, height })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn preferred_hdmi_mode(modes: &[HdmiMode]) -> Option<HdmiMode> {
|
|
|
|
|
for preferred in [
|
|
|
|
|
HdmiMode {
|
|
|
|
|
width: 1920,
|
|
|
|
|
height: 1080,
|
|
|
|
|
},
|
|
|
|
|
HdmiMode {
|
|
|
|
|
width: 1280,
|
|
|
|
|
height: 720,
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
if modes.contains(&preferred) {
|
|
|
|
|
return Some(preferred);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modes
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
.filter(|mode| mode.width.saturating_mul(9) == mode.height.saturating_mul(16))
|
|
|
|
|
.filter(|mode| mode.width.saturating_mul(mode.height) <= 1920 * 1080)
|
|
|
|
|
.max_by_key(|mode| mode.width.saturating_mul(mode.height))
|
|
|
|
|
.or_else(|| modes.first().copied())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-01-28 17:52:00 -03:00
|
|
|
fn parse_env_file(text: &str) -> HashMap<String, String> {
|
|
|
|
|
let mut out = HashMap::new();
|
|
|
|
|
for line in text.lines() {
|
|
|
|
|
let line = line.trim();
|
|
|
|
|
if line.is_empty() || line.starts_with('#') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let mut parts = line.splitn(2, '=');
|
|
|
|
|
let key = match parts.next() {
|
|
|
|
|
Some(v) => v.trim(),
|
|
|
|
|
None => continue,
|
|
|
|
|
};
|
|
|
|
|
let val = match parts.next() {
|
|
|
|
|
Some(v) => v.trim(),
|
|
|
|
|
None => continue,
|
|
|
|
|
};
|
|
|
|
|
out.insert(key.to_string(), val.to_string());
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_u32_from_env(key: &str) -> Option<u32> {
|
|
|
|
|
std::env::var(key).ok().and_then(|v| v.parse::<u32>().ok())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[cfg(not(coverage))]
|
2026-01-28 17:52:00 -03:00
|
|
|
fn read_u32_from_map(map: &HashMap<String, String>, key: &str) -> Option<u32> {
|
|
|
|
|
map.get(key).and_then(|v| v.parse::<u32>().ok())
|
|
|
|
|
}
|
2026-04-10 15:56:18 -03:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-04-22 16:50:28 -03:00
|
|
|
use super::{
|
|
|
|
|
CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, current_camera_config,
|
|
|
|
|
parse_hdmi_mode, parse_hdmi_modes, preferred_hdmi_mode, update_camera_config,
|
|
|
|
|
};
|
2026-04-10 15:56:18 -03:00
|
|
|
use serial_test::serial;
|
|
|
|
|
use temp_env::with_var;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn camera_config_env_override_prefers_uvc_values() {
|
|
|
|
|
with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || {
|
|
|
|
|
with_var("LESAVKA_UVC_WIDTH", Some("800"), || {
|
|
|
|
|
with_var("LESAVKA_UVC_HEIGHT", Some("600"), || {
|
|
|
|
|
with_var("LESAVKA_UVC_FPS", Some("24"), || {
|
|
|
|
|
let cfg = update_camera_config();
|
|
|
|
|
assert_eq!(cfg.output, CameraOutput::Uvc);
|
|
|
|
|
assert_eq!(cfg.codec, CameraCodec::Mjpeg);
|
|
|
|
|
assert_eq!(cfg.width, 800);
|
|
|
|
|
assert_eq!(cfg.height, 600);
|
|
|
|
|
assert_eq!(cfg.fps, 24);
|
|
|
|
|
|
|
|
|
|
let cached = current_camera_config();
|
|
|
|
|
assert_eq!(cached.output, CameraOutput::Uvc);
|
|
|
|
|
assert_eq!(cached.codec, CameraCodec::Mjpeg);
|
|
|
|
|
assert_eq!(cached.width, 800);
|
|
|
|
|
assert_eq!(cached.height, 600);
|
|
|
|
|
assert_eq!(cached.fps, 24);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-22 16:50:28 -03:00
|
|
|
|
2026-04-22 22:10:39 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn hdmi_camera_profile_honors_installed_1080p_override() {
|
|
|
|
|
with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || {
|
|
|
|
|
with_var("LESAVKA_CAM_WIDTH", Some("1920"), || {
|
|
|
|
|
with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || {
|
|
|
|
|
with_var("LESAVKA_CAM_FPS", Some("30"), || {
|
|
|
|
|
let cfg = update_camera_config();
|
|
|
|
|
assert_eq!(cfg.output, CameraOutput::Hdmi);
|
|
|
|
|
assert_eq!(cfg.codec, CameraCodec::H264);
|
|
|
|
|
assert_eq!(cfg.width, 1920);
|
|
|
|
|
assert_eq!(cfg.height, 1080);
|
|
|
|
|
assert_eq!(cfg.fps, 30);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:50:28 -03:00
|
|
|
#[test]
|
|
|
|
|
fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_hdmi_mode("1920x1080"),
|
|
|
|
|
Some(HdmiMode {
|
|
|
|
|
width: 1920,
|
|
|
|
|
height: 1080,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_hdmi_mode("1280x720p60"),
|
|
|
|
|
Some(HdmiMode {
|
|
|
|
|
width: 1280,
|
|
|
|
|
height: 720,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(parse_hdmi_mode("not-a-mode"), None);
|
|
|
|
|
|
|
|
|
|
let modes = parse_hdmi_modes("1920x1080\n1024x768,800x600\n");
|
|
|
|
|
assert_eq!(modes.len(), 3);
|
|
|
|
|
assert_eq!(modes[0].width, 1920);
|
|
|
|
|
assert_eq!(modes[2].height, 600);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn preferred_hdmi_mode_chooses_standard_capture_adapter_mode() {
|
|
|
|
|
let modes = parse_hdmi_modes("1024x768\n1920x1080\n800x600\n");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
preferred_hdmi_mode(&modes),
|
|
|
|
|
Some(HdmiMode {
|
|
|
|
|
width: 1920,
|
|
|
|
|
height: 1080,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let modes = parse_hdmi_modes("1600x900\n1024x768\n");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
preferred_hdmi_mode(&modes),
|
|
|
|
|
Some(HdmiMode {
|
|
|
|
|
width: 1600,
|
|
|
|
|
height: 900,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let modes = parse_hdmi_modes("1024x768\n800x600\n");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
preferred_hdmi_mode(&modes),
|
|
|
|
|
Some(HdmiMode {
|
|
|
|
|
width: 1024,
|
|
|
|
|
height: 768,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn hdmi_display_size_uses_adapter_mode_without_changing_uplink_profile() {
|
|
|
|
|
let cfg = CameraConfig {
|
|
|
|
|
output: CameraOutput::Hdmi,
|
|
|
|
|
codec: CameraCodec::H264,
|
|
|
|
|
width: 1280,
|
|
|
|
|
height: 720,
|
|
|
|
|
fps: 30,
|
|
|
|
|
hdmi: Some(HdmiConnector {
|
|
|
|
|
name: String::from("card1-HDMI-A-2"),
|
|
|
|
|
id: Some(43),
|
|
|
|
|
modes: parse_hdmi_modes("1920x1080\n1024x768\n800x600\n"),
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
with_var("LESAVKA_HDMI_WIDTH", None::<&str>, || {
|
|
|
|
|
with_var("LESAVKA_HDMI_HEIGHT", None::<&str>, || {
|
|
|
|
|
assert_eq!((cfg.width, cfg.height), (1280, 720));
|
|
|
|
|
assert_eq!(cfg.hdmi_display_size(), (1920, 1080));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn hdmi_display_size_honors_explicit_local_override() {
|
|
|
|
|
let cfg = CameraConfig {
|
|
|
|
|
output: CameraOutput::Hdmi,
|
|
|
|
|
codec: CameraCodec::H264,
|
|
|
|
|
width: 1280,
|
|
|
|
|
height: 720,
|
|
|
|
|
fps: 30,
|
|
|
|
|
hdmi: Some(HdmiConnector {
|
|
|
|
|
name: String::from("card1-HDMI-A-2"),
|
|
|
|
|
id: Some(43),
|
|
|
|
|
modes: parse_hdmi_modes("1920x1080\n"),
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
with_var("LESAVKA_HDMI_WIDTH", Some("1024"), || {
|
|
|
|
|
with_var("LESAVKA_HDMI_HEIGHT", Some("768"), || {
|
|
|
|
|
assert_eq!(cfg.hdmi_display_size(), (1024, 768));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-10 15:56:18 -03:00
|
|
|
}
|