use std::collections::HashMap;
use std::time::Duration;
pub const DMX_CHANNELS: usize = 512;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct DmxSnapshot {
pub channels: Vec<u8>,
pub universe: u16,
}
impl DmxSnapshot {
#[must_use]
pub fn blackout(universe: u16) -> Self {
Self {
channels: vec![0u8; DMX_CHANNELS],
universe,
}
}
#[must_use]
pub fn full_on(universe: u16) -> Self {
Self {
channels: vec![255u8; DMX_CHANNELS],
universe,
}
}
#[must_use]
pub fn get(&self, ch: usize) -> u8 {
self.channels.get(ch.min(DMX_CHANNELS - 1)).copied().unwrap_or(0)
}
pub fn set(&mut self, ch: usize, value: u8) {
let idx = ch.min(DMX_CHANNELS - 1);
if let Some(slot) = self.channels.get_mut(idx) {
*slot = value;
}
}
#[must_use]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn lerp(&self, target: &Self, t: f64) -> Self {
let t = t.clamp(0.0, 1.0);
let mut out = Self::blackout(self.universe);
let len = self.channels.len().min(target.channels.len()).min(DMX_CHANNELS);
for i in 0..len {
let a = self.channels[i] as f64;
let b = target.channels[i] as f64;
out.channels[i] = (a + (b - a) * t).round() as u8;
}
out
}
#[must_use]
pub fn max_diff(&self, other: &Self) -> u8 {
self.channels
.iter()
.zip(other.channels.iter())
.map(|(a, b)| a.abs_diff(*b))
.max()
.unwrap_or(0)
}
}
impl Default for DmxSnapshot {
fn default() -> Self {
Self::blackout(0)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ScenePreset {
pub name: String,
pub universe: u16,
pub channel_values: HashMap<usize, u8>,
pub default_fade_in: Duration,
pub default_fade_out: Duration,
}
impl ScenePreset {
#[must_use]
pub fn new(name: impl Into<String>, universe: u16) -> Self {
Self {
name: name.into(),
universe,
channel_values: HashMap::new(),
default_fade_in: Duration::from_millis(500),
default_fade_out: Duration::from_millis(500),
}
}
#[must_use]
pub fn with_fade(mut self, fade_in: Duration, fade_out: Duration) -> Self {
self.default_fade_in = fade_in;
self.default_fade_out = fade_out;
self
}
pub fn set_channel(&mut self, ch: usize, value: u8) {
self.channel_values.insert(ch, value);
}
#[must_use]
pub fn get_channel(&self, ch: usize) -> u8 {
self.channel_values.get(&ch).copied().unwrap_or(0)
}
#[must_use]
pub fn to_snapshot(&self) -> DmxSnapshot {
let mut snap = DmxSnapshot::blackout(self.universe);
for (&ch, &val) in &self.channel_values {
if ch < DMX_CHANNELS {
snap.channels[ch] = val;
}
}
snap
}
pub fn capture_from_snapshot(&mut self, snap: &DmxSnapshot) {
self.universe = snap.universe;
for (ch, &val) in snap.channels.iter().enumerate() {
if val > 0 {
self.channel_values.insert(ch, val);
} else {
self.channel_values.remove(&ch);
}
}
}
}
#[derive(Debug, Clone)]
pub struct CrossfadeState {
pub from: DmxSnapshot,
pub to: DmxSnapshot,
pub duration: Duration,
pub elapsed: Duration,
pub complete: bool,
}
impl CrossfadeState {
#[must_use]
pub fn new(from: DmxSnapshot, to: DmxSnapshot, duration: Duration) -> Self {
Self {
from,
to,
duration,
elapsed: Duration::ZERO,
complete: false,
}
}
pub fn advance(&mut self, dt: Duration) -> DmxSnapshot {
if self.complete {
return self.to.clone();
}
self.elapsed = (self.elapsed + dt).min(self.duration);
let t = if self.duration.is_zero() {
1.0
} else {
self.elapsed.as_secs_f64() / self.duration.as_secs_f64()
};
if t >= 1.0 {
self.complete = true;
}
self.from.lerp(&self.to, t.clamp(0.0, 1.0))
}
#[must_use]
pub fn progress(&self) -> f64 {
if self.duration.is_zero() {
1.0
} else {
(self.elapsed.as_secs_f64() / self.duration.as_secs_f64()).clamp(0.0, 1.0)
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CueEntry {
pub number: u32,
pub preset_name: String,
pub fade_in: Option<Duration>,
pub follow_delay: Option<Duration>,
}
impl CueEntry {
#[must_use]
pub fn new(number: u32, preset_name: impl Into<String>) -> Self {
Self {
number,
preset_name: preset_name.into(),
fade_in: None,
follow_delay: None,
}
}
#[must_use]
pub fn with_fade_in(mut self, d: Duration) -> Self {
self.fade_in = Some(d);
self
}
#[must_use]
pub fn with_follow(mut self, delay: Duration) -> Self {
self.follow_delay = Some(delay);
self
}
}
#[derive(Debug, Clone)]
pub struct CueList {
pub name: String,
entries: Vec<CueEntry>,
active_index: Option<usize>,
active_fade: Option<CrossfadeState>,
follow_elapsed: Duration,
live: DmxSnapshot,
}
impl CueList {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
entries: Vec::new(),
active_index: None,
active_fade: None,
follow_elapsed: Duration::ZERO,
live: DmxSnapshot::blackout(0),
}
}
pub fn add_cue(&mut self, entry: CueEntry) {
self.entries.push(entry);
}
#[must_use]
pub fn cue_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn active_index(&self) -> Option<usize> {
self.active_index
}
pub fn go_next(
&mut self,
presets: &HashMap<String, ScenePreset>,
) -> bool {
let next = match self.active_index {
None => 0,
Some(i) => i + 1,
};
if next >= self.entries.len() {
return false;
}
self.go_to(next, presets);
true
}
pub fn go_to(
&mut self,
index: usize,
presets: &HashMap<String, ScenePreset>,
) {
if index >= self.entries.len() {
return;
}
let entry = &self.entries[index];
let target_snap = presets
.get(&entry.preset_name)
.map(|p| p.to_snapshot())
.unwrap_or_else(|| DmxSnapshot::blackout(self.live.universe));
let fade = entry
.fade_in
.or_else(|| presets.get(&entry.preset_name).map(|p| p.default_fade_in))
.unwrap_or(Duration::from_millis(500));
self.active_fade = Some(CrossfadeState::new(self.live.clone(), target_snap, fade));
self.active_index = Some(index);
self.follow_elapsed = Duration::ZERO;
}
pub fn advance(
&mut self,
dt: Duration,
presets: &HashMap<String, ScenePreset>,
) -> DmxSnapshot {
if let Some(fade) = &mut self.active_fade {
self.live = fade.advance(dt);
if fade.complete {
self.active_fade = None;
}
}
if self.active_fade.is_none() {
if let Some(idx) = self.active_index {
let entry = &self.entries[idx];
if let Some(follow_delay) = entry.follow_delay {
self.follow_elapsed += dt;
if self.follow_elapsed >= follow_delay {
if self.go_next(presets) {
if let Some(fade) = &mut self.active_fade {
self.live = fade.advance(Duration::ZERO);
if fade.complete {
self.active_fade = None;
}
}
}
}
}
}
}
self.live.clone()
}
pub fn snap_to(
&mut self,
index: usize,
presets: &HashMap<String, ScenePreset>,
) {
if index >= self.entries.len() {
return;
}
let entry = &self.entries[index];
if let Some(preset) = presets.get(&entry.preset_name) {
self.live = preset.to_snapshot();
}
self.active_index = Some(index);
self.active_fade = None;
self.follow_elapsed = Duration::ZERO;
}
#[must_use]
pub fn live_snapshot(&self) -> &DmxSnapshot {
&self.live
}
}
#[derive(Debug, Default, Clone)]
pub struct SceneLibrary {
presets: HashMap<String, ScenePreset>,
}
impl SceneLibrary {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn store(&mut self, preset: ScenePreset) {
self.presets.insert(preset.name.clone(), preset);
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&ScenePreset> {
self.presets.get(name)
}
pub fn remove(&mut self, name: &str) -> Option<ScenePreset> {
self.presets.remove(name)
}
#[must_use]
pub fn count(&self) -> usize {
self.presets.len()
}
#[must_use]
pub fn snapshot(&self, name: &str) -> Option<DmxSnapshot> {
self.presets.get(name).map(|p| p.to_snapshot())
}
#[must_use]
pub fn presets(&self) -> &HashMap<String, ScenePreset> {
&self.presets
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn make_preset(name: &str, ch0_val: u8) -> ScenePreset {
let mut p = ScenePreset::new(name, 0);
p.set_channel(0, ch0_val);
p
}
#[test]
fn test_snapshot_blackout_and_full() {
let b = DmxSnapshot::blackout(0);
assert_eq!(b.get(0), 0);
let f = DmxSnapshot::full_on(0);
assert_eq!(f.get(511), 255);
}
#[test]
fn test_snapshot_lerp_midpoint() {
let from = DmxSnapshot::blackout(0);
let to = DmxSnapshot::full_on(0);
let mid = from.lerp(&to, 0.5);
assert!(mid.get(0) >= 127 && mid.get(0) <= 128);
}
#[test]
fn test_snapshot_max_diff() {
let a = DmxSnapshot::blackout(0);
let b = DmxSnapshot::full_on(0);
assert_eq!(a.max_diff(&b), 255);
}
#[test]
fn test_preset_to_snapshot() {
let mut p = ScenePreset::new("wash", 0);
p.set_channel(5, 200);
let snap = p.to_snapshot();
assert_eq!(snap.get(5), 200);
assert_eq!(snap.get(0), 0);
}
#[test]
fn test_preset_capture_from_snapshot() {
let snap = DmxSnapshot::full_on(1);
let mut p = ScenePreset::new("full", 0);
p.capture_from_snapshot(&snap);
assert_eq!(p.universe, 1);
assert_eq!(p.get_channel(0), 255);
}
#[test]
fn test_crossfader_advance_to_completion() {
let from = DmxSnapshot::blackout(0);
let to = DmxSnapshot::full_on(0);
let mut fade = CrossfadeState::new(from, to, Duration::from_millis(100));
let _ = fade.advance(Duration::from_millis(50));
assert!(!fade.complete);
let final_snap = fade.advance(Duration::from_millis(100));
assert!(fade.complete);
assert_eq!(final_snap.get(0), 255);
}
#[test]
fn test_crossfader_zero_duration() {
let from = DmxSnapshot::blackout(0);
let to = DmxSnapshot::full_on(0);
let mut fade = CrossfadeState::new(from, to, Duration::ZERO);
let snap = fade.advance(Duration::ZERO);
assert!(fade.complete);
assert_eq!(snap.get(0), 255);
}
#[test]
fn test_cue_list_go_next() {
let mut lib = SceneLibrary::new();
lib.store(make_preset("scene_a", 100));
lib.store(make_preset("scene_b", 200));
let mut cue_list = CueList::new("main");
cue_list.add_cue(CueEntry::new(1, "scene_a").with_fade_in(Duration::ZERO));
cue_list.add_cue(CueEntry::new(2, "scene_b").with_fade_in(Duration::ZERO));
assert!(cue_list.go_next(lib.presets()));
let _ = cue_list.advance(Duration::from_millis(100), lib.presets());
assert_eq!(cue_list.active_index(), Some(0));
assert!(cue_list.go_next(lib.presets()));
let snap = cue_list.advance(Duration::from_millis(100), lib.presets());
assert_eq!(cue_list.active_index(), Some(1));
assert_eq!(snap.get(0), 200);
}
#[test]
fn test_cue_list_snap_to() {
let mut lib = SceneLibrary::new();
lib.store(make_preset("scene_a", 77));
let mut cue_list = CueList::new("main");
cue_list.add_cue(CueEntry::new(1, "scene_a"));
cue_list.snap_to(0, lib.presets());
assert_eq!(cue_list.live_snapshot().get(0), 77);
}
#[test]
fn test_cue_list_follow_timing() {
let mut lib = SceneLibrary::new();
lib.store(make_preset("a", 50));
lib.store(make_preset("b", 150));
let mut cue_list = CueList::new("follow");
cue_list.add_cue(
CueEntry::new(1, "a")
.with_fade_in(Duration::ZERO)
.with_follow(Duration::from_millis(50)),
);
cue_list.add_cue(CueEntry::new(2, "b").with_fade_in(Duration::ZERO));
cue_list.go_to(0, lib.presets());
let _ = cue_list.advance(Duration::from_millis(10), lib.presets());
assert_eq!(cue_list.active_index(), Some(0));
let snap = cue_list.advance(Duration::from_millis(100), lib.presets());
assert_eq!(cue_list.active_index(), Some(1));
assert_eq!(snap.get(0), 150);
}
#[test]
fn test_scene_library_store_and_retrieve() {
let mut lib = SceneLibrary::new();
lib.store(make_preset("night", 10));
assert!(lib.get("night").is_some());
assert_eq!(lib.count(), 1);
let snap = lib.snapshot("night").expect("snap");
assert_eq!(snap.get(0), 10);
lib.remove("night");
assert_eq!(lib.count(), 0);
}
}