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 "entity_helpers.h"
14#include "hub_core.h"
15#include "log_helpers.h"
16#include "proto_commands.h"
17#include "proto_device_types.h"
18#include "roll_call.h"
19
20#include "esphome/core/hal.h"
21
22#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
23#include "esphome/core/helpers.h"
24#endif
25
26#include <algorithm>
27#include <array>
28#include <cctype>
29#include <cerrno>
30#include <cmath>
31#include <cstdio>
32#include <cstdlib>
33#include <cstring>
34#include <functional>
35#include <map>
36#include <span>
37#include <type_traits>
38#include <vector>
39
40namespace esphome {
41namespace home_io_control {
42
43namespace {
44
45constexpr const char *MANAGEMENT_ACTION_RENAME_DEVICE = "rename_device";
46constexpr const char *MANAGEMENT_ACTION_IDENTIFY_DEVICE = "identify_device";
47constexpr const char *MANAGEMENT_ACTION_FORCE_OPEN_DEVICE = "force_open_device";
48constexpr const char *MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES = "scan_paired_devices";
49constexpr const char *MANAGEMENT_ACTION_ONEWAY_SET_POSITION = "oneway_set_position";
50constexpr const char *MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER = "oneway_remove_controller";
51constexpr const char *MANAGEMENT_ACTION_HEATING_CONTROL = "heating_control";
52constexpr const char *MANAGEMENT_RESULT_EVENT = "esphome.home_io_control_action_result";
53constexpr size_t UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE = 64;
54constexpr size_t HEATING_RANGE_MESSAGE_BUFFER_SIZE = 64;
55
56} // namespace
57
58namespace detail {
59
60#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
61
62/// True when the installed ESPHome's `UserServiceDescriptor::encode_list_service_response()`
63/// takes a `std::span<char>` scratch buffer (present in some 2026.9 betas, absent on stable and
64/// current dev). Detected from the base class itself so no version gate can drift.
65template<typename Base>
66concept EncodeTakesScratch = requires(Base &base) { base.encode_list_service_response(std::span<char>{}); };
67
68/// @brief Bridges the version-dependent `encode_list_service_response` signature.
69///
70/// Only the specialization matching the installed base is ever instantiated (the `Base`
71/// parameter keeps the `override` checks dependent), and both forms forward to
72/// `build_list_response()`, which the concrete descriptor implements.
73template<bool TakesScratch, typename Base = api::UserServiceDescriptor> class EncodeListAdapter : public Base {
74 public:
75 api::ListEntitiesServicesResponse encode_list_service_response() override { return this->build_list_response(); }
76
77 protected:
78 virtual api::ListEntitiesServicesResponse build_list_response() = 0;
79};
80
81template<typename Base> class EncodeListAdapter<true, Base> : public Base {
82 public:
83 api::ListEntitiesServicesResponse encode_list_service_response(std::span<char> /*scratch*/) override {
84 return this->build_list_response();
85 }
86
87 protected:
88 virtual api::ListEntitiesServicesResponse build_list_response() = 0;
89};
90
91/// @brief Native API descriptor shared by every management action.
92///
93/// ESPHome 2026.x does not expose the generated YAML action helper runtime to external
94/// components, so Home IO Control registers the action descriptor directly with APIServer.
95/// This keeps the HA action surface identical to native ESPHome actions while avoiding
96/// the unresolved link path behind CustomAPIDevice::register_service().
97///
98/// One descriptor class serves every action: it is parametrized by name, argument list,
99/// and a callback that unpacks the request's string args and forwards them to a
100/// ManagementActions method. The callback captures a ManagementActions* and calls only
101/// public methods on it, so no friend declaration into the hub is needed. Adding action
102/// N+1 is therefore a new register_user_service() call in register_actions(), not a new
103/// descriptor class.
104class ManagementServiceDescriptor : public EncodeListAdapter<EncodeTakesScratch<api::UserServiceDescriptor>> {
105 public:
106 ManagementServiceDescriptor(const char *name, std::vector<const char *> arg_names,
107 std::function<void(const api::ExecuteServiceRequest &)> callback)
108 : name_(name), key_(fnv1_hash(name)), arg_names_(std::move(arg_names)), callback_(std::move(callback)) {}
109
110 bool execute_service(const api::ExecuteServiceRequest &request) override {
111 if (request.key != this->key_ || request.args.size() != this->arg_names_.size())
112 return false;
113 this->callback_(request);
114 return true;
115 }
116
117#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
118 bool execute_service(const api::ExecuteServiceRequest &request, uint32_t) override {
119 return this->execute_service(request);
120 }
121#endif
122
123 protected:
124 /// Wrap a literal in whatever string type the message field uses: StringRef on older
125 /// ESPHome, ProgmemStringRef (explicit from StringRef, not assignable) on newer.
126 template<typename T> static T make_ref(const char *s) {
127 if constexpr (std::is_assignable_v<T &, StringRef>) {
128 return StringRef(s);
129 } else {
130 return T(StringRef(s));
131 }
132 }
133
134 // name_ and arg_names_ point at ordinary (non-PROGMEM) .rodata string literals, so the
135 // refs stay valid after return on any target and any scratch buffer is unused.
136 api::ListEntitiesServicesResponse build_list_response() override {
137 api::ListEntitiesServicesResponse response;
138 response.name = make_ref<decltype(response.name)>(this->name_);
139 response.key = this->key_;
140 response.supports_response = api::enums::SUPPORTS_RESPONSE_NONE;
141 response.args.init(this->arg_names_.size());
142 for (const char *arg_name : this->arg_names_) {
143 auto &arg = response.args.emplace_back();
144 arg.name = make_ref<decltype(arg.name)>(arg_name);
145 arg.type = api::enums::SERVICE_ARG_TYPE_STRING;
146 }
147 return response;
148 }
149
150 const char *name_;
151 uint32_t key_;
152 std::vector<const char *> arg_names_;
153 std::function<void(const api::ExecuteServiceRequest &)> callback_;
154};
155
156#endif
157
158} // namespace detail
159
160// --- Helper free functions (file-local) ---
161
162static std::string bool_to_string(bool value) { return value ? "true" : "false"; }
163
164/// @brief Lowercase + ASCII-trim a native-API string argument.
165static std::string normalize_lower_argument(const std::string &value) {
166 std::string normalized = trim_ascii_whitespace(value);
167 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
168 [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
169 return normalized;
170}
171
172/// @brief A `value` token that maps to a fixed float (used by set_mode / set_presence /
173/// set_open_window_detection).
175 const char *token;
176 float value;
177};
178
179/// @brief Match `token` against `table`; on a hit set `out` and return true.
180static bool match_heating_named_value(const std::string &token, const HeatingNamedValue *table, size_t table_len,
181 float &out) {
182 for (size_t i = 0; i < table_len; i++) {
183 if (token == table[i].token) {
184 out = table[i].value;
185 return true;
186 }
187 }
188 return false;
189}
190
191/// @brief Parse `set_temperature`'s value into degrees Celsius, range-checked.
192static bool parse_heating_temperature(const std::string &value, float &value_out, std::string &error) {
193 const std::string text = trim_ascii_whitespace(value);
194 errno = 0;
195 // NOLINTNEXTLINE(misc-const-correctness): strtoul()/strtof() take a `char **` end pointer.
196 char *end = nullptr;
197 const float parsed = std::strtof(text.c_str(), &end);
198 if (text.empty() || end != text.c_str() + text.size() || errno == ERANGE || !std::isfinite(parsed)) {
199 error = "temperature must be a number in degrees Celsius";
200 return false;
201 }
202 if (parsed < HEATING_TEMP_MIN_C || parsed > HEATING_TEMP_MAX_C) {
203 std::array<char, HEATING_RANGE_MESSAGE_BUFFER_SIZE> buffer{};
204 std::snprintf(buffer.data(), buffer.size(), "temperature must be between %.1f and %.1f",
205 static_cast<double>(HEATING_TEMP_MIN_C), static_cast<double>(HEATING_TEMP_MAX_C));
206 error = buffer.data();
207 return false;
208 }
209 value_out = parsed;
210 return true;
211}
212
213/// @brief Parse the (`function`, `value`) argument pair of the `heating_control` action.
214///
215/// Every native-API argument is a string (ManagementServiceDescriptor hardcodes
216/// SERVICE_ARG_TYPE_STRING), so this turns the two strings into a HeatingFunction plus the float
217/// encode_heating_payload() expects: degrees C for `set_temperature`, a HeatingMode value for
218/// `set_mode`, 0/1 for `set_presence` / `set_open_window_detection`, and an ignored 0 for the
219/// register reads (`power_on`, `midnight_sync`, `window_open`). On any malformed input it fills `error` with a
220/// caller-facing message and returns false rather than coercing a value. Kept next to the action (not in proto_heating)
221/// because the climate entity receives typed enums from Home Assistant and needs no string
222/// parsing.
223/// @param function Function name argument.
224/// @param value Value argument.
225/// @param fn_out Parsed function on success.
226/// @param value_out Parsed value on success (0 for the value-less functions).
227/// @param error Caller-facing message on failure.
228/// @return true on a fully valid pair.
229static bool parse_heating_arguments(const std::string &function, const std::string &value, HeatingFunction &fn_out,
230 float &value_out, std::string &error) {
231 static constexpr HeatingNamedValue MODE_VALUES[] = {
232 {"auto", static_cast<float>(HeatingMode::AUTO)},
233 {"manual", static_cast<float>(HeatingMode::MANUAL)},
234 {"prog", static_cast<float>(HeatingMode::PROG)},
235 {"off", static_cast<float>(HeatingMode::OFF)},
236 };
237 static constexpr HeatingNamedValue PRESENCE_VALUES[] = {{"on", 1.0F}, {"off", 0.0F}};
238 static constexpr HeatingNamedValue DETECTION_VALUES[] = {{"on", 1.0F}, {"off", 0.0F}};
239
240 const std::string fn = normalize_lower_argument(function);
241 value_out = 0.0F;
242
243 if (fn == "power_on") {
245 return true;
246 }
247 if (fn == "midnight_sync") {
249 return true;
250 }
251 if (fn == "set_temperature") {
253 return parse_heating_temperature(value, value_out, error);
254 }
255 if (fn == "set_mode") {
257 if (match_heating_named_value(normalize_lower_argument(value), MODE_VALUES, std::size(MODE_VALUES), value_out))
258 return true;
259 error = "mode must be one of auto, manual, prog, off";
260 return false;
261 }
262 if (fn == "set_presence") {
264 if (match_heating_named_value(normalize_lower_argument(value), PRESENCE_VALUES, std::size(PRESENCE_VALUES),
265 value_out))
266 return true;
267 error = "presence must be 'on' or 'off'";
268 return false;
269 }
270 if (fn == "set_open_window_detection") {
272 if (match_heating_named_value(normalize_lower_argument(value), DETECTION_VALUES, std::size(DETECTION_VALUES),
273 value_out))
274 return true;
275 error = "open-window detection must be 'on' or 'off'";
276 return false;
277 }
278 if (fn == "window_open") {
280 return true;
281 }
282
283 error = "unknown heating function '" + trim_ascii_whitespace(function) + "'";
284 return false;
285}
286
287// --- ManagementActions ---
288
289ManagementActions::ManagementActions(const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning,
290 ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized,
292 : node_id_(node_id),
293 system_key_(system_key),
294 tuning_(tuning),
295 engine_(engine),
296 registry_(registry),
297 initialized_(initialized),
298 hub_(hub),
299 probes_(node_id, engine, registry, initialized,
300 DiagnosticProbesHub{[hub]() { return hub->diagnostic_probes_enabled(); },
301 [hub]() { return hub->get_radio(); },
302 [hub](const IoFrame &reply) { return hub->record_probe_reply_event(reply); }}) {}
303
305#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
306 if (api::global_api_server == nullptr) {
307 ESP_LOGW(detail::TAG, "Native API server not available, management actions will not be registered");
308 return;
309 }
310
311 // One row per user-visible action: name, argument-name list (every argument is exposed as
312 // SERVICE_ARG_TYPE_STRING — `oneway_set_position` parses its "position" string itself rather
313 // than widening a shipped API surface), whether it is gated behind `diagnostic_probes: true`,
314 // and the callback that unpacks its arguments. Adding an action is one row here.
315 struct ActionReg {
316 const char *name;
317 std::vector<const char *> arg_names;
318 bool diagnostic_only;
319 std::function<void(const api::ExecuteServiceRequest &)> callback;
320 };
321 const ActionReg actions[] = {
322 {MANAGEMENT_ACTION_RENAME_DEVICE,
323 {"device_id", "new_name"},
324 false,
325 [this](const api::ExecuteServiceRequest &r) {
326 this->api_rename_device(r.args[0].string_.str(), r.args[1].string_.str());
327 }},
328 {MANAGEMENT_ACTION_IDENTIFY_DEVICE,
329 {"device_id"},
330 false,
331 [this](const api::ExecuteServiceRequest &r) { this->api_identify_device(r.args[0].string_.str()); }},
332 {MANAGEMENT_ACTION_FORCE_OPEN_DEVICE,
333 {"device_id"},
334 false,
335 [this](const api::ExecuteServiceRequest &r) { this->api_force_open_device(r.args[0].string_.str()); }},
336 {MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES,
337 {},
338 false,
339 [this](const api::ExecuteServiceRequest &) { this->api_scan_paired_devices(); }},
340 {MANAGEMENT_ACTION_ONEWAY_SET_POSITION,
341 {"controller_id", "position"},
342 false,
343 [this](const api::ExecuteServiceRequest &r) {
344 this->api_oneway_set_position(r.args[0].string_.str(), r.args[1].string_.str());
345 }},
346 {MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER,
347 {"controller_id"},
348 false,
349 [this](const api::ExecuteServiceRequest &r) { this->api_oneway_remove_controller(r.args[0].string_.str()); }},
350 {MANAGEMENT_ACTION_HEATING_CONTROL,
351 {"device_id", "function", "value"},
352 false, // a real user feature, gated by documentation not by diagnostic_probes:
353 [this](const api::ExecuteServiceRequest &r) {
354 this->api_heating_control(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
355 }},
357 {"device_id", "probe", "index"},
358 true,
359 [this](const api::ExecuteServiceRequest &r) {
360 this->api_probe_device(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
361 }},
363 {"device_id", "probe", "first_index", "last_index"},
364 true,
365 [this](const api::ExecuteServiceRequest &r) {
366 this->api_probe_sweep(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str(),
367 r.args[3].string_.str());
368 }},
369 };
370
371 // The two probe actions are registered only when diagnostic_probes: true was set in YAML, so the
372 // action list stays clean on a default build. diagnostic_probes_enabled() already holds its
373 // final YAML-configured value here: __init__.py's to_code() emits set_diagnostic_probes_enabled()
374 // as a plain property-setter call in generated main.cpp, which runs before App.setup() calls this
375 // component's setup() (and therefore this method), not after.
376 const bool probes_enabled = hub_->diagnostic_probes_enabled();
377 for (const auto &action : actions) {
378 if (action.diagnostic_only && !probes_enabled)
379 continue;
380 api::global_api_server->register_user_service( // NOLINT
381 new detail::ManagementServiceDescriptor(action.name, action.arg_names, action.callback));
382 }
383#endif
384}
385
386IoDevice *ManagementActions::resolve_device_(const char *action, const std::string &device_id,
387 ManagementActionResult &result) {
388 return resolve_management_device(action, device_id, *initialized_, registry_, result);
389}
390
391bool ManagementActions::send_authenticated_request_(const IoFrame &request, IoFrame &response, const char *action_verb,
392 ManagementActionResult &result) {
393 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
394 if (outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE)
395 return true;
396 engine_.log_debug(result.device_id.c_str());
397 // A management action's whole point is the payload it reads back (a name, an info block), so an
398 // unconfirmed acceptance still cannot satisfy the caller — but it is a materially different
399 // situation from silence, and saying so saves the user chasing a link problem that isn't one.
400 result.message = outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED
401 ? std::string("device accepted the ") + action_verb + " request but sent no response"
402 : std::string("no valid response to ") + action_verb + " request";
403 return false;
404}
405
406void ManagementActions::api_rename_device(const std::string &device_id, const std::string &new_name) {
407 publish_result(rename_device(device_id, new_name));
408}
409
411 // device_id is empty for actions with no single target (e.g. scan_paired_devices' roll-call);
412 // the "for device %s" clause is omitted rather than rendering as "for device :".
413 const bool has_device = !result.device_id.empty();
414 std::string prefix = "Management action " + result.action;
415 if (has_device)
416 prefix += " for device " + result.device_id;
417 prefix += result.success ? ": " : " failed: ";
419 result.log_message.empty() ? result.message : result.log_message);
420
421 if (!hub_->is_connected())
422 return;
423
424 std::map<std::string, std::string> event_data{{"action", result.action},
425 {"device_id", result.device_id},
426 {"success", bool_to_string(result.success)},
427 {"verified", bool_to_string(result.verified)},
428 {"message", result.message}};
429
430 if (!result.requested_name.empty())
431 event_data["requested_name"] = result.requested_name;
432 if (!result.applied_name.empty())
433 event_data["applied_name"] = result.applied_name;
434 if (result.has_result_code) {
435 event_data["result_code"] = format_hex_byte(result.result_code);
436 event_data["result_code_name"] = command_result_name(result.result_code);
437 }
438 if (!result.probe_name.empty()) {
439 event_data["probe"] = result.probe_name;
440 // probe_index is empty only until a sweep has parsed its range; both probe_device() and
441 // probe_sweep() fill it before publishing, so an absent key means "no index applies" rather
442 // than "the index was blank".
443 if (!result.probe_index.empty())
444 event_data["index"] = result.probe_index;
445 }
446 if (result.has_response_cmd) {
447 event_data["response_cmd"] = format_hex_byte(result.response_cmd);
448 event_data["response_cmd_name"] = command_name(result.response_cmd);
449 }
450 if (!result.response_hex.empty())
451 event_data["response_hex"] = result.response_hex;
452
453 hub_->fire_homeassistant_event(MANAGEMENT_RESULT_EVENT, event_data);
454}
455
456ManagementActionResult ManagementActions::rename_device(const std::string &device_id, const std::string &new_name) {
458 auto *dev = resolve_device_(MANAGEMENT_ACTION_RENAME_DEVICE, device_id, result);
459 if (dev == nullptr)
460 return result;
461
462 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE];
463 std::string normalized_name;
464 const DeviceNameValidationError name_error = encode_device_name_payload(new_name, payload, normalized_name);
465 result.requested_name = normalized_name.empty() ? trim_ascii_whitespace(new_name) : normalized_name;
466 if (name_error != DeviceNameValidationError::NONE) {
468 return result;
469 }
470
471 IoFrame request;
472 if (!create_set_name(request, node_id_, dev->node_id, dev->low_power, payload)) {
473 result.message = "failed to build rename request";
474 return result;
475 }
476
477 IoFrame response;
478 if (!send_authenticated_request_(request, response, "rename", result))
479 return result;
480
481 if (response.cmd == CMD_ERROR_RESP) {
482 if (apply_error_response(response, result)) {
483 result.message =
484 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
485 }
486 return result;
487 }
488
489 if (response.cmd != CMD_SET_NAME_RESP) {
490 std::array<char, UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE> buffer{};
491 std::snprintf(buffer.data(), buffer.size(), "unexpected rename response 0x%02X", response.cmd);
492 result.message = buffer.data();
493 return result;
494 }
495
496 result.success = true;
497 result.message = "rename acknowledged by device";
498
499 if (!hub_->request_device_name(result.device_id)) {
500 result.message = "rename acknowledged but verification readback failed";
501 return result;
502 }
503
504 auto *updated_device = registry_.get(result.device_id);
505 if (updated_device != nullptr)
506 result.applied_name = updated_device->name;
507
508 if (result.applied_name == normalized_name) {
509 result.verified = true;
510 result.message = "rename verified by device readback";
511 return result;
512 }
513
514 result.message = "rename acknowledged but readback did not match the requested name";
515 return result;
516}
517
518void ManagementActions::api_identify_device(const std::string &device_id) {
520}
521
524 // Deliberately no device-type gating beyond "registered on this hub" — see the doxygen note on
525 // the declaration for why.
526 auto *dev = resolve_device_(MANAGEMENT_ACTION_IDENTIFY_DEVICE, device_id, result);
527 if (dev == nullptr)
528 return result;
529
530 IoFrame request;
531 if (!create_identify(request, node_id_, dev->node_id, dev->low_power)) {
532 result.message = "failed to build identify request";
533 return result;
534 }
535
536 IoFrame response;
537 if (!send_authenticated_request_(request, response, "identify", result))
538 return result;
539
540 if (response.cmd == CMD_ERROR_RESP) {
541 // Deliberate deviation from rename's error handling: a device may answer CMD_IDENTIFY with
542 // CMD_ERROR_RESP and still have performed the jog, so this counts as success, not failure.
543 result.success = true;
544 if (apply_error_response(response, result)) {
545 result.message = "identify triggered (device reported " + std::string(command_result_name(result.result_code)) +
546 ": " + command_result_description(result.result_code) + ")";
547 } else {
548 result.message = "identify triggered (device returned an empty error response)";
549 }
550 return result;
551 }
552
553 // Any other endpoint-matched reply counts as acknowledgment; unlike rename there is no specific
554 // response command to check against, and no readback exists to set `verified`.
555 result.success = true;
556 result.message = "identify acknowledged by device";
557 return result;
558}
559
560void ManagementActions::api_force_open_device(const std::string &device_id) {
562}
563
566 auto *dev = resolve_device_(MANAGEMENT_ACTION_FORCE_OPEN_DEVICE, device_id, result);
567 if (dev == nullptr)
568 return result;
569
570 // Delegate to the hub's normal cover-command dispatch path (capability gating, poll tracking,
571 // settle handling, backoff already live there) instead of talking to the radio directly.
572 if (!hub_->queue_device_command(result.device_id, CoverCommand::FORCE_OPEN)) {
573 result.message = "device does not accept cover commands";
574 return result;
575 }
576
577 result.success = true;
578 result.message =
579 "force open queued (elevated-priority open; wind/rain lock bypass unconfirmed; movement result arrives via "
580 "cover state)";
581 return result;
582}
583
584void ManagementActions::api_heating_control(const std::string &device_id, const std::string &function,
585 const std::string &value) {
586 publish_result(heating_control(device_id, function, value));
587}
588
589ManagementActionResult ManagementActions::heating_control(const std::string &device_id, const std::string &function,
590 const std::string &value) {
592 auto *dev = resolve_device_(MANAGEMENT_ACTION_HEATING_CONTROL, device_id, result);
593 if (dev == nullptr)
594 return result;
595
596 HeatingFunction fn{};
597 float encoded_value = 0.0F;
598 std::string parse_error;
599 if (!parse_heating_arguments(function, value, fn, encoded_value, parse_error)) {
600 result.message = parse_error;
601 return result;
602 }
603
604 // Capability gate via the predicate only — no inline device-type or vendor list.
605 if (!device_supports_climate_control(dev->type, dev->subtype)) {
606 result.message = "device is not a climate device";
607 return result;
608 }
609 if (!device_uses_heating_registers(dev->type, dev->subtype)) {
610 result.message = "device is commanded with heating levels, not private registers";
611 return result;
612 }
613
614 // Snapshot any pre-existing CMD_ERROR_RESP code so a stale one (e.g. an old wind lockout from a
615 // different command) is not misattributed to this send.
616 const uint8_t prior_result_code = dev->last_result_code;
617 const uint32_t prior_result_at_ms = dev->last_result_at_ms;
618
619 // A register with a decoded value (the window sensor) is read through the entity's read path, so
620 // the action shows exactly what the climate card would; every other function is one send. Both
621 // paths share the hub's exchange helper, and a CMD_ERROR_RESP is recorded on the device record by it.
622 float read_value = 0.0F;
623 const bool read_with_value = fn == HeatingFunction::WINDOW_OPEN;
624 const bool acknowledged = read_with_value
625 ? hub_->read_heating_register(result.device_id, fn, &read_value, EXCHANGE_RETRY_COUNT)
626 : hub_->send_heating_command(result.device_id, fn, encoded_value);
627 if (!acknowledged) {
628 result.message = std::string("device did not acknowledge the ") + heating_function_name(fn) + " command";
629 if (const auto *updated = registry_.get(result.device_id);
630 updated != nullptr && updated->last_result_code != 0 &&
631 (updated->last_result_code != prior_result_code || updated->last_result_at_ms != prior_result_at_ms)) {
632 result.has_result_code = true;
633 result.result_code = updated->last_result_code;
634 result.message =
635 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
636 }
637 return result;
638 }
639
640 result.success = true;
641 // verified stays false: an acknowledged write is the device saying it took the frame, not the
642 // device showing the new value; the climate entity reads the register back for that.
643 if (read_with_value) {
644 result.message =
645 std::string("heating ") + heating_function_name(fn) + ": window " + (read_value != 0.0F ? "open" : "closed");
646 } else {
647 result.message = std::string("heating ") + heating_function_name(fn) + " acknowledged by device";
648 }
649 return result;
650}
651
653
654void ManagementActions::api_oneway_set_position(const std::string &controller_id, const std::string &position) {
655 // device_id carries the controller-identity handle: 1W addresses a class, so there is no device
656 // to name, and the identity is what the caller actually chose.
657 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_ONEWAY_SET_POSITION, controller_id);
658
659 if (hub_->oneway_controllers().get(controller_id) == nullptr) {
660 result.message = "no oneway_controllers identity with that id";
661 publish_result(result);
662 return;
663 }
664
665 // Parse the position string here and reject loudly on anything unparseable. Do NOT relax this to
666 // atoi()/strtoul()-with-default: a value that silently became 0 would send a fully-open command
667 // to every actuator bound to this 1W identity.
668 const std::string trimmed = trim_ascii_whitespace(position);
669 if (trimmed.empty() || trimmed.find_first_not_of("0123456789") != std::string::npos) {
670 result.message = "position must be a whole number between 0 and 100";
671 publish_result(result);
672 return;
673 }
674 const unsigned long parsed = strtoul(trimmed.c_str(), nullptr, 10); // NOLINT(google-runtime-int)
675 if (parsed > ONEWAY_POSITION_FULLY_CLOSED) {
676 result.message = "position must be between 0 and 100";
677 publish_result(result);
678 return;
679 }
680
681 hub_->send_oneway_position(controller_id, static_cast<uint8_t>(parsed));
682 result.success = true;
683 // Deliberately "queued", not "sent" or "applied": 1W reports nothing back, and neither should
684 // this. See the "Last 1W Command" sensor for what was actually transmitted.
685 result.message = "1W position command queued";
686 publish_result(result);
687}
688
689void ManagementActions::api_oneway_remove_controller(const std::string &controller_id) {
690 // device_id carries the controller-identity handle, same convention as api_oneway_set_position().
691 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER, controller_id);
692
693 if (hub_->oneway_controllers().get(controller_id) == nullptr) {
694 result.message = "no oneway_controllers identity with that id";
695 publish_result(result);
696 return;
697 }
698
699 hub_->send_oneway_unenroll(controller_id);
700 result.success = true;
701 // "Queued", not "removed": 1W has no reply, so nothing here can ever confirm a device actually
702 // forgot this identity — same framing as every other 1W action result.
703 result.message = "1W remove-controller (0x39) queued";
704 publish_result(result);
705}
706
707/// @brief The keys one scan sweeps with: slot 0 is the hub key, the rest are the distinct per-device keys.
708using ScanKeys = std::vector<std::array<uint8_t, AES_KEY_SIZE>>;
709
710/// @brief Collect the keys to sweep: the hub key, then each per-device key that differs from it and
711/// from every key already collected. A per-device key equal to the hub key changes nothing, so it
712/// never earns a sweep of its own.
713static ScanKeys collect_scan_keys(const uint8_t *hub_key, DeviceRegistry &registry) {
714 ScanKeys keys(1);
715 memcpy(keys[0].data(), hub_key, AES_KEY_SIZE);
716 for (auto &entry : registry) {
717 const IoDevice &dev = entry.second;
718 if (!dev.has_system_key)
719 continue;
720 const bool known = std::ranges::any_of(
721 keys, [&dev](const auto &key) { return memcmp(key.data(), dev.system_key, AES_KEY_SIZE) == 0; });
722 if (known)
723 continue;
724 keys.emplace_back();
725 memcpy(keys.back().data(), dev.system_key, AES_KEY_SIZE);
726 }
727 return keys;
728}
729
730/// @brief How many registered devices exchange with `key`: those with it as their own key, or, for the
731/// hub key, those with no key of their own (or one equal to the hub's).
732static size_t count_devices_using_key(DeviceRegistry &registry, const uint8_t *hub_key, const uint8_t *key) {
733 const bool is_hub_key = memcmp(key, hub_key, AES_KEY_SIZE) == 0;
734 size_t count = 0;
735 for (auto &entry : registry) {
736 const IoDevice &dev = entry.second;
737 const uint8_t *device_key = dev.has_system_key ? dev.system_key : hub_key;
738 if (memcmp(device_key, key, AES_KEY_SIZE) == 0 && (is_hub_key || dev.has_system_key))
739 count++;
740 }
741 return count;
742}
743
744/// @brief Build the final report: known/unknown grouping, header, the selection NOTE, and the
745/// truncation NOTE.
746static std::string build_scan_report(const ScanState &state, DeviceRegistry &registry, ScanPowerClasses selection,
747 const ScanKeys &keys, bool reveal_keys) {
748 // Grouped into known-first, unknown-second rather than interleaved in arrival order: the two
749 // groups need very different follow-up (nothing to do vs. paste a YAML block), so burying an
750 // unknown responder between two known ones makes it easy to miss.
751 std::string known_body;
752 std::string unknown_body;
753 uint8_t unknown_count = 0;
754 for (uint8_t i = 0; i < state.count; i++) {
755 const std::string device_id = node_id_to_string(state.responders[i].src);
756 // A device a pairing added in this boot still needs its block, so it counts as unknown here.
757 const IoDevice *registered = registry.get_declared(device_id);
758 const uint8_t slot = state.responders[i].key_slot;
759 const RollCallReplyKeys reply_keys{keys[slot].data(), keys[0].data(), slot != 0};
761 if (registered != nullptr) {
762 known_body += format_roll_call_responder(state.responders[i], device_id, registered, reply_keys, display);
763 } else {
764 unknown_count++;
765 unknown_body += format_roll_call_responder(state.responders[i], device_id, nullptr, reply_keys, display);
766 }
767 }
768
769 std::string body;
770 if (!known_body.empty())
771 body += "Known:\n" + known_body;
772 if (!unknown_body.empty())
773 body += "Unknown:\n" + unknown_body;
774
775 std::string message = "Roll-call: " + std::to_string(state.count) + " device" + (state.count == 1 ? "" : "s") +
776 " detected (" + std::to_string(state.count - unknown_count) + " known, " +
777 std::to_string(unknown_count) + " unknown)\n";
778 // A non-default selection must say so in the report itself, not just the log: a user who left
779 // this tunable on always_alive must not read a missing solar/battery device as "gone". Derived
780 // from ROLL_CALL_PASSES rather than a switch/literal, so a third power class would not need a
781 // second place taught about it.
782 if (selection != ScanPowerClasses::BOTH) {
783 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
784 if (scan_power_classes_include(selection, pass.power_save_mode))
785 continue;
786 message += "NOTE: scan_power_classes=" + scan_power_classes_to_string(selection) + ": " +
787 power_save_mode_name(pass.power_save_mode) + " devices were not called.\n";
788 }
789 }
790 // Surfaced in the report itself, not only the log: a scan that silently listed a subset would
791 // look like devices had gone missing.
792 if (state.truncated) {
793 message += "NOTE: more than " + std::to_string(SCAN_MAX_REPLIES) +
794 " devices answered; the list below is truncated. Re-run to see whether other devices "
795 "appear, and raise SCAN_MAX_REPLIES if this install really is larger.\n";
796 }
797 message += body;
798 return message;
799}
800
802 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES, "");
803
804 if (!*initialized_) {
805 result.message = "hub is not initialized";
806 return result;
807 }
808
809 // One full sweep (run_roll_call_sweep(): every enabled pass on every channel) per distinct key:
810 // the hub key first, then each different per-device key. A device answers only a roll-call built
811 // with the key it holds, so a device that keeps its own key is invisible to the hub-key sweep.
812 // Attempt numbers are global and 1-based across the sweeps actually run, matching the "n/N" log
813 // line and the per-reply `attempt=` field.
814 const ScanPowerClasses selection = tuning_->scan_power_classes;
815 const ScanKeys keys = collect_scan_keys(system_key_, registry_);
816 const uint8_t total_attempts = count_enabled_passes(selection) * SCAN_CHANNEL_COUNT * keys.size();
817 ScanState state{};
818 uint8_t attempt = 0;
819 for (size_t slot = 0; slot < keys.size(); slot++) {
820 const size_t using_key = count_devices_using_key(registry_, system_key_, keys[slot].data());
821 ESP_LOGI(detail::TAG, "Roll-call with key check value %s (%u registered device%s)",
822 detail::format_key_check_value(keys[slot].data()).c_str(), static_cast<unsigned>(using_key),
823 using_key == 1 ? "" : "s");
824 if (run_roll_call_sweep(engine_, node_id_, keys[slot].data(), static_cast<uint8_t>(slot), *tuning_, state, attempt,
825 total_attempts) == RollCallAttemptResult::BUILD_FAILED) {
826 result.message = "failed to build roll-call request";
827 return result;
828 }
829 }
830
831 result.success = true;
832 // Two renderings of one report. The event message goes to Home Assistant, whose recorder stores it, so
833 // it must not carry a device key; the device log gets the version with the key, bannered.
834 result.message = build_scan_report(state, registry_, selection, keys, /*reveal_keys=*/false);
835 if (keys.size() > 1) // with the hub key alone there is no key to reveal, so one report serves both
836 result.log_message = build_scan_report(state, registry_, selection, keys, /*reveal_keys=*/true);
837 return result;
838}
839
840void ManagementActions::api_probe_device(const std::string &device_id, const std::string &probe,
841 const std::string &index) {
842 publish_result(probe_device(device_id, probe, index));
843}
844
845ManagementActionResult ManagementActions::probe_device(const std::string &device_id, const std::string &probe,
846 const std::string &index) {
847 return probes_.probe_device(device_id, probe, index);
848}
849
850void ManagementActions::api_probe_sweep(const std::string &device_id, const std::string &probe,
851 const std::string &first_index, const std::string &last_index) {
852 publish_result(probe_sweep(device_id, probe, first_index, last_index));
853}
854
855ManagementActionResult ManagementActions::probe_sweep(const std::string &device_id, const std::string &probe,
856 const std::string &first_index, const std::string &last_index) {
857 return probes_.probe_sweep(device_id, probe, first_index, last_index);
858}
859
860} // namespace home_io_control
861} // 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.
The main IO-Homecontrol component.
Definition hub_core.h:100
bool diagnostic_probes_enabled() const
Whether diagnostic probes are enabled for this build.
Definition hub_core.h:494
void api_probe_device(const std::string &device_id, const std::string &probe, const std::string &index)
Native API callback: run a single diagnostic probe and publish the result as a HA event.
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.
void api_oneway_set_position(const std::string &controller_id, const std::string &position)
Native API callback: queue a 1W position for a controller identity.
ManagementActionResult scan_paired_devices()
Broadcast a roll-call and report every device that answers.
ManagementActionResult probe_device(const std::string &device_id, const std::string &probe, const std::string &index)
Send a single diagnostic probe frame to a paired device; delegates to DiagnosticProbes::probe_device(...
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 api_oneway_remove_controller(const std::string &controller_id)
Native API callback: queue a standalone 1W un-enrollment (remove-controller, CMD 0x39) for a controll...
void api_heating_control(const std::string &device_id, const std::string &function, const std::string &value)
Native API callback: run a heating/climate function and publish the result as a HA event.
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_probe_sweep(const std::string &device_id, const std::string &probe, const std::string &first_index, const std::string &last_index)
Native API callback: run a bounded probe sweep and publish the result as a HA event.
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.
ManagementActionResult heating_control(const std::string &device_id, const std::string &function, const std::string &value)
Send one 2W heating/climate function (CMD_WRITE_PRIVATE 0x20) to a registered climate device.
ManagementActionResult probe_sweep(const std::string &device_id, const std::string &probe, const std::string &first_index, const std::string &last_index)
Walk a bounded index range, one probe per index; delegates to DiagnosticProbes::probe_sweep().
Conversions and renderers shared by the hub and its Home Assistant entities.
IO-Homecontrol ESPHome component — protocol controller.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
Hub-level management operations exposed as Home Assistant actions.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:32
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...
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.
bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build an authenticated device-identify request (0x1E).
static bool parse_heating_temperature(const std::string &value, float &value_out, std::string &error)
Parse set_temperature's value into degrees Celsius, range-checked.
uint8_t count_enabled_passes(ScanPowerClasses selection)
How many of ROLL_CALL_PASSES selection actually calls.
Definition roll_call.cpp:52
bool create_set_name(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE])
Build an authenticated set-name request (0x52) using a fixed zero-padded Latin-1 payload.
static bool match_heating_named_value(const std::string &token, const HeatingNamedValue *table, size_t table_len, float &out)
Match token against table; on a hit set out and return true.
HeatingFunction
Registers and functions of a 2W heater, one row of the descriptor table each.
@ MIDNIGHT_SYNC
Register 0x0130, read: the comfort/eco/auto setpoint block. Logged, never decoded.
@ POWER_ON
Register 0x012C, read: the paired-device list. Logged, never decoded.
@ SET_PRESENCE
Register 0x0110, write-only: someone is in the room (1) or not (0).
@ WINDOW_OPEN
Register 0x010E, read-only: the heater's window sensor, 0 closed / 1 open.
@ SET_OPEN_WINDOW_DETECTION
Register 0x0106, read/write: open-window detection on (1) or off (0).
@ SET_TEMPERATURE
Register 0x0103, read/write: comfort setpoint, tenths of a degree.
@ SET_MODE
Register 0x0100, read/write: operating mode (HeatingMode).
static ScanKeys collect_scan_keys(const uint8_t *hub_key, DeviceRegistry &registry)
Collect the keys to sweep: the hub key, then each per-device key that differs from it and from every ...
static std::string build_scan_report(const ScanState &state, DeviceRegistry &registry, ScanPowerClasses selection, const ScanKeys &keys, bool reveal_keys)
Build the final report: known/unknown grouping, header, the selection NOTE, and the truncation NOTE.
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
@ FORCE_OPEN
Move to fully open at elevated priority; intended to bypass soft locks and environmental limits (conf...
static std::string bool_to_string(bool value)
constexpr float HEATING_TEMP_MAX_C
Highest setpoint this codec will encode.
constexpr uint8_t SCAN_CHANNEL_COUNT
Definition roll_call.h:71
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
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.
@ BOTH
Low-power pass, then always-alive pass (default).
const char * heating_function_name(HeatingFunction fn)
Stable lowercase name for a heating function ("power_on", "set_temperature", ...).
static size_t count_devices_using_key(DeviceRegistry &registry, const uint8_t *hub_key, const uint8_t *key)
How many registered devices exchange with key: those with it as their own key, or,...
bool scan_power_classes_include(ScanPowerClasses selection, uint8_t power_save_mode)
Whether selection calls the roll-call pass for power_save_mode.
bool apply_error_response(const IoFrame &response, ManagementActionResult &result)
Decode a CMD_ERROR_RESP frame's result code into result.
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
bool device_supports_climate_control(DeviceType type, uint8_t subtype)
Can a climate entity drive this device?
std::string format_hex_byte(uint8_t value)
Format a byte as two uppercase hex digits, no prefix.
constexpr const char * MANAGEMENT_ACTION_PROBE_DEVICE
Native API action names of the two probe actions, shared with ManagementActions' registration table.
const char * command_result_name(uint8_t result)
Return a stable symbolic name for a CMD_ERROR_RESP result code.
static std::string normalize_lower_argument(const std::string &value)
Lowercase + ASCII-trim a native-API string argument.
DeviceNameValidationError
Validation result for outbound device-name writes.
constexpr float HEATING_TEMP_MIN_C
Lowest setpoint this codec will encode.
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).
@ BUILD_FAILED
create_discovery_request() failed; the caller should abort the whole scan.
Definition roll_call.h:124
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
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.
@ PROG
Program mode; the device runs its own stored weekly schedule.
@ MANUAL
Manual mode; follows the last written setpoint.
@ AUTO
Automatic mode; the device manages the setpoint itself.
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.
std::vector< std::array< uint8_t, AES_KEY_SIZE > > ScanKeys
The keys one scan sweeps with: slot 0 is the hub key, the rest are the distinct per-device keys.
IoDevice * resolve_management_device(const char *action, const std::string &device_id, bool initialized, DeviceRegistry &registry, ManagementActionResult &result)
Shared preamble for every device-addressed action: validate the hub and device ID,...
bool device_uses_heating_registers(DeviceType type, uint8_t subtype)
Does a climate entity drive this device over the private register protocol?
ManagementActionResult make_management_result(const std::string &action, const std::string &device_id)
A result that carries only the action name and the target device ID.
constexpr uint8_t SCAN_MAX_REPLIES
Maximum distinct responders reported by one scan_paired_devices() call.
Definition roll_call.h:34
constexpr const char * MANAGEMENT_ACTION_PROBE_SWEEP
const char * device_name_validation_error_description(DeviceNameValidationError error)
Return a human-readable explanation for a device-name validation result.
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
std::string scan_power_classes_to_string(ScanPowerClasses value)
Format a ScanPowerClasses value for YAML/logs.
static bool parse_heating_arguments(const std::string &function, const std::string &value, HeatingFunction &fn_out, float &value_out, std::string &error)
Parse the (function, value) argument pair of the heating_control action.
Command builders for the IO‑Homecontrol protocol.
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
The roll-call sweep: one keyed 0x2A broadcast, repeated over channels and power classes.
The only three things a probe needs from the hub, injected by ManagementActions.
A value token that maps to a fixed float (used by set_mode / set_presence / set_open_window_detection...
Runtime state of a paired IO‑Homecontrol device.
uint8_t system_key[AES_KEY_SIZE]
The device's own key; meaningful only when has_system_key.
bool has_system_key
YAML-declared (system_key:): this device keeps a key of its own.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
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.
bool has_response_cmd
True when response_cmd contains a probe reply's command byte.
std::string probe_index
Requested index argument, as received (before parsing).
uint8_t result_code
Optional CMD_ERROR_RESP result byte.
std::string action
Action name, e.g. "rename_device".
uint8_t response_cmd
Probe reply's command byte (distinct from a CMD_ERROR_RESP result_code above, which is a different by...
std::string log_message
When non-empty, the device log shows this instead of message.
std::string applied_name
Verified cached UTF-8 name after a readback, when available.
std::string probe_name
Probe name for probe_device()/probe_sweep(), e.g. "private_fn".
std::string response_hex
Probe reply's full raw wire hex, for pasting into scripts/corpus/ingest.py.
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.
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
uint8_t src[NODE_ID_SIZE]
Responder's node ID; also the dedup key.
Definition roll_call.h:43
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
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.