use heapless::{String, Vec};
use serde::{Deserialize, Serialize};
pub const PRESET_SCHEMA_VERSION: u8 = 5;
pub const MAX_PRESETS: usize = 32;
pub const MAX_BUTTONS: usize = 6;
pub const MAX_ENCODERS: usize = 2;
pub const MAX_ANALOG: usize = 2;
pub const MAX_LABEL_LEN: usize = 16;
pub const MAX_ACTIONS: usize = 8;
pub const MAX_CYCLE_VALUES: usize = 12;
pub type Label = String<MAX_LABEL_LEN>;
pub const GLOBAL_CONFIG_RESOURCE: u8 = 0x7F;
pub const SYSTEM_COMMAND_RESOURCE: u8 = 0x7E;
pub const DEVICE_INFO_RESOURCE: u8 = 0x7D;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum SystemCommand {
Reboot = 0x01,
Bootloader = 0x02,
FactoryReset = 0x03,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceInfo {
pub flash_format: u8,
pub presets_loaded: u8,
pub presets_skipped: u8,
pub version: String<24>,
}
impl SystemCommand {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(Self::Reboot),
0x02 => Some(Self::Bootloader),
0x03 => Some(Self::FactoryReset),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GlobalConfig {
#[serde(default = "default_true")]
pub din_enabled: bool,
#[serde(default = "default_true")]
pub din_to_usb_thru: bool,
#[serde(default)]
pub usb_to_din_thru: bool,
#[serde(default)]
pub usb_to_usb_thru: bool,
#[serde(default)]
pub midi_clock: bool,
#[serde(default = "default_bpm")]
pub bpm: u16,
#[serde(default)]
pub exp1_min: u16,
#[serde(default = "default_adc_max")]
pub exp1_max: u16,
#[serde(default)]
pub exp2_min: u16,
#[serde(default = "default_adc_max")]
pub exp2_max: u16,
}
fn default_true() -> bool {
true
}
fn default_bpm() -> u16 {
120
}
fn default_adc_max() -> u16 {
3750
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
din_enabled: true,
din_to_usb_thru: true,
usb_to_din_thru: false,
usb_to_usb_thru: false,
midi_clock: false,
bpm: 120,
exp1_min: 0,
exp1_max: 3750,
exp2_min: 0,
exp2_max: 3750,
}
}
}
impl GlobalConfig {
pub fn tick_interval_us(&self) -> u32 {
if self.bpm == 0 {
return 20_833; }
60_000_000 / (self.bpm as u32 * 24)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config<
const B: usize = MAX_BUTTONS,
const E: usize = MAX_ENCODERS,
const A: usize = MAX_ANALOG,
> {
#[serde(default)]
pub global: GlobalConfig,
pub presets: Vec<Preset<B, E, A>, MAX_PRESETS>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Preset<
const B: usize = MAX_BUTTONS,
const E: usize = MAX_ENCODERS,
const A: usize = MAX_ANALOG,
> {
pub name: Label,
pub buttons: Vec<ButtonConfig, B>,
pub encoders: Vec<EncoderConfig, E>,
pub analog: Vec<AnalogConfig, A>,
#[serde(default)]
pub defaults: InitialState<B, E>,
#[serde(default)]
pub on_enter: Vec<Action, MAX_ACTIONS>,
#[serde(default)]
pub on_exit: Vec<Action, MAX_ACTIONS>,
#[serde(default)]
pub triggers: Vec<Trigger, MAX_TRIGGERS>,
}
pub const MAX_TRIGGERS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Trigger {
pub match_msg: TriggerMatch,
pub action: TriggerAction,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TriggerMatch {
Cc {
cc: u8,
channel: u8,
#[serde(default)]
value_min: u8,
#[serde(default = "default_value_max")]
value_max: u8,
},
ProgramChange { program: u8, channel: u8 },
NoteOn { note: u8, channel: u8 },
}
fn default_value_max() -> u8 {
127
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TriggerAction {
Activate(u8),
Deactivate(u8),
PresetSelect(u8),
Execute(u8),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InitialState<const B: usize = MAX_BUTTONS, const E: usize = MAX_ENCODERS> {
#[serde(default)]
pub button_active: Vec<bool, B>,
#[serde(default)]
pub encoder_values: Vec<u8, E>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ButtonConfig {
pub label: Label,
pub color: LedConfig,
pub mode: ButtonMode,
pub on_press: Vec<Action, MAX_ACTIONS>,
pub on_release: Vec<Action, MAX_ACTIONS>,
pub on_long_press: Vec<Action, MAX_ACTIONS>,
#[serde(default)]
pub cycle_values: Vec<u8, MAX_CYCLE_VALUES>,
#[serde(default)]
pub listen_cc: Option<ListenCc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListenCc {
pub cc: u8,
pub channel: u8,
#[serde(default)]
pub mode: ListenMode,
#[serde(default = "default_threshold")]
pub threshold: u8,
}
fn default_threshold() -> u8 {
64
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ListenMode {
#[default]
Heatmap,
Trigger,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ButtonMode {
#[default]
Momentary,
Toggle,
RadioGroup(u8),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Action {
Midi { data: [u8; 3], len: u8 },
CcCycle { cc: u8, channel: u8, reverse: bool },
SetLed {
color: Color,
animation: LedAnimation,
},
Delay(u16),
PresetSelect(u8),
PresetNext,
PresetPrev,
BankUp,
BankDown,
TapTempo,
}
impl Action {
pub fn cc(cc: u8, value: u8, channel: u8) -> Option<Self> {
if cc > 127 || value > 127 || channel == 0 || channel > 16 {
return None;
}
Some(Self::Midi {
data: [0xB0 | (channel - 1), cc, value],
len: 3,
})
}
pub fn program_change(program: u8, channel: u8) -> Option<Self> {
if program > 127 || channel == 0 || channel > 16 {
return None;
}
Some(Self::Midi {
data: [0xC0 | (channel - 1), program, 0],
len: 2,
})
}
pub fn note_on(note: u8, channel: u8) -> Option<Self> {
if note > 127 || channel == 0 || channel > 16 {
return None;
}
Some(Self::Midi {
data: [0x90 | (channel - 1), note, 127],
len: 3,
})
}
pub fn note_off(note: u8, channel: u8) -> Option<Self> {
if note > 127 || channel == 0 || channel > 16 {
return None;
}
Some(Self::Midi {
data: [0x80 | (channel - 1), note, 0],
len: 3,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncoderConfig {
pub label: Label,
pub action: EncoderAction,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EncoderAction {
Cc {
cc: u16,
channel: u8,
min: u8,
max: u8,
},
CcRelative {
cc: u8,
channel: u8,
increment: u8,
decrement: u8,
},
PresetScroll,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnalogConfig {
pub label: Label,
pub cc: u8,
pub channel: u8,
pub min: u8,
pub max: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LedConfig {
pub on: Color,
pub off: Color,
#[serde(default)]
pub animation: LedAnimation,
#[serde(default)]
pub renderer: LedRenderer,
#[serde(default)]
pub renderer_param: u8,
}
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Color {
#[default]
Off,
Red,
Green,
Blue,
Yellow,
Cyan,
Magenta,
White,
Orange,
Purple,
Custom(u8, u8, u8), }
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LedAnimation {
#[default]
Solid,
Blink,
Pulse,
Rotate,
ColorCycle,
}
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LedRenderer {
#[default]
Solid,
Fill,
Single,
Dots,
}
pub type DefaultPreset = Preset<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
pub type DefaultConfig = Config<MAX_BUTTONS, MAX_ENCODERS, MAX_ANALOG>;
pub type DefaultInitialState = InitialState<MAX_BUTTONS, MAX_ENCODERS>;
impl Default for LedConfig {
fn default() -> Self {
LedConfig {
on: Color::Off,
off: Color::Off,
animation: LedAnimation::Solid,
renderer: LedRenderer::Solid,
renderer_param: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_roundtrip_morningstar_style() {
let config: Config = Config {
global: GlobalConfig::default(),
presets: {
let mut p = Vec::new();
let _ = p.push(Preset {
name: Label::try_from("Live FX").unwrap(),
buttons: {
let mut b = Vec::new();
let _ = b.push(ButtonConfig {
label: Label::try_from("Board 1").unwrap(),
color: LedConfig {
on: Color::Blue,
off: Color::Off,
animation: LedAnimation::Solid,
renderer: LedRenderer::Solid,
renderer_param: 0,
},
mode: ButtonMode::RadioGroup(1),
on_press: {
let mut a = Vec::new();
let _ = a.push(Action::program_change(0, 2).unwrap());
let _ = a.push(Action::SetLed {
color: Color::Blue,
animation: LedAnimation::Solid,
});
a
},
on_release: Vec::new(),
on_long_press: Vec::new(),
cycle_values: Vec::new(),
listen_cc: None,
});
b
},
encoders: {
let mut e = Vec::new();
let _ = e.push(EncoderConfig {
label: Label::try_from("Vol").unwrap(),
action: EncoderAction::Cc {
cc: 7,
channel: 1,
min: 0,
max: 127,
},
});
e
},
analog: Vec::new(),
defaults: Default::default(),
on_enter: Vec::new(),
on_exit: Vec::new(),
triggers: Vec::new(),
});
p
},
};
let mut buf = [0u8; 512];
let bytes = postcard::to_slice(&config, &mut buf).unwrap();
let decoded: Config = postcard::from_bytes(bytes).unwrap();
assert_eq!(config, decoded);
}
#[test]
fn multi_action_button() {
let btn = ButtonConfig {
label: Label::try_from("Scene 1").unwrap(),
color: LedConfig {
on: Color::Green,
off: Color::Off,
animation: LedAnimation::Solid,
renderer: LedRenderer::Solid,
renderer_param: 0,
},
mode: ButtonMode::Momentary,
on_press: {
let mut a = Vec::new();
let _ = a.push(Action::program_change(0, 1).unwrap());
let _ = a.push(Action::cc(69, 127, 1).unwrap());
let _ = a.push(Action::Delay(50));
let _ = a.push(Action::cc(70, 0, 1).unwrap());
a
},
on_release: Vec::new(),
on_long_press: {
let mut a = Vec::new();
let _ = a.push(Action::PresetNext);
a
},
cycle_values: Vec::new(),
listen_cc: None,
};
let mut buf = [0u8; 256];
let bytes = postcard::to_slice(&btn, &mut buf).unwrap();
assert!(bytes.len() < 80);
let decoded: ButtonConfig = postcard::from_bytes(bytes).unwrap();
assert_eq!(btn, decoded);
}
#[test]
fn global_config_postcard_roundtrip() {
let gc = GlobalConfig {
din_enabled: true,
din_to_usb_thru: true,
usb_to_din_thru: false,
usb_to_usb_thru: false,
midi_clock: true,
bpm: 140,
exp1_min: 180,
exp1_max: 3700,
exp2_min: 200,
exp2_max: 3750,
};
let mut buf = [0u8; 32];
let bytes = postcard::to_slice(&gc, &mut buf).unwrap();
let decoded: GlobalConfig = postcard::from_bytes(bytes).unwrap();
assert_eq!(gc, decoded);
}
#[test]
fn global_config_default_values() {
let gc = GlobalConfig::default();
assert!(gc.din_enabled);
assert!(gc.din_to_usb_thru);
assert!(!gc.usb_to_din_thru);
assert!(!gc.usb_to_usb_thru);
assert!(!gc.midi_clock);
assert_eq!(gc.bpm, 120);
}
#[test]
fn global_config_tick_interval_120bpm() {
let gc = GlobalConfig {
bpm: 120,
..Default::default()
};
assert_eq!(gc.tick_interval_us(), 20833);
}
#[test]
fn global_config_tick_interval_60bpm() {
let gc = GlobalConfig {
bpm: 60,
..Default::default()
};
assert_eq!(gc.tick_interval_us(), 41666);
}
#[test]
fn global_config_tick_interval_zero_bpm_fallback() {
let gc = GlobalConfig {
bpm: 0,
..Default::default()
};
assert_eq!(gc.tick_interval_us(), 20833);
}
#[test]
fn global_config_compact_serialization() {
let gc = GlobalConfig::default();
let mut buf = [0u8; 32];
let bytes = postcard::to_slice(&gc, &mut buf).unwrap();
assert_eq!(bytes.len(), 12);
}
#[test]
fn action_cc_validates_ranges() {
assert!(Action::cc(0, 0, 1).is_some());
assert!(Action::cc(127, 127, 16).is_some());
assert!(Action::cc(0, 0, 0).is_none()); assert!(Action::cc(0, 0, 17).is_none()); }
#[test]
fn action_note_on_validates_ranges() {
assert!(Action::note_on(0, 1).is_some());
assert!(Action::note_on(127, 16).is_some());
assert!(Action::note_on(60, 0).is_none()); assert!(Action::note_on(60, 17).is_none()); }
#[test]
fn action_program_change_validates_ranges() {
assert!(Action::program_change(0, 1).is_some());
assert!(Action::program_change(127, 16).is_some());
assert!(Action::program_change(5, 0).is_none()); assert!(Action::program_change(5, 17).is_none()); }
#[test]
fn preset_serialization_layout_is_stable() {
let preset: Preset = Preset {
name: Label::try_from("Stable").unwrap(),
buttons: {
let mut b = Vec::new();
let _ = b.push(ButtonConfig {
label: Label::try_from("Btn").unwrap(),
color: LedConfig {
on: Color::Red,
off: Color::Off,
animation: LedAnimation::Blink,
renderer: LedRenderer::Fill,
renderer_param: 6,
},
mode: ButtonMode::Toggle,
on_press: {
let mut a = Vec::new();
let _ = a.push(Action::cc(80, 127, 1).unwrap());
a
},
on_release: {
let mut a = Vec::new();
let _ = a.push(Action::cc(80, 0, 1).unwrap());
a
},
on_long_press: {
let mut a = Vec::new();
let _ = a.push(Action::PresetNext);
a
},
cycle_values: {
let mut cv = Vec::new();
let _ = cv.push(0);
let _ = cv.push(64);
let _ = cv.push(127);
cv
},
listen_cc: Some(ListenCc {
cc: 100,
channel: 1,
mode: ListenMode::Trigger,
threshold: 64,
}),
});
b
},
encoders: {
let mut e = Vec::new();
let _ = e.push(EncoderConfig {
label: Label::try_from("Vol").unwrap(),
action: EncoderAction::Cc {
cc: 7,
channel: 1,
min: 0,
max: 127,
},
});
e
},
analog: {
let mut a = Vec::new();
let _ = a.push(AnalogConfig {
label: Label::try_from("Wah").unwrap(),
cc: 11,
channel: 1,
min: 0,
max: 127,
});
a
},
defaults: InitialState {
button_active: {
let mut v = Vec::new();
let _ = v.push(true);
v
},
encoder_values: {
let mut v = Vec::new();
let _ = v.push(100);
v
},
},
on_enter: {
let mut a = Vec::new();
let _ = a.push(Action::program_change(0, 2).unwrap());
a
},
on_exit: {
let mut a = Vec::new();
let _ = a.push(Action::cc(123, 0, 1).unwrap());
a
},
triggers: {
let mut t = Vec::new();
let _ = t.push(Trigger {
match_msg: TriggerMatch::Cc {
cc: 80,
channel: 1,
value_min: 64,
value_max: 127,
},
action: TriggerAction::Activate(0),
});
t
},
};
let mut buf = [0u8; 512];
let bytes = postcard::to_slice(&preset, &mut buf).unwrap();
fn fnv1a(data: &[u8]) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for &b in data {
hash ^= b as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
let hash = fnv1a(bytes);
const EXPECTED_HASH: u32 = 0x087d_6074;
const EXPECTED_VERSION: u8 = 5;
assert_eq!(
PRESET_SCHEMA_VERSION, EXPECTED_VERSION,
"PRESET_SCHEMA_VERSION changed — update EXPECTED_VERSION and EXPECTED_HASH"
);
assert_eq!(
hash,
EXPECTED_HASH,
"Serialized Preset layout changed (hash {:#010x} != expected {:#010x}).\n\
If layout changed, bump PRESET_SCHEMA_VERSION in config.rs.\n\
Then update both EXPECTED_VERSION and EXPECTED_HASH.\n\
Serialized bytes ({} bytes): {:02x?}",
hash,
EXPECTED_HASH,
bytes.len(),
bytes
);
}
}