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 = 5;
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}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub enum EncoderAction {
377    Cc {
378        cc: u16,
379        channel: u8,
380        min: u8,
381        max: u8,
382    },
383    /// Two separate CC values for CW/CCW (e.g. relative encoding)
384    CcRelative {
385        cc: u8,
386        channel: u8,
387        increment: u8,
388        decrement: u8,
389    },
390    PresetScroll,
391}
392
393// --- Analog (expression pedals) ---
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct AnalogConfig {
397    pub label: Label,
398    pub cc: u8,
399    pub channel: u8,
400    pub min: u8,
401    pub max: u8,
402}
403
404// --- LED ---
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct LedConfig {
408    /// Color when active/on
409    pub on: Color,
410    /// Color when inactive/off (None = LED off)
411    pub off: Color,
412    /// Animation modifier when active (default: Solid)
413    #[serde(default)]
414    pub animation: LedAnimation,
415    /// Spatial renderer (default: Solid — all 12 LEDs)
416    #[serde(default)]
417    pub renderer: LedRenderer,
418    /// Renderer parameter (fill count, wing count, single position)
419    #[serde(default)]
420    pub renderer_param: u8,
421}
422
423#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
424pub enum Color {
425    #[default]
426    Off,
427    Red,
428    Green,
429    Blue,
430    Yellow,
431    Cyan,
432    Magenta,
433    White,
434    Orange,
435    Purple,
436    Custom(u8, u8, u8), // RGB
437}
438
439#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
440pub enum LedAnimation {
441    #[default]
442    Solid,
443    Blink,
444    Pulse,
445    Rotate,
446    ColorCycle,
447}
448
449#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
450pub enum LedRenderer {
451    /// All 12 LEDs same color
452    #[default]
453    Solid,
454    /// Partial arc (param = count 1-12)
455    Fill,
456    /// Single LED (param = clock position 0-11)
457    Single,
458    /// N evenly-spaced LEDs (param = count 1-6)
459    Dots,
460}
461
462// --- Defaults ---
463
464/// Type alias for the default preset configuration (6 buttons, 2 encoders, 2 analog).
465pub type DefaultPreset = Preset<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
466/// Type alias for the default system configuration.
467pub type DefaultConfig = Config<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
468/// Type alias for the default initial state.
469pub type DefaultInitialState = InitialState<MAX_BUTTONS, MAX_ENCODERS>;
470
471impl Default for LedConfig {
472    fn default() -> Self {
473        LedConfig {
474            on: Color::Off,
475            off: Color::Off,
476            animation: LedAnimation::Solid,
477            renderer: LedRenderer::Solid,
478            renderer_param: 0,
479        }
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn serialize_roundtrip_morningstar_style() {
489        let config: Config = Config {
490            global: GlobalConfig::default(),
491            presets: {
492                let mut p = Vec::new();
493                let _ = p.push(Preset {
494                    name: Label::try_from("Live FX").unwrap(),
495                    buttons: {
496                        let mut b = Vec::new();
497                        let _ = b.push(ButtonConfig {
498                            label: Label::try_from("Board 1").unwrap(),
499                            color: LedConfig {
500                                on: Color::Blue,
501                                off: Color::Off,
502                                animation: LedAnimation::Solid,
503                                renderer: LedRenderer::Solid,
504                                renderer_param: 0,
505                            },
506                            mode: ButtonMode::RadioGroup(1),
507                            on_press: {
508                                let mut a = Vec::new();
509                                let _ = a.push(Action::program_change(0, 2).unwrap());
510                                let _ = a.push(Action::SetLed {
511                                    color: Color::Blue,
512                                    animation: LedAnimation::Solid,
513                                });
514                                a
515                            },
516                            on_release: Vec::new(),
517                            on_long_press: Vec::new(),
518                            cycle_values: Vec::new(),
519                            listen_cc: None,
520                        });
521                        b
522                    },
523                    encoders: {
524                        let mut e = Vec::new();
525                        let _ = e.push(EncoderConfig {
526                            label: Label::try_from("Vol").unwrap(),
527                            action: EncoderAction::Cc {
528                                cc: 7,
529                                channel: 1,
530                                min: 0,
531                                max: 127,
532                            },
533                        });
534                        e
535                    },
536                    analog: Vec::new(),
537                    defaults: Default::default(),
538                    on_enter: Vec::new(),
539                    on_exit: Vec::new(),
540                    triggers: Vec::new(),
541                });
542                p
543            },
544        };
545
546        let mut buf = [0u8; 512];
547        let bytes = postcard::to_slice(&config, &mut buf).unwrap();
548        let decoded: Config = postcard::from_bytes(bytes).unwrap();
549        assert_eq!(config, decoded);
550    }
551
552    #[test]
553    fn multi_action_button() {
554        // Morningstar-style: button sends PC + CC + delay + CC
555        let btn = ButtonConfig {
556            label: Label::try_from("Scene 1").unwrap(),
557            color: LedConfig {
558                on: Color::Green,
559                off: Color::Off,
560                animation: LedAnimation::Solid,
561                renderer: LedRenderer::Solid,
562                renderer_param: 0,
563            },
564            mode: ButtonMode::Momentary,
565            on_press: {
566                let mut a = Vec::new();
567                let _ = a.push(Action::program_change(0, 1).unwrap());
568                let _ = a.push(Action::cc(69, 127, 1).unwrap());
569                let _ = a.push(Action::Delay(50));
570                let _ = a.push(Action::cc(70, 0, 1).unwrap());
571                a
572            },
573            on_release: Vec::new(),
574            on_long_press: {
575                let mut a = Vec::new();
576                let _ = a.push(Action::PresetNext);
577                a
578            },
579            cycle_values: Vec::new(),
580            listen_cc: None,
581        };
582
583        let mut buf = [0u8; 256];
584        let bytes = postcard::to_slice(&btn, &mut buf).unwrap();
585        assert!(bytes.len() < 80);
586        let decoded: ButtonConfig = postcard::from_bytes(bytes).unwrap();
587        assert_eq!(btn, decoded);
588    }
589
590    #[test]
591    fn global_config_postcard_roundtrip() {
592        let gc = GlobalConfig {
593            din_enabled: true,
594            din_to_usb_thru: true,
595            usb_to_din_thru: false,
596            usb_to_usb_thru: false,
597            midi_clock: true,
598            bpm: 140,
599            exp1_min: 180,
600            exp1_max: 3700,
601            exp2_min: 200,
602            exp2_max: 3750,
603        };
604        let mut buf = [0u8; 32];
605        let bytes = postcard::to_slice(&gc, &mut buf).unwrap();
606        let decoded: GlobalConfig = postcard::from_bytes(bytes).unwrap();
607        assert_eq!(gc, decoded);
608    }
609
610    #[test]
611    fn global_config_default_values() {
612        let gc = GlobalConfig::default();
613        assert!(gc.din_enabled);
614        assert!(gc.din_to_usb_thru);
615        assert!(!gc.usb_to_din_thru);
616        assert!(!gc.usb_to_usb_thru);
617        assert!(!gc.midi_clock);
618        assert_eq!(gc.bpm, 120);
619    }
620
621    #[test]
622    fn global_config_tick_interval_120bpm() {
623        let gc = GlobalConfig {
624            bpm: 120,
625            ..Default::default()
626        };
627        // 120 BPM = 60_000_000 / (120 * 24) = 20833 µs
628        assert_eq!(gc.tick_interval_us(), 20833);
629    }
630
631    #[test]
632    fn global_config_tick_interval_60bpm() {
633        let gc = GlobalConfig {
634            bpm: 60,
635            ..Default::default()
636        };
637        // 60 BPM = 60_000_000 / (60 * 24) = 41666 µs
638        assert_eq!(gc.tick_interval_us(), 41666);
639    }
640
641    #[test]
642    fn global_config_tick_interval_zero_bpm_fallback() {
643        let gc = GlobalConfig {
644            bpm: 0,
645            ..Default::default()
646        };
647        assert_eq!(gc.tick_interval_us(), 20833);
648    }
649
650    #[test]
651    fn global_config_compact_serialization() {
652        let gc = GlobalConfig::default();
653        let mut buf = [0u8; 32];
654        let bytes = postcard::to_slice(&gc, &mut buf).unwrap();
655        // 5 bools (5B) + bpm varint (1B) + 4x u16 varint (1+2+1+2 = 6B) = 12 bytes
656        assert_eq!(bytes.len(), 12);
657    }
658
659    #[test]
660    fn action_cc_validates_ranges() {
661        assert!(Action::cc(0, 0, 1).is_some());
662        assert!(Action::cc(127, 127, 16).is_some());
663        // cc > 127 (impossible with u8, but value/channel can be wrong)
664        assert!(Action::cc(0, 0, 0).is_none()); // channel 0
665        assert!(Action::cc(0, 0, 17).is_none()); // channel 17
666    }
667
668    #[test]
669    fn action_note_on_validates_ranges() {
670        assert!(Action::note_on(0, 1).is_some());
671        assert!(Action::note_on(127, 16).is_some());
672        assert!(Action::note_on(60, 0).is_none()); // channel 0
673        assert!(Action::note_on(60, 17).is_none()); // channel 17
674    }
675
676    #[test]
677    fn action_program_change_validates_ranges() {
678        assert!(Action::program_change(0, 1).is_some());
679        assert!(Action::program_change(127, 16).is_some());
680        assert!(Action::program_change(5, 0).is_none()); // channel 0
681        assert!(Action::program_change(5, 17).is_none()); // channel 17
682    }
683
684    /// Snapshot test: detects serialized layout changes without a PRESET_SCHEMA_VERSION bump.
685    ///
686    /// If this test fails, it means the postcard-serialized representation of Preset changed.
687    /// To fix:
688    /// 1. Determine if the change is breaking (reorder/remove/retype) or additive (append with default)
689    /// 2. If breaking: bump PRESET_SCHEMA_VERSION and update EXPECTED_SERIALIZED_LEN below
690    /// 3. If additive (new #[serde(default)] field at end): just update EXPECTED_SERIALIZED_LEN
691    ///
692    /// The test uses a fully-populated Preset to maximize sensitivity to layout changes.
693    #[test]
694    fn preset_serialization_layout_is_stable() {
695        // Canonical preset with all fields populated (maximizes change detection)
696        let preset: Preset = Preset {
697            name: Label::try_from("Stable").unwrap(),
698            buttons: {
699                let mut b = Vec::new();
700                let _ = b.push(ButtonConfig {
701                    label: Label::try_from("Btn").unwrap(),
702                    color: LedConfig {
703                        on: Color::Red,
704                        off: Color::Off,
705                        animation: LedAnimation::Blink,
706                        renderer: LedRenderer::Fill,
707                        renderer_param: 6,
708                    },
709                    mode: ButtonMode::Toggle,
710                    on_press: {
711                        let mut a = Vec::new();
712                        let _ = a.push(Action::cc(80, 127, 1).unwrap());
713                        a
714                    },
715                    on_release: {
716                        let mut a = Vec::new();
717                        let _ = a.push(Action::cc(80, 0, 1).unwrap());
718                        a
719                    },
720                    on_long_press: {
721                        let mut a = Vec::new();
722                        let _ = a.push(Action::PresetNext);
723                        a
724                    },
725                    cycle_values: {
726                        let mut cv = Vec::new();
727                        let _ = cv.push(0);
728                        let _ = cv.push(64);
729                        let _ = cv.push(127);
730                        cv
731                    },
732                    listen_cc: Some(ListenCc {
733                        cc: 100,
734                        channel: 1,
735                        mode: ListenMode::Trigger,
736                        threshold: 64,
737                    }),
738                });
739                b
740            },
741            encoders: {
742                let mut e = Vec::new();
743                let _ = e.push(EncoderConfig {
744                    label: Label::try_from("Vol").unwrap(),
745                    action: EncoderAction::Cc {
746                        cc: 7,
747                        channel: 1,
748                        min: 0,
749                        max: 127,
750                    },
751                });
752                e
753            },
754            analog: {
755                let mut a = Vec::new();
756                let _ = a.push(AnalogConfig {
757                    label: Label::try_from("Wah").unwrap(),
758                    cc: 11,
759                    channel: 1,
760                    min: 0,
761                    max: 127,
762                });
763                a
764            },
765            defaults: InitialState {
766                button_active: {
767                    let mut v = Vec::new();
768                    let _ = v.push(true);
769                    v
770                },
771                encoder_values: {
772                    let mut v = Vec::new();
773                    let _ = v.push(100);
774                    v
775                },
776            },
777            on_enter: {
778                let mut a = Vec::new();
779                let _ = a.push(Action::program_change(0, 2).unwrap());
780                a
781            },
782            on_exit: {
783                let mut a = Vec::new();
784                let _ = a.push(Action::cc(123, 0, 1).unwrap());
785                a
786            },
787            triggers: {
788                let mut t = Vec::new();
789                let _ = t.push(Trigger {
790                    match_msg: TriggerMatch::Cc {
791                        cc: 80,
792                        channel: 1,
793                        value_min: 64,
794                        value_max: 127,
795                    },
796                    action: TriggerAction::Activate(0),
797                });
798                t
799            },
800        };
801
802        let mut buf = [0u8; 512];
803        let bytes = postcard::to_slice(&preset, &mut buf).unwrap();
804
805        // FNV-1a hash of serialized bytes — detects any layout change.
806        // To fix a failure:
807        // 1. If breaking (reorder/remove/retype): bump PRESET_SCHEMA_VERSION
808        // 2. If additive (new #[serde(default)] at end): no version bump needed
809        // 3. In both cases: update EXPECTED_HASH to the value shown in the failure message
810        fn fnv1a(data: &[u8]) -> u32 {
811            let mut hash: u32 = 0x811c_9dc5;
812            for &b in data {
813                hash ^= b as u32;
814                hash = hash.wrapping_mul(0x0100_0193);
815            }
816            hash
817        }
818
819        let hash = fnv1a(bytes);
820        const EXPECTED_HASH: u32 = 0x087d_6074;
821        const EXPECTED_VERSION: u8 = 5;
822
823        assert_eq!(
824            PRESET_SCHEMA_VERSION, EXPECTED_VERSION,
825            "PRESET_SCHEMA_VERSION changed — update EXPECTED_VERSION and EXPECTED_HASH"
826        );
827        assert_eq!(
828            hash,
829            EXPECTED_HASH,
830            "Serialized Preset layout changed (hash {:#010x} != expected {:#010x}).\n\
831             If layout changed, bump PRESET_SCHEMA_VERSION in config.rs.\n\
832             Then update both EXPECTED_VERSION and EXPECTED_HASH.\n\
833             Serialized bytes ({} bytes): {:02x?}",
834            hash,
835            EXPECTED_HASH,
836            bytes.len(),
837            bytes
838        );
839    }
840}