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