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.rssi_dbm = rssi_dbm;
43 new_entry.manufacturer = info.manufacturer;
44 new_entry.flags = info.flags;
45 new_entry.has_extended = info.has_extended;
46 new_entry.metadata_complete = info.metadata_complete;
47 new_entry.key_slot = key_slot;
48 entry = &new_entry;
50}
51
53 uint8_t count = 0;
54 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
55 if (scan_power_classes_include(selection, pass.power_save_mode))
56 count++;
57 }
58 return count;
59}
60
61namespace {
62
63/// @brief Per-attempt facts a roll-call reply's DEBUG line needs, kept in one small struct so the
64/// reply lambda's capture list stays a couple of pointers rather than growing one field at a time.
65struct RollCallAttemptContext {
66 const RollCallPass *pass;
67 uint8_t attempt; ///< Global attempt number (1-based, across all passes).
68 uint32_t tx_freq_hz; ///< Channel this attempt transmitted the request on.
69 uint8_t key_slot; ///< Key slot of the sweep this attempt belongs to.
70};
71
72/// @brief Record one accepted roll-call reply into the scan state, and log it.
73///
74/// Reads back `add_scan_responder()`'s out-parameter rather than decoding the frame a second time
75/// to learn the responder's self-reported power-save class. RSSI in the log line is this reply's
76/// own `info.rssi_dbm`, not the value stored on first sighting, so a later, stronger or weaker
77/// reception of an already-known responder is still visible. Duplicates are logged too — a repeat
78/// still answers "which pass heard this device".
79void record_roll_call_reply(ScanState &state, const RollCallAttemptContext &ctx, const IoFrame &frame,
80 const ExchangeEngine::BroadcastReplyInfo &info) {
81 const ScanResponder *entry = nullptr;
82 if (add_scan_responder(state.responders, state.count, SCAN_MAX_REPLIES, frame, info.rssi_dbm, ctx.key_slot, entry) ==
84 state.truncated = true;
85 }
86
87 const char *power_save = (entry != nullptr && entry->has_extended)
89 : "unknown";
90 ESP_LOGD(detail::TAG,
91 "Roll-call reply src=%s pass=%s attempt=%u tx=%" PRIu32 " rx=%" PRIu32 " +%" PRIu32
92 "ms rssi=%d power_save=%s",
93 node_id_to_string(frame.src).c_str(), power_save_mode_name(ctx.pass->power_save_mode), ctx.attempt,
94 ctx.tx_freq_hz, info.rx_freq_hz, info.after_tx_ms, info.rssi_dbm, power_save);
95}
96
97/// @brief Transmit one roll-call attempt and collect its replies into `state`.
98///
99/// Builds a fresh request every call (create_discovery_request() draws a new random nonce each
100/// time) rather than replaying one frame across attempts, and logs one attempt-summary DEBUG line
101/// after collection closes. The pass determines the frame shape (CTRL1, and therefore preamble via
102/// request_preamble_for()'s rule) and the listen policy; this function does not choose either.
103RollCallAttemptResult run_roll_call_attempt(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key,
104 uint8_t key_slot, const TuningConfig &tuning, const RollCallPass &pass,
105 uint32_t tx_freq_hz, uint8_t attempt, uint8_t total_attempts,
106 ScanState &state) {
107 IoFrame request;
108 const bool low_power = pass.power_save_mode == POWER_SAVE_LOW_POWER;
109 if (!create_discovery_request(request, node_id, CMD_DISCOVER_SPE_REQ, BROADCAST_DISCOVER, low_power, pass.ack_capable,
110 /*payload_enabled=*/false, /*payload=*/0, key)) {
112 }
113
114 const uint8_t before = state.count;
115 RollCallAttemptContext ctx{&pass, attempt, tx_freq_hz, key_slot};
116 const uint8_t heard = engine.collect_broadcast_responses(
117 request, tx_freq_hz, CMD_DISCOVER_SPE_RESP, tuning.pairing_discovery_wait_ms,
118 [&state, &ctx](const IoFrame &frame, const ExchangeEngine::BroadcastReplyInfo &info) {
119 record_roll_call_reply(state, ctx, frame, info);
120 },
121 pass.listen_policy);
122
123 // Diagnostic only (not part of the user-facing report). Reporting heard and new separately is
124 // what makes it useful: "heard 3, 0 new" means devices are answering every attempt (so the
125 // extra channels are redundant here). ctrl1 is read back from the built frame rather than
126 // re-derived from the pass, so this line can never disagree with what was actually transmitted.
127 const uint8_t new_count = state.count - before;
128 ESP_LOGD(detail::TAG,
129 "Roll-call attempt %u/%u (pass=%s, ctrl1=0x%02X, tx %" PRIu32 " Hz, %u ms window): %u repl%s heard, %u new",
130 attempt, total_attempts, power_save_mode_name(pass.power_save_mode), request.ctrl1, tx_freq_hz,
131 tuning.pairing_discovery_wait_ms, heard, heard == 1 ? "y" : "ies", new_count);
133}
134
135} // namespace
136
137RollCallAttemptResult run_roll_call_sweep(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key,
138 uint8_t key_slot, const TuningConfig &tuning, ScanState &state,
139 uint8_t &attempt, uint8_t total_attempts) {
140 const ScanPowerClasses selection = tuning.scan_power_classes;
141 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
142 if (!scan_power_classes_include(selection, pass.power_save_mode))
143 continue;
144 for (const uint32_t tx_freq_hz : SCAN_CHANNELS) {
145 attempt++;
146 if (run_roll_call_attempt(engine, node_id, key, key_slot, tuning, pass, tx_freq_hz, attempt, total_attempts,
149 }
150 }
151 }
153}
154
155namespace {
156
157/// The `hint:` lines for a responder the registry knows: a power-save class that disagrees with its
158/// YAML `low_power`, and an answer under another key than its YAML uses.
159///
160/// Advice only: this never touches the registry or the device's runtime low_power property (an
161/// explicit YAML override stays authoritative). A responder without extended metadata has nothing to
162/// compare, so it gets no power-save hint either way. A power_save value outside the two known classes
163/// (2 or 3) is not evidence of either class, so it gets none either: only an exact match against one
164/// of the two known classes triggers advice.
165std::string known_responder_hints(const ScanResponder &responder, const IoDevice &registered,
166 const RollCallReplyKeys &keys, uint8_t power_save) {
167 std::string hints;
168 if (responder.has_extended) {
169 if (power_save == POWER_SAVE_LOW_POWER && !registered.low_power) {
170 hints += " hint: reports power_save=" + std::string(power_save_mode_name(power_save)) +
171 " but its YAML has no low_power: true; add it or directed commands may miss it while it "
172 "sleeps\n";
173 } else if (power_save == POWER_SAVE_ALWAYS_ALIVE && registered.low_power) {
174 hints += " hint: reports power_save=" + std::string(power_save_mode_name(power_save)) +
175 " but its YAML sets low_power: true; remove it, the " + std::to_string(LONG_PREAMBLE) +
176 "-byte wake-up preamble can stop an always-alive receiver answering\n";
177 }
178 }
179 // The exchanges with this device use its YAML key (or the hub's). If it answered a roll-call built
180 // with another key, every exchange will fail.
181 const uint8_t *yaml_key = registered.has_system_key ? registered.system_key : keys.hub;
182 if (memcmp(yaml_key, keys.answered, AES_KEY_SIZE) != 0) {
183 hints += " hint: answered under the key with check value " + detail::format_key_check_value(keys.answered) +
184 " but its YAML uses the key with check value " + detail::format_key_check_value(yaml_key) +
185 "; fix its system_key:\n";
186 }
187 return hints;
188}
189
190/// The paste-ready part of an unknown responder's entry, or the "not supported yet" line.
191/// @param key The per-device key it answered under, or nullptr for the hub key.
192std::string unknown_responder_block(const ScanResponder &responder, const std::string &device_id, const uint8_t *key,
193 RollCallKeyDisplay display, bool low_power) {
194 const bool with_key = key != nullptr && display != RollCallKeyDisplay::HIDDEN;
195 const std::string snippet =
196 build_device_yaml_snippet(responder.type, responder.subtype, device_id, responder.metadata_complete, low_power,
197 with_key ? detail::format_key_hex(key) : std::string());
198 if (snippet.empty()) {
199 if (key != nullptr && display == RollCallKeyDisplay::PLAIN) {
200 return " not supported yet. Its system_key, for when support lands: \"" + detail::format_key_hex(key) + "\"\n";
201 }
202 return " not supported yet\n";
203 }
204 if (key == nullptr || display == RollCallKeyDisplay::PLAIN)
205 return " Paste this into your YAML to register it:\n" + snippet;
206 if (display == RollCallKeyDisplay::HIDDEN) {
207 return " Paste this into your YAML to register it, and add this device's system_key: (the key is printed in "
208 "the device log, not here).\n" +
209 snippet;
210 }
211 // The snippet carries the device's own key: the one place this report prints key material, so it sits
212 // inside the same kind of banner the key-extraction and key-pull reports use.
213 return " DEVICE KEY BELOW -- DO NOT SHARE IT. Keep it in secrets.yaml and write `system_key: !secret ...`.\n"
214 " Paste this into your YAML to register it:\n" +
215 snippet + " END OF DEVICE KEY BLOCK\n";
216}
217
218} // namespace
219
220std::string format_roll_call_responder(const ScanResponder &responder, const std::string &device_id,
221 const IoDevice *registered, const RollCallReplyKeys &keys,
222 RollCallKeyDisplay display) {
223 const uint8_t *key = keys.per_device ? keys.answered : nullptr;
224 std::string line = " " + device_id + ": " + format_device_kind(responder.type, responder.subtype) + " (" +
225 format_device_ids(responder.type, responder.subtype) + ")";
226 if (key != nullptr)
227 line += " key=" + detail::format_key_check_value(key);
228 line += " rssi=" + std::to_string(responder.rssi_dbm) + "dBm";
229 uint8_t power_save = 0;
230 if (responder.has_extended) {
231 uint8_t const att = discovery_att_class(responder.flags);
232 power_save = discovery_power_save_mode(responder.flags);
233 line += std::string(" manufacturer=") + manufacturer_name(responder.manufacturer) +
234 " turnaround=" + att_class_name(att) + " power_save=" + power_save_mode_name(power_save);
235 }
236 line += registered != nullptr ? " [known]\n" : " [unknown]\n";
237
238 if (registered != nullptr)
239 return line + known_responder_hints(responder, *registered, keys, power_save);
240 const bool low_power = responder.has_extended && power_save == POWER_SAVE_LOW_POWER;
241 return line + unknown_responder_block(responder, device_id, key, display, low_power);
242}
243
244std::string build_hub_key_pull_report(const PulledKeyReportInfo &info, const ScanState &state,
245 DeviceRegistry &registry) {
246 if (state.count == 0)
247 return "";
248 std::string report = "Device " + info.pairing_device_id + " (" + format_device_kind(info.type, info.subtype) + ", " +
249 format_device_ids(info.type, info.subtype) +
250 ") answered the key pull with this hub's own key. " + std::to_string(state.count) + " device" +
251 (state.count == 1 ? " that holds" : "s that hold") + " it answered:\n";
252 const RollCallReplyKeys keys{info.hub_key, info.hub_key, false};
253 for (uint8_t i = 0; i < state.count; i++) {
254 const std::string device_id = node_id_to_string(state.responders[i].src);
255 report += format_roll_call_responder(state.responders[i], device_id, registry.get_declared(device_id), keys,
257 }
258 if (state.truncated)
259 report += "NOTE: more devices answered than the list holds; it is truncated.\n";
260 return report;
261}
262
263std::string build_pulled_key_report(const PulledKeyReportInfo &info, const ScanState &state, DeviceRegistry &registry) {
264 const std::string key_hex = detail::format_key_hex(info.key);
265 std::string report = "DEVICE KEY RECEIVED -- DO NOT SHARE IT\n";
266 report += "Device " + info.pairing_device_id + " (" + format_device_kind(info.type, info.subtype) + ", " +
267 format_device_ids(info.type, info.subtype) +
268 ") holds a different system key than this hub (key check value " +
270 (info.proven_by_sender ? " and proved it holds it.\n"
271 : ". It sent no proof of its own; the devices below answered under the key, "
272 "which proves it.\n");
273 report += "Anyone with this key can control every device that holds it. Keep it in secrets.yaml and write\n"
274 "`system_key: !secret ...` in the entity of each device.\n";
275 if (state.count == 0) {
276 report += "A roll-call under that key found no device. Put this key into the system_key: of the entity that\n"
277 "controls the device:\n"
278 " system_key: \"" +
279 key_hex + "\"\n";
280 } else {
281 report += std::to_string(state.count) + " device" + (state.count == 1 ? " that holds" : "s that hold") +
282 " it answered. Paste a block per device:\n";
283 const RollCallReplyKeys keys{info.key, info.hub_key, true};
284 for (uint8_t i = 0; i < state.count; i++) {
285 const std::string device_id = node_id_to_string(state.responders[i].src);
286 report += format_roll_call_responder(state.responders[i], device_id, registry.get_declared(device_id), keys,
288 }
289 if (state.truncated)
290 report += "NOTE: more devices answered than the list holds; it is truncated.\n";
291 }
292 report += "END OF DEVICE KEY REPORT";
293 return report;
294}
295
296} // namespace home_io_control
297} // 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:65
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:80
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:52
uint8_t discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
constexpr uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class 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: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
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:105
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: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
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, 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).
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:191
@ HIDDEN
Never print the key: its check value only. For anything Home Assistant records.
Definition roll_call.h:192
@ 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
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.
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: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
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 * 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
All runtime tunable parameters for pairing and radio diagnostics.
ScanPowerClasses scan_power_classes
Power classes the scan_paired_devices roll-call calls.