Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
radio_soft_phy_driver_base.cpp
Go to the documentation of this file.
1/// @file radio_soft_phy_driver_base.cpp
2/// @brief Shared driver flow implementation for the software-PHY radios.
3/// @ingroup hioc_radio
4///
5/// See radio_soft_phy_driver_base.h for the architectural context. This file holds the shared
6/// RX/TX orchestration for both software-PHY drivers, parameterized over the small set of virtual
7/// primitives/hooks the two chips genuinely differ on.
8
9// Opcode payloads and recovery thresholds are written in the same shape as the chip protocol
10// and on-air framing they reproduce.
11// NOLINTBEGIN(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
12
14#include "log_frame.h"
15#include "radio_poll.h"
16#include "esphome/core/log.h"
17#include "esphome/core/application.h"
18
19#include <algorithm>
20#include <cinttypes>
21#include <cstdio>
22
23namespace esphome {
24namespace home_io_control {
25
26static const char *const TAG = "home_io_control.soft_phy";
27
28namespace {
29
30/// Block until `raw_bytes` (plus @ref SOFT_PHY_EARLY_READ_MARGIN_BYTES) have had time to arrive
31/// since the sync word was observed at `sync_us`. Pure wall-clock waiting against the protocol's
32/// line rate — it reads no chip state, so it lives here rather than on the driver.
33/// @return false if the caller's `timeout_ms` window closed first.
34bool wait_for_air_time(uint32_t sync_us, uint8_t raw_bytes, uint32_t start_ms, uint32_t timeout_ms) {
35 uint32_t const needed_us = soft_phy_air_time_us((uint32_t) raw_bytes + SOFT_PHY_EARLY_READ_MARGIN_BYTES);
36 while (micros() - sync_us < needed_us) {
37 // Never outstay the window the caller asked for: a receive that has run out of time falls back
38 // to the RX_DONE path (which will time out on its own terms) rather than silently overrunning.
39 if (millis() - start_ms > timeout_ms)
40 return false;
41 App.feed_wdt();
42 delayMicroseconds(SOFT_PHY_EARLY_POLL_US);
43 }
44 return true;
45}
46
47} // namespace
48
49// === SPI transport helper shared by both chips ===
50
52 // Once a BUSY timeout has failed the driver, every later call would otherwise re-run the same
53 // timeout again — init()'s remaining configure_radio_() steps would each block for
54 // busy_timeout_ms_ before init() finally returns false. Short-circuit instead: the chip is
55 // already known-unresponsive, so there's nothing to wait for.
56 if (this->failed_)
57 return;
58 // Decide on a sample taken after the deadline (see poll_until): a task preempted past the
59 // timeout between the pin read and the clock read must not turn a chip that went ready meanwhile
60 // into a latched failure.
61 PollResult const r = poll_until([this] { return !this->busy_pin_->digital_read(); }, this->busy_timeout_ms_);
62 if (!r.ready) {
63 ESP_LOGE(TAG, "BUSY timeout after %" PRIu32 " ms", r.elapsed_ms);
64 this->fail_("BUSY pin stayed high -- check busy_pin, the SPI wiring and tcxo_voltage");
65 return;
66 }
67 if (r.elapsed_ms > this->busy_timeout_ms_) {
68 this->busy_overruns_++;
69 ESP_LOGD(TAG, "BUSY wait overran to %" PRIu32 " ms; chip ready (task preempted)", r.elapsed_ms);
70 }
71}
72
73// === Packet RX (blocking) ===
74
75bool SoftPhyDriverBase::wait_for_packet(RadioRxPacket &packet, uint32_t timeout_ms) {
76 // Blocking receive with timeout. This orchestrator decomposes the state machine into three
77 // low-complexity helpers, shared verbatim between SX1262 and LR1121.
78 this->prepare_blocking_receive_(packet);
79
80 uint32_t const start = millis();
81 uint32_t irq = 0;
82
83 // Phase 1: wait for first *terminal* activity (see activity_irq_mask()'s doc comment).
84 if (!this->poll_until_activity_(start, timeout_ms, irq)) {
85 return false;
86 }
87
88 // Phase 2: resolve the SYNC_WORD_VALID → RX_DONE race condition, and take the length-driven
89 // shortcut when the chip supports it.
90 bool early_completed = false;
91 if (!this->resolve_sync_race_(start, timeout_ms, irq, packet, early_completed)) {
92 return false;
93 }
94 if (early_completed) {
95 return true;
96 }
97
98 // Phase 3: finalize — either read the packet or treat as a failure.
99 return this->finalize_receive_(packet, irq);
100}
101
102/// Poll for first terminal radio activity (IRQ pin or an IRQ status bit within
103/// activity_irq_mask()) within timeout. `irq` is always freshly read before this returns, so
104/// callers never need a separate refresh step. A reading outside activity_irq_mask() (e.g.
105/// LR1121's preamble-only case) keeps the loop going rather than returning — the loop re-reads
106/// chip status via SPI every iteration regardless of the DIO edge, so nothing is lost.
107bool SoftPhyDriverBase::poll_until_activity_(uint32_t start, uint32_t timeout_ms, uint32_t &irq) {
108 while (true) {
109 if (this->is_dio_fired())
110 this->clear_dio_fired();
111 irq = this->read_irq_status_raw();
112 if ((irq & this->activity_irq_mask()) != 0) {
113 this->preamble_hold_active_ = false; // the frame the hold was waiting for has arrived
114 return true;
115 }
116 if (millis() - start > timeout_ms) {
117 this->clear_dio_fired();
118 // A preamble on air: keep RX (and the sticky bit the caller's linger guard reads) rather
119 // than restart it under the frame. See preamble_hold_since_ms_.
120 if (!this->hold_rx_for_preamble_(irq))
121 this->reset_rx_state_();
122 return false;
123 }
124 App.feed_wdt();
125 delay(1);
126 }
127}
128
129bool SoftPhyDriverBase::hold_rx_for_preamble_(uint32_t irq) {
130 if ((irq & this->preamble_detected_bit()) == 0)
131 return false;
132 const uint32_t now = millis();
133 if (!this->preamble_hold_active_) {
134 this->preamble_hold_active_ = true;
135 this->preamble_hold_since_ms_ = now;
136 return true;
137 }
138 // Past the cap the detection was noise, or a preamble too long to be an answer: let the caller
139 // reset RX (which ends the hold) and move on.
140 return now - this->preamble_hold_since_ms_ < SOFT_PHY_PREAMBLE_HOLD_MS;
141}
142
143/// Resolve the SYNC_WORD_VALID → RX_DONE race condition common to both chips: the sync-word IRQ
144/// can assert before the packet is fully received. If we observe SYNC without RX_DONE, clear the
145/// sticky SYNC flag and spin until RX_DONE arrives or the timeout, extended by
146/// SOFT_PHY_POST_SYNC_GRACE_MS once the sync is seen, elapses.
147bool SoftPhyDriverBase::resolve_sync_race_(uint32_t start, uint32_t timeout_ms, uint32_t &irq, RadioRxPacket &packet,
148 bool &early_completed) {
149 early_completed = false;
150 // If RX_DONE already set or SYNC not set, nothing to resolve.
151 if ((irq & this->sync_word_valid_bit()) == 0 || (irq & this->rx_done_bit()) != 0) {
152 return true;
153 }
154 // SYNC is the frame's own start marker, so from here everything about the frame's arrival is a
155 // question of air time. Timestamp it before any SPI work so the length-driven receive below
156 // measures from as close to the on-air event as this loop can see.
157 uint32_t const sync_us = micros();
158 // SYNC seen without RX_DONE — clear sticky SYNC and wait for RX_DONE.
160 // The frame has started: give it its air time even past the caller's deadline, or a sync that
161 // lands near the end of a dwell or linger loses the frame mid-reception.
162 timeout_ms = std::max(timeout_ms, (millis() - start) + SOFT_PHY_POST_SYNC_GRACE_MS);
163
164 if (this->try_early_completion_(packet, sync_us, irq, start, timeout_ms)) {
165 early_completed = true;
166 return true;
167 }
168 while (millis() - start <= timeout_ms) {
169 if (!this->is_dio_fired()) {
170 irq = this->read_irq_status_raw();
171 if ((irq & this->rx_done_bit()) != 0)
172 return true;
173 App.feed_wdt();
174 delay(1);
175 continue;
176 }
177 this->clear_dio_fired();
178 irq = this->read_irq_status_raw();
179 if ((irq & this->rx_done_bit()) != 0)
180 return true;
181 if (irq != 0)
182 this->clear_irq_status(irq);
183 }
184 return false; // timeout
185}
186
187/// Finish a reception on the frame's own air time instead of the chip's fixed-length RX_DONE.
188///
189/// Three stages, each of which can bail out harmlessly:
190/// 1. wait out the first UART cell and read it — CTRL0 carries the whole frame's length;
191/// 2. wait out exactly that frame's air time and read exactly its bytes;
192/// 3. run the normal UART probe and accept only a CRC-valid frame.
193///
194/// Stage 3 is the safety property that makes this worth doing at all. CRC-CCITT is a 1-in-65536
195/// gate, so a frame accepted here is a frame that would have been accepted at RX_DONE anyway —
196/// and *any* failure (a chip that turns out not to expose its buffer mid-reception, a spurious
197/// sync detect, a mis-guessed length, a bit error) simply returns false and leaves the caller on
198/// the original RX_DONE path, which re-reads the full buffer from scratch. A wrong guess here
199/// costs some latency; it can never cost a frame.
200bool SoftPhyDriverBase::try_early_completion_(RadioRxPacket &packet, uint32_t sync_us, uint32_t irq_status,
201 uint32_t start_ms, uint32_t timeout_ms) {
202 int16_t const base = this->early_rx_read_offset();
203 if (base < 0)
204 return false; // chip does not expose its data buffer mid-reception
205 if (timeout_ms < SOFT_PHY_EARLY_MIN_WINDOW_MS)
206 return false; // no room left in this window to receive a whole frame either way
207 auto const offset = (uint8_t) base;
208
209 // Stage 1: ten bits of air time is all it takes to learn how long the frame will be.
210 if (!wait_for_air_time(sync_us, SOFT_PHY_EARLY_HEADER_RAW_BYTES, start_ms, timeout_ms))
211 return false;
212 uint8_t header[SOFT_PHY_EARLY_HEADER_RAW_BYTES] = {0};
213 this->read_rx_buffer(offset, header, sizeof(header));
214 uint8_t const frame_len = soft_phy_peek_frame_length(header, sizeof(header));
215 if (frame_len == 0)
216 return false;
217
218 // Stage 2: wait out only what this frame needs, then take the whole thing. The margin bytes
219 // have been waited for either way, so read them too — they give the probe slack to work with
220 // when the stream is not byte-aligned.
221 uint8_t const frame_raw_len = soft_phy_raw_bytes_for_frame(frame_len);
222 if (!wait_for_air_time(sync_us, frame_raw_len, start_ms, timeout_ms))
223 return false;
224 uint8_t raw[RADIO_PACKET_BUFFER_SIZE] = {0};
225 auto const read_len =
226 (uint8_t) std::min<uint16_t>((uint16_t) frame_raw_len + SOFT_PHY_EARLY_READ_MARGIN_BYTES, sizeof(raw));
227 this->read_rx_buffer(offset, raw, read_len);
228
229 // Stage 3: CRC decides.
230 UartProbeResult const probe = find_uart_probe(raw, read_len);
231 if (!probe.valid)
232 return false;
233
234 memcpy(packet.data, probe.decoded + probe.frame_start, probe.frame_len);
235 packet.len = probe.frame_len;
236 packet.freq_hz = this->current_freq_;
237 // Reading packet status this early is still sound: the RSSI a driver reports from it is latched
238 // at sync-word detection, which by definition has already happened.
239 this->fill_capture_info(true, irq_status, offset, read_len, raw, read_len, packet.data, packet.len);
240
241#ifdef IOHOME_FRAME_LOG
242 frame_log_line(FrameLogLevel::DEBUG, TAG, "Early RX: frame_len=%u raw_len=%u air_us=%" PRIu32, frame_len, read_len,
243 (uint32_t) (micros() - sync_us));
244 log_frame("RX", packet.data, packet.len, this->current_freq_);
245#endif
246
247 // The reception this latch refers to is being torn down deliberately, so drop it rather than
248 // let a stale edge look like a fresh packet to the next wait.
249 this->clear_dio_fired();
250 this->reset_rx_state_();
251 return true;
252}
253
254/// Finalize receive: read the packet if RX_DONE is set, otherwise record failure.
255bool SoftPhyDriverBase::finalize_receive_(RadioRxPacket &packet, uint32_t irq) {
256 if ((irq & this->rx_done_bit()) == 0) {
257 this->fill_capture_info(true, irq, 0, 0, nullptr, 0, nullptr, 0);
258 this->reset_rx_state_();
259 return false;
260 }
261 return this->read_rx_packet(packet, true, irq);
262}
263
265 // The minimum needed to be listening again, because the peer can answer within a millisecond or
266 // two of our carrier dropping. Deliberately *not* reset_rx_state_(): that also issues SetStandby
267 // and re-writes the buffer base address, and on this path both are dead weight on the one code
268 // path where microseconds decide whether a fast reply is heard at all --
269 // - both drivers program SetRxTxFallbackMode = STDBY_XOSC at init, so the chip is already in
270 // standby the instant TxDone fires;
271 // - the buffer base is written at init and nothing since has moved it.
272 // What genuinely must happen: clear the latched TxDone (it shares the IRQ word with RX events),
273 // restore the RX packet params that this transmission overwrote, and re-enter RX. Also drop the
274 // hop holdoff (issue #81): whatever it was tracking belonged to the reception that this TX just
275 // destroyed by transmitting over it, and RX is genuinely re-arming here same as it does through
276 // reset_rx_state_() — unlike that call's SetStandby/buffer-base work, clearing the flag costs
277 // nothing, so there is no reason to leave it stale on this path.
278 //
279 // invalidate_stale_rx_content_after_tx() is the same shape: no-op on SX1262 (its buffer split
280 // already keeps TX content away from where a length-driven receive reads), so this path pays
281 // nothing extra there; only LR1121, whose shared buffer needs it, does real work here.
283 this->preamble_hold_active_ = false;
284 this->clear_irq_status(0xFFFFFFFF);
286 this->set_rx_packet_params();
287 this->set_mode_rx();
288}
289
291 // The SetPacketParams preamble field is bit-denominated on both chips (sx126x's
292 // preamble_len_in_bits / lr11xx's pbl_len_in_bit), but preamble_bytes arrives in bytes, matching
293 // every other layer in this codebase (LONG_PREAMBLE, SHORT_PREAMBLE, the tuning defaults, the
294 // SX1276's byte-wide RegPreambleMsb/Lsb). Convert here, once, at the last step before the wire —
295 // this is the conversion the 63e2502 fix had to patch separately in each driver.
296 const uint16_t preamble_bits = p.preamble_bytes * 8;
297 out[0] = static_cast<uint8_t>(preamble_bits >> 8); // Preamble length MSB
298 out[1] = static_cast<uint8_t>(preamble_bits); // Preamble length LSB
299 out[2] = p.preamble_detector; // Preamble detector length (chip constant)
300 out[3] = p.sync_word_param; // Sync word length: 24 bits (chip constant)
301 out[4] = 0x00; // Address comparison: off
302 out[5] = p.packet_type; // GFSK packet type: known/fixed length
303 out[6] = p.payload_len; // Configured payload length
304 out[7] = p.crc_type; // CRC mode (chip constant)
305 out[8] = 0x00; // Whitening: off
306}
307
308void SoftPhyDriverBase::reset_rx_state_(bool force_standby) {
309 // Whatever was arriving is over — delivered, timed out, or deliberately discarded. Drop the hop
310 // holdoff with it, rather than leaving the next ~12 ms of hopping waiting on a deadline that no
311 // longer refers to anything (issue #81). This funnel covers every "RX torn down and re-armed"
312 // path except rearm_rx_after_tx_(), which clears the same flag itself for the same reason (see
313 // its own comment for why it can't just call this function): poll_until_activity_()'s timeout,
314 // try_early_completion_()'s success, read_rx_packet()'s end, finalize_receive_()'s failure, and
315 // check_for_packet()'s catch-all.
317 this->preamble_hold_active_ = false;
318 if (force_standby)
319 this->set_mode_standby();
320 this->clear_irq_status(0xFFFFFFFF);
321 this->configure_buffer_base();
322 this->set_rx_packet_params();
323 this->set_mode_rx();
324}
325
326bool SoftPhyDriverBase::read_rx_packet(RadioRxPacket &packet, bool blocking_wait, uint32_t irq_status) {
327 uint8_t raw_reported_len = 0;
328 uint8_t rx_offset = 0;
329 this->get_rx_buffer_status(raw_reported_len, rx_offset);
330
331 uint8_t rx_buf[RADIO_PACKET_BUFFER_SIZE] = {0};
332 uint8_t recovered_buf[RADIO_PACKET_BUFFER_SIZE] = {0};
333 uint8_t const reported_len = std::min(raw_reported_len, (uint8_t) sizeof(rx_buf));
334 uint8_t raw_probe_len = reported_len;
335 if (reported_len > 0 && reported_len < 32) {
336 // When the chip reports a short packet length, still pull the full raw window: the useful
337 // UART-packed tail (e.g. a post-auth response) can sit past the chip-reported boundary, so
338 // trimming the probe to that boundary would lose it.
339 raw_probe_len = sizeof(rx_buf);
340 }
341 if (reported_len == SOFT_PHY_RX_PROBE_PACKET_LEN)
342 raw_probe_len = SOFT_PHY_RX_PROBE_PACKET_LEN;
343 if (raw_probe_len > 0)
344 this->read_rx_buffer(rx_offset, rx_buf, raw_probe_len);
345
346 // Neither chip exposes the already-decoded IO-homecontrol frame the way SX1276 does. We first
347 // capture the raw bytes exactly as reported by the chip, then recover the UART-packed protocol
348 // stream in software and only pass a plausible frame up to the parser. This software recovery
349 // path is the soft-PHY-specific adaptation to the same protocol.
350 UartProbeResult probe = find_uart_probe(rx_buf, raw_probe_len);
351 if (probe.valid) {
352 memcpy(recovered_buf, probe.decoded + probe.frame_start, probe.frame_len);
353 memcpy(packet.data, recovered_buf, probe.frame_len);
354 packet.len = probe.frame_len;
355#ifdef IOHOME_FRAME_LOG
356 // rx_offset is the chip-reported buffer offset this reception was read from — logged here
357 // (issue #81) because it is otherwise populated by every driver's fill_capture_info() and read
358 // by nothing, and it is the one fact that would confirm or correct LR1121_RX_BUFFER_BASE
359 // against a real LR1121 (see RadioLR1121::early_rx_read_offset's doc comment).
360 frame_log_line(FrameLogLevel::DEBUG, TAG,
361 "UART probe: valid=1 bit_offset=%u frame_start=%u frame_len=%u decoded_len=%u rx_offset=%u",
362 probe.bit_offset, probe.frame_start, probe.frame_len, probe.decoded_len, rx_offset);
363#endif
364 } else {
365#ifdef IOHOME_FRAME_LOG
366 // Log diagnostic info when CRC validation rejects all decode attempts — helps identify
367 // whether post-TX RX corruption is being correctly caught by CRC or slipping through.
368 char hex_buf[97] = {0}; // 32 bytes * 3 chars + null
369 uint8_t dump_len = std::min(raw_probe_len, (uint8_t) 32);
370 for (uint8_t i = 0; i < dump_len; i++)
371 snprintf(hex_buf + (i * 3), 4, "%02X ", rx_buf[i]);
372 frame_log_line(FrameLogLevel::WARN, TAG, "UART probe: valid=0 decoded_len=%u raw_probe_len=%u", probe.decoded_len,
373 raw_probe_len);
374 frame_log_line(FrameLogLevel::WARN, TAG, " raw[0..%u]: %s", dump_len - 1, hex_buf);
375 // Try to show why CRC failed at best offset
376 if (probe.decoded_len >= FRAME_MIN_SIZE) {
377 // FRAME_MAX_WIRE_SIZE, not FRAME_MAX_SIZE: this is the diagnostic that explains *why* a CRC
378 // check failed, so it must be able to reach a MAC-bearing 1W frame's longer non-CRC length
379 // (see IoFrame::has_mac) too — capped at the declared-only bound, this log would report a
380 // spurious mismatch for a frame that is actually fine, exactly during the bring-up it exists
381 // to help with.
382 int best_len = std::min<int>(probe.decoded_len, FRAME_MAX_WIRE_SIZE);
383 IoFrame test_frame;
384 for (int cl = best_len; cl >= FRAME_MIN_SIZE; cl--) {
385 if (!parse(probe.decoded, cl, test_frame))
386 continue;
387 if (cl + 2 <= (int) probe.decoded_len) {
388 uint16_t computed = crc_ccitt(probe.decoded, cl);
389 uint16_t received = (uint16_t) probe.decoded[cl] | ((uint16_t) probe.decoded[cl + 1] << 8);
390 frame_log_line(FrameLogLevel::WARN, TAG,
391 " CRC check: candidate_len=%d cmd=0x%02X crc_computed=0x%04X crc_received=0x%04X %s", cl,
392 test_frame.cmd, computed, received, computed == received ? "MATCH" : "MISMATCH");
393 }
394 break;
395 }
396 }
397#endif
398 // FRAME_MAX_WIRE_SIZE, not FRAME_MAX_SIZE: this fallback runs when no CRC-valid frame was
399 // found, so what's being copied is raw chip-reported bytes on a best-effort basis, not a
400 // frame known to lack a MAC trailer — capping to the declared-only bound would silently
401 // truncate a genuine MAC-bearing frame's tail before it ever reached find_uart_probe again.
402 uint8_t const copy_len = std::min(reported_len, FRAME_MAX_WIRE_SIZE);
403 if (copy_len > 0)
404 memcpy(packet.data, rx_buf, copy_len);
405 packet.len = copy_len;
406 }
407 packet.freq_hz = this->current_freq_;
408 this->fill_capture_info(blocking_wait, irq_status, rx_offset, reported_len, rx_buf, raw_probe_len, packet.data,
409 packet.len);
410
411#ifdef IOHOME_FRAME_LOG
412 if (packet.len > 0)
413 log_frame("RX", packet.data, packet.len, this->current_freq_);
414#endif
415 this->reset_rx_state_();
416 return packet.len > 0;
417}
418
419// === Packet RX (non-blocking) ===
420
422 if (!this->is_dio_fired())
423 return false;
424 this->prepare_nonblocking_receive_(packet);
425
426 uint32_t const irq = this->read_irq_status_raw();
427
428 if ((irq & this->activity_irq_mask()) == 0) {
429 // A reading outside activity_irq_mask() (e.g. preamble-only on a chip that routes it to the
430 // IRQ pin) means a frame may still be arriving. Clear just that bit instead of calling
431 // reset_rx_state_() below, which would tear down RX mid-reception. Unlike
432 // poll_until_activity_(), this function has no internal retry loop, so leaving the chip-level
433 // bit set would starve later loop() ticks of the DIO edge they need to notice the real
434 // RX_DONE. On chips whose activity_irq_mask() is "any bit" (the default), this branch can
435 // never trigger — that hardware has already filtered such readings out before they get here.
436 if (irq != 0)
437 this->clear_irq_status(irq);
438 return false;
439 }
440
441 if ((irq & this->sync_word_valid_bit()) != 0 && (irq & this->rx_done_bit()) == 0) {
442 // A frame is genuinely arriving. Record it so maybe_hop() (which runs a few lines up the
443 // caller's stack in loop()) holds the idle-path hop off instead of retuning under it —
444 // change_frequency() clears the whole IRQ word and the DIO latch, so a frame that loses that
445 // race is destroyed outright, not merely delayed (issue #81). A fresh is_sync_detected() read
446 // from maybe_hop() would not work here: the clear_irq_status() call right below has already
447 // dropped the sync bit by the time maybe_hop() could look at it, so the observation has to be
448 // captured now, before it disappears. The holdoff expires on its own; see RX_HOP_HOLDOFF_US.
449 //
450 // Past the holdoff arm above, finish the reception here instead of leaving it to the RX_DONE
451 // ~10 ms away and a loop() pass after that (issue #81, Mechanism B). sync_us is timestamped now,
452 // not when the sync word actually landed on air, so it can be a whole loop period stale — that
453 // only ever makes wait_for_air_time() (inside try_early_completion_()) wait longer than the
454 // frame needed, never shorter, so reading the buffer late is harmless while reading it early
455 // would not be. Timestamped before the SPI clear below for the same reason resolve_sync_race_()
456 // does it: keep the reference as close to the on-air event as this loop can see.
457 uint32_t const sync_us = micros();
458 uint32_t const start_ms = millis();
461 // Falling through here is not a lost frame: try_early_completion_()'s failure paths issue only
462 // reads; nothing re-arms, retunes, or clears an IRQ, so the caller is free to fall back to the
463 // ordinary RX_DONE path. try_early_completion_() can itself block for up to
464 // idle_rx_completion_budget_ms() (~9.4 ms worst case), which eats into the holdoff armed above
465 // before this function even returns — on the fall-through path, re-arm it fresh right here so
466 // maybe_hop() (a few lines up the caller's stack in loop()) still sees the full holdoff window
467 // instead of whatever fraction survived this call, and can't retune under a frame whose RX_DONE
468 // hasn't been read yet. The success path does not need this: try_early_completion_() already
469 // clears the holdoff itself via reset_rx_state_() once the frame is fully recovered.
470 bool const completed =
471 this->try_early_completion_(packet, sync_us, irq, start_ms, this->idle_rx_completion_budget_ms());
472 if (!completed)
474 return completed;
475 }
476
477 if ((irq & this->rx_done_bit()) != 0) {
478 return this->read_rx_packet(packet, false, irq);
479 }
480
481 this->fill_capture_info(false, irq, 0, 0, nullptr, 0, nullptr, 0);
482 this->reset_rx_state_();
483 return false;
484}
485
486// === Packet TX ===
487
488TxResult SoftPhyDriverBase::send_packet(const uint8_t *data, uint8_t len, const RadioTxConfig &tx_config) {
489 if (len == 0)
490 return TxResult::failed();
491 // Measured in RX, before standby: the check needs the receiver running on the TX channel.
492 if (auto busy = this->channel_busy_level_(tx_config, SOFT_PHY_CCA_SETTLE_US))
493 return TxResult::channel_busy(*busy);
494
495#ifdef IOHOME_FRAME_LOG
496 log_frame("TX", data, len, tx_config.freq_hz, tx_config.preamble_len);
497#endif
498
499 this->set_mode_standby();
500 this->set_frequency_register(tx_config.freq_hz);
501
502 // FRAME_MAX_WIRE_SIZE already includes the CRC bytes (declared + trailer + CRC), so this holds
503 // `len` (which may itself include a serialize()-emitted MAC trailer, see IoFrame::has_mac) plus
504 // its CRC without truncating a frame this driver is asked to transmit.
505 uint8_t frame_with_crc[FRAME_MAX_WIRE_SIZE] = {0};
506 uint8_t tx_buf[RADIO_PACKET_BUFFER_SIZE];
507 if ((uint16_t) len + 2 > (uint16_t) sizeof(frame_with_crc))
508 return TxResult::failed();
509
510 memcpy(frame_with_crc, data, len);
511 const uint16_t crc = crc_ccitt(data, len);
512 frame_with_crc[len] = crc & 0xFF;
513 frame_with_crc[len + 1] = (crc >> 8) & 0xFF;
514
515 const uint8_t encoded_len = uart_encode_packet(frame_with_crc, len + 2, tx_buf, sizeof(tx_buf));
516 if (encoded_len == 0)
517 return TxResult::failed();
518
519 this->set_tx_packet_params(tx_config.preamble_len, encoded_len);
520
521 this->clear_irq_status(0xFFFFFFFF);
522 this->write_tx_buffer(tx_buf, encoded_len);
523
524 // Chip-specific pre-TX workaround hook (no-op on chips that don't need one).
525 this->before_tx_arm();
526
527 this->clear_dio_fired();
528 this->start_tx();
529
530 // Wait for an actual TxDone IRQ. The IRQ pin is shared with RX-related events, so a stale or
531 // unrelated interrupt must not be treated as TX completion. The wait is bounded by the frame's
532 // own air time plus a guard, so a chip that stopped transmitting costs a fraction of a second.
533 uint32_t const air_time_us = this->tx_air_time_us(len, tx_config);
534 uint32_t const timeout_ms = tx_done_timeout_ms(air_time_us);
535 uint32_t const start = millis();
536 uint32_t tx_irq = 0;
537 while (true) {
538 if (!this->is_dio_fired()) {
539 if (millis() - start > timeout_ms) {
540 // Read before standby, so the log shows the state the chip was left in by the TX.
541 tx_irq = this->read_irq_status_raw();
542 ESP_LOGE(TAG,
543 "TX timeout after %" PRIu32 " ms (air time %" PRIu32 " us) — DIO/IRQ pin never fired, IRQ status "
544 "0x%08" PRIX32,
545 timeout_ms, air_time_us, tx_irq);
546 this->log_tx_timeout_state(tx_irq);
547 this->set_mode_standby();
548 return TxResult::failed();
549 }
550 App.feed_wdt();
551 delayMicroseconds(100);
552 continue;
553 }
554
555 this->clear_dio_fired();
556 tx_irq = this->read_irq_status_raw();
557 if ((tx_irq & this->tx_done_bit()) != 0)
558 break;
559
560 if (tx_irq != 0) {
561 this->clear_irq_status(tx_irq);
562 }
563
564 if (millis() - start > timeout_ms) {
565 ESP_LOGE(TAG, "TX timeout after %" PRIu32 " ms — no TX_DONE IRQ (last_irq=0x%08" PRIX32 ")", timeout_ms, tx_irq);
566 this->log_tx_timeout_state(tx_irq);
567 this->set_mode_standby();
568 return TxResult::failed();
569 }
570 }
571 // TxDone used the same DIO/IRQ latch as RX. Clear the local latch before re-arming RX so an
572 // immediate reply remains visible to wait_for_packet().
573 this->clear_dio_fired();
574
575#ifdef IOHOME_FRAME_LOG
576 uint32_t const tx_done_us = micros();
577#endif
578 this->rearm_rx_after_tx_();
579
580 // Post-TX settling delay: the GFSK demodulator needs time to stabilize after the TX→STDBY→RX
581 // transition. Without this, frames received immediately after TX (e.g. the 0x3C challenge
582 // during pairing) can suffer UART decode bit errors before the demodulator's frequency
583 // discrimination has settled. Runtime-tunable per chip. The radio is already armed by this
584 // point, so a frame arriving during the delay is still captured in hardware.
585 delayMicroseconds(this->post_tx_settle_us_);
586
587 // A peer can reply within a millisecond or two of our carrier dropping, so re-arm time is the
588 // margin the whole exchange lives on: if it outlasts the peer's turnaround, the reply is not
589 // late, it is never heard at all, and no response-window length can recover it. Behind the
590 // frame-log flag with the rest of the PHY-level instrumentation because it fires on *every*
591 // transmission; the timing calls compile out with it.
592#ifdef IOHOME_FRAME_LOG
593 frame_log_line(FrameLogLevel::DEBUG, TAG, "TX->RX re-arm: %" PRIu32 " us (+%u us settle)", micros() - tx_done_us,
594 this->post_tx_settle_us_);
595#endif
596
597 return TxResult::ok();
598}
599
600// === Frequency control ===
601
603 this->preamble_hold_active_ = false; // a preamble held on the old channel says nothing about this one
604 this->set_mode_standby();
605 this->set_frequency_register(freq_hz);
606 this->clear_irq_status(0xFFFFFFFF); // Clear stale preamble/sync bits from previous channel
607 this->clear_dio_fired(); // Clear stale IRQ latch from previous channel activity
608 this->set_mode_rx();
609}
610
611// === RSSI / sync / preamble ===
612
614
616
620
621// === Device-error decoding ===
622
623void format_device_error_bits(uint16_t errors, const DeviceErrorBit *bits, size_t bit_count, char *buf,
624 size_t buf_size) {
625 if (buf == nullptr || buf_size == 0)
626 return;
627 buf[0] = '\0';
628 if (errors == 0) {
629 snprintf(buf, buf_size, "none");
630 return;
631 }
632
633 size_t pos = 0;
634 uint16_t named = 0;
635 for (size_t i = 0; i < bit_count; i++) {
636 if ((errors & bits[i].mask) == 0)
637 continue;
638 named |= bits[i].mask;
639 const int n = snprintf(buf + pos, buf_size - pos, "%s%s", pos > 0 ? "|" : "", bits[i].name);
640 if (n <= 0 || static_cast<size_t>(n) >= buf_size - pos)
641 return; // buffer full — leave what fit, already NUL-terminated by snprintf
642 pos += static_cast<size_t>(n);
643 }
644
645 const uint16_t unknown = errors & static_cast<uint16_t>(~named);
646 if (unknown != 0)
647 snprintf(buf + pos, buf_size - pos, "%sUNKNOWN_0x%04X", pos > 0 ? "|" : "", unknown);
648}
649
650void SoftPhyDriverBase::record_init_device_errors_(const char *tag, const char *chip_label, uint16_t errors,
651 DeviceErrorFormatter format) {
652 this->init_device_errors_ = errors;
653 if (errors == 0)
654 return;
655 // The boot line reaches serial captures; dump_init_device_errors_() repeats it for API clients.
656 char errbuf[DEVICE_ERROR_STR_SIZE];
657 format(errors, errbuf, sizeof(errbuf));
658 ESP_LOGW(tag, "%s device errors after init: 0x%04X (%s)", chip_label, errors, errbuf);
659}
660
662 if (this->init_device_errors_ == 0)
663 return;
664 // configure_radio_() clears the chip's register at its end, so a live "Device errors" line reads
665 // none even when bring-up hit a calibration, PLL or TCXO fault.
666 char errbuf[DEVICE_ERROR_STR_SIZE];
667 format(this->init_device_errors_, errbuf, sizeof(errbuf));
668 ESP_LOGCONFIG(tag, " Init device errors (cleared after init): 0x%04X (%s)", this->init_device_errors_, errbuf);
669}
670
671} // namespace home_io_control
672} // namespace esphome
673
674// NOLINTEND(cppcoreguidelines-avoid-magic-numbers,readability-magic-numbers)
bool is_dio_fired() const
Set by the ISR when DIO fires.
void note_reception_in_progress_()
Record that a frame is arriving right now.
virtual void set_mode_standby()=0
Switch to standby mode.
void clear_reception_in_progress_()
Drop the holdoff: the reception ended, was delivered, or was torn down deliberately.
std::optional< int16_t > channel_busy_level_(const RadioTxConfig &tx_config, uint32_t settle_us)
The clear-channel check a driver runs at the top of send_packet().
void fail_(const char *reason)
Latch the failed state and record why, so is_failed and failure_reason can never disagree.
void prepare_nonblocking_receive_(RadioRxPacket &packet)
Common preamble for non‑blocking receive: clear diagnostics, output packet, and DIO latch.
bool failed_
Latched by fail_; see is_failed.
void prepare_blocking_receive_(RadioRxPacket &packet)
Common preamble for blocking receive: clear diagnostics and output packet.
virtual void set_mode_rx()=0
Switch to continuous receive mode.
virtual uint32_t read_irq_status_raw()=0
Read the raw IRQ status word from the radio.
virtual void set_frequency_register(uint32_t freq_hz)=0
Set RF frequency via the chip's own frequency register/opcode encoding, and update current_freq_.
void wait_busy_()
Wait until busy_pin_ reads low, feeding the watchdog while polling.
virtual void get_rx_buffer_status(uint8_t &reported_len, uint8_t &rx_offset)=0
Read the chip-reported RX length and buffer offset (raw, before any clamping).
bool is_preamble_detected() override
Check if preamble has been detected (while in RX).
virtual void read_rx_buffer(uint8_t offset, uint8_t *data, uint8_t len)=0
Read len bytes from the RX buffer starting at offset.
virtual bool read_rx_packet(RadioRxPacket &packet, bool blocking_wait, uint32_t irq_status)
Read a received packet from the buffer and return the raw bytes reported by the chip.
void dump_init_device_errors_(const char *tag, DeviceErrorFormatter format) const
Log the init-time device errors under tag, or nothing when there were none.
int16_t raw_rssi_to_dbm_(uint8_t raw) const
Convert a chip's raw RSSI byte to an antenna-referred dBm reading.
virtual uint32_t rx_done_bit() const =0
void rearm_rx_after_tx_()
Minimal path back into RX immediately after a transmission — see the definition for why this is delib...
bool is_sync_detected() override
Check if sync word has been detected (while in RX).
TxResult send_packet(const uint8_t *data, uint8_t len, const RadioTxConfig &tx_config) override
Send a packet using the specified carrier frequency and preamble settings.
virtual uint32_t preamble_detected_bit() const =0
static void build_gfsk_packet_params(const SoftPhyPacketParams &p, uint8_t out[GFSK_PACKET_PARAMS_LEN])
Fill the nine-byte GFSK SetPacketParams payload shared by both chips.
virtual void configure_buffer_base()
Hook run as part of reset_rx_state_, before re-entering RX.
virtual void set_rx_packet_params()=0
Configure RX-specific packet parameters (preamble detector length, fixed probe length).
uint32_t tx_air_time_us(uint8_t len, const RadioTxConfig &cfg) const override
Transmit time: the preamble, sync word and UART-coded frame at the protocol line rate.
virtual uint32_t sync_word_valid_bit() const =0
virtual void fill_capture_info(bool blocking_wait, uint32_t irq_status, uint8_t rx_offset, uint8_t reported_len, const uint8_t *raw, uint8_t raw_len, const uint8_t *frame, uint8_t frame_len)=0
Populate the RadioCaptureInfo from chip-specific telemetry (RSSI opcode, packet-status byte,...
virtual void clear_irq_status(uint32_t irq_mask)=0
Clear IRQ status bits.
uint16_t init_device_errors_
Device-error word a driver reads at the end of configure_radio_(), before it clears the chip's regist...
virtual void set_tx_packet_params(uint16_t preamble_len, uint8_t payload_len)=0
Configure TX packet parameters for one outgoing UART-encoded frame.
void reset_rx_state_(bool force_standby=true)
Reset RX state machine and buffer. Optionally force standby first.
virtual uint32_t tx_done_bit() const =0
virtual uint8_t read_rssi_raw_byte()=0
Read the single raw RSSI byte (chip-specific opcode); formula is shared, see read_rssi.
virtual void log_tx_timeout_state(uint32_t irq_status)
Log the chip's own state after a transmission that never reported TX-done, before the driver forces s...
int16_t read_rssi() override
Read instantaneous RSSI (in dBm) on the current channel, while in RX mode.
virtual void before_tx_arm()
Hook run immediately before every SetTx.
virtual void invalidate_stale_rx_content_after_tx()
Hook run from rearm_rx_after_tx_, after a transmission and before re-entering RX.
InternalGPIOPin * busy_pin_
BUSY line, read directly by both concrete drivers' own dump_debug() in addition to wait_busy_,...
static constexpr uint8_t GFSK_PACKET_PARAMS_LEN
Byte count of the GFSK SetPacketParams payload — identical on both software-PHY chips.
virtual uint32_t idle_rx_completion_budget_ms() const
Blocking budget for the idle-path length-driven receive, in milliseconds (issue #81).
virtual void write_tx_buffer(const uint8_t *data, uint8_t len)=0
Write the UART-encoded TX payload into the chip's TX buffer.
void record_init_device_errors_(const char *tag, const char *chip_label, uint16_t errors, DeviceErrorFormatter format)
Keep the device-error word read at the end of configure_radio_() and warn if it is non-zero.
virtual int16_t early_rx_read_offset() const
Data-buffer offset an in-flight reception is being written to, or a negative value when this chip mus...
virtual uint32_t activity_irq_mask() const
IRQ bits that count as "activity" for the internal poll_until_activity_() helper and check_for_packet...
bool check_for_packet(RadioRxPacket &packet) override
Non-blocking check for a received packet.
bool wait_for_packet(RadioRxPacket &packet, uint32_t timeout_ms) override
Wait (blocking) for a packet with timeout.
virtual void start_tx()=0
Issue the SetTx opcode with the fixed TX timeout — identical 3-byte payload on both chips,...
void change_frequency(uint32_t freq_hz) override
Change the carrier frequency using fast hop (no standby transition needed).
Shared frame logging helpers for IO-Homecontrol.
constexpr uint32_t soft_phy_air_time_us(uint32_t raw_bytes)
On-air time in microseconds for raw_bytes bytes at the protocol's line rate.
static constexpr const char * TAG
uint8_t soft_phy_peek_frame_length(const uint8_t *raw, uint8_t raw_len)
Recover a frame's total length from the very first UART cell of a reception.
uint8_t uart_encode_packet(const uint8_t *data, uint8_t len, uint8_t *encoded, uint8_t encoded_max_len)
UART-encode a buffer of bytes (start bit 0, 8 data bits LSB-first, stop bit 1).
PollResult poll_until(Ready ready, uint32_t timeout_ms)
Poll ready() until it is true or timeout_ms has passed, feeding the watchdog between samples.
Definition radio_poll.h:40
UartProbeResult find_uart_probe(const uint8_t *raw, uint8_t raw_len)
Search raw RX buffer for the best CRC-validated IO-Homecontrol frame.
uint16_t crc_ccitt(const uint8_t *data, uint8_t len)
CRC-CCITT used by the IO-Homecontrol protocol for frame validation.
uint8_t soft_phy_raw_bytes_for_frame(uint8_t frame_len)
Raw on-air bytes needed to carry a whole frame: frame_len protocol bytes plus the two trailing CRC by...
constexpr uint32_t tx_done_timeout_ms(uint32_t air_time_us)
How long a driver waits for TX-done after starting a transmission.
bool parse(const uint8_t *buf, uint8_t buf_len, IoFrame &f)
Parse a wire buffer into a parsed IoFrame (validates length and CTRL0).
void format_device_error_bits(uint16_t errors, const DeviceErrorBit *bits, size_t bit_count, char *buf, size_t buf_size)
Expand a device-error word into a human-readable NAME|NAME|... string.
constexpr uint8_t RADIO_PACKET_BUFFER_SIZE
Scratch buffer size for raw radio packets and recovered frames.
void(*)(uint16_t errors, char *buf, size_t buf_size) DeviceErrorFormatter
A chip's own decoder, as passed to SoftPhyDriverBase::dump_init_device_errors_().
Preemption-safe "poll a condition until it holds or a deadline passes" helper.
Shared driver flow for radios using the software PHY (SX1262, LR1121).
One named bit of a chip's device-error word.
uint16_t mask
The bit (or bits) of the error word this row names.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint32_t elapsed_ms
Time from the first sample to the last clock read before the deciding one.
Definition radio_poll.h:24
bool ready
The condition held on the deciding sample.
Definition radio_poll.h:23
Raw packet received from the radio.
uint8_t len
Length of packet in bytes.
uint32_t freq_hz
Frequency the packet was received on (Hz).
uint8_t data[RADIO_PACKET_BUFFER_SIZE]
Raw packet data buffer.
Configuration for transmitting a packet: carrier frequency, preamble length, wake-up level and clear-...
uint16_t preamble_len
Preamble length in symbol periods (bytes).
uint32_t freq_hz
Carrier frequency in Hz.
The GFSK SetPacketParams fields both software-PHY chips program identically.
uint16_t preamble_bytes
Byte-denominated, like every other preamble value in this codebase.
What send_packet() did with a frame.
static TxResult channel_busy(int16_t level_dbm)
Result of the UART probe: best candidate frame within a raw capture.
uint8_t decoded_len
Total number of bytes decoded at that offset.
uint8_t frame_start
Index into decoded buffer where the frame begins.
bool valid
A plausible frame was found.
uint8_t bit_offset
Bit offset where the best decode started.
uint8_t frame_len
Length of the candidate IoFrame (decoded bytes).
uint8_t decoded[RADIO_PACKET_BUFFER_SIZE]
Full decoded UART stream at the chosen offset.