add lightcontrol rust tool
Three binaries against the c-base mainhall rig: - lightcontrol: TUI with 15 animated presets, live matelight-sync via the /monitor WebSocket, and a microphone beat detector that drives the BeatPulse preset across RGB PARs, CB-100 colour-wheels, and TSL-250 scanners. - panels: one-shot RGB wash for the Showtec LED Par 56 wall panels via dmxbackend. - bars: ArtNet direct driver for the Stairville SonicPulse LED bars, with ArtPoll discovery and a universe sweeper. Shared lib code (fixture model, render pipeline, matelight + beat modules) lives in src/lib.rs. Mic feature behind the default `mic` cargo feature so builds without ALSA dev libs work via --no-default-features. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
de43fa4ea4
commit
55bd37cd99
10 changed files with 6401 additions and 0 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Rust build artefacts
|
||||
/target/
|
||||
|
||||
# Editor / OS noise
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# QLC+ autosaves (the canonical .qxw is committed separately if desired)
|
||||
*.qxw.autosave
|
||||
*.autosave.qxw
|
||||
|
||||
# Local distribution packages — don't drag installers into the repo
|
||||
*.deb
|
||||
2781
Cargo.lock
generated
Normal file
2781
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "lightcontrol"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = ["mic"]
|
||||
mic = ["dep:cpal"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cpal = { version = "0.15", optional = true }
|
||||
crossterm = "0.28"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||
ratatui = "0.29"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
470
src/beat.rs
Normal file
470
src/beat.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
//! Microphone-driven beat detection.
|
||||
//!
|
||||
//! Captures audio from the default input device via `cpal`, runs an
|
||||
//! energy-onset detector against the live samples, and exposes the latest
|
||||
//! beat state through a shared `Arc<std::sync::Mutex<BeatState>>`. Beat
|
||||
//! state is consumed by the `BeatPulse` preset to drive the hall lights in
|
||||
//! sync with the room's audio.
|
||||
//!
|
||||
//! Detector design — single-band energy onset, robust enough for a club
|
||||
//! environment:
|
||||
//!
|
||||
//! 1. Downmix incoming samples to mono.
|
||||
//! 2. Compute RMS energy per ~23 ms chunk (1024 samples @ 44.1 kHz).
|
||||
//! 3. Track two single-pole envelopes: a fast one (~50 ms) following the
|
||||
//! instantaneous level, and a slow one (~1.5 s) acting as the running
|
||||
//! baseline.
|
||||
//! 4. Trigger a beat when the short envelope crosses `threshold ×
|
||||
//! long envelope`, gated by a refractory period (≥200 ms ⇒ ≤300 BPM).
|
||||
//! 5. Median of the last 8 inter-beat intervals → BPM estimate.
|
||||
//!
|
||||
//! When the `mic` cargo feature is off, this module still compiles but the
|
||||
//! feeder is a no-op that just reports "mic disabled". The `BeatPulse`
|
||||
//! preset still works as a "no-mic" placeholder pulse.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Snapshot passed to renderers per frame. Computed in `main.rs` from the
|
||||
/// latest `BeatState` at render time so it has the freshest `time_since_beat`.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct BeatInput {
|
||||
/// Seconds since the most recent beat. Large (e.g. 99.0) when no beat
|
||||
/// has happened yet.
|
||||
pub time_since_beat: f32,
|
||||
/// Monotonic counter — increments on every detected beat. Renderers use
|
||||
/// this to pick a new colour per beat without needing wall-clock time.
|
||||
pub beat_index: u64,
|
||||
/// Estimated BPM (median of the last few inter-beat intervals).
|
||||
pub bpm: f32,
|
||||
/// Current short-window energy envelope, roughly 0..1.
|
||||
pub energy: f32,
|
||||
}
|
||||
|
||||
/// Authoritative beat state — written by the mic thread, read by the
|
||||
/// engine and TUI. Uses `std::sync::Mutex` (not tokio's) because the cpal
|
||||
/// callback runs on a non-tokio audio thread.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BeatState {
|
||||
pub last_beat: Option<Instant>,
|
||||
pub beat_count: u64,
|
||||
pub bpm: f32,
|
||||
pub energy: f32,
|
||||
/// Device label set when capture starts.
|
||||
pub device_name: Option<String>,
|
||||
/// Last error from cpal init or stream errors. Surfaced in the TUI.
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
pub type SharedBeat = Arc<std::sync::Mutex<BeatState>>;
|
||||
|
||||
pub fn shared() -> SharedBeat {
|
||||
Arc::new(std::sync::Mutex::new(BeatState::default()))
|
||||
}
|
||||
|
||||
/// Snapshot the live state into a per-frame `BeatInput`. Returns `None`
|
||||
/// when nothing useful has been observed yet (mic not up and no beats).
|
||||
pub fn snapshot(shared: &SharedBeat, now: Instant) -> Option<BeatInput> {
|
||||
let s = shared.lock().ok()?;
|
||||
if s.device_name.is_none() && s.last_beat.is_none() {
|
||||
return None;
|
||||
}
|
||||
let time_since_beat = s
|
||||
.last_beat
|
||||
.map(|t| now.duration_since(t).as_secs_f32())
|
||||
.unwrap_or(99.0);
|
||||
Some(BeatInput {
|
||||
time_since_beat,
|
||||
beat_index: s.beat_count,
|
||||
bpm: s.bpm,
|
||||
energy: s.energy,
|
||||
})
|
||||
}
|
||||
|
||||
/// Status string derived from the live state for the TUI mic line.
|
||||
pub fn status_line(shared: &SharedBeat) -> String {
|
||||
let s = match shared.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return "mic: lock poisoned".to_string(),
|
||||
};
|
||||
if let Some(err) = &s.last_error {
|
||||
return format!("mic: {err}");
|
||||
}
|
||||
match &s.device_name {
|
||||
Some(name) => format!(
|
||||
"mic: {name} beats={} bpm={:.0} level={:.2}",
|
||||
s.beat_count, s.bpm, s.energy
|
||||
),
|
||||
None => "mic: starting…".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detector — feature-independent, takes raw mono samples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Energy-onset beat detector. Sample-format and capture-backend agnostic —
|
||||
/// callers downmix to mono f32 and call `feed`.
|
||||
pub struct EnergyDetector {
|
||||
pub sample_rate: u32,
|
||||
chunk_size: usize,
|
||||
chunk_accum: Vec<f32>,
|
||||
short_env: f32,
|
||||
long_env: f32,
|
||||
last_beat: Option<Instant>,
|
||||
beat_count: u64,
|
||||
intervals: std::collections::VecDeque<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BeatEvent {
|
||||
pub at: Instant,
|
||||
pub index: u64,
|
||||
pub energy: f32,
|
||||
pub bpm: f32,
|
||||
}
|
||||
|
||||
impl EnergyDetector {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
// ~23ms chunks at 44.1k. Same wall-clock at higher rates so envelope
|
||||
// time-constants stay roughly correct.
|
||||
let chunk_size = ((sample_rate as f32) * 0.023).round() as usize;
|
||||
Self {
|
||||
sample_rate,
|
||||
chunk_size: chunk_size.max(64),
|
||||
chunk_accum: Vec::with_capacity(8192),
|
||||
short_env: 0.0,
|
||||
long_env: 0.0,
|
||||
last_beat: None,
|
||||
beat_count: 0,
|
||||
intervals: std::collections::VecDeque::with_capacity(8),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a chunk of mono f32 samples (range roughly -1..=1). Returns one
|
||||
/// `BeatEvent` per processed chunk that crossed the onset condition;
|
||||
/// callers should drain by calling repeatedly with empty slices is *not*
|
||||
/// needed — process_chunk is invoked internally.
|
||||
pub fn feed(&mut self, samples: &[f32], now: Instant) -> Option<BeatEvent> {
|
||||
let mut event: Option<BeatEvent> = None;
|
||||
for &s in samples {
|
||||
self.chunk_accum.push(s);
|
||||
if self.chunk_accum.len() >= self.chunk_size {
|
||||
if let Some(b) = self.process_chunk(now) {
|
||||
event = Some(b);
|
||||
}
|
||||
self.chunk_accum.clear();
|
||||
}
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
fn process_chunk(&mut self, now: Instant) -> Option<BeatEvent> {
|
||||
let n = self.chunk_accum.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
// RMS
|
||||
let sum_sq: f32 = self.chunk_accum.iter().map(|&s| s * s).sum();
|
||||
let rms = (sum_sq / n as f32).sqrt();
|
||||
|
||||
// Single-pole IIR envelopes. dt is the chunk duration in seconds;
|
||||
// tau is the envelope time-constant.
|
||||
let dt = n as f32 / self.sample_rate as f32;
|
||||
let alpha_short = 1.0 - (-dt / 0.050).exp(); // 50ms
|
||||
let alpha_long = 1.0 - (-dt / 1.500).exp(); // 1.5s
|
||||
self.short_env += alpha_short * (rms - self.short_env);
|
||||
self.long_env += alpha_long * (rms - self.long_env);
|
||||
|
||||
// Onset condition. Three gates that must all pass:
|
||||
// 1. Ratio: instantaneous level is meaningfully above the running
|
||||
// baseline (35% bump).
|
||||
// 2. Absolute delta: the level *rose* by at least MIN_DELTA in
|
||||
// absolute terms. This prevents false beats during the warmup
|
||||
// period when the long-envelope is still climbing — the ratio
|
||||
// can read huge while both envelopes are tiny.
|
||||
// 3. Baseline above noise floor: don't trigger on dead silence.
|
||||
const THRESHOLD: f32 = 1.35;
|
||||
const MIN_BASELINE: f32 = 0.005; // noise floor
|
||||
const MIN_DELTA: f32 = 0.020; // absolute rise required (~-34 dBFS)
|
||||
const MIN_INTERVAL_MS: u64 = 200; // ≤300 BPM
|
||||
let above = self.short_env > self.long_env * THRESHOLD
|
||||
&& self.short_env - self.long_env > MIN_DELTA
|
||||
&& self.long_env > MIN_BASELINE;
|
||||
let cooled = self
|
||||
.last_beat
|
||||
.map(|t| now.duration_since(t).as_millis() as u64 >= MIN_INTERVAL_MS)
|
||||
.unwrap_or(true);
|
||||
|
||||
if above && cooled {
|
||||
if let Some(prev) = self.last_beat {
|
||||
let dur = now.duration_since(prev).as_secs_f32();
|
||||
self.intervals.push_back(dur);
|
||||
if self.intervals.len() > 8 {
|
||||
self.intervals.pop_front();
|
||||
}
|
||||
}
|
||||
self.last_beat = Some(now);
|
||||
self.beat_count += 1;
|
||||
return Some(BeatEvent {
|
||||
at: now,
|
||||
index: self.beat_count,
|
||||
energy: self.short_env,
|
||||
bpm: self.estimate_bpm(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn estimate_bpm(&self) -> f32 {
|
||||
if self.intervals.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut v: Vec<f32> = self.intervals.iter().copied().collect();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let median = v[v.len() / 2];
|
||||
if median > 1e-3 {
|
||||
60.0 / median
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Current short-window envelope (for live "level" display).
|
||||
pub fn energy(&self) -> f32 {
|
||||
self.short_env
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mic capture — feature-gated cpal integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "mic")]
|
||||
mod capture {
|
||||
use super::*;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
|
||||
/// Build the cpal input stream and drive the detector. The returned
|
||||
/// `Stream` value must be kept alive — capture stops when it drops.
|
||||
pub fn build(shared: SharedBeat) -> Result<cpal::Stream> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| anyhow!("no default audio input device"))?;
|
||||
let device_name = device.name().unwrap_or_else(|_| "unknown".to_string());
|
||||
|
||||
let supported = device
|
||||
.default_input_config()
|
||||
.context("default_input_config")?;
|
||||
let sample_format = supported.sample_format();
|
||||
let sample_rate = supported.sample_rate().0;
|
||||
let channels = supported.channels() as usize;
|
||||
let stream_config: cpal::StreamConfig = supported.into();
|
||||
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.device_name = Some(format!(
|
||||
"{device_name} ({sample_rate}Hz, {channels}ch, {sample_format:?})"
|
||||
));
|
||||
s.last_error = None;
|
||||
}
|
||||
|
||||
let mut detector = EnergyDetector::new(sample_rate);
|
||||
let shared_for_data = shared.clone();
|
||||
let shared_for_err = shared.clone();
|
||||
let mut mono = Vec::with_capacity(4096);
|
||||
|
||||
let err_fn = move |err| {
|
||||
if let Ok(mut s) = shared_for_err.lock() {
|
||||
s.last_error = Some(format!("stream error: {err}"));
|
||||
}
|
||||
};
|
||||
|
||||
let stream = match sample_format {
|
||||
cpal::SampleFormat::F32 => device.build_input_stream(
|
||||
&stream_config,
|
||||
move |data: &[f32], _: &_| {
|
||||
process(&mut detector, &shared_for_data, &mut mono, data, channels);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?,
|
||||
cpal::SampleFormat::I16 => device.build_input_stream(
|
||||
&stream_config,
|
||||
move |data: &[i16], _: &_| {
|
||||
mono.clear();
|
||||
mono.extend(data.iter().map(|&s| s as f32 / i16::MAX as f32));
|
||||
process_mono(&mut detector, &shared_for_data, &mono, channels);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?,
|
||||
cpal::SampleFormat::U16 => device.build_input_stream(
|
||||
&stream_config,
|
||||
move |data: &[u16], _: &_| {
|
||||
mono.clear();
|
||||
mono.extend(
|
||||
data.iter()
|
||||
.map(|&s| (s as f32 - i16::MAX as f32) / i16::MAX as f32),
|
||||
);
|
||||
process_mono(&mut detector, &shared_for_data, &mono, channels);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?,
|
||||
other => return Err(anyhow!("unsupported sample format: {other:?}")),
|
||||
};
|
||||
|
||||
stream.play().context("stream.play")?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn process(
|
||||
detector: &mut EnergyDetector,
|
||||
shared: &SharedBeat,
|
||||
mono: &mut Vec<f32>,
|
||||
data: &[f32],
|
||||
channels: usize,
|
||||
) {
|
||||
mono.clear();
|
||||
if channels <= 1 {
|
||||
mono.extend_from_slice(data);
|
||||
} else {
|
||||
for frame in data.chunks(channels) {
|
||||
let s: f32 = frame.iter().copied().sum::<f32>() / channels as f32;
|
||||
mono.push(s);
|
||||
}
|
||||
}
|
||||
process_mono(detector, shared, mono, channels);
|
||||
}
|
||||
|
||||
fn process_mono(
|
||||
detector: &mut EnergyDetector,
|
||||
shared: &SharedBeat,
|
||||
mono: &[f32],
|
||||
_channels: usize,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
let event = detector.feed(mono, now);
|
||||
// Update live envelope on every callback; bump beat fields on event.
|
||||
if let Ok(mut s) = shared.lock() {
|
||||
s.energy = detector.energy();
|
||||
if let Some(e) = event {
|
||||
s.last_beat = Some(e.at);
|
||||
s.beat_count = e.index;
|
||||
s.bpm = e.bpm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mic")]
|
||||
pub use capture::build as build_stream;
|
||||
|
||||
/// Spawn the mic capture thread. The thread owns the cpal `Stream` so it
|
||||
/// stays alive for the program's lifetime. On error, the failure is
|
||||
/// surfaced through `shared.last_error` and the thread exits.
|
||||
pub fn spawn(shared: SharedBeat) {
|
||||
#[cfg(feature = "mic")]
|
||||
{
|
||||
std::thread::Builder::new()
|
||||
.name("beat-mic".to_string())
|
||||
.spawn(move || match build_stream(shared.clone()) {
|
||||
Ok(stream) => {
|
||||
// Keep stream alive. The cpal callback owns the detector
|
||||
// and writes to shared state directly.
|
||||
std::mem::forget(stream);
|
||||
loop {
|
||||
std::thread::park();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Ok(mut s) = shared.lock() {
|
||||
s.last_error = Some(format!("init: {e}"));
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn beat-mic thread");
|
||||
}
|
||||
#[cfg(not(feature = "mic"))]
|
||||
{
|
||||
if let Ok(mut s) = shared.lock() {
|
||||
s.last_error =
|
||||
Some("disabled at build time — rebuild with --features mic".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn detector_fires_on_loud_pulse_against_quiet_baseline() {
|
||||
let mut det = EnergyDetector::new(44_100);
|
||||
let start = Instant::now();
|
||||
let chunk_n = det.chunk_size;
|
||||
|
||||
// Seed a few seconds of quiet so long_env settles low.
|
||||
let quiet: Vec<f32> = (0..(chunk_n * 50)).map(|_| 0.01).collect();
|
||||
det.feed(&quiet, start);
|
||||
|
||||
// Then a loud burst — should fire one beat.
|
||||
let burst: Vec<f32> = (0..chunk_n).map(|_| 0.6).collect();
|
||||
let later = start + Duration::from_millis(2000);
|
||||
let beat = det.feed(&burst, later);
|
||||
assert!(beat.is_some(), "loud burst over quiet baseline must trigger");
|
||||
assert_eq!(beat.unwrap().index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detector_respects_refractory_period() {
|
||||
let mut det = EnergyDetector::new(44_100);
|
||||
let now = Instant::now();
|
||||
let chunk_n = det.chunk_size;
|
||||
|
||||
// Baseline.
|
||||
let quiet: Vec<f32> = (0..(chunk_n * 30)).map(|_| 0.005).collect();
|
||||
det.feed(&quiet, now);
|
||||
|
||||
// Two loud bursts within 50 ms — only the first should fire.
|
||||
let burst: Vec<f32> = (0..chunk_n).map(|_| 0.7).collect();
|
||||
let t0 = now + Duration::from_millis(1500);
|
||||
let b1 = det.feed(&burst, t0);
|
||||
assert!(b1.is_some());
|
||||
let t1 = t0 + Duration::from_millis(50);
|
||||
let b2 = det.feed(&burst, t1);
|
||||
assert!(b2.is_none(), "second burst within refractory window must be suppressed");
|
||||
|
||||
// After 250ms, another burst can fire.
|
||||
let t2 = t0 + Duration::from_millis(250);
|
||||
let b3 = det.feed(&burst, t2);
|
||||
assert!(b3.is_some(), "burst past refractory must fire");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detector_estimates_bpm_from_periodic_beats() {
|
||||
let mut det = EnergyDetector::new(44_100);
|
||||
let now = Instant::now();
|
||||
let chunk_n = det.chunk_size;
|
||||
|
||||
// Quiet baseline.
|
||||
let quiet: Vec<f32> = (0..(chunk_n * 40)).map(|_| 0.003).collect();
|
||||
det.feed(&quiet, now);
|
||||
|
||||
// Hit four beats at 120 BPM = 500ms apart.
|
||||
let burst: Vec<f32> = (0..chunk_n).map(|_| 0.8).collect();
|
||||
for i in 0..6 {
|
||||
let t = now + Duration::from_millis(2000 + 500 * i);
|
||||
det.feed(&burst, t);
|
||||
// Drop a quiet chunk between bursts so envelope can fall.
|
||||
let between: Vec<f32> = (0..(chunk_n * 8)).map(|_| 0.003).collect();
|
||||
det.feed(&between, t + Duration::from_millis(50));
|
||||
}
|
||||
let bpm = det.estimate_bpm();
|
||||
assert!(bpm > 100.0 && bpm < 140.0, "expected ~120 BPM, got {bpm}");
|
||||
}
|
||||
}
|
||||
453
src/bin/bars.rs
Normal file
453
src/bin/bars.rs
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
//! Direct-ArtNet tool for the four Stairville SonicPulse LED Bar 10s.
|
||||
//!
|
||||
//! Sends a single ArtDMX packet to the ArtNet node, addressing only the 156
|
||||
//! channels the four bars occupy in U3 (4 × 39ch starting at DMX 1, 40, 79,
|
||||
//! 118). Channels beyond that aren't included in the packet, so per the
|
||||
//! ArtNet spec the receiver retains their prior values — "without changing
|
||||
//! anything else".
|
||||
//!
|
||||
//! Channel layout per bar (Stairville 39-channel mode):
|
||||
//! ch1 master dimmer
|
||||
//! ch2 shutter / strobe
|
||||
//! ch3 mode selection (0 = direct pixel control)
|
||||
//! ch4 colour preset
|
||||
//! ch5 effect select
|
||||
//! ch6 sound mode
|
||||
//! ch7 speed / sensitivity
|
||||
//! ch8..39 8 segments × RGBW (4 channels each)
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::UdpSocket;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_NODE: &str = "10.0.0.146:6454";
|
||||
const DEFAULT_UNIVERSE: u16 = 2; // QLC+ "Universe 3" = 0-indexed ArtNet universe 2
|
||||
|
||||
// 0-indexed DMX offsets for each bar within the universe (DMX address minus 1).
|
||||
const BAR_OFFSETS: &[usize] = &[0, 39, 78, 117];
|
||||
const BAR_CHANNELS: usize = 39;
|
||||
const SEGMENTS: usize = 8;
|
||||
|
||||
struct Args {
|
||||
node: String,
|
||||
universe: u16,
|
||||
color: (u8, u8, u8),
|
||||
white: u8,
|
||||
dim: u8,
|
||||
mode: u8,
|
||||
repeats: u32,
|
||||
interval_ms: u64,
|
||||
dry_run: bool,
|
||||
poll: bool,
|
||||
broadcast: bool,
|
||||
sweep: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = parse_args()?;
|
||||
|
||||
if args.poll {
|
||||
return run_poll(&args.node);
|
||||
}
|
||||
|
||||
if let Some((lo, hi)) = args.sweep {
|
||||
return run_sweep(&args, lo, hi);
|
||||
}
|
||||
|
||||
let dmx = build_dmx(args.color, args.white, args.dim, args.mode);
|
||||
let pkt = build_artdmx(args.universe, 0, &dmx);
|
||||
|
||||
let (r, g, b) = args.color;
|
||||
println!("node: {}", args.node);
|
||||
println!(
|
||||
"universe: {} (Net={}, SubUni={})",
|
||||
args.universe,
|
||||
(args.universe >> 8) & 0x7f,
|
||||
args.universe & 0xff
|
||||
);
|
||||
println!(
|
||||
"colour: rgb=({r},{g},{b}) W={} dim={} mode={}",
|
||||
args.white, args.dim, args.mode
|
||||
);
|
||||
println!(
|
||||
"bars: {} × 39ch at DMX {} = {}-byte packet (header 18 + data {})",
|
||||
BAR_OFFSETS.len(),
|
||||
BAR_OFFSETS
|
||||
.iter()
|
||||
.map(|o| (o + 1).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
pkt.len(),
|
||||
dmx.len()
|
||||
);
|
||||
|
||||
if args.dry_run {
|
||||
println!("(dry run — not sending)");
|
||||
print_dmx_summary(&dmx);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").context("bind udp")?;
|
||||
if args.broadcast {
|
||||
sock.set_broadcast(true).context("enable broadcast")?;
|
||||
}
|
||||
sock.connect(&args.node)
|
||||
.with_context(|| format!("connect {}", args.node))?;
|
||||
for i in 0..args.repeats {
|
||||
sock.send(&pkt).context("send")?;
|
||||
if i + 1 < args.repeats {
|
||||
std::thread::sleep(Duration::from_millis(args.interval_ms));
|
||||
}
|
||||
}
|
||||
println!("sent {} packet(s).", args.repeats);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- ArtPoll diagnostic ----------------------------------------------------
|
||||
|
||||
fn run_poll(target: &str) -> Result<()> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:6454")
|
||||
.context("bind UDP 6454 (close QLC+/other ArtNet listeners and retry)")?;
|
||||
sock.set_broadcast(true)?;
|
||||
sock.set_read_timeout(Some(Duration::from_millis(300)))?;
|
||||
|
||||
let mut pkt = Vec::with_capacity(14);
|
||||
pkt.extend_from_slice(b"Art-Net\0");
|
||||
pkt.extend_from_slice(&0x2000u16.to_le_bytes()); // OpPoll
|
||||
pkt.extend_from_slice(&14u16.to_be_bytes()); // ProtVer
|
||||
pkt.push(0x02); // TalkToMe: send ArtPollReply on changes too
|
||||
pkt.push(0); // Priority
|
||||
|
||||
// Send to limited broadcast, subnet broadcast, and the supplied target.
|
||||
let targets = ["255.255.255.255:6454", "10.0.0.255:6454", target];
|
||||
for t in targets {
|
||||
if let Err(e) = sock.send_to(&pkt, t) {
|
||||
eprintln!("warn: send ArtPoll to {t}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("listening for ArtPollReply for 2.5s…\n");
|
||||
let start = Instant::now();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while start.elapsed() < Duration::from_millis(2500) {
|
||||
let mut buf = [0u8; 1024];
|
||||
match sock.recv_from(&mut buf) {
|
||||
Ok((n, addr)) => {
|
||||
if n >= 14 && &buf[0..8] == b"Art-Net\0" {
|
||||
let opcode = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
if opcode == 0x2100 {
|
||||
if !seen.insert(addr) {
|
||||
continue;
|
||||
}
|
||||
print_poll_reply(addr, &buf[..n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
if seen.is_empty() {
|
||||
println!("no ArtPollReply received.");
|
||||
println!("→ check: is the node powered + on this LAN? are you on the same subnet as 10.0.0.0/24?");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_poll_reply(addr: std::net::SocketAddr, data: &[u8]) {
|
||||
if data.len() < 200 {
|
||||
println!("{} — short ArtPollReply ({} bytes), can't decode", addr, data.len());
|
||||
return;
|
||||
}
|
||||
let ip = format!("{}.{}.{}.{}", data[10], data[11], data[12], data[13]);
|
||||
let net_switch = data[18];
|
||||
let sub_switch = data[19];
|
||||
let short_name = cstr(&data[26..44]);
|
||||
let long_name = cstr(&data[44..108]);
|
||||
let num_ports = u16::from_be_bytes([data[172], data[173]]).min(4) as usize;
|
||||
let port_types = &data[174..178];
|
||||
let sw_out = &data[190..194];
|
||||
|
||||
println!("=== ArtPollReply from {} ===", addr);
|
||||
println!(" ip: {}", ip);
|
||||
println!(" short name: {:?}", short_name);
|
||||
println!(" long name: {:?}", long_name);
|
||||
println!(" net/subnet: net={net_switch} sub={sub_switch}");
|
||||
println!(" num ports: {num_ports}");
|
||||
for i in 0..num_ports {
|
||||
let ptype = port_types[i];
|
||||
let direction = if ptype & 0x80 != 0 {
|
||||
"output"
|
||||
} else if ptype & 0x40 != 0 {
|
||||
"input"
|
||||
} else {
|
||||
"?"
|
||||
};
|
||||
let universe = ((net_switch as u16 & 0x7f) << 8)
|
||||
| ((sub_switch as u16 & 0x0f) << 4)
|
||||
| (sw_out[i] as u16 & 0x0f);
|
||||
println!(
|
||||
" port {} ({}): SwOut=0x{:02x} → ArtNet universe {}",
|
||||
i, direction, sw_out[i], universe
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn cstr(b: &[u8]) -> String {
|
||||
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||
String::from_utf8_lossy(&b[..end]).into_owned()
|
||||
}
|
||||
|
||||
// --- universe sweep --------------------------------------------------------
|
||||
|
||||
fn run_sweep(args: &Args, lo: u16, hi: u16) -> Result<()> {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").context("bind udp")?;
|
||||
if args.broadcast {
|
||||
sock.set_broadcast(true)?;
|
||||
}
|
||||
let dmx = build_dmx(args.color, args.white, args.dim, args.mode);
|
||||
println!(
|
||||
"sweep: sending colour={:?} dim={} mode={} to {} universes {}..={}, {} ms each",
|
||||
args.color, args.dim, args.mode, args.node, lo, hi, args.interval_ms
|
||||
);
|
||||
for u in lo..=hi {
|
||||
let pkt = build_artdmx(u, 0, &dmx);
|
||||
sock.send_to(&pkt, &args.node)
|
||||
.with_context(|| format!("send universe {u}"))?;
|
||||
println!(" universe {u} sent — watch the bars for ~{}ms", args.interval_ms);
|
||||
std::thread::sleep(Duration::from_millis(args.interval_ms));
|
||||
}
|
||||
// After sweep, blank everything we touched
|
||||
let dark = build_dmx((0, 0, 0), 0, 0, args.mode);
|
||||
for u in lo..=hi {
|
||||
let pkt = build_artdmx(u, 1, &dark);
|
||||
let _ = sock.send_to(&pkt, &args.node);
|
||||
}
|
||||
println!("sweep done (all universes blanked).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_dmx(color: (u8, u8, u8), white: u8, dim: u8, mode: u8) -> Vec<u8> {
|
||||
let last = BAR_OFFSETS.iter().max().copied().unwrap_or(0) + BAR_CHANNELS;
|
||||
let mut dmx = vec![0u8; last];
|
||||
for &off in BAR_OFFSETS {
|
||||
dmx[off] = dim; // ch1 master dimmer
|
||||
dmx[off + 1] = 0; // ch2 strobe
|
||||
dmx[off + 2] = mode; // ch3 mode
|
||||
dmx[off + 3] = 0; // ch4 preset
|
||||
dmx[off + 4] = 0; // ch5 effect
|
||||
dmx[off + 5] = 0; // ch6 sound mode
|
||||
dmx[off + 6] = 0; // ch7 speed
|
||||
for seg in 0..SEGMENTS {
|
||||
let base = off + 7 + seg * 4;
|
||||
dmx[base] = color.0; // R
|
||||
dmx[base + 1] = color.1; // G
|
||||
dmx[base + 2] = color.2; // B
|
||||
dmx[base + 3] = white; // W
|
||||
}
|
||||
}
|
||||
dmx
|
||||
}
|
||||
|
||||
fn build_artdmx(universe: u16, sequence: u8, dmx: &[u8]) -> Vec<u8> {
|
||||
// ArtNet ArtDMX header is 18 bytes.
|
||||
let length = dmx.len() as u16;
|
||||
let mut pkt = Vec::with_capacity(18 + dmx.len());
|
||||
pkt.extend_from_slice(b"Art-Net\0"); // 8 byte ID
|
||||
pkt.extend_from_slice(&0x5000u16.to_le_bytes()); // OpDmx (little-endian)
|
||||
pkt.extend_from_slice(&14u16.to_be_bytes()); // ProtVer Hi/Lo (big-endian)
|
||||
pkt.push(sequence); // Sequence
|
||||
pkt.push(0); // Physical
|
||||
pkt.push((universe & 0xff) as u8); // SubUni
|
||||
pkt.push(((universe >> 8) & 0x7f) as u8); // Net
|
||||
pkt.extend_from_slice(&length.to_be_bytes()); // Length (big-endian)
|
||||
pkt.extend_from_slice(dmx);
|
||||
pkt
|
||||
}
|
||||
|
||||
fn print_dmx_summary(dmx: &[u8]) {
|
||||
println!("\nDMX channels (1-indexed):");
|
||||
for (i, &off) in BAR_OFFSETS.iter().enumerate() {
|
||||
println!(
|
||||
" bar {} @ ch{}..{}: master={} strobe={} mode={} preset={} effect={} sound={} speed={}",
|
||||
i + 1,
|
||||
off + 1,
|
||||
off + BAR_CHANNELS,
|
||||
dmx[off],
|
||||
dmx[off + 1],
|
||||
dmx[off + 2],
|
||||
dmx[off + 3],
|
||||
dmx[off + 4],
|
||||
dmx[off + 5],
|
||||
dmx[off + 6]
|
||||
);
|
||||
for seg in 0..SEGMENTS {
|
||||
let base = off + 7 + seg * 4;
|
||||
println!(
|
||||
" seg {} @ ch{}: r={} g={} b={} w={}",
|
||||
seg + 1,
|
||||
base + 1,
|
||||
dmx[base],
|
||||
dmx[base + 1],
|
||||
dmx[base + 2],
|
||||
dmx[base + 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- CLI ---------------------------------------------------------------
|
||||
|
||||
fn parse_args() -> Result<Args> {
|
||||
let mut node: Option<String> = None;
|
||||
let mut universe: Option<u16> = None;
|
||||
let mut color_arg: Option<String> = None;
|
||||
let mut white: u8 = 0;
|
||||
let mut dim: u8 = 255;
|
||||
let mut mode: u8 = 0;
|
||||
let mut repeats: u32 = 3;
|
||||
let mut interval_ms: u64 = 50;
|
||||
let mut dry_run = false;
|
||||
let mut poll = false;
|
||||
let mut broadcast = false;
|
||||
let mut sweep: Option<(u16, u16)> = None;
|
||||
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"-h" | "--help" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
"--node" => node = it.next(),
|
||||
"--universe" => {
|
||||
universe = it.next().map(|s| s.parse()).transpose()?;
|
||||
}
|
||||
"--color" => color_arg = it.next(),
|
||||
"--white" => white = it.next().context("--white needs value")?.parse()?,
|
||||
"--dim" => dim = it.next().context("--dim needs value")?.parse()?,
|
||||
"--mode" => mode = it.next().context("--mode needs value")?.parse()?,
|
||||
"--repeats" => repeats = it.next().context("--repeats needs value")?.parse()?,
|
||||
"--interval" => interval_ms = it.next().context("--interval needs value")?.parse()?,
|
||||
"--dry-run" => dry_run = true,
|
||||
"--poll" => poll = true,
|
||||
"--broadcast" => broadcast = true,
|
||||
"--sweep" => {
|
||||
let v = it.next().context("--sweep needs LOW-HIGH")?;
|
||||
sweep = Some(parse_sweep(&v)?);
|
||||
if interval_ms < 250 {
|
||||
interval_ms = 600;
|
||||
}
|
||||
}
|
||||
other if let Some(v) = other.strip_prefix("--sweep=") => {
|
||||
sweep = Some(parse_sweep(v)?);
|
||||
if interval_ms < 250 {
|
||||
interval_ms = 600;
|
||||
}
|
||||
}
|
||||
other if let Some(v) = other.strip_prefix("--node=") => node = Some(v.to_string()),
|
||||
other if let Some(v) = other.strip_prefix("--universe=") => universe = Some(v.parse()?),
|
||||
other if let Some(v) = other.strip_prefix("--color=") => color_arg = Some(v.to_string()),
|
||||
other if let Some(v) = other.strip_prefix("--white=") => white = v.parse()?,
|
||||
other if let Some(v) = other.strip_prefix("--dim=") => dim = v.parse()?,
|
||||
other if let Some(v) = other.strip_prefix("--mode=") => mode = v.parse()?,
|
||||
other if let Some(v) = other.strip_prefix("--repeats=") => repeats = v.parse()?,
|
||||
other if let Some(v) = other.strip_prefix("--interval=") => interval_ms = v.parse()?,
|
||||
_ => {
|
||||
eprintln!("unknown argument: {a}");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Args {
|
||||
node: node.unwrap_or_else(|| DEFAULT_NODE.to_string()),
|
||||
universe: universe.unwrap_or(DEFAULT_UNIVERSE),
|
||||
color: parse_color(color_arg.as_deref().unwrap_or("red"))?,
|
||||
white,
|
||||
dim,
|
||||
mode,
|
||||
repeats,
|
||||
interval_ms,
|
||||
dry_run,
|
||||
poll,
|
||||
broadcast,
|
||||
sweep,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sweep(s: &str) -> Result<(u16, u16)> {
|
||||
let (a, b) = s
|
||||
.split_once('-')
|
||||
.or_else(|| s.split_once(':'))
|
||||
.context("--sweep wants LOW-HIGH, e.g. 0-15")?;
|
||||
let lo: u16 = a.parse()?;
|
||||
let hi: u16 = b.parse()?;
|
||||
if lo > hi {
|
||||
anyhow::bail!("--sweep low > high");
|
||||
}
|
||||
Ok((lo, hi))
|
||||
}
|
||||
|
||||
fn parse_color(s: &str) -> Result<(u8, u8, u8)> {
|
||||
let s = s.trim();
|
||||
if let Some(hex) = s.strip_prefix('#') {
|
||||
if hex.len() != 6 {
|
||||
anyhow::bail!("hex colour must be #RRGGBB");
|
||||
}
|
||||
return Ok((
|
||||
u8::from_str_radix(&hex[0..2], 16)?,
|
||||
u8::from_str_radix(&hex[2..4], 16)?,
|
||||
u8::from_str_radix(&hex[4..6], 16)?,
|
||||
));
|
||||
}
|
||||
Ok(match s.to_lowercase().as_str() {
|
||||
"red" => (255, 0, 0),
|
||||
"green" => (0, 255, 0),
|
||||
"blue" => (0, 0, 255),
|
||||
"white" => (255, 255, 255),
|
||||
"yellow" => (255, 200, 0),
|
||||
"orange" => (255, 80, 0),
|
||||
"magenta" | "pink" => (255, 0, 120),
|
||||
"cyan" => (0, 255, 255),
|
||||
"off" | "black" => (0, 0, 0),
|
||||
other => anyhow::bail!("unknown colour {other:?} (use a name or #RRGGBB)"),
|
||||
})
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"bars — colour the four SonicPulse LED Bar 10s via direct ArtNet (one-shot)
|
||||
|
||||
USAGE:
|
||||
bars [OPTIONS]
|
||||
|
||||
OPTIONS:
|
||||
--node HOST:PORT ArtNet node (default {DEFAULT_NODE})
|
||||
--universe N 0-indexed ArtNet universe (default {DEFAULT_UNIVERSE} = QLC+ \"Universe 3\")
|
||||
--color C red | green | blue | white | yellow | orange | magenta | cyan |
|
||||
off | #RRGGBB (default red)
|
||||
--white N W (0-255) — added to each segment alongside RGB (default 0)
|
||||
--dim N master dimmer 0-255 (default 255)
|
||||
--mode N ch3 mode value (default 0 — direct DMX pixel control)
|
||||
--repeats N send the packet N times (default 3, mitigates UDP drops)
|
||||
--interval MS ms between repeats (default 50)
|
||||
--dry-run print without sending
|
||||
--broadcast send to the address as a broadcast (e.g. 10.0.0.255)
|
||||
--poll ArtPoll the LAN, list ArtNet nodes + their universes
|
||||
--sweep LOW-HIGH sweep through ArtNet universes LOW..=HIGH (e.g. 0-15)
|
||||
sending the colour to each in turn, then blanking
|
||||
-h, --help this help
|
||||
|
||||
NOTES:
|
||||
Packet length is set to exactly 156 channels (4 bars × 39ch). Per the
|
||||
ArtNet spec, channels 157-512 in this universe are NOT included in the
|
||||
packet and the receiver must retain their previous values.
|
||||
|
||||
--poll binds UDP port 6454 to receive ArtPollReply. If another ArtNet
|
||||
app (QLC+, OLA, dmxbackend) holds that port, close it first.
|
||||
"
|
||||
);
|
||||
}
|
||||
232
src/bin/panels.rs
Normal file
232
src/bin/panels.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
//! One-shot tool to colour the wall-mounted panel fixtures.
|
||||
//!
|
||||
//! Defaults to the "Columns" group (Showtec LED Par 56) and sets them solid
|
||||
//! red. Only sends updates for the matched fixtures' RGB + dimmer (+ zeroing
|
||||
//! amber/white/uv on those fixtures so the colour reads pure). Nothing else
|
||||
//! in the room is touched.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::SinkExt;
|
||||
use lightcontrol::{fetch_fixtures, http_client, Fixture, Update};
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const DEFAULT_HOST: &str = "dmx.cbrp3.c-base.org:8000";
|
||||
const DEFAULT_MODEL: &str = "LED Par 56";
|
||||
|
||||
struct Args {
|
||||
host: String,
|
||||
model: String,
|
||||
color: (u8, u8, u8),
|
||||
dim: u8,
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = parse_args()?;
|
||||
let base = format!("http://{}", args.host);
|
||||
let ws_url = format!("ws://{}/api/v1/websocket_state/", args.host);
|
||||
|
||||
let client = http_client()?;
|
||||
let fixtures = fetch_fixtures(&client, &base)
|
||||
.await
|
||||
.context("fetching fixtures")?;
|
||||
|
||||
let matched: Vec<&Fixture> = fixtures
|
||||
.iter()
|
||||
.filter(|f| f.model.eq_ignore_ascii_case(&args.model))
|
||||
.collect();
|
||||
|
||||
if matched.is_empty() {
|
||||
eprintln!("no fixtures match model {:?}", args.model);
|
||||
eprintln!("available models:");
|
||||
let mut models: std::collections::BTreeMap<&str, usize> =
|
||||
std::collections::BTreeMap::new();
|
||||
for f in &fixtures {
|
||||
*models.entry(f.model.as_str()).or_default() += 1;
|
||||
}
|
||||
for (m, n) in models {
|
||||
eprintln!(" {n:3} x {m}");
|
||||
}
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let updates = build_updates(&matched, args.color, args.dim);
|
||||
|
||||
println!(
|
||||
"host: {} model: {:?} color: ({}, {}, {}) dim: {}",
|
||||
args.host, args.model, args.color.0, args.color.1, args.color.2, args.dim
|
||||
);
|
||||
println!("matched {} fixture(s):", matched.len());
|
||||
for f in &matched {
|
||||
println!(" {} ({})", f.fixture_id, f.model);
|
||||
}
|
||||
println!("→ sending {} channel update(s)", updates.len());
|
||||
|
||||
if args.dry_run {
|
||||
for u in &updates {
|
||||
println!(" {} = {}", u.channel_id, u.value);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let payload = serde_json::to_string(&updates)?;
|
||||
let (ws, _) = tokio_tungstenite::connect_async(&ws_url)
|
||||
.await
|
||||
.with_context(|| format!("ws connect {ws_url}"))?;
|
||||
let (mut write, _read) = futures_util::StreamExt::split(ws);
|
||||
write
|
||||
.send(Message::Text(payload))
|
||||
.await
|
||||
.context("send frame")?;
|
||||
// Give the server a beat to apply before we close — the dmxbackend
|
||||
// pushes state to all listeners on receipt, but a graceful close avoids
|
||||
// a stray ConnectionReset on its side.
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
let _ = write.close().await;
|
||||
|
||||
println!("done.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_updates(fixtures: &[&Fixture], (r, g, b): (u8, u8, u8), dim: u8) -> Vec<Update> {
|
||||
let mut out = Vec::new();
|
||||
for f in fixtures {
|
||||
for el in &f.elements {
|
||||
match el.name.as_str() {
|
||||
"rgb" => {
|
||||
for c in &el.channels {
|
||||
let v = match c.name.as_str() {
|
||||
"r" | "red" => r,
|
||||
"g" | "green" => g,
|
||||
"b" | "blue" => b,
|
||||
_ => continue,
|
||||
};
|
||||
out.push(Update {
|
||||
channel_id: c.channel_id.clone(),
|
||||
value: v,
|
||||
});
|
||||
}
|
||||
}
|
||||
"dimmer" => {
|
||||
for c in &el.channels {
|
||||
out.push(Update {
|
||||
channel_id: c.channel_id.clone(),
|
||||
value: dim,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Zero competing colour layers on the same fixture so the
|
||||
// panel actually reads as the requested colour. Everything
|
||||
// else (strobe / wheels / pan / tilt / etc.) is left alone.
|
||||
"amber" | "white" | "warmwhite" | "uv" => {
|
||||
for c in &el.channels {
|
||||
out.push(Update {
|
||||
channel_id: c.channel_id.clone(),
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args> {
|
||||
let mut host: Option<String> = None;
|
||||
let mut model: Option<String> = None;
|
||||
let mut color_arg: Option<String> = None;
|
||||
let mut dim: u8 = 255;
|
||||
let mut dry_run = false;
|
||||
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"-h" | "--help" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
"--host" => host = it.next(),
|
||||
"--model" => model = it.next(),
|
||||
"--color" => color_arg = it.next(),
|
||||
"--dim" => {
|
||||
if let Some(v) = it.next() {
|
||||
dim = v.parse().context("--dim expects 0-255")?;
|
||||
}
|
||||
}
|
||||
"--dry-run" => dry_run = true,
|
||||
other if other.starts_with("--host=") => {
|
||||
host = Some(other.trim_start_matches("--host=").to_string());
|
||||
}
|
||||
other if other.starts_with("--model=") => {
|
||||
model = Some(other.trim_start_matches("--model=").to_string());
|
||||
}
|
||||
other if other.starts_with("--color=") => {
|
||||
color_arg = Some(other.trim_start_matches("--color=").to_string());
|
||||
}
|
||||
other if other.starts_with("--dim=") => {
|
||||
dim = other.trim_start_matches("--dim=").parse()?;
|
||||
}
|
||||
_ => {
|
||||
eprintln!("unknown argument: {a}");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let host = host
|
||||
.or_else(|| std::env::var("DMX_HOST").ok())
|
||||
.unwrap_or_else(|| DEFAULT_HOST.to_string());
|
||||
let model = model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||
let color = parse_color(color_arg.as_deref().unwrap_or("red"))?;
|
||||
|
||||
Ok(Args { host, model, color, dim, dry_run })
|
||||
}
|
||||
|
||||
fn parse_color(s: &str) -> Result<(u8, u8, u8)> {
|
||||
let s = s.trim();
|
||||
if let Some(hex) = s.strip_prefix('#') {
|
||||
if hex.len() != 6 {
|
||||
anyhow::bail!("hex colour must be #RRGGBB");
|
||||
}
|
||||
let r = u8::from_str_radix(&hex[0..2], 16)?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16)?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16)?;
|
||||
return Ok((r, g, b));
|
||||
}
|
||||
Ok(match s.to_lowercase().as_str() {
|
||||
"red" => (255, 0, 0),
|
||||
"green" => (0, 255, 0),
|
||||
"blue" => (0, 0, 255),
|
||||
"white" => (255, 255, 255),
|
||||
"yellow" => (255, 200, 0),
|
||||
"orange" => (255, 80, 0),
|
||||
"magenta" | "pink" => (255, 0, 120),
|
||||
"cyan" => (0, 255, 255),
|
||||
"off" | "black" => (0, 0, 0),
|
||||
other => anyhow::bail!("unknown colour {other:?} (use a name or #RRGGBB)"),
|
||||
})
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"panels — colour the wall panel fixtures (one-shot)
|
||||
|
||||
USAGE:
|
||||
panels [OPTIONS]
|
||||
|
||||
OPTIONS:
|
||||
--host HOST backend host:port (default {DEFAULT_HOST})
|
||||
--model NAME fixture model to target (default {DEFAULT_MODEL:?})
|
||||
--color C red | green | blue | white | yellow | orange |
|
||||
magenta | cyan | off | #RRGGBB (default red)
|
||||
--dim N dimmer value 0-255 (default 255)
|
||||
--dry-run print the updates without sending them
|
||||
-h, --help show this help
|
||||
"
|
||||
);
|
||||
}
|
||||
64
src/lib.rs
Normal file
64
src/lib.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
pub mod beat;
|
||||
pub mod matelight;
|
||||
mod presets;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
pub use beat::{
|
||||
shared as shared_beat, snapshot as snapshot_beat, status_line as beat_status,
|
||||
spawn as spawn_beat, BeatInput, BeatState, SharedBeat,
|
||||
};
|
||||
pub use matelight::{
|
||||
shared as shared_matelight, MatelightFrame, SharedMatelight, DEFAULT_MATELIGHT_WS,
|
||||
};
|
||||
pub use presets::{render_frame, render_frame_with, Preset, PRESETS};
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Channel {
|
||||
pub name: String,
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Element {
|
||||
pub name: String,
|
||||
pub channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Fixture {
|
||||
pub fixture_id: String,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub pos_x: f32,
|
||||
#[serde(default)]
|
||||
pub pos_y: f32,
|
||||
pub elements: Vec<Element>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct Update {
|
||||
pub channel_id: String,
|
||||
pub value: u8,
|
||||
}
|
||||
|
||||
pub async fn fetch_fixtures(client: &reqwest::Client, base: &str) -> Result<Vec<Fixture>> {
|
||||
let url = format!("{base}/api/v1/fixtures/");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?
|
||||
.error_for_status()?
|
||||
.json::<Vec<Fixture>>()
|
||||
.await?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub fn http_client() -> Result<reqwest::Client> {
|
||||
Ok(reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()?)
|
||||
}
|
||||
665
src/main.rs
Normal file
665
src/main.rs
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use crossterm::ExecutableCommand;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Sparkline};
|
||||
use ratatui::{Frame, Terminal};
|
||||
use std::collections::VecDeque;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use lightcontrol::matelight::{feeder as matelight_feeder, StatusFn};
|
||||
use lightcontrol::{
|
||||
beat_status, fetch_fixtures, http_client, render_frame_with, shared_beat, shared_matelight,
|
||||
snapshot_beat, spawn_beat, Preset, SharedBeat, SharedMatelight, DEFAULT_MATELIGHT_WS, PRESETS,
|
||||
};
|
||||
|
||||
const DEFAULT_HOST: &str = "dmx.cbrp3.c-base.org:8000";
|
||||
const TARGET_FPS: f32 = 25.0;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
host: String,
|
||||
preset: Preset,
|
||||
selected_idx: usize,
|
||||
fps: f32,
|
||||
frame_count: u64,
|
||||
connected: bool,
|
||||
fixture_count: usize,
|
||||
status: String,
|
||||
matelight_status: String,
|
||||
mic_status: String,
|
||||
// Live beat-detector visualisation state (pumped from the mic thread's
|
||||
// shared `BeatState` at ~20 Hz so the meter feels responsive).
|
||||
beat_envelope: f32, // current short envelope, 0..~1
|
||||
beat_age: Option<f32>, // seconds since last detected beat
|
||||
beat_count: u64, // monotonic counter
|
||||
beat_bpm: f32, // median-of-8 BPM estimate
|
||||
beat_history: VecDeque<u64>, // last 80 envelope samples × 1000
|
||||
}
|
||||
|
||||
const BEAT_HISTORY_LEN: usize = 80;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let Args { host, preset, check, matelight_url } = parse_args();
|
||||
|
||||
if check {
|
||||
return check_fixtures(&host).await;
|
||||
}
|
||||
|
||||
let preset_idx = PRESETS.iter().position(|p| *p == preset).unwrap_or(0);
|
||||
let state = Arc::new(Mutex::new(AppState {
|
||||
host: host.clone(),
|
||||
preset,
|
||||
selected_idx: preset_idx,
|
||||
fps: 0.0,
|
||||
frame_count: 0,
|
||||
connected: false,
|
||||
fixture_count: 0,
|
||||
status: "starting…".to_string(),
|
||||
matelight_status: "matelight: not started".to_string(),
|
||||
mic_status: "mic: not started".to_string(),
|
||||
beat_envelope: 0.0,
|
||||
beat_age: None,
|
||||
beat_count: 0,
|
||||
beat_bpm: 0.0,
|
||||
beat_history: VecDeque::with_capacity(BEAT_HISTORY_LEN),
|
||||
}));
|
||||
|
||||
// Spawn microphone capture + beat detector (own thread, owns cpal stream).
|
||||
let beat = shared_beat();
|
||||
spawn_beat(beat.clone());
|
||||
|
||||
let matelight = shared_matelight();
|
||||
let matelight_status_cb: StatusFn = {
|
||||
let state = state.clone();
|
||||
Arc::new(move |msg| {
|
||||
// Stash the latest matelight status string for the TUI footer.
|
||||
// Brief lock; never awaits user input.
|
||||
if let Ok(mut s) = state.try_lock() {
|
||||
s.matelight_status = msg;
|
||||
}
|
||||
})
|
||||
};
|
||||
let matelight_handle = tokio::spawn(matelight_feeder(
|
||||
matelight_url.clone(),
|
||||
matelight.clone(),
|
||||
matelight_status_cb,
|
||||
));
|
||||
|
||||
let engine_handle = tokio::spawn(engine_task(
|
||||
state.clone(),
|
||||
host,
|
||||
matelight.clone(),
|
||||
beat.clone(),
|
||||
));
|
||||
|
||||
// Pump beat-detector state into the TUI at 20Hz so the level meter and
|
||||
// sparkline feel live. The mic_status text and the beat-viz fields are
|
||||
// updated in the same tick so they don't drift apart.
|
||||
let mic_status_handle = {
|
||||
let state = state.clone();
|
||||
let beat = beat.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(50));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let now = Instant::now();
|
||||
let (envelope, age, count, bpm) = {
|
||||
let bs = beat.lock().unwrap();
|
||||
let age = bs.last_beat.map(|t| now.duration_since(t).as_secs_f32());
|
||||
(bs.energy, age, bs.beat_count, bs.bpm)
|
||||
};
|
||||
// Status line text — done separately so we don't double-lock.
|
||||
let line = beat_status(&beat);
|
||||
let mut s = state.lock().await;
|
||||
s.mic_status = line;
|
||||
s.beat_envelope = envelope;
|
||||
s.beat_age = age;
|
||||
s.beat_count = count;
|
||||
s.beat_bpm = bpm;
|
||||
// Scale 0..1 envelope into 0..1000 for the sparkline buffer.
|
||||
// Boost a bit (×2.5) so quiet rooms still show some shape —
|
||||
// the sparkline auto-scales against `max`.
|
||||
let sample = ((envelope * 2500.0).clamp(0.0, 1000.0)) as u64;
|
||||
s.beat_history.push_back(sample);
|
||||
while s.beat_history.len() > BEAT_HISTORY_LEN {
|
||||
s.beat_history.pop_front();
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
||||
std::thread::spawn(move || loop {
|
||||
match event::poll(Duration::from_millis(100)) {
|
||||
Ok(true) => match event::read() {
|
||||
Ok(ev) => {
|
||||
if tx.send(ev).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => return,
|
||||
},
|
||||
Ok(false) => {}
|
||||
Err(_) => return,
|
||||
}
|
||||
});
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
stdout.execute(EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(100));
|
||||
|
||||
let outcome: Result<()> = async {
|
||||
loop {
|
||||
let snap = state.lock().await.clone();
|
||||
terminal.draw(|f| render_ui(f, &snap))?;
|
||||
|
||||
tokio::select! {
|
||||
_ = tick.tick() => {}
|
||||
Some(ev) = rx.recv() => {
|
||||
if let Event::Key(key) = ev {
|
||||
if (matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')))
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
break;
|
||||
}
|
||||
let mut s = state.lock().await;
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||
KeyCode::Char('1') => set_preset(&mut s, Preset::Rainbows),
|
||||
KeyCode::Char('2') => set_preset(&mut s, Preset::BlueHouse),
|
||||
KeyCode::Char('3') => set_preset(&mut s, Preset::Disco70),
|
||||
KeyCode::Char('4') => set_preset(&mut s, Preset::Jungle),
|
||||
KeyCode::Char('5') => set_preset(&mut s, Preset::Lasertec),
|
||||
KeyCode::Char('6') => set_preset(&mut s, Preset::Police),
|
||||
KeyCode::Char('7') => set_preset(&mut s, Preset::MirrorBall),
|
||||
KeyCode::Char('8') => set_preset(&mut s, Preset::MorningLight),
|
||||
KeyCode::Char('9') => set_preset(&mut s, Preset::OrangeGreenBall),
|
||||
KeyCode::Char('m') | KeyCode::Char('M') => {
|
||||
set_preset(&mut s, Preset::Matelight);
|
||||
}
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => {
|
||||
set_preset(&mut s, Preset::GabbaStrobe);
|
||||
}
|
||||
KeyCode::Char('r') | KeyCode::Char('R') => {
|
||||
set_preset(&mut s, Preset::RedStrobe);
|
||||
}
|
||||
// Capital L for blue (b/B is blackout).
|
||||
KeyCode::Char('l') | KeyCode::Char('L') => {
|
||||
set_preset(&mut s, Preset::BlueStrobe);
|
||||
}
|
||||
KeyCode::Char('p') | KeyCode::Char('P') => {
|
||||
set_preset(&mut s, Preset::PinkStrobe);
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Char('K') => {
|
||||
set_preset(&mut s, Preset::BeatPulse);
|
||||
}
|
||||
KeyCode::Char('0') | KeyCode::Char('b') | KeyCode::Char('B') => {
|
||||
set_preset(&mut s, Preset::Off);
|
||||
}
|
||||
KeyCode::Up if s.selected_idx > 0 => {
|
||||
s.selected_idx -= 1;
|
||||
}
|
||||
KeyCode::Down if s.selected_idx + 1 < PRESETS.len() => {
|
||||
s.selected_idx += 1;
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
s.preset = PRESETS[s.selected_idx];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
disable_raw_mode().ok();
|
||||
terminal.backend_mut().execute(LeaveAlternateScreen).ok();
|
||||
terminal.show_cursor().ok();
|
||||
engine_handle.abort();
|
||||
matelight_handle.abort();
|
||||
mic_status_handle.abort();
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
struct Args {
|
||||
host: String,
|
||||
preset: Preset,
|
||||
check: bool,
|
||||
matelight_url: String,
|
||||
}
|
||||
|
||||
fn parse_args() -> Args {
|
||||
let mut host: Option<String> = None;
|
||||
let mut preset = Preset::Off;
|
||||
let mut check = false;
|
||||
let mut matelight_url: Option<String> = None;
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--host" => host = it.next(),
|
||||
"--preset" => {
|
||||
if let Some(v) = it.next() {
|
||||
preset = preset_from_str(&v).unwrap_or(preset);
|
||||
}
|
||||
}
|
||||
"--matelight" => matelight_url = it.next(),
|
||||
"--check" => check = true,
|
||||
other if other.starts_with("--host=") => {
|
||||
host = Some(other.trim_start_matches("--host=").to_string());
|
||||
}
|
||||
other if other.starts_with("--preset=") => {
|
||||
let v = other.trim_start_matches("--preset=");
|
||||
preset = preset_from_str(v).unwrap_or(preset);
|
||||
}
|
||||
other if other.starts_with("--matelight=") => {
|
||||
matelight_url = Some(other.trim_start_matches("--matelight=").to_string());
|
||||
}
|
||||
other if !other.starts_with('-') && host.is_none() => {
|
||||
host = Some(other.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let host = host
|
||||
.or_else(|| std::env::var("DMX_HOST").ok())
|
||||
.unwrap_or_else(|| DEFAULT_HOST.to_string());
|
||||
let matelight_url = matelight_url
|
||||
.or_else(|| std::env::var("MATELIGHT_URL").ok())
|
||||
.unwrap_or_else(|| DEFAULT_MATELIGHT_WS.to_string());
|
||||
Args { host, preset, check, matelight_url }
|
||||
}
|
||||
|
||||
fn preset_from_str(s: &str) -> Option<Preset> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"off" | "blackout" | "0" => Some(Preset::Off),
|
||||
"rainbow" | "rainbows" | "1" => Some(Preset::Rainbows),
|
||||
"blue" | "bluehouse" | "blue-house" | "2" => Some(Preset::BlueHouse),
|
||||
"disco" | "disco70" | "70th" | "70th-disco" | "3" => Some(Preset::Disco70),
|
||||
"jungle" | "4" => Some(Preset::Jungle),
|
||||
"lasertec" | "laser" | "5" => Some(Preset::Lasertec),
|
||||
"police" | "polizei" | "6" => Some(Preset::Police),
|
||||
"mirror" | "mirrorball" | "mirror-ball" | "discoball" | "kugel" | "7" => {
|
||||
Some(Preset::MirrorBall)
|
||||
}
|
||||
"morning" | "morninglight" | "morning-light" | "sunrise" | "dawn" | "sonnenaufgang"
|
||||
| "morgen" | "8" => Some(Preset::MorningLight),
|
||||
"orangegreen" | "orange-green" | "orange" | "green" | "orangeball" | "ogball"
|
||||
| "kürbis" | "kuerbis" | "9" => Some(Preset::OrangeGreenBall),
|
||||
"matelight" | "mate" | "sync" | "m" => Some(Preset::Matelight),
|
||||
"gabba" | "strobe" | "strobo" | "stroboscope" | "s" => Some(Preset::GabbaStrobe),
|
||||
"redstrobe" | "red-strobe" | "rot" | "rotstrobe" | "rot-strobe" | "red" | "r"
|
||||
=> Some(Preset::RedStrobe),
|
||||
// Note: bare "blue" is already taken by the slow BlueHouse preset (2).
|
||||
// The fast strobe needs the explicit "-strobe" or German "blau" alias.
|
||||
"bluestrobe" | "blue-strobe" | "blau" | "blaustrobe" | "blau-strobe" | "l"
|
||||
=> Some(Preset::BlueStrobe),
|
||||
"pinkstrobe" | "pink-strobe" | "pink" | "p" | "magenta"
|
||||
=> Some(Preset::PinkStrobe),
|
||||
"beat" | "beatpulse" | "beat-pulse" | "kick" | "mic" | "audio" | "k"
|
||||
=> Some(Preset::BeatPulse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_preset(s: &mut AppState, p: Preset) {
|
||||
s.preset = p;
|
||||
if let Some(i) = PRESETS.iter().position(|x| *x == p) {
|
||||
s.selected_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
fn render_ui(f: &mut Frame, s: &AppState) {
|
||||
let area = f.area();
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(7), // header
|
||||
Constraint::Length(5), // beat detector viz
|
||||
Constraint::Min(1), // preset list
|
||||
Constraint::Length(3), // help
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let conn_color = if s.connected { Color::Green } else { Color::Red };
|
||||
let conn_text = if s.connected { "connected" } else { "disconnected" };
|
||||
let header = Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::raw("host: "),
|
||||
Span::styled(s.host.clone(), Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" ["),
|
||||
Span::styled(
|
||||
conn_text,
|
||||
Style::default()
|
||||
.fg(conn_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw("]"),
|
||||
]),
|
||||
Line::from(format!(
|
||||
"fixtures: {} frames: {} fps: {:.1}",
|
||||
s.fixture_count, s.frame_count, s.fps
|
||||
)),
|
||||
Line::from(format!("status: {}", s.status)),
|
||||
Line::from(s.matelight_status.clone()),
|
||||
Line::from(s.mic_status.clone()),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" c-base lightcontrol "),
|
||||
);
|
||||
f.render_widget(header, outer[0]);
|
||||
render_beat_panel(f, outer[1], s);
|
||||
|
||||
let items: Vec<ListItem> = PRESETS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let active = *p == s.preset;
|
||||
let hovered = i == s.selected_idx;
|
||||
let cursor = if hovered { "›" } else { " " };
|
||||
let style = if active {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if hovered {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(format!("{cursor} [{}] ", p.key())),
|
||||
Span::raw(p.label()),
|
||||
]))
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" presets "),
|
||||
);
|
||||
f.render_widget(list, outer[2]);
|
||||
|
||||
let help = Paragraph::new(Line::from(
|
||||
" 1-9 / m / s r L p / k(beat) select ↑/↓ + enter 0/b blackout q quit",
|
||||
))
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
f.render_widget(help, outer[3]);
|
||||
}
|
||||
|
||||
/// Renders the beat-detector panel: flash bar that lights up on every beat,
|
||||
/// BPM + count text on the left, a rolling sparkline of the audio envelope
|
||||
/// on the right. The whole thing makes it instantly visible whether the
|
||||
/// detector is locked onto the music or just chasing room noise.
|
||||
fn render_beat_panel(f: &mut Frame, area: Rect, s: &AppState) {
|
||||
let split = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(34), Constraint::Min(20)])
|
||||
.split(area);
|
||||
|
||||
// --- Left: flash bar + BPM + counters -------------------------------
|
||||
// Flash level decays exponentially from each detected beat. 6/s means
|
||||
// ~115 ms half-life — bright on the hit, gone in ~400 ms.
|
||||
let flash = s
|
||||
.beat_age
|
||||
.map(|t| (-t * 6.0).exp().clamp(0.0, 1.0))
|
||||
.unwrap_or(0.0);
|
||||
// Cycle a hue palette per beat so consecutive flashes are visually
|
||||
// distinct — mirrors what the BeatPulse preset actually does to the
|
||||
// lights.
|
||||
let palette: [Color; 6] = [
|
||||
Color::Red,
|
||||
Color::LightYellow,
|
||||
Color::Green,
|
||||
Color::Cyan,
|
||||
Color::Magenta,
|
||||
Color::LightBlue,
|
||||
];
|
||||
let beat_color = palette[(s.beat_count as usize) % palette.len()];
|
||||
let dim_color = if flash > 0.6 {
|
||||
beat_color
|
||||
} else if flash > 0.2 {
|
||||
Color::Yellow
|
||||
} else if flash > 0.05 {
|
||||
Color::DarkGray
|
||||
} else {
|
||||
Color::Black
|
||||
};
|
||||
// ~13 wide bar made of full blocks; brighter on the hit.
|
||||
let flash_label = if flash > 0.05 {
|
||||
Span::styled(
|
||||
"█████████████",
|
||||
Style::default().fg(dim_color).add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
Span::styled("░░░░░░░░░░░░░", Style::default().fg(Color::DarkGray))
|
||||
};
|
||||
|
||||
// Level bar — the same envelope the sparkline plots, but as an
|
||||
// instantaneous gauge so you can watch it climb and dip in realtime.
|
||||
let level_width: usize = 20;
|
||||
let lvl = (s.beat_envelope * 2.5).clamp(0.0, 1.0); // same boost as sparkline
|
||||
let filled = (lvl * level_width as f32).round() as usize;
|
||||
let mut level_bar = String::with_capacity(level_width);
|
||||
for i in 0..level_width {
|
||||
level_bar.push(if i < filled { '█' } else { '░' });
|
||||
}
|
||||
let level_color = if lvl > 0.85 {
|
||||
Color::LightRed
|
||||
} else if lvl > 0.5 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Green
|
||||
};
|
||||
|
||||
let bpm_text = if s.beat_bpm > 0.0 {
|
||||
format!("{:6.1} BPM", s.beat_bpm)
|
||||
} else {
|
||||
" --- BPM".to_string()
|
||||
};
|
||||
let age_text = match s.beat_age {
|
||||
Some(a) if a < 9.9 => format!("{:.2}s ago", a),
|
||||
_ => "—".to_string(),
|
||||
};
|
||||
|
||||
let left = Paragraph::new(vec![
|
||||
Line::from(vec![Span::raw("BEAT "), flash_label]),
|
||||
Line::from(vec![
|
||||
Span::raw("level "),
|
||||
Span::styled(level_bar, Style::default().fg(level_color)),
|
||||
]),
|
||||
Line::from(format!(
|
||||
"{} beats={:<5} last {}",
|
||||
bpm_text, s.beat_count, age_text
|
||||
)),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" beat detector "),
|
||||
);
|
||||
f.render_widget(left, split[0]);
|
||||
|
||||
// --- Right: rolling envelope sparkline ------------------------------
|
||||
let history: Vec<u64> = s.beat_history.iter().copied().collect();
|
||||
let spark = Sparkline::default()
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" envelope (last ~4 s) "),
|
||||
)
|
||||
.data(&history)
|
||||
.style(Style::default().fg(Color::Cyan))
|
||||
.max(1000);
|
||||
f.render_widget(spark, split[1]);
|
||||
}
|
||||
|
||||
async fn engine_task(
|
||||
state: Arc<Mutex<AppState>>,
|
||||
host: String,
|
||||
matelight: SharedMatelight,
|
||||
beat: SharedBeat,
|
||||
) {
|
||||
let base = format!("http://{host}");
|
||||
let ws_url = format!("ws://{host}/api/v1/websocket_state/");
|
||||
let client = match http_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
set_status(&state, format!("client init: {e}"), false).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
set_status(&state, "fetching fixtures…", false).await;
|
||||
|
||||
let fixtures = match fetch_fixtures(&client, &base).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
set_status(&state, format!("fixture fetch: {e}"), false).await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut s = state.lock().await;
|
||||
s.fixture_count = fixtures.len();
|
||||
}
|
||||
|
||||
set_status(&state, "connecting websocket…", false).await;
|
||||
let stream = match tokio_tungstenite::connect_async(&ws_url).await {
|
||||
Ok((s, _)) => s,
|
||||
Err(e) => {
|
||||
set_status(&state, format!("ws connect: {e}"), false).await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (mut write, mut read) = stream.split();
|
||||
|
||||
// Server sends a full-state snapshot on connect — drain it.
|
||||
let _ = tokio::time::timeout(Duration::from_millis(800), read.next()).await;
|
||||
|
||||
set_status(&state, "running", true).await;
|
||||
|
||||
let start = Instant::now();
|
||||
let frame_dt = Duration::from_secs_f32(1.0 / TARGET_FPS);
|
||||
let mut next = Instant::now() + frame_dt;
|
||||
let mut window_start = Instant::now();
|
||||
let mut window_frames: u32 = 0;
|
||||
|
||||
let drop_err: Option<String> = loop {
|
||||
let preset = { state.lock().await.preset };
|
||||
let t = start.elapsed().as_secs_f32();
|
||||
let now = Instant::now();
|
||||
let beat_input = snapshot_beat(&beat, now);
|
||||
let mate_guard = matelight.lock().await;
|
||||
let updates =
|
||||
render_frame_with(preset, t, &fixtures, mate_guard.as_ref(), beat_input);
|
||||
drop(mate_guard);
|
||||
let payload = match serde_json::to_string(&updates) {
|
||||
Ok(p) => p,
|
||||
Err(e) => break Some(format!("encode: {e}")),
|
||||
};
|
||||
if let Err(e) = write.send(Message::Text(payload)).await {
|
||||
break Some(format!("send: {e}"));
|
||||
}
|
||||
|
||||
window_frames += 1;
|
||||
if window_start.elapsed() >= Duration::from_millis(500) {
|
||||
let secs = window_start.elapsed().as_secs_f32().max(1e-3);
|
||||
let fps = window_frames as f32 / secs;
|
||||
let mut s = state.lock().await;
|
||||
s.fps = fps;
|
||||
s.frame_count += window_frames as u64;
|
||||
window_frames = 0;
|
||||
window_start = Instant::now();
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if next > now {
|
||||
tokio::time::sleep(next - now).await;
|
||||
}
|
||||
next += frame_dt;
|
||||
let now = Instant::now();
|
||||
if now > next + frame_dt * 4 {
|
||||
next = now + frame_dt;
|
||||
}
|
||||
};
|
||||
|
||||
let _ = write.close().await;
|
||||
let msg = drop_err.unwrap_or_else(|| "websocket closed".to_string());
|
||||
set_status(&state, format!("reconnect in 2s — {msg}"), false).await;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_fixtures(host: &str) -> Result<()> {
|
||||
let client = http_client()?;
|
||||
let base = format!("http://{host}");
|
||||
let fixtures = fetch_fixtures(&client, &base).await?;
|
||||
println!("host: {host}");
|
||||
println!("fixtures: {}", fixtures.len());
|
||||
let total_chans: usize = fixtures.iter()
|
||||
.flat_map(|f| f.elements.iter().map(|e| e.channels.len()))
|
||||
.sum();
|
||||
println!("channels total: {total_chans}");
|
||||
let mut models: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
for f in &fixtures {
|
||||
*models.entry(f.model.clone()).or_default() += 1;
|
||||
}
|
||||
println!("models:");
|
||||
for (m, n) in &models {
|
||||
println!(" {n:3} x {m}");
|
||||
}
|
||||
let mut elements: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
for f in &fixtures {
|
||||
for e in &f.elements {
|
||||
elements.insert(e.name.clone());
|
||||
}
|
||||
}
|
||||
println!("element names: {:?}", elements);
|
||||
println!("\nfirst fixture sample:");
|
||||
if let Some(f) = fixtures.first() {
|
||||
println!(" {} ({}) @({},{})", f.fixture_id, f.model, f.pos_x, f.pos_y);
|
||||
for e in &f.elements {
|
||||
let chans: Vec<&str> = e.channels.iter().map(|c| c.name.as_str()).collect();
|
||||
println!(" {}: [{}]", e.name, chans.join(", "));
|
||||
for c in &e.channels {
|
||||
println!(" {} -> {}", c.name, c.channel_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_status(state: &Arc<Mutex<AppState>>, msg: impl Into<String>, connected: bool) {
|
||||
let mut s = state.lock().await;
|
||||
s.status = msg.into();
|
||||
s.connected = connected;
|
||||
}
|
||||
238
src/matelight.rs
Normal file
238
src/matelight.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
//! Matelight live frame feeder.
|
||||
//!
|
||||
//! Connects to the Matelight hardware controller's `/monitor` WebSocket
|
||||
//! (subprotocol `matemon`), parses JSON config + Brightness messages and
|
||||
//! binary RGB framebuffer pushes, and keeps a shared `MatelightFrame` up
|
||||
//! to date. The `Matelight` preset samples from this frame to colour the
|
||||
//! hall lights in real time.
|
||||
//!
|
||||
//! Wire format (reverse-engineered from the live server's `/monitor` HTML):
|
||||
//! - On connect, server sends JSON: `{"Screen":{"Crates_X":8,"Crates_Y":4,
|
||||
//! "CrateSize_X":5,"CrateSize_Y":4}}` and `{"Brightness":<f64>}`.
|
||||
//! - Subsequent binary messages: `width*height*3` bytes of row-major RGB
|
||||
//! (40×16 = 1920 bytes for the standard rig).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
pub const DEFAULT_MATELIGHT_WS: &str = "ws://matelight.cbrp3.c-base.org:8081/monitor";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MatelightFrame {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
/// 0..=255, mirrors the matelight's master brightness slider.
|
||||
pub brightness: u8,
|
||||
/// Row-major RGB triples. `pixels.len() == width * height * 3`.
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MatelightFrame {
|
||||
fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||
let i = (y * self.width + x) * 3;
|
||||
(self.pixels[i], self.pixels[i + 1], self.pixels[i + 2])
|
||||
}
|
||||
|
||||
/// Sample a vertical strip of the matelight centred on the column that
|
||||
/// maps to `t` (0.0 = left edge, 1.0 = right edge). Returns the average
|
||||
/// RGB of that strip across all rows. `half_band` controls the width:
|
||||
/// 0 = single column, 1 = ±1 col, 2 = ±2 cols, etc.
|
||||
pub fn sample_column(&self, t: f32, half_band: usize) -> (u8, u8, u8) {
|
||||
if self.pixels.is_empty() {
|
||||
return (0, 0, 0);
|
||||
}
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let center = ((t * (self.width.saturating_sub(1)) as f32).round() as isize)
|
||||
.clamp(0, self.width as isize - 1) as usize;
|
||||
let lo = center.saturating_sub(half_band);
|
||||
let hi = (center + half_band).min(self.width - 1);
|
||||
|
||||
let mut r_sum: u32 = 0;
|
||||
let mut g_sum: u32 = 0;
|
||||
let mut b_sum: u32 = 0;
|
||||
let mut count: u32 = 0;
|
||||
for y in 0..self.height {
|
||||
for x in lo..=hi {
|
||||
let (r, g, b) = self.pixel(x, y);
|
||||
r_sum += r as u32;
|
||||
g_sum += g as u32;
|
||||
b_sum += b as u32;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return (0, 0, 0);
|
||||
}
|
||||
((r_sum / count) as u8, (g_sum / count) as u8, (b_sum / count) as u8)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedMatelight = Arc<Mutex<Option<MatelightFrame>>>;
|
||||
|
||||
pub fn shared() -> SharedMatelight {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScreenMsg {
|
||||
#[serde(rename = "Screen")]
|
||||
screen: Option<ScreenSpec>,
|
||||
#[serde(rename = "Brightness")]
|
||||
brightness: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScreenSpec {
|
||||
#[serde(rename = "Crates_X")]
|
||||
crates_x: usize,
|
||||
#[serde(rename = "Crates_Y")]
|
||||
crates_y: usize,
|
||||
#[serde(rename = "CrateSize_X")]
|
||||
crate_size_x: usize,
|
||||
#[serde(rename = "CrateSize_Y")]
|
||||
crate_size_y: usize,
|
||||
}
|
||||
|
||||
/// Status callback signature: receives short human-readable status updates
|
||||
/// (e.g. "matelight: connected 40x16"). Engine plumbs these into the TUI.
|
||||
pub type StatusFn = Arc<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
/// Connects to the matelight monitor websocket and pushes frames into
|
||||
/// `shared` until cancelled. Reconnects on error with a 3s backoff.
|
||||
pub async fn feeder(url: String, shared: SharedMatelight, status: StatusFn) {
|
||||
loop {
|
||||
status(format!("matelight: connecting {url}"));
|
||||
match feeder_once(&url, &shared, &status).await {
|
||||
Ok(()) => status("matelight: stream ended".to_string()),
|
||||
Err(e) => status(format!("matelight: {e}")),
|
||||
}
|
||||
// Drop the last frame so the renderer goes back to "no signal".
|
||||
*shared.lock().await = None;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn feeder_once(url: &str, shared: &SharedMatelight, status: &StatusFn) -> Result<()> {
|
||||
// Hand-build a request so we can attach the `matemon` subprotocol header
|
||||
// (the server JS uses `new WebSocket(url, 'matemon')`).
|
||||
let mut req = url.into_client_request().context("invalid matelight URL")?;
|
||||
req.headers_mut().insert(
|
||||
"Sec-WebSocket-Protocol",
|
||||
"matemon".parse().expect("static header value"),
|
||||
);
|
||||
|
||||
let (stream, _) = tokio_tungstenite::connect_async(req)
|
||||
.await
|
||||
.context("matelight ws connect")?;
|
||||
let (_write, mut read) = stream.split();
|
||||
|
||||
let mut width = 0usize;
|
||||
let mut height = 0usize;
|
||||
let mut brightness: u8 = 255;
|
||||
|
||||
while let Some(msg) = read.next().await {
|
||||
match msg.context("matelight ws read")? {
|
||||
Message::Text(t) => {
|
||||
let parsed: ScreenMsg =
|
||||
serde_json::from_str(&t).context("matelight ws json decode")?;
|
||||
if let Some(s) = parsed.screen {
|
||||
width = s.crates_x * s.crate_size_x;
|
||||
height = s.crates_y * s.crate_size_y;
|
||||
status(format!("matelight: connected {width}x{height}"));
|
||||
}
|
||||
if let Some(b) = parsed.brightness {
|
||||
// The server's "Brightness" message is a 0..1 float (the
|
||||
// hardware controller normalises it from the 0..255 byte).
|
||||
brightness = (b.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
Message::Binary(buf) => {
|
||||
if width == 0 || height == 0 {
|
||||
// Got pixels before the Screen config — ignore until we
|
||||
// know the geometry.
|
||||
continue;
|
||||
}
|
||||
let expected = width * height * 3;
|
||||
if buf.len() != expected {
|
||||
// Wrong-size frame: don't trust it, but don't kill the
|
||||
// stream either.
|
||||
status(format!(
|
||||
"matelight: bad frame {} bytes (want {})",
|
||||
buf.len(),
|
||||
expected
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let frame = MatelightFrame {
|
||||
width,
|
||||
height,
|
||||
brightness,
|
||||
pixels: buf.to_vec(),
|
||||
};
|
||||
*shared.lock().await = Some(frame);
|
||||
}
|
||||
Message::Close(_) => return Err(anyhow!("server closed connection")),
|
||||
// Pings/pongs/frame fragments — the tungstenite default handler
|
||||
// takes care of pongs; nothing else to do.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("ws stream ended without close frame"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn solid_frame(w: usize, h: usize, r: u8, g: u8, b: u8) -> MatelightFrame {
|
||||
let mut pixels = Vec::with_capacity(w * h * 3);
|
||||
for _ in 0..(w * h) {
|
||||
pixels.push(r);
|
||||
pixels.push(g);
|
||||
pixels.push(b);
|
||||
}
|
||||
MatelightFrame { width: w, height: h, brightness: 255, pixels }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_column_returns_solid_colour() {
|
||||
let f = solid_frame(40, 16, 200, 80, 30);
|
||||
assert_eq!(f.sample_column(0.0, 2), (200, 80, 30));
|
||||
assert_eq!(f.sample_column(0.5, 2), (200, 80, 30));
|
||||
assert_eq!(f.sample_column(1.0, 2), (200, 80, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_column_picks_correct_side() {
|
||||
// Left half red, right half blue. Half-band 0 → single column.
|
||||
let w = 40;
|
||||
let h = 16;
|
||||
let mut pixels = Vec::with_capacity(w * h * 3);
|
||||
for _ in 0..h {
|
||||
for x in 0..w {
|
||||
if x < w / 2 {
|
||||
pixels.extend_from_slice(&[255, 0, 0]);
|
||||
} else {
|
||||
pixels.extend_from_slice(&[0, 0, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let f = MatelightFrame { width: w, height: h, brightness: 255, pixels };
|
||||
let (lr, _, lb) = f.sample_column(0.05, 0);
|
||||
let (rr, _, rb) = f.sample_column(0.95, 0);
|
||||
assert!(lr > 200 && lb < 20, "left should be red, got ({lr},_,{lb})");
|
||||
assert!(rb > 200 && rr < 20, "right should be blue, got ({rr},_,{rb})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_handles_empty_gracefully() {
|
||||
let f = MatelightFrame { width: 0, height: 0, brightness: 0, pixels: vec![] };
|
||||
assert_eq!(f.sample_column(0.5, 2), (0, 0, 0));
|
||||
}
|
||||
}
|
||||
1458
src/presets.rs
Normal file
1458
src/presets.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue