lesavka/server/src/camera/selection.rs

384 lines
12 KiB
Rust

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<CameraOutput> {
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<CameraCodec> {
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<String, String>>) -> 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<HdmiConnector>) -> 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<HdmiConnector> {
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<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());
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<HdmiMode> {
raw.lines()
.flat_map(|line| line.split(','))
.filter_map(parse_hdmi_mode)
.collect()
}
pub(crate) 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 })
}
pub(crate) 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())
}
#[cfg(not(coverage))]
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
}
pub(crate) fn read_u32_from_env(key: &str) -> Option<u32> {
std::env::var(key).ok().and_then(|v| v.parse::<u32>().ok())
}
#[cfg(not(coverage))]
fn read_u32_from_map(map: &HashMap<String, String>, key: &str) -> Option<u32> {
map.get(key).and_then(|v| v.parse::<u32>().ok())
}