Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
roll_call.cpp
Go to the documentation of this file.
1/// @file roll_call.cpp
2/// @brief The roll-call sweep: keyed `0x2A` broadcasts over channels and power classes.
3/// @ingroup hioc_hub
4
5#include "roll_call.h"
6
7#include "log_helpers.h"
8#include "proto_constants.h"
10
11#include <cstring>
12#include <string>
13
14namespace esphome {
15namespace home_io_control {
16
17ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity, const IoFrame &frame,
18 int16_t rssi_dbm, uint8_t key_slot, const ScanResponder *&entry) {
19 for (uint8_t i = 0; i < count; i++) {
20 if (memcmp(responders[i].src, frame.src, NODE_ID_SIZE) == 0) {
21 entry = &responders[i];
23 }
24 }
25 if (count >= capacity) {
26 entry = nullptr;
28 }
29
30 // decode_discovery_response() also produces the hex device-ID string, which is deliberately
31 // discarded here and rebuilt from `src` when the report is formatted. Keeping it would mean a
32 // std::string per entry — more memory than the entire ScanResponder — to save re-deriving six
33 // characters that fit in a small-string buffer. Do not "optimise" this by adding a string member.
34 IoDevice device{};
35 std::string unused_device_id;
36 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, unused_device_id);
37
38 ScanResponder &new_entry = responders[count++];
39 memcpy(new_entry.src, frame.src, NODE_ID_SIZE);
40 new_entry.type = device.type;
41 new_entry.subtype = device.subtype;
42 new_entry.inverted = device.inverted;
43 new_entry.rssi_dbm = rssi_dbm;
44 new_entry.manufacturer = info.manufacturer;
45 new_entry.flags = info.flags;
46 new_entry.has_extended = info.has_extended;
47 new_entry.metadata_complete = info.metadata_complete;
48 new_entry.key_slot = key_slot;
49 entry = &new_entry;
51}
52
54 uint8_t count = 0;
55 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
56 if (scan_power_classes_include(selection, pass.power_save_mode))
57 count++;
58 }
59 return count;
60}
61
62namespace {
63
64/// @brief Per-attempt facts a roll-call reply's DEBUG line needs, kept in one small struct so the
65/// reply lambda's capture list stays a couple of pointers rather than growing one field at a time.
66struct RollCallAttemptContext {
67 const RollCallPass *pass;
68 uint8_t attempt; ///< Global attempt number (1-based, across all passes).
69 uint32_t tx_freq_hz; ///< Channel this attempt transmitted the request on.
70 uint8_t key_slot; ///< Key slot of the sweep this attempt belongs to.
71};
72
73/// @brief Record one accepted roll-call reply into the scan state, and log it.
74///
75/// Reads back `add_scan_responder()`'s out-parameter rather than decoding the frame a second time
76/// to learn the responder's self-reported power-save class. RSSI in the log line is this reply's
77/// own `info.rssi_dbm`, not the value stored on first sighting, so a later, stronger or weaker
78/// reception of an already-known responder is still visible. Duplicates are logged too — a repeat
79/// still answers "which pass heard this device".
80void record_roll_call_reply(ScanState &state, const RollCallAttemptContext &ctx, const IoFrame &frame,
81 const ExchangeEngine::BroadcastReplyInfo &info) {
82 const ScanResponder *entry = nullptr;
83 if (add_scan_responder(state.responders, state.count, SCAN_MAX_REPLIES, frame, info.rssi_dbm, ctx.key_slot, entry) ==
85 state.truncated = true;
86 }
87
88 const char *power_save = (entry != nullptr && entry->has_extended)
90 : "unknown";
91 ESP_LOGD(detail::TAG,
92 "Roll-call reply src=%s pass=%s attempt=%u tx=%" PRIu32 " rx=%" PRIu32 " +%" PRIu32
93 "ms rssi=%d power_save=%s",
94 node_id_to_string(frame.src).c_str(), power_save_mode_name(ctx.pass->power_save_mode), ctx.attempt,
95 ctx.tx_freq_hz, info.rx_freq_hz, info.after_tx_ms, info.rssi_dbm, power_save);
96}
97
98/// @brief Transmit one roll-call attempt and collect its replies into `state`.
99///
100/// Builds a fresh request every call (create_discovery_request() draws a new random nonce each
101/// time) rather than replaying one frame across attempts, and logs one attempt-summary DEBUG line
102/// after collection closes. The pass determines the frame shape (CTRL1, and therefore preamble via
103/// request_preamble_for()'s rule) and the listen policy; this function does not choose either.
104RollCallAttemptResult run_roll_call_attempt(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key,
105 uint8_t key_slot, const TuningConfig &tuning, const RollCallPass &pass,
106 uint32_t tx_freq_hz, uint8_t attempt, uint8_t total_attempts,
107 ScanState &state) {
108 IoFrame request;
109 const bool low_power = pass.power_save_mode == POWER_SAVE_LOW_POWER;
110 if (!create_discovery_request(request, node_id, CMD_DISCOVER_SPE_REQ, BROADCAST_DISCOVER, low_power, pass.ack_capable,
111 /*payload_enabled=*/false, /*payload=*/0, key)) {
113 }
114
115 const uint8_t before = state.count;
116 RollCallAttemptContext ctx{&pass, attempt, tx_freq_hz, key_slot};
117 const uint8_t heard = engine.collect_broadcast_responses(
118 request, tx_freq_hz, CMD_DISCOVER_SPE_RESP, tuning.pairing_discovery_wait_ms,
119 [&state, &ctx](const IoFrame &frame, const ExchangeEngine::BroadcastReplyInfo &info) {
120 record_roll_call_reply(state, ctx, frame, info);
121 },
122 pass.listen_policy);
123
124 // Diagnostic only (not part of the user-facing report). Reporting heard and new separately is
125 // what makes it useful: "heard 3, 0 new" means devices are answering every attempt (so the
126 // extra channels are redundant here). ctrl1 is read back from the built frame rather than
127 // re-derived from the pass, so this line can never disagree with what was actually transmitted.
128 const uint8_t new_count = state.count - before;
129 ESP_LOGD(detail::TAG,
130 "Roll-call attempt %u/%u (pass=%s, ctrl1=0x%02X, tx %" PRIu32 " Hz, %u ms window): %u repl%s heard, %u new",
131 attempt, total_attempts, power_save_mode_name(pass.power_save_mode), request.ctrl1, tx_freq_hz,
132 tuning.pairing_discovery_wait_ms, heard, heard == 1 ? "y" : "ies", new_count);
134}
135
136} // namespace
137
138RollCallAttemptResult run_roll_call_sweep(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key,
139 uint8_t key_slot, const TuningConfig &tuning, ScanState &state,
140 uint8_t &attempt, uint8_t total_attempts) {
141 const ScanPowerClasses selection = tuning.scan_power_classes;
142 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
143 if (!scan_power_classes_include(selection, pass.power_save_mode))
144 continue;
145 for (const uint32_t tx_freq_hz : SCAN_CHANNELS) {
146 attempt++;
147 if (run_roll_call_attempt(engine, node_id, key, key_slot, tuning, pass, tx_freq_hz, attempt, total_attempts,
150 }
151 }
152 }
154}
155
156namespace {
157
158/// The `hint:` lines for a responder the registry knows: a power-save class that disagrees with its
159/// YAML `low_power`, and an answer under another key than its YAML uses.
160///
161/// Advice only: this never touches the registry or the device's runtime low_power property (an
162/// explicit YAML override stays authoritative). A responder without extended metadata has nothing to
163/// compare, so it gets no power-save hint either way. A power_save value outside the two known classes
164/// (2 or 3) is not evidence of either class, so it gets none either: only an exact match against one
165/// of the two known classes triggers advice.
166std::string known_responder_hints(const ScanResponder &responder, const IoDevice &registered,
167 const RollCallReplyKeys &keys, uint8_t power_save) {
168 std::string hints;
169 if (responder.has_extended) {
170 if (power_save == POWER_SAVE_LOW_POWER && !registered.low_power) {
171 hints += " hint: reports power_save=" + std::string(power_save_mode_name(power_save)) +
172 " but its YAML has no low_power: true; add it or directed commands may miss it while it "
173 "sleeps\n";
174 } else if (power_save == POWER_SAVE_ALWAYS_ALIVE && registered.low_power) {
175 hints += " hint: reports power_save=" + std::string(power_save_mode_name(power_save)) +
176 " but its YAML sets low_power: true; remove it, the " + std::to_string(LONG_PREAMBLE) +
177 "-byte wake-up preamble can stop an always-alive receiver answering\n";
178 }
179 }
180 // The exchanges with this device use its YAML key (or the hub's). If it answered a roll-call built
181 // with another key, every exchange will fail.
182 const uint8_t *yaml_key = registered.has_system_key ? registered.system_key : keys.hub;
183 if (memcmp(yaml_key, keys.answered, AES_KEY_SIZE) != 0) {
184 hints += " hint: answered under the key with check value " + detail::format_key_check_value(keys.answered) +
185 " but its YAML uses the key with check value " + detail::format_key_check_value(yaml_key) +
186 "; fix its system_key:\n";
187 }
188 return hints;
189}
190
191/// The paste-ready part of an unknown responder's entry, or the "not supported yet" line.
192/// @param key The per-device key it answered under, or nullptr for the hub key.
193std::string unknown_responder_block(const ScanResponder &responder, const std::string &device_id, const uint8_t *key,
194 RollCallKeyDisplay display, bool low_power) {
195 const bool with_key = key != nullptr && display != RollCallKeyDisplay::HIDDEN;
196 const std::string snippet =
197 build_device_yaml_snippet(responder.type, responder.subtype, device_id, responder.metadata_complete,
198 responder.inverted, low_power, with_key ? detail::format_key_hex(key) : std::string());
199 if (snippet.empty()) {
200 if (key != nullptr && display == RollCallKeyDisplay::PLAIN) {
201 return " not supported yet. Its system_key, for when support lands: \"" + detail::format_key_hex(key) + "\"\n";
202 }
203 return " not supported yet\n";
204 }
205 if (key == nullptr || display == RollCallKeyDisplay::PLAIN)
206 return " Paste this into your YAML to register it:\n" + snippet;
207 if (display == RollCallKeyDisplay::HIDDEN) {
208 return " Paste this into your YAML to register it, and add this device's system_key: (the key is printed in "
209 "the device log, not here).\n" +
210 snippet;
211 }
212 // The snippet carries the device's own key: the one place this report prints key material, so it sits
213 // inside the same kind of banner the key-extraction and key-pull reports use.
214 return " DEVICE KEY BELOW -- DO NOT SHARE IT. Keep it in secrets.yaml and write `system_key: !secret ...`.\n"
215 " Paste this into your YAML to register it:\n" +
216 snippet + " END OF DEVICE KEY BLOCK\n";
217}
218
219} // namespace
220
221std::string format_roll_call_responder(const ScanResponder &responder, const std::string &device_id,
222 const IoDevice *registered, const RollCallReplyKeys &keys,
223 RollCallKeyDisplay display) {
224 const uint8_t *key = keys.per_device ? keys.answered : nullptr;
225 std::string line = " " + device_id + ": " + format_device_kind(responder.type, responder.subtype) + " (" +
226 format_device_ids(responder.type, responder.subtype) + ")";
227 if (key != nullptr)
228 line += " key=" + detail::format_key_check_value(key);
229 line += " rssi=" + std::to_string(responder.rssi_dbm) + "dBm";
230 uint8_t power_save = 0;
231 if (responder.has_extended) {
232 uint8_t const att = discovery_att_class(responder.flags);
233 power_save = discovery_power_save_mode(responder.flags);
234 line += std::string(" manufacturer=") + manufacturer_name(responder.manufacturer) +
235 " turnaround=" + att_class_name(att) + " power_save=" + power_save_mode_name(power_save);
236 }
237 line += registered != nullptr ? " [known]\n" : " [unknown]\n";
238
239 if (registered != nullptr)
240 return line + known_responder_hints(responder, *registered, keys, power_save);
241 const bool low_power = responder.has_extended && power_save == POWER_SAVE_LOW_POWER;
242 return line + unknown_responder_block(responder, device_id, key, display, low_power);
243}
244
245std::string build_hub_key_pull_report(const PulledKeyReportInfo &info, const ScanState &state,
246 DeviceRegistry &registry) {
247 if (state.count == 0)
248 return "";
249 std::string report = "Device " + info.pairing_device_id + " (" + format_device_kind(info.type, info.subtype) + ", " +
250 format_device_ids(info.type, info.subtype) +
251 ") answered the key pull with this hub's own key. " + std::to_string(state.count) + " device" +
252 (state.count == 1 ? " that holds" : "s that hold") + " it answered:\n";
253 const RollCallReplyKeys keys{info.hub_key, info.hub_key, false};
254 for (uint8_t i = 0; i < state.count; i++) {
255 const std::string device_id = node_id_to_string(state.responders[i].src);
256 report += format_roll_call_responder(state.responders[i], device_id, registry.get_declared(device_id), keys,
258 }
259 if (state.truncated)
260 report += "NOTE: more devices answered than the list holds; it is truncated.\n";
261 return report;
262}
263
264std::string build_pulled_key_report(const PulledKeyReportInfo &info, const ScanState &state, DeviceRegistry &registry) {
265 const std::string key_hex = detail::format_key_hex(info.key);
266 std::string report = "DEVICE KEY RECEIVED -- DO NOT SHARE IT\n";
267 report += "Device " + info.pairing_device_id + " (" + format_device_kind(info.type, info.subtype) + ", " +
268 format_device_ids(info.type, info.subtype) +
269 ") holds a different system key than this hub (key check value " +
270 detail::format_key_check_value(info.key) + ") and proved it holds it.\n";
271 report += "Anyone with this key can control every device that holds it. Keep it in secrets.yaml and write\n"
272 "`system_key: !secret ...` in the entity of each device.\n";
273 if (state.count == 0) {
274 report += "A roll-call under that key found no device. Put this key into the system_key: of the entity that\n"
275 "controls the device:\n"
276 " system_key: \"" +
277 key_hex + "\"\n";
278 } else {
279 report += std::to_string(state.count) + " device" + (state.count == 1 ? " that holds" : "s that hold") +
280 " it answered. Paste a block per device:\n";
281 const RollCallReplyKeys keys{info.key, info.hub_key, true};
282 for (uint8_t i = 0; i < state.count; i++) {
283 const std::string device_id = node_id_to_string(state.responders[i].src);
284 report += format_roll_call_responder(state.responders[i], device_id, registry.get_declared(device_id), keys,
286 }
287 if (state.truncated)
288 report += "NOTE: more devices answered than the list holds; it is truncated.\n";
289 }
290 report += "END OF DEVICE KEY REPORT";
291 return report;
292}
293
294} // namespace home_io_control
295} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
IoDevice * get_declared(const std::string &device_id)
Retrieve a device the YAML declares, ignoring one a pairing added in this boot.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:32
std::string format_key_hex(const uint8_t key[AES_KEY_SIZE])
Format a 16-byte key as an uppercase, unseparated hex string for display.
Definition log_helpers.h:62
std::string format_key_check_value(const uint8_t key[AES_KEY_SIZE])
A key check value: the first three bytes of the key's AES encryption of a zero block.
Definition log_helpers.h:77
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.
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
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
uint8_t discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
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
const char * att_class_name(uint8_t att_class)
Get a human-readable turnaround time string for an ATT class value.
std::string format_device_ids(DeviceType type, uint8_t subtype)
Wire values of a (type, subtype), for logs that must stay readable without the table.
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
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.
bool scan_power_classes_include(ScanPowerClasses selection, uint8_t power_save_mode)
Whether selection calls the roll-call pass for power_save_mode.
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
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted, bool low_power, const std::string &system_key_hex)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
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.
DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id)
Decode a discovery-response payload (CMD_DISCOVER_RESP 0x29 or CMD_DISCOVER_SPE_RESP 0x2B — both carr...
std::string format_device_kind(DeviceType type, uint8_t subtype)
Human-readable description of a (type, subtype) for logs and reports.
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
@ 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
IO-Homecontrol command IDs, result codes and protocol enumerations.
IO-Homecontrol device-type model, capabilities and runtime device state.
The roll-call sweep: one keyed 0x2A broadcast, repeated over channels and power classes.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
bool has_extended
data_len >= DISCOVERY_RESP_FULL_SIZE (mfr/flags/timestamp present).
bool metadata_complete
data_len >= DEVICE_METADATA_SIZE (type/subtype present).
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
Per-reply facts collect_broadcast_responses() hands its caller alongside the frame.
Runtime state of a paired IO‑Homecontrol device.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
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
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 * 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
All runtime tunable parameters for pairing and radio diagnostics.
ScanPowerClasses scan_power_classes
Power classes the scan_paired_devices roll-call calls.