|
| 1 | +//! ESP-NOW command frame parsing — tag-byte envelope with zero-copy payload. |
| 2 | +//! |
| 3 | +//! Every ESP-NOW command is a single-byte tag followed by an optional payload. |
| 4 | +//! Tags are partitioned into two ranges: |
| 5 | +//! |
| 6 | +//! - **System tags** (`0xF0..=0xFF`) — reserved for infrastructure commands |
| 7 | +//! (`Ping`, `SelfTest`, `Identify`). Unrecognised system tags parse as |
| 8 | +//! [`SystemCommand::Unknown`] and are reserved for future use. |
| 9 | +//! - **Module tags** (`0x01..=0xEF`) — available for application-specific |
| 10 | +//! commands defined by each peripheral module. |
| 11 | +//! - Tag `0x00` is reserved and never used. [`parse_frame`] still accepts |
| 12 | +//! it so lower-level inspection works, but both [`is_system_tag`] and |
| 13 | +//! [`is_module_tag`] return `false`; callers should reject it. |
| 14 | +//! |
| 15 | +//! `CommandFrame` borrows from the payload slice — no heap, no allocator. |
| 16 | +//! The sender MAC address is intentionally excluded (ADR 010); transport |
| 17 | +//! metadata is threaded as a sidecar at the dispatch site. |
| 18 | +
|
| 19 | +/// A parsed command frame borrowing from a raw ESP-NOW payload. |
| 20 | +/// |
| 21 | +/// The frame is a single tag byte followed by zero or more payload bytes. |
| 22 | +/// Use [`parse_frame`] to construct. |
| 23 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 24 | +pub struct CommandFrame<'a> { |
| 25 | + /// Command tag identifying the operation. |
| 26 | + pub tag: u8, |
| 27 | + /// Payload bytes following the tag (may be empty). |
| 28 | + pub payload: &'a [u8], |
| 29 | +} |
| 30 | + |
| 31 | +/// Parses a raw ESP-NOW payload into a [`CommandFrame`]. |
| 32 | +/// |
| 33 | +/// Returns `None` if `data` is empty (if no tag byte is present). |
| 34 | +pub fn parse_frame(data: &[u8]) -> Option<CommandFrame<'_>> { |
| 35 | + let (&tag, payload) = data.split_first()?; |
| 36 | + Some(CommandFrame { tag, payload }) |
| 37 | +} |
| 38 | + |
| 39 | +/// Returns `true` if `tag` falls in the system range (`0xF0..=0xFF`). |
| 40 | +pub fn is_system_tag(tag: u8) -> bool { |
| 41 | + tag >= 0xF0 |
| 42 | +} |
| 43 | + |
| 44 | +/// Returns `true` if `tag` falls in the module range (`0x01..=0xEF`). |
| 45 | +pub fn is_module_tag(tag: u8) -> bool { |
| 46 | + (0x01..=0xEF).contains(&tag) |
| 47 | +} |
| 48 | + |
| 49 | +// ─── System commands ─────────────────────────────────────────────────────── |
| 50 | + |
| 51 | +/// System-level command tags. |
| 52 | +pub const TAG_PING: u8 = 0xF0; |
| 53 | +/// System-level command tag for self-test. |
| 54 | +pub const TAG_SELF_TEST: u8 = 0xF1; |
| 55 | +/// System-level command tag for identify. |
| 56 | +pub const TAG_IDENTIFY: u8 = 0xF2; |
| 57 | + |
| 58 | +/// A parsed system command. |
| 59 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 60 | +pub enum SystemCommand { |
| 61 | + /// Health probe — peer responds with a pong. |
| 62 | + Ping, |
| 63 | + /// Request a self-test result from the peripheral. |
| 64 | + SelfTest, |
| 65 | + /// Request module type and version identification. |
| 66 | + Identify, |
| 67 | + /// An unrecognised system tag (reserved for future use). |
| 68 | + Unknown(u8), |
| 69 | +} |
| 70 | + |
| 71 | +/// Parses a system command from a [`CommandFrame`]. |
| 72 | +/// |
| 73 | +/// Returns `None` if the frame's tag is not in the system range. |
| 74 | +pub fn parse_system_command(frame: &CommandFrame<'_>) -> Option<SystemCommand> { |
| 75 | + if !is_system_tag(frame.tag) { |
| 76 | + return None; |
| 77 | + } |
| 78 | + Some(match frame.tag { |
| 79 | + TAG_PING => SystemCommand::Ping, |
| 80 | + TAG_SELF_TEST => SystemCommand::SelfTest, |
| 81 | + TAG_IDENTIFY => SystemCommand::Identify, |
| 82 | + other => SystemCommand::Unknown(other), |
| 83 | + }) |
| 84 | +} |
| 85 | + |
| 86 | +// ─── Response helpers ────────────────────────────────────────────────────── |
| 87 | + |
| 88 | +/// Pong response payload: tag `0xF0`, status `0x01`. |
| 89 | +pub const PONG_RESPONSE: [u8; 2] = [TAG_PING, 0x01]; |
| 90 | + |
| 91 | +/// Self-test pass response: tag `0xF1`, result `0x00`. |
| 92 | +pub const SELF_TEST_PASS: [u8; 2] = [TAG_SELF_TEST, 0x00]; |
| 93 | + |
| 94 | +/// Self-test fail response: tag `0xF1`, result `0x01`. |
| 95 | +pub const SELF_TEST_FAIL: [u8; 2] = [TAG_SELF_TEST, 0x01]; |
| 96 | + |
| 97 | +/// Builds an identify response: tag `0xF2` + module type + version. |
| 98 | +pub fn identify_response(module_type: u8, version: u8) -> [u8; 3] { |
| 99 | + [TAG_IDENTIFY, module_type, version] |
| 100 | +} |
| 101 | + |
| 102 | +// ─── Tests ───────────────────────────────────────────────────────────────── |
| 103 | + |
| 104 | +#[cfg(test)] |
| 105 | +mod tests { |
| 106 | + use super::*; |
| 107 | + |
| 108 | + // ── parse_frame ──────────────────────────────────────────────────── |
| 109 | + |
| 110 | + #[test] |
| 111 | + fn parse_frame_empty_returns_none() { |
| 112 | + assert!(parse_frame(&[]).is_none()); |
| 113 | + } |
| 114 | + |
| 115 | + #[test] |
| 116 | + fn parse_frame_single_byte() { |
| 117 | + let frame = parse_frame(&[0xF0]).unwrap(); |
| 118 | + assert_eq!(frame.tag, 0xF0); |
| 119 | + assert!(frame.payload.is_empty()); |
| 120 | + } |
| 121 | + |
| 122 | + #[test] |
| 123 | + fn parse_frame_with_payload() { |
| 124 | + let frame = parse_frame(&[0x01, 0xAA, 0xBB]).unwrap(); |
| 125 | + assert_eq!(frame.tag, 0x01); |
| 126 | + assert_eq!(frame.payload, &[0xAA, 0xBB]); |
| 127 | + } |
| 128 | + |
| 129 | + #[test] |
| 130 | + fn parse_frame_reserved_zero_tag() { |
| 131 | + let frame = parse_frame(&[0x00, 0x01]).unwrap(); |
| 132 | + assert_eq!(frame.tag, 0x00); |
| 133 | + assert!(!is_system_tag(frame.tag)); |
| 134 | + assert!(!is_module_tag(frame.tag)); |
| 135 | + } |
| 136 | + |
| 137 | + // ── tag range predicates ─────────────────────────────────────────── |
| 138 | + |
| 139 | + #[test] |
| 140 | + fn system_tag_range() { |
| 141 | + assert!(is_system_tag(0xF0)); |
| 142 | + assert!(is_system_tag(0xFF)); |
| 143 | + assert!(!is_system_tag(0xEF)); |
| 144 | + assert!(!is_system_tag(0x00)); |
| 145 | + } |
| 146 | + |
| 147 | + #[test] |
| 148 | + fn module_tag_range() { |
| 149 | + assert!(is_module_tag(0x01)); |
| 150 | + assert!(is_module_tag(0xEF)); |
| 151 | + assert!(!is_module_tag(0x00)); |
| 152 | + assert!(!is_module_tag(0xF0)); |
| 153 | + } |
| 154 | + |
| 155 | + #[test] |
| 156 | + fn tag_zero_is_neither_system_nor_module() { |
| 157 | + assert!(!is_system_tag(0x00)); |
| 158 | + assert!(!is_module_tag(0x00)); |
| 159 | + } |
| 160 | + |
| 161 | + // ── parse_system_command ─────────────────────────────────────────── |
| 162 | + |
| 163 | + #[test] |
| 164 | + fn parse_ping() { |
| 165 | + let frame = parse_frame(&[TAG_PING]).unwrap(); |
| 166 | + assert_eq!(parse_system_command(&frame), Some(SystemCommand::Ping)); |
| 167 | + } |
| 168 | + |
| 169 | + #[test] |
| 170 | + fn parse_self_test() { |
| 171 | + let frame = parse_frame(&[TAG_SELF_TEST]).unwrap(); |
| 172 | + assert_eq!(parse_system_command(&frame), Some(SystemCommand::SelfTest)); |
| 173 | + } |
| 174 | + |
| 175 | + #[test] |
| 176 | + fn parse_identify() { |
| 177 | + let frame = parse_frame(&[TAG_IDENTIFY]).unwrap(); |
| 178 | + assert_eq!(parse_system_command(&frame), Some(SystemCommand::Identify)); |
| 179 | + } |
| 180 | + |
| 181 | + #[test] |
| 182 | + fn parse_unknown_system_tag() { |
| 183 | + let frame = parse_frame(&[0xF5]).unwrap(); |
| 184 | + assert_eq!( |
| 185 | + parse_system_command(&frame), |
| 186 | + Some(SystemCommand::Unknown(0xF5)) |
| 187 | + ); |
| 188 | + } |
| 189 | + |
| 190 | + #[test] |
| 191 | + fn module_tag_returns_none_for_system_command() { |
| 192 | + let frame = parse_frame(&[0x01, 0xAA]).unwrap(); |
| 193 | + assert!(parse_system_command(&frame).is_none()); |
| 194 | + } |
| 195 | + |
| 196 | + #[test] |
| 197 | + fn reserved_zero_returns_none_for_system_command() { |
| 198 | + let frame = parse_frame(&[0x00]).unwrap(); |
| 199 | + assert!(parse_system_command(&frame).is_none()); |
| 200 | + } |
| 201 | + |
| 202 | + // ── response helpers ─────────────────────────────────────────────── |
| 203 | + |
| 204 | + #[test] |
| 205 | + fn pong_response_correct() { |
| 206 | + assert_eq!(PONG_RESPONSE, [0xF0, 0x01]); |
| 207 | + } |
| 208 | + |
| 209 | + #[test] |
| 210 | + fn self_test_pass_response_correct() { |
| 211 | + assert_eq!(SELF_TEST_PASS, [0xF1, 0x00]); |
| 212 | + } |
| 213 | + |
| 214 | + #[test] |
| 215 | + fn self_test_fail_response_correct() { |
| 216 | + assert_eq!(SELF_TEST_FAIL, [0xF1, 0x01]); |
| 217 | + } |
| 218 | + |
| 219 | + #[test] |
| 220 | + fn identify_response_correct() { |
| 221 | + let resp = identify_response(0x42, 0x03); |
| 222 | + assert_eq!(resp, [0xF2, 0x42, 0x03]); |
| 223 | + } |
| 224 | + |
| 225 | + #[test] |
| 226 | + fn identify_response_roundtrip() { |
| 227 | + let resp = identify_response(0x10, 0x02); |
| 228 | + let frame = parse_frame(&resp).unwrap(); |
| 229 | + assert_eq!(frame.tag, TAG_IDENTIFY); |
| 230 | + assert_eq!(frame.payload, &[0x10, 0x02]); |
| 231 | + } |
| 232 | + |
| 233 | + // ── response parsing roundtrips ──────────────────────────────────── |
| 234 | + |
| 235 | + #[test] |
| 236 | + fn pong_response_parses_as_ping_command() { |
| 237 | + let frame = parse_frame(&PONG_RESPONSE).unwrap(); |
| 238 | + assert_eq!(parse_system_command(&frame), Some(SystemCommand::Ping)); |
| 239 | + assert_eq!(frame.payload, &[0x01]); |
| 240 | + } |
| 241 | +} |
0 commit comments