Skip to content

Commit 529c082

Browse files
committed
Add feature for ESP-NOW Peripheral Command Framework
1 parent 6c2f1c4 commit 529c082

7 files changed

Lines changed: 634 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Added
1515

16+
- `espnow-pure`: `PeerTracker` — heartbeat-based peer liveness tracker with online/offline transition detection, extracted from rustbox-rgb-puzzle brain firmware
1617
- `espnow-pure`: `ScanConfig::with_probe_timeout()` and `DEFAULT_PROBE_TIMEOUT` (100 ms) — per-channel probe timeout is now configurable
1718
- `espnow-pure`: `ScanConfig::with_burst_timeout()` and `DEFAULT_BURST_TIMEOUT` (3 s) — bounds total time the radio spends at boosted TX power during peer discovery
1819
- `wifi-pure`: `TxPowerLevel` enum (`Lowest`, `Low`, `Medium`, `High`, `Max`) with `to_quarter_dbm()` mapping to ESP-IDF quarter-dBm values; `WiFiConfig::with_tx_power()` builder method (see `docs/features/wifi-radio-power-config-v1.md`)
20+
- `rustyfarian-esp-idf-wifi`: applies `TxPowerLevel` via `esp_wifi_set_max_tx_power()` after Wi-Fi start
21+
- `rustyfarian-esp-hal-wifi`: stores `TxPowerLevel` config; logs warning that `esp-radio 0.17` does not expose TX power API
22+
- `rustyfarian-esp-idf-espnow`: `scan_for_peer()` auto-bursts TX power to maximum during channel scanning, restores previous level after scan completes
23+
- `espnow-pure`: `command` module — `CommandFrame<'a>` zero-copy parser, `SystemCommand` enum (`Ping`, `SelfTest`, `Identify`), tag range helpers, and response payload builders for the ESP-NOW Peripheral Command Framework (see `docs/features/espnow-peripheral-command-framework-v1.md`)
24+
- Justfile: `check-wifi-hal-embassy` recipe that verifies the `embassy` feature compiles for ESP32-C6 (`riscv32imac-unknown-none-elf`) and ESP32-C3 (`riscv32imc-unknown-none-elf`)
1925
- `rustyfarian-esp-hal-wifi`: `EspHalWifiManager` with real `WifiDriver` implementation using `esp-radio 0.17.0` for bare-metal ESP32-C3/C6 (ADR 006 Phase 5); `hal_c3_connect` and `hal_c6_connect` examples
2026
- `rustyfarian-network-pure`: `status_colors` module with shared LED colour palette (`BOOT`, `WIFI_CONNECTING`, `MQTT_CONNECTING`, `CONNECTED`, `ERROR`, `OFFLINE`)
2127
- `rustyfarian-esp-idf-mqtt`: `MqttBuilder::build_and_wait()` with `StatusLed` support for visual boot feedback (cyan pulse while connecting, green on success, red on timeout)

crates/espnow-pure/src/command.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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+
}

crates/espnow-pure/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! - [`EspNowDriver`] — hardware-agnostic ESP-NOW driver interface
66
//! - [`EspNowEvent`] — received frame (fixed-size, no heap)
77
//! - [`PeerConfig`] — peer registration parameters
8+
//! - [`PeerTracker`] — heartbeat-based peer liveness tracking
89
//! - [`mock::MockEspNowDriver`] — test double for host-side unit tests
910
//! (requires the `mock` feature or `#[cfg(test)]`)
1011
//!
@@ -16,8 +17,16 @@
1617
1718
#![no_std]
1819

20+
pub mod command;
1921
#[cfg(any(test, feature = "mock"))]
2022
pub mod mock;
23+
pub mod tracker;
24+
25+
pub use command::{
26+
parse_frame, parse_system_command, CommandFrame, SystemCommand, TAG_IDENTIFY, TAG_PING,
27+
TAG_SELF_TEST,
28+
};
29+
pub use tracker::PeerTracker;
2130

2231
// ─── Constants ──────────────────────────────────────────────────────────────
2332

0 commit comments

Comments
 (0)