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