817 lines
28 KiB
Rust
817 lines
28 KiB
Rust
// WIP mesh/transport protocol — suppress dead code warnings
|
|
#![allow(dead_code)]
|
|
//! Meshcore binary frame protocol: constants, encoding, decoding, command builders.
|
|
//!
|
|
//! Frame format (USB serial):
|
|
//! - Outbound (host -> device): `<` (0x3C) + 2-byte LE length + frame data
|
|
//! - Inbound (device -> host): `>` (0x3E) + 2-byte LE length + frame data
|
|
//! - Baud: 115200, 8N1
|
|
//! - Max message payload: 160 bytes
|
|
|
|
use anyhow::Result;
|
|
|
|
// --- Frame markers ---
|
|
pub const OUTBOUND_MARKER: u8 = 0x3C; // '<' (host -> device)
|
|
pub const INBOUND_MARKER: u8 = 0x3E; // '>' (device -> host)
|
|
|
|
// --- Commands (host -> device) ---
|
|
pub const CMD_APP_START: u8 = 0x01;
|
|
pub const CMD_SEND_TXT_MSG: u8 = 0x02;
|
|
pub const CMD_SEND_CHANNEL_TXT_MSG: u8 = 0x03;
|
|
pub const CMD_GET_CONTACTS: u8 = 0x04;
|
|
pub const CMD_GET_DEVICE_TIME: u8 = 0x05;
|
|
pub const CMD_SET_DEVICE_TIME: u8 = 0x06;
|
|
pub const CMD_SEND_SELF_ADVERT: u8 = 0x07;
|
|
pub const CMD_SET_ADVERT_NAME: u8 = 0x08;
|
|
pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
|
|
/// CMD_RESET_PATH (0x0D): Tell the firmware to drop the stored route for
|
|
/// a contact and fall back to flood routing (out_path_len = 0xFF). Used to
|
|
/// unstick direct messages to contacts whose `path_len=0` means "no route
|
|
/// known" — without this, the firmware silently drops outbound TXT_MSG
|
|
/// frames to such contacts.
|
|
pub const CMD_RESET_PATH: u8 = 0x0D;
|
|
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
|
|
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
|
|
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
|
|
pub const CMD_DEVICE_QUERY: u8 = 0x16;
|
|
pub const CMD_GET_CHANNEL: u8 = 0x1F;
|
|
pub const CMD_SET_CHANNEL: u8 = 0x20;
|
|
pub const CMD_GET_STATS: u8 = 0x38;
|
|
|
|
// --- Response codes (device -> host, synchronous) ---
|
|
pub const RESP_OK: u8 = 0x00;
|
|
pub const RESP_ERR: u8 = 0x01;
|
|
pub const RESP_CONTACT_START: u8 = 0x02;
|
|
pub const RESP_CONTACT: u8 = 0x03;
|
|
pub const RESP_CONTACT_END: u8 = 0x04;
|
|
pub const RESP_SELF_INFO: u8 = 0x05;
|
|
pub const RESP_SENT: u8 = 0x06;
|
|
pub const RESP_CONTACT_MSG: u8 = 0x07;
|
|
pub const RESP_CHANNEL_MSG: u8 = 0x08;
|
|
pub const RESP_CURRENT_TIME: u8 = 0x09;
|
|
pub const RESP_NO_MORE_MESSAGES: u8 = 0x0A;
|
|
pub const RESP_CONTACT_URI: u8 = 0x0B;
|
|
pub const RESP_BATTERY: u8 = 0x0C;
|
|
pub const RESP_DEVICE_INFO: u8 = 0x0D;
|
|
pub const RESP_CONTACT_MSG_V3: u8 = 0x10;
|
|
pub const RESP_CHANNEL_MSG_V3: u8 = 0x11;
|
|
pub const RESP_CHANNEL_INFO: u8 = 0x12;
|
|
pub const RESP_STATS: u8 = 0x18;
|
|
|
|
// --- Push notification codes (device -> host, async, >= 0x80) ---
|
|
pub const PUSH_CONTACT_ADVERT: u8 = 0x80;
|
|
pub const PUSH_PATH_UPDATE: u8 = 0x81;
|
|
pub const PUSH_ACK: u8 = 0x82;
|
|
pub const PUSH_MESSAGES_WAITING: u8 = 0x83;
|
|
pub const PUSH_RAW_DATA: u8 = 0x84;
|
|
pub const PUSH_LOG_DATA: u8 = 0x88;
|
|
pub const PUSH_NEW_CONTACT: u8 = 0x8A;
|
|
|
|
// --- Error codes ---
|
|
pub const ERR_UNSUPPORTED_CMD: u8 = 0x01;
|
|
pub const ERR_NOT_FOUND: u8 = 0x02;
|
|
pub const ERR_TABLE_FULL: u8 = 0x03;
|
|
pub const ERR_BAD_STATE: u8 = 0x04;
|
|
pub const ERR_FILE_IO: u8 = 0x05;
|
|
pub const ERR_ILLEGAL_ARG: u8 = 0x06;
|
|
|
|
/// Maximum payload size for a single LoRa message.
|
|
pub const MAX_MESSAGE_LEN: usize = 160;
|
|
|
|
/// Marker byte for "direct message wrapped as channel broadcast". Our
|
|
/// meshcore devices can hear each other's channel broadcasts (via
|
|
/// repeater flooding) but direct unicast frames don't reach between
|
|
/// archipelago nodes — so we emulate DMs by sending them on the shared
|
|
/// channel with a recipient pubkey-prefix header. Format:
|
|
/// `[DM_VIA_CHANNEL_MARKER][dest_pubkey_prefix(6B)][inner_payload…]`
|
|
/// The inner payload is whatever we would have sent directly — a typed
|
|
/// envelope, a chunked MC frame, or plain text.
|
|
pub const DM_VIA_CHANNEL_MARKER: u8 = 0xD1;
|
|
|
|
/// Minimum frame size: marker (1) + length (2) + command/response (1) = 4 bytes.
|
|
const MIN_FRAME_SIZE: usize = 4;
|
|
|
|
/// Protocol version we advertise during handshake.
|
|
const PROTOCOL_VERSION: u8 = 3;
|
|
|
|
// ─── Frame encoding ─────────────────────────────────────────────────────
|
|
|
|
/// Encode a command frame for sending to the device.
|
|
/// Returns: `>` + 2-byte LE length + data
|
|
pub fn encode_frame(data: &[u8]) -> Vec<u8> {
|
|
let len = data.len() as u16;
|
|
let mut frame = Vec::with_capacity(3 + data.len());
|
|
frame.push(OUTBOUND_MARKER);
|
|
frame.extend_from_slice(&len.to_le_bytes());
|
|
frame.extend_from_slice(data);
|
|
frame
|
|
}
|
|
|
|
/// Result of parsing one inbound frame from the device.
|
|
#[derive(Debug)]
|
|
pub struct InboundFrame {
|
|
/// Response or push notification code (first byte of payload).
|
|
pub code: u8,
|
|
/// Remaining payload after the code byte.
|
|
pub data: Vec<u8>,
|
|
/// Total bytes consumed from the buffer (for advancing read position).
|
|
pub bytes_consumed: usize,
|
|
}
|
|
|
|
/// Try to parse one inbound frame from a buffer.
|
|
/// Returns `None` if the buffer doesn't contain a complete frame yet.
|
|
pub fn decode_frame(buf: &[u8]) -> Option<InboundFrame> {
|
|
if buf.len() < MIN_FRAME_SIZE {
|
|
return None;
|
|
}
|
|
|
|
// Find the inbound marker
|
|
let start = buf.iter().position(|&b| b == INBOUND_MARKER)?;
|
|
let remaining = &buf[start..];
|
|
|
|
if remaining.len() < 3 {
|
|
return None;
|
|
}
|
|
|
|
let len = u16::from_le_bytes([remaining[1], remaining[2]]) as usize;
|
|
let total = 3 + len; // marker + 2 length bytes + payload
|
|
|
|
if remaining.len() < total {
|
|
return None; // incomplete frame
|
|
}
|
|
|
|
if len == 0 {
|
|
return None; // empty payload is invalid
|
|
}
|
|
|
|
let payload = &remaining[3..total];
|
|
let code = payload[0];
|
|
let data = payload[1..].to_vec();
|
|
|
|
Some(InboundFrame {
|
|
code,
|
|
data,
|
|
bytes_consumed: start + total,
|
|
})
|
|
}
|
|
|
|
// ─── Command builders ───────────────────────────────────────────────────
|
|
|
|
/// CMD_DEVICE_QUERY (0x16): Query device capabilities and negotiate protocol version.
|
|
pub fn build_device_query() -> Vec<u8> {
|
|
encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION])
|
|
}
|
|
|
|
/// CMD_APP_START (0x01): Initialize communication session.
|
|
/// Format matches official meshcore_py: [0x01][version][padded_name]
|
|
/// The official library sends: b"\x01\x03 mccli"
|
|
pub fn build_app_start(app_name: &str) -> Vec<u8> {
|
|
let mut data = vec![CMD_APP_START, PROTOCOL_VERSION];
|
|
// Pad name to 6 chars minimum (matching official library behavior)
|
|
let name_bytes = app_name.as_bytes();
|
|
let padded_len = name_bytes.len().max(6);
|
|
let len = padded_len.min(32);
|
|
// Pad with spaces if name is shorter than 6 chars
|
|
for i in 0..len {
|
|
if i < name_bytes.len() {
|
|
data.push(name_bytes[i]);
|
|
} else {
|
|
data.push(b' ');
|
|
}
|
|
}
|
|
encode_frame(&data)
|
|
}
|
|
|
|
/// CMD_SET_DEVICE_TIME (0x06): Sync device clock with Unix timestamp.
|
|
pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
|
|
let mut data = vec![CMD_SET_DEVICE_TIME];
|
|
data.extend_from_slice(&(unix_secs as u32).to_le_bytes());
|
|
encode_frame(&data)
|
|
}
|
|
|
|
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
|
|
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
|
|
let mut data = vec![CMD_SET_ADVERT_NAME];
|
|
let name_bytes = name.as_bytes();
|
|
let len = name_bytes.len().min(32);
|
|
data.extend_from_slice(&name_bytes[..len]);
|
|
encode_frame(&data)
|
|
}
|
|
|
|
/// CMD_SEND_TXT_MSG (0x02): Send a text message to a specific contact.
|
|
/// Destination is the first 6 bytes of the contact's public key (hex decoded).
|
|
/// Format: 0x02 + 0x00 (txt_type) + attempt(1B) + timestamp(4B LE) + dest_prefix(6B) + text
|
|
pub fn build_send_text(dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<Vec<u8>> {
|
|
if msg.len() > MAX_MESSAGE_LEN {
|
|
anyhow::bail!(
|
|
"Message too large for LoRa: {} bytes (max {})",
|
|
msg.len(),
|
|
MAX_MESSAGE_LEN
|
|
);
|
|
}
|
|
let timestamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as u32;
|
|
|
|
let mut data = vec![CMD_SEND_TXT_MSG, 0x00, 0x00]; // cmd + txt_type=0 + attempt=0
|
|
data.extend_from_slice(×tamp.to_le_bytes());
|
|
data.extend_from_slice(dest_pubkey_prefix);
|
|
data.extend_from_slice(msg);
|
|
Ok(encode_frame(&data))
|
|
}
|
|
|
|
/// CMD_SEND_CHANNEL_TXT_MSG (0x03): Broadcast a text message on a channel.
|
|
/// Frame layout per meshcore companion protocol:
|
|
/// `[0x03][txt_type=0][channel][timestamp_le32][text…]`
|
|
/// The txt_type and timestamp fields are mandatory — without them the
|
|
/// firmware rejects the command with ERR_UNSUPPORTED.
|
|
pub fn build_send_channel_text(channel: u8, msg: &[u8]) -> Result<Vec<u8>> {
|
|
if msg.len() > MAX_MESSAGE_LEN {
|
|
anyhow::bail!(
|
|
"Message too large for LoRa: {} bytes (max {})",
|
|
msg.len(),
|
|
MAX_MESSAGE_LEN
|
|
);
|
|
}
|
|
let timestamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as u32)
|
|
.unwrap_or(0);
|
|
let mut data = vec![CMD_SEND_CHANNEL_TXT_MSG, 0x00, channel];
|
|
data.extend_from_slice(×tamp.to_le_bytes());
|
|
data.extend_from_slice(msg);
|
|
Ok(encode_frame(&data))
|
|
}
|
|
|
|
/// CMD_GET_CONTACTS (0x04): Request the contact list from the device.
|
|
pub fn build_get_contacts() -> Vec<u8> {
|
|
encode_frame(&[CMD_GET_CONTACTS])
|
|
}
|
|
|
|
/// CMD_RESET_PATH (0x0D): `[0x0D][pub_key:32]`. Clears the stored route
|
|
/// for a contact so subsequent sends route via flood instead of being
|
|
/// silently dropped.
|
|
pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec<u8> {
|
|
let mut data = vec![CMD_RESET_PATH];
|
|
data.extend_from_slice(pubkey);
|
|
encode_frame(&data)
|
|
}
|
|
|
|
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
|
|
pub fn build_sync_next_message() -> Vec<u8> {
|
|
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
|
|
}
|
|
|
|
/// CMD_SEND_SELF_ADVERT (0x07): Broadcast our advertisement to the mesh.
|
|
pub fn build_send_self_advert() -> Vec<u8> {
|
|
encode_frame(&[CMD_SEND_SELF_ADVERT])
|
|
}
|
|
|
|
/// CMD_GET_STATS (0x38): Request device statistics.
|
|
pub fn build_get_stats() -> Vec<u8> {
|
|
encode_frame(&[CMD_GET_STATS])
|
|
}
|
|
|
|
// ─── Response parsers ───────────────────────────────────────────────────
|
|
|
|
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
|
/// Returns firmware version string and device capabilities.
|
|
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
|
// Device info format varies by firmware version.
|
|
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
|
|
if data.is_empty() {
|
|
anyhow::bail!("Empty device info response");
|
|
}
|
|
|
|
// Find null terminator for version string, or use all data as version
|
|
let version_end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
|
|
let version = String::from_utf8_lossy(&data[..version_end]).to_string();
|
|
|
|
let max_contacts = if data.len() > version_end + 2 {
|
|
u16::from_le_bytes([data[version_end + 1], data[version_end + 2]])
|
|
} else {
|
|
100 // default
|
|
};
|
|
|
|
Ok((version, max_contacts))
|
|
}
|
|
|
|
/// Parse RESP_SELF_INFO (0x05) response.
|
|
/// Returns (node_id, advert_name).
|
|
pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
|
if data.len() < 4 {
|
|
anyhow::bail!("Self info response too short: {} bytes", data.len());
|
|
}
|
|
|
|
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
|
|
|
// Name follows after fixed fields — find it by scanning for printable ASCII
|
|
let name_start = 4;
|
|
let name = if data.len() > name_start {
|
|
let name_end = data[name_start..]
|
|
.iter()
|
|
.position(|&b| b == 0)
|
|
.map(|p| name_start + p)
|
|
.unwrap_or(data.len());
|
|
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
Ok((node_id, name))
|
|
}
|
|
|
|
/// Parsed contact from RESP_CONTACT (0x03).
|
|
#[derive(Clone)]
|
|
pub struct ParsedContact {
|
|
pub public_key_hex: String,
|
|
pub advert_name: String,
|
|
pub last_advert: u32,
|
|
pub contact_type: u8,
|
|
pub path_len: u8,
|
|
pub flags: u8,
|
|
}
|
|
|
|
/// Parse RESP_CONTACT (0x03) response.
|
|
/// Format: 32B pubkey + 1B type + 1B flags + 1B path_len + 64B path + 32B name + 4B last_advert + 4B lat + 4B lon + 4B lastmod
|
|
pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
|
if data.len() < 34 {
|
|
anyhow::bail!(
|
|
"Contact response too short: {} bytes (need >= 34)",
|
|
data.len()
|
|
);
|
|
}
|
|
|
|
let public_key_hex = hex::encode(&data[0..32]);
|
|
let contact_type = data[32];
|
|
let flags = if data.len() > 33 { data[33] } else { 0 };
|
|
let path_len = if data.len() > 34 { data[34] } else { 0 };
|
|
// path at data[35..99] (64 bytes)
|
|
// name at data[99..131] (32 bytes)
|
|
let name_start = 99.min(data.len());
|
|
let name_end = (name_start + 32).min(data.len());
|
|
let advert_name = if data.len() > name_start {
|
|
String::from_utf8_lossy(&data[name_start..name_end])
|
|
.trim_end_matches('\0')
|
|
.to_string()
|
|
} else {
|
|
format!("{}...", &public_key_hex[..8])
|
|
};
|
|
|
|
// last_advert at data[131..135]
|
|
let last_advert = if data.len() >= 135 {
|
|
u32::from_le_bytes([data[131], data[132], data[133], data[134]])
|
|
} else {
|
|
0
|
|
};
|
|
|
|
Ok(ParsedContact {
|
|
public_key_hex,
|
|
advert_name,
|
|
last_advert,
|
|
contact_type,
|
|
path_len,
|
|
flags,
|
|
})
|
|
}
|
|
|
|
/// Parse RESP_CONTACT_MSG_V3 (0x10) - private message.
|
|
/// Format: SNR(1B) + reserved(2B) + pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
|
|
/// Returns (sender_pubkey_prefix_hex, text, snr).
|
|
pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
|
|
if data.len() < 15 {
|
|
anyhow::bail!("Contact message too short: {} bytes", data.len());
|
|
}
|
|
let snr = data[0] as i8;
|
|
// data[1..3] reserved
|
|
let pubkey_prefix = hex::encode(&data[3..9]);
|
|
// data[9] = path_len
|
|
let txt_type = data[10];
|
|
// data[11..15] = timestamp
|
|
let text_start = if txt_type == 2 { 19 } else { 15 }; // skip 4-byte signature if txt_type==2
|
|
let text = if data.len() > text_start {
|
|
String::from_utf8_lossy(&data[text_start..]).to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
Ok((pubkey_prefix, text, snr))
|
|
}
|
|
|
|
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
|
|
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
|
|
/// Returns (channel_idx, text).
|
|
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
|
|
if data.len() < 7 {
|
|
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
|
}
|
|
let channel_idx = data[0];
|
|
// data[1] = path_len, data[2] = txt_type
|
|
// data[3..7] = timestamp
|
|
let text = if data.len() > 7 {
|
|
String::from_utf8_lossy(&data[7..])
|
|
.trim_end_matches('\0')
|
|
.to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
Ok((channel_idx, text))
|
|
}
|
|
|
|
/// Parse RESP_CONTACT_MSG (0x07) - v1 private message.
|
|
/// Format: pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
|
|
/// Returns (sender_pubkey_prefix_hex, text).
|
|
pub fn parse_contact_msg_v1(data: &[u8]) -> Result<(String, String)> {
|
|
if data.len() < 12 {
|
|
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
|
|
}
|
|
let pubkey_prefix = hex::encode(&data[0..6]);
|
|
// data[6] = path_len, data[7] = txt_type
|
|
let txt_type = data[7];
|
|
// data[8..12] = timestamp
|
|
let text_start = if txt_type == 2 { 16 } else { 12 };
|
|
let text = if data.len() > text_start {
|
|
String::from_utf8_lossy(&data[text_start..]).to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
Ok((pubkey_prefix, text))
|
|
}
|
|
|
|
/// Parse RESP_CHANNEL_MSG (0x08) - v1 channel message.
|
|
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
|
|
pub fn parse_channel_msg_v1(data: &[u8]) -> Result<(u8, String)> {
|
|
if data.len() < 7 {
|
|
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
|
|
}
|
|
let channel_idx = data[0];
|
|
// data[1] = path_len, data[2] = txt_type
|
|
// data[3..7] = timestamp
|
|
let text = if data.len() > 7 {
|
|
String::from_utf8_lossy(&data[7..])
|
|
.trim_end_matches('\0')
|
|
.to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
Ok((channel_idx, text))
|
|
}
|
|
|
|
// ─── Raw-bytes variants for typed message detection ────────────────────
|
|
|
|
/// Parse RESP_CONTACT_MSG_V3 returning raw payload bytes (not UTF-8 lossy).
|
|
/// Returns (sender_pubkey_prefix_hex, raw_payload_bytes, snr).
|
|
pub fn parse_contact_msg_v3_raw(data: &[u8]) -> Result<(String, Vec<u8>, i8)> {
|
|
if data.len() < 15 {
|
|
anyhow::bail!("Contact message too short: {} bytes", data.len());
|
|
}
|
|
let snr = data[0] as i8;
|
|
let pubkey_prefix = hex::encode(&data[3..9]);
|
|
let txt_type = data[10];
|
|
let text_start = if txt_type == 2 { 19 } else { 15 };
|
|
let payload = if data.len() > text_start {
|
|
data[text_start..].to_vec()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok((pubkey_prefix, payload, snr))
|
|
}
|
|
|
|
/// Parse RESP_CONTACT_MSG returning raw payload bytes.
|
|
/// Returns (sender_pubkey_prefix_hex, raw_payload_bytes).
|
|
pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
|
|
if data.len() < 12 {
|
|
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
|
|
}
|
|
let pubkey_prefix = hex::encode(&data[0..6]);
|
|
let txt_type = data[7];
|
|
let text_start = if txt_type == 2 { 16 } else { 12 };
|
|
let payload = if data.len() > text_start {
|
|
data[text_start..].to_vec()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok((pubkey_prefix, payload))
|
|
}
|
|
|
|
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
|
|
/// Returns (channel_idx, raw_payload_bytes).
|
|
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
|
|
if data.len() < 7 {
|
|
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
|
}
|
|
let channel_idx = data[0];
|
|
let payload = if data.len() > 7 {
|
|
let mut p = data[7..].to_vec();
|
|
// Strip trailing NUL bytes
|
|
while p.last() == Some(&0) {
|
|
p.pop();
|
|
}
|
|
p
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok((channel_idx, payload))
|
|
}
|
|
|
|
/// Parse RESP_CHANNEL_MSG returning raw payload bytes.
|
|
/// Returns (channel_idx, raw_payload_bytes).
|
|
pub fn parse_channel_msg_v1_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
|
|
if data.len() < 7 {
|
|
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
|
|
}
|
|
let channel_idx = data[0];
|
|
let payload = if data.len() > 7 {
|
|
let mut p = data[7..].to_vec();
|
|
while p.last() == Some(&0) {
|
|
p.pop();
|
|
}
|
|
p
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok((channel_idx, payload))
|
|
}
|
|
|
|
/// Parse RESP_ERR (0x01). Returns descriptive error string.
|
|
pub fn parse_error(data: &[u8]) -> String {
|
|
if data.is_empty() {
|
|
return "Unknown device error".to_string();
|
|
}
|
|
match data[0] {
|
|
ERR_UNSUPPORTED_CMD => "Unsupported command".to_string(),
|
|
ERR_NOT_FOUND => "Not found".to_string(),
|
|
ERR_TABLE_FULL => "Contact table full".to_string(),
|
|
ERR_BAD_STATE => "Bad device state".to_string(),
|
|
ERR_FILE_IO => "Device file I/O error".to_string(),
|
|
ERR_ILLEGAL_ARG => "Illegal argument".to_string(),
|
|
code => format!("Device error code 0x{:02x}", code),
|
|
}
|
|
}
|
|
|
|
/// Check if a response code is a push notification (async event from device).
|
|
pub fn is_push_notification(code: u8) -> bool {
|
|
code >= 0x80
|
|
}
|
|
|
|
// ─── Archipelago identity wire format ───────────────────────────────────
|
|
|
|
/// Prefix for Archipelago identity broadcasts over mesh channel.
|
|
pub const ARCHY_IDENTITY_PREFIX: &str = "ARCHY:1:";
|
|
|
|
/// Encode an Archipelago identity announcement for channel broadcast.
|
|
/// Compact format: `ARCHY:2:{ed25519_pubkey_hex}:{x25519_pubkey_hex}`
|
|
/// DID is omitted to fit within 160-byte LoRa limit — receiver reconstructs did:key from ed25519 pubkey.
|
|
/// Total: 8 + 64 + 1 + 64 = 137 bytes (fits in 160).
|
|
pub fn encode_identity_broadcast(
|
|
_did: &str,
|
|
ed_pubkey_hex: &str,
|
|
x25519_pubkey_hex: &str,
|
|
) -> String {
|
|
format!("ARCHY:2:{}:{}", ed_pubkey_hex, x25519_pubkey_hex)
|
|
}
|
|
|
|
/// Try to parse an Archipelago identity from a received channel message.
|
|
/// Returns (did, ed25519_pubkey_hex, x25519_pubkey_hex) if valid.
|
|
///
|
|
/// Supports two formats:
|
|
/// - v2 (compact): `ARCHY:2:{ed25519_hex_64}:{x25519_hex_64}` — DID reconstructed from ed25519
|
|
/// - v1 (legacy): `ARCHY:1:{did}:{ed25519_hex_64}:{x25519_hex_64}`
|
|
pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
|
|
// Try v2 compact format first
|
|
if let Some(rest) = msg.strip_prefix("ARCHY:2:") {
|
|
let parts: Vec<&str> = rest.splitn(2, ':').collect();
|
|
if parts.len() != 2 {
|
|
return None;
|
|
}
|
|
let ed_pubkey = parts[0];
|
|
let x25519_pubkey = parts[1];
|
|
if ed_pubkey.len() != 64 || x25519_pubkey.len() != 64 {
|
|
return None;
|
|
}
|
|
if !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit())
|
|
|| !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit())
|
|
{
|
|
return None;
|
|
}
|
|
// Reconstruct DID from ed25519 pubkey
|
|
let did = crate::identity::did_key_from_pubkey_hex(ed_pubkey).ok()?;
|
|
return Some((did, ed_pubkey.to_string(), x25519_pubkey.to_string()));
|
|
}
|
|
|
|
// Try v1 legacy format
|
|
let rest = msg.strip_prefix(ARCHY_IDENTITY_PREFIX)?;
|
|
let last_colon = rest.rfind(':')?;
|
|
let x25519_pubkey = &rest[last_colon + 1..];
|
|
if x25519_pubkey.len() != 64 || !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return None;
|
|
}
|
|
let before_x25519 = &rest[..last_colon];
|
|
let second_last_colon = before_x25519.rfind(':')?;
|
|
let ed_pubkey = &before_x25519[second_last_colon + 1..];
|
|
if ed_pubkey.len() != 64 || !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return None;
|
|
}
|
|
let did = &before_x25519[..second_last_colon];
|
|
if !did.starts_with("did:key:z") {
|
|
return None;
|
|
}
|
|
Some((
|
|
did.to_string(),
|
|
ed_pubkey.to_string(),
|
|
x25519_pubkey.to_string(),
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_encode_frame() {
|
|
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
|
|
assert_eq!(frame[0], OUTBOUND_MARKER);
|
|
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 2);
|
|
assert_eq!(frame[3], CMD_DEVICE_QUERY);
|
|
assert_eq!(frame[4], PROTOCOL_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_frame_complete() -> Result<()> {
|
|
// Simulate an inbound frame: < + len(2) + [RESP_OK]
|
|
let buf = vec![INBOUND_MARKER, 0x01, 0x00, RESP_OK];
|
|
let frame =
|
|
decode_frame(&buf).ok_or_else(|| anyhow::anyhow!("failed to parse complete frame"))?;
|
|
assert_eq!(frame.code, RESP_OK);
|
|
assert!(frame.data.is_empty());
|
|
assert_eq!(frame.bytes_consumed, 4);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_frame_with_data() -> Result<()> {
|
|
// < + len(5) + [RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04]
|
|
let buf = vec![
|
|
INBOUND_MARKER,
|
|
0x05,
|
|
0x00,
|
|
RESP_SELF_INFO,
|
|
0x01,
|
|
0x02,
|
|
0x03,
|
|
0x04,
|
|
];
|
|
let frame =
|
|
decode_frame(&buf).ok_or_else(|| anyhow::anyhow!("failed to parse frame with data"))?;
|
|
assert_eq!(frame.code, RESP_SELF_INFO);
|
|
assert_eq!(frame.data, vec![0x01, 0x02, 0x03, 0x04]);
|
|
assert_eq!(frame.bytes_consumed, 8);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_frame_incomplete() {
|
|
let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_OK]; // says 5 bytes but only 1
|
|
assert!(decode_frame(&buf).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_frame_no_marker() {
|
|
let buf = vec![0xFF, 0x01, 0x00, RESP_OK];
|
|
assert!(decode_frame(&buf).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_frame_skips_garbage() -> Result<()> {
|
|
// Garbage bytes before the actual frame
|
|
let buf = vec![0xFF, 0xAA, INBOUND_MARKER, 0x01, 0x00, RESP_OK];
|
|
let frame = decode_frame(&buf)
|
|
.ok_or_else(|| anyhow::anyhow!("failed to skip garbage and parse frame"))?;
|
|
assert_eq!(frame.code, RESP_OK);
|
|
assert_eq!(frame.bytes_consumed, 6); // 2 garbage + 4 frame
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_device_query() {
|
|
let frame = build_device_query();
|
|
assert_eq!(frame[0], OUTBOUND_MARKER);
|
|
assert_eq!(frame[3], CMD_DEVICE_QUERY);
|
|
assert_eq!(frame[4], PROTOCOL_VERSION);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_app_start() -> Result<()> {
|
|
// Frame layout: [0: '>'][1-2: len LE][3: CMD][4: VERSION][5..: padded name]
|
|
let frame = build_app_start("Archipelago");
|
|
assert_eq!(frame[3], CMD_APP_START);
|
|
assert_eq!(frame[4], PROTOCOL_VERSION);
|
|
let name = &frame[5..];
|
|
assert_eq!(
|
|
std::str::from_utf8(name)
|
|
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in app name: {}", e))?,
|
|
"Archipelago"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_set_device_time() {
|
|
let ts: u64 = 1710600000;
|
|
let frame = build_set_device_time(ts);
|
|
assert_eq!(frame[3], CMD_SET_DEVICE_TIME);
|
|
let time_bytes = &frame[4..8];
|
|
assert_eq!(
|
|
u32::from_le_bytes([time_bytes[0], time_bytes[1], time_bytes[2], time_bytes[3]]),
|
|
ts as u32
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_send_text() -> Result<()> {
|
|
let dest: [u8; 6] = [0x00, 0x00, 0x00, 0x2A, 0x00, 0x00];
|
|
let frame = build_send_text(&dest, b"hello")?;
|
|
assert_eq!(frame[3], CMD_SEND_TXT_MSG);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_send_text_too_large() {
|
|
let dest: [u8; 6] = [0x00; 6];
|
|
let big = vec![0u8; MAX_MESSAGE_LEN + 1];
|
|
assert!(build_send_text(&dest, &big).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_send_channel_text() -> Result<()> {
|
|
let frame = build_send_channel_text(2, b"test")?;
|
|
// Frame: [marker][len_lo][len_hi][cmd][txt_type][channel][ts(4)][text]
|
|
assert_eq!(frame[3], CMD_SEND_CHANNEL_TXT_MSG);
|
|
assert_eq!(frame[4], 0); // txt_type
|
|
assert_eq!(frame[5], 2); // channel idx
|
|
// frame[6..10] = timestamp, non-deterministic
|
|
assert_eq!(&frame[10..], b"test");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_identity_broadcast_roundtrip() -> Result<()> {
|
|
// The v2 encoding drops the DID and the decoder reconstructs it
|
|
// deterministically from the ed25519 pubkey, so the roundtripped
|
|
// DID won't equal an arbitrary input DID. Derive what the decoder
|
|
// will produce and assert against that.
|
|
let ed_pub = "a".repeat(64);
|
|
let x25519_pub = "b".repeat(64);
|
|
let expected_did = crate::identity::did_key_from_pubkey_hex(&ed_pub)
|
|
.map_err(|e| anyhow::anyhow!("derive did: {}", e))?;
|
|
|
|
let encoded = encode_identity_broadcast(&expected_did, &ed_pub, &x25519_pub);
|
|
|
|
let (parsed_did, parsed_ed, parsed_x) = parse_identity_broadcast(&encoded)
|
|
.ok_or_else(|| anyhow::anyhow!("failed to parse identity broadcast"))?;
|
|
assert_eq!(parsed_did, expected_did);
|
|
assert_eq!(parsed_ed, ed_pub);
|
|
assert_eq!(parsed_x, x25519_pub);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_identity_broadcast_invalid() {
|
|
assert!(parse_identity_broadcast("not an identity").is_none());
|
|
assert!(parse_identity_broadcast("ARCHY:1:bad").is_none());
|
|
assert!(parse_identity_broadcast("ARCHY:1:did:key:z123:short:short").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_error_codes() {
|
|
assert_eq!(parse_error(&[ERR_NOT_FOUND]), "Not found");
|
|
assert_eq!(parse_error(&[ERR_TABLE_FULL]), "Contact table full");
|
|
assert_eq!(parse_error(&[]), "Unknown device error");
|
|
assert!(parse_error(&[0xFF]).contains("0xff"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_push_notification() {
|
|
assert!(is_push_notification(PUSH_NEW_CONTACT));
|
|
assert!(is_push_notification(PUSH_ACK));
|
|
assert!(is_push_notification(0x80));
|
|
assert!(!is_push_notification(RESP_OK));
|
|
assert!(!is_push_notification(RESP_DEVICE_INFO));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_self_info() -> Result<()> {
|
|
let mut data = vec![0x2A, 0x00, 0x00, 0x00]; // node_id = 42
|
|
data.extend_from_slice(b"TestNode\0");
|
|
let (id, name) = parse_self_info(&data)?;
|
|
assert_eq!(id, 42);
|
|
assert_eq!(name, "TestNode");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_self_info_too_short() {
|
|
assert!(parse_self_info(&[0x01, 0x02]).is_err());
|
|
}
|
|
}
|