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/application.h"
21#include "esphome/core/hal.h"
22
23#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
24#include "esphome/core/helpers.h"
25#endif
26
27#include <algorithm>
28#include <array>
29#include <cctype>
30#include <cerrno>
31#include <cmath>
32#include <cstdio>
33#include <cstdlib>
34#include <cstring>
35#include <functional>
36#include <limits>
37#include <map>
38#include <span>
39#include <type_traits>
40#include <vector>
41
42namespace esphome {
43namespace home_io_control {
44
45namespace {
46
47constexpr const char *MANAGEMENT_ACTION_RENAME_DEVICE = "rename_device";
48constexpr const char *MANAGEMENT_ACTION_IDENTIFY_DEVICE = "identify_device";
49constexpr const char *MANAGEMENT_ACTION_FORCE_OPEN_DEVICE = "force_open_device";
50constexpr const char *MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES = "scan_paired_devices";
51constexpr const char *MANAGEMENT_ACTION_ONEWAY_SET_POSITION = "oneway_set_position";
52constexpr const char *MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER = "oneway_remove_controller";
53constexpr const char *MANAGEMENT_ACTION_PROBE_DEVICE = "probe_device";
54constexpr const char *MANAGEMENT_ACTION_PROBE_SWEEP = "probe_sweep";
55constexpr const char *MANAGEMENT_ACTION_HEATING_CONTROL = "heating_control";
56constexpr const char *MANAGEMENT_RESULT_EVENT = "esphome.home_io_control_action_result";
57constexpr size_t RESULT_CODE_BUFFER_SIZE = 5;
58constexpr size_t UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE = 64;
59constexpr size_t HEATING_RANGE_MESSAGE_BUFFER_SIZE = 64;
60
61// --- Diagnostic probe names (probe_device()/probe_sweep() `probe` argument) ---
62constexpr const char *PROBE_NAME_PRIVATE_FN = "private_fn"; ///< Q0: create_private_function().
63constexpr const char *PROBE_NAME_PRIVATE_FN_SUB = "private_fn_sub"; ///< CMD_PRIVATE fn 0x09, chosen second byte.
64constexpr const char *PROBE_NAME_STATUS_EXT = "status_ext"; ///< Q1: create_get_status_extended().
65constexpr const char *PROBE_NAME_STATUS_EXT_FN6 = "status_ext_fn6"; ///< Extended CMD_PRIVATE at function ID 0x06.
66constexpr const char *PROBE_NAME_STATUS_EXT_FN9 = "status_ext_fn9"; ///< Extended CMD_PRIVATE at function ID 0x09.
67constexpr const char *PROBE_NAME_GET_INFO1 = "get_info1"; ///< create_get_info1() (0x54, no payload).
68constexpr const char *PROBE_NAME_GET_INFO2 = "get_info2"; ///< create_get_info2() (0x56, no payload).
69constexpr const char *PROBE_NAME_GENERAL_INFO3 = "general_info3"; ///< Q2: create_general_info3().
70constexpr const char *PROBE_NAME_PRIVATE2 = "private2"; ///< Q3 long form: create_private2_read().
71constexpr const char *PROBE_NAME_PRIVATE2_SHORT = "private2_short"; ///< Q3 short form: create_private2_read().
72constexpr const char *PROBE_NAME_STATUS_MP_FP = "status_mp_fp"; ///< create_status_mp_fp_read() (no index).
73constexpr const char *PROBE_NAME_LIMITATION =
74 "limitation"; ///< create_limitation_status_read(); index 0 = minimum, 1 = maximum.
75/// Highest `limitation` index: the two limit types of the KLF 200 API's get-limitation command
76/// (LimitationType numbering). The index is never the on-air selector byte.
77constexpr uint8_t PROBE_LIMITATION_MAX_INDEX = 1;
78constexpr uint8_t PROBE_LIMITATION_INDEX_MINIMUM = 0; ///< `limitation` index that reads the resulting minimum.
79/// Function IDs the extended-shape probes hold fixed. Production software elsewhere describes
80/// these two as a battery read; on real hardware (17 solar devices plus our own mains motors)
81/// the *short* 3-byte form at these IDs returned position-family values, never a charge value.
82/// The extended 4-byte shape at these IDs has never been sent by anything.
83constexpr uint8_t PROBE_EXT_FUNCTION_ID_06 = 0x06;
84constexpr uint8_t PROBE_EXT_FUNCTION_ID_09 = 0x09;
85/// Function ID `private_fn_sub` holds fixed while `index` walks the second payload byte.
86constexpr uint8_t PROBE_PRIVATE_FN_SUB_FUNCTION_ID = 0x09;
87/// Extended-CMD_PRIVATE selector "status_ext" probes: the field-observed, never-decoded 0x80.
88/// Selector 0x20 (tilt) already has a permanent builder/action (create_get_status_tilt()) and is
89/// not part of this probe.
90constexpr uint8_t PROBE_STATUS_EXT_SELECTOR = 0x80;
91/// Bound on probe_sweep()'s index range — this transmits on a shared ISM band to what may be a
92/// battery device; an unbounded sweep is antisocial and would keep the device awake needlessly.
93constexpr uint8_t PROBE_SWEEP_MAX_INDICES = 16;
94/// Spacing between probe_sweep() indices. Sweep steps are sequential and blocking, so they never
95/// overlap regardless of this value; it exists for ISM-band duty cycling and to give a battery
96/// device real idle time between reads.
97constexpr uint32_t PROBE_SWEEP_DELAY_MS = 1000;
98
99/// @brief Uniform wrapper signature every probe builder is adapted to, so PROBE_TABLE below can
100/// hold one function-pointer type regardless of each create_*() builder's own parameter list.
101/// `low_power` is the target device's YAML-declared power class, forwarded to each builder so a
102/// probe to an always-alive device is shaped like one — the diagnostic subsystem has to work on
103/// the very device class a silent-device investigation reaches for it.
104using ProbeBuilderFn = bool (*)(IoFrame &, const uint8_t *, const uint8_t *, uint8_t index, bool low_power);
105
106bool build_probe_private_fn(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
107 return create_private_function(f, own, dst, low_power, index);
108}
109bool build_probe_private_fn_sub(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
110 return create_private_function(f, own, dst, low_power, PROBE_PRIVATE_FN_SUB_FUNCTION_ID, index);
111}
112bool build_probe_status_ext(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
113 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index);
114}
115bool build_probe_status_ext_fn6(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
116 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index, PROBE_EXT_FUNCTION_ID_06);
117}
118bool build_probe_status_ext_fn9(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
119 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index, PROBE_EXT_FUNCTION_ID_09);
120}
121bool build_probe_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
122 return create_get_info1(f, own, dst, low_power);
123}
124bool build_probe_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
125 return create_get_info2(f, own, dst, low_power);
126}
127bool build_probe_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
128 return create_general_info3(f, own, dst, low_power);
129}
130bool build_probe_private2_long(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
131 return create_private2_read(f, own, dst, index, /*long_form=*/true, low_power);
132}
133bool build_probe_private2_short(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
134 return create_private2_read(f, own, dst, index, /*long_form=*/false, low_power);
135}
136bool build_probe_status_mp_fp(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
137 return create_status_mp_fp_read(f, own, dst, low_power);
138}
139
140bool build_probe_limitation(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
141 if (index > PROBE_LIMITATION_MAX_INDEX) {
142 return false; // Unreachable through probe_device()/probe_sweep(), which gate on max_index.
143 }
144 const LimitationType type =
145 index == PROBE_LIMITATION_INDEX_MINIMUM ? LimitationType::MINIMUM : LimitationType::MAXIMUM;
146 return create_limitation_status_read(f, own, dst, type, low_power);
147}
148
149/// @brief One row per probe_device()/probe_sweep() `probe` argument value.
150struct ProbeDescriptor {
151 const char *name;
152 ProbeBuilderFn builder;
153 const char *index_help; ///< Wording for the rejection when an index exceeds max_index (null: unbounded).
154 bool needs_index; ///< False for the probes whose builders take no index/selector ("general_info3", "get_info1",
155 ///< "get_info2", "status_mp_fp").
156 uint8_t max_index; ///< Highest index the probe accepts; the full byte range when index_help is null.
157};
158
159/// @brief The full set of probes probe_device()/probe_sweep() can dispatch to.
160///
161/// Single source of truth for probe names. Adding, removing, or renaming a probe is a one-line
162/// change here rather than a change to both probe_device()'s dispatch and its "index must be..."
163/// error message. Deliberately has no "unknown4a" row -- see ADR 0024.
164constexpr ProbeDescriptor PROBE_TABLE[] = {
165 {PROBE_NAME_PRIVATE_FN, build_probe_private_fn, nullptr, true, UINT8_MAX},
166 {PROBE_NAME_PRIVATE_FN_SUB, build_probe_private_fn_sub, nullptr, true, UINT8_MAX},
167 {PROBE_NAME_STATUS_EXT, build_probe_status_ext, nullptr, true, UINT8_MAX},
168 {PROBE_NAME_STATUS_EXT_FN6, build_probe_status_ext_fn6, nullptr, true, UINT8_MAX},
169 {PROBE_NAME_STATUS_EXT_FN9, build_probe_status_ext_fn9, nullptr, true, UINT8_MAX},
170 {PROBE_NAME_GET_INFO1, build_probe_get_info1, nullptr, false, UINT8_MAX},
171 {PROBE_NAME_GET_INFO2, build_probe_get_info2, nullptr, false, UINT8_MAX},
172 {PROBE_NAME_GENERAL_INFO3, build_probe_general_info3, nullptr, false, UINT8_MAX},
173 {PROBE_NAME_PRIVATE2, build_probe_private2_long, nullptr, true, UINT8_MAX},
174 {PROBE_NAME_PRIVATE2_SHORT, build_probe_private2_short, nullptr, true, UINT8_MAX},
175 {PROBE_NAME_STATUS_MP_FP, build_probe_status_mp_fp, nullptr, false, UINT8_MAX},
176 {PROBE_NAME_LIMITATION, build_probe_limitation, "index 0 (minimum) or 1 (maximum)", true,
177 PROBE_LIMITATION_MAX_INDEX},
178};
179constexpr uint8_t PROBE_TABLE_SIZE = sizeof(PROBE_TABLE) / sizeof(PROBE_TABLE[0]);
180
181/// @brief Look up a probe by name.
182/// @return Pointer into PROBE_TABLE, or nullptr if `probe` names none of its rows.
183const ProbeDescriptor *find_probe_descriptor(const std::string &probe) {
184 for (const auto &descriptor : PROBE_TABLE) {
185 if (probe == descriptor.name)
186 return &descriptor;
187 }
188 return nullptr;
189}
190
191/// @brief The error message probe_device()/probe_sweep() report for an unrecognized `probe`
192/// argument, built from PROBE_TABLE so the list of names can never drift out of sync with it.
193std::string unknown_probe_message(const std::string &probe) {
194 std::string names;
195 for (uint8_t i = 0; i < PROBE_TABLE_SIZE; i++) {
196 if (i > 0)
197 names += (i + 1 == PROBE_TABLE_SIZE) ? ", or " : ", ";
198 names += PROBE_TABLE[i].name;
199 }
200 return "unknown probe \"" + probe + "\" (expected " + names + ")";
201}
202
203/// @brief The error message for an index above a probe's max_index (probe_device() and probe_sweep()).
204std::string index_out_of_range_message(const ProbeDescriptor &descriptor) {
205 return "probe \"" + std::string(descriptor.name) + "\" takes " + descriptor.index_help;
206}
207
208/// @brief Shared probe-name lookup for probe_device()/probe_sweep(): returns the descriptor, or
209/// nullptr after writing the "unknown probe" message into @p result. The device-resolution and
210/// index-parse steps stay per-method: probe_device keeps the resolved device (for the moving
211/// check and node_id), probe_sweep discards it, and one parses a single index while the other
212/// parses a first/last range.
213const ProbeDescriptor *resolve_probe_descriptor(const std::string &probe, ManagementActionResult &result) {
214 const ProbeDescriptor *descriptor = find_probe_descriptor(probe);
215 if (descriptor == nullptr)
216 result.message = unknown_probe_message(probe);
217 return descriptor;
218}
219
220} // namespace
221
222namespace detail {
223
224#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
225
226/// True when the installed ESPHome's `UserServiceDescriptor::encode_list_service_response()`
227/// takes a `std::span<char>` scratch buffer (present in some 2026.9 betas, absent on stable and
228/// current dev). Detected from the base class itself so no version gate can drift.
229template<typename Base>
230concept EncodeTakesScratch = requires(Base &base) { base.encode_list_service_response(std::span<char>{}); };
231
232/// @brief Bridges the version-dependent `encode_list_service_response` signature.
233///
234/// Only the specialization matching the installed base is ever instantiated (the `Base`
235/// parameter keeps the `override` checks dependent), and both forms forward to
236/// `build_list_response()`, which the concrete descriptor implements.
237template<bool TakesScratch, typename Base = api::UserServiceDescriptor> class EncodeListAdapter : public Base {
238 public:
239 api::ListEntitiesServicesResponse encode_list_service_response() override { return this->build_list_response(); }
240
241 protected:
242 virtual api::ListEntitiesServicesResponse build_list_response() = 0;
243};
244
245template<typename Base> class EncodeListAdapter<true, Base> : public Base {
246 public:
247 api::ListEntitiesServicesResponse encode_list_service_response(std::span<char> /*scratch*/) override {
248 return this->build_list_response();
249 }
250
251 protected:
252 virtual api::ListEntitiesServicesResponse build_list_response() = 0;
253};
254
255/// @brief Native API descriptor shared by every management action.
256///
257/// ESPHome 2026.x does not expose the generated YAML action helper runtime to external
258/// components, so Home IO Control registers the action descriptor directly with APIServer.
259/// This keeps the HA action surface identical to native ESPHome actions while avoiding
260/// the unresolved link path behind CustomAPIDevice::register_service().
261///
262/// One descriptor class serves every action: it is parametrized by name, argument list,
263/// and a callback that unpacks the request's string args and forwards them to a
264/// ManagementActions method. The callback captures a ManagementActions* and calls only
265/// public methods on it, so no friend declaration into the hub is needed. Adding action
266/// N+1 is therefore a new register_user_service() call in register_actions(), not a new
267/// descriptor class.
268class ManagementServiceDescriptor : public EncodeListAdapter<EncodeTakesScratch<api::UserServiceDescriptor>> {
269 public:
270 ManagementServiceDescriptor(const char *name, std::vector<const char *> arg_names,
271 std::function<void(const api::ExecuteServiceRequest &)> callback)
272 : name_(name), key_(fnv1_hash(name)), arg_names_(std::move(arg_names)), callback_(std::move(callback)) {}
273
274 bool execute_service(const api::ExecuteServiceRequest &request) override {
275 if (request.key != this->key_ || request.args.size() != this->arg_names_.size())
276 return false;
277 this->callback_(request);
278 return true;
279 }
280
281#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
282 bool execute_service(const api::ExecuteServiceRequest &request, uint32_t) override {
283 return this->execute_service(request);
284 }
285#endif
286
287 protected:
288 /// Wrap a literal in whatever string type the message field uses: StringRef on older
289 /// ESPHome, ProgmemStringRef (explicit from StringRef, not assignable) on newer.
290 template<typename T> static T make_ref(const char *s) {
291 if constexpr (std::is_assignable_v<T &, StringRef>) {
292 return StringRef(s);
293 } else {
294 return T(StringRef(s));
295 }
296 }
297
298 // name_ and arg_names_ point at ordinary (non-PROGMEM) .rodata string literals, so the
299 // refs stay valid after return on any target and any scratch buffer is unused.
300 api::ListEntitiesServicesResponse build_list_response() override {
301 api::ListEntitiesServicesResponse response;
302 response.name = make_ref<decltype(response.name)>(this->name_);
303 response.key = this->key_;
304 response.supports_response = api::enums::SUPPORTS_RESPONSE_NONE;
305 response.args.init(this->arg_names_.size());
306 for (const char *arg_name : this->arg_names_) {
307 auto &arg = response.args.emplace_back();
308 arg.name = make_ref<decltype(arg.name)>(arg_name);
309 arg.type = api::enums::SERVICE_ARG_TYPE_STRING;
310 }
311 return response;
312 }
313
314 const char *name_;
315 uint32_t key_;
316 std::vector<const char *> arg_names_;
317 std::function<void(const api::ExecuteServiceRequest &)> callback_;
318};
319
320#endif
321
322} // namespace detail
323
324// --- Helper free functions (file-local) ---
325
326static std::string normalize_device_id_argument(const std::string &device_id) {
327 std::string normalized = trim_ascii_whitespace(device_id);
328 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
329 [](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
330 return normalized;
331}
332
333static std::string bool_to_string(bool value) { return value ? "true" : "false"; }
334
335/// @brief Format a byte as two uppercase hex digits, no prefix. Named neutrally rather than
336/// after any one caller: used for CMD_ERROR_RESP result codes, probe reply/index command bytes,
337/// and sweep index values alike -- none of those is a "result code" except the first.
338static std::string format_hex_byte(uint8_t value) {
339 std::array<char, RESULT_CODE_BUFFER_SIZE> buffer{};
340 std::snprintf(buffer.data(), buffer.size(), "%02X", value);
341 return std::string(buffer.data());
342}
343
344static ManagementActionResult make_management_result(const std::string &action, const std::string &device_id) {
346 result.action = action;
347 result.device_id = device_id;
348 return result;
349}
350
351/// @brief Decode a CMD_ERROR_RESP frame's result code into `result`.
352///
353/// Populates has_result_code/result_code but deliberately leaves `result.message` untouched:
354/// rename and identify_device report different wording for the same decoded code, so message
355/// composition stays with each caller. On an empty error response, sets a stock message itself
356/// (there is no code to report) and returns false; callers should treat that the same way as a
357/// decoded code, just without result-code-specific wording.
358/// @param response Frame whose cmd is CMD_ERROR_RESP.
359/// @param result Result to populate.
360/// @return true if a result code was decoded, false if the response carried no data.
361static bool apply_error_response(const IoFrame &response, ManagementActionResult &result) {
362 if (response.data_len == 0) {
363 result.message = "device returned an empty error response";
364 return false;
365 }
366 result.has_result_code = true;
367 result.result_code = response.data[0];
368 return true;
369}
370
371/// @brief Parse a probe_device()/probe_sweep() index argument into a byte.
372///
373/// Accepts both a bare decimal string ("6") and a "0x"-prefixed hex string ("0x06") — the two
374/// shapes a Home Assistant user is likely to type when copying a value out of a captured frame
375/// dump. Rejects anything else (empty string, trailing garbage, out-of-range value) rather than
376/// silently defaulting to 0, since a wrong index silently sent as 0 would misrepresent what was
377/// actually probed.
378/// @param text Argument as received from the native API call.
379/// @param out Parsed byte on success; left untouched on failure.
380/// @return true if `text` is exactly one well-formed byte value.
381static bool parse_probe_index(const std::string &text, uint8_t &out) {
382 if (text.empty())
383 return false;
384 // A leading "0" followed by another digit (e.g. "010") is valid octal to strtoul() below and
385 // would silently probe a different index than a user copying a byte value out of a hex dump
386 // intended -- reject it explicitly rather than relying on strtoul()'s octal parsing, which only
387 // rejects the invalid-octal-digit cases ("08", "09"), not the valid ones.
388 if (text.size() > 1 && text[0] == '0' && text[1] != 'x' && text[1] != 'X')
389 return false;
390 errno = 0;
391 // NOLINTNEXTLINE(misc-const-correctness): strtoul()/strtof() take a `char **` end pointer.
392 char *end = nullptr;
393 // Base 0: strtoul() itself recognizes a "0x"/"0X" prefix as hex and a bare "0" as decimal
394 // zero, which is exactly the "6" / "0x06" / "0" shape probes need to accept.
395 const uint32_t value = std::strtoul(text.c_str(), &end, 0);
396 if (end != text.c_str() + text.size())
397 return false;
398 if (errno == ERANGE || value > std::numeric_limits<uint8_t>::max())
399 return false;
400 out = static_cast<uint8_t>(value);
401 return true;
402}
403
404/// @brief Lowercase + ASCII-trim a native-API string argument.
405static std::string normalize_lower_argument(const std::string &value) {
406 std::string normalized = trim_ascii_whitespace(value);
407 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
408 [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
409 return normalized;
410}
411
412/// @brief A `value` token that maps to a fixed float (used by set_mode / set_presence /
413/// set_open_window_detection).
415 const char *token;
416 float value;
417};
418
419/// @brief Match `token` against `table`; on a hit set `out` and return true.
420static bool match_heating_named_value(const std::string &token, const HeatingNamedValue *table, size_t table_len,
421 float &out) {
422 for (size_t i = 0; i < table_len; i++) {
423 if (token == table[i].token) {
424 out = table[i].value;
425 return true;
426 }
427 }
428 return false;
429}
430
431/// @brief Parse `set_temperature`'s value into degrees Celsius, range-checked.
432static bool parse_heating_temperature(const std::string &value, float &value_out, std::string &error) {
433 const std::string text = trim_ascii_whitespace(value);
434 errno = 0;
435 // NOLINTNEXTLINE(misc-const-correctness): strtoul()/strtof() take a `char **` end pointer.
436 char *end = nullptr;
437 const float parsed = std::strtof(text.c_str(), &end);
438 if (text.empty() || end != text.c_str() + text.size() || errno == ERANGE || !std::isfinite(parsed)) {
439 error = "temperature must be a number in degrees Celsius";
440 return false;
441 }
442 if (parsed < HEATING_TEMP_MIN_C || parsed > HEATING_TEMP_MAX_C) {
443 std::array<char, HEATING_RANGE_MESSAGE_BUFFER_SIZE> buffer{};
444 std::snprintf(buffer.data(), buffer.size(), "temperature must be between %.1f and %.1f",
445 static_cast<double>(HEATING_TEMP_MIN_C), static_cast<double>(HEATING_TEMP_MAX_C));
446 error = buffer.data();
447 return false;
448 }
449 value_out = parsed;
450 return true;
451}
452
453/// @brief Parse the (`function`, `value`) argument pair of the `heating_control` action.
454///
455/// Every native-API argument is a string (ManagementServiceDescriptor hardcodes
456/// SERVICE_ARG_TYPE_STRING), so this turns the two strings into a HeatingFunction plus the float
457/// encode_heating_payload() expects: degrees C for `set_temperature`, a HeatingMode value for
458/// `set_mode`, 0/1 for `set_presence` / `set_open_window_detection`, and an ignored 0 for the
459/// register reads (`power_on`, `midnight_sync`, `window_open`). On any malformed input it fills `error` with a
460/// caller-facing message and returns false rather than coercing a value. Kept next to the action (not in proto_heating)
461/// because the climate entity receives typed enums from Home Assistant and needs no string
462/// parsing.
463/// @param function Function name argument.
464/// @param value Value argument.
465/// @param fn_out Parsed function on success.
466/// @param value_out Parsed value on success (0 for the value-less functions).
467/// @param error Caller-facing message on failure.
468/// @return true on a fully valid pair.
469static bool parse_heating_arguments(const std::string &function, const std::string &value, HeatingFunction &fn_out,
470 float &value_out, std::string &error) {
471 static constexpr HeatingNamedValue MODE_VALUES[] = {
472 {"auto", static_cast<float>(HeatingMode::AUTO)},
473 {"manual", static_cast<float>(HeatingMode::MANUAL)},
474 {"prog", static_cast<float>(HeatingMode::PROG)},
475 {"off", static_cast<float>(HeatingMode::OFF)},
476 };
477 static constexpr HeatingNamedValue PRESENCE_VALUES[] = {{"on", 1.0F}, {"off", 0.0F}};
478 static constexpr HeatingNamedValue DETECTION_VALUES[] = {{"on", 1.0F}, {"off", 0.0F}};
479
480 const std::string fn = normalize_lower_argument(function);
481 value_out = 0.0F;
482
483 if (fn == "power_on") {
485 return true;
486 }
487 if (fn == "midnight_sync") {
489 return true;
490 }
491 if (fn == "set_temperature") {
493 return parse_heating_temperature(value, value_out, error);
494 }
495 if (fn == "set_mode") {
497 if (match_heating_named_value(normalize_lower_argument(value), MODE_VALUES, std::size(MODE_VALUES), value_out))
498 return true;
499 error = "mode must be one of auto, manual, prog, off";
500 return false;
501 }
502 if (fn == "set_presence") {
504 if (match_heating_named_value(normalize_lower_argument(value), PRESENCE_VALUES, std::size(PRESENCE_VALUES),
505 value_out))
506 return true;
507 error = "presence must be 'on' or 'off'";
508 return false;
509 }
510 if (fn == "set_open_window_detection") {
512 if (match_heating_named_value(normalize_lower_argument(value), DETECTION_VALUES, std::size(DETECTION_VALUES),
513 value_out))
514 return true;
515 error = "open-window detection must be 'on' or 'off'";
516 return false;
517 }
518 if (fn == "window_open") {
520 return true;
521 }
522
523 error = "unknown heating function '" + trim_ascii_whitespace(function) + "'";
524 return false;
525}
526
527// --- ManagementActions ---
528
529ManagementActions::ManagementActions(const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning,
530 ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized,
532 : node_id_(node_id),
533 system_key_(system_key),
534 tuning_(tuning),
535 engine_(engine),
536 registry_(registry),
537 initialized_(initialized),
538 hub_(hub) {}
539
541#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
542 if (api::global_api_server == nullptr) {
543 ESP_LOGW(detail::TAG, "Native API server not available, management actions will not be registered");
544 return;
545 }
546
547 // One row per user-visible action: name, argument-name list (every argument is exposed as
548 // SERVICE_ARG_TYPE_STRING — `oneway_set_position` parses its "position" string itself rather
549 // than widening a shipped API surface), whether it is gated behind `diagnostic_probes: true`,
550 // and the callback that unpacks its arguments. Adding an action is one row here.
551 struct ActionReg {
552 const char *name;
553 std::vector<const char *> arg_names;
554 bool diagnostic_only;
555 std::function<void(const api::ExecuteServiceRequest &)> callback;
556 };
557 const ActionReg actions[] = {
558 {MANAGEMENT_ACTION_RENAME_DEVICE,
559 {"device_id", "new_name"},
560 false,
561 [this](const api::ExecuteServiceRequest &r) {
562 this->api_rename_device(r.args[0].string_.str(), r.args[1].string_.str());
563 }},
564 {MANAGEMENT_ACTION_IDENTIFY_DEVICE,
565 {"device_id"},
566 false,
567 [this](const api::ExecuteServiceRequest &r) { this->api_identify_device(r.args[0].string_.str()); }},
568 {MANAGEMENT_ACTION_FORCE_OPEN_DEVICE,
569 {"device_id"},
570 false,
571 [this](const api::ExecuteServiceRequest &r) { this->api_force_open_device(r.args[0].string_.str()); }},
572 {MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES,
573 {},
574 false,
575 [this](const api::ExecuteServiceRequest &) { this->api_scan_paired_devices(); }},
576 {MANAGEMENT_ACTION_ONEWAY_SET_POSITION,
577 {"controller_id", "position"},
578 false,
579 [this](const api::ExecuteServiceRequest &r) {
580 this->api_oneway_set_position(r.args[0].string_.str(), r.args[1].string_.str());
581 }},
582 {MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER,
583 {"controller_id"},
584 false,
585 [this](const api::ExecuteServiceRequest &r) { this->api_oneway_remove_controller(r.args[0].string_.str()); }},
586 {MANAGEMENT_ACTION_HEATING_CONTROL,
587 {"device_id", "function", "value"},
588 false, // a real user feature, gated by documentation not by diagnostic_probes:
589 [this](const api::ExecuteServiceRequest &r) {
590 this->api_heating_control(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
591 }},
592 {MANAGEMENT_ACTION_PROBE_DEVICE,
593 {"device_id", "probe", "index"},
594 true,
595 [this](const api::ExecuteServiceRequest &r) {
596 this->api_probe_device(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
597 }},
598 {MANAGEMENT_ACTION_PROBE_SWEEP,
599 {"device_id", "probe", "first_index", "last_index"},
600 true,
601 [this](const api::ExecuteServiceRequest &r) {
602 this->api_probe_sweep(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str(),
603 r.args[3].string_.str());
604 }},
605 };
606
607 // The two probe actions are registered only when diagnostic_probes: true was set in YAML, so the
608 // action list stays clean on a default build. diagnostic_probes_enabled() already holds its
609 // final YAML-configured value here: __init__.py's to_code() emits set_diagnostic_probes_enabled()
610 // as a plain property-setter call in generated main.cpp, which runs before App.setup() calls this
611 // component's setup() (and therefore this method), not after.
612 const bool probes_enabled = hub_->diagnostic_probes_enabled();
613 for (const auto &action : actions) {
614 if (action.diagnostic_only && !probes_enabled)
615 continue;
616 api::global_api_server->register_user_service( // NOLINT
617 new detail::ManagementServiceDescriptor(action.name, action.arg_names, action.callback));
618 }
619#endif
620}
621
622IoDevice *ManagementActions::resolve_device_(const char *action, const std::string &device_id,
623 ManagementActionResult &result) {
624 const std::string normalized_device_id = normalize_device_id_argument(device_id);
625 result = make_management_result(action, normalized_device_id);
626
627 if (!*initialized_) {
628 result.message = "hub is not initialized";
629 return nullptr;
630 }
631
632 uint8_t parsed_device_id[NODE_ID_SIZE]{};
633 if (!hex_to_bytes(normalized_device_id, parsed_device_id, NODE_ID_SIZE)) {
634 result.message = "device ID must be exactly 6 hexadecimal characters";
635 return nullptr;
636 }
637
638 auto *dev = registry_.get(normalized_device_id);
639 if (dev == nullptr) {
640 result.message = "device is not registered on this hub";
641 return nullptr;
642 }
643
644 return dev;
645}
646
647bool ManagementActions::send_authenticated_request_(const IoFrame &request, IoFrame &response, const char *action_verb,
648 ManagementActionResult &result) {
649 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
651 return true;
652 engine_.log_debug(result.device_id.c_str());
653 // A management action's whole point is the payload it reads back (a name, an info block), so an
654 // unconfirmed acceptance still cannot satisfy the caller — but it is a materially different
655 // situation from silence, and saying so saves the user chasing a link problem that isn't one.
656 result.message = outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED
657 ? std::string("device accepted the ") + action_verb + " request but sent no response"
658 : std::string("no valid response to ") + action_verb + " request";
659 return false;
660}
661
662void ManagementActions::api_rename_device(const std::string &device_id, const std::string &new_name) {
663 publish_result(rename_device(device_id, new_name));
664}
665
667 // device_id is empty for actions with no single target (e.g. scan_paired_devices' roll-call);
668 // the "for device %s" clause is omitted rather than rendering as "for device :".
669 const bool has_device = !result.device_id.empty();
670 std::string prefix = "Management action " + result.action;
671 if (has_device)
672 prefix += " for device " + result.device_id;
673 prefix += result.success ? ": " : " failed: ";
675 result.log_message.empty() ? result.message : result.log_message);
676
677 if (!hub_->is_connected())
678 return;
679
680 std::map<std::string, std::string> event_data{{"action", result.action},
681 {"device_id", result.device_id},
682 {"success", bool_to_string(result.success)},
683 {"verified", bool_to_string(result.verified)},
684 {"message", result.message}};
685
686 if (!result.requested_name.empty())
687 event_data["requested_name"] = result.requested_name;
688 if (!result.applied_name.empty())
689 event_data["applied_name"] = result.applied_name;
690 if (result.has_result_code) {
691 event_data["result_code"] = format_hex_byte(result.result_code);
692 event_data["result_code_name"] = command_result_name(result.result_code);
693 }
694 if (!result.probe_name.empty()) {
695 event_data["probe"] = result.probe_name;
696 // probe_index is empty only until a sweep has parsed its range; both probe_device() and
697 // probe_sweep() fill it before publishing, so an absent key means "no index applies" rather
698 // than "the index was blank".
699 if (!result.probe_index.empty())
700 event_data["index"] = result.probe_index;
701 }
702 if (result.has_response_cmd) {
703 event_data["response_cmd"] = format_hex_byte(result.response_cmd);
704 event_data["response_cmd_name"] = command_name(result.response_cmd);
705 }
706 if (!result.response_hex.empty())
707 event_data["response_hex"] = result.response_hex;
708
709 hub_->fire_homeassistant_event(MANAGEMENT_RESULT_EVENT, event_data);
710}
711
712ManagementActionResult ManagementActions::rename_device(const std::string &device_id, const std::string &new_name) {
714 auto *dev = resolve_device_(MANAGEMENT_ACTION_RENAME_DEVICE, device_id, result);
715 if (dev == nullptr)
716 return result;
717
718 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE];
719 std::string normalized_name;
720 const DeviceNameValidationError name_error = encode_device_name_payload(new_name, payload, normalized_name);
721 result.requested_name = normalized_name.empty() ? trim_ascii_whitespace(new_name) : normalized_name;
722 if (name_error != DeviceNameValidationError::NONE) {
724 return result;
725 }
726
727 IoFrame request;
728 if (!create_set_name(request, node_id_, dev->node_id, dev->low_power, payload)) {
729 result.message = "failed to build rename request";
730 return result;
731 }
732
733 IoFrame response;
734 if (!send_authenticated_request_(request, response, "rename", result))
735 return result;
736
737 if (response.cmd == CMD_ERROR_RESP) {
738 if (apply_error_response(response, result)) {
739 result.message =
740 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
741 }
742 return result;
743 }
744
745 if (response.cmd != CMD_SET_NAME_RESP) {
746 std::array<char, UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE> buffer{};
747 std::snprintf(buffer.data(), buffer.size(), "unexpected rename response 0x%02X", response.cmd);
748 result.message = buffer.data();
749 return result;
750 }
751
752 result.success = true;
753 result.message = "rename acknowledged by device";
754
755 if (!hub_->request_device_name(result.device_id)) {
756 result.message = "rename acknowledged but verification readback failed";
757 return result;
758 }
759
760 auto *updated_device = registry_.get(result.device_id);
761 if (updated_device != nullptr)
762 result.applied_name = updated_device->name;
763
764 if (result.applied_name == normalized_name) {
765 result.verified = true;
766 result.message = "rename verified by device readback";
767 return result;
768 }
769
770 result.message = "rename acknowledged but readback did not match the requested name";
771 return result;
772}
773
774void ManagementActions::api_identify_device(const std::string &device_id) {
776}
777
780 // Deliberately no device-type gating beyond "registered on this hub" — see the doxygen note on
781 // the declaration for why.
782 auto *dev = resolve_device_(MANAGEMENT_ACTION_IDENTIFY_DEVICE, device_id, result);
783 if (dev == nullptr)
784 return result;
785
786 IoFrame request;
787 if (!create_identify(request, node_id_, dev->node_id, dev->low_power)) {
788 result.message = "failed to build identify request";
789 return result;
790 }
791
792 IoFrame response;
793 if (!send_authenticated_request_(request, response, "identify", result))
794 return result;
795
796 if (response.cmd == CMD_ERROR_RESP) {
797 // Deliberate deviation from rename's error handling: a device may answer CMD_IDENTIFY with
798 // CMD_ERROR_RESP and still have performed the jog, so this counts as success, not failure.
799 result.success = true;
800 if (apply_error_response(response, result)) {
801 result.message = "identify triggered (device reported " + std::string(command_result_name(result.result_code)) +
802 ": " + command_result_description(result.result_code) + ")";
803 } else {
804 result.message = "identify triggered (device returned an empty error response)";
805 }
806 return result;
807 }
808
809 // Any other endpoint-matched reply counts as acknowledgment; unlike rename there is no specific
810 // response command to check against, and no readback exists to set `verified`.
811 result.success = true;
812 result.message = "identify acknowledged by device";
813 return result;
814}
815
816void ManagementActions::api_force_open_device(const std::string &device_id) {
818}
819
822 auto *dev = resolve_device_(MANAGEMENT_ACTION_FORCE_OPEN_DEVICE, device_id, result);
823 if (dev == nullptr)
824 return result;
825
826 // Delegate to the hub's normal cover-command dispatch path (capability gating, poll tracking,
827 // settle handling, backoff already live there) instead of talking to the radio directly.
828 if (!hub_->queue_device_command(result.device_id, CoverCommand::FORCE_OPEN)) {
829 result.message = "device does not accept cover commands";
830 return result;
831 }
832
833 result.success = true;
834 result.message =
835 "force open queued (elevated-priority open; wind/rain lock bypass unconfirmed; movement result arrives via "
836 "cover state)";
837 return result;
838}
839
840void ManagementActions::api_heating_control(const std::string &device_id, const std::string &function,
841 const std::string &value) {
842 publish_result(heating_control(device_id, function, value));
843}
844
845ManagementActionResult ManagementActions::heating_control(const std::string &device_id, const std::string &function,
846 const std::string &value) {
848 auto *dev = resolve_device_(MANAGEMENT_ACTION_HEATING_CONTROL, device_id, result);
849 if (dev == nullptr)
850 return result;
851
852 HeatingFunction fn{};
853 float encoded_value = 0.0F;
854 std::string parse_error;
855 if (!parse_heating_arguments(function, value, fn, encoded_value, parse_error)) {
856 result.message = parse_error;
857 return result;
858 }
859
860 // Capability gate via the predicate only — no inline device-type or vendor list.
861 if (!device_supports_climate_control(dev->type, dev->subtype)) {
862 result.message = "device is not a climate device";
863 return result;
864 }
865 if (!device_uses_heating_registers(dev->type, dev->subtype)) {
866 result.message = "device is commanded with heating levels, not private registers";
867 return result;
868 }
869
870 // Snapshot any pre-existing CMD_ERROR_RESP code so a stale one (e.g. an old wind lockout from a
871 // different command) is not misattributed to this send.
872 const uint8_t prior_result_code = dev->last_result_code;
873 const uint32_t prior_result_at_ms = dev->last_result_at_ms;
874
875 // A register with a decoded value (the window sensor) is read through the entity's read path, so
876 // the action shows exactly what the climate card would; every other function is one send. Both
877 // paths share the hub's exchange helper, and a CMD_ERROR_RESP is recorded on the device record by it.
878 float read_value = 0.0F;
879 const bool read_with_value = fn == HeatingFunction::WINDOW_OPEN;
880 const bool acknowledged = read_with_value
881 ? hub_->read_heating_register(result.device_id, fn, &read_value, EXCHANGE_RETRY_COUNT)
882 : hub_->send_heating_command(result.device_id, fn, encoded_value);
883 if (!acknowledged) {
884 result.message = std::string("device did not acknowledge the ") + heating_function_name(fn) + " command";
885 if (const auto *updated = registry_.get(result.device_id);
886 updated != nullptr && updated->last_result_code != 0 &&
887 (updated->last_result_code != prior_result_code || updated->last_result_at_ms != prior_result_at_ms)) {
888 result.has_result_code = true;
889 result.result_code = updated->last_result_code;
890 result.message =
891 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
892 }
893 return result;
894 }
895
896 result.success = true;
897 // verified stays false: an acknowledged write is the device saying it took the frame, not the
898 // device showing the new value; the climate entity reads the register back for that.
899 if (read_with_value) {
900 result.message =
901 std::string("heating ") + heating_function_name(fn) + ": window " + (read_value != 0.0F ? "open" : "closed");
902 } else {
903 result.message = std::string("heating ") + heating_function_name(fn) + " acknowledged by device";
904 }
905 return result;
906}
907
909
910void ManagementActions::api_oneway_set_position(const std::string &controller_id, const std::string &position) {
911 // device_id carries the controller-identity handle: 1W addresses a class, so there is no device
912 // to name, and the identity is what the caller actually chose.
913 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_ONEWAY_SET_POSITION, controller_id);
914
915 if (hub_->oneway_controllers().get(controller_id) == nullptr) {
916 result.message = "no oneway_controllers identity with that id";
917 publish_result(result);
918 return;
919 }
920
921 // Parse the position string here and reject loudly on anything unparseable. Do NOT relax this to
922 // atoi()/strtoul()-with-default: a value that silently became 0 would send a fully-open command
923 // to every actuator bound to this 1W identity.
924 const std::string trimmed = trim_ascii_whitespace(position);
925 if (trimmed.empty() || trimmed.find_first_not_of("0123456789") != std::string::npos) {
926 result.message = "position must be a whole number between 0 and 100";
927 publish_result(result);
928 return;
929 }
930 const unsigned long parsed = strtoul(trimmed.c_str(), nullptr, 10); // NOLINT(google-runtime-int)
931 if (parsed > ONEWAY_POSITION_FULLY_CLOSED) {
932 result.message = "position must be between 0 and 100";
933 publish_result(result);
934 return;
935 }
936
937 hub_->send_oneway_position(controller_id, static_cast<uint8_t>(parsed));
938 result.success = true;
939 // Deliberately "queued", not "sent" or "applied": 1W reports nothing back, and neither should
940 // this. See the "Last 1W Command" sensor for what was actually transmitted.
941 result.message = "1W position command queued";
942 publish_result(result);
943}
944
945void ManagementActions::api_oneway_remove_controller(const std::string &controller_id) {
946 // device_id carries the controller-identity handle, same convention as api_oneway_set_position().
947 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER, controller_id);
948
949 if (hub_->oneway_controllers().get(controller_id) == nullptr) {
950 result.message = "no oneway_controllers identity with that id";
951 publish_result(result);
952 return;
953 }
954
955 hub_->send_oneway_unenroll(controller_id);
956 result.success = true;
957 // "Queued", not "removed": 1W has no reply, so nothing here can ever confirm a device actually
958 // forgot this identity — same framing as every other 1W action result.
959 result.message = "1W remove-controller (0x39) queued";
960 publish_result(result);
961}
962
963/// @brief The keys one scan sweeps with: slot 0 is the hub key, the rest are the distinct per-device keys.
964using ScanKeys = std::vector<std::array<uint8_t, AES_KEY_SIZE>>;
965
966/// @brief Collect the keys to sweep: the hub key, then each per-device key that differs from it and
967/// from every key already collected. A per-device key equal to the hub key changes nothing, so it
968/// never earns a sweep of its own.
969static ScanKeys collect_scan_keys(const uint8_t *hub_key, DeviceRegistry &registry) {
970 ScanKeys keys(1);
971 memcpy(keys[0].data(), hub_key, AES_KEY_SIZE);
972 for (auto &entry : registry) {
973 const IoDevice &dev = entry.second;
974 if (!dev.has_system_key)
975 continue;
976 const bool known = std::ranges::any_of(
977 keys, [&dev](const auto &key) { return memcmp(key.data(), dev.system_key, AES_KEY_SIZE) == 0; });
978 if (known)
979 continue;
980 keys.emplace_back();
981 memcpy(keys.back().data(), dev.system_key, AES_KEY_SIZE);
982 }
983 return keys;
984}
985
986/// @brief How many registered devices exchange with `key`: those with it as their own key, or, for the
987/// hub key, those with no key of their own (or one equal to the hub's).
988static size_t count_devices_using_key(DeviceRegistry &registry, const uint8_t *hub_key, const uint8_t *key) {
989 const bool is_hub_key = memcmp(key, hub_key, AES_KEY_SIZE) == 0;
990 size_t count = 0;
991 for (auto &entry : registry) {
992 const IoDevice &dev = entry.second;
993 const uint8_t *device_key = dev.has_system_key ? dev.system_key : hub_key;
994 if (memcmp(device_key, key, AES_KEY_SIZE) == 0 && (is_hub_key || dev.has_system_key))
995 count++;
996 }
997 return count;
998}
999
1000/// @brief Build the final report: known/unknown grouping, header, the selection NOTE, and the
1001/// truncation NOTE.
1002static std::string build_scan_report(const ScanState &state, DeviceRegistry &registry, ScanPowerClasses selection,
1003 const ScanKeys &keys, bool reveal_keys) {
1004 // Grouped into known-first, unknown-second rather than interleaved in arrival order: the two
1005 // groups need very different follow-up (nothing to do vs. paste a YAML block), so burying an
1006 // unknown responder between two known ones makes it easy to miss.
1007 std::string known_body;
1008 std::string unknown_body;
1009 uint8_t unknown_count = 0;
1010 for (uint8_t i = 0; i < state.count; i++) {
1011 const std::string device_id = node_id_to_string(state.responders[i].src);
1012 // A device a pairing added in this boot still needs its block, so it counts as unknown here.
1013 const IoDevice *registered = registry.get_declared(device_id);
1014 const uint8_t slot = state.responders[i].key_slot;
1015 const RollCallReplyKeys reply_keys{keys[slot].data(), keys[0].data(), slot != 0};
1017 if (registered != nullptr) {
1018 known_body += format_roll_call_responder(state.responders[i], device_id, registered, reply_keys, display);
1019 } else {
1020 unknown_count++;
1021 unknown_body += format_roll_call_responder(state.responders[i], device_id, nullptr, reply_keys, display);
1022 }
1023 }
1024
1025 std::string body;
1026 if (!known_body.empty())
1027 body += "Known:\n" + known_body;
1028 if (!unknown_body.empty())
1029 body += "Unknown:\n" + unknown_body;
1030
1031 std::string message = "Roll-call: " + std::to_string(state.count) + " device" + (state.count == 1 ? "" : "s") +
1032 " detected (" + std::to_string(state.count - unknown_count) + " known, " +
1033 std::to_string(unknown_count) + " unknown)\n";
1034 // A non-default selection must say so in the report itself, not just the log: a user who left
1035 // this tunable on always_alive must not read a missing solar/battery device as "gone". Derived
1036 // from ROLL_CALL_PASSES rather than a switch/literal, so a third power class would not need a
1037 // second place taught about it.
1038 if (selection != ScanPowerClasses::BOTH) {
1039 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
1040 if (scan_power_classes_include(selection, pass.power_save_mode))
1041 continue;
1042 message += "NOTE: scan_power_classes=" + scan_power_classes_to_string(selection) + ": " +
1043 power_save_mode_name(pass.power_save_mode) + " devices were not called.\n";
1044 }
1045 }
1046 // Surfaced in the report itself, not only the log: a scan that silently listed a subset would
1047 // look like devices had gone missing.
1048 if (state.truncated) {
1049 message += "NOTE: more than " + std::to_string(SCAN_MAX_REPLIES) +
1050 " devices answered; the list below is truncated. Re-run to see whether other devices "
1051 "appear, and raise SCAN_MAX_REPLIES if this install really is larger.\n";
1052 }
1053 message += body;
1054 return message;
1055}
1056
1058 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES, "");
1059
1060 if (!*initialized_) {
1061 result.message = "hub is not initialized";
1062 return result;
1063 }
1064
1065 // One full sweep (run_roll_call_sweep(): every enabled pass on every channel) per distinct key:
1066 // the hub key first, then each different per-device key. A device answers only a roll-call built
1067 // with the key it holds, so a device that keeps its own key is invisible to the hub-key sweep.
1068 // Attempt numbers are global and 1-based across the sweeps actually run, matching the "n/N" log
1069 // line and the per-reply `attempt=` field.
1070 const ScanPowerClasses selection = tuning_->scan_power_classes;
1071 const ScanKeys keys = collect_scan_keys(system_key_, registry_);
1072 const uint8_t total_attempts = count_enabled_passes(selection) * SCAN_CHANNEL_COUNT * keys.size();
1073 ScanState state{};
1074 uint8_t attempt = 0;
1075 for (size_t slot = 0; slot < keys.size(); slot++) {
1076 const size_t using_key = count_devices_using_key(registry_, system_key_, keys[slot].data());
1077 ESP_LOGI(detail::TAG, "Roll-call with key check value %s (%u registered device%s)",
1078 detail::format_key_check_value(keys[slot].data()).c_str(), static_cast<unsigned>(using_key),
1079 using_key == 1 ? "" : "s");
1080 if (run_roll_call_sweep(engine_, node_id_, keys[slot].data(), static_cast<uint8_t>(slot), *tuning_, state, attempt,
1081 total_attempts) == RollCallAttemptResult::BUILD_FAILED) {
1082 result.message = "failed to build roll-call request";
1083 return result;
1084 }
1085 }
1086
1087 result.success = true;
1088 // Two renderings of one report. The event message goes to Home Assistant, whose recorder stores it, so
1089 // it must not carry a device key; the device log gets the version with the key, bannered.
1090 result.message = build_scan_report(state, registry_, selection, keys, /*reveal_keys=*/false);
1091 if (keys.size() > 1) // with the hub key alone there is no key to reveal, so one report serves both
1092 result.log_message = build_scan_report(state, registry_, selection, keys, /*reveal_keys=*/true);
1093 return result;
1094}
1095
1096void ManagementActions::api_probe_device(const std::string &device_id, const std::string &probe,
1097 const std::string &index) {
1098 publish_result(probe_device(device_id, probe, index));
1099}
1100
1101ManagementActionResult ManagementActions::probe_device(const std::string &device_id, const std::string &probe,
1102 const std::string &index) {
1103 // resolve_device_() reassigns its `result` argument wholesale (via make_management_result()),
1104 // so it must write into its own object rather than the one already carrying probe_name/index --
1105 // matches probe_sweep()'s resolve_result pattern below.
1106 ManagementActionResult resolve_result;
1107 auto *dev = resolve_device_(MANAGEMENT_ACTION_PROBE_DEVICE, device_id, resolve_result);
1108 if (dev == nullptr) {
1109 resolve_result.probe_name = probe;
1110 resolve_result.probe_index = index;
1111 return resolve_result;
1112 }
1113
1114 ManagementActionResult result = resolve_result;
1115 result.probe_name = probe;
1116 result.probe_index = index;
1117
1118 if (!hub_->diagnostic_probes_enabled()) {
1119 result.message = "diagnostic probes are not enabled; set diagnostic_probes: true in YAML";
1120 result.terminal_refusal = true;
1121 return result;
1122 }
1123 // An unknown frame into a mid-transaction device state machine is the one avoidable way a
1124 // read-shaped probe could cause harm.
1125 if (!effective_is_stopped(*dev)) {
1126 result.message = "device is moving; refusing to probe mid-transaction";
1127 result.terminal_refusal = true;
1128 return result;
1129 }
1130
1131 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1132 if (descriptor == nullptr)
1133 return result;
1134
1135 uint8_t index_byte = 0;
1136 if (descriptor->needs_index && !parse_probe_index(index, index_byte)) {
1137 result.message = "index must be a decimal or 0x-prefixed byte value (0-255)";
1138 return result;
1139 }
1140 if (descriptor->needs_index && index_byte > descriptor->max_index) {
1141 result.message = index_out_of_range_message(*descriptor);
1142 return result;
1143 }
1144
1145 IoFrame request;
1146 if (!descriptor->builder(request, node_id_, dev->node_id, index_byte, dev->low_power)) {
1147 result.message = "failed to build probe request";
1148 return result;
1149 }
1150
1151 IoFrame response;
1152 // A probe exists to read back a payload, so -- like a status poll or name read, and unlike a
1153 // bare CMD_EXECUTE -- SUCCESS_UNCONFIRMED (device accepted the request but sent nothing back)
1154 // does not satisfy the caller here.
1155 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
1157 engine_.log_debug(result.device_id.c_str());
1159 ? "device accepted the probe request but sent no response"
1160 : "no reply after " + std::to_string(EXCHANGE_RETRY_COUNT) +
1161 " attempts (device asleep, unreachable, or silently ignoring this opcode)";
1162 return result;
1163 }
1164
1165 // Report the raw reply, not an interpretation -- the whole point of a probe is that we do not
1166 // know what these bytes mean yet. This never touches update_device_status_(): ManagementActions
1167 // has no access to that protected hub method at all (see hub_core.h), so a probe reply can
1168 // never be misread as a position update.
1169 uint8_t raw[FRAME_MAX_SIZE] = {0};
1170 const uint8_t raw_len = serialize(response, raw, sizeof(raw));
1171 char hex[FRAME_LOG_HEX_BUFFER_SIZE];
1172 render_frame_hex_redacted(raw, raw_len, hex, sizeof(hex));
1173
1174 // Log through the same "io_capture" structured tag every other received frame uses
1175 // (log_component_capture(), log_helpers.h) rather than relying on IOHOME_FRAME_LOG -- that
1176 // build flag is opt-in (only set in the loopback/monitor configs under config/), and the
1177 // always-on receive path (process_received_packet_(), gated on !busy_) never sees a probe
1178 // reply at all, since the reply is consumed here, inside a blocking send_and_receive() call,
1179 // while busy_ is still true. Without this call a probe reply would only appear in the action's
1180 // hex message/event on a default build -- not a form scripts/corpus/ingest.py parses -- despite
1181 // the reply already having gone out over an authenticated, radio-verified exchange.
1182 detail::log_component_capture(hub_->get_radio(), "probe_rx", raw, raw_len, &response);
1183
1184 result.success = true;
1185 result.has_response_cmd = true;
1186 result.response_cmd = response.cmd;
1187 result.response_hex = hex;
1188 result.message = "probe \"" + probe + "\" reply cmd=0x" + format_hex_byte(response.cmd) + " (" +
1189 command_name(response.cmd) + ") hex=" + hex;
1190 // The status-reply table is a decoded part of the protocol, unlike the probe payload itself --
1191 // unlike the payload bytes, a result code is not something the probe exists to discover.
1192 if (response.cmd == CMD_ERROR_RESP && apply_error_response(response, result)) {
1193 result.message += " [" + std::string(command_result_name(result.result_code)) + ": " +
1195 }
1196 // The decode depends on the reply, not on which probe drew it, so any probe that happens to
1197 // draw a limitation reply reports it. Rendered, never applied: the device record is untouched.
1198 if (response.cmd == CMD_LIMITATION_STATUS_RESP) {
1199 result.decoded = detail::describe_limitation_reply(response);
1200 result.message += " [" + result.decoded + "]";
1201 }
1202 return result;
1203}
1204
1205void ManagementActions::api_probe_sweep(const std::string &device_id, const std::string &probe,
1206 const std::string &first_index, const std::string &last_index) {
1207 publish_result(probe_sweep(device_id, probe, first_index, last_index));
1208}
1209
1210ManagementActionResult ManagementActions::probe_sweep(const std::string &device_id, const std::string &probe,
1211 const std::string &first_index, const std::string &last_index) {
1212 ManagementActionResult result =
1213 make_management_result(MANAGEMENT_ACTION_PROBE_SWEEP, normalize_device_id_argument(device_id));
1214 result.probe_name = probe;
1215
1216 // Validate the device and the probe name once, up front. Neither can start succeeding partway
1217 // through a range: an unregistered device or an unrecognized probe name fails identically for
1218 // every index, so without this check the loop below would run the full span and append the
1219 // same error line up to PROBE_SWEEP_MAX_INDICES times.
1220 ManagementActionResult resolve_result;
1221 if (resolve_device_(MANAGEMENT_ACTION_PROBE_SWEEP, device_id, resolve_result) == nullptr) {
1222 result.message = resolve_result.message;
1223 return result;
1224 }
1225 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1226 if (descriptor == nullptr)
1227 return result;
1228 if (!descriptor->needs_index) {
1229 result.message = "probe \"" + probe + "\" takes no index; use probe_device";
1230 return result;
1231 }
1232
1233 uint8_t first = 0;
1234 uint8_t last = 0;
1235 if (!parse_probe_index(first_index, first) || !parse_probe_index(last_index, last)) {
1236 result.message = "first_index/last_index must be decimal or 0x-prefixed byte values (0-255)";
1237 return result;
1238 }
1239 if (last < first) {
1240 result.message = "last_index must be >= first_index";
1241 return result;
1242 }
1243 if (last > descriptor->max_index) {
1244 result.message = index_out_of_range_message(*descriptor);
1245 return result;
1246 }
1247 const uint32_t span = static_cast<uint32_t>(last) - first + 1;
1248 if (span > PROBE_SWEEP_MAX_INDICES) {
1249 result.message =
1250 "sweep range too wide: " + std::to_string(span) + " indices, max " + std::to_string(PROBE_SWEEP_MAX_INDICES);
1251 return result;
1252 }
1253
1254 std::string report;
1255 uint32_t answered_count = 0;
1256 bool stopped_early = false;
1257 for (uint32_t idx = first; idx <= last; idx++) {
1258 if (idx != first) {
1259 App.feed_wdt();
1260 delay(PROBE_SWEEP_DELAY_MS);
1261 }
1262 const std::string index_str = std::to_string(idx);
1263 const ManagementActionResult step = probe_device(device_id, probe, index_str);
1264 report += "index=0x" + format_hex_byte(static_cast<uint8_t>(idx)) + ": ";
1265 if (step.success) {
1266 answered_count++;
1267 report += "cmd=0x" + format_hex_byte(step.response_cmd) + " hex=" + step.response_hex;
1268 if (!step.decoded.empty()) {
1269 report += " [" + step.decoded + "]";
1270 }
1271 report += "\n";
1272 } else {
1273 report += step.message + "\n";
1274 }
1275 // terminal_refusal (diagnostic probes not enabled, device moving) applies to every remaining
1276 // index the same way it applied to this one -- stop rather than repeat it N more times. A
1277 // structured flag, not a search over `step.message`: probe_device() sets it explicitly, so
1278 // rewording a refusal message can never silently break this check.
1279 if (step.terminal_refusal) {
1280 report += "(stopping sweep: " + step.message + ")\n";
1281 stopped_early = true;
1282 break;
1283 }
1284 }
1285
1286 // A sweep that ran its full requested range is a success even if nothing answered -- "no
1287 // device answered any index" is a valid, reportable outcome, the same philosophy
1288 // scan_paired_devices() uses for zero replies. A sweep cut short by a terminal refusal did not
1289 // complete what was asked of it and must not report success.
1290 result.success = !stopped_early;
1291 // Renders the same 0x-prefixed form as the report body, so the Home Assistant event carries the
1292 // swept range rather than a blank `index` field left over from the per-index probe_device()
1293 // results this loop discards.
1294 result.probe_index = "0x" + format_hex_byte(first) + "-0x" + format_hex_byte(last);
1295 result.message = "Sweep \"" + probe + "\" over [0x" + format_hex_byte(first) + ",0x" + format_hex_byte(last) +
1296 "]: " + std::to_string(answered_count) + " answered\n" + report;
1297 return result;
1298}
1299
1300} // namespace home_io_control
1301} // 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
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 an already-paired device and report the raw reply.
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_device() call per index, in one user gesture.
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.
std::string describe_limitation_reply(const IoFrame &frame)
Render a CMD_LIMITATION_STATUS_RESP frame for a log line or probe report.
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...
void log_component_capture(const RadioDriver *radio, const char *stage, const uint8_t *buf, uint8_t len, const IoFrame *frame=nullptr)
Log a frame at the "io_capture" tag with structured fields.
Definition log_helpers.h:97
std::string format_key_check_value(const uint8_t key[AES_KEY_SIZE])
A key check value: the first three bytes of the key's AES encryption of a zero block.
Definition log_helpers.h:77
RollCallAttemptResult run_roll_call_sweep(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *key, uint8_t key_slot, const TuningConfig &tuning, ScanState &state, uint8_t &attempt, uint8_t total_attempts)
Run the full roll-call sweep under one key: every enabled pass on every channel.
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.
static bool parse_probe_index(const std::string &text, uint8_t &out)
Parse a probe_device()/probe_sweep() index argument into a byte.
uint8_t count_enabled_passes(ScanPowerClasses selection)
How many of ROLL_CALL_PASSES selection actually calls.
Definition roll_call.cpp:53
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).
bool create_private2_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t modifier, bool long_form, bool low_power)
Build a CMD_PRIVATE2 (0x0C) request in either of the two field-observed shapes.
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.
static ManagementActionResult make_management_result(const std::string &action, const std::string &device_id)
bool create_limitation_status_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, LimitationType type, bool low_power)
Build the limitation read (see the header for the evidence and the read-only guarantee).
@ 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:72
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO1 (0x54) request. No payload. See proto_commands.h for the evidence note.
constexpr RollCallPass ROLL_CALL_PASSES[]
The roll-call's two fixed frame shapes, low-power first.
Definition roll_call.h:106
ScanPowerClasses
Which power classes scan_paired_devices() calls.
@ 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.
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?
bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_GENERAL_INFO3 (0x58) request.
bool create_status_mp_fp_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build the KLF-style status read of the main parameter plus FP1-FP7 (see the header for the evidence).
bool create_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO2 (0x56) request. No payload. See proto_commands.h for the evidence note.
void render_frame_hex_redacted(const uint8_t *data, uint8_t len, char *out, size_t out_size)
Render a frame's bytes as spaced hex text, masking the payload when the command carries key material ...
Definition log_frame.h:71
const char * command_result_name(uint8_t result)
Return a stable symbolic name for a CMD_ERROR_RESP result code.
ExchangeOutcome
Authenticated exchange engine — outbound and inbound protocol flows.
@ SUCCESS_WITH_RESPONSE
Device replied; the caller's response frame is populated.
@ SUCCESS_UNCONFIRMED
Device authenticated the request — so it received and accepted it — but sent no final response.
static std::string normalize_lower_argument(const std::string &value)
Lowercase + ASCII-trim a native-API string argument.
constexpr size_t FRAME_LOG_HEX_BUFFER_SIZE
Fits a full 32-byte frame rendered as spaced hex text.
Definition log_frame.h:19
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:125
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
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.
bool create_private_function(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t function_id, uint8_t sub_index)
Build a CMD_PRIVATE (0x03) request for an arbitrary function ID.
bool device_uses_heating_registers(DeviceType type, uint8_t subtype)
Does a climate entity drive this device over the private register protocol?
bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector, uint8_t block, uint8_t function_id)
Build an extended CMD_PRIVATE (0x03) request with a selector/block pair — the shape real hubs use for...
LimitationType
On-air selector byte (request data[0]) choosing which resulting limit to read.
@ MINIMUM
Resulting minimum limit; rain protection raises it (a floor on the position value).
static std::string normalize_device_id_argument(const std::string &device_id)
static std::string format_hex_byte(uint8_t value)
Format a byte as two uppercase hex digits, no prefix.
constexpr uint8_t SCAN_MAX_REPLIES
Maximum distinct responders reported by one scan_paired_devices() call.
Definition roll_call.h:34
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:192
@ HIDDEN
Never print the key: its check value only. For anything Home Assistant records.
Definition roll_call.h:193
@ BANNERED
Print the key in a snippet, wrapped in the line's own DO-NOT-SHARE block.
Definition roll_call.h:194
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.
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.
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
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.
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
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t data_len
Actual length of data.
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.
bool terminal_refusal
True when probe_device() failed for a reason that will recur identically for every remaining index in...
std::string applied_name
Verified cached UTF-8 name after a readback, when available.
std::string decoded
Rendering of the probe reply for the replies this codebase can decode (currently a limitation reply,...
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:80
The keys a responder line needs: the one a responder answered under, and the hub's.
Definition roll_call.h:185
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:52
Accumulated state of one scan, threaded through every sweep.
Definition roll_call.h:129
uint8_t count
Entries in use.
Definition roll_call.h:131
bool truncated
A responder was dropped because the array was full.
Definition roll_call.h:132
ScanResponder responders[SCAN_MAX_REPLIES]
Distinct responders, in arrival order.
Definition roll_call.h:130
All runtime tunable parameters for pairing and radio diagnostics.