Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions firmware/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions firmware/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions firmware/src/at/commands.rs
Original file line number Diff line number Diff line change
@@ -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<String<16>, Handler, 8> {
let mut map: LinearMap<String<16>, 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
}
57 changes: 57 additions & 0 deletions firmware/src/at/handler.rs
Original file line number Diff line number Diff line change
@@ -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<heapless::String<64>, 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<heapless::String<64>, 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<heapless::String<64>, crate::error::Error> {
if params.len() != 2 {
return Err(crate::error::Error::ParamCount);
}
let a = params[0]
.parse::<i32>()
.map_err(|_| crate::error::Error::ParamValue)?;
let b = params[1]
.parse::<i32>()
.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)
}
}
74 changes: 74 additions & 0 deletions firmware/src/at/mod.rs
Original file line number Diff line number Diff line change
@@ -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<heapless::String<16>, 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
}
}
}
}
80 changes: 80 additions & 0 deletions firmware/src/at/parser.rs
Original file line number Diff line number Diff line change
@@ -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<heapless::String<16>, 8>,
pub is_query: bool,
}

/// Parse AT command string into name, params, and query flag
pub fn parse(input: &str) -> Result<ParsedAtCommand, crate::error::Error> {
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 = &param_str[1..];
let mut params = Vec::<String<16>, 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::<String<16>, 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::<String<16>, 8>::new(),
is_query: false,
})
}
}
29 changes: 29 additions & 0 deletions firmware/src/b64/mod.rs
Original file line number Diff line number Diff line change
@@ -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<String<64>, 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
}
29 changes: 29 additions & 0 deletions firmware/src/error/mod.rs
Original file line number Diff line number Diff line change
@@ -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",
}
}
}
Loading
Loading