Skip to main content

midi_controller/
config.rs

1//! Pedalboard configuration types shared between CLI and firmware.
2//!
3//! **IMPORTANT:** When changing `Preset`, `ButtonConfig`, `EncoderConfig`, `AnalogConfig`,
4//! `Action`, or any type serialized into flash, bump `PRESET_SCHEMA_VERSION` below.
5//! The firmware uses this to reject stale presets on boot.
6
7use heapless::{String, Vec};
8use serde::{Deserialize, Serialize};
9
10/// Bump when any struct that is postcard-serialized into preset flash changes layout.
11/// Must match `FORMAT_VERSION` in `pedalboard-midi/src/preset_format.rs`.
12pub const PRESET_SCHEMA_VERSION: u8 = 6;
13
14pub const MAX_PRESETS: usize = 32;
15pub const MAX_BUTTONS: usize = 6;
16pub const MAX_ENCODERS: usize = 2;
17pub const MAX_ANALOG: usize = 2;
18pub const MAX_LABEL_LEN: usize = 16;
19pub const MAX_ACTIONS: usize = 8;
20pub const MAX_CYCLE_VALUES: usize = 12;
21
22pub type Label = String<MAX_LABEL_LEN>;
23
24/// PE resource ID for global configuration (presets use 0x00..0x1F).
25pub const GLOBAL_CONFIG_RESOURCE: u8 = 0x7F;
26
27/// PE resource ID for system commands.
28pub const SYSTEM_COMMAND_RESOURCE: u8 = 0x7E;
29
30/// PE resource ID for device info (read-only, GET only).
31pub const DEVICE_INFO_RESOURCE: u8 = 0x7D;
32
33/// System command identifiers (body of PE Set to resource 0x7E).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(u8)]
36pub enum SystemCommand {
37    Reboot = 0x01,
38    Bootloader = 0x02,
39    FactoryReset = 0x03,
40}
41
42/// Device info returned by PE GET to resource 0x7D.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct DeviceInfo {
45    /// Flash format version (PRESET_SCHEMA_VERSION).
46    pub flash_format: u8,
47    /// Number of presets currently loaded in RAM.
48    pub presets_loaded: u8,
49    /// Number of presets skipped on boot (version mismatch).
50    pub presets_skipped: u8,
51    /// Firmware version string (e.g. "0.2.0-76de139").
52    pub version: String<24>,
53}
54
55impl SystemCommand {
56    pub fn from_byte(b: u8) -> Option<Self> {
57        match b {
58            0x01 => Some(Self::Reboot),
59            0x02 => Some(Self::Bootloader),
60            0x03 => Some(Self::FactoryReset),
61            _ => None,
62        }
63    }
64}
65
66/// System-wide configuration, independent of presets.
67/// Replaces OpenDeck global settings.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct GlobalConfig {
70    /// Enable DIN MIDI output for locally-generated messages.
71    #[serde(default = "default_true")]
72    pub din_enabled: bool,
73    /// Route incoming DIN MIDI → USB MIDI out.
74    #[serde(default = "default_true")]
75    pub din_to_usb_thru: bool,
76    /// Route incoming USB MIDI → DIN MIDI out.
77    #[serde(default)]
78    pub usb_to_din_thru: bool,
79    /// Route incoming USB MIDI → USB MIDI out (echo).
80    #[serde(default)]
81    pub usb_to_usb_thru: bool,
82    /// Enable MIDI Clock (0xF8) output.
83    #[serde(default)]
84    pub midi_clock: bool,
85    /// MIDI Clock tempo in BPM (30–300).
86    #[serde(default = "default_bpm")]
87    pub bpm: u16,
88    /// Expression pedal 1 ADC value at heel (rest) position.
89    #[serde(default)]
90    pub exp1_min: u16,
91    /// Expression pedal 1 ADC value at toe (full) position.
92    #[serde(default = "default_adc_max")]
93    pub exp1_max: u16,
94    /// Expression pedal 2 ADC value at heel (rest) position.
95    #[serde(default)]
96    pub exp2_min: u16,
97    /// Expression pedal 2 ADC value at toe (full) position.
98    #[serde(default = "default_adc_max")]
99    pub exp2_max: u16,
100}
101
102fn default_true() -> bool {
103    true
104}
105
106fn default_bpm() -> u16 {
107    120
108}
109
110fn default_adc_max() -> u16 {
111    3750
112}
113
114impl Default for GlobalConfig {
115    fn default() -> Self {
116        Self {
117            din_enabled: true,
118            din_to_usb_thru: true,
119            usb_to_din_thru: false,
120            usb_to_usb_thru: false,
121            midi_clock: false,
122            bpm: 120,
123            exp1_min: 0,
124            exp1_max: 3750,
125            exp2_min: 0,
126            exp2_max: 3750,
127        }
128    }
129}
130
131impl GlobalConfig {
132    /// MIDI Clock tick interval in microseconds (24 PPQ).
133    pub fn tick_interval_us(&self) -> u32 {
134        if self.bpm == 0 {
135            return 20_833; // fallback to 120 BPM
136        }
137        // 60_000_000 / (bpm * 24)
138        60_000_000 / (self.bpm as u32 * 24)
139    }
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143pub struct Config<
144    const B: usize = MAX_BUTTONS,
145    const E: usize = MAX_ENCODERS,
146    const A: usize = MAX_ANALOG,
147> {
148    #[serde(default)]
149    pub global: GlobalConfig,
150    pub presets: Vec<Preset<B, E, A>, MAX_PRESETS>,
151}
152
153#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
154pub struct Preset<
155    const B: usize = MAX_BUTTONS,
156    const E: usize = MAX_ENCODERS,
157    const A: usize = MAX_ANALOG,
158> {
159    pub name: Label,
160    pub buttons: Vec<ButtonConfig, B>,
161    pub encoders: Vec<EncoderConfig, E>,
162    pub analog: Vec<AnalogConfig, A>,
163    /// Initial state applied on first boot / after upload (before any user interaction).
164    #[serde(default)]
165    pub defaults: InitialState<B, E>,
166    /// Actions fired when this preset becomes active (on switch or boot).
167    #[serde(default)]
168    pub on_enter: Vec<Action, MAX_ACTIONS>,
169    /// Actions fired when leaving this preset (before switching to another).
170    #[serde(default)]
171    pub on_exit: Vec<Action, MAX_ACTIONS>,
172    /// Incoming MIDI triggers: react to external messages by changing state or firing actions.
173    #[serde(default)]
174    pub triggers: Vec<Trigger, MAX_TRIGGERS>,
175}
176
177pub const MAX_TRIGGERS: usize = 8;
178
179/// A trigger that reacts to incoming MIDI.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct Trigger {
182    /// What incoming MIDI message to match.
183    pub match_msg: TriggerMatch,
184    /// What to do when matched.
185    pub action: TriggerAction,
186}
187
188/// Incoming MIDI message pattern to match.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub enum TriggerMatch {
191    /// Match CC on channel, with optional value range.
192    Cc {
193        cc: u8,
194        channel: u8,
195        #[serde(default)]
196        value_min: u8,
197        #[serde(default = "default_value_max")]
198        value_max: u8,
199    },
200    /// Match Program Change on channel.
201    ProgramChange { program: u8, channel: u8 },
202    /// Match Note On on channel.
203    NoteOn { note: u8, channel: u8 },
204}
205
206fn default_value_max() -> u8 {
207    127
208}
209
210/// Action to perform when a trigger matches.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub enum TriggerAction {
213    /// Set button active (LED on, no outgoing MIDI).
214    Activate(u8),
215    /// Set button inactive (LED off, no outgoing MIDI).
216    Deactivate(u8),
217    /// Switch to a preset by index.
218    PresetSelect(u8),
219    /// Fire button's on_press actions as if pressed.
220    Execute(u8),
221}
222
223/// Default toggle/radio/encoder state for a preset on first activation.
224#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
225pub struct InitialState<const B: usize = MAX_BUTTONS, const E: usize = MAX_ENCODERS> {
226    /// Which buttons start active (true = on). Length matches buttons vec.
227    #[serde(default)]
228    pub button_active: Vec<bool, B>,
229    /// Initial encoder values (0-127). Length matches encoders vec.
230    #[serde(default)]
231    pub encoder_values: Vec<u8, E>,
232}
233
234// --- Buttons ---
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct ButtonConfig {
238    pub label: Label,
239    pub color: LedConfig,
240    pub mode: ButtonMode,
241    pub on_press: Vec<Action, MAX_ACTIONS>,
242    pub on_release: Vec<Action, MAX_ACTIONS>,
243    pub on_long_press: Vec<Action, MAX_ACTIONS>,
244    #[serde(default)]
245    pub cycle_values: Vec<u8, MAX_CYCLE_VALUES>,
246    /// Reactive LED: ring shows heatmap proportional to incoming CC value.
247    #[serde(default)]
248    pub listen_cc: Option<ListenCc>,
249}
250
251/// Reactive CC binding: maps incoming MIDI CC to LED ring visualization.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ListenCc {
254    pub cc: u8,
255    pub channel: u8,
256    /// Visualization mode (default: Heatmap).
257    #[serde(default)]
258    pub mode: ListenMode,
259    /// Threshold for trigger mode (default: 64). Value ≥ threshold = on.
260    #[serde(default = "default_threshold")]
261    pub threshold: u8,
262}
263
264fn default_threshold() -> u8 {
265    64
266}
267
268/// How the LED ring reacts to incoming CC.
269#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
270pub enum ListenMode {
271    /// Fill proportional to value (0-127 → 0-12 LEDs).
272    #[default]
273    Heatmap,
274    /// On/off using button's color+animation when value ≥ threshold.
275    Trigger,
276}
277
278#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
279pub enum ButtonMode {
280    /// Fire on_press once per press
281    #[default]
282    Momentary,
283    /// Alternate between on_press (pos 1) and on_release (pos 2)
284    Toggle,
285    /// Only one button in the group can be active (others deactivate)
286    RadioGroup(u8),
287}
288
289// --- Actions (Morningstar-style message list) ---
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub enum Action {
293    /// Raw MIDI message (1-3 bytes, pre-encoded by CLI).
294    /// Use `midi2::BytesMessage::try_from(&data[..len])` for structured debug output.
295    Midi { data: [u8; 3], len: u8 },
296    /// CC cycling through button's cycle_values list on each press (stateful)
297    CcCycle { cc: u8, channel: u8, reverse: bool },
298    /// Set LED state (for sequencing LED changes in action lists)
299    SetLed {
300        color: Color,
301        animation: LedAnimation,
302    },
303    /// Delay in ms between actions in a sequence
304    Delay(u16),
305    /// Switch to preset by index
306    PresetSelect(u8),
307    /// Next preset
308    PresetNext,
309    /// Previous preset
310    PresetPrev,
311    /// Bank up (scroll preset page)
312    BankUp,
313    /// Bank down
314    BankDown,
315    /// Tap tempo: updates MIDI clock BPM from press intervals.
316    /// Averages the last 3 intervals (needs 4 taps). Resets after 2s idle.
317    TapTempo,
318}
319
320impl Action {
321    /// Control Change (status 0xBn).
322    /// Returns `None` if cc > 127, value > 127, or channel not in 1..=16.
323    pub fn cc(cc: u8, value: u8, channel: u8) -> Option<Self> {
324        if cc > 127 || value > 127 || channel == 0 || channel > 16 {
325            return None;
326        }
327        Some(Self::Midi {
328            data: [0xB0 | (channel - 1), cc, value],
329            len: 3,
330        })
331    }
332    /// Program Change (status 0xCn).
333    /// Returns `None` if program > 127 or channel not in 1..=16.
334    pub fn program_change(program: u8, channel: u8) -> Option<Self> {
335        if program > 127 || channel == 0 || channel > 16 {
336            return None;
337        }
338        Some(Self::Midi {
339            data: [0xC0 | (channel - 1), program, 0],
340            len: 2,
341        })
342    }
343    /// Note On (status 0x9n, velocity 127).
344    /// Returns `None` if note > 127 or channel not in 1..=16.
345    pub fn note_on(note: u8, channel: u8) -> Option<Self> {
346        if note > 127 || channel == 0 || channel > 16 {
347            return None;
348        }
349        Some(Self::Midi {
350            data: [0x90 | (channel - 1), note, 127],
351            len: 3,
352        })
353    }
354    /// Note Off (status 0x8n, velocity 0).
355    /// Returns `None` if note > 127 or channel not in 1..=16.
356    pub fn note_off(note: u8, channel: u8) -> Option<Self> {
357        if note > 127 || channel == 0 || channel > 16 {
358            return None;
359        }
360        Some(Self::Midi {
361            data: [0x80 | (channel - 1), note, 0],
362            len: 3,
363        })
364    }
365}
366
367// --- Encoders ---
368
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct EncoderConfig {
371    pub label: Label,
372    pub action: EncoderAction,
373    /// LED ring configuration. Default: Heatmap (tracks encoder value).
374    #[serde(default = "encoder_led_default")]
375    pub color: LedConfig,
376}
377
378impl Default for EncoderConfig {
379    fn default() -> Self {
380        Self {
381            label: Label::new(),
382            action: EncoderAction::Cc {
383                cc: 0,
384                channel: 1,
385                min: 0,
386                max: 127,
387            },
388            color: encoder_led_default(),
389        }
390    }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394pub enum EncoderAction {
395    Cc {
396        cc: u16,
397        channel: u8,
398        min: u8,
399        max: u8,
400    },
401    /// Two separate CC values for CW/CCW (e.g. relative encoding)
402    CcRelative {
403        cc: u8,
404        channel: u8,
405        increment: u8,
406        decrement: u8,
407    },
408    PresetScroll,
409}
410
411// --- Analog (expression pedals) ---
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct AnalogConfig {
415    pub label: Label,
416    pub cc: u8,
417    pub channel: u8,
418    pub min: u8,
419    pub max: u8,
420}
421
422// --- LED ---
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct LedConfig {
426    /// Color when active/on
427    pub on: Color,
428    /// Color when inactive/off (None = LED off)
429    pub off: Color,
430    /// Animation modifier when active (default: Solid)
431    #[serde(default)]
432    pub animation: LedAnimation,
433    /// Spatial renderer (default: Solid — all 12 LEDs)
434    #[serde(default)]
435    pub renderer: LedRenderer,
436    /// Renderer parameter (fill count, wing count, single position)
437    #[serde(default)]
438    pub renderer_param: u8,
439}
440
441#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
442pub enum Color {
443    #[default]
444    Off,
445    Red,
446    Green,
447    Blue,
448    Yellow,
449    Cyan,
450    Magenta,
451    White,
452    Orange,
453    Purple,
454    Custom(u8, u8, u8), // RGB
455}
456
457#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
458pub enum LedAnimation {
459    #[default]
460    Solid,
461    Blink,
462    Pulse,
463    Rotate,
464    ColorCycle,
465}
466
467#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
468pub enum LedRenderer {
469    /// All 12 LEDs same color
470    #[default]
471    Solid,
472    /// Partial arc (param = count 1-12)
473    Fill,
474    /// Single LED (param = clock position 0-11)
475    Single,
476    /// N evenly-spaced LEDs (param = count 1-6)
477    Dots,
478    /// Value-driven heatmap (blue→green→red). Default for encoders.
479    /// `renderer_param` is the current fill level (0-12), typically driven by encoder value.
480    Heatmap,
481}
482
483// --- Defaults ---
484
485/// Type alias for the default preset configuration (6 buttons, 2 encoders, 2 analog).
486pub type DefaultPreset = Preset<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
487/// Type alias for the default system configuration.
488pub type DefaultConfig = Config<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
489/// Type alias for the default initial state.
490pub type DefaultInitialState = InitialState<MAX_BUTTONS, MAX_ENCODERS>;
491
492impl Default for LedConfig {
493    fn default() -> Self {
494        LedConfig {
495            on: Color::Off,
496            off: Color::Off,
497            animation: LedAnimation::Solid,
498            renderer: LedRenderer::Solid,
499            renderer_param: 0,
500        }
501    }
502}
503
504/// Default LED config for encoders: Heatmap renderer (tracks encoder value).
505fn encoder_led_default() -> LedConfig {
506    LedConfig {
507        on: Color::Off,
508        off: Color::Off,
509        animation: LedAnimation::Solid,
510        renderer: LedRenderer::Heatmap,
511        renderer_param: 0,
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    #[test]
520    fn serialize_roundtrip_morningstar_style() {
521        let config: Config = Config {
522            global: GlobalConfig::default(),
523            presets: {
524                let mut p = Vec::new();
525                let _ = p.push(Preset {
526                    name: Label::try_from("Live FX").unwrap(),
527                    buttons: {
528                        let mut b = Vec::new();
529                        let _ = b.push(ButtonConfig {
530                            label: Label::try_from("Board 1").unwrap(),
531                            color: LedConfig {
532                                on: Color::Blue,
533                                off: Color::Off,
534                                animation: LedAnimation::Solid,
535                                renderer: LedRenderer::Solid,
536                                renderer_param: 0,
537                            },
538                            mode: ButtonMode::RadioGroup(1),
539                            on_press: {
540                                let mut a = Vec::new();
541                                let _ = a.push(Action::program_change(0, 2).unwrap());
542                                let _ = a.push(Action::SetLed {
543                                    color: Color::Blue,
544                                    animation: LedAnimation::Solid,
545                                });
546                                a
547                            },
548                            on_release: Vec::new(),
549                            on_long_press: Vec::new(),
550                            cycle_values: Vec::new(),
551                            listen_cc: None,
552                        });
553                        b
554                    },
555                    encoders: {
556                        let mut e = Vec::new();
557                        let _ = e.push(EncoderConfig {
558                            label: Label::try_from("Vol").unwrap(),
559                            action: EncoderAction::Cc {
560                                cc: 7,
561                                channel: 1,
562                                min: 0,
563                                max: 127,
564                            },
565                            ..Default::default()
566                        });
567                        e
568                    },
569                    analog: Vec::new(),
570                    defaults: Default::default(),
571                    on_enter: Vec::new(),
572                    on_exit: Vec::new(),
573                    triggers: Vec::new(),
574                });
575                p
576            },
577        };
578
579        let mut buf = [0u8; 512];
580        let bytes = postcard::to_slice(&config, &mut buf).unwrap();
581        let decoded: Config = postcard::from_bytes(bytes).unwrap();
582        assert_eq!(config, decoded);
583    }
584
585    #[test]
586    fn multi_action_button() {
587        // Morningstar-style: button sends PC + CC + delay + CC
588        let btn = ButtonConfig {
589            label: Label::try_from("Scene 1").unwrap(),
590            color: LedConfig {
591                on: Color::Green,
592                off: Color::Off,
593                animation: LedAnimation::Solid,
594                renderer: LedRenderer::Solid,
595                renderer_param: 0,
596            },
597            mode: ButtonMode::Momentary,
598            on_press: {
599                let mut a = Vec::new();
600                let _ = a.push(Action::program_change(0, 1).unwrap());
601                let _ = a.push(Action::cc(69, 127, 1).unwrap());
602                let _ = a.push(Action::Delay(50));
603                let _ = a.push(Action::cc(70, 0, 1).unwrap());
604                a
605            },
606            on_release: Vec::new(),
607            on_long_press: {
608                let mut a = Vec::new();
609                let _ = a.push(Action::PresetNext);
610                a
611            },
612            cycle_values: Vec::new(),
613            listen_cc: None,
614        };
615
616        let mut buf = [0u8; 256];
617        let bytes = postcard::to_slice(&btn, &mut buf).unwrap();
618        assert!(bytes.len() < 80);
619        let decoded: ButtonConfig = postcard::from_bytes(bytes).unwrap();
620        assert_eq!(btn, decoded);
621    }
622
623    #[test]
624    fn global_config_postcard_roundtrip() {
625        let gc = GlobalConfig {
626            din_enabled: true,
627            din_to_usb_thru: true,
628            usb_to_din_thru: false,
629            usb_to_usb_thru: false,
630            midi_clock: true,
631            bpm: 140,
632            exp1_min: 180,
633            exp1_max: 3700,
634            exp2_min: 200,
635            exp2_max: 3750,
636        };
637        let mut buf = [0u8; 32];
638        let bytes = postcard::to_slice(&gc, &mut buf).unwrap();
639        let decoded: GlobalConfig = postcard::from_bytes(bytes).unwrap();
640        assert_eq!(gc, decoded);
641    }
642
643    #[test]
644    fn global_config_default_values() {
645        let gc = GlobalConfig::default();
646        assert!(gc.din_enabled);
647        assert!(gc.din_to_usb_thru);
648        assert!(!gc.usb_to_din_thru);
649        assert!(!gc.usb_to_usb_thru);
650        assert!(!gc.midi_clock);
651        assert_eq!(gc.bpm, 120);
652    }
653
654    #[test]
655    fn global_config_tick_interval_120bpm() {
656        let gc = GlobalConfig {
657            bpm: 120,
658            ..Default::default()
659        };
660        // 120 BPM = 60_000_000 / (120 * 24) = 20833 µs
661        assert_eq!(gc.tick_interval_us(), 20833);
662    }
663
664    #[test]
665    fn global_config_tick_interval_60bpm() {
666        let gc = GlobalConfig {
667            bpm: 60,
668            ..Default::default()
669        };
670        // 60 BPM = 60_000_000 / (60 * 24) = 41666 µs
671        assert_eq!(gc.tick_interval_us(), 41666);
672    }
673
674    #[test]
675    fn global_config_tick_interval_zero_bpm_fallback() {
676        let gc = GlobalConfig {
677            bpm: 0,
678            ..Default::default()
679        };
680        assert_eq!(gc.tick_interval_us(), 20833);
681    }
682
683    #[test]
684    fn global_config_compact_serialization() {
685        let gc = GlobalConfig::default();
686        let mut buf = [0u8; 32];
687        let bytes = postcard::to_slice(&gc, &mut buf).unwrap();
688        // 5 bools (5B) + bpm varint (1B) + 4x u16 varint (1+2+1+2 = 6B) = 12 bytes
689        assert_eq!(bytes.len(), 12);
690    }
691
692    #[test]
693    fn action_cc_validates_ranges() {
694        assert!(Action::cc(0, 0, 1).is_some());
695        assert!(Action::cc(127, 127, 16).is_some());
696        // cc > 127 (impossible with u8, but value/channel can be wrong)
697        assert!(Action::cc(0, 0, 0).is_none()); // channel 0
698        assert!(Action::cc(0, 0, 17).is_none()); // channel 17
699    }
700
701    #[test]
702    fn action_note_on_validates_ranges() {
703        assert!(Action::note_on(0, 1).is_some());
704        assert!(Action::note_on(127, 16).is_some());
705        assert!(Action::note_on(60, 0).is_none()); // channel 0
706        assert!(Action::note_on(60, 17).is_none()); // channel 17
707    }
708
709    #[test]
710    fn action_program_change_validates_ranges() {
711        assert!(Action::program_change(0, 1).is_some());
712        assert!(Action::program_change(127, 16).is_some());
713        assert!(Action::program_change(5, 0).is_none()); // channel 0
714        assert!(Action::program_change(5, 17).is_none()); // channel 17
715    }
716
717    /// Snapshot test: detects serialized layout changes without a PRESET_SCHEMA_VERSION bump.
718    ///
719    /// If this test fails, it means the postcard-serialized representation of Preset changed.
720    /// To fix:
721    /// 1. Determine if the change is breaking (reorder/remove/retype) or additive (append with default)
722    /// 2. If breaking: bump PRESET_SCHEMA_VERSION and update EXPECTED_SERIALIZED_LEN below
723    /// 3. If additive (new #[serde(default)] field at end): just update EXPECTED_SERIALIZED_LEN
724    ///
725    /// The test uses a fully-populated Preset to maximize sensitivity to layout changes.
726    #[test]
727    fn preset_serialization_layout_is_stable() {
728        // Canonical preset with all fields populated (maximizes change detection)
729        let preset: Preset = Preset {
730            name: Label::try_from("Stable").unwrap(),
731            buttons: {
732                let mut b = Vec::new();
733                let _ = b.push(ButtonConfig {
734                    label: Label::try_from("Btn").unwrap(),
735                    color: LedConfig {
736                        on: Color::Red,
737                        off: Color::Off,
738                        animation: LedAnimation::Blink,
739                        renderer: LedRenderer::Fill,
740                        renderer_param: 6,
741                    },
742                    mode: ButtonMode::Toggle,
743                    on_press: {
744                        let mut a = Vec::new();
745                        let _ = a.push(Action::cc(80, 127, 1).unwrap());
746                        a
747                    },
748                    on_release: {
749                        let mut a = Vec::new();
750                        let _ = a.push(Action::cc(80, 0, 1).unwrap());
751                        a
752                    },
753                    on_long_press: {
754                        let mut a = Vec::new();
755                        let _ = a.push(Action::PresetNext);
756                        a
757                    },
758                    cycle_values: {
759                        let mut cv = Vec::new();
760                        let _ = cv.push(0);
761                        let _ = cv.push(64);
762                        let _ = cv.push(127);
763                        cv
764                    },
765                    listen_cc: Some(ListenCc {
766                        cc: 100,
767                        channel: 1,
768                        mode: ListenMode::Trigger,
769                        threshold: 64,
770                    }),
771                });
772                b
773            },
774            encoders: {
775                let mut e = Vec::new();
776                let _ = e.push(EncoderConfig {
777                    label: Label::try_from("Vol").unwrap(),
778                    action: EncoderAction::Cc {
779                        cc: 7,
780                        channel: 1,
781                        min: 0,
782                        max: 127,
783                    },
784                    ..Default::default()
785                });
786                e
787            },
788            analog: {
789                let mut a = Vec::new();
790                let _ = a.push(AnalogConfig {
791                    label: Label::try_from("Wah").unwrap(),
792                    cc: 11,
793                    channel: 1,
794                    min: 0,
795                    max: 127,
796                });
797                a
798            },
799            defaults: InitialState {
800                button_active: {
801                    let mut v = Vec::new();
802                    let _ = v.push(true);
803                    v
804                },
805                encoder_values: {
806                    let mut v = Vec::new();
807                    let _ = v.push(100);
808                    v
809                },
810            },
811            on_enter: {
812                let mut a = Vec::new();
813                let _ = a.push(Action::program_change(0, 2).unwrap());
814                a
815            },
816            on_exit: {
817                let mut a = Vec::new();
818                let _ = a.push(Action::cc(123, 0, 1).unwrap());
819                a
820            },
821            triggers: {
822                let mut t = Vec::new();
823                let _ = t.push(Trigger {
824                    match_msg: TriggerMatch::Cc {
825                        cc: 80,
826                        channel: 1,
827                        value_min: 64,
828                        value_max: 127,
829                    },
830                    action: TriggerAction::Activate(0),
831                });
832                t
833            },
834        };
835
836        let mut buf = [0u8; 512];
837        let bytes = postcard::to_slice(&preset, &mut buf).unwrap();
838
839        // FNV-1a hash of serialized bytes — detects any layout change.
840        // To fix a failure:
841        // 1. If breaking (reorder/remove/retype): bump PRESET_SCHEMA_VERSION
842        // 2. If additive (new #[serde(default)] at end): no version bump needed
843        // 3. In both cases: update EXPECTED_HASH to the value shown in the failure message
844        fn fnv1a(data: &[u8]) -> u32 {
845            let mut hash: u32 = 0x811c_9dc5;
846            for &b in data {
847                hash ^= b as u32;
848                hash = hash.wrapping_mul(0x0100_0193);
849            }
850            hash
851        }
852
853        let hash = fnv1a(bytes);
854        const EXPECTED_HASH: u32 = 0x97ae_bc54;
855        const EXPECTED_VERSION: u8 = 6;
856
857        assert_eq!(
858            PRESET_SCHEMA_VERSION, EXPECTED_VERSION,
859            "PRESET_SCHEMA_VERSION changed — update EXPECTED_VERSION and EXPECTED_HASH"
860        );
861        assert_eq!(
862            hash,
863            EXPECTED_HASH,
864            "Serialized Preset layout changed (hash {:#010x} != expected {:#010x}).\n\
865             If layout changed, bump PRESET_SCHEMA_VERSION in config.rs.\n\
866             Then update both EXPECTED_VERSION and EXPECTED_HASH.\n\
867             Serialized bytes ({} bytes): {:02x?}",
868            hash,
869            EXPECTED_HASH,
870            bytes.len(),
871            bytes
872        );
873    }
874}