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/// A directed start frame to a **low-power** target (`CTRL1_LOW_POWER` set) uses the long preamble
27/// (1024 bytes = 8192 bits) as a wake-up burst for its duty-cycled receiver; every other start
28/// frame uses the runtime-tunable `normal_start_preamble`, which an always-listening receiver
29/// detects fine. Subsequent frames in the same exchange use a short preamble (8 bytes) since both
30/// sides are already on the same channel. The exchange engine derives all of this from the frame
31/// (exchange_engine.cpp).
32static constexpr uint16_t LONG_PREAMBLE = 1024; ///< 1024 bytes: wake-up burst for a low-power start frame
33static constexpr uint16_t SHORT_PREAMBLE = 8; ///< 8 bytes for response/continuation frames
34
35/// Default for `TuningConfig::cold_broadcast_reply_preamble` — preamble for a *broadcast* reply to
36/// a frame the peer caught via a rotating/hopping listen (currently: only the key-extraction
37/// responder's 0x29, the sole `start=true` frame any device-role builder in this codebase
38/// produces) — long enough to be reliably caught by a hopping receiver, short enough that
39/// broadcasting it across all 3 channels doesn't block the loop the way LONG_PREAMBLE does.
40///
41/// Sized above the ~13.9 ms real-device discovery-reply preamble this project's own SX1262/LR1121
42/// discovery hop-slice tuning was measured against (issue #65 SDR analysis) — that measurement is
43/// what "reliably caught by a hopping receiver" is calibrated to here. 80 bytes ≈ 16.7 ms at the
44/// protocol's 38400 bps line rate (soft_phy_air_time_us()),
45/// budgeted as a starting point, not yet hardware-validated for this exact chip/scenario
46/// combination. Runtime-tunable via `cold_broadcast_reply_preamble` for exactly that reason.
47static constexpr uint16_t COLD_BROADCAST_REPLY_PREAMBLE = 80;
48
49/// Default for `TuningConfig::normal_start_preamble` — the preamble in front of a directed *start*
50/// frame whose target is **not** a low-power / duty-cycled device (`CTRL1_LOW_POWER` clear). An
51/// always-listening receiver does not need the ~213 ms `LONG_PREAMBLE` wake-up burst, and some
52/// receivers never lock onto one that long; a normal start frame gets this shorter preamble
53/// instead, matching what a reference hub sends to an always-alive device.
54///
55/// 32 bytes = 256 bits sits inside the preamble band the iown-homecontrol documentation describes
56/// (256 bits in its radio notes; 128 bits is the "Long PPDU" preamble in its link-layer notes),
57/// well above the ~12-byte response preamble a
58/// short-turnaround chip uses, ~6.7 ms of air time, and two orders of magnitude below the 1024-byte
59/// burst. 8 bytes is a proven lower bound against paired always-alive devices but nothing bounds
60/// where a start frame stops being heard, so 32 is the defensible middle — and `normal_start_preamble`
61/// is a live tuning knob so a wrong guess costs a number change, not a rebuild.
62static constexpr uint16_t NORMAL_START_PREAMBLE = 32;
63
64/// Default for `TuningConfig::pairing_discovery_preamble` — the preamble on the pairing discovery
65/// broadcast (`CMD_DISCOVER_REQ`/`CMD_DISCOVER_ALT_REQ`, 0x28/0x2E). Defaults to `LONG_PREAMBLE`,
66/// unchanged from historical behavior: a factory-fresh device in learning mode is exactly the kind
67/// of duty-cycled receiver `LONG_PREAMBLE` exists to wake. But unlike every other directed start
68/// frame (see `NORMAL_START_PREAMBLE`'s history, issue #87 — some always-alive receivers never
69/// lock onto a preamble this long), the discovery broadcast can't be made power-class-aware the
70/// same way: discovery exists to learn a device before anything is known about it, so it still
71/// unconditionally pays `LONG_PREAMBLE`'s ~213 ms even against an always-listening target. Issue
72/// #27 (Somfy Sunea IO devices repeatedly failing to answer discovery) raised this as a plausible,
73/// unconfirmed contributor. Runtime-tunable via `pairing_discovery_preamble` so that hypothesis is
74/// testable without a rebuild — not yet hardware-confirmed as a fix for any specific device.
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
114/// Exchange tries for a status poll the scheduler owns — every status poll issued while
115/// StatusPollPolicy is tracking the device, which today is every status poll this component can
116/// produce (there is no user-facing "refresh status" button or action; if one is ever added, it
117/// must not take this branch).
118///
119/// Such a poll's failure is re-armed by the backoff ladder (STATUS_RETRY_AFTER_FAIL_MS and its
120/// successors), so the ladder *is* its retry mechanism; stacking EXCHANGE_RETRY_COUNT blocking
121/// in-exchange tries on top of it buys no freshness and costs ~1.6 s of blocked loop() while a
122/// device is unresponsive (e.g. an actuator mid-manoeuvre) — the settle poll fires seconds after a
123/// command, squarely inside the manoeuvre, so keeping it a single try is what lets a STOP a user
124/// presses mid-move dispatch promptly. A poll with no ladder behind it keeps the full
125/// EXCHANGE_RETRY_COUNT. See SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE below for the one place this
126/// trade-off is deliberately bought back.
127static constexpr uint8_t SCHEDULED_POLL_MAX_TRIES = 1;
128
129/// Ladder positions at which a scheduler-owned status poll gets the full EXCHANGE_RETRY_COUNT back.
130///
131/// The single try above is right at both ends of the backoff ladder and wrong in the middle. At the
132/// first slot after a command the device is still executing the manoeuvre: its silence is expected,
133/// retries cannot change that, and blocking loop() for the full retry product would delay a STOP
134/// the user presses mid-move. Once a device has missed several slots in a row it is unreachable
135/// rather than merely asleep, and retries are just as pointless. In between sits the slot where the
136/// manoeuvre has just ended and the device is awake again but duty-cycled — one 400 ms listen
137/// samples its receive window once; EXCHANGE_RETRY_COUNT tries sample it three times, ~870 ms apart,
138/// and a success there also clears the failure streak and ends the backoff.
139///
140/// Counted in consecutive silent failures already recorded when the poll is dispatched, so 0 is the
141/// post-command settle poll and 1..3 are the ~5 s / ~15 s / ~30 s ladder slots after it — roughly
142/// t+8 s to t+53 s, spanning every cover travel time this project has measured. An auth-shaped
143/// streak is excluded entirely: a device that answers with a 0x3C challenge is awake, so extra
144/// tries buy no wake-up, and an auth try is the most expensive shape the engine runs.
145static constexpr uint8_t SCHEDULED_POLL_RETRY_GRACE_FIRST_FAILURE = 1;
146static constexpr uint8_t SCHEDULED_POLL_RETRY_GRACE_LAST_FAILURE = 3;
147
148/// Wall-clock ceiling on one whole exchange, retries included.
149///
150/// EXCHANGE_RETRY_COUNT tries x (long preamble + response window + retry gap) is what actually
151/// determines how long a failing command blocks the ESPHome loop, and that blocking also starves
152/// the receive path the rest of the exchange depends on. ESPHome itself warns when one operation
153/// takes longer than 2550 ms (ADR 0013); this budget must stay under that threshold.
154///
155/// So the retry count is a maximum, not a promise: a try only starts if the exchange has budget
156/// left. At the current 400 ms response window all three tries still fit (~2.3 s); raising the
157/// window well past the default is what starts trimming retries, since three full tries stop being
158/// affordable at that point — one long listen is the better trade there anyway.
159static constexpr uint16_t EXCHANGE_TOTAL_BUDGET_MS = 2500;
160
161/// One-way (1W) transmit cadence.
162///
163/// A 1W command is fire-and-forget: nothing replies, so there is no acknowledgement to retry on
164/// and no way to learn a frame was missed. Repetition *is* the reliability mechanism — real
165/// remotes send the same frame four times, and a receiving device treats the set as one command
166/// because all four carry the same sequence. These are protocol values shared by every 1W
167/// transmitter, not radio tuning: they do not vary by chip and must not be moved into a driver or
168/// a TuningConfig field.
169///
170/// Both values come from the reference implementation, which sets them on adjacent lines when it
171/// forges a 1W packet: `packet->repeat = 4` and `packet->repeatTime = 40` in its 1W remote. The
172/// capture logs embedded in that same source show
173/// consecutive copies of one burst arriving roughly 25 ms apart, which is not a contradiction:
174/// those are receive-side timestamps of a burst whose configured gap is 40 ms, so they measure
175/// something else. Do not "correct" 40 down to 25 on the strength of them.
176///
177/// Erring long would in any case be the safe direction — a device needs only one of the four
178/// copies to land, so a wider spacing costs nothing and leaves more room on a shared band.
179///
180/// The resulting burst duration — see ONEWAY_BURST_INTERVAL_MS's own comment and send_burst()'s
181/// doxygen (oneway_transmitter.h) for the two numbers this decomposes into — is what
182/// ONEWAY_QUIET_PERIOD_MS (status_poll_policy.h) is sized against when it holds background polls
183/// back during 1W activity.
184static constexpr uint8_t ONEWAY_BURST_REPEATS = 4; ///< Copies of each 1W command sent per press.
185/// Gap between those copies. Three gaps between four copies is 3 * 40 = 120 ms of pure *delay*;
186/// adding each of the four copies' own airtime brings the wall-clock burst closer to ~160 ms.
187/// send_burst()'s doxygen (oneway_transmitter.h) states both — they are not competing claims about
188/// the same number, just two different things measured on the same burst.
189static constexpr uint32_t ONEWAY_BURST_INTERVAL_MS = 40;
190
191/// Listen-before-talk (LBT) parameters for ETSI EN 300 220 compliance.
192/// Before transmitting, the radio checks that the channel RSSI is below the
193/// threshold. If the channel is busy, TX is deferred by LBT_RETRY_DELAY_MS
194/// up to LBT_MAX_RETRIES times.
195static constexpr int16_t LBT_RSSI_THRESHOLD_DBM = -90; ///< Channel-free threshold (ETSI: ≤ -90 dBm)
196static constexpr uint8_t LBT_MAX_RETRIES = 5; ///< Max carrier-sense attempts before TX anyway
197static constexpr uint8_t LBT_RETRY_DELAY_MS = 5; ///< Backoff between LBT checks (≥ 5ms per ETSI)
198
199/// Canonical defaults for the chip-neutral runtime-tunable pairing/discovery parameters.
200///
201/// These are the single source of truth for the diagnostics tuning layer: `TuningConfig`
202/// initializes its fields from them, and the ESPHome YAML schema falls back to them when a
203/// key is omitted. Adjust a value here and both the compiled default and the documented
204/// YAML default follow. Chip-specific tuning defaults live in tuning_config.h instead.
205/// See @ref hioc_tuning.
206static constexpr uint16_t PAIRING_DISCOVERY_WAIT_MS = 2000; ///< Wait window after sending each discovery command.
207static constexpr uint16_t PAIRING_DISCOVERY_INITIAL_DWELL_MS = 300; ///< Dwell on CH2 before discovery hopping begins.
208static constexpr uint8_t PAIRING_KEY_EXCHANGE_RETRIES = 3; ///< Retries for the authenticated key-exchange phase.
209
210} // namespace home_io_control
211} // 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 NORMAL_START_PREAMBLE
Default for TuningConfig::normal_start_preamble — the preamble in front of a directed start frame who...
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 EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
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 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
8 bytes for response/continuation frames
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 ...