Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
roll_call.h
Go to the documentation of this file.
1#pragma once
2
3/// @file roll_call.h
4/// @brief The roll-call sweep: one keyed `0x2A` broadcast, repeated over channels and power classes.
5/// @ingroup hioc_hub
6///
7/// Scan Paired Devices and (in a later change) a pull-key pairing both ask "which devices answer a
8/// roll-call under this key?". This module is that question, with the key passed in explicitly:
9/// the frame shapes, the channels, the reply decoding and the de-duplication live here once.
10
11#include "device_registry.h"
12#include "exchange_engine.h"
13#include "proto_commands.h"
14#include "proto_device_types.h"
15#include "tuning_config.h"
16
17#include <algorithm>
18#include <array>
19#include <cstdint>
20#include <vector>
21
22namespace esphome {
23namespace home_io_control {
24
25/// @brief Maximum distinct responders reported by one scan_paired_devices() call.
26///
27/// Sized against the loop task's stack, which is where this runs: ESPHome spawns its own
28/// `loopTask` with `ESPHOME_LOOP_TASK_STACK_SIZE` (8192 B) — not the 3.5 KB ESP-IDF main task.
29/// `ScanResponder` is 12 B, so 24 slots cost ≈288 B (~3.5 % of that stack) in a single array;
30/// replies are decoded on arrival rather than buffered as whole 33 B `IoFrame`s, which is what
31/// makes a limit this size cheap. Installs with 20+ actuators are real, so 8 (the original
32/// value, chosen when whole frames were retained) was too low to be useful. Overflow beyond
33/// this is reported in the scan's own output, never silent.
34inline constexpr uint8_t SCAN_MAX_REPLIES = 24;
35
36/// @brief One roll-call responder, decoded on arrival.
37///
38/// Deliberately holds only what the report needs, so the fixed array stays small: the raw
39/// `IoFrame` is discarded as soon as decode_discovery_response() has run, and the hex device-ID
40/// string is rebuilt from `src` at format time rather than stored (a `std::string` member would
41/// cost more per entry than this whole struct).
43 uint8_t src[NODE_ID_SIZE]; ///< Responder's node ID; also the dedup key.
44 DeviceType type; ///< Decoded device type.
45 uint8_t subtype; ///< Decoded device subtype.
46 bool inverted; ///< Decoded position-inversion flag.
47 int16_t rssi_dbm; ///< RSSI of the reply that produced this entry.
48 uint8_t manufacturer; ///< Raw manufacturer ID; name via manufacturer_name().
49 uint8_t flags; ///< Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
50 bool has_extended; ///< Whether manufacturer/flags above are present.
51 bool metadata_complete; ///< Whether type/subtype were present in the payload.
52 uint8_t key_slot; ///< Which key of the sweep it answered under: 0 is the hub key, n the n-th
53 ///< per-device key (RollCallKeys).
54};
55
56/// @brief Channels scan_paired_devices() transmits its roll-call request on, one attempt per
57/// channel, per pass (see @ref ROLL_CALL_PASSES).
58///
59/// A single broadcast on one fixed channel only reaches a paired device that happens to be
60/// awake and listening on that exact channel at that exact instant — real hardware testing
61/// found paired devices duty-cycle across all three channels independently of the hub, so a
62/// one-shot broadcast on CH2 alone misses whichever devices are elsewhere in their cycle right
63/// then. Retrying the same request on the other two channels gives every device up to three
64/// chances per pass to be listening when the hub transmits. CH2 first since it is the protocol's
65/// designated TX channel (see FREQ_CH2's doc comment) and therefore the most likely to catch a
66/// reply on the first attempt.
67///
68/// This TX retry is a duty-cycle workaround, not a receive-side fix. The hub's listen path
69/// separately extends its dwell on a detected preamble/sync rather than hopping mid-frame, which
70/// is what keeps it from dropping replies it does hear.
71inline constexpr uint32_t SCAN_CHANNELS[] = {FREQ_CH2, FREQ_CH1, FREQ_CH3};
72inline constexpr uint8_t SCAN_CHANNEL_COUNT = sizeof(SCAN_CHANNELS) / sizeof(SCAN_CHANNELS[0]);
73
74/// @brief One roll-call frame shape, and how to listen for its replies.
75///
76/// `low_power` for the frame builder is derived from `power_save_mode` (`== POWER_SAVE_LOW_POWER`)
77/// rather than stored separately, and the pass name used in logs is
78/// `power_save_mode_name(power_save_mode)` — one vocabulary, shared with the responder's own
79/// self-reported power-save byte, instead of a second spelling table.
81 uint8_t power_save_mode; ///< POWER_SAVE_LOW_POWER or POWER_SAVE_ALWAYS_ALIVE: the class this pass calls.
82 bool ack_capable; ///< Set CTRL1_ACK on the 0x2A.
83 ListenPolicy listen_policy; ///< Channel policy for this pass's reply windows.
84};
85
86/// @brief The roll-call's two fixed frame shapes, low-power first.
87///
88/// Low-power pass first: a sleeper that answers late is still collected by the later
89/// always-alive windows (collect_broadcast_responses() accepts any 0x2B addressed to us,
90/// regardless of which pass is currently running); in the opposite order a late low-power reply
91/// would fall off the end of the scan with nothing left to catch it.
92///
93/// Low-power pass, `CTRL1 = 0x30` (LOW_POWER | ACK): the exact header a real VELUX KLR300 uses
94/// for its own roll-call, whose low-power shutters answered it. Setting CTRL1_LOW_POWER selects
95/// LONG_PREAMBLE through the shared start-preamble rule (request_preamble_for()) — no preamble is
96/// picked here. `ROTATE_ALL_CHANNELS`: those VELUX low-power replies were observed landing on the
97/// request channel, which a skipping listen would miss entirely.
98///
99/// Always-alive pass, `CTRL1 = 0x00` at `normal_start_preamble`: an always-alive VELUX
100/// installation ignores a 1024-byte start preamble and answers the 32-byte one; Somfy roll-call
101/// replies land on the request channel about 1 in 149 times, so `ROTATE_SKIPPING_REQUEST` keeps
102/// the window on the channels replies actually use.
103///
104/// Both shapes are fixed, named constants: `pairing_discovery_ack_capable` belongs to Discover &
105/// Pair and does not influence the roll-call either way.
106inline constexpr RollCallPass ROLL_CALL_PASSES[] = {
107 {POWER_SAVE_LOW_POWER, /*ack_capable=*/true, ListenPolicy::ROTATE_ALL_CHANNELS},
108 {POWER_SAVE_ALWAYS_ALIVE, /*ack_capable=*/false, ListenPolicy::ROTATE_SKIPPING_REQUEST},
109};
110
111/// A broadcast has no pinned conversation to hold a channel for, so HOLD_REQUEST_CHANNEL can
112/// never be a valid roll-call listen policy — checked at compile time rather than at every call
113/// site.
115 return std::ranges::all_of(ROLL_CALL_PASSES, [](const RollCallPass &pass) {
117 });
118}
120 "a broadcast roll-call pass has no pinned conversation to hold the request channel for");
121
122/// @brief Outcome of one roll-call attempt or sweep, so the caller can surface a build failure.
123enum class RollCallAttemptResult : uint8_t {
124 OK, ///< Request built and transmitted (with or without replies).
125 BUILD_FAILED, ///< create_discovery_request() failed; the caller should abort the whole scan.
126};
127
128/// @brief Accumulated state of one scan, threaded through every sweep.
129struct ScanState {
130 ScanResponder responders[SCAN_MAX_REPLIES]; ///< Distinct responders, in arrival order.
131 uint8_t count{0}; ///< Entries in use.
132 bool truncated{false}; ///< A responder was dropped because the array was full.
133};
134
135/// @brief How many of ROLL_CALL_PASSES `selection` actually calls.
136/// @param selection The scan_power_classes tunable.
137uint8_t count_enabled_passes(ScanPowerClasses selection);
138
139/// @brief Outcome of add_scan_responder(), so callers can tell a harmless repeat from real loss.
140enum class ScanAddResult : uint8_t {
141 ADDED, ///< New responder recorded.
142 DUPLICATE, ///< Already recorded from an earlier reply; nothing changed.
143 FULL, ///< Dropped: SCAN_MAX_REPLIES distinct responders already recorded.
144};
145
146/// @brief Record a responder unless its address is already present.
147///
148/// Deduplication is by node ID across the whole scan (all keys, all passes), so a device that
149/// answers several of the attempts still yields one entry. The duplicate check runs before the
150/// capacity check so that repeat replies from already-recorded devices never look like overflow
151/// once the array is full.
152/// @param responders Accumulated array, appended to in place.
153/// @param count In: entries already present. Out: updated count.
154/// @param capacity Maximum entries `responders` can hold.
155/// @param frame Reply frame to decode and store.
156/// @param rssi_dbm RSSI of that reply.
157/// @param key_slot The key slot of the sweep the reply arrived in.
158/// @param entry Out: the newly recorded entry (ADDED), the already-recorded entry with the same
159/// `src` (DUPLICATE), or nullptr (FULL) — lets a caller read back the stored
160/// manufacturer/flags without decoding the frame a second time.
161/// @return Which of the three outcomes occurred.
162ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity, const IoFrame &frame,
163 int16_t rssi_dbm, uint8_t key_slot, const ScanResponder *&entry);
164
165/// @brief Run the full roll-call sweep under one key: every enabled pass on every channel.
166///
167/// Outer loop over passes (low-power first, see ROLL_CALL_PASSES), skipping any pass the
168/// scan_power_classes tunable excludes; inner loop over SCAN_CHANNELS. Every attempt always runs,
169/// even once the array is full: stopping early would silently skip channels/passes, which is
170/// exactly the single-channel behaviour the repeated attempts exist to avoid.
171/// @param engine Exchange engine that transmits and collects the replies.
172/// @param node_id The hub's node ID.
173/// @param key The AES_KEY_SIZE-byte key the request is built with; only devices holding it answer.
174/// @param key_slot Slot recorded on every responder this sweep adds (0 = hub key).
175/// @param tuning Live tuning (reply window, power-class selection).
176/// @param state Accumulated scan state, appended to.
177/// @param attempt In/out: the global 1-based attempt counter across sweeps (0 before the first).
178/// @param total_attempts Attempts the whole scan will run, for the "n/N" log line.
179/// @return BUILD_FAILED if a request could not be built; OK otherwise.
180RollCallAttemptResult run_roll_call_sweep(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key,
181 uint8_t key_slot, const TuningConfig &tuning, ScanState &state,
182 uint8_t &attempt, uint8_t total_attempts);
183
184/// @brief The keys a responder line needs: the one a responder answered under, and the hub's.
186 const uint8_t *answered; ///< The key of the sweep the responder answered in (always set).
187 const uint8_t *hub; ///< The hub key (slot 0).
188 bool per_device; ///< True when `answered` is a per-device key, not the hub's.
189};
190
191/// @brief How a responder line shows the key it answered under.
192enum class RollCallKeyDisplay : uint8_t {
193 HIDDEN, ///< Never print the key: its check value only. For anything Home Assistant records.
194 BANNERED, ///< Print the key in a snippet, wrapped in the line's own DO-NOT-SHARE block.
195 PLAIN, ///< Print the key in a snippet with no framing: the caller's report supplies one banner.
196};
197
198/// @brief Format one roll-call responder's report line(s).
199///
200/// Known responders get a single summary line, plus an indented `hint:` line when their
201/// self-reported power-save class disagrees with the registered device's YAML `low_power`
202/// property (the responder must carry extended metadata for the comparison to be possible at
203/// all), or when they answered under a key other than the one their YAML uses. Unknown responders
204/// get the same summary line plus a lead-in sentence and a ready-to-paste YAML block (or, if the
205/// decoded type has no ESPHome platform yet, a "not supported yet" line instead of a blank) — the
206/// same "paste this in" framing a successful pairing prints. A responder found under a per-device
207/// key gets that key's check value on its summary line; `display` decides whether its snippet
208/// carries the key itself.
209/// @param responder Decoded responder record.
210/// @param device_id Hex device ID string for this responder, rebuilt from `responder.src`.
211/// @param registered The matching registry entry, or nullptr if this responder is not known.
212/// @param keys Which key the responder answered under and what the hub key is.
213/// @param display Whether and how the key is printed.
214/// @return The report text, ending in a newline.
215std::string format_roll_call_responder(const ScanResponder &responder, const std::string &device_id,
216 const IoDevice *registered, const RollCallReplyKeys &keys,
217 RollCallKeyDisplay display);
218
219/// @brief What build_pulled_key_report() says about the device that handed over a key.
221 std::string pairing_device_id; ///< Hex node ID of the device that sent the key.
222 DeviceType type; ///< Its node type from discovery.
223 uint8_t subtype; ///< Its subtype from discovery.
224 const uint8_t *key; ///< The pulled key (AES_KEY_SIZE bytes) it proved it holds.
225 const uint8_t *hub_key; ///< The hub's own key.
226};
227
228/// @brief Build the report for a pull that returned the hub's own key from a node no platform drives.
229///
230/// The devices that hold the hub key were found by a roll-call under it, or by a direct read of the
231/// address after an Atlantic pairing node. Their blocks need no key and no banner, so they read like a
232/// scan's unknown responders.
233/// @param info The device that sent the key.
234/// @param state The devices found under the hub key.
235/// @param registry Device registry before any registration: a responder the YAML declares is known; one
236/// an earlier pairing in this boot added is not (DeviceRegistry::get_declared()).
237/// @return The report, or an empty string when nothing answered.
238std::string build_hub_key_pull_report(const PulledKeyReportInfo &info, const ScanState &state,
239 DeviceRegistry &registry);
240
241/// @brief Build the one report a proven key pull prints after looking for the devices that hold the key.
242///
243/// One DO-NOT-SHARE banner frames the whole report (device log only; it carries the key). Each
244/// responder gets its summary line and a paste-ready block with `system_key:` added
245/// (RollCallKeyDisplay::PLAIN); a responder of a type no platform drives yet gets the "not supported
246/// yet" line and its key. With no responder, the report gives the key once for the pairing node
247/// itself. Nothing here registers a device.
248/// @param info The device that sent the key.
249/// @param state The devices found under that key (roll-call, or the direct read of the next address).
250/// @param registry Device registry as it stands before any registration: a responder the YAML declares
251/// is reported as known; one an earlier pairing in this boot added gets its block again.
252std::string build_pulled_key_report(const PulledKeyReportInfo &info, const ScanState &state, DeviceRegistry &registry);
253
254} // namespace home_io_control
255} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
Per-hub device table, update-callback fan-out, and linked-remote map.
Self-contained authenticated exchange engine for IO-Homecontrol 2W.
RollCallAttemptResult run_roll_call_sweep(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key, uint8_t key_slot, const TuningConfig &tuning, ScanState &state, uint8_t &attempt, uint8_t total_attempts)
Run the full roll-call sweep under one key: every enabled pass on every channel.
ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity, const IoFrame &frame, int16_t rssi_dbm, uint8_t key_slot, const ScanResponder *&entry)
Record a responder unless its address is already present.
Definition roll_call.cpp:17
uint8_t count_enabled_passes(ScanPowerClasses selection)
How many of ROLL_CALL_PASSES selection actually calls.
Definition roll_call.cpp:53
ScanAddResult
Outcome of add_scan_responder(), so callers can tell a harmless repeat from real loss.
Definition roll_call.h:140
@ ADDED
New responder recorded.
Definition roll_call.h:141
@ DUPLICATE
Already recorded from an earlier reply; nothing changed.
Definition roll_call.h:142
@ FULL
Dropped: SCAN_MAX_REPLIES distinct responders already recorded.
Definition roll_call.h:143
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
constexpr uint8_t SCAN_CHANNEL_COUNT
Definition roll_call.h:72
DeviceType
IO-Homecontrol node types.
constexpr RollCallPass ROLL_CALL_PASSES[]
The roll-call's two fixed frame shapes, low-power first.
Definition roll_call.h:106
ScanPowerClasses
Which power classes scan_paired_devices() calls.
std::string format_roll_call_responder(const ScanResponder &responder, const std::string &device_id, const IoDevice *registered, const RollCallReplyKeys &keys, RollCallKeyDisplay display)
Format one roll-call responder's report line(s).
RollCallAttemptResult
Outcome of one roll-call attempt or sweep, so the caller can surface a build failure.
Definition roll_call.h:123
@ BUILD_FAILED
create_discovery_request() failed; the caller should abort the whole scan.
Definition roll_call.h:125
@ OK
Request built and transmitted (with or without replies).
Definition roll_call.h:124
ListenPolicy
Which channels a listen covers.
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel.
@ HOLD_REQUEST_CHANNEL
Never retunes, never slices. Unicast replies.
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
constexpr bool no_roll_call_pass_holds_request_channel()
A broadcast has no pinned conversation to hold a channel for, so HOLD_REQUEST_CHANNEL can never be a ...
Definition roll_call.h:114
std::string build_hub_key_pull_report(const PulledKeyReportInfo &info, const ScanState &state, DeviceRegistry &registry)
Build the report for a pull that returned the hub's own key from a node no platform drives.
constexpr uint8_t SCAN_MAX_REPLIES
Maximum distinct responders reported by one scan_paired_devices() call.
Definition roll_call.h:34
std::string build_pulled_key_report(const PulledKeyReportInfo &info, const ScanState &state, DeviceRegistry &registry)
Build the one report a proven key pull prints after looking for the devices that hold the key.
RollCallKeyDisplay
How a responder line shows the key it answered under.
Definition roll_call.h:192
@ HIDDEN
Never print the key: its check value only. For anything Home Assistant records.
Definition roll_call.h:193
@ BANNERED
Print the key in a snippet, wrapped in the line's own DO-NOT-SHARE block.
Definition roll_call.h:194
@ PLAIN
Print the key in a snippet with no framing: the caller's report supplies one banner.
Definition roll_call.h:195
constexpr uint32_t SCAN_CHANNELS[]
Channels scan_paired_devices() transmits its roll-call request on, one attempt per channel,...
Definition roll_call.h:71
Command builders for the IO‑Homecontrol protocol.
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
What build_pulled_key_report() says about the device that handed over a key.
Definition roll_call.h:220
std::string pairing_device_id
Hex node ID of the device that sent the key.
Definition roll_call.h:221
const uint8_t * hub_key
The hub's own key.
Definition roll_call.h:225
uint8_t subtype
Its subtype from discovery.
Definition roll_call.h:223
const uint8_t * key
The pulled key (AES_KEY_SIZE bytes) it proved it holds.
Definition roll_call.h:224
DeviceType type
Its node type from discovery.
Definition roll_call.h:222
One roll-call frame shape, and how to listen for its replies.
Definition roll_call.h:80
ListenPolicy listen_policy
Channel policy for this pass's reply windows.
Definition roll_call.h:83
uint8_t power_save_mode
POWER_SAVE_LOW_POWER or POWER_SAVE_ALWAYS_ALIVE: the class this pass calls.
Definition roll_call.h:81
bool ack_capable
Set CTRL1_ACK on the 0x2A.
Definition roll_call.h:82
The keys a responder line needs: the one a responder answered under, and the hub's.
Definition roll_call.h:185
bool per_device
True when answered is a per-device key, not the hub's.
Definition roll_call.h:188
const uint8_t * hub
The hub key (slot 0).
Definition roll_call.h:187
const uint8_t * answered
The key of the sweep the responder answered in (always set).
Definition roll_call.h:186
One roll-call responder, decoded on arrival.
Definition roll_call.h:42
uint8_t src[NODE_ID_SIZE]
Responder's node ID; also the dedup key.
Definition roll_call.h:43
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
Definition roll_call.h:48
bool inverted
Decoded position-inversion flag.
Definition roll_call.h:46
DeviceType type
Decoded device type.
Definition roll_call.h:44
bool has_extended
Whether manufacturer/flags above are present.
Definition roll_call.h:50
int16_t rssi_dbm
RSSI of the reply that produced this entry.
Definition roll_call.h:47
bool metadata_complete
Whether type/subtype were present in the payload.
Definition roll_call.h:51
uint8_t key_slot
Which key of the sweep it answered under: 0 is the hub key, n the n-th per-device key (RollCallKeys).
Definition roll_call.h:52
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Definition roll_call.h:49
uint8_t subtype
Decoded device subtype.
Definition roll_call.h:45
Accumulated state of one scan, threaded through every sweep.
Definition roll_call.h:129
uint8_t count
Entries in use.
Definition roll_call.h:131
bool truncated
A responder was dropped because the array was full.
Definition roll_call.h:132
ScanResponder responders[SCAN_MAX_REPLIES]
Distinct responders, in arrival order.
Definition roll_call.h:130
Runtime tuning configuration for pairing and radio diagnostics.