From e911a11bf7144da3004acd186313a5ba851ed9d6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 27 Oct 2026 01:10:50 +0100 Subject: [PATCH 1/4] Format the code with rustfmt This commit changes only the format. CI checks the format from now on. --- crates/autoblink-core-dbserver/src/client.rs | 9 +- crates/autoblink-core-dbserver/src/lib.rs | 11 +- crates/autoblink-core-midi/src/clock.rs | 98 ++++++-- crates/autoblink-core-midi/src/mapper.rs | 217 +++++++++++++++--- crates/autoblink-core-midi/src/settings.rs | 57 ++++- crates/autoblink-core-prolink/src/tracker.rs | 86 ++++++-- esp32-emulator/src/main.rs | 221 +++++++++++++++---- esp32-emulator/src/verify_stopped.rs | 2 +- esp32/src/dmx.rs | 13 +- esp32/src/logring.rs | 45 +++- esp32/src/main.rs | 103 +++++++-- esp32/src/mem.rs | 9 +- esp32/src/midi.rs | 21 +- esp32/src/net.rs | 51 ++++- esp32/src/ota.rs | 54 ++++- esp32/src/prolink.rs | 51 ++++- esp32/src/settings.rs | 112 ++++++++-- esp32/src/tracks.rs | 90 ++++++-- esp32/src/webui.rs | 134 ++++++++--- host/src/midi/mapper.rs | 10 +- host/src/prolink/metadata.rs | 6 +- host/src/runtime/midi.rs | 4 +- host/src/runtime/prolink.rs | 18 +- 23 files changed, 1146 insertions(+), 276 deletions(-) diff --git a/crates/autoblink-core-dbserver/src/client.rs b/crates/autoblink-core-dbserver/src/client.rs index 6cf5394..d089e7a 100644 --- a/crates/autoblink-core-dbserver/src/client.rs +++ b/crates/autoblink-core-dbserver/src/client.rs @@ -105,7 +105,14 @@ pub fn fetch( track_type: u8, rekordbox_id: u32, ) -> Result { - fetch_with(&TcpStream::connect_timeout, ip, asking, track_slot, track_type, rekordbox_id) + fetch_with( + &TcpStream::connect_timeout, + ip, + asking, + track_slot, + track_type, + rekordbox_id, + ) } /// Like [`fetch`], but opens the two TCP connections with `connect`. Use it to select the diff --git a/crates/autoblink-core-dbserver/src/lib.rs b/crates/autoblink-core-dbserver/src/lib.rs index 5a63592..2127e8f 100644 --- a/crates/autoblink-core-dbserver/src/lib.rs +++ b/crates/autoblink-core-dbserver/src/lib.rs @@ -22,11 +22,12 @@ pub use parse::{ }; pub use protocol::{ decode_utf16be, describe_args, encode_message, encode_number_field, encode_utf16be, read_field, - read_message, read_message_with, DbMessage, Field, ARG_TAG_BLOB, ARG_TAG_NUMBER, ARG_TAG_STRING, DB_MAGIC, - DB_SERVER_QUERY_PORT, EXT_DAT, EXT_EXT, FIELD_BLOB, FIELD_STRING, FIELD_U16, FIELD_U32, - FIELD_U8, MAX_ARG_COUNT, MAX_FIELD_LEN, MAX_STREAM_LEN, MENU_DATA, MSG_ANLZ_TAG_REQ, MSG_ANLZ_TAG_RESP, - MSG_CUE_LIST_EXT_REQ, MSG_CUE_LIST_EXT_RESP, MSG_MENU_FOOTER, MSG_MENU_HEADER, MSG_MENU_ITEM, - MSG_METADATA_REQ, MSG_RENDER_MENU, MSG_SETUP_REQ, PREVIEW_COLUMNS, TAG_PQTZ, TAG_PSSI, TAG_PWV4, + read_message, read_message_with, DbMessage, Field, ARG_TAG_BLOB, ARG_TAG_NUMBER, + ARG_TAG_STRING, DB_MAGIC, DB_SERVER_QUERY_PORT, EXT_DAT, EXT_EXT, FIELD_BLOB, FIELD_STRING, + FIELD_U16, FIELD_U32, FIELD_U8, MAX_ARG_COUNT, MAX_FIELD_LEN, MAX_STREAM_LEN, MENU_DATA, + MSG_ANLZ_TAG_REQ, MSG_ANLZ_TAG_RESP, MSG_CUE_LIST_EXT_REQ, MSG_CUE_LIST_EXT_RESP, + MSG_MENU_FOOTER, MSG_MENU_HEADER, MSG_MENU_ITEM, MSG_METADATA_REQ, MSG_RENDER_MENU, + MSG_SETUP_REQ, PREVIEW_COLUMNS, TAG_PQTZ, TAG_PSSI, TAG_PWV4, }; pub use types::{ current_phrase_idx, phrase_info, phrase_status, Cue, PhraseEntry, PhraseInfo, PhraseKind, diff --git a/crates/autoblink-core-midi/src/clock.rs b/crates/autoblink-core-midi/src/clock.rs index 688529b..19b844e 100644 --- a/crates/autoblink-core-midi/src/clock.rs +++ b/crates/autoblink-core-midi/src/clock.rs @@ -233,13 +233,22 @@ impl Clock { return; } self.bars = 0; - let Some(error) = self.earliest.take() else { return }; + let Some(error) = self.earliest.take() else { + return; + }; self.last_error_ms = Some(error / 1000.0); if error.abs() < DEADBAND { return; } - let duration = (cfg.smoothing_ms as f64 * 1000.0).max(1000.0).max(10.0 * error.abs()); - self.slew = Some(Slew { start: now_f, duration, total: error, applied: 0.0 }); + let duration = (cfg.smoothing_ms as f64 * 1000.0) + .max(1000.0) + .max(10.0 * error.abs()); + self.slew = Some(Slew { + start: now_f, + duration, + total: error, + applied: 0.0, + }); } /// The time from the nearest beat of the grid to `now`, in µs. The part of the slew that the @@ -291,7 +300,11 @@ mod tests { const BEAT: u64 = 500_000; // 120 BPM fn master(playing: bool) -> Option { - Some(Master { device: 1, playing, bpm: 120.0 }) + Some(Master { + device: 1, + playing, + bpm: 120.0, + }) } fn run(f: impl FnOnce(&mut dyn FnMut(Out))) -> Vec { @@ -332,7 +345,9 @@ mod tests { let mut now = 0; while now < BEAT { now += 1000; - n += pulses(&run(|o| { c.poll(&cfg, now, master(true), &mut |x| o(x)); })); + n += pulses(&run(|o| { + c.poll(&cfg, now, master(true), &mut |x| o(x)); + })); } // The pulse at time 0 came with Start, so one beat later 24 more are out. assert_eq!(n, 24); @@ -341,7 +356,10 @@ mod tests { #[test] fn latency_moves_the_send_time() { - let cfg = MidiConfig { latency_ms: 5, ..MidiConfig::default() }; + let cfg = MidiConfig { + latency_ms: 5, + ..MidiConfig::default() + }; let mut c = started(&cfg); let next = c.poll(&cfg, 0, master(true), &mut |_| {}).unwrap(); assert_eq!(next, BEAT / 24 + 5_000); @@ -351,30 +369,57 @@ mod tests { fn loop_sync_stops_when_the_master_stops() { let cfg = MidiConfig::default(); let mut c = started(&cfg); - let out = run(|o| { c.poll(&cfg, 10, master(false), &mut |x| o(x)); }); + let out = run(|o| { + c.poll(&cfg, 10, master(false), &mut |x| o(x)); + }); assert_eq!(out, [Out::Realtime(STOP)]); assert_eq!(c.state_label(), "wait"); } #[test] fn without_loop_sync_a_restart_continues_at_the_beat() { - let cfg = MidiConfig { clock_loop_sync: false, ..MidiConfig::default() }; + let cfg = MidiConfig { + clock_loop_sync: false, + ..MidiConfig::default() + }; let mut c = Clock::default(); c.poll(&cfg, 0, master(true), &mut |_| {}); let out = run(|o| c.beat(&cfg, 0, 1, 3, 120.0, &mut |x| o(x))); assert_eq!(out, [Out::Realtime(START), Out::Realtime(CLOCK)]); // A pause does not stop the clock. - assert!(run(|o| { c.poll(&cfg, 10, master(false), &mut |x| o(x)); }).is_empty()); + assert!(run(|o| { + c.poll(&cfg, 10, master(false), &mut |x| o(x)); + }) + .is_empty()); // A new master stops it. Its first beat continues from the song position. - let other = Some(Master { device: 2, playing: true, bpm: 120.0 }); - assert_eq!(run(|o| { c.poll(&cfg, 20, other, &mut |x| o(x)); }), [Out::Realtime(STOP)]); + let other = Some(Master { + device: 2, + playing: true, + bpm: 120.0, + }); + assert_eq!( + run(|o| { + c.poll(&cfg, 20, other, &mut |x| o(x)); + }), + [Out::Realtime(STOP)] + ); let out = run(|o| c.beat(&cfg, 30, 2, 2, 120.0, &mut |x| o(x))); - assert_eq!(out, [Out::SongPosition(4), Out::Realtime(CONTINUE), Out::Realtime(CLOCK)]); + assert_eq!( + out, + [ + Out::SongPosition(4), + Out::Realtime(CONTINUE), + Out::Realtime(CLOCK) + ] + ); } #[test] fn a_late_downbeat_slews_the_grid() { - let cfg = MidiConfig { phrase_lock_bars: 1, ..MidiConfig::default() }; + let cfg = MidiConfig { + phrase_lock_bars: 1, + ..MidiConfig::default() + }; let mut c = started(&cfg); // The next downbeat comes 4 beats plus 10 ms later than the grid expects. let at = 4 * BEAT + 10_000; @@ -405,7 +450,10 @@ mod tests { #[test] fn the_correction_uses_the_earliest_beat() { - let cfg = MidiConfig { phrase_lock_bars: 2, ..MidiConfig::default() }; + let cfg = MidiConfig { + phrase_lock_bars: 2, + ..MidiConfig::default() + }; let mut c = started(&cfg); bar(&mut c, &cfg, BEAT, [150_000, 40_000, 180_000, 90_000]); bar(&mut c, &cfg, 5 * BEAT, [120_000, 30_000, 200_000, 160_000]); @@ -415,7 +463,10 @@ mod tests { #[test] fn a_small_error_does_not_move_the_grid() { - let cfg = MidiConfig { phrase_lock_bars: 1, ..MidiConfig::default() }; + let cfg = MidiConfig { + phrase_lock_bars: 1, + ..MidiConfig::default() + }; let mut c = started(&cfg); bar(&mut c, &cfg, BEAT, [4_000, 3_000, 4_000, 4_000]); assert!((c.last_error_ms().unwrap() - 3.0).abs() < 0.1); @@ -426,11 +477,20 @@ mod tests { fn a_long_stall_resyncs_on_the_next_downbeat() { let cfg = MidiConfig::default(); let mut c = started(&cfg); - let out = run(|o| { c.poll(&cfg, 2 * BEAT + 1000, master(true), &mut |x| o(x)); }); + let out = run(|o| { + c.poll(&cfg, 2 * BEAT + 1000, master(true), &mut |x| o(x)); + }); // 48 pulses were due. The clock sends only 48 % 24 = 0 of them. assert_eq!(pulses(&out), 0); let out = run(|o| c.beat(&cfg, 4 * BEAT, 1, 1, 120.0, &mut |x| o(x))); - assert_eq!(out, [Out::Realtime(STOP), Out::Realtime(START), Out::Realtime(CLOCK)]); + assert_eq!( + out, + [ + Out::Realtime(STOP), + Out::Realtime(START), + Out::Realtime(CLOCK) + ] + ); } #[test] @@ -438,7 +498,9 @@ mod tests { let mut cfg = MidiConfig::default(); let mut c = started(&cfg); cfg.clock_enabled = false; - let out = run(|o| { c.poll(&cfg, 10, master(true), &mut |x| o(x)); }); + let out = run(|o| { + c.poll(&cfg, 10, master(true), &mut |x| o(x)); + }); assert_eq!(out, [Out::Realtime(STOP)]); assert_eq!(c.state_label(), "idle"); } diff --git a/crates/autoblink-core-midi/src/mapper.rs b/crates/autoblink-core-midi/src/mapper.rs index 6ad3a3b..cf98847 100644 --- a/crates/autoblink-core-midi/src/mapper.rs +++ b/crates/autoblink-core-midi/src/mapper.rs @@ -61,7 +61,13 @@ fn cc_value(i: usize, s: &Snapshot) -> u8 { 3 if s.phrase16 > 0 => scale01(((s.phrase16 - 1) as f64 + s.beat_phase) / 16.0), 3 => 0, 4 => scale01(s.beat_phase), - 5 => if s.playing { 127 } else { 0 }, + 5 => { + if s.playing { + 127 + } else { + 0 + } + } _ => s.device.min(127), } } @@ -79,7 +85,12 @@ pub struct Mapper { impl Default for Mapper { fn default() -> Self { - Self { prev: [0; CC_COUNT], prev_phrase16: 0, prev_phrase_idx: None, kind_sent_for: None } + Self { + prev: [0; CC_COUNT], + prev_phrase16: 0, + prev_phrase_idx: None, + kind_sent_for: None, + } } } @@ -91,10 +102,18 @@ impl Mapper { } let ch = cfg.note_channel; if cfg.send_beat { - out(Out::Note { ch, note: cfg.beat_note, vel: beat_velocity(beat_in_bar) }); + out(Out::Note { + ch, + note: cfg.beat_note, + vel: beat_velocity(beat_in_bar), + }); } if beat_in_bar == 1 && cfg.send_downbeat { - out(Out::Note { ch, note: cfg.downbeat_note, vel: 127 }); + out(Out::Note { + ch, + note: cfg.downbeat_note, + vel: 127, + }); } } @@ -110,14 +129,22 @@ impl Mapper { let val = cc_value(i, s); if val != self.prev[i] { self.prev[i] = val; - out(Out::Cc { ch, num: cc.num, val }); + out(Out::Cc { + ch, + num: cc.num, + val, + }); } } // The page help says: "Sends 0 at the start of each 16-beat block". The host never // sends it, because its dedupe starts at 0. The firmware sends it as the help says. let cc = cfg.ccs[PHRASE_16]; if cfg.cc_enabled && cc.on && s.phrase16 == 1 && self.prev_phrase16 != 1 { - out(Out::Cc { ch, num: cc.num, val: 0 }); + out(Out::Cc { + ch, + num: cc.num, + val: 0, + }); } self.prev_phrase16 = s.phrase16; self.update_phrase(cfg, s, out); @@ -132,7 +159,11 @@ impl Mapper { let idx = s.phrase.map(|p| p.idx); if let (Some(prev), Some(cur)) = (self.prev_phrase_idx, idx) { if prev != cur && cfg.notes_enabled && cfg.send_phrase_change { - out(Out::Note { ch: cfg.note_channel, note: cfg.phrase_change_note, vel: 127 }); + out(Out::Note { + ch: cfg.note_channel, + note: cfg.phrase_change_note, + vel: 127, + }); } } self.prev_phrase_idx = idx; @@ -143,20 +174,35 @@ impl Mapper { self.kind_sent_for = Some(key); let cc = cfg.ccs[PHRASE_KIND]; if cfg.cc_enabled && cc.on { - out(Out::Cc { ch: cfg.cc_channel, num: cc.num, val: (code * 10).min(127) }); + out(Out::Cc { + ch: cfg.cc_channel, + num: cc.num, + val: (code * 10).min(127), + }); } if cfg.notes_enabled && cfg.send_phrase_kind { let note = cfg.phrase_base_note.saturating_add(code).min(127); - out(Out::Note { ch: cfg.note_channel, note, vel: 127 }); + out(Out::Note { + ch: cfg.note_channel, + note, + vel: 127, + }); } } - for (i, val) in [(PHRASE_PROGRESS, scale01(p.progress)), (BEATS_LEFT, p.beats_left.min(127) as u8)] { + for (i, val) in [ + (PHRASE_PROGRESS, scale01(p.progress)), + (BEATS_LEFT, p.beats_left.min(127) as u8), + ] { let cc = cfg.ccs[i]; if !cfg.cc_enabled || !cc.on { self.prev[i] = 255; } else if val != self.prev[i] { self.prev[i] = val; - out(Out::Cc { ch: cfg.cc_channel, num: cc.num, val }); + out(Out::Cc { + ch: cfg.cc_channel, + num: cc.num, + val, + }); } } } @@ -195,7 +241,15 @@ mod tests { } fn snap() -> Snapshot { - Snapshot { device: 2, bpm: 128.5, pitch: 2.0, phrase16: 5, beat_phase: 0.5, playing: true, phrase: None } + Snapshot { + device: 2, + bpm: 128.5, + pitch: 2.0, + phrase16: 5, + beat_phase: 0.5, + playing: true, + phrase: None, + } } #[test] @@ -203,9 +257,30 @@ mod tests { let cfg = MidiConfig::default(); let m = Mapper::default(); let out = collect(|o| m.beat(&cfg, 1, &mut |x| o(x))); - assert_eq!(out, [Out::Note { ch: 9, note: 36, vel: 127 }, Out::Note { ch: 9, note: 37, vel: 127 }]); + assert_eq!( + out, + [ + Out::Note { + ch: 9, + note: 36, + vel: 127 + }, + Out::Note { + ch: 9, + note: 37, + vel: 127 + } + ] + ); let out = collect(|o| m.beat(&cfg, 3, &mut |x| o(x))); - assert_eq!(out, [Out::Note { ch: 9, note: 36, vel: 64 }]); + assert_eq!( + out, + [Out::Note { + ch: 9, + note: 36, + vel: 64 + }] + ); } #[test] @@ -216,7 +291,15 @@ mod tests { let cc = |num, val| Out::Cc { ch: 0, num, val }; assert_eq!( out, - [cc(1, 68), cc(33, 64), cc(2, 76), cc(3, 36), cc(4, 64), cc(5, 127), cc(6, 2)] + [ + cc(1, 68), + cc(33, 64), + cc(2, 76), + cc(3, 36), + cc(4, 64), + cc(5, 127), + cc(6, 2) + ] ); assert!(collect(|o| m.update(&cfg, &snap(), &mut |x| o(x))).is_empty()); } @@ -230,47 +313,117 @@ mod tests { m.update(&cfg, &snap(), &mut |_| {}); cfg.ccs[5].on = true; let out = collect(|o| m.update(&cfg, &snap(), &mut |x| o(x))); - assert_eq!(out, [Out::Cc { ch: 0, num: 5, val: 127 }]); + assert_eq!( + out, + [Out::Cc { + ch: 0, + num: 5, + val: 127 + }] + ); } #[test] fn phrase16_sends_0_at_each_block_start() { let cfg = MidiConfig::default(); let mut m = Mapper::default(); - let at = |p| Snapshot { phrase16: p, ..snap() }; + let at = |p| Snapshot { + phrase16: p, + ..snap() + }; m.update(&cfg, &at(16), &mut |_| {}); let out = collect(|o| m.update(&cfg, &at(1), &mut |x| o(x))); - assert!(out.contains(&Out::Cc { ch: 0, num: 7, val: 0 })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 7, + val: 0 + })); let out = collect(|o| m.update(&cfg, &at(1), &mut |x| o(x))); - assert!(!out.contains(&Out::Cc { ch: 0, num: 7, val: 0 })); + assert!(!out.contains(&Out::Cc { + ch: 0, + num: 7, + val: 0 + })); } #[test] fn phrase_messages_follow_the_host_rules() { let cfg = MidiConfig::default(); let mut m = Mapper::default(); - let phrase = |idx, code, beats_left| Phrase { rekordbox_id: 207, idx, code: Some(code), progress: 0.5, beats_left }; - let at = |p| Snapshot { phrase: Some(p), ..snap() }; + let phrase = |idx, code, beats_left| Phrase { + rekordbox_id: 207, + idx, + code: Some(code), + progress: 0.5, + beats_left, + }; + let at = |p| Snapshot { + phrase: Some(p), + ..snap() + }; let out = collect(|o| m.update(&cfg, &at(phrase(0, 1, 16)), &mut |x| o(x))); // The first phrase: kind CC 10, kind note 40 + 1, progress 64, beats left 16. No change note. - assert!(out.contains(&Out::Cc { ch: 0, num: 20, val: 10 })); - assert!(out.contains(&Out::Note { ch: 9, note: 41, vel: 127 })); - assert!(out.contains(&Out::Cc { ch: 0, num: 21, val: 64 })); - assert!(out.contains(&Out::Cc { ch: 0, num: 22, val: 16 })); - assert!(!out.contains(&Out::Note { ch: 9, note: 38, vel: 127 })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 20, + val: 10 + })); + assert!(out.contains(&Out::Note { + ch: 9, + note: 41, + vel: 127 + })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 21, + val: 64 + })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 22, + val: 16 + })); + assert!(!out.contains(&Out::Note { + ch: 9, + note: 38, + vel: 127 + })); // The same phrase again sends nothing new. let out = collect(|o| m.update(&cfg, &at(phrase(0, 1, 16)), &mut |x| o(x))); assert!(out.is_empty()); // The next phrase: the change note and the new kind. let out = collect(|o| m.update(&cfg, &at(phrase(1, 8, 32)), &mut |x| o(x))); - assert!(out.contains(&Out::Note { ch: 9, note: 38, vel: 127 })); - assert!(out.contains(&Out::Cc { ch: 0, num: 20, val: 80 })); - assert!(out.contains(&Out::Note { ch: 9, note: 48, vel: 127 })); + assert!(out.contains(&Out::Note { + ch: 9, + note: 38, + vel: 127 + })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 20, + val: 80 + })); + assert!(out.contains(&Out::Note { + ch: 9, + note: 48, + vel: 127 + })); // A phrase with an unknown kind still sends the change note and the progress CCs. - let unknown = Phrase { code: None, ..phrase(2, 0, 8) }; + let unknown = Phrase { + code: None, + ..phrase(2, 0, 8) + }; let out = collect(|o| m.update(&cfg, &at(unknown), &mut |x| o(x))); - assert!(out.contains(&Out::Note { ch: 9, note: 38, vel: 127 })); - assert!(out.contains(&Out::Cc { ch: 0, num: 22, val: 8 })); + assert!(out.contains(&Out::Note { + ch: 9, + note: 38, + vel: 127 + })); + assert!(out.contains(&Out::Cc { + ch: 0, + num: 22, + val: 8 + })); assert!(!out.iter().any(|o| matches!(o, Out::Cc { num: 20, .. }))); } diff --git a/crates/autoblink-core-midi/src/settings.rs b/crates/autoblink-core-midi/src/settings.rs index b9d89dc..be15ab9 100644 --- a/crates/autoblink-core-midi/src/settings.rs +++ b/crates/autoblink-core-midi/src/settings.rs @@ -25,8 +25,9 @@ const CC_LABELS: [&str; CC_COUNT] = [ ]; /// The host indices that the firmware supports. -pub const INDICES: [usize; 24] = - [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]; +pub const INDICES: [usize; 24] = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, +]; /// One row of the settings message. #[derive(Debug, Clone, PartialEq, Serialize)] @@ -78,15 +79,45 @@ fn item(cfg: &MidiConfig, idx: usize) -> SettingItem { match idx { 3 => toggle("Clock Enabled", Some("MIDI Clock"), cfg.clock_enabled), 4 => toggle("Clock Loop Sync", None, cfg.clock_loop_sync), - 5 => number("BPM Smoothing", None, format!("{} ms", cfg.smoothing_ms), cfg.smoothing_ms.to_string(), None), - 6 => number("Latency Comp", None, format!("{} ms", cfg.latency_ms), cfg.latency_ms.to_string(), None), - 7 => number("Phrase Lock Stable", None, format!("{} bars", cfg.phrase_lock_bars), cfg.phrase_lock_bars.to_string(), None), + 5 => number( + "BPM Smoothing", + None, + format!("{} ms", cfg.smoothing_ms), + cfg.smoothing_ms.to_string(), + None, + ), + 6 => number( + "Latency Comp", + None, + format!("{} ms", cfg.latency_ms), + cfg.latency_ms.to_string(), + None, + ), + 7 => number( + "Phrase Lock Stable", + None, + format!("{} bars", cfg.phrase_lock_bars), + cfg.phrase_lock_bars.to_string(), + None, + ), 8 => toggle("Notes Enabled", Some("MIDI Notes"), cfg.notes_enabled), 9 => plain("Note Channel", (cfg.note_channel + 1).to_string(), None), 10 => plain("Beat Note", cfg.beat_note.to_string(), Some(cfg.send_beat)), - 11 => plain("Downbeat Note", cfg.downbeat_note.to_string(), Some(cfg.send_downbeat)), - 12 => plain("Phrase Change Note", cfg.phrase_change_note.to_string(), Some(cfg.send_phrase_change)), - 13 => plain("Phrase Kind Base Note", cfg.phrase_base_note.to_string(), Some(cfg.send_phrase_kind)), + 11 => plain( + "Downbeat Note", + cfg.downbeat_note.to_string(), + Some(cfg.send_downbeat), + ), + 12 => plain( + "Phrase Change Note", + cfg.phrase_change_note.to_string(), + Some(cfg.send_phrase_change), + ), + 13 => plain( + "Phrase Kind Base Note", + cfg.phrase_base_note.to_string(), + Some(cfg.send_phrase_kind), + ), 15 => toggle("CC Enabled", Some("MIDI CC"), cfg.cc_enabled), 16 => plain("CC Channel", (cfg.cc_channel + 1).to_string(), None), _ => { @@ -127,7 +158,10 @@ pub fn cycle(cfg: &mut MidiConfig, idx: usize, dir: i8) -> Result<(), String> { 3 => cfg.clock_enabled = !cfg.clock_enabled, 4 => cfg.clock_loop_sync = !cfg.clock_loop_sync, 6 => cfg.latency_ms = (cfg.latency_ms + if dir < 0 { -5 } else { 5 }).clamp(-1000, 1000), - 7 => cfg.phrase_lock_bars = (cfg.phrase_lock_bars as i16 + dir.signum() as i16).clamp(1, 32) as u8, + 7 => { + cfg.phrase_lock_bars = + (cfg.phrase_lock_bars as i16 + dir.signum() as i16).clamp(1, 32) as u8 + } 8 => cfg.notes_enabled = !cfg.notes_enabled, 10 => cfg.send_beat = !cfg.send_beat, 11 => cfg.send_downbeat = !cfg.send_downbeat, @@ -191,7 +225,10 @@ mod tests { v["ccs"].as_array_mut().unwrap().truncate(8); v["ccs"][0]["num"] = 99.into(); let cfg: MidiConfig = serde_json::from_value(v).unwrap(); - assert_eq!((cfg.ccs[0].num, cfg.ccs[8].num, cfg.ccs[10].num), (99, 20, 22)); + assert_eq!( + (cfg.ccs[0].num, cfg.ccs[8].num, cfg.ccs[10].num), + (99, 20, 22) + ); } #[test] diff --git a/crates/autoblink-core-prolink/src/tracker.rs b/crates/autoblink-core-prolink/src/tracker.rs index b5a3378..effabc3 100644 --- a/crates/autoblink-core-prolink/src/tracker.rs +++ b/crates/autoblink-core-prolink/src/tracker.rs @@ -85,7 +85,11 @@ impl Deck { } fn period_ms(&self) -> f64 { - if self.bpm > 0.0 { 60_000.0 / self.bpm } else { 1_000.0 } + if self.bpm > 0.0 { + 60_000.0 / self.bpm + } else { + 1_000.0 + } } } @@ -113,7 +117,13 @@ pub struct Tracker { impl Tracker { pub const fn new() -> Self { - Self { decks: Vec::new(), peers: Vec::new(), beat_master: None, flag_master: None, own: 0 } + Self { + decks: Vec::new(), + peers: Vec::new(), + beat_master: None, + flag_master: None, + own: 0, + } } /// Sets the device number of this device, so that the tracker ignores its own packets. @@ -131,7 +141,12 @@ impl Tracker { return; } if ka.device_type != DEV_CDJ || !self.is_player(ka.device_number) { - let peer = Peer { num: ka.device_number, name: ka.name.clone(), ip: ka.ip, last_seen_ms: now_ms }; + let peer = Peer { + num: ka.device_number, + name: ka.name.clone(), + ip: ka.ip, + last_seen_ms: now_ms, + }; match self.peers.iter_mut().find(|p| p.num == ka.device_number) { Some(p) => *p = peer, None => self.peers.push(peer), @@ -205,7 +220,8 @@ impl Tracker { if deck.status_playing { deck.pitch = st.pitch_pct; } - deck.beat_count = (st.beat_count != u32::MAX && st.beat_count != 0).then_some(st.beat_count); + deck.beat_count = + (st.beat_count != u32::MAX && st.beat_count != 0).then_some(st.beat_count); if let Some(count) = deck.beat_count { // Like the host: the phrase position follows the beat number of the track. deck.phrase16 = ((count - 1) % 16 + 1) as u8; @@ -252,7 +268,11 @@ impl Tracker { if secs == 0 { return; } - if let Some(d) = self.decks.iter_mut().find(|d| d.num == device && d.track.rekordbox_id == rekordbox_id) { + if let Some(d) = self + .decks + .iter_mut() + .find(|d| d.num == device && d.track.rekordbox_id == rekordbox_id) + { d.track_len_ms = Some(secs.saturating_mul(1000)); } } @@ -263,7 +283,8 @@ impl Tracker { .retain(|d| now_ms.saturating_sub(d.last_seen_ms) <= DECK_TIMEOUT_MS); self.peers .retain(|p| now_ms.saturating_sub(p.last_seen_ms) <= DECK_TIMEOUT_MS); - let present = |m: Option, decks: &[Deck]| m.filter(|n| decks.iter().any(|d| d.num == *n)); + let present = + |m: Option, decks: &[Deck]| m.filter(|n| decks.iter().any(|d| d.num == *n)); self.beat_master = present(self.beat_master, &self.decks); self.flag_master = present(self.flag_master, &self.decks); } @@ -278,9 +299,12 @@ impl Tracker { /// The name and the IP address of any device on the network: a deck or another device. pub fn device_address(&self, num: u8) -> Option<(&str, [u8; 4])> { - self.deck(num) - .map(|d| (d.name.as_str(), d.ip)) - .or_else(|| self.peers.iter().find(|p| p.num == num).map(|p| (p.name.as_str(), p.ip))) + self.deck(num).map(|d| (d.name.as_str(), d.ip)).or_else(|| { + self.peers + .iter() + .find(|p| p.num == num) + .map(|p| (p.name.as_str(), p.ip)) + }) } pub fn master(&self) -> Option<&Deck> { @@ -300,7 +324,10 @@ impl Tracker { let i = match self.decks.iter().position(|d| d.num == num) { Some(i) => i, None => { - self.decks.push(Deck { num, ..Deck::default() }); + self.decks.push(Deck { + num, + ..Deck::default() + }); self.decks.sort_by_key(|d| d.num); self.decks.iter().position(|d| d.num == num).unwrap() } @@ -414,8 +441,21 @@ mod tests { fn keepalive_names_the_deck_and_skips_other_devices() { let mut t = Tracker::new(); t.on_keepalive(&keepalive(3, "XDJ-AZ"), 0); - t.on_keepalive(&KeepAlive { device_type: 1, ..keepalive(0x21, "DJM") }, 0); - t.on_keepalive(&KeepAlive { device_type: 3, ip: [10, 0, 0, 9], ..keepalive(17, "rekordbox") }, 0); + t.on_keepalive( + &KeepAlive { + device_type: 1, + ..keepalive(0x21, "DJM") + }, + 0, + ); + t.on_keepalive( + &KeepAlive { + device_type: 3, + ip: [10, 0, 0, 9], + ..keepalive(17, "rekordbox") + }, + 0, + ); assert_eq!(t.decks().len(), 1); assert_eq!(t.device_address(17), Some(("rekordbox", [10, 0, 0, 9]))); assert_eq!(t.decks()[0].name, "XDJ-AZ"); @@ -449,10 +489,19 @@ mod tests { #[test] fn status_reports_a_track_change_once() { let mut t = Tracker::new(); - let tr = t.on_cdj_status(&status(2, false, PlayState::Playing, 207), 0).unwrap(); - assert_eq!((tr.device, tr.slot, tr.track_type, tr.rekordbox_id), (2, 3, 1, 207)); - assert!(t.on_cdj_status(&status(2, false, PlayState::Playing, 207), 200).is_none()); - let eject = t.on_cdj_status(&status(2, false, PlayState::NoTrack, 0), 400).unwrap(); + let tr = t + .on_cdj_status(&status(2, false, PlayState::Playing, 207), 0) + .unwrap(); + assert_eq!( + (tr.device, tr.slot, tr.track_type, tr.rekordbox_id), + (2, 3, 1, 207) + ); + assert!(t + .on_cdj_status(&status(2, false, PlayState::Playing, 207), 200) + .is_none()); + let eject = t + .on_cdj_status(&status(2, false, PlayState::NoTrack, 0), 400) + .unwrap(); assert_eq!(eject.rekordbox_id, 0); } @@ -484,7 +533,10 @@ mod tests { }; t.on_abs_position(&p, 0); let d = t.deck(3).unwrap(); - assert_eq!((d.playhead_ms, d.track_len_ms), (Some(12_345), Some(300_000))); + assert_eq!( + (d.playhead_ms, d.track_len_ms), + (Some(12_345), Some(300_000)) + ); t.set_track_length(3, 0, 299); assert_eq!(t.deck(3).unwrap().track_len_ms, Some(299_000)); } diff --git a/esp32-emulator/src/main.rs b/esp32-emulator/src/main.rs index 6e06d2e..23c3ec3 100644 --- a/esp32-emulator/src/main.rs +++ b/esp32-emulator/src/main.rs @@ -172,7 +172,11 @@ impl EmulatorState { state.add_log(0, "INFO", "MIDI: GPIO1(IN) GPIO3(OUT) @ 31250"); state.add_log(0, "INFO", "Pro DJ Link virtual CDJ #5"); state.add_log(0, "INFO", "Listening on UDP:50000,50001,50002"); - state.add_log(0, "INFO", "Proactive participation enabled (keep-alive + status templates)"); + state.add_log( + 0, + "INFO", + "Proactive participation enabled (keep-alive + status templates)", + ); state.add_log(0, "INFO", "Ready for Pro DJ Link packets..."); state } @@ -195,30 +199,51 @@ impl EmulatorState { fn refresh_master(&mut self) { let prev_master = self.master_idx; - + // 1. Priority: Explicit master flag from a deck - let explicit_master = self.decks.iter().position(|d| d.is_master && d.device_number != 0); - + let explicit_master = self + .decks + .iter() + .position(|d| d.is_master && d.device_number != 0); + if let Some(idx) = explicit_master { if self.master_idx != idx { let deck = &self.decks[idx]; - self.add_log(0, "INFO", format!("Master handoff: #{} ({}) is now MASTER", deck.device_number, deck.name)); + self.add_log( + 0, + "INFO", + format!( + "Master handoff: #{} ({}) is now MASTER", + deck.device_number, deck.name + ), + ); } self.master_idx = idx; return; } // 2. Fallback: First playing deck (if no explicit master) - let playing_deck = self.decks.iter().position(|d| d.is_playing && d.device_number != 0); + let playing_deck = self + .decks + .iter() + .position(|d| d.is_playing && d.device_number != 0); if let Some(idx) = playing_deck { if self.master_idx != idx { let deck = &self.decks[idx]; - self.add_log(0, "INFO", format!("Master fallback: #{} ({}) is PLAYING", deck.device_number, deck.name)); + self.add_log( + 0, + "INFO", + format!( + "Master fallback: #{} ({}) is PLAYING", + deck.device_number, deck.name + ), + ); } self.master_idx = idx; } else { // 3. Last resort: Keep current master if it still exists, otherwise reset to 0 - if self.master_idx >= self.decks.len() || self.decks[self.master_idx].device_number == 0 { + if self.master_idx >= self.decks.len() || self.decks[self.master_idx].device_number == 0 + { if prev_master != 0 { self.add_log(0, "WARN", "Master lost: no active or playing decks"); } @@ -239,7 +264,9 @@ impl EmulatorState { let pkt_type = data[10]; match port { - autoblink_core_prolink::PORT_DISCOVERY => self.handle_discovery_packet(addr, pkt_type, data), + autoblink_core_prolink::PORT_DISCOVERY => { + self.handle_discovery_packet(addr, pkt_type, data) + } autoblink_core_prolink::PORT_BEAT => self.handle_beat_packet(addr, pkt_type, data), autoblink_core_prolink::PORT_STATUS => self.handle_status_packet(addr, pkt_type, data), _ => {} @@ -272,7 +299,14 @@ impl EmulatorState { if self.decks[idx].name.is_empty() { self.decks[idx].device_type = ka.device_type; self.decks[idx].name = ka.name.clone(); - self.add_log(0, "INFO", format!("Identified: {} #{} ({})", ka.name, ka.device_number, type_name)); + self.add_log( + 0, + "INFO", + format!( + "Identified: {} #{} ({})", + ka.name, ka.device_number, type_name + ), + ); } } else if let Some(slot) = self.find_empty_slot() { self.decks[slot].device_number = ka.device_number; @@ -280,7 +314,14 @@ impl EmulatorState { self.decks[slot].name = ka.name.clone(); self.decks[slot].ip = Some(ka.ip); self.decks[slot].last_seen = std::time::Instant::now(); - self.add_log(0, "INFO", format!("Discovered: {} #{} ({})", ka.name, ka.device_number, type_name)); + self.add_log( + 0, + "INFO", + format!( + "Discovered: {} #{} ({})", + ka.name, ka.device_number, type_name + ), + ); } } } @@ -293,7 +334,11 @@ impl EmulatorState { 0x00 | 0x02 | 0x04 => { if data.len() >= 12 { let device_num = data[11]; - self.add_log(2, "DEBUG", format!("Claim {}: device #{}", pkt_type, device_num)); + self.add_log( + 2, + "DEBUG", + format!("Claim {}: device #{}", pkt_type, device_num), + ); } } 0x08 => { @@ -311,7 +356,11 @@ impl EmulatorState { if let Some(idx) = self.find_deck(beat.device_number) { self.decks[idx].bpm = beat.effective_bpm; self.decks[idx].pitch = beat.pitch_pct; - self.decks[idx].beat = if beat.beat_in_bar == 0 { 4 } else { beat.beat_in_bar }; + self.decks[idx].beat = if beat.beat_in_bar == 0 { + 4 + } else { + beat.beat_in_bar + }; self.decks[idx].last_seen = std::time::Instant::now(); let old_beat = self.decks[idx].beat; @@ -323,7 +372,9 @@ impl EmulatorState { self.decks[idx].is_playing = true; } - if self.decks.get(self.master_idx).map(|d| d.device_number) == Some(beat.device_number) { + if self.decks.get(self.master_idx).map(|d| d.device_number) + == Some(beat.device_number) + { self.midi_note_count += 1; self.midi_cc_count += 1; self.last_note = self.midi_config.beat_note; @@ -335,7 +386,14 @@ impl EmulatorState { self.last_cc_at = Some(Instant::now()); } - self.add_log(2, "DEBUG", format!("Beat #{} bpm={:.2} beat={}/4", beat.device_number, beat.effective_bpm, beat.beat_in_bar)); + self.add_log( + 2, + "DEBUG", + format!( + "Beat #{} bpm={:.2} beat={}/4", + beat.device_number, beat.effective_bpm, beat.beat_in_bar + ), + ); } } autoblink_core_prolink::PKT_ABS_POSITION => { @@ -345,14 +403,23 @@ impl EmulatorState { self.decks[idx].pitch = abs.pitch_pct; self.decks[idx].last_seen = std::time::Instant::now(); } - if self.decks.get(self.master_idx).map(|d| d.device_number) == Some(abs.device_number) { + if self.decks.get(self.master_idx).map(|d| d.device_number) + == Some(abs.device_number) + { self.midi_cc_count += 1; self.last_cc = self.midi_config.bar_phase_cc; self.last_cc_val = 64; self.last_cc_ch = self.midi_config.cc_channel; self.last_cc_at = Some(Instant::now()); } - self.add_log(2, "DEBUG", format!("AbsPosition #{} pos={}ms bpm={:.2}", abs.device_number, abs.playhead_ms, abs.effective_bpm)); + self.add_log( + 2, + "DEBUG", + format!( + "AbsPosition #{} pos={}ms bpm={:.2}", + abs.device_number, abs.playhead_ms, abs.effective_bpm + ), + ); } } _ => {} @@ -370,7 +437,7 @@ impl EmulatorState { self.decks[idx].is_on_air = s.is_on_air; self.decks[idx].pitch = s.pitch_pct; self.decks[idx].last_seen = std::time::Instant::now(); - + if s.is_master { // Clear master flag from all other decks if this one claims it for i in 0..self.decks.len() { @@ -379,15 +446,30 @@ impl EmulatorState { } } } - + self.decks[idx].is_master = s.is_master; self.decks[idx].bpm = s.effective_bpm; self.decks[idx].beat = s.beat_in_bar; } - let playing_str = if s.play_state.is_playing() || s.is_playing_flag { "playing" } else { "stopped" }; + let playing_str = if s.play_state.is_playing() || s.is_playing_flag { + "playing" + } else { + "stopped" + }; let master_str = if s.is_master { " MASTER" } else { "" }; - self.add_log(0, "INFO", format!("Status #{} {} bpm={:.2}{} (beat={})", s.device_number, playing_str, s.effective_bpm, master_str, s.beat_in_bar)); + self.add_log( + 0, + "INFO", + format!( + "Status #{} {} bpm={:.2}{} (beat={})", + s.device_number, + playing_str, + s.effective_bpm, + master_str, + s.beat_in_bar + ), + ); } } autoblink_core_prolink::PKT_MIXER_STATUS => { @@ -406,7 +488,16 @@ impl EmulatorState { self.decks[idx].bpm = bpm; } } - self.add_log(0, "DEBUG", format!("Mixer #{} is {} (bpm={:?})", s.device_number, if s.is_master { "master" } else { "slave" }, s.track_bpm)); + self.add_log( + 0, + "DEBUG", + format!( + "Mixer #{} is {} (bpm={:?})", + s.device_number, + if s.is_master { "master" } else { "slave" }, + s.track_bpm + ), + ); } } _ => {} @@ -415,18 +506,22 @@ impl EmulatorState { fn find_empty_slot(&self) -> Option { for (i, d) in self.decks.iter().enumerate() { - if d.device_number == 0 { return Some(i); } + if d.device_number == 0 { + return Some(i); + } } None } fn find_deck(&self, device_num: u8) -> Option { - self.decks.iter().position(|d| d.device_number == device_num) + self.decks + .iter() + .position(|d| d.device_number == device_num) } fn tick(&mut self) { self.tick += 1; - + let now = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(2); @@ -445,9 +540,17 @@ impl EmulatorState { self.refresh_master(); let master_idx = self.master_idx; - let master_bpm = if master_idx < self.decks.len() { self.decks[master_idx].bpm } else { 0.0 }; - let master_playing = if master_idx < self.decks.len() { self.decks[master_idx].is_playing } else { false }; - + let master_bpm = if master_idx < self.decks.len() { + self.decks[master_idx].bpm + } else { + 0.0 + }; + let master_playing = if master_idx < self.decks.len() { + self.decks[master_idx].is_playing + } else { + false + }; + if master_bpm > 0.0 && master_playing { let clocks_to_add = ((24.0 * master_bpm) / 600.0).round() as u64; if clocks_to_add > 0 { @@ -463,7 +566,8 @@ impl EmulatorState { } fn visible_decks(&self) -> Vec<&DeckState> { - self.decks.iter() + self.decks + .iter() .filter(|d| d.device_number != 0 && d.device_number != self.virtual_device_num) .collect() } @@ -587,8 +691,16 @@ impl EmulatorState { let note = |v: &str| v.parse::().ok().filter(|v| *v <= 127); let channel = |v: &str| v.parse::().ok().filter(|v| (1..=16).contains(v)); let ok = match idx { - 5 => value.parse::().ok().filter(|v| *v <= 1000).map(|v| c.smoothing_ms = v), - 6 => value.parse::().ok().filter(|v| (-1000..=1000).contains(v)).map(|v| c.latency_ms = v), + 5 => value + .parse::() + .ok() + .filter(|v| *v <= 1000) + .map(|v| c.smoothing_ms = v), + 6 => value + .parse::() + .ok() + .filter(|v| (-1000..=1000).contains(v)) + .map(|v| c.latency_ms = v), 8 => channel(value).map(|v| c.note_channel = v), 9 => note(value).map(|v| c.beat_note = v), 10 => note(value).map(|v| c.downbeat_note = v), @@ -614,7 +726,8 @@ impl EmulatorState { 3 => self.midi_config.clock_enabled = !self.midi_config.clock_enabled, 6 => { let step = if dir < 0 { -5 } else { 5 }; - self.midi_config.latency_ms = (self.midi_config.latency_ms + step).clamp(-1000, 1000); + self.midi_config.latency_ms = + (self.midi_config.latency_ms + step).clamp(-1000, 1000); } _ => return false, } @@ -627,7 +740,11 @@ impl EmulatorState { let first = self.log_total - self.logs.len() as u64; let skip = seq.saturating_sub(first) as usize; ( - self.logs.iter().skip(skip).map(|l| l.message.clone()).collect(), + self.logs + .iter() + .skip(skip) + .map(|l| l.message.clone()) + .collect(), self.log_total, ) } @@ -667,7 +784,10 @@ pub async fn run_emulator() -> Result<()> { let mut buf = [0u8; 4096]; loop { if let Ok((len, addr)) = sock_50000_recv.recv_from(&mut buf).await { - state_net.write().await.handle_packet(addr, 50000, &buf[..len]); + state_net + .write() + .await + .handle_packet(addr, 50000, &buf[..len]); } } }); @@ -696,7 +816,10 @@ pub async fn run_emulator() -> Result<()> { let mut buf = [0u8; 4096]; loop { if let Ok((len, addr)) = sock.recv_from(&mut buf).await { - state_net3.write().await.handle_packet(addr, 50002, &buf[..len]); + state_net3 + .write() + .await + .handle_packet(addr, 50002, &buf[..len]); } } }); @@ -709,12 +832,12 @@ pub async fn run_emulator() -> Result<()> { let mut interval = interval(Duration::from_millis(1500)); let mac = [0x02, 0xAB, 0xCD, 0xEF, 0x01, 0x02]; let name = build::pad_name("autoblink-emu"); - + loop { interval.tick().await; let s = state_part.read().await; let dev_num = s.virtual_device_num; - + // 1. Broadcast keep-alive // We use 10.10.10.50 as our IP in the packet for the emulator let my_ip = [10, 10, 10, 50]; @@ -722,10 +845,14 @@ pub async fn run_emulator() -> Result<()> { if let Err(e) = sock_part.send_to(&ka_pkt, "255.255.255.255:50000").await { eprintln!("Failed to send keep-alive: {}", e); } - + // 2. Unicast status template to all discovered decks on port 50002 let status_pkt = build::build_status_packet(&name, dev_num); - for deck in s.decks.iter().filter(|d| d.device_number != 0 && d.ip.is_some()) { + for deck in s + .decks + .iter() + .filter(|d| d.device_number != 0 && d.ip.is_some()) + { if let Some(ip) = deck.ip { let addr = SocketAddr::from((ip, 50002)); if let Err(e) = sock_part.send_to(&status_pkt, addr).await { @@ -808,10 +935,16 @@ fn origin_allowed(headers: &HeaderMap) -> bool { host.rsplit_once(':').map_or(host, |(name, _)| name) } .to_ascii_lowercase(); - same && (name.parse::().is_ok() || name == "localhost" || name.ends_with(".local")) + same && (name.parse::().is_ok() + || name == "localhost" + || name.ends_with(".local")) } -async fn ws_handler(ws: WebSocketUpgrade, headers: HeaderMap, State(sh): State) -> Response { +async fn ws_handler( + ws: WebSocketUpgrade, + headers: HeaderMap, + State(sh): State, +) -> Response { if !origin_allowed(&headers) { return StatusCode::FORBIDDEN.into_response(); } @@ -888,7 +1021,11 @@ async fn handle_command(sh: &Shared, text: &str) -> Option { let mut s = sh.state.write().await; match cmd["type"].as_str() { Some("cycle") => { - let dir = if cmd["dir"].as_i64().unwrap_or(1) < 0 { -1 } else { 1 }; + let dir = if cmd["dir"].as_i64().unwrap_or(1) < 0 { + -1 + } else { + 1 + }; s.cycle_setting(idx, dir); None } diff --git a/esp32-emulator/src/verify_stopped.rs b/esp32-emulator/src/verify_stopped.rs index dd9056f..dba53dc 100644 --- a/esp32-emulator/src/verify_stopped.rs +++ b/esp32-emulator/src/verify_stopped.rs @@ -1,6 +1,6 @@ +use autoblink_core_prolink::{MAGIC, PKT_ABS_POSITION, PKT_MIXER_STATUS}; use std::net::UdpSocket; use std::time::Duration; -use autoblink_core_prolink::{MAGIC, PKT_ABS_POSITION, PKT_MIXER_STATUS}; fn main() -> anyhow::Result<()> { let sock = UdpSocket::bind("0.0.0.0:0")?; diff --git a/esp32/src/dmx.rs b/esp32/src/dmx.rs index 67ae580..291bdee 100644 --- a/esp32/src/dmx.rs +++ b/esp32/src/dmx.rs @@ -33,7 +33,11 @@ pub struct Dmx { impl Dmx { /// Starts the frame thread. `rx` is not used. The UART driver of ESP-IDF 5.5 does not install /// without a receive buffer, so the driver gets the input-only pin IO36. - pub fn start(uart: UART1<'static>, tx: Gpio4<'static>, rx: Gpio36<'static>) -> anyhow::Result { + pub fn start( + uart: UART1<'static>, + tx: Gpio4<'static>, + rx: Gpio36<'static>, + ) -> anyhow::Result { let cfg = config::Config::new() .baudrate(Hertz(250_000)) .stop_bits(config::StopBits::STOP2); @@ -44,9 +48,10 @@ impl Dmx { // Byte 0 is the start code (0 for dimmer data). Bytes 1 to 512 are the channels. let mut frame = [0u8; 513]; loop { - let level = last_beat.lock().unwrap().map_or(0.0, |t| { - 1.0 - t.elapsed().as_secs_f32() * 1000.0 / FLASH_MS - }); + let level = last_beat + .lock() + .unwrap() + .map_or(0.0, |t| 1.0 - t.elapsed().as_secs_f32() * 1000.0 / FLASH_MS); frame[1] = (level.clamp(0.0, 1.0) * 255.0) as u8; // The UART sends the frame, then the break. The break before the next frame // and the idle time after it make the reset sequence of DMX512. diff --git a/esp32/src/logring.rs b/esp32/src/logring.rs index 2a659b3..e310c23 100644 --- a/esp32/src/logring.rs +++ b/esp32/src/logring.rs @@ -56,7 +56,13 @@ impl Log for Logger { let mut line = String::new(); // SAFETY: esp_log_timestamp has no preconditions. let ms = unsafe { sys::esp_log_timestamp() }; - let _ = write!(line, "{ms} {} {}: {}", &record.level().as_str()[..1], record.target(), record.args()); + let _ = write!( + line, + "{ms} {} {}: {}", + &record.level().as_str()[..1], + record.target(), + record.args() + ); if line.len() > MAX_LINE { let mut end = MAX_LINE; while !line.is_char_boundary(end) { @@ -111,7 +117,11 @@ pub fn init() { // SAFETY: this function holds LOCK. The volatile read accepts the random data after a power-on. let valid = unsafe { addr_of!((*r).magic).read_volatile() } == MAGIC; // SAFETY: a valid magic number shows that the previous start wrote the ring. - let previous = if valid { contents(unsafe { &*r }) } else { String::new() }; + let previous = if valid { + contents(unsafe { &*r }) + } else { + String::new() + }; // SAFETY: this function holds LOCK. unsafe { addr_of_mut!((*r).magic).write(MAGIC); @@ -137,9 +147,17 @@ pub fn text() -> String { contents(unsafe { &*ring() }) }; let previous = PREVIOUS.get().map_or("", String::as_str); - let previous = if previous.is_empty() { "(no lines: the box started from power-on)\n" } else { previous }; + let previous = if previous.is_empty() { + "(no lines: the box started from power-on)\n" + } else { + previous + }; let crash = CRASH.lock().unwrap_or_else(|e| e.into_inner()); - let crash = if crash.is_empty() { "(no core dump)\n" } else { crash.as_str() }; + let crash = if crash.is_empty() { + "(no core dump)\n" + } else { + crash.as_str() + }; format!("=== The last crash ===\n{crash}=== The previous start ===\n{previous}=== This start ===\n{current}") } @@ -160,8 +178,12 @@ pub fn report_core_dump() { } } let mut reason = [0u8; 200]; - if sys::esp_core_dump_get_panic_reason(reason.as_mut_ptr().cast(), reason.len()) == sys::ESP_OK { - let reason = CStr::from_bytes_until_nul(&reason).map(CStr::to_string_lossy).unwrap_or_default(); + if sys::esp_core_dump_get_panic_reason(reason.as_mut_ptr().cast(), reason.len()) + == sys::ESP_OK + { + let reason = CStr::from_bytes_until_nul(&reason) + .map(CStr::to_string_lossy) + .unwrap_or_default(); crash_line(format!("The last crash: {reason}")); } let mut s: sys::esp_core_dump_summary_t = std::mem::zeroed(); @@ -169,13 +191,20 @@ pub fn report_core_dump() { let task = CStr::from_ptr(s.exc_task.as_ptr()).to_string_lossy(); let bt = &s.exc_bt_info; let depth = (bt.depth as usize).min(bt.bt.len()); - let addrs: Vec = bt.bt[..depth].iter().map(|a| format!("{a:#010x}")).collect(); + let addrs: Vec = bt.bt[..depth] + .iter() + .map(|a| format!("{a:#010x}")) + .collect(); crash_line(format!( "The last crash: task {task}, PC {:#010x}, cause {}, address {:#010x}{}", s.exc_pc, s.ex_info.exc_cause, s.ex_info.exc_vaddr, - if bt.corrupted { ", the backtrace is corrupt" } else { "" } + if bt.corrupted { + ", the backtrace is corrupt" + } else { + "" + } )); crash_line(format!("The last crash: backtrace {}", addrs.join(" "))); } diff --git a/esp32/src/main.rs b/esp32/src/main.rs index 499ca2d..8f5856e 100644 --- a/esp32/src/main.rs +++ b/esp32/src/main.rs @@ -28,15 +28,15 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; +use autoblink_core_prolink::Tracker; use esp_idf_svc::eventloop::EspSystemEventLoop; use esp_idf_svc::hal::cpu::Core; use esp_idf_svc::hal::gpio::PinDriver; -use esp_idf_svc::hal::task::thread::ThreadSpawnConfiguration; use esp_idf_svc::hal::peripherals::Peripherals; +use esp_idf_svc::hal::task::thread::ThreadSpawnConfiguration; use esp_idf_svc::mdns::EspMdns; use esp_idf_svc::nvs::EspDefaultNvsPartition; use esp_idf_svc::sys; -use autoblink_core_prolink::Tracker; use crate::midi::MidiStatus; use crate::net::{EthPins, AP_IP}; @@ -94,7 +94,10 @@ fn main() { fn run() -> anyhow::Result<()> { // SAFETY: esp_reset_reason has no preconditions. let reset_reason = reset_reason(unsafe { sys::esp_reset_reason() }); - log::info!("AUTOBLINK {} starts. Reason for the restart: {reset_reason}", env!("CARGO_PKG_VERSION")); + log::info!( + "AUTOBLINK {} starts. Reason for the restart: {reset_reason}", + env!("CARGO_PKG_VERSION") + ); logring::report_core_dump(); let p = Peripherals::take()?; let sysloop = EspSystemEventLoop::take()?; @@ -106,7 +109,9 @@ fn run() -> anyhow::Result<()> { let mut mac = [0u8; 6]; // SAFETY: the buffer has the 6 bytes that a MAC address needs. - sys::esp!(unsafe { sys::esp_read_mac(mac.as_mut_ptr(), sys::esp_mac_type_t_ESP_MAC_WIFI_SOFTAP) })?; + sys::esp!(unsafe { + sys::esp_read_mac(mac.as_mut_ptr(), sys::esp_mac_type_t_ESP_MAC_WIFI_SOFTAP) + })?; let default_ssid = format!("autoblink-{:02X}{:02X}", mac[4], mac[5]); let settings = Settings::new(nvs, default_ssid)?; let ssid = settings.ssid(); @@ -118,7 +123,9 @@ fn run() -> anyhow::Result<()> { log::info!("WiFi \"{join}\": the firmware joins this network"); } // mDNS answers on all networks. Without it, the page is still at its IP addresses. - let _mdns = start_mdns().inspect_err(|e| log::warn!("mDNS is off: {e}")).ok(); + let _mdns = start_mdns() + .inspect_err(|e| log::warn!("mDNS is off: {e}")) + .ok(); let (eth, _osc) = net::start_ethernet( EthPins { @@ -142,8 +149,14 @@ fn run() -> anyhow::Result<()> { log::info!("Firmware update: the firmware starts only the network for the download"); let deadline = Instant::now() + Duration::from_secs(30); while Instant::now() < deadline { - let sta = wifi.sta_netif().get_ip_info().is_ok_and(|i| !i.ip.is_unspecified()); - let eth_ip = eth.netif().get_ip_info().is_ok_and(|i| !i.ip.is_unspecified() && !i.ip.is_link_local()); + let sta = wifi + .sta_netif() + .get_ip_info() + .is_ok_and(|i| !i.ip.is_unspecified()); + let eth_ip = eth + .netif() + .get_ip_info() + .is_ok_and(|i| !i.ip.is_unspecified() && !i.ip.is_link_local()); if sta || eth_ip { break; } @@ -172,7 +185,13 @@ fn run() -> anyhow::Result<()> { }), start: Instant::now(), }); - let midi_beats = midi::start(p.uart2, p.pins.gpio17, p.pins.gpio35, state.clone(), settings.midi.clone())?; + let midi_beats = midi::start( + p.uart2, + p.pins.gpio17, + p.pins.gpio35, + state.clone(), + settings.midi.clone(), + )?; let device_number = settings.device_number.clone(); let settings = Arc::new(Mutex::new(settings)); let (_server, web_beats) = webui::start(state.clone(), settings.clone())?; @@ -227,8 +246,20 @@ fn run() -> anyhow::Result<()> { loop { state.tracker.lock().unwrap().expire(state.now_ms()); // Free the track data of a deck that left the network. - let decks: Vec = state.tracker.lock().unwrap().decks().iter().map(|d| d.num).collect(); - state.tracks.0.lock().unwrap().retain(|n, _| decks.contains(n)); + let decks: Vec = state + .tracker + .lock() + .unwrap() + .decks() + .iter() + .map(|d| d.num) + .collect(); + state + .tracks + .0 + .lock() + .unwrap() + .retain(|n, _| decks.contains(n)); settings.lock().unwrap().save_midi(); // SAFETY: the heap calls only read counters. let (free, min, block) = unsafe { @@ -241,7 +272,9 @@ fn run() -> anyhow::Result<()> { // The time of this line in GET /log shows the event that used the heap in the last second. if min.saturating_add(HEAP_LOW_STEP) <= heap_low { heap_low = min; - log::warn!("Heap low point: {min} bytes. Now {free} bytes free, largest block {block} bytes"); + log::warn!( + "Heap low point: {min} bytes. Now {free} bytes free, largest block {block} bytes" + ); } if Instant::now() >= next_heap_log { next_heap_log += Duration::from_secs(60); @@ -258,7 +291,10 @@ fn run() -> anyhow::Result<()> { join_retry = JOIN_RETRY; } else { join_retry = (join_retry * 2).min(JOIN_RETRY_MAX); - log::info!("WiFi \"{name}\": the firmware tries to join. The next try is in {} s", join_retry.as_secs()); + log::info!( + "WiFi \"{name}\": the firmware tries to join. The next try is in {} s", + join_retry.as_secs() + ); if let Err(e) = wifi.connect() { log::warn!("WiFi: the connection did not start: {e}"); } @@ -266,7 +302,11 @@ fn run() -> anyhow::Result<()> { next_join = Instant::now() + join_retry; } - let info = eth.netif().get_ip_info().ok().filter(|i| !i.ip.is_unspecified()); + let info = eth + .netif() + .get_ip_info() + .ok() + .filter(|i| !i.ip.is_unspecified()); let eth_ip = info.map(|i| (i.ip, i.subnet.mask.0)); let sta_info = join_ssid .as_ref() @@ -288,8 +328,15 @@ fn run() -> anyhow::Result<()> { quiet_since = None; dwell = SWITCH_AFTER; } else if quiet_since.get_or_insert_with(Instant::now).elapsed() >= dwell { - let (from, to) = if use_sta { (sta_name, eth_name) } else { (eth_name, sta_name) }; - log::info!("No player on \"{from}\" for {} s. Pro DJ Link tries \"{to}\".", dwell.as_secs()); + let (from, to) = if use_sta { + (sta_name, eth_name) + } else { + (eth_name, sta_name) + }; + log::info!( + "No player on \"{from}\" for {} s. Pro DJ Link tries \"{to}\".", + dwell.as_secs() + ); use_sta = !use_sta; quiet_since = None; // Without a player on the two interfaces, the changes and their log lines become rare. @@ -312,7 +359,9 @@ fn run() -> anyhow::Result<()> { broadcast: broadcast(i.ip, i.subnet.mask.0), mac, }); - state.wifi_join_down.store(join_ssid.is_some() && sta_ip.is_none(), Ordering::Relaxed); + state + .wifi_join_down + .store(join_ssid.is_some() && sta_ip.is_none(), Ordering::Relaxed); let now = (eth_ip.map(|(ip, _)| ip), sta_ip); if now != last { if now.0 != last.0 { @@ -353,7 +402,9 @@ fn run() -> anyhow::Result<()> { None => log::info!("WiFi \"{name}\" is not connected"), } } - let mut iface = now.0.map_or("Ethernet: no address".into(), |ip| format!("Ethernet {ip}")); + let mut iface = now + .0 + .map_or("Ethernet: no address".into(), |ip| format!("Ethernet {ip}")); if let Some(ip) = now.1 { iface.push_str(&format!(" · WiFi {ip}")); } @@ -379,7 +430,11 @@ pub fn restart_soon() -> std::io::Result<()> { /// Without the spawn configuration, each Rust thread has the task name "pthread". The thread has /// priority 5 (CONFIG_PTHREAD_TASK_PRIO_DEFAULT), the priority of the HTTP server task. It runs on /// the two cores. -pub fn spawn(name: &'static CStr, stack_size: usize, f: F) -> std::io::Result> +pub fn spawn( + name: &'static CStr, + stack_size: usize, + f: F, +) -> std::io::Result> where F: FnOnce() -> T + Send + 'static, T: Send + 'static, @@ -404,7 +459,13 @@ where { // The configuration applies to the threads that the calling thread starts. Reset it after // the start, so that no other thread gets this name. - let _ = ThreadSpawnConfiguration { name: Some(name), priority, pin_to_core: core, ..Default::default() }.set(); + let _ = ThreadSpawnConfiguration { + name: Some(name), + priority, + pin_to_core: core, + ..Default::default() + } + .set(); let handle = thread::Builder::new() .name(name.to_string_lossy().into_owned()) .stack_size(stack_size) @@ -434,7 +495,9 @@ fn start_mdns() -> anyhow::Result { /// Returns `true` when `a` and `b` are in one subnet with a prefix of `prefix` bits. fn same_subnet(a: Ipv4Addr, b: Ipv4Addr, prefix: u8) -> bool { - let mask = u32::MAX.checked_shl(32 - u32::from(prefix.min(32))).unwrap_or(0); + let mask = u32::MAX + .checked_shl(32 - u32::from(prefix.min(32))) + .unwrap_or(0); u32::from(a) & mask == u32::from(b) & mask } diff --git a/esp32/src/mem.rs b/esp32/src/mem.rs index 7e4d29e..6a9caa7 100644 --- a/esp32/src/mem.rs +++ b/esp32/src/mem.rs @@ -35,10 +35,15 @@ pub fn report() -> String { // Tasks can start between the two calls, so leave space for some more. let mut tasks = vec![sys::TaskStatus_t::default(); count + 4]; // SAFETY: the array has `tasks.len()` entries. The total run time can be null. - let n = unsafe { sys::uxTaskGetSystemState(tasks.as_mut_ptr(), tasks.len() as u32, std::ptr::null_mut()) }; + let n = unsafe { + sys::uxTaskGetSystemState(tasks.as_mut_ptr(), tasks.len() as u32, std::ptr::null_mut()) + }; tasks.truncate(n as usize); tasks.sort_by_key(|t| std::cmp::Reverse(t.usStackHighWaterMark)); - let _ = writeln!(out, "\nFree stack of each task (low point since the start, in bytes):"); + let _ = writeln!( + out, + "\nFree stack of each task (low point since the start, in bytes):" + ); for t in &tasks { // SAFETY: FreeRTOS keeps the name of a live task in its control block. let name = unsafe { CStr::from_ptr(t.pcTaskName) }.to_string_lossy(); diff --git a/esp32/src/midi.rs b/esp32/src/midi.rs index 4f97e4e..4235453 100644 --- a/esp32/src/midi.rs +++ b/esp32/src/midi.rs @@ -21,6 +21,9 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use autoblink_core_dbserver::{current_phrase_idx, phrase_info}; +use autoblink_core_midi::clock::Master; +use autoblink_core_midi::{Clock, Mapper, MidiConfig, Out, Phrase, Smoother, Snapshot, CLOCK}; use esp_idf_svc::hal::cpu::Core; use esp_idf_svc::hal::delay::TickType; use esp_idf_svc::hal::gpio::{Gpio17, Gpio35}; @@ -28,9 +31,6 @@ use esp_idf_svc::hal::task::queue::Queue; use esp_idf_svc::hal::uart::{config, UartDriver, UART2}; use esp_idf_svc::hal::units::Hertz; use esp_idf_svc::sys; -use autoblink_core_midi::clock::Master; -use autoblink_core_dbserver::{current_phrase_idx, phrase_info}; -use autoblink_core_midi::{Clock, Mapper, MidiConfig, Out, Phrase, Smoother, Snapshot, CLOCK}; use crate::State; @@ -140,7 +140,14 @@ pub fn start( last_master = b.device; } smoothed_bpm = smoother.push(b.bpm, cfg.smoothing_ms); - clock.beat(&cfg, b.at_us, b.device, b.beat_in_bar, smoothed_bpm, &mut send); + clock.beat( + &cfg, + b.at_us, + b.device, + b.beat_in_bar, + smoothed_bpm, + &mut send, + ); mapper.beat(&cfg, b.beat_in_bar, &mut send); } @@ -150,7 +157,11 @@ pub fn start( tracker.master().map_or((Snapshot::default(), None), |m| { let snap = Snapshot { device: m.num, - bpm: if m.num == last_master && smoothed_bpm > 0.0 { smoothed_bpm } else { m.bpm }, + bpm: if m.num == last_master && smoothed_bpm > 0.0 { + smoothed_bpm + } else { + m.bpm + }, pitch: m.pitch, phrase16: m.phrase16, beat_phase: m.beat_phase(now_ms), diff --git a/esp32/src/net.rs b/esp32/src/net.rs index f789e16..792e180 100644 --- a/esp32/src/net.rs +++ b/esp32/src/net.rs @@ -8,7 +8,10 @@ use std::time::Duration; use esp_idf_svc::eth::{EspEth, EthDriver, RmiiClockConfig, RmiiEth, RmiiEthChipset}; use esp_idf_svc::eventloop::EspSystemEventLoop; -use esp_idf_svc::hal::gpio::{self, Gpio0, Gpio16, Gpio18, Gpio19, Gpio21, Gpio22, Gpio23, Gpio25, Gpio26, Gpio27, Output, PinDriver}; +use esp_idf_svc::hal::gpio::{ + self, Gpio0, Gpio16, Gpio18, Gpio19, Gpio21, Gpio22, Gpio23, Gpio25, Gpio26, Gpio27, Output, + PinDriver, +}; use esp_idf_svc::hal::mac::MAC; use esp_idf_svc::hal::modem::Modem; use esp_idf_svc::ipv4::{self, Mask, RouterConfiguration, Subnet}; @@ -28,7 +31,11 @@ pub const MDNS_NAME: &str = "autoblink"; /// `netif` removes the binding. pub fn bind_device(fd: RawFd, netif: &str) -> io::Result<()> { let mut req = sys::ifreq::default(); - for (dst, src) in req.ifr_name.iter_mut().zip(netif.bytes().take(sys::IFNAMSIZ as usize - 1)) { + for (dst, src) in req + .ifr_name + .iter_mut() + .zip(netif.bytes().take(sys::IFNAMSIZ as usize - 1)) + { *dst = src as _; } // SAFETY: `req` is a valid `ifreq`, and the length is its size. @@ -41,7 +48,11 @@ pub fn bind_device(fd: RawFd, netif: &str) -> io::Result<()> { std::mem::size_of::() as _, ) }; - if r == 0 { Ok(()) } else { Err(io::Error::last_os_error()) } + if r == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } } /// Opens a TCP connection to `addr` on the network interface `netif`. An empty `netif` is for @@ -66,7 +77,9 @@ pub fn connect_on(netif: &str, addr: &SocketAddr, timeout: Duration) -> io::Resu sin_len: std::mem::size_of::() as _, sin_family: sys::AF_INET as _, sin_port: v4.port().to_be(), - sin_addr: sys::in_addr { s_addr: u32::from_ne_bytes(v4.ip().octets()) }, + sin_addr: sys::in_addr { + s_addr: u32::from_ne_bytes(v4.ip().octets()), + }, sin_zero: Default::default(), }; // SAFETY: `sa` is a valid `sockaddr_in`, and the length is its size. @@ -82,7 +95,11 @@ pub fn connect_on(netif: &str, addr: &SocketAddr, timeout: Duration) -> io::Resu if e.raw_os_error() != Some(sys::EINPROGRESS as i32) { return Err(e); } - let mut p = sys::pollfd { fd, events: sys::POLLOUT as _, revents: 0 }; + let mut p = sys::pollfd { + fd, + events: sys::POLLOUT as _, + revents: 0, + }; // SAFETY: `p` is one valid `pollfd`. match unsafe { sys::poll(&mut p, 1, timeout.as_millis() as _) } { 0 => return Err(io::ErrorKind::TimedOut.into()), @@ -164,7 +181,10 @@ pub fn start_wifi( let driver = WifiDriver::new(modem, sysloop, None)?; let ap_netif = EspNetif::new_with_conf(&NetifConfiguration { ip_configuration: Some(ipv4::Configuration::Router(RouterConfiguration { - subnet: Subnet { gateway: AP_IP, mask: Mask(24) }, + subnet: Subnet { + gateway: AP_IP, + mask: Mask(24), + }, dhcp_enabled: true, dns: None, secondary_dns: None, @@ -173,8 +193,12 @@ pub fn start_wifi( })?; let mut wifi = EspWifi::wrap_all(driver, EspNetif::new(NetifStack::Sta)?, ap_netif)?; let ap = AccessPointConfiguration { - ssid: ssid.try_into().map_err(|_| anyhow::anyhow!("The WiFi name is too long"))?, - password: password.try_into().map_err(|_| anyhow::anyhow!("The WiFi password is too long"))?, + ssid: ssid + .try_into() + .map_err(|_| anyhow::anyhow!("The WiFi name is too long"))?, + password: password + .try_into() + .map_err(|_| anyhow::anyhow!("The WiFi password is too long"))?, auth_method: AuthMethod::WPA2Personal, channel: 6, max_connections: 4, @@ -186,12 +210,19 @@ pub fn start_wifi( return Ok(wifi); }; let client = ClientConfiguration { - ssid: join_ssid.as_str().try_into().map_err(|_| anyhow::anyhow!("The join WiFi name is too long"))?, + ssid: join_ssid + .as_str() + .try_into() + .map_err(|_| anyhow::anyhow!("The join WiFi name is too long"))?, password: join_password .as_str() .try_into() .map_err(|_| anyhow::anyhow!("The join WiFi password is too long"))?, - auth_method: if join_password.is_empty() { AuthMethod::None } else { AuthMethod::WPA2Personal }, + auth_method: if join_password.is_empty() { + AuthMethod::None + } else { + AuthMethod::WPA2Personal + }, ..Default::default() }; wifi.set_configuration(&Configuration::Mixed(client, ap))?; diff --git a/esp32/src/ota.rs b/esp32/src/ota.rs index d53b620..5a32c98 100644 --- a/esp32/src/ota.rs +++ b/esp32/src/ota.rs @@ -66,7 +66,6 @@ pub struct OtaStatus { pub error: Option, } - /// `true` while an update thread runs. static BUSY: AtomicBool = AtomicBool::new(false); @@ -120,8 +119,12 @@ pub fn run(state: Arc, cmd: OtaCmd) -> Result<(), String> { fn start_check(state: Arc) -> Result<(), String> { // SAFETY: the heap calls only read counters. - let (free, block) = - unsafe { (sys::esp_get_free_heap_size(), sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT)) }; + let (free, block) = unsafe { + ( + sys::esp_get_free_heap_size(), + sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT), + ) + }; if free < CHECK_MIN_FREE || block < CHECK_MIN_BLOCK { return Err(format!( "Not enough memory for the check: {free} bytes free, largest block {block} bytes. \ @@ -138,7 +141,9 @@ fn start_check(state: Arc) -> Result<(), String> { } BUSY.store(false, Ordering::Release); }); - spawned.map(|_| ()).map_err(|e| format!("The firmware update cannot start a thread: {e}")) + spawned + .map(|_| ()) + .map_err(|e| format!("The firmware update cannot start a thread: {e}")) } fn request_install(state: &State) -> Result<(), String> { @@ -182,7 +187,11 @@ pub fn mark_valid() { // not fit into its strings. Thus read the label of the partition directly. // SAFETY: the running partition is a static entry of the partition table, or null. let label = unsafe { sys::esp_ota_get_running_partition().as_ref() } - .map(|p| unsafe { std::ffi::CStr::from_ptr(p.label.as_ptr()) }.to_string_lossy().into_owned()) + .map(|p| { + unsafe { std::ffi::CStr::from_ptr(p.label.as_ptr()) } + .to_string_lossy() + .into_owned() + }) .unwrap_or_default(); match EspOta::new().and_then(|mut ota| ota.mark_running_slot_valid()) { Ok(()) => log::info!("The app in slot {label} is marked valid"), @@ -207,7 +216,12 @@ fn check(state: &Mutex) -> anyhow::Result { let newer = newer(&latest, env!("CARGO_PKG_VERSION")) .ok_or_else(|| anyhow!("The release has the version {latest:?}. It is not x.y.z."))?; log::info!("Firmware update: the release has the version {latest}"); - set(state, if newer { "available" } else { "up-to-date" }, Some(latest), 0); + set( + state, + if newer { "available" } else { "up-to-date" }, + Some(latest), + 0, + ); Ok(newer) } @@ -232,7 +246,8 @@ fn install(state: &Mutex) -> anyhow::Result<()> { let mut ota = EspOta::new()?; // Erase each sector before the write to it. One erase of the full slot stops the other // tasks for some seconds. - let mut update = ota.initiate_update_with_known_size(sys::OTA_WITH_SEQUENTIAL_WRITES as usize)?; + let mut update = + ota.initiate_update_with_known_size(sys::OTA_WITH_SEQUENTIAL_WRITES as usize)?; let mut buf = vec![0u8; 4096]; let mut done = 0; loop { @@ -249,7 +264,10 @@ fn install(state: &Mutex) -> anyhow::Result<()> { } // esp_ota_end examines the image. It refuses an image for another chip or with a bad checksum. update.complete()?; - log::info!("Firmware update: {} is written. The firmware restarts.", latest.as_deref().unwrap_or("?")); + log::info!( + "Firmware update: {} is written. The firmware restarts.", + latest.as_deref().unwrap_or("?") + ); thread::sleep(Duration::from_secs(1)); // SAFETY: esp_restart has no preconditions. unsafe { sys::esp_restart() } @@ -267,7 +285,9 @@ fn get(url: &str) -> anyhow::Result { ..Default::default() })?; conn.initiate_request(Method::Get, url, &[]) - .with_context(|| format!("No connection to {url}. The box needs a network with internet."))?; + .with_context(|| { + format!("No connection to {url}. The box needs a network with internet.") + })?; conn.initiate_response()?; match conn.status() { 200 => Ok(conn), @@ -291,7 +311,10 @@ fn fix_dns() { } let mut dns = sys::esp_netif_dns_info_t::default(); // SAFETY: `sta` is a live netif, and `dns` is a valid output buffer. - if unsafe { sys::esp_netif_get_dns_info(sta, sys::esp_netif_dns_type_t_ESP_NETIF_DNS_MAIN, &mut dns) } != sys::ESP_OK { + if unsafe { + sys::esp_netif_get_dns_info(sta, sys::esp_netif_dns_type_t_ESP_NETIF_DNS_MAIN, &mut dns) + } != sys::ESP_OK + { return; } // SAFETY: the ip4 member is valid for each address type, because it is at the start of the union. @@ -303,11 +326,18 @@ fn fix_dns() { dns.ip.u_addr.ip4.addr = u32::from_ne_bytes(PUBLIC_DNS.octets()); dns.ip.type_ = sys::ESP_IPADDR_TYPE_V4 as u8; // SAFETY: `sta` is a live netif, and `dns` holds an IPv4 address. - unsafe { sys::esp_netif_set_dns_info(sta, sys::esp_netif_dns_type_t_ESP_NETIF_DNS_MAIN, &mut dns) }; + unsafe { + sys::esp_netif_set_dns_info(sta, sys::esp_netif_dns_type_t_ESP_NETIF_DNS_MAIN, &mut dns) + }; } fn set(state: &Mutex, name: &'static str, latest: Option, progress: u8) { - *state.lock().unwrap() = OtaStatus { state: name, latest, progress, error: None }; + *state.lock().unwrap() = OtaStatus { + state: name, + latest, + progress, + error: None, + }; } /// Returns `Some(true)` when the version `a` is newer than `b`. Returns `None` when `a` or `b` is diff --git a/esp32/src/prolink.rs b/esp32/src/prolink.rs index 516ccd6..1afcf6f 100644 --- a/esp32/src/prolink.rs +++ b/esp32/src/prolink.rs @@ -25,13 +25,13 @@ use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; -use esp_idf_svc::hal::cpu::Core; -use esp_idf_svc::sys; use autoblink_core_prolink::{ build_announce, build_claim1, build_claim2, build_claim_final, build_keepalive, build_status_packet, has_magic, pad_name, parse_abs_position, parse_beat, parse_cdj_status, parse_keepalive, TrackRef, PKT_CONFLICT, PORT_BEAT, PORT_DISCOVERY, PORT_STATUS, }; +use esp_idf_svc::hal::cpu::Core; +use esp_idf_svc::sys; use crate::midi::Beat; use crate::State; @@ -82,7 +82,11 @@ pub fn spawn( } // A CDJ-3000 class status packet has more than 0x1c0 bytes. let mut buf = [0u8; 1500]; - let mut polls = fds.map(|fd| sys::pollfd { fd, events: sys::POLLIN as _, revents: 0 }); + let mut polls = fds.map(|fd| sys::pollfd { + fd, + events: sys::POLLIN as _, + revents: 0, + }); loop { // SAFETY: the task subscribed above. A reset of an unsubscribed task only gives an // error code. @@ -115,7 +119,9 @@ pub fn spawn( } })?; - crate::spawn(c"virtual-cdj", 4608, move || virtual_cdj(&sender, &state, &device_number, &fds))?; + crate::spawn(c"virtual-cdj", 4608, move || { + virtual_cdj(&sender, &state, &device_number, &fds) + })?; Ok(()) } @@ -141,7 +147,11 @@ fn on_beat_port(st: &State, data: &[u8], on_master_beat: &impl Fn(Beat)) { let is_master = st.tracker.lock().unwrap().on_beat(&beat, now); if is_master { if beat.beat_in_bar == 1 { - log::debug!("Bar: deck {} at {:.2} BPM", beat.device_number, beat.effective_bpm); + log::debug!( + "Bar: deck {} at {:.2} BPM", + beat.device_number, + beat.effective_bpm + ); } on_master_beat(Beat { device: beat.device_number, @@ -154,13 +164,18 @@ fn on_beat_port(st: &State, data: &[u8], on_master_beat: &impl Fn(Beat)) { /// Handles a packet on port 50002: the CDJ status of a player. fn on_status(st: &State, data: &[u8], on_track: &impl Fn(TrackRef)) { - let Some(cdj) = parse_cdj_status(data) else { return }; + let Some(cdj) = parse_cdj_status(data) else { + return; + }; let now = st.now_ms(); let change = st.tracker.lock().unwrap().on_cdj_status(&cdj, now); if let Some(track) = change { log::info!( "Deck {} loaded track {} (slot {}, player {})", - track.device, track.rekordbox_id, track.slot, track.source_player + track.device, + track.rekordbox_id, + track.slot, + track.source_player ); on_track(track); } @@ -212,7 +227,14 @@ fn virtual_cdj(sock: &UdpSocket, state: &State, wanted: &AtomicU8, fds: &[RawFd] errors = 0; } // The players send their status only to a device that sends its own status to them. - let ips: Vec<[u8; 4]> = state.tracker.lock().unwrap().decks().iter().map(|d| d.ip).collect(); + let ips: Vec<[u8; 4]> = state + .tracker + .lock() + .unwrap() + .decks() + .iter() + .map(|d| d.ip) + .collect(); for ip in ips.into_iter().filter(|ip| *ip != [0; 4]) { let _ = sock.send_to(&status, SocketAddrV4::new(ip.into(), PORT_STATUS)); } @@ -242,7 +264,13 @@ fn bind_netif(fds: &[RawFd], netif: &str) -> bool { /// Sends the announce and claim packets. Returns the claimed device number. When another device /// reports a conflict, the next number is tried. -fn join(sock: &UdpSocket, state: &State, name: &[u8; 20], eth: &LinkInfo, want: u8) -> anyhow::Result { +fn join( + sock: &UdpSocket, + state: &State, + name: &[u8; 20], + eth: &LinkInfo, + want: u8, +) -> anyhow::Result { let bcast = SocketAddrV4::new(eth.broadcast, PORT_DISCOVERY); let ip = eth.ip.octets(); let send = |pkt: &[u8]| -> anyhow::Result<()> { @@ -265,7 +293,10 @@ fn join(sock: &UdpSocket, state: &State, name: &[u8; 20], eth: &LinkInfo, want: let deadline = Instant::now() + CLAIM_INTERVAL; while Instant::now() < deadline { if state.conflict.swap(0, Ordering::Relaxed) == claimed { - log::warn!("Device number {claimed} is in use. The virtual CDJ tries {}.", claimed + 1); + log::warn!( + "Device number {claimed} is in use. The virtual CDJ tries {}.", + claimed + 1 + ); claimed += 1; anyhow::ensure!(claimed <= 15, "No free device number is available"); continue 'claim; diff --git a/esp32/src/settings.rs b/esp32/src/settings.rs index 85e7c58..57e451d 100644 --- a/esp32/src/settings.rs +++ b/esp32/src/settings.rs @@ -8,8 +8,8 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault}; use autoblink_core_midi::{settings as midi_settings, MidiConfig, SettingItem}; +use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs, NvsDefault}; /// The host index of "Device Number": the number that the virtual CDJ asks for. const IDX_DEVICE: usize = 2; @@ -43,7 +43,11 @@ impl Settings { .flatten() .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); - let device = nvs.get_u8("devnum").ok().flatten().filter(|n| (1..=15).contains(n)); + let device = nvs + .get_u8("devnum") + .ok() + .flatten() + .filter(|n| (1..=15).contains(n)); Ok(Self { nvs, default_ssid, @@ -54,7 +58,8 @@ impl Settings { } pub fn ssid(&self) -> String { - self.get("ssid").unwrap_or_else(|| self.default_ssid.clone()) + self.get("ssid") + .unwrap_or_else(|| self.default_ssid.clone()) } pub fn password(&self) -> String { @@ -93,15 +98,53 @@ impl Settings { let join = self.join().map(|(s, _)| s).unwrap_or_default(); let mut items = vec![SettingItem { section: None, - ..row(IDX_DEVICE, "Device Number", "number", device.clone(), Some(device)) + ..row( + IDX_DEVICE, + "Device Number", + "number", + device.clone(), + Some(device), + ) }]; items.extend(midi_settings::items(&self.midi.lock().unwrap())); - items.push(row(IDX_SSID, "WiFi name (applies after restart)", "text", ssid.clone(), Some(ssid))); - items.push(row(IDX_PASSWORD, "WiFi password (applies after restart)", "text", hidden(), None)); - let shown = if join.is_empty() { "off".to_string() } else { join.clone() }; - items.push(row(IDX_JOIN_SSID, "Join WiFi name (applies after restart)", "text", shown, Some(join))); - items.push(row(IDX_JOIN_PASSWORD, "Join WiFi password (applies after restart)", "text", hidden(), None)); - serde_json::to_string(&Message { kind: "settings", items }).unwrap_or_default() + items.push(row( + IDX_SSID, + "WiFi name (applies after restart)", + "text", + ssid.clone(), + Some(ssid), + )); + items.push(row( + IDX_PASSWORD, + "WiFi password (applies after restart)", + "text", + hidden(), + None, + )); + let shown = if join.is_empty() { + "off".to_string() + } else { + join.clone() + }; + items.push(row( + IDX_JOIN_SSID, + "Join WiFi name (applies after restart)", + "text", + shown, + Some(join), + )); + items.push(row( + IDX_JOIN_PASSWORD, + "Join WiFi password (applies after restart)", + "text", + hidden(), + None, + )); + serde_json::to_string(&Message { + kind: "settings", + items, + }) + .unwrap_or_default() } /// Stores a typed value. Returns the error text for the page when the value is not valid. @@ -112,26 +155,37 @@ impl Settings { .parse::() .ok() .filter(|n| (1..=15).contains(n)) - .ok_or_else(|| format!("Value \"{value}\" is out of range for setting {idx}"))?; - self.nvs.set_u8("devnum", n).map_err(|e| format!("Cannot save the setting: {e}"))?; + .ok_or_else(|| { + format!("Value \"{value}\" is out of range for setting {idx}") + })?; + self.nvs + .set_u8("devnum", n) + .map_err(|e| format!("Cannot save the setting: {e}"))?; self.device_number.store(n, Ordering::Relaxed); log::info!("Device number changed to {n}. The virtual CDJ joins again."); Ok(()) } IDX_SSID if (1..=32).contains(&value.len()) => self.put("ssid", value), IDX_SSID => Err("The WiFi name must have 1 to 32 bytes".into()), - IDX_PASSWORD if (8..=63).contains(&value.len()) && value.is_ascii() => self.put("pass", value), + IDX_PASSWORD if (8..=63).contains(&value.len()) && value.is_ascii() => { + self.put("pass", value) + } IDX_PASSWORD => Err("The WiFi password must have 8 to 63 ASCII characters".into()), // An empty name stops the join. The page cannot send an empty password, so the // password goes too. Then the next name can be an open network. - IDX_JOIN_SSID if value.is_empty() => self.remove("jpass").and_then(|()| self.remove("jssid")), + IDX_JOIN_SSID if value.is_empty() => { + self.remove("jpass").and_then(|()| self.remove("jssid")) + } IDX_JOIN_SSID if value.len() <= 32 => self.put("jssid", value), IDX_JOIN_SSID => Err("The WiFi name must have 0 to 32 bytes".into()), IDX_JOIN_PASSWORD if value.is_empty() => self.remove("jpass"), - IDX_JOIN_PASSWORD if (8..=63).contains(&value.len()) && value.is_ascii() => self.put("jpass", value), - IDX_JOIN_PASSWORD => { - Err("The WiFi password must be empty (open network) or have 8 to 63 ASCII characters".into()) + IDX_JOIN_PASSWORD if (8..=63).contains(&value.len()) && value.is_ascii() => { + self.put("jpass", value) } + IDX_JOIN_PASSWORD => Err( + "The WiFi password must be empty (open network) or have 8 to 63 ASCII characters" + .into(), + ), _ => self.change_midi(|cfg| midi_settings::set(cfg, idx, value)), } } @@ -145,7 +199,10 @@ impl Settings { /// /// An NVS write stops the flash cache of both cores for up to some 100 ms, and the MIDI clock /// stops with it. Many fast clicks on the page then give many stops. The delay gives one. - fn change_midi(&mut self, f: impl FnOnce(&mut MidiConfig) -> Result<(), String>) -> Result<(), String> { + fn change_midi( + &mut self, + f: impl FnOnce(&mut MidiConfig) -> Result<(), String>, + ) -> Result<(), String> { f(&mut self.midi.lock().unwrap())?; self.midi_changed = Some(Instant::now()); Ok(()) @@ -159,26 +216,37 @@ impl Settings { } self.midi_changed = None; let json = serde_json::to_string(&*self.midi.lock().unwrap()); - match json.map_err(anyhow::Error::from).and_then(|j| Ok(self.nvs.set_str("midi", &j)?)) { + match json + .map_err(anyhow::Error::from) + .and_then(|j| Ok(self.nvs.set_str("midi", &j)?)) + { Ok(()) => log::info!("The MIDI settings are saved"), Err(e) => log::warn!("Cannot save the MIDI settings: {e}"), } } fn put(&self, key: &str, value: &str) -> Result<(), String> { - self.nvs.set_str(key, value).map_err(|e| format!("Cannot save the setting: {e}"))?; + self.nvs + .set_str(key, value) + .map_err(|e| format!("Cannot save the setting: {e}"))?; log::info!("Setting {key} changed. It applies after a restart."); Ok(()) } fn remove(&self, key: &str) -> Result<(), String> { - self.nvs.remove(key).map_err(|e| format!("Cannot save the setting: {e}"))?; + self.nvs + .remove(key) + .map_err(|e| format!("Cannot save the setting: {e}"))?; log::info!("Setting {key} removed. It applies after a restart."); Ok(()) } fn get(&self, key: &str) -> Option { let mut buf = [0u8; 65]; - self.nvs.get_str(key, &mut buf).ok().flatten().map(str::to_owned) + self.nvs + .get_str(key, &mut buf) + .ok() + .flatten() + .map(str::to_owned) } } diff --git a/esp32/src/tracks.rs b/esp32/src/tracks.rs index 13329b3..1a99888 100644 --- a/esp32/src/tracks.rs +++ b/esp32/src/tracks.rs @@ -13,7 +13,9 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use autoblink_core_dbserver::{asking_player, fetch_with, SongStructure, TrackMetadata, TrackOverview}; +use autoblink_core_dbserver::{ + asking_player, fetch_with, SongStructure, TrackMetadata, TrackOverview, +}; use autoblink_core_prolink::TrackRef; use esp_idf_svc::sys; @@ -61,7 +63,13 @@ pub fn start(state: Arc) -> anyhow::Result> { tracks.remove(&track.device); continue; } - tracks.insert(track.device, TrackData { rekordbox_id: track.rekordbox_id, ..Default::default() }); + tracks.insert( + track.device, + TrackData { + rekordbox_id: track.rekordbox_id, + ..Default::default() + }, + ); drop(tracks); if fetch_one(&state, track) { continue; @@ -73,9 +81,18 @@ pub fn start(state: Arc) -> anyhow::Result> { while crate::ota::busy() && Instant::now() < until { thread::sleep(Duration::from_secs(1)); } - let loaded = state.tracker.lock().unwrap().deck(track.device).map(|d| d.track.rekordbox_id); + let loaded = state + .tracker + .lock() + .unwrap() + .deck(track.device) + .map(|d| d.track.rekordbox_id); if loaded == Some(track.rekordbox_id) { - log::info!("Deck {} track {}: the fetch starts again", track.device, track.rekordbox_id); + log::info!( + "Deck {} track {}: the fetch starts again", + track.device, + track.rekordbox_id + ); fetch_one(&state, track); } } @@ -89,39 +106,65 @@ fn fetch_one(state: &State, track: TrackRef) -> bool { let (source, players) = { let tracker = state.tracker.lock().unwrap(); // A track from rekordbox has a source player above 15, which is not a deck. - let source = tracker.device_address(track.source_player).map(|(name, ip)| (ip, name.to_owned())); - let players: Vec<(u8, u8)> = - tracker.decks().iter().map(|d| (d.num, d.track.source_player)).collect(); + let source = tracker + .device_address(track.source_player) + .map(|(name, ip)| (ip, name.to_owned())); + let players: Vec<(u8, u8)> = tracker + .decks() + .iter() + .map(|d| (d.num, d.track.source_player)) + .collect(); (source, players) }; // SAFETY: the two heap calls only read counters. - let (free, block) = - unsafe { (sys::esp_get_free_heap_size(), sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT)) }; + let (free, block) = unsafe { + ( + sys::esp_get_free_heap_size(), + sys::heap_caps_get_largest_free_block(sys::MALLOC_CAP_8BIT), + ) + }; let result = match source { - _ if free < MIN_FREE_HEAP || block < MIN_HEAP_BLOCK => { - Err(format!("not enough memory for the track data ({free} bytes free, largest block {block} bytes)")) - } + _ if free < MIN_FREE_HEAP || block < MIN_HEAP_BLOCK => Err(format!( + "not enough memory for the track data ({free} bytes free, largest block {block} bytes)" + )), Some((ip, name)) if ip != [0; 4] => { let own = state.own_device.load(Ordering::Relaxed); let asking = asking_player(own, track.source_player, &name, players); // Connect on the interface of the virtual CDJ. let netif = state.link.lock().unwrap().map_or("", |l| l.netif); let connect = |addr: &_, timeout| crate::net::connect_on(netif, addr, timeout); - fetch_with(&connect, ip.into(), asking, track.slot, track.track_type, track.rekordbox_id) - .map_err(|e| e.to_string()) + fetch_with( + &connect, + ip.into(), + asking, + track.slot, + track.track_type, + track.rekordbox_id, + ) + .map_err(|e| e.to_string()) } - _ => Err(format!("player {} is not on the network", track.source_player)), + _ => Err(format!( + "player {} is not on the network", + track.source_player + )), }; // The tracker checks the track ID itself. Lock it before the tracks, never inside them. if let Ok(f) = &result { if let Some(secs) = f.metadata.duration_s { - state.tracker.lock().unwrap().set_track_length(track.device, track.rekordbox_id, secs); + state + .tracker + .lock() + .unwrap() + .set_track_length(track.device, track.rekordbox_id, secs); } } let mut tracks = state.tracks.0.lock().unwrap(); // A newer track on the deck makes this result stale. - let Some(data) = tracks.get_mut(&track.device).filter(|d| d.rekordbox_id == track.rekordbox_id) else { + let Some(data) = tracks + .get_mut(&track.device) + .filter(|d| d.rekordbox_id == track.rekordbox_id) + else { return true; }; match result { @@ -129,14 +172,23 @@ fn fetch_one(state: &State, track: TrackRef) -> bool { for w in &f.warnings { log::warn!("Deck {} track {}: {w}", track.device, track.rekordbox_id); } - log::info!("Deck {}: {} - {}", track.device, f.metadata.artist, f.metadata.title); + log::info!( + "Deck {}: {} - {}", + track.device, + f.metadata.artist, + f.metadata.title + ); data.metadata = Some(f.metadata); data.structure = Some(f.song_structure); data.overview = f.overview.map(Arc::new); true } Err(e) => { - log::warn!("Deck {} track {}: the fetch failed: {e}", track.device, track.rekordbox_id); + log::warn!( + "Deck {} track {}: the fetch failed: {e}", + track.device, + track.rekordbox_id + ); data.structure = Some(Err(e)); false } diff --git a/esp32/src/webui.rs b/esp32/src/webui.rs index c06af57..fc65427 100644 --- a/esp32/src/webui.rs +++ b/esp32/src/webui.rs @@ -54,7 +54,9 @@ impl Active { fn send(&self, text: &[u8]) { // A send waits for the HTTP server task, and that task takes this lock when a connection // closes. Send without the lock, so that the two tasks cannot block each other. - let (generation, Some(mut sender)) = self.0.lock().unwrap().clone() else { return }; + let (generation, Some(mut sender)) = self.0.lock().unwrap().clone() else { + return; + }; if sender.send(FrameType::Text(false), text).is_err() { let mut active = self.0.lock().unwrap(); if active.0 == generation { @@ -106,21 +108,30 @@ pub fn start( stack_size: 8 * 1024, uri_match_wildcard: true, max_open_sockets: 6, - keep_alive: Some(KeepAlive { idle_secs: 5, interval_secs: 5, probe_count: 3 }), + keep_alive: Some(KeepAlive { + idle_secs: 5, + interval_secs: 5, + probe_count: 3, + }), ..Default::default() })?; let active = Arc::new(Active::default()); let wave_state = state.clone(); server.fn_handler("/waveform/*", Method::Get, move |req| { - let deck = req.uri().rsplit('/').next().and_then(|n| n.parse::().ok()); + let deck = req + .uri() + .rsplit('/') + .next() + .and_then(|n| n.parse::().ok()); let overview = deck.and_then(|n| { let tracks = wave_state.tracks.0.lock().unwrap(); tracks.get(&n).and_then(|t| t.overview.clone()) }); match overview { Some(o) => { - let mut resp = req.into_response(200, None, &[("Content-Type", "application/json")])?; + let mut resp = + req.into_response(200, None, &[("Content-Type", "application/json")])?; write_waveform(&mut resp, &o) } None => req.into_status_response(404)?.write_all(b"no waveform"), @@ -142,7 +153,10 @@ pub fn start( req.into_response( 200, None, - &[("Content-Type", "text/html; charset=utf-8"), ("Content-Encoding", "gzip")], + &[ + ("Content-Type", "text/html; charset=utf-8"), + ("Content-Encoding", "gzip"), + ], )? .write_all(INDEX_HTML_GZ) })?; @@ -195,7 +209,10 @@ pub fn start( // serde_json refuses that byte, so remove it. let text = buf.strip_suffix(&[0]).unwrap_or(&buf); let Ok(cmd) = serde_json::from_slice::(text) else { - return ws.send(FrameType::Text(false), error("The command is not valid JSON").as_bytes()); + return ws.send( + FrameType::Text(false), + error("The command is not valid JSON").as_bytes(), + ); }; if cmd["type"] == "take_over" { take_over(ws, &ws_active)?; @@ -224,7 +241,10 @@ pub fn start( match beat_rx.recv_timeout(next_status.saturating_duration_since(Instant::now())) { Ok((device, beat)) => { beat_msg.clear(); - let _ = write!(beat_msg, r#"{{"type":"beat","device":{device},"beat":{beat}}}"#); + let _ = write!( + beat_msg, + r#"{{"type":"beat","device":{device},"beat":{beat}}}"# + ); active.send(beat_msg.as_bytes()); } Err(RecvTimeoutError::Timeout) => {} @@ -277,12 +297,24 @@ fn write_waveform(w: &mut W, o: &TrackOverview) -> Result<(), W::Error text.clear(); } // The response is not a `std::io::Write`, so serde_json writes the tail into a buffer first. - let cues: Vec = - o.cues.iter().map(|c| CueJson { hot: c.hot_cue, start: c.start_ms, end: c.end_ms }).collect(); + let cues: Vec = o + .cues + .iter() + .map(|c| CueJson { + hot: c.hot_cue, + start: c.start_ms, + end: c.end_ms, + }) + .collect(); let phrases: Vec = o .phrases .iter() - .map(|p| SpanJson { kind: Disp(p.kind), start: p.start_ms, end: p.end_ms, fill: p.fill_ms }) + .map(|p| SpanJson { + kind: Disp(p.kind), + start: p.start_ms, + end: p.end_ms, + fill: p.fill_ms, + }) .collect(); let mut tail = br#"],"cues":"#.to_vec(); let _ = serde_json::to_writer(&mut tail, &cues); @@ -329,7 +361,14 @@ fn command(cmd: &Value, settings: &Mutex, state: &Arc) -> Strin None => Err("A set command needs a value".into()), } } else { - settings.cycle(idx, if cmd["dir"].as_i64() == Some(-1) { -1 } else { 1 }) + settings.cycle( + idx, + if cmd["dir"].as_i64() == Some(-1) { + -1 + } else { + 1 + }, + ) }; match result { Ok(()) => settings.message(), @@ -338,7 +377,11 @@ fn command(cmd: &Value, settings: &Mutex, state: &Arc) -> Strin } // The update thread does the work. The page sees the result in the "ota" field of the status. Some(kind @ ("ota_check" | "ota_install")) => { - let cmd = if kind == "ota_check" { OtaCmd::Check } else { OtaCmd::Install }; + let cmd = if kind == "ota_check" { + OtaCmd::Check + } else { + OtaCmd::Install + }; // The reply is small on purpose. The TLS handshake of the OTA thread takes about // 40 KB of heap, and the full settings message can then fail to allocate. match ota::run(state.clone(), cmd) { @@ -385,7 +428,10 @@ struct Src { impl Serialize for Src { fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(&format_args!("deck#{} {} (rb#{})", self.player, self.slot, self.id)) + serializer.collect_str(&format_args!( + "deck#{} {} (rb#{})", + self.player, self.slot, self.id + )) } } @@ -399,10 +445,14 @@ struct PhraseJson { /// Builds the track fields like `host/src/web/status.rs`. fn track_fields(d: &Deck, t: Option<&TrackData>) -> TrackFields { - let Some(t) = t.filter(|t| t.rekordbox_id == d.track.rekordbox_id && t.rekordbox_id != 0) else { + let Some(t) = t.filter(|t| t.rekordbox_id == d.track.rekordbox_id && t.rekordbox_id != 0) + else { return TrackFields::default(); }; - let mut f = TrackFields { wave: t.overview.as_ref().map(|o| o.rekordbox_id), ..Default::default() }; + let mut f = TrackFields { + wave: t.overview.as_ref().map(|o| o.rekordbox_id), + ..Default::default() + }; if let Some(m) = &t.metadata { let mut parts: Vec = [&m.artist, &m.title, &m.key] .into_iter() @@ -418,12 +468,15 @@ fn track_fields(d: &Deck, t: Option<&TrackData>) -> TrackFields { } match &t.structure { Some(Ok(ss)) => { - f.phrase = d.beat_count.and_then(|bc| phrase_status(ss, bc)).map(|p| PhraseJson { - kind: Disp(p.kind), - progress: p.progress, - fill: p.fill, - beats_left: p.beats_left, - }); + f.phrase = d + .beat_count + .and_then(|bc| phrase_status(ss, bc)) + .map(|p| PhraseJson { + kind: Disp(p.kind), + progress: p.progress, + fill: p.fill, + beats_left: p.beats_left, + }); } Some(Err(reason)) => f.phrase_note = Some(format!("phrase info unavailable: {reason}")), // The fetch is still in progress. @@ -534,11 +587,18 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { let now = state.now_ms(); let (decks, master_num, master_virtual) = { let tracker = state.tracker.lock().unwrap(); - (tracker.decks().to_vec(), tracker.master().map(|m| m.num), tracker.master_is_virtual()) + ( + tracker.decks().to_vec(), + tracker.master().map(|m| m.num), + tracker.master_is_virtual(), + ) }; let fields: Vec = { let tracks = state.tracks.0.lock().unwrap(); - decks.iter().map(|d| track_fields(d, tracks.get(&d.num))).collect() + decks + .iter() + .map(|d| track_fields(d, tracks.get(&d.num))) + .collect() }; let master = master_num.and_then(|n| decks.iter().find(|d| d.num == n)); let deck_json: Vec = decks @@ -555,7 +615,11 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { }; DeckJson { num: d.num, - name: if d.name.is_empty() { Cow::Owned(format!("Deck {}", d.num)) } else { Cow::Borrowed(&d.name) }, + name: if d.name.is_empty() { + Cow::Owned(format!("Deck {}", d.num)) + } else { + Cow::Borrowed(&d.name) + }, playing: d.is_playing(now), master: is_master, virtual_master: is_master && master_virtual, @@ -580,7 +644,11 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { .collect(); let (clock, output) = { let midi = state.midi.lock().unwrap(); - let clock = ClockJson { out16: midi.out16, state: midi.clock_state, delta_ms: midi.delta_ms }; + let clock = ClockJson { + out16: midi.out16, + state: midi.clock_state, + delta_ms: midi.delta_ms, + }; let output = OutputJson { port: "UART2 TX (IO17)", connected: true, @@ -590,8 +658,16 @@ fn status_message(state: &State, clock_enabled: bool, out: &mut Vec) { notes: midi.notes, ccs: midi.ccs, // Like the host: the history keeps an entry for 3 s, newest first. - notes_recent: midi.recent_notes.iter().filter_map(|&(n, t)| Some((n, age_ms(t)?))).collect(), - ccs_recent: midi.recent_ccs.iter().filter_map(|&(n, v, t)| Some((n, v, age_ms(t)?))).collect(), + notes_recent: midi + .recent_notes + .iter() + .filter_map(|&(n, t)| Some((n, age_ms(t)?))) + .collect(), + ccs_recent: midi + .recent_ccs + .iter() + .filter_map(|&(n, v, t)| Some((n, v, age_ms(t)?))) + .collect(), mtc: None, mtc_qf: 0, mtc_full: 0, @@ -689,5 +765,7 @@ fn origin_allowed(origin: Option<&str>, host: Option<&str>) -> bool { host.rsplit_once(':').map_or(host, |(name, _)| name) } .to_ascii_lowercase(); - same && (name.parse::().is_ok() || name == "localhost" || name.ends_with(".local")) + same && (name.parse::().is_ok() + || name == "localhost" + || name.ends_with(".local")) } diff --git a/host/src/midi/mapper.rs b/host/src/midi/mapper.rs index 1c4e145..5ec7d4e 100644 --- a/host/src/midi/mapper.rs +++ b/host/src/midi/mapper.rs @@ -866,7 +866,10 @@ async fn run_with_midi( // The UI counters are optional. Do not wait for the lock. if let Some(mut act) = activity.try_lock() { act.cc_sent += 1; - remember(&mut act.recent_ccs, (cc_cfg.phrase_16, new_phrase_16, Instant::now())); + remember( + &mut act.recent_ccs, + (cc_cfg.phrase_16, new_phrase_16, Instant::now()), + ); } } } @@ -922,7 +925,10 @@ async fn run_with_midi( // The UI counters are optional. Do not wait for the lock. if let Some(mut act) = activity.try_lock() { act.cc_sent += 1; - remember(&mut act.recent_ccs, (c.phrase_kind, kind_val, Instant::now())); + remember( + &mut act.recent_ccs, + (c.phrase_kind, kind_val, Instant::now()), + ); } } let n = cfg_now.notes; diff --git a/host/src/prolink/metadata.rs b/host/src/prolink/metadata.rs index a948c59..991d4fa 100644 --- a/host/src/prolink/metadata.rs +++ b/host/src/prolink/metadata.rs @@ -223,7 +223,11 @@ pub async fn run( map.insert(key, ()); } - let session = Arc::clone(sessions.entry(ip).or_insert_with(|| Arc::new(Semaphore::new(1)))); + let session = Arc::clone( + sessions + .entry(ip) + .or_insert_with(|| Arc::new(Semaphore::new(1))), + ); let inflight2 = Arc::clone(&inflight); let state2 = Arc::clone(&state); let warned2 = Arc::clone(&warned); diff --git a/host/src/runtime/midi.rs b/host/src/runtime/midi.rs index c94ae9e..1dc421e 100644 --- a/host/src/runtime/midi.rs +++ b/host/src/runtime/midi.rs @@ -75,7 +75,9 @@ pub async fn reconnect_loop( if midi_out.is_connected() { warned = false; let wanted = cfg.read().midi.output.clone(); - let ports = tokio::task::spawn_blocking(list.clone()).await.unwrap_or_default(); + let ports = tokio::task::spawn_blocking(list.clone()) + .await + .unwrap_or_default(); if port_present(&wanted, &ports) { continue; } diff --git a/host/src/runtime/prolink.rs b/host/src/runtime/prolink.rs index a648e85..a89cf7b 100644 --- a/host/src/runtime/prolink.rs +++ b/host/src/runtime/prolink.rs @@ -51,8 +51,15 @@ pub fn spawn( mac, ); - let reason = - watch_group(&mut group, &ctx, &hint, device_number, bind_ip, &device_table).await; + let reason = watch_group( + &mut group, + &ctx, + &hint, + device_number, + bind_ip, + &device_table, + ) + .await; tracing::warn!(%reason, "Rebuilding the Pro DJ Link network tasks"); group.shutdown().await; tokio::time::sleep(CHECK_INTERVAL).await; @@ -140,10 +147,9 @@ fn network_changed(hint: &str, bind_ip: Ipv4Addr, players: bool) -> Option Some(format!( - "a better interface is up: {} ({})", - best.name, best.ip - )), + Some(best) if hint == "auto" && !players && best.priority < current.priority => Some( + format!("a better interface is up: {} ({})", best.name, best.ip), + ), _ => None, } } From 2045d3afae783b4d4a2136318419a1ae40032936 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 27 Oct 2026 01:13:23 +0100 Subject: [PATCH 2/4] Fix the clippy warnings The changes keep the behaviour. Most changes come from cargo clippy --fix. Two functions with many arguments get an allow attribute. CI checks clippy from now on. --- crates/autoblink-core-prolink/src/parse.rs | 68 +++++++++++----------- esp32-emulator/src/main.rs | 8 +-- host/src/app.rs | 4 +- host/src/config.rs | 18 +----- host/src/midi/clock.rs | 35 +++++------ host/src/midi/soak.rs | 4 +- host/src/midi/timecode.rs | 20 +++---- host/src/prolink/metadata.rs | 8 ++- host/src/state/dj_state.rs | 3 +- 9 files changed, 77 insertions(+), 91 deletions(-) diff --git a/crates/autoblink-core-prolink/src/parse.rs b/crates/autoblink-core-prolink/src/parse.rs index 382e49d..077902e 100644 --- a/crates/autoblink-core-prolink/src/parse.rs +++ b/crates/autoblink-core-prolink/src/parse.rs @@ -83,40 +83,6 @@ pub fn parse_beat(data: &[u8]) -> Option { }) } -#[cfg(test)] -mod tests { - use super::*; - use crate::{BEAT_NONE, PITCH_NORMAL}; - - fn magic_pkt(kind: u8, len: usize) -> alloc::vec::Vec { - let mut v = alloc::vec![0u8; len]; - v[..10].copy_from_slice(&MAGIC); - v[0x0a] = kind; - v - } - - #[test] - fn beat_timings_scale_by_pitch_and_preserve_sentinel() { - let mut p = magic_pkt(PKT_BEAT, 0x60); - p[0x21] = 1; - p[0x5a] = 0x2e; // 120.00 BPM raw hi - p[0x5b] = 0xe0; // 120.00 BPM raw lo - - let pitch_plus_8 = ((PITCH_NORMAL as f64) * 1.08).round() as u32; - p[0x54..0x58].copy_from_slice(&pitch_plus_8.to_be_bytes()); - - p[0x24..0x28].copy_from_slice(&500u32.to_be_bytes()); - p[0x28..0x2c].copy_from_slice(&1000u32.to_be_bytes()); - p[0x2c..0x30].copy_from_slice(&BEAT_NONE.to_be_bytes()); - p[0x5c] = 1; - - let bp = parse_beat(&p).expect("beat packet should parse"); - assert_eq!(bp.next_beat_ms, 463); - assert_eq!(bp.second_beat_ms, 926); - assert_eq!(bp.next_bar_ms, BEAT_NONE); - } -} - pub fn parse_abs_position(data: &[u8]) -> Option { if data.len() < 0x3c { return None; @@ -211,3 +177,37 @@ pub fn parse_mixer_status(data: &[u8]) -> Option { beat_in_bar: data[0x37], }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BEAT_NONE, PITCH_NORMAL}; + + fn magic_pkt(kind: u8, len: usize) -> alloc::vec::Vec { + let mut v = alloc::vec![0u8; len]; + v[..10].copy_from_slice(&MAGIC); + v[0x0a] = kind; + v + } + + #[test] + fn beat_timings_scale_by_pitch_and_preserve_sentinel() { + let mut p = magic_pkt(PKT_BEAT, 0x60); + p[0x21] = 1; + p[0x5a] = 0x2e; // 120.00 BPM raw hi + p[0x5b] = 0xe0; // 120.00 BPM raw lo + + let pitch_plus_8 = ((PITCH_NORMAL as f64) * 1.08).round() as u32; + p[0x54..0x58].copy_from_slice(&pitch_plus_8.to_be_bytes()); + + p[0x24..0x28].copy_from_slice(&500u32.to_be_bytes()); + p[0x28..0x2c].copy_from_slice(&1000u32.to_be_bytes()); + p[0x2c..0x30].copy_from_slice(&BEAT_NONE.to_be_bytes()); + p[0x5c] = 1; + + let bp = parse_beat(&p).expect("beat packet should parse"); + assert_eq!(bp.next_beat_ms, 463); + assert_eq!(bp.second_beat_ms, 926); + assert_eq!(bp.next_bar_ms, BEAT_NONE); + } +} diff --git a/esp32-emulator/src/main.rs b/esp32-emulator/src/main.rs index 23c3ec3..e41d82b 100644 --- a/esp32-emulator/src/main.rs +++ b/esp32-emulator/src/main.rs @@ -257,7 +257,7 @@ impl EmulatorState { return; } - if &data[..10] != autoblink_core_prolink::MAGIC { + if data[..10] != autoblink_core_prolink::MAGIC { return; } @@ -380,7 +380,7 @@ impl EmulatorState { self.last_note = self.midi_config.beat_note; self.last_note_ch = self.midi_config.note_channel; self.last_cc = self.midi_config.beat_phase_cc; - self.last_cc_val = (beat.beat_in_bar * 31) as u8; + self.last_cc_val = beat.beat_in_bar * 31; self.last_cc_ch = self.midi_config.cc_channel; self.last_note_at = Some(Instant::now()); self.last_cc_at = Some(Instant::now()); @@ -555,12 +555,12 @@ impl EmulatorState { let clocks_to_add = ((24.0 * master_bpm) / 600.0).round() as u64; if clocks_to_add > 0 { self.midi_clock_count += clocks_to_add; - } else if self.tick % 5 == 0 { + } else if self.tick.is_multiple_of(5) { self.midi_clock_count += 1; } } - if self.tick % 500 == 0 { + if self.tick.is_multiple_of(500) { self.add_log(0, "INFO", "MIDI clock: 24PPQ running"); } } diff --git a/host/src/app.rs b/host/src/app.rs index 715f521..b5f5b55 100644 --- a/host/src/app.rs +++ b/host/src/app.rs @@ -590,8 +590,8 @@ pub fn list_interfaces() -> anyhow::Result<()> { println!("Detected network interfaces:"); println!( - "{:<6} {:<30} {:<18} {:<18} {}", - "Prio", "Name", "IPv4", "Broadcast", "MAC" + "{:<6} {:<30} {:<18} {:<18} MAC", + "Prio", "Name", "IPv4", "Broadcast" ); println!("{}", "-".repeat(95)); diff --git a/host/src/config.rs b/host/src/config.rs index fda6838..01535f4 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -268,24 +268,20 @@ pub struct ProgramConfig { /// MTC frame rate variants. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum MtcFrameRate { /// 24 fps (film/cinema). #[serde(alias = "24")] Fps24, /// 25 fps (PAL video). #[serde(alias = "25")] + #[default] Fps25, /// 30 fps non-drop (NTSC). #[serde(alias = "30")] Fps30, } -impl Default for MtcFrameRate { - fn default() -> Self { - Self::Fps25 - } -} - impl MtcFrameRate { /// Frames per second as an integer. pub fn fps(self) -> u8 { @@ -326,6 +322,7 @@ impl MtcFrameRate { #[derive(Debug, Clone, Deserialize)] #[serde(default)] +#[derive(Default)] pub struct MtcConfig { /// Enable MIDI Timecode output. pub enabled: bool, @@ -333,15 +330,6 @@ pub struct MtcConfig { pub frame_rate: MtcFrameRate, } -impl Default for MtcConfig { - fn default() -> Self { - Self { - enabled: false, - frame_rate: MtcFrameRate::default(), - } - } -} - // ── Ableton Link config ─────────────────────────────────────────────────────── #[derive(Debug, Clone, Deserialize)] diff --git a/host/src/midi/clock.rs b/host/src/midi/clock.rs index cba6227..c38f791 100644 --- a/host/src/midi/clock.rs +++ b/host/src/midi/clock.rs @@ -535,7 +535,7 @@ fn handle_beat_event( a.clock_wait_beats_seen = cs.wait_beats_seen; a.clock_phrase_beat = cs.beat_count; a.clock_pulse_index = cs.pulse_index; - a.clock_timing_delta_ms = beat_timing_delta_ms(&cs, Instant::now()); + a.clock_timing_delta_ms = beat_timing_delta_ms(cs, Instant::now()); } } @@ -566,7 +566,7 @@ fn handle_beat_event( a.clock_wait_beats_seen = 0; a.clock_phrase_beat = cs.beat_count; a.clock_pulse_index = cs.pulse_index; - a.clock_timing_delta_ms = beat_timing_delta_ms(&cs, Instant::now()); + a.clock_timing_delta_ms = beat_timing_delta_ms(cs, Instant::now()); } } cs.pulse_index = 0; @@ -617,7 +617,7 @@ fn handle_beat_event( a.clock_wait_beats_seen = cs.wait_beats_seen; a.clock_phrase_beat = cs.beat_count; a.clock_pulse_index = cs.pulse_index; - a.clock_timing_delta_ms = beat_timing_delta_ms(&cs, Instant::now()); + a.clock_timing_delta_ms = beat_timing_delta_ms(cs, Instant::now()); } } @@ -649,7 +649,7 @@ fn handle_beat_event( a.clock_wait_beats_seen = 0; a.clock_phrase_beat = cs.beat_count; a.clock_pulse_index = cs.pulse_index; - a.clock_timing_delta_ms = beat_timing_delta_ms(&cs, Instant::now()); + a.clock_timing_delta_ms = beat_timing_delta_ms(cs, Instant::now()); } } cs.pulse_index = 0; @@ -682,19 +682,16 @@ fn drain_beat_events( smoothing_ms: u64, beat_rx: &mut broadcast::Receiver, ) { - loop { - match beat_rx.try_recv() { - Ok(evt) => handle_beat_event( - cs, - state, - midi, - activity, - resync_interval_bars, - smoothing_ms, - evt, - ), - Err(_) => break, - } + while let Ok(evt) = beat_rx.try_recv() { + handle_beat_event( + cs, + state, + midi, + activity, + resync_interval_bars, + smoothing_ms, + evt, + ); } } @@ -940,7 +937,7 @@ pub async fn run( let mut cached_is_playing = false; let mut last_master_key = { let st = state.read(); - (st.master.source.clone(), st.master.device_number) + (st.master.source, st.master.device_number) }; let mut last_connected = midi.is_connected(); @@ -993,7 +990,7 @@ pub async fn run( let current_master_key = { let st = state.read(); - (st.master.source.clone(), st.master.device_number) + (st.master.source, st.master.device_number) }; if clock_enabled && current_master_key != last_master_key { diff --git a/host/src/midi/soak.rs b/host/src/midi/soak.rs index 8f61703..313bfcf 100644 --- a/host/src/midi/soak.rs +++ b/host/src/midi/soak.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use clap::ValueEnum; @@ -59,7 +59,7 @@ fn compute_percentiles_ms(mut samples: Vec) -> Percentiles { } } -fn ensure_parent_dir(path: &PathBuf) -> anyhow::Result<()> { +fn ensure_parent_dir(path: &Path) -> anyhow::Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; diff --git a/host/src/midi/timecode.rs b/host/src/midi/timecode.rs index 4304747..de8eb15 100644 --- a/host/src/midi/timecode.rs +++ b/host/src/midi/timecode.rs @@ -354,12 +354,13 @@ impl MtcScheduler { // Preferred: fresh, master-scoped AbsPosition measurement. let is_master_scoped = measurement.device_number == Some(master_device); - if is_master_scoped - && measurement.kind == MeasurementKind::ProLinkAbsPositionPacket - && measurement.playhead_ms.is_some() - && *age <= PLAYHEAD_MAX_AGE - { - let pos = measurement.playhead_ms.unwrap() as i64; + let fresh_playhead = measurement.playhead_ms.filter(|_| { + is_master_scoped + && measurement.kind == MeasurementKind::ProLinkAbsPositionPacket + && *age <= PLAYHEAD_MAX_AGE + }); + if let Some(playhead_ms) = fresh_playhead { + let pos = playhead_ms as i64; let speed = speed_factor_from_master_pitch_pct(master_pitch_pct); match &mut self.osc { Some(osc) => osc.retime(now, pos, speed), @@ -380,6 +381,7 @@ impl MtcScheduler { Some((pos, false)) } + #[allow(clippy::too_many_arguments)] fn on_wake( &mut self, now: Instant, @@ -519,11 +521,7 @@ impl MtcScheduler { let frame = tc.total_frames(frame_rate.fps()); if let Some(prev) = self.last_cycle_frame { - let diff = if frame >= prev { - frame - prev - } else { - prev - frame - }; + let diff = frame.abs_diff(prev); if diff > DISCONTINUITY_THRESHOLD_FRAMES { // Exactly one full-frame resync, then restart QF cycle. out.push(MtcOut::FullFrame(full_frame_sysex(&tc, &frame_rate))); diff --git a/host/src/prolink/metadata.rs b/host/src/prolink/metadata.rs index 991d4fa..5b374fb 100644 --- a/host/src/prolink/metadata.rs +++ b/host/src/prolink/metadata.rs @@ -952,8 +952,8 @@ fn parse_pssi_tag_inner(data: &[u8]) -> anyhow::Result { }, TrackMood::Low => match kind_id { 1 => PhraseKind::Intro, - 2 | 3 | 4 => PhraseKind::Verse1, - 5 | 6 | 7 => PhraseKind::Verse2, + 2..=4 => PhraseKind::Verse1, + 5..=7 => PhraseKind::Verse2, 8 => PhraseKind::Bridge, 9 => PhraseKind::Chorus, 10 => PhraseKind::Outro, @@ -1116,7 +1116,9 @@ async fn read_field(stream: &mut TcpStream) -> anyhow::Result { /// Decode UTF-16 big-endian bytes to a Rust String, stripping null terminators. fn decode_utf16be(data: &[u8]) -> String { let u16s: Vec = data - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|c| u16::from_be_bytes([c[0], c[1]])) .collect(); String::from_utf16_lossy(&u16s) diff --git a/host/src/state/dj_state.rs b/host/src/state/dj_state.rs index e249094..3897b92 100644 --- a/host/src/state/dj_state.rs +++ b/host/src/state/dj_state.rs @@ -169,7 +169,7 @@ impl DjState { dev.current_phrase_idx = None; dev.prev_phrase_idx = None; // An eject has rekordbox_id 0. It clears the metadata but loads no track. - (s.rekordbox_id != 0).then(|| TrackChange { + (s.rekordbox_id != 0).then_some(TrackChange { device_number: s.device_number, track_source_player: s.track_source_player, track_slot: s.track_slot, @@ -492,6 +492,7 @@ impl DjState { } } + #[allow(clippy::too_many_arguments)] pub fn apply_link_state( &mut self, bpm: f64, From a3bd461c385e05632fab92aff2d9841c0dc50f7f Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 27 Oct 2026 01:14:48 +0100 Subject: [PATCH 3/4] Run only quick checks on GitHub and the builds on the local computer A pull request runs one Linux job. The job checks the format, runs clippy and runs the tests. CI ran for each push and again for the pull request. Now it runs for the pull request and for main only. The Windows and macOS jobs run cargo check after a merge to main. They do not build a release. The firmware workflow builds one image after a merge to main. scripts/check.sh runs the same checks and the two firmware builds on the local computer. The pre-push hook in .githooks runs the script. --- .githooks/pre-push | 5 +++ .github/workflows/ci.yml | 45 +++++++++++++++++++++---- .github/workflows/esp32-ci.yml | 20 +++++------ AGENTS.md | 12 ++++++- scripts/check.sh | 61 ++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 19 deletions(-) create mode 100755 .githooks/pre-push create mode 100755 scripts/check.sh diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..e7f3905 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +# Runs scripts/check.sh before each push. +# Enable it once for each clone: git config core.hooksPath .githooks +# Skip it for one push: git push --no-verify +exec sh scripts/check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d60ba06..e5570a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,29 @@ name: CI +# A pull request gets only the quick Linux job: format, clippy and tests. +# Run `sh scripts/check.sh` before a push. It runs the same checks and the firmware build. on: - push: pull_request: + paths-ignore: ["**.md", "docs/**"] + push: + branches: [main] + paths-ignore: ["**.md", "docs/**"] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true env: CARGO_TERM_COLOR: always RUSTY_LINK_VERSION: "0.2.3" jobs: - # ── Tests (Linux) ──────────────────────────────────────────────────────────── + # ── Lint and tests (Linux) ────────────────────────────────────────────────── test: - name: Test + name: Lint and test runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -54,17 +65,37 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + # Only main writes the cache. A pull request reads the cache of main. - name: Cache Cargo registry & build artifacts uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Check the format + run: cargo fmt --all --check + + # The firmware has its own workspace and the esp toolchain. rustfmt reads the module tree + # from main.rs, so the stable toolchain can check the format. + - name: Check the format of the firmware + run: rustfmt --check --edition 2021 esp32/build.rs esp32/src/main.rs + + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings - name: Run tests run: cargo test --locked # ── Build checks (Windows + macOS) ────────────────────────────────────────── + # This job runs after a merge to main and on a manual start. It does not run for a pull request. + # The release workflow builds the release binaries for all targets. build: - name: Build (${{ matrix.os }}) + name: Check (${{ matrix.os }}) + if: github.event_name != 'pull_request' runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -154,6 +185,8 @@ jobs: - name: Cache Cargo registry & build artifacts uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Build - run: cargo build --release --locked + - name: Check + run: cargo check --locked diff --git a/.github/workflows/esp32-ci.yml b/.github/workflows/esp32-ci.yml index af5322f..1a9ac50 100644 --- a/.github/workflows/esp32-ci.yml +++ b/.github/workflows/esp32-ci.yml @@ -1,11 +1,16 @@ name: ESP32 CI +# The firmware build takes about 5 minutes. It runs after a merge to main and on a manual start. +# Before a push, `sh scripts/check.sh` builds the firmware with and without the DMX feature. on: push: branches: [main] paths: ["esp32/**", "crates/**", "webui/**", ".github/workflows/esp32-ci.yml"] - pull_request: - paths: ["esp32/**", "crates/**", "webui/**", ".github/workflows/esp32-ci.yml"] + workflow_dispatch: + +concurrency: + group: esp32-ci-${{ github.ref }} + cancel-in-progress: true env: CARGO_TERM_COLOR: always @@ -14,6 +19,7 @@ jobs: esp32-build: name: Build ESP32 firmware (WT32-ETH01) runs-on: ubuntu-latest + timeout-minutes: 25 steps: - uses: actions/checkout@v4 @@ -49,16 +55,6 @@ jobs: esp32/target key: esp32-${{ hashFiles('esp32/Cargo.lock', 'esp32/.cargo/config.toml', 'esp32/sdkconfig.defaults') }} - - name: Build with the DMX output - working-directory: esp32 - run: cargo build --release --locked --features dmx - - name: Build working-directory: esp32 run: cargo build --release --locked - - - name: Upload firmware - uses: actions/upload-artifact@v4 - with: - name: esp32-firmware - path: esp32/target/xtensa-esp32-espidf/release/autoblink-esp32 diff --git a/AGENTS.md b/AGENTS.md index ff3a40b..16f4a10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,10 @@ cargo run -p autoblink-esp32-emulator # Tests (all crates) cargo test --locked +# The checks of CI before a push: format, clippy, tests, and the firmware build when the branch changes it +sh scripts/check.sh # --esp32 always builds the firmware, --no-esp32 never builds it +git config core.hooksPath .githooks # once for each clone: the pre-push hook runs scripts/check.sh + # Single workspace member cargo test -p autoblink-host cargo build -p autoblink-esp32-emulator @@ -120,7 +124,13 @@ All have `#[cfg(not(windows))]` guards around reuse_port calls. - **`cargo test --locked`** runs tests across all crates - No dedicated test infrastructure found (no `tests/` directory, no fixtures) -- ESP32 CI (`esp32-ci.yml`) builds the firmware when a change touches `esp32/`, `crates/` or `webui/`. No test runs on the device. +- Do the builds on your computer. GitHub runs only quick checks. +- Before a push, run `sh scripts/check.sh`. It runs the checks of CI. When the branch changes `esp32/`, `crates/` or `webui/`, it also builds the firmware with and without `dmx`. +- `ci.yml` runs for each pull request and each merge to `main`. One Linux job checks the format, runs clippy with `-D warnings` and runs the tests. A change to Markdown or `docs/` only does not start it. +- The Windows and macOS jobs of `ci.yml` run `cargo check` only after a merge to `main` or on a manual start. +- `esp32-ci.yml` builds the firmware without `dmx` only after a merge to `main` or on a manual start. No test runs on the device. +- To start a workflow on a branch, run `gh workflow run ci.yml --ref ` or `gh workflow run esp32-ci.yml --ref `. +- The release workflows (`release.yml`, `esp32-release.yml`) start from a tag. They build all targets. - Coverage gaps: packet parsing, state machine transitions, BPM smoothing, MIDI clock transport ## ESP32 Build Requirements diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..da6f868 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Runs the checks of CI on this machine, and the firmware build that CI does not run for a pull request. +# +# Usage: sh scripts/check.sh [--esp32 | --no-esp32] +# +# The script builds the firmware when the branch changes esp32/, crates/ or webui/. +# --esp32 always builds the firmware. --no-esp32 never builds it. +# The firmware build needs the esp toolchain and ~/export-esp.sh (see AGENTS.md). +set -eu + +cd "$(git rev-parse --show-toplevel)" + +esp32=auto +case "${1:-}" in + --esp32) esp32=yes ;; + --no-esp32) esp32=no ;; + "") ;; + *) echo "usage: sh scripts/check.sh [--esp32 | --no-esp32]" >&2; exit 2 ;; +esac + +if [ ! -f vendor/rusty_link/Cargo.toml ]; then + echo "vendor/rusty_link is missing. Do the steps in AGENTS.md, section \"Vendor Setup\"." >&2 + exit 1 +fi + +step() { printf '\n==> %s\n' "$*"; } + +step "Format" +cargo fmt --all --check +rustfmt --check --edition 2021 esp32/build.rs esp32/src/main.rs + +step "Clippy" +cargo clippy --locked --all-targets -- -D warnings + +step "Tests" +cargo test --locked + +if [ "$esp32" = auto ]; then + esp32=no + base=$(git merge-base HEAD origin/main 2>/dev/null || true) + if [ -n "$base" ] && git diff --name-only "$base" HEAD -- esp32 crates webui | grep -q .; then + esp32=yes + fi +fi + +if [ "$esp32" = yes ]; then + if [ ! -f "$HOME/export-esp.sh" ]; then + echo "The branch changes the firmware, but ~/export-esp.sh is missing. Install the esp toolchain (see AGENTS.md)." >&2 + exit 1 + fi + # export-esp.sh sets PATH and LIBCLANG_PATH for the esp toolchain. + . "$HOME/export-esp.sh" + step "Firmware" + (cd esp32 && cargo build --release --locked) + step "Firmware with the DMX output" + (cd esp32 && cargo build --release --locked --features dmx) +else + step "Firmware: skipped. The branch does not change esp32/, crates/ or webui/." +fi + +step "All checks passed" From f749ddbf42a1fd13e5677aa6c36ffe7e4130fb06 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 27 Oct 2026 01:27:03 +0100 Subject: [PATCH 4/4] Build main on the local computer after each merge A dated rule in AGENTS.md asks for a local build after each merge to main. GitHub Actions runs only quick checks. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 16f4a10..b89de1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,6 +267,7 @@ Do not connect a load to IO12 at power-on. If IO12 is high, the ESP32 sets the f ## Constraints - **until 2027-01-11** — Keep the host app alive under every fault. A task must restart after a panic (`runtime::supervise`). A blocking call must not run on a tokio worker. An FFI callback must not capture Rust data, because the vendored `rusty_link` callback setters keep a dangling pointer. +- **until 2027-01-25** — After each merge to `main`, build `main` on the local computer. Run `git checkout main && git pull`, then `sh scripts/check.sh --esp32 && cargo build --release --locked`. GitHub Actions runs only quick checks, because the account has a limit of free Actions minutes. ## graphify