Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
radio_interface.h
Go to the documentation of this file.
1#pragma once
2
3/// @file radio_interface.h
4/// @brief Radio abstraction layer for IO-Homecontrol.
5/// @ingroup hioc_radio
6///
7/// Defines the SpiAccess interface for SPI bus access and the RadioDriver abstract
8/// class that encapsulates all chip-specific radio operations. This allows the
9/// protocol layer to work with different radio chips (SX1276, SX1262, etc.)
10/// without knowing the hardware details.
11
12#include "proto_frame.h"
13#include "proto_timing.h"
14#include "tuning_config.h"
15#include <atomic>
16#include <cstdint>
17#include "esphome/core/hal.h"
18
19namespace esphome {
20namespace home_io_control {
21
22inline constexpr uint8_t RADIO_PACKET_BUFFER_SIZE =
23 64; ///< Scratch buffer size for raw radio packets and recovered frames.
24
25/// @brief Longest a frame arriving on the current channel may hold off an idle-path channel hop,
26/// in microseconds.
27///
28/// Sized to outlast the slowest thing a hop could destroy. On the software-PHY chips that is the
29/// fixed-length RX_DONE, which lands 48 raw bytes = 10.0 ms after the sync word
30/// (SOFT_PHY_RX_PROBE_PACKET_LEN at 38400 bps; a static_assert in radio_soft_phy_driver_base.h
31/// ties this constant to that arithmetic, since neither header can see the other's constants).
32/// On the SX1276 it is the frame's own air time, at most ~9.4 ms for the longest possible frame.
33/// 12 ms covers both with margin for poll granularity.
34///
35/// It is a *bound*, not a target: every mechanism that sets the holdoff is expected to clear it
36/// early, and the bound exists only so that a sync detection with no frame behind it — noise, a
37/// truncated burst, a peer that gave up — cannot wedge channel hopping permanently.
38inline constexpr uint32_t RX_HOP_HOLDOFF_US = 12000;
39
40/// Interface for SPI bus access.
41/// The ESPHome component implements this by delegating to its SPIDevice methods,
42/// allowing radio drivers to perform SPI transactions without depending on the
43/// ESPHome SPI framework directly.
44/// @ingroup hioc_radio
45class SpiAccess {
46 public:
47 virtual ~SpiAccess() = default;
48 /// Enable the SPI bus (assert CS low).
49 virtual void spi_enable() = 0;
50 /// Disable the SPI bus (deassert CS).
51 virtual void spi_disable() = 0;
52 /// Transfer one byte full‑duplex (MOSI→MISO).
53 /// @param data Byte to send.
54 /// @return Byte received from MISO.
55 virtual uint8_t spi_transfer(uint8_t data) = 0;
56 /// Write one byte (MOSI only, MISO ignored).
57 /// @param data Byte to send.
58 virtual void spi_write(uint8_t data) = 0;
59 /// Read one byte (MISO only, MOSI driven with 0).
60 /// @return Byte received.
61 virtual uint8_t spi_read() = 0;
62};
63
64/// Configuration for transmitting a packet: carrier frequency and preamble length.
66 uint32_t freq_hz{FREQ_CH2}; ///< Carrier frequency in Hz.
67 uint16_t preamble_len{SHORT_PREAMBLE}; ///< Preamble length in symbol periods (bytes).
68};
69
70/// Raw packet received from the radio.
72 uint32_t freq_hz{0}; ///< Frequency the packet was received on (Hz).
73 uint8_t len{0}; ///< Length of packet in bytes.
74 uint8_t data[RADIO_PACKET_BUFFER_SIZE]{}; ///< Raw packet data buffer.
75};
76
77/// Diagnostic capture from a radio operation.
78///
79/// Populated after every wait_for_packet / check_for_packet. Contains both the
80/// raw bytes reported by the chip (before any protocol-specific recovery) and
81/// the parsed frame handed to the protocol layer.
83 bool valid{false}; ///< True if capture is valid.
84 bool blocking_wait{false}; ///< True if captured during a blocking wait.
85 bool rx_done{false}; ///< True if RxDone IRQ fired.
86 bool crc_error{false}; ///< True if a CRC error was detected. Chip-dependent: some drivers cannot report
87 ///< CRC failures — see the concrete driver's capture documentation.
88 uint32_t timestamp_ms{0}; ///< Timestamp of capture (millis).
89 uint32_t freq_hz{0}; ///< RF frequency of capture (Hz).
90 int16_t rssi_dbm{0}; ///< Received signal strength (dBm).
91 uint16_t irq_status{0}; ///< Raw IRQ status register value.
92 uint8_t irq_flags1{0}; ///< IRQ flags group 1 (chip-specific).
93 uint8_t irq_flags2{0}; ///< IRQ flags group 2 (chip-specific, includes CRC flag).
94 uint8_t packet_status{0}; ///< Packet status byte (chip-specific).
95 uint8_t rx_offset{0}; ///< RX buffer offset where the frame starts (0 for chips without offset reporting).
96 uint8_t reported_len{0}; ///< Length reported by the radio chip.
97 // raw[] preserves the chip-reported bytes before any driver-specific recovery, while
98 // frame[] stores the bytes handed to parse(). Keeping both makes it possible to compare
99 // one driver's recovery output against reference captures from another.
100 uint8_t raw_len{0}; ///< Number of valid bytes in raw[].
101 uint8_t frame_len{0}; ///< Number of valid bytes in frame[].
102 uint8_t raw[RADIO_PACKET_BUFFER_SIZE]{}; ///< Raw radio buffer bytes.
103 uint8_t frame[RADIO_PACKET_BUFFER_SIZE]{}; ///< Parsed protocol frame bytes.
104};
105
106/// Abstract radio driver for IO-Homecontrol.
107///
108/// Encapsulates all chip-specific operations: initialization, packet TX/RX,
109/// frequency control, and mode switching. Concrete implementations (RadioSX1276,
110/// RadioSX1262, RadioLR1121) handle the register-level details for each chip.
111/// @ingroup hioc_radio
113 public:
114 explicit RadioDriver(InternalGPIOPin *rst_pin = nullptr) : rst_pin_(rst_pin) {}
115 virtual ~RadioDriver() = default;
116
117 /// Initialize the radio hardware. Returns true on success.
118 virtual bool init() = 0;
119
120 /// Send a packet using the specified carrier frequency and preamble settings.
121 /// The driver is responsible for appending the protocol CRC on the air
122 /// (in hardware or software, depending on the chip).
123 virtual bool send_packet(const uint8_t *data, uint8_t len, const RadioTxConfig &tx_config) = 0;
124
125 /// Wait (blocking) for a packet with timeout. Returns true if a packet was received.
126 /// Contract:
127 /// - Clears last_capture_ and output packet before waiting.
128 /// - On success: populates packet and last_capture_, returns true.
129 /// - On timeout/failure: may populate last_capture_ for diagnostics, returns false.
130 /// - Radio remains in RX mode on return (regardless of outcome).
131 virtual bool wait_for_packet(RadioRxPacket &packet, uint32_t timeout_ms) = 0;
132
133 /// Non-blocking check for a received packet. Called from loop().
134 /// Returns true if a packet was read into packet.
135 /// Contract:
136 /// - Returns false immediately if no DIO interrupt has fired.
137 /// - On success: populates packet and last_capture_, returns true.
138 /// - On failure: may populate last_capture_ for diagnostics, returns false.
139 virtual bool check_for_packet(RadioRxPacket &packet) = 0;
140
141 /// Read instantaneous RSSI (in dBm) while in RX mode.
142 /// Used for listen-before-talk (LBT) carrier sense before transmitting.
143 /// @return RSSI in dBm (negative value).
144 virtual int16_t read_rssi() = 0;
145
146 /// @brief Check if sync word has been detected (while in RX).
147 /// Used to gate frequency hopping — prevents hopping away mid-frame.
148 virtual bool is_sync_detected() = 0;
149
150 /// @brief Check if preamble has been detected (while in RX).
151 /// Used together with sync detection to gate frequency hopping.
152 virtual bool is_preamble_detected() = 0;
153
154 /// @brief Return the preamble length for response/continuation frames.
155 ///
156 /// Callers use this instead of hardcoding SHORT_PREAMBLE for any frame sent as
157 /// an immediate reply within an exchange (challenge responses, key transfers,
158 /// and any future non-START continuation frames — i.e. tight RX→TX turnaround).
159 ///
160 /// The default is the protocol's standard SHORT_PREAMBLE. Drivers whose TX
161 /// waveform gives the peer device less synchronization margin override this
162 /// with a longer preamble (see the concrete drivers for the chip-specific
163 /// rationale).
164 ///
165 /// @return Preamble length in bytes.
166 [[nodiscard]] virtual uint16_t response_preamble() const { return SHORT_PREAMBLE; }
167
168 /// @brief Apply runtime tuning parameters to the driver.
169 ///
170 /// Each driver consumes only the fields it understands; the default is a no-op for
171 /// chips with no runtime-tunable radio parameters. This keeps the hub free of
172 /// chip-specific tuning knowledge — it hands over the whole config and lets the
173 /// driver pick what it needs.
174 /// @param tuning Current tuning configuration.
175 virtual void apply_tuning(const TuningConfig &tuning) {}
176
177 /// @brief Per-channel dwell for a rotating listen that does not name its own dwell.
178 ///
179 /// Every @ref ListenPolicy::ROTATE_ALL_CHANNELS or @ref ListenPolicy::ROTATE_SKIPPING_REQUEST
180 /// listen falls back to this when @ref ListenSpec::dwell_ms is left at 0 — which is every call
181 /// site today: pairing discovery and the broadcast roll-call both rotate, and neither has a
182 /// measured reason to dwell differently from the other. The right dwell is inherently
183 /// chip-specific — it depends on how fast the chip can retune (fast hop vs. a
184 /// standby→retune→RX cycle) — so there is no generic default: each driver must return its
185 /// value, normally from its user-facing tuning field. This answers a chip question ("how long
186 /// must this radio sit on a channel before it can hear anything at all"), never a protocol one
187 /// — a loop with a measured reason to dwell differently sets @ref ListenSpec::dwell_ms instead
188 /// of asking for a second driver virtual.
189 /// @param tuning Current tuning configuration.
190 /// @return Dwell length in milliseconds.
191 [[nodiscard]] virtual uint16_t hop_dwell_ms(const TuningConfig &tuning) const = 0;
192
193 /// @brief Whether the chip re-enters RX fast enough after a TX to catch an
194 /// immediate reply through the standard exchange wait.
195 ///
196 /// Some chips need a standby/settle cycle between TX and RX, so a device's
197 /// immediate response (e.g. the pairing key-confirm 0x33) can arrive while the
198 /// receiver is still settling and be lost. Callers choose between the standard
199 /// exchange wait and a dedicated wait-and-retrigger strategy based on this.
200 /// There is no safe generic default — each driver must declare it.
201 /// @return true if an immediate reply after TX is reliably received.
202 [[nodiscard]] virtual bool has_fast_tx_rx_turnaround() const = 0;
203
204 /// Change the carrier frequency using fast hop (no standby transition needed).
205 virtual void change_frequency(uint32_t freq_hz) = 0;
206
207 /// Switch to continuous receive mode.
208 virtual void set_mode_rx() = 0;
209
210 /// Switch to standby mode.
211 virtual void set_mode_standby() = 0;
212
213 /// Returns true if the radio failed to initialize or encountered a fatal error.
214 /// @return true on failure.
215 [[nodiscard]] virtual bool is_failed() const = 0;
216
217 /// @brief Get a human‑readable chip name.
218 /// @return Short lowercase identifier (e.g. "sx1276").
219 [[nodiscard]] virtual const char *chip_name() const = 0;
220
221 /// Optional chip-specific diagnostics emitted from dump_config.
222 virtual void dump_debug() {}
223
224 /// @brief Get the current RF frequency.
225 /// @return Frequency in Hz.
226 [[nodiscard]] uint32_t get_current_freq() const { return this->current_freq_; }
227 /// @brief Get the most recent radio capture info.
228 /// @return const reference to RadioCaptureInfo.
229 [[nodiscard]] const RadioCaptureInfo &get_last_capture() const { return this->last_capture_; }
230
231 /// @brief Reset the diagnostic capture buffer.
232 ///
233 /// Public because ExchangeEngine blanks it at the start of every exchange: the radio only clears
234 /// this buffer when it actually begins a listen, so without an explicit reset a fully-silent
235 /// exchange's failure report would inherit the *previous* exchange's capture (frame length, RSSI,
236 /// IRQ bits) and claim "we heard something" when nothing was on air.
238
239 /// @brief True while a frame is arriving on the current channel and retuning would destroy it.
240 ///
241 /// Consulted by ExchangeEngine::maybe_hop() — the idle-path hop — which is purely time-gated and
242 /// otherwise fires on essentially every loop() pass (issue #81). Not consulted by
243 /// hop_frequency() itself: the blocking listen() loop does its own, differently-shaped gating
244 /// through preamble_or_sync_incoming(), and a caller that asked for a hop explicitly must get one.
245 ///
246 /// The default implementation is the recorded-state one: a driver reports a reception by calling
247 /// note_reception_in_progress_() from wherever it can actually observe one, and the holdoff
248 /// expires by itself after RX_HOP_HOLDOFF_US. That default is correct for any driver whose RX
249 /// path passes through check_for_packet() while the frame is still arriving, and it is what both
250 /// software-PHY drivers use. A driver that cannot observe a reception from check_for_packet()
251 /// overrides this and reads the chip at hop time instead — see RadioSX1276.
252 ///
253 /// Non-const because it expires its own latch, and because an override may do SPI.
254 [[nodiscard]] virtual bool reception_in_progress() {
255 if (!this->rx_hold_armed_)
256 return false;
257 if (micros() - this->rx_hold_since_us_ >= RX_HOP_HOLDOFF_US) {
258 this->rx_hold_armed_ = false;
259 return false;
260 }
261 return true;
262 }
263
264 /// Set by the ISR when DIO fires. Using access helpers instead of touching the flag directly
265 /// keeps the ISR/main-loop handoff explicit and lets ESP32 builds use atomic storage.
266 [[nodiscard]] bool is_dio_fired() const {
267#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32)
268 return this->dio_fired_.load(std::memory_order_acquire);
269#else
270 return this->dio_fired_;
271#endif
272 }
273
275 // The wait/check loops clear the latch only after they have observed it. That avoids losing
276 // an edge when TX completion and the next RX event happen close together.
277#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32)
278 this->dio_fired_.store(false, std::memory_order_release);
279#else
280 this->dio_fired_ = false;
281#endif
282 }
283
285 // Keep the ISR work to a single flag store so the interrupt path remains deterministic.
286#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32)
287 this->dio_fired_.store(true, std::memory_order_release);
288#else
289 this->dio_fired_ = true;
290#endif
291 }
292
293 protected:
294 /// Common preamble for blocking receive: clear diagnostics and output packet.
295 /// @param packet Output packet buffer to zero and prepare.
297 this->clear_last_capture();
298 packet = RadioRxPacket{};
299 }
300
301 /// Common preamble for non‑blocking receive: clear diagnostics, output packet, and DIO latch.
302 /// @param packet Output packet buffer to zero and prepare.
304 this->clear_last_capture();
305 packet = RadioRxPacket{};
306 this->clear_dio_fired();
307 }
308
309 /// Record that a frame is arriving right now. Re-arming refreshes the deadline, so a driver may
310 /// call this on every poll that still sees the reception.
312 this->rx_hold_armed_ = true;
313 this->rx_hold_since_us_ = micros();
314 }
315 /// Drop the holdoff: the reception ended, was delivered, or was torn down deliberately.
317
318 /// Shared hardware reset sequence for chips with an active-low RST pin.
319 /// Drives RST pin low → 10 ms → high → 10 ms. Called from derived driver init().
320 void reset_hardware_();
321
322 /// Populate the common fields of RadioCaptureInfo from raw telemetry.
323 /// Chip‑specific fields (rx_done, crc_error, irq_flags*, irq_status, packet_status, etc.)
324 /// must be set by the derived driver after calling this helper.
325 /// @param blocking_wait if this was a blocking receive.
326 /// @param freq_hz RF frequency of the capture.
327 /// @param rssi_dbm Received signal strength.
328 /// @param raw Pointer to raw bytes (may be nullptr).
329 /// @param raw_len Length of raw buffer.
330 /// @param frame Pointer to parsed frame bytes (may be nullptr).
331 /// @param frame_len Length of parsed frame.
332 void populate_capture_base_(bool blocking_wait, uint32_t freq_hz, int16_t rssi_dbm, const uint8_t *raw,
333 uint8_t raw_len, const uint8_t *frame, uint8_t frame_len) {
335 this->last_capture_.valid = true;
336 this->last_capture_.blocking_wait = blocking_wait;
337 this->last_capture_.timestamp_ms = millis();
338 this->last_capture_.freq_hz = freq_hz;
339 this->last_capture_.rssi_dbm = rssi_dbm;
340 if (raw != nullptr && raw_len > 0) {
341 this->last_capture_.raw_len = std::min(raw_len, (uint8_t) sizeof(this->last_capture_.raw));
342 memcpy(this->last_capture_.raw, raw, this->last_capture_.raw_len);
343 }
344 if (frame != nullptr && frame_len > 0) {
345 this->last_capture_.frame_len = std::min(frame_len, (uint8_t) sizeof(this->last_capture_.frame));
346 memcpy(this->last_capture_.frame, frame, this->last_capture_.frame_len);
347 }
348 }
349
352 InternalGPIOPin *rst_pin_{nullptr};
353
354 bool rx_hold_armed_{false}; ///< Idle-hop holdoff latch — see reception_in_progress().
355 uint32_t rx_hold_since_us_{0}; ///< micros() timestamp the holdoff was last (re-)armed at.
356
357#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32)
358 std::atomic<bool> dio_fired_{false};
359#else
360 volatile bool dio_fired_{false};
361#endif
362};
363
364} // namespace home_io_control
365} // namespace esphome
void populate_capture_base_(bool blocking_wait, uint32_t freq_hz, int16_t rssi_dbm, const uint8_t *raw, uint8_t raw_len, const uint8_t *frame, uint8_t frame_len)
Populate the common fields of RadioCaptureInfo from raw telemetry.
void clear_last_capture()
Reset the diagnostic capture buffer.
uint32_t get_current_freq() const
Get the current RF frequency.
RadioDriver(InternalGPIOPin *rst_pin=nullptr)
virtual bool has_fast_tx_rx_turnaround() const =0
Whether the chip re-enters RX fast enough after a TX to catch an immediate reply through the standard...
void reset_hardware_()
Shared hardware reset sequence for chips with an active-low RST pin.
virtual uint16_t response_preamble() const
Return the preamble length for response/continuation frames.
virtual bool reception_in_progress()
True while a frame is arriving on the current channel and retuning would destroy it.
virtual bool is_sync_detected()=0
Check if sync word has been detected (while in RX).
virtual void change_frequency(uint32_t freq_hz)=0
Change the carrier frequency using fast hop (no standby transition needed).
virtual bool is_failed() const =0
Returns true if the radio failed to initialize or encountered a fatal error.
const RadioCaptureInfo & get_last_capture() const
Get the most recent radio capture info.
bool is_dio_fired() const
Set by the ISR when DIO fires.
uint32_t rx_hold_since_us_
micros() timestamp the holdoff was last (re-)armed at.
virtual bool send_packet(const uint8_t *data, uint8_t len, const RadioTxConfig &tx_config)=0
Send a packet using the specified carrier frequency and preamble settings.
virtual const char * chip_name() const =0
Get a human‑readable chip name.
virtual uint16_t hop_dwell_ms(const TuningConfig &tuning) const =0
Per-channel dwell for a rotating listen that does not name its own dwell.
void note_reception_in_progress_()
Record that a frame is arriving right now.
bool rx_hold_armed_
Idle-hop holdoff latch — see reception_in_progress().
virtual bool init()=0
Initialize the radio hardware. Returns true on success.
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.
virtual void apply_tuning(const TuningConfig &tuning)
Apply runtime tuning parameters to the driver.
virtual void dump_debug()
Optional chip-specific diagnostics emitted from dump_config.
virtual bool check_for_packet(RadioRxPacket &packet)=0
Non-blocking check for a received packet.
virtual int16_t read_rssi()=0
Read instantaneous RSSI (in dBm) while in RX mode.
void prepare_nonblocking_receive_(RadioRxPacket &packet)
Common preamble for non‑blocking receive: clear diagnostics, output packet, and DIO latch.
virtual bool wait_for_packet(RadioRxPacket &packet, uint32_t timeout_ms)=0
Wait (blocking) for a packet with timeout.
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 bool is_preamble_detected()=0
Check if preamble has been detected (while in RX).
Interface for SPI bus access.
virtual void spi_enable()=0
Enable the SPI bus (assert CS low).
virtual void spi_write(uint8_t data)=0
Write one byte (MOSI only, MISO ignored).
virtual uint8_t spi_transfer(uint8_t data)=0
Transfer one byte full‑duplex (MOSI→MISO).
virtual void spi_disable()=0
Disable the SPI bus (deassert CS).
virtual uint8_t spi_read()=0
Read one byte (MISO only, MOSI driven with 0).
constexpr uint32_t RX_HOP_HOLDOFF_US
Longest a frame arriving on the current channel may hold off an idle-path channel hop,...
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
static constexpr uint16_t SHORT_PREAMBLE
8 bytes for response/continuation frames
constexpr uint8_t RADIO_PACKET_BUFFER_SIZE
Scratch buffer size for raw radio packets and recovered frames.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
Diagnostic capture from a radio operation.
uint32_t timestamp_ms
Timestamp of capture (millis).
uint8_t frame_len
Number of valid bytes in frame[].
uint16_t irq_status
Raw IRQ status register value.
uint8_t packet_status
Packet status byte (chip-specific).
uint8_t irq_flags2
IRQ flags group 2 (chip-specific, includes CRC flag).
bool blocking_wait
True if captured during a blocking wait.
bool crc_error
True if a CRC error was detected.
uint8_t frame[RADIO_PACKET_BUFFER_SIZE]
Parsed protocol frame bytes.
bool valid
True if capture is valid.
uint8_t reported_len
Length reported by the radio chip.
bool rx_done
True if RxDone IRQ fired.
uint32_t freq_hz
RF frequency of capture (Hz).
uint8_t irq_flags1
IRQ flags group 1 (chip-specific).
uint8_t raw[RADIO_PACKET_BUFFER_SIZE]
Raw radio buffer bytes.
uint8_t raw_len
Number of valid bytes in raw[].
int16_t rssi_dbm
Received signal strength (dBm).
uint8_t rx_offset
RX buffer offset where the frame starts (0 for chips without offset reporting).
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.
All runtime tunable parameters for pairing and radio diagnostics.
Runtime tuning configuration for pairing and radio diagnostics.