Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
proto_timing.h
Go to the documentation of this file.
1#pragma once
2
3/// @file proto_timing.h
4/// @brief Physical-layer radio and timing parameters for the IO-Homecontrol protocol.
5/// @ingroup hioc_protocol
6
7#include <cstdint>
8
9namespace esphome {
10namespace home_io_control {
11
12// ============================================================================
13// Physical Layer — Radio Parameters
14// ============================================================================
15
16/// The protocol uses 3 frequency channels in the 868 MHz ISM band.
17/// IO-Homecontrol uses 3 channels in the 868 MHz SRD band. In 1W (one-way) mode,
18/// only CH2 is used. In 2W (two-way) mode, the controller hops across all three
19/// channels every ~2.7ms when idle. Commands are sent on CH2; responses may arrive
20/// on any channel within the exchange wait window.
21static constexpr uint32_t FREQ_CH1 = 868250000; ///< Channel 1: 868.25 MHz (2W only)
22static constexpr uint32_t FREQ_CH2 = 868950000; ///< Channel 2: 868.95 MHz (1W and 2W, TX channel)
23static constexpr uint32_t FREQ_CH3 = 869850000; ///< Channel 3: 869.85 MHz (2W only)
24
25/// Preamble is a sequence of 0xAA bytes that precedes every frame.
26/// Any start frame carrying `CTRL1_LOW_POWER` — a directed frame to a low-power target, or the
27/// SPE roll-call's low-power frame shape — uses the long preamble (1024 bytes = 8192 bits) as a
28/// wake-up burst for a duty-cycled receiver; every other start frame uses the runtime-tunable
29/// `normal_start_preamble`, which an always-listening receiver detects fine. Every continuation
30/// frame (a reply, our challenge or challenge answer, a status-update ACK) uses the radio driver's
31/// `response_preamble()`, since both sides are already on the same channel; SHORT_PREAMBLE is that
32/// value's protocol default and the tuning floor. The exchange engine derives all of this from the
33/// frame (exchange_engine.cpp).
34static constexpr uint16_t LONG_PREAMBLE = 1024; ///< 1024 bytes: wake-up burst for a low-power start frame
35static constexpr uint16_t SHORT_PREAMBLE = 8; ///< Protocol default and floor for continuation frames
36
37/// Default for `TuningConfig::cold_broadcast_reply_preamble` — preamble for a *broadcast* reply to
38/// a frame the peer caught via a rotating/hopping listen (currently: only the key-extraction
39/// responder's 0x29, the sole `start=true` frame any device-role builder in this codebase
40/// produces) — long enough to be reliably caught by a hopping receiver, short enough that
41/// broadcasting it across all 3 channels doesn't block the loop the way LONG_PREAMBLE does.
42///
43/// Sized above the ~13.9 ms real-device discovery-reply preamble this project's own SX1262/LR1121
44/// discovery hop-slice tuning was measured against (issue #65 SDR analysis) — that measurement is
45/// what "reliably caught by a hopping receiver" is calibrated to here. 80 bytes ≈ 16.7 ms at the
46/// protocol's 38400 bps line rate (soft_phy_air_time_us()),
47/// budgeted as a starting point, not yet hardware-validated for this exact chip/scenario
48/// combination. Runtime-tunable via `cold_broadcast_reply_preamble` for exactly that reason.
49static constexpr uint16_t COLD_BROADCAST_REPLY_PREAMBLE = 80;
50
51/// Default for `TuningConfig::normal_start_preamble` — the preamble in front of a directed *start*
52/// frame whose target is **not** a low-power / duty-cycled device (`CTRL1_LOW_POWER` clear). An
53/// always-listening receiver does not need the ~213 ms `LONG_PREAMBLE` wake-up burst, and some
54/// receivers never lock onto one that long; a normal start frame gets this shorter preamble
55/// instead, matching what a reference hub sends to an always-alive device.
56///
57/// 32 bytes = 256 bits sits inside the preamble band the iown-homecontrol documentation describes
58/// (256 bits in its radio notes; 128 bits is the "Long PPDU" preamble in its link-layer notes),
59/// well above the ~12-byte response preamble a
60/// short-turnaround chip uses, ~6.7 ms of air time, and two orders of magnitude below the 1024-byte
61/// burst. 8 bytes is a proven lower bound against paired always-alive devices but nothing bounds
62/// where a start frame stops being heard, so 32 is the defensible middle — and `normal_start_preamble`
63/// is a live tuning knob so a wrong guess costs a number change, not a rebuild.
64static constexpr uint16_t NORMAL_START_PREAMBLE = 32;
65
66/// Default for `TuningConfig::pairing_discovery_preamble` — the preamble on the pairing discovery
67/// broadcast (`CMD_DISCOVER_REQ`/`CMD_DISCOVER_ALT_REQ`, 0x28/0x2E). `LONG_PREAMBLE`, because a
68/// device in learning mode may be a duty-cycled receiver that needs the wake-up burst. Unlike a
69/// directed start frame (see `NORMAL_START_PREAMBLE`), the discovery broadcast can't follow the
70/// target's power class: discovery runs before anything is known about the device. Some awake
71/// receivers never lock onto a preamble this long — a VELUX SSL solar roller shutter answers
72/// discovery only at a short preamble — so the value is runtime-tunable via
73/// `pairing_discovery_preamble`, and pairing reuses the tuned value for the frames it then sends
74/// to the discovered device (`PairingEngine::pairing_start_preamble_()`).
75static constexpr uint16_t PAIRING_DISCOVERY_PREAMBLE = LONG_PREAMBLE;
76
77/// Preamble/sync linger extension for a rotating listen (`ListenSpec::linger_dwell_ms`): how much
78/// longer to stay on a channel once a frame is visibly incoming, so a hop doesn't cut it off
79/// mid-reception. Sized to a frame's air time, not to a hop slice, so it does not need to change
80/// when a chip's hop slice does. Shared by every rotating listen (pairing discovery, broadcast
81/// roll-call): both wait for the same class of short protocol frame, so there is no measured
82/// reason for them to differ.
83static constexpr uint32_t PREAMBLE_LINGER_DWELL_MS = 15;
84// Chip-specific defaults (response preamble, post-TX settle, per-chip discovery hop slices
85// for SX1262 and LR1121) live beside their TuningConfig fields in tuning_config.h; the
86// SX1262/LR1121 exchange dwell constants live in radio_sx1262.h / radio_lr1121.h respectively.
87// Kept out of the generic protocol layer per the radio-timing layering cleanup — this header
88// holds only chip-neutral protocol values.
89
90/// Timing constants for frequency hopping and response waiting.
91static constexpr int32_t HOP_TIME_US = 2700; ///< Time per channel when hopping (2.7ms)
92static constexpr int32_t RESPONSE_WAIT_MS = 500; ///< Wait for response to non-start frame
93
94/// Wait for a response to a start frame — the first frame of an exchange, and the one a sleeping
95/// device has just been woken by.
96///
97/// This device class replies within a few milliseconds of the carrier dropping, or not at all —
98/// it is fast-or-never, not slow. A failure therefore shows up as `saw_challenge=0` with no frame
99/// received at all, rather than as a late arrival, so a longer window cannot fix a device that
100/// genuinely fails to answer.
101///
102/// 400 ms sits comfortably above every directly measured reply while keeping a failed exchange
103/// inside EXCHANGE_TOTAL_BUDGET_MS, so a dead device does not block the ESPHome loop past its own
104/// warning threshold (ADR 0013). Raise `exchange_start_response_wait_ms` from YAML if a device ever
105/// genuinely answers late — but check `wait_ms` in the logs first, since a fast-or-never device is a
106/// turnaround problem that a longer window cannot fix.
107static constexpr int32_t RESPONSE_START_WAIT_MS = 400;
108
109static constexpr int32_t RESPONSE_AUTH_WAIT_MS =
110 RESPONSE_WAIT_MS; ///< Wait for final response after challenge response
111static constexpr int32_t EXCHANGE_RETRY_DELAY_MS = 250; ///< Gap between retries within one HA command
112static constexpr uint8_t EXCHANGE_RETRY_COUNT = 3; ///< Attempts per command before reporting failure
113/// Re-sends allowed for a CMD_EXECUTE the target accepted without a closing reply, within the same
114/// exchange and its retry budget. One: a second silent try says the loss is not a one-off, and a
115/// third copy of a movement command would only lengthen the blocked loop. See
116/// decisions::retry_after_unconfirmed_accept_is_safe().
117static constexpr uint8_t UNCONFIRMED_EXECUTE_MAX_RESENDS = 1;
118/// Gap before that re-send, in place of EXCHANGE_RETRY_DELAY_MS. A device that did act on the first
119/// copy is often deaf for about a second while its motor or load switches: on a Somfy awning, a
120/// re-send 0.77 s after the first copy drew no challenge at all in 3 of 14 cases. With this gap the
121/// re-send goes out about 1.3 s after the first copy, and the whole exchange still fits
122/// EXCHANGE_TOTAL_BUDGET_MS.
123static constexpr uint32_t UNCONFIRMED_EXECUTE_RESEND_DELAY_MS = 750;
125 "the re-send gap extends the ordinary retry gap, it never shortens it");
126
127/// How long evidence that a low-power receiver is moving keeps it believed awake enough to hear the
128/// short start preamble first. A moving VELUX solar receiver ignores the 1024-byte wake-up
129/// preamble but answers the short one, so a command, STOP or poll sent mid-travel must lead with
130/// the short one. Sized above the longest cover travel this project has measured; a first estimate
131/// that field logs will correct. Past it the receiver is presumed to have finished and fallen back
132/// asleep.
133static constexpr uint32_t LOW_POWER_MAX_TRAVEL_MS = 120000;
134
135/// How long any sign of life (a frame from the device, or moving evidence) keeps a low-power
136/// receiver believed "maybe awake": short preamble first, wake-up preamble as the fallback. Shorter
137/// than LOW_POWER_MAX_TRAVEL_MS because a receiver that is merely active, not moving, drops back to
138/// its duty cycle sooner. A first estimate that field logs will correct.
139static constexpr uint32_t LOW_POWER_AWAKE_HOLD_MS = 30000;
140
141/// Tries for pairing's phase-3 SetConfig1 (0x6F). One: no device on record accepts it (a Somfy
142/// Izymo answers `FE 28`, a VELUX SSL solar roller shutter stays silent) and no real controller
143/// sends it, so a retry only adds 0.7–0.9 s of blocking and a frame of airtime to every pairing
144/// with a silent device. A challenged try still completes its 0x3C/0x3D round within the one try.
145static constexpr uint8_t PAIRING_SET_CONFIG1_MAX_TRIES = 1;
146
147/// Exchange tries for a status poll the scheduler owns — every status poll issued while
148/// StatusPollPolicy is tracking the device, which today is every status poll this component can
149/// produce (there is no user-facing "refresh status" button or action; if one is ever added, it
150/// must not take this branch).
151///
152/// Such a poll's failure is re-armed by the backoff ladder (STATUS_RETRY_AFTER_FAIL_MS and its
153/// successors), so the ladder *is* its retry mechanism; stacking EXCHANGE_RETRY_COUNT blocking
154/// in-exchange tries on top of it buys no freshness and costs ~1.6 s of blocked loop() while a
155/// device is unresponsive (e.g. an actuator mid-manoeuvre) — the settle poll fires seconds after a
156/// command, squarely inside the manoeuvre, so keeping it a single try is what lets a STOP a user
157/// presses mid-move dispatch promptly. A poll with no ladder behind it keeps the full
158/// EXCHANGE_RETRY_COUNT. SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE and STOP_SETTLE_POLL_TRIES below
159/// are the two places this trade-off is deliberately bought back.
160static constexpr uint8_t SCHEDULED_POLL_MAX_TRIES = 1;
161
162/// Ladder positions at which a scheduler-owned status poll gets the full EXCHANGE_RETRY_COUNT back.
163///
164/// The single try above is right at both ends of the backoff ladder and wrong in the middle. At the
165/// first slot after a command the device is still executing the manoeuvre: its silence is expected,
166/// retries cannot change that, and blocking loop() for the full retry product would delay a STOP
167/// the user presses mid-move. Once a device has missed several slots in a row it is unreachable
168/// rather than merely asleep, and retries are just as pointless. In between sits the slot where the
169/// manoeuvre has just ended and the device is awake again but duty-cycled — one 400 ms listen
170/// samples its receive window once; EXCHANGE_RETRY_COUNT tries sample it three times, ~870 ms apart,
171/// and a success there also clears the failure streak and ends the backoff.
172///
173/// Counted in consecutive silent failures already recorded when the poll is dispatched, so 0 is the
174/// post-command settle poll and 1..3 are the ~5 s / ~15 s / ~30 s ladder slots after it — roughly
175/// t+8 s to t+53 s, spanning every cover travel time this project has measured. An auth-shaped
176/// streak is excluded entirely: a device that answers with a 0x3C challenge is awake, so extra
177/// tries buy no wake-up, and an auth try is the most expensive shape the engine runs.
178static constexpr uint8_t SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE = 1;
179static constexpr uint8_t SCHEDULED_POLL_RETRY_GRACE_LAST_FAILURE = 3;
180
181/// Exchange tries for the settle poll after an accepted STOP.
182///
183/// The single-try rule above protects a STOP pressed during a manoeuvre from a blocking poll. After
184/// an accepted STOP nothing is moving, and the user's next action — reversing the cover — waits on
185/// exactly this poll, because until it answers the entity still shows the last reported position.
186/// A single try bets that on one sample of a receiver whose state right after a STOP is uncertain
187/// (still running down, awake, or already back on its duty cycle); a miss costs the full
188/// STATUS_RETRY_AFTER_FAIL_MS backoff before the next look. The full budget samples it up to three
189/// times within one exchange (under EXCHANGE_TOTAL_BUDGET_MS), and blocks only when all of them miss.
191
192/// Wall-clock ceiling on one whole exchange, retries included.
193///
194/// EXCHANGE_RETRY_COUNT tries x (long preamble + response window + retry gap) is what actually
195/// determines how long a failing command blocks the ESPHome loop, and that blocking also starves
196/// the receive path the rest of the exchange depends on. ESPHome itself warns when one operation
197/// takes longer than 2550 ms (ADR 0013); this budget must stay under that threshold.
198///
199/// So the retry count is a maximum, not a promise: a try only starts if the exchange has budget
200/// left. At the current 400 ms response window all three tries still fit (~2.3 s); raising the
201/// window well past the default is what starts trimming retries, since three full tries stop being
202/// affordable at that point — one long listen is the better trade there anyway.
203static constexpr uint16_t EXCHANGE_TOTAL_BUDGET_MS = 2500;
204
205/// One-way (1W) transmit cadence.
206///
207/// A 1W command is fire-and-forget: nothing replies, so there is no acknowledgement to retry on
208/// and no way to learn a frame was missed. Repetition *is* the reliability mechanism — real
209/// remotes send the same frame four times, and a receiving device treats the set as one command
210/// because all four carry the same sequence. These are protocol values shared by every 1W
211/// transmitter, not radio tuning: they do not vary by chip and must not be moved into a driver or
212/// a TuningConfig field.
213///
214/// Both values come from the reference implementation, which sets them on adjacent lines when it
215/// forges a 1W packet: `packet->repeat = 4` and `packet->repeatTime = 40` in its 1W remote. The
216/// capture logs embedded in that same source show
217/// consecutive copies of one burst arriving roughly 25 ms apart, which is not a contradiction:
218/// those are receive-side timestamps of a burst whose configured gap is 40 ms, so they measure
219/// something else. Do not "correct" 40 down to 25 on the strength of them.
220///
221/// Erring long would in any case be the safe direction — a device needs only one of the four
222/// copies to land, so a wider spacing costs nothing and leaves more room on a shared band.
223///
224/// The resulting burst duration — see ONEWAY_BURST_INTERVAL_MS's own comment and send_burst()'s
225/// doxygen (oneway_transmitter.h) for what it decomposes into per power class — is what
226/// ONEWAY_QUIET_PERIOD_MS (status_poll_policy.h) is sized against when it holds background polls
227/// back during 1W activity.
228static constexpr uint8_t ONEWAY_BURST_REPEATS = 4; ///< Copies of each 1W command sent per press.
229/// Gap between those copies. Three gaps between four copies is 3 * 40 = 120 ms of pure *delay*,
230/// fixed regardless of the identity's power class. What that adds up to on the wire depends on
231/// each copy's own airtime, which is a per-identity, per-copy choice (`OneWayPowerClass`,
232/// oneway_controller.h; ADR 0038): a burst with the long preamble on every copy runs well over a
233/// second, one with the short preamble on every copy a few hundred ms. send_burst()'s doxygen
234/// (oneway_transmitter.h) has both real shapes.
235static constexpr uint32_t ONEWAY_BURST_INTERVAL_MS = 40;
236
237/// Listen-before-talk (LBT) parameters for ETSI EN 300 220 compliance.
238/// Before transmitting, the radio checks that the channel RSSI is below the
239/// threshold. If the channel is busy, TX is deferred by LBT_RETRY_DELAY_MS
240/// up to LBT_MAX_RETRIES times.
241static constexpr int16_t LBT_RSSI_THRESHOLD_DBM = -90; ///< Channel-free threshold (ETSI: ≤ -90 dBm)
242static constexpr uint8_t LBT_MAX_RETRIES = 5; ///< Max carrier-sense attempts before TX anyway
243static constexpr uint8_t LBT_RETRY_DELAY_MS = 5; ///< Backoff between LBT checks (≥ 5ms per ETSI)
244
245/// Canonical defaults for the chip-neutral runtime-tunable pairing/discovery parameters.
246///
247/// These are the single source of truth for the diagnostics tuning layer: `TuningConfig`
248/// initializes its fields from them, and the ESPHome YAML schema falls back to them when a
249/// key is omitted. Adjust a value here and both the compiled default and the documented
250/// YAML default follow. Chip-specific tuning defaults live in tuning_config.h instead.
251/// See @ref hioc_tuning.
252static constexpr uint16_t PAIRING_DISCOVERY_WAIT_MS = 2000; ///< Wait window after sending each discovery command.
253static constexpr uint16_t PAIRING_DISCOVERY_INITIAL_DWELL_MS = 300; ///< Dwell on CH2 before discovery hopping begins.
254static constexpr uint8_t PAIRING_KEY_EXCHANGE_RETRIES = 3; ///< Retries for the authenticated key-exchange phase.
255
256/// Default for `TuningConfig::pairing_key_init_delay_ms` — pause after the discover-confirm step
257/// (whether it was ACKed, refused, or silent) and before CMD_KEY_INIT (0x31). No capture
258/// precedent for 300 ms specifically: real hubs take several seconds longer here, and appear to
259/// spend that time re-broadcasting 0x28 to look for more devices, which this project's
260/// single-device discovery phase has no equivalent of. 300 ms is the short end: a Somfy Izymo
261/// dimmer pairs with both 300 ms and 5000 ms here, so the default keeps pairing fast, and
262/// `pairing_key_init_delay_ms` can lengthen it for a device that needs a later 0x31.
263static constexpr uint16_t PAIRING_KEY_INIT_DELAY_MS = 300;
264
265} // namespace home_io_control
266} // namespace esphome
static constexpr uint8_t PAIRING_KEY_EXCHANGE_RETRIES
Retries for the authenticated key-exchange phase.
static constexpr uint8_t ONEWAY_BURST_REPEATS
One-way (1W) transmit cadence.
static constexpr uint32_t ONEWAY_BURST_INTERVAL_MS
Gap between those copies.
static constexpr uint8_t LBT_MAX_RETRIES
Max carrier-sense attempts before TX anyway.
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static constexpr uint8_t SCHEDULED_POLL_MAX_TRIES
Exchange tries for a status poll the scheduler owns — every status poll issued while StatusPollPolicy...
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
static constexpr int32_t HOP_TIME_US
Timing constants for frequency hopping and response waiting.
static constexpr uint16_t PAIRING_KEY_INIT_DELAY_MS
Default for TuningConfig::pairing_key_init_delay_ms — pause after the discover-confirm step (whether ...
static constexpr uint16_t NORMAL_START_PREAMBLE
Default for TuningConfig::normal_start_preamble — the preamble in front of a directed start frame who...
static constexpr uint32_t UNCONFIRMED_EXECUTE_RESEND_DELAY_MS
Gap before that re-send, in place of EXCHANGE_RETRY_DELAY_MS.
static constexpr uint8_t SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE
Ladder positions at which a scheduler-owned status poll gets the full EXCHANGE_RETRY_COUNT back.
static constexpr int32_t EXCHANGE_RETRY_DELAY_MS
Gap between retries within one HA command.
static constexpr uint8_t STOP_SETTLE_POLL_TRIES
Exchange tries for the settle poll after an accepted STOP.
static constexpr uint8_t PAIRING_SET_CONFIG1_MAX_TRIES
Tries for pairing's phase-3 SetConfig1 (0x6F).
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
static constexpr uint32_t LOW_POWER_MAX_TRAVEL_MS
How long evidence that a low-power receiver is moving keeps it believed awake enough to hear the shor...
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
static constexpr uint16_t PAIRING_DISCOVERY_WAIT_MS
Canonical defaults for the chip-neutral runtime-tunable pairing/discovery parameters.
static constexpr uint32_t LOW_POWER_AWAKE_HOLD_MS
How long any sign of life (a frame from the device, or moving evidence) keeps a low-power receiver be...
static constexpr uint8_t LBT_RETRY_DELAY_MS
Backoff between LBT checks (≥ 5ms per ETSI).
static constexpr uint16_t COLD_BROADCAST_REPLY_PREAMBLE
Default for TuningConfig::cold_broadcast_reply_preamble — preamble for a broadcast reply to a frame t...
static constexpr uint16_t SHORT_PREAMBLE
Protocol default and floor for continuation frames.
static constexpr uint8_t UNCONFIRMED_EXECUTE_MAX_RESENDS
Re-sends allowed for a CMD_EXECUTE the target accepted without a closing reply, within the same excha...
static constexpr int32_t RESPONSE_WAIT_MS
Wait for response to non-start frame.
static constexpr uint32_t PREAMBLE_LINGER_DWELL_MS
Preamble/sync linger extension for a rotating listen (ListenSpec::linger_dwell_ms): how much longer t...
static constexpr uint16_t LONG_PREAMBLE
Preamble is a sequence of 0xAA bytes that precedes every frame.
static constexpr int32_t RESPONSE_AUTH_WAIT_MS
Wait for final response after challenge response.
static constexpr uint16_t PAIRING_DISCOVERY_PREAMBLE
Default for TuningConfig::pairing_discovery_preamble — the preamble on the pairing discovery broadcas...
static constexpr uint8_t SCHEDULED_POLL_RETRY_GRACE_LAST_FAILURE
static constexpr uint16_t EXCHANGE_TOTAL_BUDGET_MS
Wall-clock ceiling on one whole exchange, retries included.
static constexpr int16_t LBT_RSSI_THRESHOLD_DBM
Listen-before-talk (LBT) parameters for ETSI EN 300 220 compliance.
static constexpr uint16_t PAIRING_DISCOVERY_INITIAL_DWELL_MS
Dwell on CH2 before discovery hopping begins.
static constexpr int32_t RESPONSE_START_WAIT_MS
Wait for a response to a start frame — the first frame of an exchange, and the one a sleeping device ...