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