// 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 { 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, /// 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 { 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 { 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 { 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 { 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 { 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> { 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> { 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 { 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 { 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 { encode_frame(&[CMD_SYNC_NEXT_MESSAGE]) } /// CMD_SEND_SELF_ADVERT (0x07): Broadcast our advertisement to the mesh. pub fn build_send_self_advert() -> Vec { encode_frame(&[CMD_SEND_SELF_ADVERT]) } /// CMD_GET_STATS (0x38): Request device statistics. pub fn build_get_stats() -> Vec { 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 { 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, 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)> { 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)> { 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)> { 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()); } }