diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index dead682..a7b0df4 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -90,6 +90,17 @@ name = "assign-resources" version = "0.4.0" source = "git+https://github.com/adamgreig/assign-resources?rev=bd22cb7a92031fb16f74a5da42469d466c33383e#bd22cb7a92031fb16f74a5da42469d466c33383e" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -132,6 +143,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + [[package]] name = "bit-set" version = "0.8.0" @@ -1283,6 +1300,8 @@ name = "hexagenmini" version = "0.1.0" dependencies = [ "assign-resources", + "async-trait", + "base64ct", "byte-slice-cast", "cortex-m", "cortex-m-rt", @@ -1324,6 +1343,7 @@ dependencies = [ "smart-leds", "static_cell", "usbd-hid", + "usbd-midi", ] [[package]] @@ -2302,6 +2322,16 @@ dependencies = [ "usbd-hid-descriptors", ] +[[package]] +name = "usbd-midi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88782a02de4ea460cd84dc5cc8007a27618a5a8bb14514699dba5e7dcf9a590" +dependencies = [ + "num_enum 0.7.4", + "usb-device", +] + [[package]] name = "vcell" version = "0.1.3" diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index e516b41..e7cdd0b 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -87,6 +87,7 @@ byte-slice-cast = { version = "1.2.0", default-features = false } smart-leds = "0.4.0" heapless = "0.9.1" usbd-hid = "0.8.1" +usbd-midi = "0.5.0" embedded-hal-1 = { package = "embedded-hal", version = "1.0" } embedded-hal-async = "1.0" @@ -98,6 +99,8 @@ portable-atomic = { version = "1.5", features = ["critical-section"] } log = "0.4" rand = { version = "0.9.0", default-features = false } embedded-sdmmc = "0.9.0" +base64ct = "1.8.0" +async-trait = "0.1.89" [profile.release] # Enable generation of debug symbols even on release builds diff --git a/firmware/src/at/commands.rs b/firmware/src/at/commands.rs new file mode 100644 index 0000000..553ec88 --- /dev/null +++ b/firmware/src/at/commands.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +use crate::at::handler::{SumHandler, VersionHandler}; +use heapless::{LinearMap, String}; + +pub enum Handler { + Version(VersionHandler), + Sum(SumHandler), +} + +pub fn register_all() -> LinearMap, Handler, 8> { + let mut map: LinearMap, Handler, 8> = LinearMap::new(); + let mut version_key = String::<16>::new(); + version_key.push_str("VERSION").unwrap(); + map.insert(version_key, Handler::Version(VersionHandler)) + .ok(); + let mut sum_key = String::<16>::new(); + sum_key.push_str("SUM").unwrap(); + map.insert(sum_key, Handler::Sum(SumHandler)).ok(); + map +} diff --git a/firmware/src/at/handler.rs b/firmware/src/at/handler.rs new file mode 100644 index 0000000..76c9aa9 --- /dev/null +++ b/firmware/src/at/handler.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +use core::fmt::Write; + +/// Trait for AT command handlers +pub trait AtHandler { + fn handle( + &self, + params: &[heapless::String<16>], + is_query: bool, + ) -> Result, crate::error::Error>; +} + +/// Example: Version query handler +pub struct VersionHandler; + +impl AtHandler for VersionHandler { + fn handle( + &self, + _params: &[heapless::String<16>], + is_query: bool, + ) -> Result, crate::error::Error> { + if is_query { + let mut out = heapless::String::<64>::new(); + out.push_str("1.2.3") + .map_err(|_| crate::error::Error::InvalidUtf8)?; + Ok(out) + } else { + Err(crate::error::Error::NotAQuery) + } + } +} + +/// Example: Sum handler (takes two params, returns their sum) +pub struct SumHandler; + +impl AtHandler for SumHandler { + fn handle( + &self, + params: &[heapless::String<16>], + _is_query: bool, + ) -> Result, crate::error::Error> { + if params.len() != 2 { + return Err(crate::error::Error::ParamCount); + } + let a = params[0] + .parse::() + .map_err(|_| crate::error::Error::ParamValue)?; + let b = params[1] + .parse::() + .map_err(|_| crate::error::Error::ParamValue)?; + let mut out = heapless::String::<64>::new(); + write!(out, "{}", a + b).map_err(|_| crate::error::Error::InvalidUtf8)?; + Ok(out) + } +} diff --git a/firmware/src/at/mod.rs b/firmware/src/at/mod.rs new file mode 100644 index 0000000..f09ef09 --- /dev/null +++ b/firmware/src/at/mod.rs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +#![allow(dead_code)] +use core::fmt::Write; + +use heapless::String; + +mod parser; +pub use parser::parse; + +mod handler; +pub use handler::AtHandler; + +mod commands; + +/// AT command dispatcher +pub struct AtDispatcher { + handlers: heapless::LinearMap, crate::at::commands::Handler, 8>, +} + +impl AtDispatcher { + /// Create a new dispatcher with all registered handlers + pub fn new() -> Self { + Self { + handlers: commands::register_all(), + } + } + + /// Dispatch an AT command string to the appropriate handler + pub fn dispatch(&self, input: &str) -> String<64> { + match parse(input) { + Ok(cmd) => match self.handlers.get(&cmd.name) { + Some(handler) => { + let result = match handler { + crate::at::commands::Handler::Version(h) => { + h.handle(&cmd.params, cmd.is_query) + } + crate::at::commands::Handler::Sum(h) => h.handle(&cmd.params, cmd.is_query), + }; + match result { + Ok(resp) => { + let encoded = crate::b64::encode(&resp); + let mut out = String::<64>::new(); + write!(out, "AT+OK={}", encoded).unwrap(); + out + } + Err(e) => { + let msg = e.description(); + let encoded = crate::b64::encode(msg); + let mut out = String::<64>::new(); + write!(out, "AT+ERROR={}", encoded).unwrap(); + out + } + } + } + None => { + let msg = crate::error::Error::UnknownCommand.description(); + let encoded = crate::b64::encode(msg); + let mut out = String::<64>::new(); + write!(out, "AT+ERROR={}", encoded).unwrap(); + out + } + }, + Err(e) => { + let msg = e.description(); + let encoded = crate::b64::encode(msg); + let mut out = String::<64>::new(); + write!(out, "AT+ERROR={}", encoded).unwrap(); + out + } + } + } +} diff --git a/firmware/src/at/parser.rs b/firmware/src/at/parser.rs new file mode 100644 index 0000000..4b8dba8 --- /dev/null +++ b/firmware/src/at/parser.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +use defmt::info; +use heapless::{String, Vec}; + +/// Parsed AT command +pub struct ParsedAtCommand { + pub name: heapless::String<16>, + pub params: heapless::Vec, 8>, + pub is_query: bool, +} + +/// Parse AT command string into name, params, and query flag +pub fn parse(input: &str) -> Result { + let input = input.trim(); + if !input.starts_with("AT+") { + return Err(crate::error::Error::InvalidCommand); + } + let cmd = &input[3..]; + if let Some(eq_pos) = cmd.find('=') { + let (name, param_str) = cmd.split_at(eq_pos); + let param_str = ¶m_str[1..]; + let mut params = Vec::, 8>::new(); + for p in param_str + .split('#') + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + { + info!("Parsing param: {}", p); + match crate::b64::decode(p) { + Ok(decoded) => { + info!("Decoded param: {}", decoded.as_str()); + let mut s_hl = String::<16>::new(); + s_hl.push_str(&decoded) + .map_err(|_| crate::error::Error::InvalidUtf8)?; + params + .push(s_hl) + .map_err(|_| crate::error::Error::ParamCount)?; + } + Err(_) => { + info!("Base64 decode failed for param: {}", p); + return Err(crate::error::Error::InvalidBase64); + } + } + } + Ok(ParsedAtCommand { + name: { + let mut n = String::<16>::new(); + n.push_str(name) + .map_err(|_| crate::error::Error::InvalidUtf8)?; + n + }, + params, + is_query: false, + }) + } else if let Some(name) = cmd.strip_suffix('?') { + Ok(ParsedAtCommand { + name: { + let mut n = String::<16>::new(); + n.push_str(name) + .map_err(|_| crate::error::Error::InvalidUtf8)?; + n + }, + params: Vec::, 8>::new(), + is_query: true, + }) + } else { + Ok(ParsedAtCommand { + name: { + let mut n = String::<16>::new(); + n.push_str(cmd) + .map_err(|_| crate::error::Error::InvalidUtf8)?; + n + }, + params: Vec::, 8>::new(), + is_query: false, + }) + } +} diff --git a/firmware/src/b64/mod.rs b/firmware/src/b64/mod.rs new file mode 100644 index 0000000..952123d --- /dev/null +++ b/firmware/src/b64/mod.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +use base64ct::{Base64, Encoding}; +use heapless::String; + +pub fn decode(input: &str) -> Result, crate::error::Error> { + let mut buf = [0u8; 64]; + match Base64::decode(input, &mut buf) { + Ok(decoded) => match core::str::from_utf8(decoded) { + Ok(s) => { + let mut out = String::<64>::new(); + out.push_str(s) + .map_err(|_| crate::error::Error::InvalidUtf8)?; + Ok(out) + } + Err(_) => Err(crate::error::Error::InvalidUtf8), + }, + Err(_) => Err(crate::error::Error::InvalidBase64), + } +} + +pub fn encode(input: &str) -> String<64> { + let mut buf = [0u8; 64]; + let encoded = Base64::encode(input.as_bytes(), &mut buf).unwrap(); + let mut out = String::<64>::new(); + out.push_str(encoded).unwrap(); + out +} diff --git a/firmware/src/error/mod.rs b/firmware/src/error/mod.rs new file mode 100644 index 0000000..5d23991 --- /dev/null +++ b/firmware/src/error/mod.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +#[derive(Debug, Clone)] +pub enum Error { + InvalidCommand, // "Not an AT command" + InvalidBase64, // "Invalid base64 param" + InvalidUtf8, // "Invalid UTF-8 in param" + InvalidSysEx, // "Invalid SysEx" + ParamCount, // "Param count" + ParamValue, // "Param value" + NotAQuery, // "Not a query" + UnknownCommand, // "Unknown command" +} + +impl Error { + pub fn description(&self) -> &'static str { + match self { + Error::InvalidCommand => "Not an AT command", + Error::InvalidBase64 => "Invalid base64 param", + Error::InvalidUtf8 => "Invalid UTF-8 in param", + Error::InvalidSysEx => "Invalid SysEx", + Error::ParamCount => "Invalid param count", + Error::ParamValue => "Invalid param value", + Error::NotAQuery => "Not a query", + Error::UnknownCommand => "Unknown command", + } + } +} diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 8caf0f5..6521e78 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -4,6 +4,7 @@ #![no_std] #![no_main] +use defmt::info; use embassy_executor::Spawner; use embassy_rp::bind_interrupts; use embassy_rp::gpio; @@ -14,6 +15,10 @@ use embassy_time::Timer; use gpio::{Level, Output}; use {defmt_rtt as _, panic_probe as _}; +mod at; +mod b64; +mod error; +mod sysex; mod usb; bind_interrupts!(struct Irqs { @@ -23,11 +28,13 @@ bind_interrupts!(struct Irqs { #[embassy_executor::main] async fn main(spawner: Spawner) { let p = embassy_rp::init(Default::default()); - + info!("Starting hexaGenMini firmware"); let driver = Driver::new(p.USB, Irqs); let usb::UsbMidi { device, midi } = usb::init(driver); + static DISPATCHER: static_cell::StaticCell = static_cell::StaticCell::new(); + let dispatcher = DISPATCHER.init(at::AtDispatcher::new()); spawner.spawn(usb::dev_task(device)).unwrap(); - spawner.spawn(usb::usb_io_task(midi)).unwrap(); + spawner.spawn(usb::usb_io_task(midi, dispatcher)).unwrap(); let mut led = Output::new(p.PIN_25, Level::Low); loop { diff --git a/firmware/src/sysex/mod.rs b/firmware/src/sysex/mod.rs new file mode 100644 index 0000000..ebddb38 --- /dev/null +++ b/firmware/src/sysex/mod.rs @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2025 hexaTune LLC +// SPDX-License-Identifier: MIT + +use heapless::Vec as HVec; + +pub fn build_sysex(payload: &str) -> Option> { + let mut out: HVec = HVec::new(); + + // SysEx start + out.push(0xF0).ok()?; + + // Payload + for b in payload.as_bytes() { + out.push(*b).ok()?; + } + + // SysEx end + out.push(0xF7).ok()?; + + Some(out) +} + +pub fn sysex_to_usb_midi_packets(sysex: &[u8]) -> heapless::Vec<[u8; 4], M> { + use heapless::Vec; + let mut out: Vec<[u8; 4], M> = Vec::new(); + + let mut i = 0usize; + while i < sysex.len() { + let rem = sysex.len() - i; + if rem >= 3 { + if rem == 3 && sysex[sysex.len() - 1] == 0xF7 { + out.push([0x07, sysex[i], sysex[i + 1], sysex[i + 2]]).ok(); // end with 3 + i += 3; + } else { + out.push([0x04, sysex[i], sysex[i + 1], sysex[i + 2]]).ok(); // start/continue + i += 3; + } + } else if rem == 2 { + out.push([0x06, sysex[i], sysex[i + 1], 0x00]).ok(); // end with 2 + i += 2; + } else { + out.push([0x05, sysex[i], 0x00, 0x00]).ok(); // end with 1 + i += 1; + } + } + out +} + +pub fn extract_sysex_payload(data: &[u8]) -> Option> { + use heapless::Vec; + let mut out: Vec = Vec::new(); + + for chunk in data.chunks_exact(4) { + let cin = chunk[0] & 0x0F; + let b1 = chunk[1]; + let b2 = chunk[2]; + let b3 = chunk[3]; + + match cin { + 0x4 => { + // SysEx continue/start (3 byte data) + for &b in &[b1, b2, b3] { + if b != 0 { + out.push(b).ok(); + } + } + } + 0x5 => { + if b1 != 0 { + out.push(b1).ok(); + } + } // end with 1 + 0x6 => { + for &b in &[b1, b2] { + if b != 0 { + out.push(b).ok(); + } + } + } // end with 2 + 0x7 => { + for &b in &[b1, b2, b3] { + if b != 0 { + out.push(b).ok(); + } + } + } // end with 3 + _ => {} + } + } + + if out.first().copied() == Some(0xF0) && out.last().copied() == Some(0xF7) { + let mut payload: Vec = Vec::new(); + payload.extend_from_slice(&out[1..out.len() - 1]).ok()?; + Some(payload) + } else { + None + } +} diff --git a/firmware/src/usb/mod.rs b/firmware/src/usb/mod.rs index 62cd45c..96183f9 100644 --- a/firmware/src/usb/mod.rs +++ b/firmware/src/usb/mod.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2025 hexaTune LLC // SPDX-License-Identifier: MIT +use core::fmt::Write; use defmt::info; use embassy_usb::class::midi::MidiClass; use embassy_usb::{Builder, Config}; @@ -51,12 +52,76 @@ pub async fn dev_task(mut dev: MyUsbDevice<'static>) { } #[embassy_executor::task] -pub async fn usb_io_task(mut midi: MyMidiClass<'static>) { +pub async fn usb_io_task( + mut midi: MyMidiClass<'static>, + dispatcher: &'static crate::at::AtDispatcher, +) { let mut buf = [0; 64]; loop { let n = midi.read_packet(&mut buf).await.unwrap(); let data = &buf[..n]; - info!("data: {:x}", data); - midi.write_packet(data).await.unwrap(); + info!("Received MIDI packet: {:?}", data); + if let Some(payload) = crate::sysex::extract_sysex_payload(data) { + if let Ok(input) = core::str::from_utf8(&payload) { + let response = dispatcher.dispatch(input); + info!("Received AT command: {}", input); + info!("AT response: {}", response.as_str()); + if let Some(sysex) = crate::sysex::build_sysex::<64>(&response) { + let packets = crate::sysex::sysex_to_usb_midi_packets::<64>(&sysex); + for pkt in packets.iter() { + match midi.write_packet(pkt).await { + Ok(_) => { + info!("Sent AT response via SysEx"); + } + Err(e) => { + info!("Failed to send MIDI packet: {:?}", e); + } + } + } + } else { + info!("Response too long to fit in SysEx"); + } + } else { + let error = crate::error::Error::InvalidUtf8.description(); + let encoded = crate::b64::encode(error); + let mut response = heapless::String::<64>::new(); + write!(response, "AT+ERROR={}", encoded).unwrap(); + if let Some(sysex) = crate::sysex::build_sysex::<64>(&response) { + let packets = crate::sysex::sysex_to_usb_midi_packets::<64>(&sysex); + for pkt in packets.iter() { + match midi.write_packet(pkt).await { + Ok(_) => { + info!("Sent AT response via SysEx"); + } + Err(e) => { + info!("Failed to send MIDI packet: {:?}", e); + } + } + } + } else { + info!("Error response too long to fit in SysEx"); + } + } + } else { + let error = crate::error::Error::InvalidSysEx.description(); + let encoded = crate::b64::encode(error); + let mut response = heapless::String::<64>::new(); + write!(response, "AT+ERROR={}", encoded).unwrap(); + if let Some(sysex) = crate::sysex::build_sysex::<64>(&response) { + let packets = crate::sysex::sysex_to_usb_midi_packets::<64>(&sysex); + for pkt in packets.iter() { + match midi.write_packet(pkt).await { + Ok(_) => { + info!("Sent AT response via SysEx"); + } + Err(e) => { + info!("Failed to send MIDI packet: {:?}", e); + } + } + } + } else { + info!("Error response too long to fit in SysEx"); + } + } } }