lesavka/server/src/camera_runtime.rs

447 lines
15 KiB
Rust

#![cfg_attr(coverage, allow(dead_code, unused_imports, unused_variables))]
#![forbid(unsafe_code)]
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::Mutex;
use tonic::Status;
use tracing::info;
use crate::{camera, uvc_runtime, video};
struct CameraRelaySlot {
profile: CameraRelayProfile,
relay: Arc<video::CameraRelay>,
}
#[derive(Clone, Debug)]
struct CameraRelayProfile {
primary: camera::CameraConfig,
hdmi_mirror: Option<camera::CameraConfig>,
}
/// Manage the currently active camera relay instance.
///
/// Inputs: camera configurations requested by incoming RPC streams.
/// Outputs: a reusable relay handle plus a monotonically increasing session id.
/// Why: only one camera output should own the physical sink at a time, but we
/// still want identical stream requests to reuse the existing pipeline.
pub struct CameraRuntime {
generation: AtomicU64,
slot: Mutex<Option<CameraRelaySlot>>,
}
impl CameraRuntime {
/// Create an empty runtime with no active relay.
///
/// Inputs: none.
/// Outputs: a fresh runtime with generation zero.
/// Why: keeping construction trivial lets the main server handler create
/// camera state early and share it across RPCs.
#[must_use]
pub fn new() -> Self {
Self {
generation: AtomicU64::new(0),
slot: Mutex::new(None),
}
}
/// Activate the relay matching the current configuration.
///
/// Inputs: the desired camera configuration selected from the environment.
/// Outputs: a session id plus a relay that is either reused or recreated.
/// Why: UVC/HDMI sinks are expensive to churn, so identical requests should
/// reuse the active pipeline instead of rebuilding it every time.
#[cfg(coverage)]
pub async fn activate(
&self,
cfg: &camera::CameraConfig,
) -> Result<(u64, Arc<video::CameraRelay>, bool), Status> {
let session_id = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
if matches!(cfg.output, camera::CameraOutput::Uvc)
&& std::env::var("LESAVKA_DISABLE_UVC").is_ok()
{
return Err(Status::failed_precondition(
"UVC output disabled (LESAVKA_DISABLE_UVC set)",
));
}
Ok((session_id, Arc::new(video::CameraRelay::new_noop(0)), false))
}
#[cfg(not(coverage))]
pub async fn activate(
&self,
cfg: &camera::CameraConfig,
) -> Result<(u64, Arc<video::CameraRelay>, bool), Status> {
let session_id = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
let requested_profile = camera_relay_profile(cfg);
let mut slot = self.slot.lock().await;
let mut reused = false;
let relay = if let Some(existing) = slot.as_ref() {
if camera_relay_profile_eq(&existing.profile, &requested_profile) {
reused = true;
existing.relay.clone()
} else {
self.make_relay(&requested_profile)?
}
} else {
self.make_relay(&requested_profile)?
};
if !reused {
*slot = Some(CameraRelaySlot {
profile: requested_profile.clone(),
relay: relay.clone(),
});
info!(
session_id,
output = requested_profile.primary.output.as_str(),
codec = requested_profile.primary.codec.as_str(),
width = requested_profile.primary.width,
height = requested_profile.primary.height,
fps = requested_profile.primary.fps,
hdmi_mirror = requested_profile.hdmi_mirror.is_some(),
"🎥 camera relay (re)created"
);
} else {
info!(session_id, "🎥 camera relay reused");
}
Ok((session_id, relay, reused))
}
/// Check whether a previously issued session id is still current.
///
/// Inputs: a session id returned by `activate`.
/// Outputs: `true` only when the session is still the most recent owner of
/// the active camera relay.
/// Why: superseded streams must stop writing frames into a sink that has
/// already been reconfigured for a newer client session.
#[must_use]
pub fn is_active(&self, session_id: u64) -> bool {
self.generation.load(Ordering::Relaxed) == session_id
}
/// Release the active relay when the owning stream has ended.
///
/// Inputs: the camera session id returned by `activate`.
/// Outputs: true only when that session was still current and was
/// superseded. Why: keeping a completed UVC session's GStreamer pipeline
/// alive preserves native buffers and makes allocator retention look like
/// a server leak after the client UI disconnects.
pub async fn release_if_active(&self, session_id: u64) -> bool {
if self
.generation
.compare_exchange(
session_id,
session_id.saturating_add(1),
Ordering::SeqCst,
Ordering::Relaxed,
)
.is_err()
{
return false;
}
let mut slot = self.slot.lock().await;
let released = slot.take().is_some();
info!(
session_id,
released, "🎥 camera relay released after stream lifecycle ended"
);
true
}
/// Supersede the active camera stream and drop the userspace relay sink.
///
/// Inputs: none.
/// Outputs: none.
/// Why: UVC recovery should make the client reconnect and recreate the
/// spool/appsrc pipeline without cycling the USB controller while a browser
/// may still own the gadget.
#[cfg(coverage)]
pub async fn soft_recover(&self) {
self.generation.fetch_add(1, Ordering::SeqCst);
}
#[cfg(not(coverage))]
pub async fn soft_recover(&self) {
self.generation.fetch_add(1, Ordering::SeqCst);
let mut slot = self.slot.lock().await;
let _dropped = slot.take();
}
#[allow(clippy::result_large_err)]
#[cfg(not(coverage))]
fn make_relay(&self, profile: &CameraRelayProfile) -> Result<Arc<video::CameraRelay>, Status> {
let cfg = &profile.primary;
let relay = match cfg.output {
camera::CameraOutput::Uvc => {
if std::env::var("LESAVKA_DISABLE_UVC").is_ok() {
return Err(Status::failed_precondition(
"UVC output disabled (LESAVKA_DISABLE_UVC set)",
));
}
let uvc = uvc_runtime::pick_uvc_device()
.map_err(|e| Status::internal(format!("{e:#}")))?;
if let Some(hdmi_cfg) = profile.hdmi_mirror.as_ref() {
let display_size = hdmi_cfg.hdmi_display_size();
info!(
%uvc,
hdmi = hdmi_cfg
.hdmi
.as_ref()
.map(|h| h.name.as_str())
.unwrap_or("none"),
display_width = display_size.0,
display_height = display_size.1,
"🎥 stream_camera using UVC sink with HDMI mirror"
);
video::CameraRelay::new_uvc_with_hdmi_mirror(0, &uvc, cfg, hdmi_cfg)
.map_err(|e| Status::internal(format!("{e:#}")))?
} else {
info!(%uvc, "🎥 stream_camera using UVC sink");
video::CameraRelay::new_uvc(0, &uvc, cfg)
.map_err(|e| Status::internal(format!("{e:#}")))?
}
}
camera::CameraOutput::Hdmi => video::CameraRelay::new_hdmi(0, cfg)
.map_err(|e| Status::internal(format!("{e:#}")))?,
};
Ok(Arc::new(relay))
}
}
impl Default for CameraRuntime {
fn default() -> Self {
Self::new()
}
}
/// Compare two camera configurations for sink reuse.
///
/// Inputs: the currently active camera config and the requested config.
/// Outputs: `true` when both configs target the same sink and stream profile.
/// Why: reusing a pipeline is only safe when both the transport parameters and
/// the HDMI connector identity still match.
#[must_use]
pub fn camera_cfg_eq(a: &camera::CameraConfig, b: &camera::CameraConfig) -> bool {
if a.output != b.output
|| a.codec != b.codec
|| a.width != b.width
|| a.height != b.height
|| a.fps != b.fps
{
return false;
}
if a.output == camera::CameraOutput::Hdmi && a.hdmi_display_size() != b.hdmi_display_size() {
return false;
}
match (&a.hdmi, &b.hdmi) {
(Some(left), Some(right)) => left.name == right.name && left.id == right.id,
(None, None) => true,
_ => false,
}
}
fn camera_relay_profile(cfg: &camera::CameraConfig) -> CameraRelayProfile {
let hdmi_mirror = (cfg.output == camera::CameraOutput::Uvc)
.then(|| match hdmi_mirror_setting() {
HdmiMirrorSetting::Enabled => Some(camera::hdmi_mirror_config(cfg)),
HdmiMirrorSetting::Auto if hdmi_mirror_connector_is_configured() => {
let mirror = camera::hdmi_mirror_config(cfg);
mirror.hdmi.is_some().then_some(mirror)
}
HdmiMirrorSetting::Auto | HdmiMirrorSetting::Disabled => None,
})
.flatten();
CameraRelayProfile {
primary: cfg.clone(),
hdmi_mirror,
}
}
fn camera_relay_profile_eq(a: &CameraRelayProfile, b: &CameraRelayProfile) -> bool {
camera_cfg_eq(&a.primary, &b.primary)
&& match (&a.hdmi_mirror, &b.hdmi_mirror) {
(Some(left), Some(right)) => camera_cfg_eq(left, right),
(None, None) => true,
_ => false,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum HdmiMirrorSetting {
Disabled,
Enabled,
Auto,
}
fn hdmi_mirror_setting() -> HdmiMirrorSetting {
std::env::var("LESAVKA_CAM_HDMI_MIRROR")
.ok()
.map(|value| match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => HdmiMirrorSetting::Enabled,
"auto" => HdmiMirrorSetting::Auto,
_ => HdmiMirrorSetting::Disabled,
})
.unwrap_or(HdmiMirrorSetting::Disabled)
}
fn hdmi_mirror_connector_is_configured() -> bool {
std::env::var("LESAVKA_HDMI_CONNECTOR")
.ok()
.is_some_and(|value| !value.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::{HdmiMirrorSetting, camera_cfg_eq, camera_relay_profile, camera_relay_profile_eq};
use crate::camera::{CameraCodec, CameraConfig, CameraOutput, HdmiConnector};
use serial_test::serial;
#[test]
fn camera_cfg_eq_requires_matching_sink_profile() {
let base = CameraConfig {
output: CameraOutput::Hdmi,
codec: CameraCodec::H264,
width: 1920,
height: 1080,
fps: 30,
hdmi: Some(HdmiConnector {
name: String::from("HDMI-A-1"),
id: Some(42),
modes: Vec::new(),
}),
};
let same = base.clone();
assert!(camera_cfg_eq(&base, &same));
let mut changed = base.clone();
changed.fps = 25;
assert!(!camera_cfg_eq(&base, &changed));
changed = base.clone();
changed.hdmi = Some(HdmiConnector {
name: String::from("HDMI-A-2"),
id: Some(42),
modes: Vec::new(),
});
assert!(!camera_cfg_eq(&base, &changed));
}
#[test]
#[serial]
fn camera_relay_profile_tracks_hdmi_mirror_flag() {
let base = CameraConfig {
output: CameraOutput::Uvc,
codec: CameraCodec::Mjpeg,
width: 1280,
height: 720,
fps: 20,
hdmi: None,
};
temp_env::with_var("LESAVKA_CAM_HDMI_MIRROR", None::<&str>, || {
let profile = camera_relay_profile(&base);
assert!(profile.hdmi_mirror.is_none());
});
temp_env::with_var("LESAVKA_CAM_HDMI_MIRROR", Some("1"), || {
let profile = camera_relay_profile(&base);
let mirror = profile.hdmi_mirror.as_ref().expect("HDMI mirror config");
assert_eq!(mirror.output, CameraOutput::Hdmi);
assert_eq!(mirror.codec, CameraCodec::Mjpeg);
assert_eq!((mirror.width, mirror.height, mirror.fps), (1280, 720, 20));
});
}
#[test]
#[serial]
fn camera_relay_profile_auto_mirrors_only_when_hdmi_is_detected() {
let base = CameraConfig {
output: CameraOutput::Uvc,
codec: CameraCodec::Mjpeg,
width: 1280,
height: 720,
fps: 20,
hdmi: None,
};
temp_env::with_vars(
[
("LESAVKA_CAM_HDMI_MIRROR", Some("auto")),
("LESAVKA_HDMI_CONNECTOR", None::<&str>),
],
|| {
assert_eq!(super::hdmi_mirror_setting(), HdmiMirrorSetting::Auto);
assert!(camera_relay_profile(&base).hdmi_mirror.is_none());
},
);
temp_env::with_vars(
[
("LESAVKA_CAM_HDMI_MIRROR", Some("auto")),
("LESAVKA_HDMI_CONNECTOR", Some("card1-HDMI-A-1")),
],
|| {
let profile = camera_relay_profile(&base);
let mirror = profile
.hdmi_mirror
.as_ref()
.expect("auto HDMI mirror config");
assert_eq!(mirror.output, CameraOutput::Hdmi);
assert_eq!(mirror.hdmi.as_ref().unwrap().name, "card1-HDMI-A-1");
},
);
}
#[test]
#[serial]
fn camera_relay_profile_reuse_requires_same_mirror_connector() {
let base = CameraConfig {
output: CameraOutput::Uvc,
codec: CameraCodec::Mjpeg,
width: 1280,
height: 720,
fps: 20,
hdmi: None,
};
let left = CameraConfig {
output: CameraOutput::Hdmi,
hdmi: Some(HdmiConnector {
name: String::from("HDMI-A-1"),
id: Some(41),
modes: Vec::new(),
}),
..base.clone()
};
let right = CameraConfig {
output: CameraOutput::Hdmi,
hdmi: Some(HdmiConnector {
name: String::from("HDMI-A-2"),
id: Some(42),
modes: Vec::new(),
}),
..base.clone()
};
assert!(!camera_relay_profile_eq(
&super::CameraRelayProfile {
primary: base.clone(),
hdmi_mirror: Some(left),
},
&super::CameraRelayProfile {
primary: base,
hdmi_mirror: Some(right),
},
));
}
}