use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
use tokio::task::JoinHandle;
use tokio::time;
use crate::lighting::effects::{is_multiplier_channel, FixtureState};
use crate::lighting::EffectEngine;
#[derive(Clone, Debug)]
pub struct FixtureSnapshot {
pub name: String,
pub channels: HashMap<String, u8>,
}
#[derive(Clone, Debug, Default)]
pub struct StateSnapshot {
pub fixtures: Vec<FixtureSnapshot>,
pub active_effects: Vec<String>,
}
#[cfg(test)]
pub fn start_sampler(
effect_engine: Arc<Mutex<EffectEngine>>,
) -> (watch::Receiver<Arc<StateSnapshot>>, JoinHandle<()>) {
let (tx, rx) = watch::channel(Arc::new(StateSnapshot::default()));
let handle = tokio::spawn(sampler_loop(effect_engine, tx));
(rx, handle)
}
pub fn start_sampler_cancellable(
effect_engine: Arc<Mutex<EffectEngine>>,
tx: Arc<watch::Sender<Arc<StateSnapshot>>>,
cancel: tokio_util::sync::CancellationToken,
) -> JoinHandle<()> {
tokio::spawn(sampler_loop_cancellable(effect_engine, tx, cancel))
}
async fn init_dimmer_map(effect_engine: &Arc<Mutex<EffectEngine>>) -> HashMap<String, bool> {
let engine_ref = effect_engine.clone();
tokio::task::spawn_blocking(move || {
let engine = engine_ref.lock();
engine
.get_fixture_registry()
.iter()
.map(|(name, info)| (name.clone(), info.channels.contains_key("dimmer")))
.collect()
})
.await
.unwrap_or_default()
}
async fn sample_tick(
effect_engine: &Arc<Mutex<EffectEngine>>,
has_dimmer_map: &HashMap<String, bool>,
) -> Option<Arc<StateSnapshot>> {
let engine_ref = effect_engine.clone();
let (states, mut active_effects) = tokio::task::spawn_blocking(move || {
let engine = engine_ref.lock();
let states = engine.get_fixture_states();
let effects: Vec<String> = engine.get_active_effects().keys().cloned().collect();
(states, effects)
})
.await
.ok()?;
active_effects.sort();
let fixtures = compute_fixture_snapshots(&states, has_dimmer_map);
Some(Arc::new(StateSnapshot {
fixtures,
active_effects,
}))
}
#[cfg(test)]
async fn sampler_loop(
effect_engine: Arc<Mutex<EffectEngine>>,
tx: watch::Sender<Arc<StateSnapshot>>,
) {
let mut interval = time::interval(Duration::from_millis(50));
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
let has_dimmer_map = init_dimmer_map(&effect_engine).await;
loop {
interval.tick().await;
if let Some(snapshot) = sample_tick(&effect_engine, &has_dimmer_map).await {
let _ = tx.send(snapshot);
}
}
}
async fn sampler_loop_cancellable(
effect_engine: Arc<Mutex<EffectEngine>>,
tx: Arc<watch::Sender<Arc<StateSnapshot>>>,
cancel: tokio_util::sync::CancellationToken,
) {
let mut interval = time::interval(Duration::from_millis(50));
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
let has_dimmer_map = init_dimmer_map(&effect_engine).await;
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = interval.tick() => {}
}
if let Some(snapshot) = sample_tick(&effect_engine, &has_dimmer_map).await {
let _ = tx.send(snapshot);
}
}
}
fn compute_fixture_snapshots(
states: &HashMap<String, FixtureState>,
has_dimmer_map: &HashMap<String, bool>,
) -> Vec<FixtureSnapshot> {
let mut snapshots: Vec<FixtureSnapshot> = states
.iter()
.map(|(name, state)| {
let has_dedicated_dimmer = has_dimmer_map.get(name).copied().unwrap_or(false);
let mut channels = HashMap::new();
for (channel_name, channel_state) in &state.channels {
if is_multiplier_channel(channel_name) {
continue;
}
let value = state.effective_channel_value(
channel_name,
channel_state,
has_dedicated_dimmer,
);
let dmx_value = (value * 255.0) as u8;
channels.insert(channel_name.clone(), dmx_value);
}
FixtureSnapshot {
name: name.clone(),
channels,
}
})
.collect();
snapshots.sort_by(|a, b| a.name.cmp(&b.name));
snapshots
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lighting::effects::{BlendMode, ChannelState, EffectLayer, FixtureState};
#[test]
fn test_compute_fixture_snapshots_rgb_only() {
let mut states = HashMap::new();
let mut fixture_state = FixtureState::new();
fixture_state.set_channel(
"red".to_string(),
ChannelState::new(1.0, EffectLayer::Background, BlendMode::Replace),
);
fixture_state.set_channel(
"green".to_string(),
ChannelState::new(0.5, EffectLayer::Background, BlendMode::Replace),
);
fixture_state.set_channel(
"blue".to_string(),
ChannelState::new(0.0, EffectLayer::Background, BlendMode::Replace),
);
states.insert("test_fixture".to_string(), fixture_state);
let has_dimmer = HashMap::from([("test_fixture".to_string(), false)]);
let snapshots = compute_fixture_snapshots(&states, &has_dimmer);
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].name, "test_fixture");
assert_eq!(*snapshots[0].channels.get("red").unwrap(), 255);
assert_eq!(*snapshots[0].channels.get("green").unwrap(), 127);
assert_eq!(*snapshots[0].channels.get("blue").unwrap(), 0);
}
#[test]
fn test_compute_fixture_snapshots_with_dimmer() {
let mut states = HashMap::new();
let mut fixture_state = FixtureState::new();
fixture_state.set_channel(
"red".to_string(),
ChannelState::new(1.0, EffectLayer::Background, BlendMode::Replace),
);
fixture_state.set_channel(
"dimmer".to_string(),
ChannelState::new(0.5, EffectLayer::Background, BlendMode::Replace),
);
fixture_state.set_channel(
"_dimmer_mult_bg".to_string(),
ChannelState::new(0.5, EffectLayer::Background, BlendMode::Multiply),
);
states.insert("fixture_with_dimmer".to_string(), fixture_state);
let has_dimmer = HashMap::from([("fixture_with_dimmer".to_string(), true)]);
let snapshots = compute_fixture_snapshots(&states, &has_dimmer);
assert_eq!(snapshots.len(), 1);
assert_eq!(*snapshots[0].channels.get("red").unwrap(), 255);
assert_eq!(*snapshots[0].channels.get("dimmer").unwrap(), 127);
}
#[test]
fn test_compute_fixture_snapshots_excludes_multiplier_channels() {
let mut states = HashMap::new();
let mut fixture_state = FixtureState::new();
fixture_state.set_channel(
"red".to_string(),
ChannelState::new(1.0, EffectLayer::Background, BlendMode::Replace),
);
fixture_state.set_channel(
"_dimmer_mult_bg".to_string(),
ChannelState::new(0.5, EffectLayer::Background, BlendMode::Multiply),
);
states.insert("test_fixture".to_string(), fixture_state);
let has_dimmer = HashMap::from([("test_fixture".to_string(), false)]);
let snapshots = compute_fixture_snapshots(&states, &has_dimmer);
assert_eq!(snapshots.len(), 1);
assert!(snapshots[0].channels.contains_key("red"));
assert!(!snapshots[0].channels.contains_key("_dimmer_mult_bg"));
}
#[test]
fn test_snapshots_sorted_by_name() {
let mut states = HashMap::new();
states.insert("zebra".to_string(), FixtureState::new());
states.insert("alpha".to_string(), FixtureState::new());
states.insert("middle".to_string(), FixtureState::new());
let snapshots = compute_fixture_snapshots(&states, &HashMap::new());
assert_eq!(snapshots[0].name, "alpha");
assert_eq!(snapshots[1].name, "middle");
assert_eq!(snapshots[2].name, "zebra");
}
#[test]
fn test_compute_fixture_snapshots_empty() {
let states = HashMap::new();
let snapshots = compute_fixture_snapshots(&states, &HashMap::new());
assert!(snapshots.is_empty());
}
#[test]
fn test_compute_fixture_snapshots_zero_value() {
let mut states = HashMap::new();
let mut fixture_state = FixtureState::new();
fixture_state.set_channel(
"red".to_string(),
ChannelState::new(0.0, EffectLayer::Background, BlendMode::Replace),
);
states.insert("dark_fixture".to_string(), fixture_state);
let has_dimmer = HashMap::new();
let snapshots = compute_fixture_snapshots(&states, &has_dimmer);
assert_eq!(snapshots.len(), 1);
assert_eq!(*snapshots[0].channels.get("red").unwrap(), 0);
}
#[test]
fn test_compute_fixture_snapshots_unknown_fixture_in_dimmer_map() {
let mut states = HashMap::new();
let mut fixture_state = FixtureState::new();
fixture_state.set_channel(
"red".to_string(),
ChannelState::new(0.5, EffectLayer::Background, BlendMode::Replace),
);
states.insert("unknown_fixture".to_string(), fixture_state);
let has_dimmer = HashMap::from([("other_fixture".to_string(), true)]);
let snapshots = compute_fixture_snapshots(&states, &has_dimmer);
assert_eq!(snapshots.len(), 1);
assert_eq!(*snapshots[0].channels.get("red").unwrap(), 127);
}
#[test]
fn test_state_snapshot_default() {
let snapshot = StateSnapshot::default();
assert!(snapshot.fixtures.is_empty());
assert!(snapshot.active_effects.is_empty());
}
#[test]
fn test_fixture_snapshot_clone() {
let mut channels = HashMap::new();
channels.insert("red".to_string(), 255u8);
channels.insert("green".to_string(), 128u8);
let snapshot = FixtureSnapshot {
name: "test".to_string(),
channels,
};
let cloned = snapshot.clone();
assert_eq!(cloned.name, "test");
assert_eq!(*cloned.channels.get("red").unwrap(), 255);
assert_eq!(*cloned.channels.get("green").unwrap(), 128);
}
#[test]
fn test_state_snapshot_clone() {
let snapshot = StateSnapshot {
fixtures: vec![FixtureSnapshot {
name: "f1".to_string(),
channels: HashMap::new(),
}],
active_effects: vec!["effect1".to_string()],
};
let cloned = snapshot.clone();
assert_eq!(cloned.fixtures.len(), 1);
assert_eq!(cloned.active_effects.len(), 1);
}
#[tokio::test]
async fn test_start_sampler_empty_engine() {
let engine = Arc::new(Mutex::new(EffectEngine::new()));
let (mut rx, handle) = start_sampler(engine);
let result = tokio::time::timeout(std::time::Duration::from_secs(2), rx.changed()).await;
assert!(result.is_ok(), "timed out waiting for sampler");
let snapshot = rx.borrow().clone();
assert!(snapshot.fixtures.is_empty());
assert!(snapshot.active_effects.is_empty());
handle.abort();
}
#[tokio::test]
async fn test_start_sampler_with_registered_fixture() {
use crate::lighting::effects::FixtureInfo;
let engine = Arc::new(Mutex::new(EffectEngine::new()));
{
let mut channels = HashMap::new();
channels.insert("red".to_string(), 1);
channels.insert("green".to_string(), 2);
channels.insert("blue".to_string(), 3);
let fixture = FixtureInfo::new(
"test_light".to_string(),
1,
1,
"rgb".to_string(),
channels,
None,
);
engine.lock().register_fixture(fixture);
}
let (mut rx, handle) = start_sampler(engine);
let result = tokio::time::timeout(std::time::Duration::from_secs(2), rx.changed()).await;
assert!(result.is_ok(), "timed out waiting for sampler");
let snapshot = rx.borrow().clone();
assert!(snapshot.active_effects.is_empty());
handle.abort();
}
#[tokio::test]
async fn test_start_sampler_multiple_ticks() {
let engine = Arc::new(Mutex::new(EffectEngine::new()));
let (mut rx, handle) = start_sampler(engine);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), rx.changed()).await;
let result = tokio::time::timeout(std::time::Duration::from_secs(2), rx.changed()).await;
assert!(result.is_ok(), "timed out waiting for second sampler tick");
handle.abort();
}
#[tokio::test]
async fn test_sampler_stops_when_receiver_dropped() {
let engine = Arc::new(Mutex::new(EffectEngine::new()));
let (rx, handle) = start_sampler(engine);
drop(rx);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
handle.abort();
let _ = handle.await;
}
}