1use heapless::{String, Vec};
8use serde::{Deserialize, Serialize};
9
10pub 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
24pub const GLOBAL_CONFIG_RESOURCE: u8 = 0x7F;
26
27pub const SYSTEM_COMMAND_RESOURCE: u8 = 0x7E;
29
30pub const DEVICE_INFO_RESOURCE: u8 = 0x7D;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(u8)]
36pub enum SystemCommand {
37 Reboot = 0x01,
38 Bootloader = 0x02,
39 FactoryReset = 0x03,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct DeviceInfo {
45 pub flash_format: u8,
47 pub presets_loaded: u8,
49 pub presets_skipped: u8,
51 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct GlobalConfig {
70 #[serde(default = "default_true")]
72 pub din_enabled: bool,
73 #[serde(default = "default_true")]
75 pub din_to_usb_thru: bool,
76 #[serde(default)]
78 pub usb_to_din_thru: bool,
79 #[serde(default)]
81 pub usb_to_usb_thru: bool,
82 #[serde(default)]
84 pub midi_clock: bool,
85 #[serde(default = "default_bpm")]
87 pub bpm: u16,
88 #[serde(default)]
90 pub exp1_min: u16,
91 #[serde(default = "default_adc_max")]
93 pub exp1_max: u16,
94 #[serde(default)]
96 pub exp2_min: u16,
97 #[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 pub fn tick_interval_us(&self) -> u32 {
134 if self.bpm == 0 {
135 return 20_833; }
137 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 #[serde(default)]
165 pub defaults: InitialState<B, E>,
166 #[serde(default)]
168 pub on_enter: Vec<Action, MAX_ACTIONS>,
169 #[serde(default)]
171 pub on_exit: Vec<Action, MAX_ACTIONS>,
172 #[serde(default)]
174 pub triggers: Vec<Trigger, MAX_TRIGGERS>,
175}
176
177pub const MAX_TRIGGERS: usize = 8;
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct Trigger {
182 pub match_msg: TriggerMatch,
184 pub action: TriggerAction,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub enum TriggerMatch {
191 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 ProgramChange { program: u8, channel: u8 },
202 NoteOn { note: u8, channel: u8 },
204}
205
206fn default_value_max() -> u8 {
207 127
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub enum TriggerAction {
213 Activate(u8),
215 Deactivate(u8),
217 PresetSelect(u8),
219 Execute(u8),
221}
222
223#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
225pub struct InitialState<const B: usize = MAX_BUTTONS, const E: usize = MAX_ENCODERS> {
226 #[serde(default)]
228 pub button_active: Vec<bool, B>,
229 #[serde(default)]
231 pub encoder_values: Vec<u8, E>,
232}
233
234#[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 #[serde(default)]
248 pub listen_cc: Option<ListenCc>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ListenCc {
254 pub cc: u8,
255 pub channel: u8,
256 #[serde(default)]
258 pub mode: ListenMode,
259 #[serde(default = "default_threshold")]
261 pub threshold: u8,
262}
263
264fn default_threshold() -> u8 {
265 64
266}
267
268#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
270pub enum ListenMode {
271 #[default]
273 Heatmap,
274 Trigger,
276}
277
278#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
279pub enum ButtonMode {
280 #[default]
282 Momentary,
283 Toggle,
285 RadioGroup(u8),
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub enum Action {
293 Midi { data: [u8; 3], len: u8 },
296 CcCycle { cc: u8, channel: u8, reverse: bool },
298 SetLed {
300 color: Color,
301 animation: LedAnimation,
302 },
303 Delay(u16),
305 PresetSelect(u8),
307 PresetNext,
309 PresetPrev,
311 BankUp,
313 BankDown,
315 TapTempo,
318}
319
320impl Action {
321 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct EncoderConfig {
371 pub label: Label,
372 pub action: EncoderAction,
373 #[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 CcRelative {
403 cc: u8,
404 channel: u8,
405 increment: u8,
406 decrement: u8,
407 },
408 PresetScroll,
409}
410
411#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct LedConfig {
426 pub on: Color,
428 pub off: Color,
430 #[serde(default)]
432 pub animation: LedAnimation,
433 #[serde(default)]
435 pub renderer: LedRenderer,
436 #[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), }
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 #[default]
471 Solid,
472 Fill,
474 Single,
476 Dots,
478 Heatmap,
481}
482
483pub type DefaultPreset = Preset<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
487pub type DefaultConfig = Config<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
489pub 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
504fn 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 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 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 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 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 assert!(Action::cc(0, 0, 0).is_none()); assert!(Action::cc(0, 0, 17).is_none()); }
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()); assert!(Action::note_on(60, 17).is_none()); }
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()); assert!(Action::program_change(5, 17).is_none()); }
716
717 #[test]
727 fn preset_serialization_layout_is_stable() {
728 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 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}