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