use super::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector, HdmiMode, LAST_CONFIG}; use gstreamer as gst; use std::collections::HashMap; use std::fs; use tracing::{info, warn}; #[cfg(coverage)] pub(super) 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))] pub(super) fn select_camera_config() -> CameraConfig { let output_env = std::env::var("LESAVKA_CAM_OUTPUT").ok(); let output_override = output_env.as_deref().and_then(parse_camera_output); 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(), }; let (display_width, display_height) = cfg.hdmi_display_size(); info!( output = cfg.output.as_str(), codec = cfg.codec.as_str(), width = cfg.width, height = cfg.height, fps = cfg.fps, display_width, display_height, 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 { match raw.trim().to_ascii_lowercase().as_str() { "uvc" => Some(CameraOutput::Uvc), "hdmi" => Some(CameraOutput::Hdmi), "auto" | "" => None, _ => None, } } fn parse_camera_codec(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "h264" => Some(CameraCodec::H264), "mjpeg" | "mjpg" | "jpeg" => Some(CameraCodec::Mjpeg), _ => None, } } fn select_hdmi_codec(hw_decode: bool) -> CameraCodec { std::env::var("LESAVKA_CAM_CODEC") .ok() .as_deref() .and_then(parse_camera_codec) .unwrap_or(if hw_decode { CameraCodec::H264 } else { CameraCodec::Mjpeg }) } fn select_uvc_codec(uvc_env: Option<&HashMap>) -> CameraCodec { std::env::var("LESAVKA_UVC_CODEC") .ok() .or_else(|| std::env::var("LESAVKA_CAM_CODEC").ok()) .or_else(|| uvc_env.and_then(|env| env.get("LESAVKA_UVC_CODEC").cloned())) .or_else(|| uvc_env.and_then(|env| env.get("LESAVKA_CAM_CODEC").cloned())) .as_deref() .and_then(parse_camera_codec) .unwrap_or(CameraCodec::Mjpeg) } fn select_hdmi_config(hdmi: Option) -> CameraConfig { let hw_decode = has_hw_h264_decode(); 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); let codec = select_hdmi_codec(hw_decode); #[cfg(not(coverage))] if !hw_decode { if matches!(codec, CameraCodec::Mjpeg) { warn!( width, height, fps, "📷 HDMI output: hardware H264 decoder not detected; preferring MJPEG uplink" ); } else if width == default_width && height == default_height { warn!( "📷 HDMI output: hardware H264 decoder not detected; forcing H264 uplink at requested size" ); } else { warn!( width, height, fps, "📷 HDMI output: hardware H264 decoder not detected; using configured camera uplink size" ); } } CameraConfig { output: CameraOutput::Hdmi, codec, width, height, fps, hdmi, } } #[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(30); let codec = select_uvc_codec(None); CameraConfig { output: CameraOutput::Uvc, codec, width, height, fps, hdmi: None, } } #[cfg(not(coverage))] 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(30); let codec = select_uvc_codec(Some(&uvc_env)); CameraConfig { output: CameraOutput::Uvc, codec, width, height, fps, hdmi: None, } } #[cfg(coverage)] fn has_hw_h264_decode() -> bool { std::env::var("LESAVKA_HW_H264").is_ok() } #[cfg(not(coverage))] 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 } #[cfg(coverage)] fn detect_hdmi_connector(require_connected: bool) -> Option { let _ = require_connected; std::env::var("LESAVKA_HDMI_CONNECTOR") .ok() .map(|name| HdmiConnector { name, id: None, modes: std::env::var("LESAVKA_HDMI_MODES") .ok() .map(|raw| parse_hdmi_modes(&raw)) .unwrap_or_default(), }) } #[cfg(not(coverage))] fn detect_hdmi_connector(require_connected: bool) -> Option { 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::().ok()); 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)); } connectors.sort_by(|a, b| a.0.cmp(&b.0)); let matches_preferred = |name: &str, preferred: &str| name == preferred || name.ends_with(preferred); if let Some(pref) = preferred.as_deref() { for (name, status, id, modes) in &connectors { if matches_preferred(name, pref) && (!require_connected || status == "connected") { return Some(HdmiConnector { name: name.clone(), id: *id, modes: modes.clone(), }); } } } 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 { for (name, status, id, modes) in &connectors { if *name == prev && (!require_connected || status == "connected") { return Some(HdmiConnector { name: name.clone(), id: *id, modes: modes.clone(), }); } } } } for (name, status, id, modes) in connectors { if !require_connected || status == "connected" { return Some(HdmiConnector { name, id, modes }); } } None } pub(crate) fn parse_hdmi_modes(raw: &str) -> Vec { raw.lines() .flat_map(|line| line.split(',')) .filter_map(parse_hdmi_mode) .collect() } pub(crate) fn parse_hdmi_mode(raw: &str) -> Option { let raw = raw.trim(); let (width, rest) = raw.split_once('x')?; let width = width.trim().parse::().ok()?; let height_digits: String = rest .trim() .chars() .take_while(|ch| ch.is_ascii_digit()) .collect(); let height = height_digits.parse::().ok()?; (width > 0 && height > 0).then_some(HdmiMode { width, height }) } pub(crate) fn preferred_hdmi_mode(modes: &[HdmiMode]) -> Option { 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()) } #[cfg(not(coverage))] fn parse_env_file(text: &str) -> HashMap { 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 } pub(crate) fn read_u32_from_env(key: &str) -> Option { std::env::var(key).ok().and_then(|v| v.parse::().ok()) } #[cfg(not(coverage))] fn read_u32_from_map(map: &HashMap, key: &str) -> Option { map.get(key).and_then(|v| v.parse::().ok()) }