1use heapless::{String, Vec};
8use serde::{Deserialize, Serialize};
9
10pub 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
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}
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 CcRelative {
385 cc: u8,
386 channel: u8,
387 increment: u8,
388 decrement: u8,
389 },
390 PresetScroll,
391}
392
393#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct LedConfig {
408 pub on: Color,
410 pub off: Color,
412 #[serde(default)]
414 pub animation: LedAnimation,
415 #[serde(default)]
417 pub renderer: LedRenderer,
418 #[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), }
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 #[default]
453 Solid,
454 Fill,
456 Single,
458 Dots,
460}
461
462pub type DefaultPreset = Preset<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
466pub type DefaultConfig = Config<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
468pub 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 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 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 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 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 assert!(Action::cc(0, 0, 0).is_none()); assert!(Action::cc(0, 0, 17).is_none()); }
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()); assert!(Action::note_on(60, 17).is_none()); }
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()); assert!(Action::program_change(5, 17).is_none()); }
683
684 #[test]
694 fn preset_serialization_layout_is_stable() {
695 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 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}