Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
management_actions.cpp
Go to the documentation of this file.
1/// @file management_actions.cpp
2/// @brief Hub-level management actions such as device rename, identify, and force-open.
3/// @ingroup hioc_hub
4///
5/// This file owns advanced management operations that are not part of the normal
6/// entity surface. Operations are exposed as ESPHome native API actions so Home
7/// Assistant can trigger them without adding always-visible helper entities. All
8/// actions share one API service descriptor (detail::ManagementServiceDescriptor);
9/// adding a new action is a registration call, not a new descriptor class.
10
11#include "management_actions.h"
12
13#include "hub_internal.h" // brings in hub_core.h + all internal helpers + logging
14#include "proto_commands.h"
15
16#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
17#include "esphome/core/helpers.h"
18#endif
19
20#include <algorithm>
21#include <array>
22#include <cctype>
23#include <cstdio>
24#include <cstring>
25#include <functional>
26#include <map>
27#include <vector>
28
29namespace esphome {
30namespace home_io_control {
31
32namespace {
33
34constexpr const char *MANAGEMENT_ACTION_RENAME_DEVICE = "rename_device";
35constexpr const char *MANAGEMENT_ACTION_IDENTIFY_DEVICE = "identify_device";
36constexpr const char *MANAGEMENT_ACTION_FORCE_OPEN_DEVICE = "force_open_device";
37constexpr const char *MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES = "scan_paired_devices";
38constexpr const char *MANAGEMENT_RESULT_EVENT = "esphome.home_io_control_action_result";
39constexpr size_t RESULT_CODE_BUFFER_SIZE = 5;
40constexpr size_t UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE = 64;
41
42/// @brief Maximum distinct responders reported by one scan_paired_devices() call.
43///
44/// Sized against the loop task's stack, which is where this runs: ESPHome spawns its own
45/// `loopTask` with `ESPHOME_LOOP_TASK_STACK_SIZE` (8192 B) — not the 3.5 KB ESP-IDF main task.
46/// `ScanResponder` is 12 B, so 24 slots cost ≈288 B (~3.5 % of that stack) in a single array;
47/// replies are decoded on arrival rather than buffered as whole 33 B `IoFrame`s, which is what
48/// makes a limit this size cheap. Installs with 20+ actuators are real, so 8 (the original
49/// value, chosen when whole frames were retained) was too low to be useful. Overflow beyond
50/// this is reported in the scan's own output, never silent.
51constexpr uint8_t SCAN_MAX_REPLIES = 24;
52
53/// @brief One roll-call responder, decoded on arrival.
54///
55/// Deliberately holds only what the report needs, so the fixed array stays small: the raw
56/// `IoFrame` is discarded as soon as decode_discovery_response() has run, and the hex device-ID
57/// string is rebuilt from `src` at format time rather than stored (a `std::string` member would
58/// cost more per entry than this whole struct).
59struct ScanResponder {
60 uint8_t src[NODE_ID_SIZE]; ///< Responder's node ID; also the dedup key.
61 DeviceType type; ///< Decoded device type.
62 uint8_t subtype; ///< Decoded device subtype.
63 bool inverted; ///< Decoded position-inversion flag.
64 int16_t rssi_dbm; ///< RSSI of the reply that produced this entry.
65 uint8_t manufacturer; ///< Raw manufacturer ID; name via manufacturer_name().
66 uint8_t flags; ///< Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
67 bool has_extended; ///< Whether manufacturer/flags above are present.
68 bool metadata_complete; ///< Whether type/subtype were present in the payload.
69};
70
71/// @brief Channels scan_paired_devices() transmits its roll-call request on, one attempt each.
72///
73/// A single broadcast on one fixed channel only reaches a paired device that happens to be
74/// awake and listening on that exact channel at that exact instant — real hardware testing
75/// found paired devices duty-cycle across all three channels independently of the hub, so a
76/// one-shot broadcast on CH2 alone misses whichever devices are elsewhere in their cycle right
77/// then. Retrying the same request on the other two channels gives every device up to three
78/// chances to be listening when the hub transmits. CH2 first since it is the protocol's
79/// designated TX channel (see FREQ_CH2's doc comment) and therefore the most likely to catch a
80/// reply on the first attempt.
81constexpr uint32_t SCAN_CHANNELS[] = {FREQ_CH2, FREQ_CH1, FREQ_CH3};
82constexpr uint8_t SCAN_CHANNEL_COUNT = sizeof(SCAN_CHANNELS) / sizeof(SCAN_CHANNELS[0]);
83
84} // namespace
85
86namespace detail {
87
88#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
89/// @brief Native API descriptor shared by every management action.
90///
91/// ESPHome 2026.x does not expose the generated YAML action helper runtime to external
92/// components, so Home IO Control registers the action descriptor directly with APIServer.
93/// This keeps the HA action surface identical to native ESPHome actions while avoiding
94/// the unresolved link path behind CustomAPIDevice::register_service().
95///
96/// One descriptor class serves every action: it is parametrized by name, argument list,
97/// and a callback that unpacks the request's string args and forwards them to a
98/// ManagementActions method. The callback captures a ManagementActions* and calls only
99/// public methods on it, so no friend declaration into the hub is needed. Adding action
100/// N+1 is therefore a new register_user_service() call in register_actions(), not a new
101/// descriptor class.
102class ManagementServiceDescriptor : public api::UserServiceDescriptor {
103 public:
104 ManagementServiceDescriptor(const char *name, std::vector<const char *> arg_names,
105 std::function<void(const api::ExecuteServiceRequest &)> callback)
106 : name_(name), key_(fnv1_hash(name)), arg_names_(std::move(arg_names)), callback_(std::move(callback)) {}
107
108 api::ListEntitiesServicesResponse encode_list_service_response() override {
109 api::ListEntitiesServicesResponse response;
110 response.name = StringRef(this->name_);
111 response.key = this->key_;
112 response.supports_response = api::enums::SUPPORTS_RESPONSE_NONE;
113 response.args.init(this->arg_names_.size());
114 for (const char *arg_name : this->arg_names_) {
115 auto &arg = response.args.emplace_back();
116 arg.name = StringRef(arg_name);
117 arg.type = api::enums::SERVICE_ARG_TYPE_STRING;
118 }
119 return response;
120 }
121
122 bool execute_service(const api::ExecuteServiceRequest &request) override {
123 if (request.key != this->key_ || request.args.size() != this->arg_names_.size())
124 return false;
125 this->callback_(request);
126 return true;
127 }
128
129#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
130 bool execute_service(const api::ExecuteServiceRequest &request, uint32_t) override {
131 return this->execute_service(request);
132 }
133#endif
134
135 protected:
136 const char *name_;
137 uint32_t key_;
138 std::vector<const char *> arg_names_;
139 std::function<void(const api::ExecuteServiceRequest &)> callback_;
140};
141#endif
142
143} // namespace detail
144
145// --- Helper free functions (file-local) ---
146
147static std::string normalize_device_id_argument(const std::string &device_id) {
148 std::string normalized = trim_ascii_whitespace(device_id);
149 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
150 [](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
151 return normalized;
152}
153
154static std::string bool_to_string(bool value) { return value ? "true" : "false"; }
155
156static std::string format_result_code(uint8_t result_code) {
157 std::array<char, RESULT_CODE_BUFFER_SIZE> buffer{};
158 std::snprintf(buffer.data(), buffer.size(), "%02X", result_code);
159 return std::string(buffer.data());
160}
161
162static ManagementActionResult make_management_result(const std::string &action, const std::string &device_id) {
164 result.action = action;
165 result.device_id = device_id;
166 return result;
167}
168
169/// @brief Log `prefix` followed by `message`, one line per log call rather than one call for the
170/// whole (possibly multi-line) string.
171///
172/// ESPHome formats each log call into a fixed 512-byte buffer (`ESPHOME_LOGGER_TX_BUFFER_SIZE`,
173/// esphome/core/defines.h) and silently truncates anything longer; scan_paired_devices()'s report
174/// exceeds that once it includes a YAML snippet, and would truncate mid-line if logged as a
175/// single call — confirmed on real hardware 2026-08-10, where a 3-device report cut off
176/// mid-snippet with no error and no indication anything was lost. Splitting by line keeps every
177/// individual call's payload small regardless of how long the full message is.
178/// The Home Assistant event (built from the same untruncated `std::string`, not from this log)
179/// is unaffected either way.
180/// @param tag Log tag.
181/// @param is_warning True to log at WARN, false for INFO.
182/// @param prefix Prepended to the message's first line only (e.g. "Management action X: ").
183/// @param message Message to log; may contain embedded `\n` line breaks.
184static void log_multiline_result(const char *tag, bool is_warning, const std::string &prefix,
185 const std::string &message) {
186 size_t start = 0;
187 bool first = true;
188 while (true) {
189 const size_t end = message.find('\n', start);
190 const std::string line = (end == std::string::npos) ? message.substr(start) : message.substr(start, end - start);
191 const std::string out = first ? prefix + line : line;
192 if (is_warning) {
193 ESP_LOGW(tag, "%s", out.c_str());
194 } else {
195 ESP_LOGI(tag, "%s", out.c_str());
196 }
197 first = false;
198 if (end == std::string::npos || end + 1 >= message.size())
199 break;
200 start = end + 1;
201 }
202}
203
204/// @brief Decode a CMD_ERROR_RESP frame's result code into `result`.
205///
206/// Populates has_result_code/result_code but deliberately leaves `result.message` untouched:
207/// rename and identify_device report different wording for the same decoded code, so message
208/// composition stays with each caller. On an empty error response, sets a stock message itself
209/// (there is no code to report) and returns false; callers should treat that the same way as a
210/// decoded code, just without result-code-specific wording.
211/// @param response Frame whose cmd is CMD_ERROR_RESP.
212/// @param result Result to populate.
213/// @return true if a result code was decoded, false if the response carried no data.
214static bool apply_error_response(const IoFrame &response, ManagementActionResult &result) {
215 if (response.data_len == 0) {
216 result.message = "device returned an empty error response";
217 return false;
218 }
219 result.has_result_code = true;
220 result.result_code = response.data[0];
221 return true;
222}
223
224// --- ManagementActions ---
225
226ManagementActions::ManagementActions(const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning,
227 ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized,
229 : node_id_(node_id),
230 system_key_(system_key),
231 tuning_(tuning),
232 engine_(engine),
233 registry_(registry),
234 initialized_(initialized),
235 hub_(hub) {}
236
238#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
239 if (api::global_api_server == nullptr) {
240 ESP_LOGW(detail::TAG, "Native API server not available, management actions will not be registered");
241 return;
242 }
243 api::global_api_server->register_user_service(new detail::ManagementServiceDescriptor( // NOLINT
244 MANAGEMENT_ACTION_RENAME_DEVICE, {"device_id", "new_name"}, [this](const api::ExecuteServiceRequest &request) {
245 this->api_rename_device(request.args[0].string_.str(), request.args[1].string_.str());
246 }));
247 api::global_api_server->register_user_service(new detail::ManagementServiceDescriptor( // NOLINT
248 MANAGEMENT_ACTION_IDENTIFY_DEVICE, {"device_id"},
249 [this](const api::ExecuteServiceRequest &request) { this->api_identify_device(request.args[0].string_.str()); }));
250 api::global_api_server->register_user_service(new detail::ManagementServiceDescriptor( // NOLINT
251 MANAGEMENT_ACTION_FORCE_OPEN_DEVICE, {"device_id"}, [this](const api::ExecuteServiceRequest &request) {
252 this->api_force_open_device(request.args[0].string_.str());
253 }));
254 api::global_api_server->register_user_service(new detail::ManagementServiceDescriptor( // NOLINT
255 MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES, {},
256 [this](const api::ExecuteServiceRequest &) { this->api_scan_paired_devices(); }));
257#endif
258}
259
260IoDevice *ManagementActions::resolve_device_(const char *action, const std::string &device_id,
261 ManagementActionResult &result) {
262 const std::string normalized_device_id = normalize_device_id_argument(device_id);
263 result = make_management_result(action, normalized_device_id);
264
265 if (!*initialized_) {
266 result.message = "hub is not initialized";
267 return nullptr;
268 }
269
270 uint8_t parsed_device_id[NODE_ID_SIZE]{};
271 if (!hex_to_bytes(normalized_device_id, parsed_device_id, NODE_ID_SIZE)) {
272 result.message = "device ID must be exactly 6 hexadecimal characters";
273 return nullptr;
274 }
275
276 auto *dev = registry_.get(normalized_device_id);
277 if (dev == nullptr) {
278 result.message = "device is not registered on this hub";
279 return nullptr;
280 }
281
282 return dev;
283}
284
285bool ManagementActions::send_authenticated_request_(const IoFrame &request, IoFrame &response, const char *action_verb,
286 ManagementActionResult &result) {
287 if (engine_.send_and_receive(request, response, FREQ_CH2))
288 return true;
289 engine_.log_debug(result.device_id.c_str());
290 result.message = std::string("no valid response to ") + action_verb + " request";
291 return false;
292}
293
294void ManagementActions::api_rename_device(const std::string &device_id, const std::string &new_name) {
295 publish_result(rename_device(device_id, new_name));
296}
297
299 // device_id is empty for actions with no single target (e.g. scan_paired_devices' roll-call);
300 // the "for device %s" clause is omitted rather than rendering as "for device :".
301 const bool has_device = !result.device_id.empty();
302 std::string prefix = "Management action " + result.action;
303 if (has_device)
304 prefix += " for device " + result.device_id;
305 prefix += result.success ? ": " : " failed: ";
306 log_multiline_result(detail::TAG, !result.success, prefix, result.message);
307
308 if (!hub_->is_connected())
309 return;
310
311 std::map<std::string, std::string> event_data{{"action", result.action},
312 {"device_id", result.device_id},
313 {"success", bool_to_string(result.success)},
314 {"verified", bool_to_string(result.verified)},
315 {"message", result.message}};
316
317 if (!result.requested_name.empty())
318 event_data["requested_name"] = result.requested_name;
319 if (!result.applied_name.empty())
320 event_data["applied_name"] = result.applied_name;
321 if (result.has_result_code) {
322 event_data["result_code"] = format_result_code(result.result_code);
323 event_data["result_code_name"] = command_result_name(result.result_code);
324 }
325
326 hub_->fire_homeassistant_event(MANAGEMENT_RESULT_EVENT, event_data);
327}
328
329ManagementActionResult ManagementActions::rename_device(const std::string &device_id, const std::string &new_name) {
331 auto *dev = resolve_device_(MANAGEMENT_ACTION_RENAME_DEVICE, device_id, result);
332 if (dev == nullptr)
333 return result;
334
335 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE];
336 std::string normalized_name;
337 const DeviceNameValidationError name_error = encode_device_name_payload(new_name, payload, normalized_name);
338 result.requested_name = normalized_name.empty() ? trim_ascii_whitespace(new_name) : normalized_name;
339 if (name_error != DeviceNameValidationError::NONE) {
341 return result;
342 }
343
344 IoFrame request;
345 if (!create_set_name(request, node_id_, dev->node_id, payload)) {
346 result.message = "failed to build rename request";
347 return result;
348 }
349
350 IoFrame response;
351 if (!send_authenticated_request_(request, response, "rename", result))
352 return result;
353
354 if (response.cmd == CMD_ERROR_RESP) {
355 if (apply_error_response(response, result)) {
356 result.message =
357 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
358 }
359 return result;
360 }
361
362 if (response.cmd != CMD_SET_NAME_RESP) {
363 std::array<char, UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE> buffer{};
364 std::snprintf(buffer.data(), buffer.size(), "unexpected rename response 0x%02X", response.cmd);
365 result.message = buffer.data();
366 return result;
367 }
368
369 result.success = true;
370 result.message = "rename acknowledged by device";
371
372 if (!hub_->request_device_name(result.device_id)) {
373 result.message = "rename acknowledged but verification readback failed";
374 return result;
375 }
376
377 auto *updated_device = registry_.get(result.device_id);
378 if (updated_device != nullptr)
379 result.applied_name = updated_device->name;
380
381 if (result.applied_name == normalized_name) {
382 result.verified = true;
383 result.message = "rename verified by device readback";
384 return result;
385 }
386
387 result.message = "rename acknowledged but readback did not match the requested name";
388 return result;
389}
390
391void ManagementActions::api_identify_device(const std::string &device_id) {
393}
394
397 // Deliberately no device-type gating beyond "registered on this hub" — see the doxygen note on
398 // the declaration for why.
399 auto *dev = resolve_device_(MANAGEMENT_ACTION_IDENTIFY_DEVICE, device_id, result);
400 if (dev == nullptr)
401 return result;
402
403 IoFrame request;
404 if (!create_identify(request, node_id_, dev->node_id)) {
405 result.message = "failed to build identify request";
406 return result;
407 }
408
409 IoFrame response;
410 if (!send_authenticated_request_(request, response, "identify", result))
411 return result;
412
413 if (response.cmd == CMD_ERROR_RESP) {
414 // Deliberate deviation from rename's error handling: a device may answer CMD_IDENTIFY with
415 // CMD_ERROR_RESP and still have performed the jog, so this counts as success, not failure.
416 result.success = true;
417 if (apply_error_response(response, result)) {
418 result.message = "identify triggered (device reported " + std::string(command_result_name(result.result_code)) +
419 ": " + command_result_description(result.result_code) + ")";
420 } else {
421 result.message = "identify triggered (device returned an empty error response)";
422 }
423 return result;
424 }
425
426 // Any other endpoint-matched reply counts as acknowledgment; unlike rename there is no specific
427 // response command to check against, and no readback exists to set `verified`.
428 result.success = true;
429 result.message = "identify acknowledged by device";
430 return result;
431}
432
433void ManagementActions::api_force_open_device(const std::string &device_id) {
435}
436
439 auto *dev = resolve_device_(MANAGEMENT_ACTION_FORCE_OPEN_DEVICE, device_id, result);
440 if (dev == nullptr)
441 return result;
442
443 // Delegate to the hub's normal cover-command dispatch path (capability gating, poll tracking,
444 // settle handling, backoff already live there) instead of talking to the radio directly.
445 if (!hub_->queue_device_command(result.device_id, CoverCommand::FORCE_OPEN)) {
446 result.message = "device does not accept cover commands";
447 return result;
448 }
449
450 result.success = true;
451 result.message =
452 "force open queued (elevated-priority open; wind/rain lock bypass unconfirmed; movement result arrives via "
453 "cover state)";
454 return result;
455}
456
458
459/// @brief Format one roll-call responder's report line(s).
460///
461/// Known responders get a single summary line; unknown responders get the same summary line
462/// plus a lead-in sentence and a ready-to-paste YAML block (or, if the decoded type has no
463/// ESPHome platform, an explanatory line instead of a blank) — the same "paste this in" framing
464/// a successful pairing prints.
465/// @param responder Decoded responder record.
466/// @param device_id Hex device ID string for this responder, rebuilt from `responder.src`.
467/// @param known True if this device is already registered on this hub.
468static std::string format_scan_reply_line(const ScanResponder &responder, const std::string &device_id, bool known) {
469 std::string line = " " + device_id + ": " + device_type_name(responder.type) +
470 " subtype=" + std::to_string(responder.subtype) + " rssi=" + std::to_string(responder.rssi_dbm) +
471 "dBm";
472 if (responder.has_extended) {
473 uint8_t const att = discovery_att_class(responder.flags);
474 uint8_t const power_save = discovery_power_save_mode(responder.flags);
475 line += std::string(" manufacturer=") + manufacturer_name(responder.manufacturer) +
476 " turnaround=" + att_class_name(att) + " power_save=" + power_save_mode_name(power_save);
477 }
478 line += known ? " [known]\n" : " [unknown]\n";
479
480 if (known)
481 return line;
482
483 const std::string snippet = build_device_yaml_snippet(responder.type, responder.subtype, device_id,
484 responder.metadata_complete, responder.inverted);
485 if (!snippet.empty())
486 return line + " Paste this into your YAML to register it:\n" + snippet;
487
488 return line + " no ready-to-paste YAML: no ESPHome platform for io_device_type: " +
489 format_device_type_for_yaml(responder.type) + "\n";
490}
491
492/// @brief Outcome of add_scan_responder(), so callers can tell a harmless repeat from real loss.
493enum class ScanAddResult : uint8_t {
494 ADDED, ///< New responder recorded.
495 DUPLICATE, ///< Already recorded from an earlier reply; nothing changed.
496 FULL, ///< Dropped: SCAN_MAX_REPLIES distinct responders already recorded.
497};
498
499/// @brief Record a responder unless its address is already present.
500///
501/// Deduplication is by node ID across the whole scan, so a device that answers several of the
502/// three attempts — or twice inside one attempt — still yields one entry. The duplicate check
503/// runs before the capacity check so that repeat replies from already-recorded devices never
504/// look like overflow once the array is full.
505/// @param responders Accumulated array, appended to in place.
506/// @param count In: entries already present. Out: updated count.
507/// @param capacity Maximum entries `responders` can hold.
508/// @param frame Reply frame to decode and store.
509/// @param rssi_dbm RSSI of that reply.
510/// @return Which of the three outcomes occurred.
511static ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity,
512 const IoFrame &frame, int16_t rssi_dbm) {
513 for (uint8_t i = 0; i < count; i++) {
514 if (memcmp(responders[i].src, frame.src, NODE_ID_SIZE) == 0)
516 }
517 if (count >= capacity)
518 return ScanAddResult::FULL;
519
520 // decode_discovery_response() also produces the hex device-ID string, which is deliberately
521 // discarded here and rebuilt from `src` when the report is formatted. Keeping it would mean a
522 // std::string per entry — more memory than the entire ScanResponder — to save re-deriving six
523 // characters that fit in a small-string buffer. Do not "optimise" this by adding a string member.
524 IoDevice device{};
525 std::string unused_device_id;
526 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, unused_device_id);
527
528 ScanResponder &entry = responders[count++];
529 memcpy(entry.src, frame.src, NODE_ID_SIZE);
530 entry.type = device.type;
531 entry.subtype = device.subtype;
532 entry.inverted = device.inverted;
533 entry.rssi_dbm = rssi_dbm;
534 entry.manufacturer = info.manufacturer;
535 entry.flags = info.flags;
536 entry.has_extended = info.has_extended;
537 entry.metadata_complete = info.metadata_complete;
539}
540
542 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES, "");
543
544 if (!*initialized_) {
545 result.message = "hub is not initialized";
546 return result;
547 }
548
549 // One attempt per channel (see SCAN_CHANNELS), deduplicating responders across attempts — a
550 // device that is awake and replies to more than one attempt must still only appear once in the
551 // report. Each attempt gets a fresh request (create_discovery_request() draws a new random
552 // nonce every call) rather than replaying the same frame three times. Every channel is always
553 // tried, even once the array is full: stopping early would silently skip channels, which is
554 // exactly the single-channel behaviour the three attempts exist to avoid.
555 ScanResponder responders[SCAN_MAX_REPLIES];
556 uint8_t count = 0;
557 bool truncated = false;
558 for (uint8_t attempt = 0; attempt < SCAN_CHANNEL_COUNT; attempt++) {
559 IoFrame request;
560 if (!create_discovery_request(request, node_id_, CMD_DISCOVER_SPE_REQ, BROADCAST_DISCOVER, /*low_power=*/false,
561 /*payload_enabled=*/false, /*payload=*/0, system_key_)) {
562 result.message = "failed to build roll-call request";
563 return result;
564 }
565
566 const uint8_t before = count;
567 const uint8_t heard = engine_.collect_broadcast_responses(
568 request, SCAN_CHANNELS[attempt], CMD_DISCOVER_SPE_RESP, tuning_->pairing_discovery_wait_ms,
569 [&responders, &count, &truncated](const IoFrame &frame, int16_t rssi_dbm) {
570 if (add_scan_responder(responders, count, SCAN_MAX_REPLIES, frame, rssi_dbm) == ScanAddResult::FULL) {
571 truncated = true;
572 }
573 });
574 // Diagnostic only (not part of the user-facing report). Reporting heard and new separately is
575 // what makes it useful: "heard 3, 0 new" means devices are answering every attempt (so the
576 // extra channels are redundant here), while "heard 0" on a channel means nothing was listening
577 // there — the channel-alignment vs. duty-cycle question a single number cannot answer.
578 const uint8_t new_count = count - before;
579 ESP_LOGD(detail::TAG, "Roll-call attempt %u/%u (%" PRIu32 " Hz, %u ms window): %u repl%s heard, %u new",
580 attempt + 1, SCAN_CHANNEL_COUNT, SCAN_CHANNELS[attempt], tuning_->pairing_discovery_wait_ms, heard,
581 heard == 1 ? "y" : "ies", new_count);
582 }
583
584 // Grouped into known-first, unknown-second rather than interleaved in arrival order: the two
585 // groups need very different follow-up (nothing to do vs. paste a YAML block), so burying an
586 // unknown responder between two known ones makes it easy to miss.
587 std::string known_body;
588 std::string unknown_body;
589 uint8_t unknown_count = 0;
590 for (uint8_t i = 0; i < count; i++) {
591 const std::string device_id = node_id_to_string(responders[i].src);
592 const bool known = registry_.get(device_id) != nullptr;
593 if (known) {
594 known_body += format_scan_reply_line(responders[i], device_id, known);
595 } else {
596 unknown_count++;
597 unknown_body += format_scan_reply_line(responders[i], device_id, known);
598 }
599 }
600
601 std::string body;
602 if (!known_body.empty())
603 body += "Known:\n" + known_body;
604 if (!unknown_body.empty())
605 body += "Unknown:\n" + unknown_body;
606
607 result.success = true;
608 result.message = "Roll-call: " + std::to_string(count) + " device" + (count == 1 ? "" : "s") + " detected (" +
609 std::to_string(count - unknown_count) + " known, " + std::to_string(unknown_count) + " unknown)\n";
610 // Surfaced in the report itself, not only the log: a scan that silently listed a subset would
611 // look like devices had gone missing.
612 if (truncated) {
613 result.message += "NOTE: more than " + std::to_string(SCAN_MAX_REPLIES) +
614 " devices answered; the list below is truncated. Re-run to see whether other devices "
615 "appear, and raise SCAN_MAX_REPLIES if this install really is larger.\n";
616 }
617 result.message += body;
618 return result;
619}
620
621} // namespace home_io_control
622} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
Authenticated exchange engine — outbound and inbound protocol flows.
The main IO-Homecontrol component.
Definition hub_core.h:74
void register_actions()
Register all management actions (rename, identify, force-open, ...) with ESPHome's native API server.
void api_rename_device(const std::string &device_id, const std::string &new_name)
Native API callback: rename a device and publish the result as a HA event.
ManagementActionResult scan_paired_devices()
Broadcast a roll-call and report every device that answers.
ManagementActionResult rename_device(const std::string &device_id, const std::string &new_name)
Rename a registered device and verify the result by reading the name back.
void publish_result(const ManagementActionResult &result)
Publish a management result as one or more structured log lines (one call per line of result....
void api_force_open_device(const std::string &device_id)
Native API callback: force-open a device and publish the result as a HA event.
ManagementActions(const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning, ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized, IOHomeControlComponent *hub)
Construct with all required collaborators.
ManagementActionResult identify_device(const std::string &device_id)
Trigger a registered device's physical identify (brief jog/flash).
ManagementActionResult force_open_device(const std::string &device_id)
Move a registered cover device to fully open at elevated priority, intended to bypass wind/rain soft ...
void api_scan_paired_devices()
Native API callback: run a roll-call scan and publish the result as a HA event.
void api_identify_device(const std::string &device_id)
Native API callback: trigger a device's physical identify and publish the result as a HA event.
Internal helpers shared by the hub implementation .cpp files.
Hub-level management operations exposed as Home Assistant actions.
constexpr const char * TAG
Shared log tag for hub-level messages.
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
uint8_t discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
static ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity, const IoFrame &frame, int16_t rssi_dbm)
Record a responder unless its address is already present.
static constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
ScanAddResult
Outcome of add_scan_responder(), so callers can tell a harmless repeat from real loss.
@ DUPLICATE
Already recorded from an earlier reply; nothing changed.
@ FULL
Dropped: SCAN_MAX_REPLIES distinct responders already recorded.
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
const char * att_class_name(uint8_t att_class)
Get a human-readable turnaround time string for an ATT class value.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
static ManagementActionResult make_management_result(const std::string &action, const std::string &device_id)
@ FORCE_OPEN
Move to fully open at elevated priority; intended to bypass soft locks and environmental limits (conf...
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static std::string bool_to_string(bool value)
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
static constexpr uint8_t CMD_DISCOVER_SPE_RESP
Roll-call reply to CMD_DISCOVER_SPE_REQ, sent only by devices that already hold the requesting contro...
static constexpr uint8_t DEVICE_NAME_WRITE_PAYLOAD_SIZE
Fixed write payload: 15 visible chars plus trailing null/padding.
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
const char * command_result_name(uint8_t result)
Return a stable symbolic name for a CMD_ERROR_RESP result code.
static constexpr uint8_t CMD_SET_NAME_RESP
Device-name write response.
DeviceNameValidationError
Validation result for outbound device-name writes.
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
static constexpr uint8_t CMD_DISCOVER_SPE_REQ
Broadcast roll-call answered by every device that already holds this controller's system key,...
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
DeviceNameValidationError encode_device_name_payload(const std::string &name, uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE], std::string &normalized_name)
Validate and encode a user-supplied UTF-8 device name into the fixed Latin-1 write payload.
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.
static constexpr uint8_t BROADCAST_DISCOVER[NODE_ID_SIZE]
Broadcast address for device discovery (0x00003B).
static std::string format_scan_reply_line(const ScanResponder &responder, const std::string &device_id, bool known)
Format one roll-call responder's report line(s).
bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build an authenticated device-identify request (0x1E).
bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE])
Build an authenticated set-name request (0x52) using a fixed zero-padded Latin-1 payload.
uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
static std::string normalize_device_id_argument(const std::string &device_id)
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...
static std::string format_result_code(uint8_t result_code)
const char * device_name_validation_error_description(DeviceNameValidationError error)
Return a human-readable explanation for a device-name validation result.
static bool apply_error_response(const IoFrame &response, ManagementActionResult &result)
Decode a CMD_ERROR_RESP frame's result code into result.
bool hex_to_bytes(const std::string &hex, uint8_t *out, uint8_t len)
Convert a hex string (e.g., "123ABC") to a byte array.
static void log_multiline_result(const char *tag, bool is_warning, const std::string &prefix, const std::string &message)
Log prefix followed by message, one line per log call rather than one call for the whole (possibly mu...
Command builders for the IO‑Homecontrol protocol.
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.
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:71
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
Definition proto_frame.h:77
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:75
uint8_t data_len
Actual length of data.
Definition proto_frame.h:78
Result of a hub-level management action such as rename.
bool verified
Whether a follow-up readback confirmed the applied state.
std::string device_id
Target IO-homecontrol device ID.
uint8_t result_code
Optional CMD_ERROR_RESP result byte.
std::string action
Action name, e.g. "rename_device".
std::string applied_name
Verified cached UTF-8 name after a readback, when available.
bool success
Whether the requested management action succeeded.
bool has_result_code
True when result_code contains a decoded CMD_ERROR_RESP byte.
std::string message
Human-readable outcome summary.
std::string requested_name
Requested normalized UTF-8 name for rename actions.
All runtime tunable parameters for pairing and radio diagnostics.