Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
management_actions.cpp
Go to the documentation of this file.
1/// @file management_actions.cpp
2/// @brief Hub-level management actions such as device rename, identify, and force-open.
3/// @ingroup hioc_hub
4///
5/// This file owns advanced management operations that are not part of the normal
6/// entity surface. Operations are exposed as ESPHome native API actions so Home
7/// Assistant can trigger them without adding always-visible helper entities. All
8/// actions share one API service descriptor (detail::ManagementServiceDescriptor);
9/// adding a new action is a registration call, not a new descriptor class.
10
11#include "management_actions.h"
12
13#include "hub_internal.h" // brings in hub_core.h + all internal helpers + logging
14#include "proto_commands.h"
15
16#include "esphome/core/application.h"
17#include "esphome/core/hal.h"
18
19#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
20#include "esphome/core/helpers.h"
21#endif
22
23#include <algorithm>
24#include <array>
25#include <cctype>
26#include <cerrno>
27#include <cmath>
28#include <cstdio>
29#include <cstdlib>
30#include <cstring>
31#include <functional>
32#include <limits>
33#include <map>
34#include <span>
35#include <vector>
36
37namespace esphome {
38namespace home_io_control {
39
40namespace {
41
42constexpr const char *MANAGEMENT_ACTION_RENAME_DEVICE = "rename_device";
43constexpr const char *MANAGEMENT_ACTION_IDENTIFY_DEVICE = "identify_device";
44constexpr const char *MANAGEMENT_ACTION_FORCE_OPEN_DEVICE = "force_open_device";
45constexpr const char *MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES = "scan_paired_devices";
46constexpr const char *MANAGEMENT_ACTION_ONEWAY_SET_POSITION = "oneway_set_position";
47constexpr const char *MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER = "oneway_remove_controller";
48constexpr const char *MANAGEMENT_ACTION_PROBE_DEVICE = "probe_device";
49constexpr const char *MANAGEMENT_ACTION_PROBE_SWEEP = "probe_sweep";
50constexpr const char *MANAGEMENT_ACTION_HEATING_CONTROL = "heating_control";
51constexpr const char *MANAGEMENT_RESULT_EVENT = "esphome.home_io_control_action_result";
52constexpr size_t RESULT_CODE_BUFFER_SIZE = 5;
53constexpr size_t UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE = 64;
54constexpr size_t HEATING_RANGE_MESSAGE_BUFFER_SIZE = 64;
55
56// --- Diagnostic probe names (probe_device()/probe_sweep() `probe` argument) ---
57constexpr const char *PROBE_NAME_PRIVATE_FN = "private_fn"; ///< Q0: create_private_function().
58constexpr const char *PROBE_NAME_PRIVATE_FN_SUB = "private_fn_sub"; ///< CMD_PRIVATE fn 0x09, chosen second byte.
59constexpr const char *PROBE_NAME_STATUS_EXT = "status_ext"; ///< Q1: create_get_status_extended().
60constexpr const char *PROBE_NAME_STATUS_EXT_FN6 = "status_ext_fn6"; ///< Extended CMD_PRIVATE at function ID 0x06.
61constexpr const char *PROBE_NAME_STATUS_EXT_FN9 = "status_ext_fn9"; ///< Extended CMD_PRIVATE at function ID 0x09.
62constexpr const char *PROBE_NAME_GET_INFO1 = "get_info1"; ///< create_get_info1() (0x54, no payload).
63constexpr const char *PROBE_NAME_GET_INFO2 = "get_info2"; ///< create_get_info2() (0x56, no payload).
64constexpr const char *PROBE_NAME_GENERAL_INFO3 = "general_info3"; ///< Q2: create_general_info3().
65constexpr const char *PROBE_NAME_PRIVATE2 = "private2"; ///< Q3 long form: create_private2_read().
66constexpr const char *PROBE_NAME_PRIVATE2_SHORT = "private2_short"; ///< Q3 short form: create_private2_read().
67/// Function IDs the extended-shape probes hold fixed. Production software elsewhere describes
68/// these two as a battery read; on real hardware (17 solar devices plus our own mains motors)
69/// the *short* 3-byte form at these IDs returned position-family values, never a charge value.
70/// The extended 4-byte shape at these IDs has never been sent by anything.
71constexpr uint8_t PROBE_EXT_FUNCTION_ID_06 = 0x06;
72constexpr uint8_t PROBE_EXT_FUNCTION_ID_09 = 0x09;
73/// Function ID `private_fn_sub` holds fixed while `index` walks the second payload byte.
74constexpr uint8_t PROBE_PRIVATE_FN_SUB_FUNCTION_ID = 0x09;
75/// Extended-CMD_PRIVATE selector "status_ext" probes: the field-observed, never-decoded 0x80.
76/// Selector 0x20 (tilt) already has a permanent builder/action (create_get_status_tilt()) and is
77/// not part of this probe.
78constexpr uint8_t PROBE_STATUS_EXT_SELECTOR = 0x80;
79/// Bound on probe_sweep()'s index range — this transmits on a shared ISM band to what may be a
80/// battery device; an unbounded sweep is antisocial and would keep the device awake needlessly.
81constexpr uint8_t PROBE_SWEEP_MAX_INDICES = 16;
82/// Spacing between probe_sweep() indices. Sweep steps are sequential and blocking, so they never
83/// overlap regardless of this value; it exists for ISM-band duty cycling and to give a battery
84/// device real idle time between reads.
85constexpr uint32_t PROBE_SWEEP_DELAY_MS = 1000;
86
87/// @brief Uniform wrapper signature every probe builder is adapted to, so PROBE_TABLE below can
88/// hold one function-pointer type regardless of each create_*() builder's own parameter list.
89/// `low_power` is the target device's YAML-declared power class, forwarded to each builder so a
90/// probe to an always-alive device is shaped like one — the diagnostic subsystem has to work on
91/// the very device class a silent-device investigation reaches for it.
92using ProbeBuilderFn = bool (*)(IoFrame &, const uint8_t *, const uint8_t *, uint8_t index, bool low_power);
93
94bool build_probe_private_fn(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
95 return create_private_function(f, own, dst, low_power, index);
96}
97bool build_probe_private_fn_sub(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
98 return create_private_function(f, own, dst, low_power, PROBE_PRIVATE_FN_SUB_FUNCTION_ID, index);
99}
100bool build_probe_status_ext(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
101 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index);
102}
103bool build_probe_status_ext_fn6(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
104 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index, PROBE_EXT_FUNCTION_ID_06);
105}
106bool build_probe_status_ext_fn9(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
107 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index, PROBE_EXT_FUNCTION_ID_09);
108}
109bool build_probe_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
110 return create_get_info1(f, own, dst, low_power);
111}
112bool build_probe_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
113 return create_get_info2(f, own, dst, low_power);
114}
115bool build_probe_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
116 return create_general_info3(f, own, dst, low_power);
117}
118bool build_probe_private2_long(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
119 return create_private2_read(f, own, dst, index, /*long_form=*/true, low_power);
120}
121bool build_probe_private2_short(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
122 return create_private2_read(f, own, dst, index, /*long_form=*/false, low_power);
123}
124
125/// @brief One row per probe_device()/probe_sweep() `probe` argument value.
126struct ProbeDescriptor {
127 const char *name;
128 bool needs_index; ///< False for the no-payload probes ("general_info3", "get_info1", "get_info2") -- their
129 ///< builders take no index/selector.
130 ProbeBuilderFn builder;
131};
132
133/// @brief The full set of probes probe_device()/probe_sweep() can dispatch to.
134///
135/// Single source of truth for probe names. Adding, removing, or renaming a probe is a one-line
136/// change here rather than a change to both probe_device()'s dispatch and its "index must be..."
137/// error message. Deliberately has no "unknown4a" row -- see ADR 0024.
138constexpr ProbeDescriptor PROBE_TABLE[] = {
139 {PROBE_NAME_PRIVATE_FN, true, build_probe_private_fn},
140 {PROBE_NAME_PRIVATE_FN_SUB, true, build_probe_private_fn_sub},
141 {PROBE_NAME_STATUS_EXT, true, build_probe_status_ext},
142 {PROBE_NAME_STATUS_EXT_FN6, true, build_probe_status_ext_fn6},
143 {PROBE_NAME_STATUS_EXT_FN9, true, build_probe_status_ext_fn9},
144 {PROBE_NAME_GET_INFO1, false, build_probe_get_info1},
145 {PROBE_NAME_GET_INFO2, false, build_probe_get_info2},
146 {PROBE_NAME_GENERAL_INFO3, false, build_probe_general_info3},
147 {PROBE_NAME_PRIVATE2, true, build_probe_private2_long},
148 {PROBE_NAME_PRIVATE2_SHORT, true, build_probe_private2_short},
149};
150constexpr uint8_t PROBE_TABLE_SIZE = sizeof(PROBE_TABLE) / sizeof(PROBE_TABLE[0]);
151
152/// @brief Look up a probe by name.
153/// @return Pointer into PROBE_TABLE, or nullptr if `probe` names none of its rows.
154const ProbeDescriptor *find_probe_descriptor(const std::string &probe) {
155 for (const auto &descriptor : PROBE_TABLE) {
156 if (probe == descriptor.name)
157 return &descriptor;
158 }
159 return nullptr;
160}
161
162/// @brief The error message probe_device()/probe_sweep() report for an unrecognized `probe`
163/// argument, built from PROBE_TABLE so the list of names can never drift out of sync with it.
164std::string unknown_probe_message(const std::string &probe) {
165 std::string names;
166 for (uint8_t i = 0; i < PROBE_TABLE_SIZE; i++) {
167 if (i > 0)
168 names += (i + 1 == PROBE_TABLE_SIZE) ? ", or " : ", ";
169 names += PROBE_TABLE[i].name;
170 }
171 return "unknown probe \"" + probe + "\" (expected " + names + ")";
172}
173
174/// @brief Shared probe-name lookup for probe_device()/probe_sweep(): returns the descriptor, or
175/// nullptr after writing the "unknown probe" message into @p result. The device-resolution and
176/// index-parse steps stay per-method: probe_device keeps the resolved device (for the moving
177/// check and node_id), probe_sweep discards it, and one parses a single index while the other
178/// parses a first/last range.
179const ProbeDescriptor *resolve_probe_descriptor(const std::string &probe, ManagementActionResult &result) {
180 const ProbeDescriptor *descriptor = find_probe_descriptor(probe);
181 if (descriptor == nullptr)
182 result.message = unknown_probe_message(probe);
183 return descriptor;
184}
185
186/// @brief Maximum distinct responders reported by one scan_paired_devices() call.
187///
188/// Sized against the loop task's stack, which is where this runs: ESPHome spawns its own
189/// `loopTask` with `ESPHOME_LOOP_TASK_STACK_SIZE` (8192 B) — not the 3.5 KB ESP-IDF main task.
190/// `ScanResponder` is 12 B, so 24 slots cost ≈288 B (~3.5 % of that stack) in a single array;
191/// replies are decoded on arrival rather than buffered as whole 33 B `IoFrame`s, which is what
192/// makes a limit this size cheap. Installs with 20+ actuators are real, so 8 (the original
193/// value, chosen when whole frames were retained) was too low to be useful. Overflow beyond
194/// this is reported in the scan's own output, never silent.
195constexpr uint8_t SCAN_MAX_REPLIES = 24;
196
197/// @brief One roll-call responder, decoded on arrival.
198///
199/// Deliberately holds only what the report needs, so the fixed array stays small: the raw
200/// `IoFrame` is discarded as soon as decode_discovery_response() has run, and the hex device-ID
201/// string is rebuilt from `src` at format time rather than stored (a `std::string` member would
202/// cost more per entry than this whole struct).
203struct ScanResponder {
204 uint8_t src[NODE_ID_SIZE]; ///< Responder's node ID; also the dedup key.
205 DeviceType type; ///< Decoded device type.
206 uint8_t subtype; ///< Decoded device subtype.
207 bool inverted; ///< Decoded position-inversion flag.
208 int16_t rssi_dbm; ///< RSSI of the reply that produced this entry.
209 uint8_t manufacturer; ///< Raw manufacturer ID; name via manufacturer_name().
210 uint8_t flags; ///< Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
211 bool has_extended; ///< Whether manufacturer/flags above are present.
212 bool metadata_complete; ///< Whether type/subtype were present in the payload.
213};
214
215/// @brief Channels scan_paired_devices() transmits its roll-call request on, one attempt each.
216///
217/// A single broadcast on one fixed channel only reaches a paired device that happens to be
218/// awake and listening on that exact channel at that exact instant — real hardware testing
219/// found paired devices duty-cycle across all three channels independently of the hub, so a
220/// one-shot broadcast on CH2 alone misses whichever devices are elsewhere in their cycle right
221/// then. Retrying the same request on the other two channels gives every device up to three
222/// chances to be listening when the hub transmits. CH2 first since it is the protocol's
223/// designated TX channel (see FREQ_CH2's doc comment) and therefore the most likely to catch a
224/// reply on the first attempt.
225///
226/// This TX retry is a duty-cycle workaround, not a receive-side fix. The hub's listen path
227/// separately extends its dwell on a detected preamble/sync rather than hopping mid-frame, which
228/// is what keeps it from dropping replies it does hear.
229constexpr uint32_t SCAN_CHANNELS[] = {FREQ_CH2, FREQ_CH1, FREQ_CH3};
230constexpr uint8_t SCAN_CHANNEL_COUNT = sizeof(SCAN_CHANNELS) / sizeof(SCAN_CHANNELS[0]);
231
232} // namespace
233
234namespace detail {
235
236#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
237
238// ESPHome 2026.9 added a `std::span<char> scratch` parameter to
239// UserServiceDescriptor::encode_list_service_response(): on ESP8266 the arg-name
240// string literals live in PROGMEM and are copied into `scratch`, so the returned
241// message is only valid while `scratch` is. 2026.8.x (incl. beta) still has the
242// zero-arg signature, hence the >= 2026.9.0 gate. Detect that ABI here so our
243// override keeps matching the pure virtual across ESPHome versions. The nested #if
244// is deliberate: VERSION_CODE is undefined on older ESPHome and in the host test
245// stubs, and a skipped outer group is not parsed, so the macro call never leaks.
246// The host unit-test build always takes the zero-arg branch (its api stub mirrors
247// stable), so the scratch branch is only exercised by the weekly ESPHome-dev CI.
248#if defined(ESPHOME_VERSION_CODE) && defined(VERSION_CODE)
249#if ESPHOME_VERSION_CODE >= VERSION_CODE(2026, 9, 0)
250#define IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH 1
251#endif
252#endif
253
254/// @brief Native API descriptor shared by every management action.
255///
256/// ESPHome 2026.x does not expose the generated YAML action helper runtime to external
257/// components, so Home IO Control registers the action descriptor directly with APIServer.
258/// This keeps the HA action surface identical to native ESPHome actions while avoiding
259/// the unresolved link path behind CustomAPIDevice::register_service().
260///
261/// One descriptor class serves every action: it is parametrized by name, argument list,
262/// and a callback that unpacks the request's string args and forwards them to a
263/// ManagementActions method. The callback captures a ManagementActions* and calls only
264/// public methods on it, so no friend declaration into the hub is needed. Adding action
265/// N+1 is therefore a new register_user_service() call in register_actions(), not a new
266/// descriptor class.
267class ManagementServiceDescriptor : public api::UserServiceDescriptor {
268 public:
269 ManagementServiceDescriptor(const char *name, std::vector<const char *> arg_names,
270 std::function<void(const api::ExecuteServiceRequest &)> callback)
271 : name_(name), key_(fnv1_hash(name)), arg_names_(std::move(arg_names)), callback_(std::move(callback)) {}
272
273 // name_ and arg_names_ point at ordinary (non-PROGMEM) .rodata string literals, so
274 // the StringRefs stay valid after return on any target and the scratch buffer is
275 // unused -- same reasoning as upstream's own UserServiceDynamic.
276#ifdef IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH
277 api::ListEntitiesServicesResponse encode_list_service_response(std::span<char> /*scratch*/) override {
278#else
279 api::ListEntitiesServicesResponse encode_list_service_response() override {
280#endif
281 api::ListEntitiesServicesResponse response;
282 response.name = StringRef(this->name_);
283 response.key = this->key_;
284 response.supports_response = api::enums::SUPPORTS_RESPONSE_NONE;
285 response.args.init(this->arg_names_.size());
286 for (const char *arg_name : this->arg_names_) {
287 auto &arg = response.args.emplace_back();
288 arg.name = StringRef(arg_name);
289 arg.type = api::enums::SERVICE_ARG_TYPE_STRING;
290 }
291 return response;
292 }
293
294 bool execute_service(const api::ExecuteServiceRequest &request) override {
295 if (request.key != this->key_ || request.args.size() != this->arg_names_.size())
296 return false;
297 this->callback_(request);
298 return true;
299 }
300
301#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
302 bool execute_service(const api::ExecuteServiceRequest &request, uint32_t) override {
303 return this->execute_service(request);
304 }
305#endif
306
307 protected:
308 const char *name_;
309 uint32_t key_;
310 std::vector<const char *> arg_names_;
311 std::function<void(const api::ExecuteServiceRequest &)> callback_;
312};
313
314#undef IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH
315#endif
316
317} // namespace detail
318
319// --- Helper free functions (file-local) ---
320
321static std::string normalize_device_id_argument(const std::string &device_id) {
322 std::string normalized = trim_ascii_whitespace(device_id);
323 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
324 [](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
325 return normalized;
326}
327
328static std::string bool_to_string(bool value) { return value ? "true" : "false"; }
329
330/// @brief Format a byte as two uppercase hex digits, no prefix. Named neutrally rather than
331/// after any one caller: used for CMD_ERROR_RESP result codes, probe reply/index command bytes,
332/// and sweep index values alike -- none of those is a "result code" except the first.
333static std::string format_hex_byte(uint8_t value) {
334 std::array<char, RESULT_CODE_BUFFER_SIZE> buffer{};
335 std::snprintf(buffer.data(), buffer.size(), "%02X", value);
336 return std::string(buffer.data());
337}
338
339static ManagementActionResult make_management_result(const std::string &action, const std::string &device_id) {
341 result.action = action;
342 result.device_id = device_id;
343 return result;
344}
345
346/// @brief Decode a CMD_ERROR_RESP frame's result code into `result`.
347///
348/// Populates has_result_code/result_code but deliberately leaves `result.message` untouched:
349/// rename and identify_device report different wording for the same decoded code, so message
350/// composition stays with each caller. On an empty error response, sets a stock message itself
351/// (there is no code to report) and returns false; callers should treat that the same way as a
352/// decoded code, just without result-code-specific wording.
353/// @param response Frame whose cmd is CMD_ERROR_RESP.
354/// @param result Result to populate.
355/// @return true if a result code was decoded, false if the response carried no data.
356static bool apply_error_response(const IoFrame &response, ManagementActionResult &result) {
357 if (response.data_len == 0) {
358 result.message = "device returned an empty error response";
359 return false;
360 }
361 result.has_result_code = true;
362 result.result_code = response.data[0];
363 return true;
364}
365
366/// @brief Parse a probe_device()/probe_sweep() index argument into a byte.
367///
368/// Accepts both a bare decimal string ("6") and a "0x"-prefixed hex string ("0x06") — the two
369/// shapes a Home Assistant user is likely to type when copying a value out of a captured frame
370/// dump. Rejects anything else (empty string, trailing garbage, out-of-range value) rather than
371/// silently defaulting to 0, since a wrong index silently sent as 0 would misrepresent what was
372/// actually probed.
373/// @param text Argument as received from the native API call.
374/// @param out Parsed byte on success; left untouched on failure.
375/// @return true if `text` is exactly one well-formed byte value.
376static bool parse_probe_index(const std::string &text, uint8_t &out) {
377 if (text.empty())
378 return false;
379 // A leading "0" followed by another digit (e.g. "010") is valid octal to strtoul() below and
380 // would silently probe a different index than a user copying a byte value out of a hex dump
381 // intended -- reject it explicitly rather than relying on strtoul()'s octal parsing, which only
382 // rejects the invalid-octal-digit cases ("08", "09"), not the valid ones.
383 if (text.size() > 1 && text[0] == '0' && text[1] != 'x' && text[1] != 'X')
384 return false;
385 errno = 0;
386 char *end = nullptr;
387 // Base 0: strtoul() itself recognizes a "0x"/"0X" prefix as hex and a bare "0" as decimal
388 // zero, which is exactly the "6" / "0x06" / "0" shape probes need to accept.
389 const uint32_t value = std::strtoul(text.c_str(), &end, 0);
390 if (end != text.c_str() + text.size())
391 return false;
392 if (errno == ERANGE || value > std::numeric_limits<uint8_t>::max())
393 return false;
394 out = static_cast<uint8_t>(value);
395 return true;
396}
397
398/// @brief Lowercase + ASCII-trim a native-API string argument.
399static std::string normalize_lower_argument(const std::string &value) {
400 std::string normalized = trim_ascii_whitespace(value);
401 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
402 [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
403 return normalized;
404}
405
406/// @brief A `value` token that maps to a fixed float (used by set_mode / set_presence / set_window).
408 const char *token;
409 float value;
410};
411
412/// @brief Match `token` against `table`; on a hit set `out` and return true.
413static bool match_heating_named_value(const std::string &token, const HeatingNamedValue *table, size_t table_len,
414 float &out) {
415 for (size_t i = 0; i < table_len; i++) {
416 if (token == table[i].token) {
417 out = table[i].value;
418 return true;
419 }
420 }
421 return false;
422}
423
424/// @brief Parse `set_temperature`'s value into degrees Celsius, range-checked.
425static bool parse_heating_temperature(const std::string &value, float &value_out, std::string &error) {
426 const std::string text = trim_ascii_whitespace(value);
427 errno = 0;
428 char *end = nullptr;
429 const float parsed = std::strtof(text.c_str(), &end);
430 if (text.empty() || end != text.c_str() + text.size() || errno == ERANGE || !std::isfinite(parsed)) {
431 error = "temperature must be a number in degrees Celsius";
432 return false;
433 }
434 if (parsed < HEATING_TEMP_MIN_C || parsed > HEATING_TEMP_MAX_C) {
435 std::array<char, HEATING_RANGE_MESSAGE_BUFFER_SIZE> buffer{};
436 std::snprintf(buffer.data(), buffer.size(), "temperature must be between %.1f and %.1f",
437 static_cast<double>(HEATING_TEMP_MIN_C), static_cast<double>(HEATING_TEMP_MAX_C));
438 error = buffer.data();
439 return false;
440 }
441 value_out = parsed;
442 return true;
443}
444
445/// @brief Parse the (`function`, `value`) argument pair of the `heating_control` action.
446///
447/// Every native-API argument is a string (ManagementServiceDescriptor hardcodes
448/// SERVICE_ARG_TYPE_STRING), so this turns the two strings into a HeatingFunction plus the float
449/// encode_heating_payload() expects: degrees C for `set_temperature`, a HeatingMode value for
450/// `set_mode`, 0/1 for `set_presence` / `set_window`, and an ignored 0 for `power_on` /
451/// `midnight_sync`. On any malformed input it fills `error` with a caller-facing message and
452/// returns false rather than coercing a value. Kept next to the action (not in proto_heating)
453/// because the climate entity receives typed enums from Home Assistant and needs no string
454/// parsing.
455/// @param function Function name argument.
456/// @param value Value argument.
457/// @param fn_out Parsed function on success.
458/// @param value_out Parsed value on success (0 for the value-less functions).
459/// @param error Caller-facing message on failure.
460/// @return true on a fully valid pair.
461static bool parse_heating_arguments(const std::string &function, const std::string &value, HeatingFunction &fn_out,
462 float &value_out, std::string &error) {
463 static constexpr HeatingNamedValue MODE_VALUES[] = {
464 {"auto", static_cast<float>(HeatingMode::AUTO)},
465 {"manual", static_cast<float>(HeatingMode::MANUAL)},
466 {"prog", static_cast<float>(HeatingMode::PROG)},
467 {"off", static_cast<float>(HeatingMode::OFF)},
468 };
469 static constexpr HeatingNamedValue PRESENCE_VALUES[] = {{"on", 1.0F}, {"off", 0.0F}};
470 static constexpr HeatingNamedValue WINDOW_VALUES[] = {{"open", 1.0F}, {"close", 0.0F}};
471
472 const std::string fn = normalize_lower_argument(function);
473 value_out = 0.0F;
474
475 if (fn == "power_on") {
477 return true;
478 }
479 if (fn == "midnight_sync") {
481 return true;
482 }
483 if (fn == "set_temperature") {
485 return parse_heating_temperature(value, value_out, error);
486 }
487 if (fn == "set_mode") {
489 if (match_heating_named_value(normalize_lower_argument(value), MODE_VALUES, std::size(MODE_VALUES), value_out))
490 return true;
491 error = "mode must be one of auto, manual, prog, off";
492 return false;
493 }
494 if (fn == "set_presence") {
496 if (match_heating_named_value(normalize_lower_argument(value), PRESENCE_VALUES, std::size(PRESENCE_VALUES),
497 value_out))
498 return true;
499 error = "presence must be 'on' or 'off'";
500 return false;
501 }
502 if (fn == "set_window") {
504 if (match_heating_named_value(normalize_lower_argument(value), WINDOW_VALUES, std::size(WINDOW_VALUES), value_out))
505 return true;
506 error = "window must be 'open' or 'close'";
507 return false;
508 }
509
510 error = "unknown heating function '" + trim_ascii_whitespace(function) + "'";
511 return false;
512}
513
514// --- ManagementActions ---
515
516ManagementActions::ManagementActions(const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning,
517 ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized,
519 : node_id_(node_id),
520 system_key_(system_key),
521 tuning_(tuning),
522 engine_(engine),
523 registry_(registry),
524 initialized_(initialized),
525 hub_(hub) {}
526
528#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
529 if (api::global_api_server == nullptr) {
530 ESP_LOGW(detail::TAG, "Native API server not available, management actions will not be registered");
531 return;
532 }
533
534 // One row per user-visible action: name, argument-name list (every argument is exposed as
535 // SERVICE_ARG_TYPE_STRING — `oneway_set_position` parses its "position" string itself rather
536 // than widening a shipped API surface), whether it is gated behind `diagnostic_probes: true`,
537 // and the callback that unpacks its arguments. Adding an action is one row here.
538 struct ActionReg {
539 const char *name;
540 std::vector<const char *> arg_names;
541 bool diagnostic_only;
542 std::function<void(const api::ExecuteServiceRequest &)> callback;
543 };
544 const ActionReg actions[] = {
545 {MANAGEMENT_ACTION_RENAME_DEVICE,
546 {"device_id", "new_name"},
547 false,
548 [this](const api::ExecuteServiceRequest &r) {
549 this->api_rename_device(r.args[0].string_.str(), r.args[1].string_.str());
550 }},
551 {MANAGEMENT_ACTION_IDENTIFY_DEVICE,
552 {"device_id"},
553 false,
554 [this](const api::ExecuteServiceRequest &r) { this->api_identify_device(r.args[0].string_.str()); }},
555 {MANAGEMENT_ACTION_FORCE_OPEN_DEVICE,
556 {"device_id"},
557 false,
558 [this](const api::ExecuteServiceRequest &r) { this->api_force_open_device(r.args[0].string_.str()); }},
559 {MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES,
560 {},
561 false,
562 [this](const api::ExecuteServiceRequest &) { this->api_scan_paired_devices(); }},
563 {MANAGEMENT_ACTION_ONEWAY_SET_POSITION,
564 {"controller_id", "position"},
565 false,
566 [this](const api::ExecuteServiceRequest &r) {
567 this->api_oneway_set_position(r.args[0].string_.str(), r.args[1].string_.str());
568 }},
569 {MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER,
570 {"controller_id"},
571 false,
572 [this](const api::ExecuteServiceRequest &r) { this->api_oneway_remove_controller(r.args[0].string_.str()); }},
573 {MANAGEMENT_ACTION_HEATING_CONTROL,
574 {"device_id", "function", "value"},
575 false, // a real user feature, gated by documentation not by diagnostic_probes:
576 [this](const api::ExecuteServiceRequest &r) {
577 this->api_heating_control(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
578 }},
579 {MANAGEMENT_ACTION_PROBE_DEVICE,
580 {"device_id", "probe", "index"},
581 true,
582 [this](const api::ExecuteServiceRequest &r) {
583 this->api_probe_device(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
584 }},
585 {MANAGEMENT_ACTION_PROBE_SWEEP,
586 {"device_id", "probe", "first_index", "last_index"},
587 true,
588 [this](const api::ExecuteServiceRequest &r) {
589 this->api_probe_sweep(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str(),
590 r.args[3].string_.str());
591 }},
592 };
593
594 // The two probe actions are registered only when diagnostic_probes: true was set in YAML, so the
595 // action list stays clean on a default build. diagnostic_probes_enabled() already holds its
596 // final YAML-configured value here: __init__.py's to_code() emits set_diagnostic_probes_enabled()
597 // as a plain property-setter call in generated main.cpp, which runs before App.setup() calls this
598 // component's setup() (and therefore this method), not after.
599 const bool probes_enabled = hub_->diagnostic_probes_enabled();
600 for (const auto &action : actions) {
601 if (action.diagnostic_only && !probes_enabled)
602 continue;
603 api::global_api_server->register_user_service( // NOLINT
604 new detail::ManagementServiceDescriptor(action.name, action.arg_names, action.callback));
605 }
606#endif
607}
608
609IoDevice *ManagementActions::resolve_device_(const char *action, const std::string &device_id,
610 ManagementActionResult &result) {
611 const std::string normalized_device_id = normalize_device_id_argument(device_id);
612 result = make_management_result(action, normalized_device_id);
613
614 if (!*initialized_) {
615 result.message = "hub is not initialized";
616 return nullptr;
617 }
618
619 uint8_t parsed_device_id[NODE_ID_SIZE]{};
620 if (!hex_to_bytes(normalized_device_id, parsed_device_id, NODE_ID_SIZE)) {
621 result.message = "device ID must be exactly 6 hexadecimal characters";
622 return nullptr;
623 }
624
625 auto *dev = registry_.get(normalized_device_id);
626 if (dev == nullptr) {
627 result.message = "device is not registered on this hub";
628 return nullptr;
629 }
630
631 return dev;
632}
633
634bool ManagementActions::send_authenticated_request_(const IoFrame &request, IoFrame &response, const char *action_verb,
635 ManagementActionResult &result) {
636 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
638 return true;
639 engine_.log_debug(result.device_id.c_str());
640 // A management action's whole point is the payload it reads back (a name, an info block), so an
641 // unconfirmed acceptance still cannot satisfy the caller — but it is a materially different
642 // situation from silence, and saying so saves the user chasing a link problem that isn't one.
643 result.message = outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED
644 ? std::string("device accepted the ") + action_verb + " request but sent no response"
645 : std::string("no valid response to ") + action_verb + " request";
646 return false;
647}
648
649void ManagementActions::api_rename_device(const std::string &device_id, const std::string &new_name) {
650 publish_result(rename_device(device_id, new_name));
651}
652
654 // device_id is empty for actions with no single target (e.g. scan_paired_devices' roll-call);
655 // the "for device %s" clause is omitted rather than rendering as "for device :".
656 const bool has_device = !result.device_id.empty();
657 std::string prefix = "Management action " + result.action;
658 if (has_device)
659 prefix += " for device " + result.device_id;
660 prefix += result.success ? ": " : " failed: ";
662
663 if (!hub_->is_connected())
664 return;
665
666 std::map<std::string, std::string> event_data{{"action", result.action},
667 {"device_id", result.device_id},
668 {"success", bool_to_string(result.success)},
669 {"verified", bool_to_string(result.verified)},
670 {"message", result.message}};
671
672 if (!result.requested_name.empty())
673 event_data["requested_name"] = result.requested_name;
674 if (!result.applied_name.empty())
675 event_data["applied_name"] = result.applied_name;
676 if (result.has_result_code) {
677 event_data["result_code"] = format_hex_byte(result.result_code);
678 event_data["result_code_name"] = command_result_name(result.result_code);
679 }
680 if (!result.probe_name.empty()) {
681 event_data["probe"] = result.probe_name;
682 // probe_index is empty only until a sweep has parsed its range; both probe_device() and
683 // probe_sweep() fill it before publishing, so an absent key means "no index applies" rather
684 // than "the index was blank".
685 if (!result.probe_index.empty())
686 event_data["index"] = result.probe_index;
687 }
688 if (result.has_response_cmd) {
689 event_data["response_cmd"] = format_hex_byte(result.response_cmd);
690 event_data["response_cmd_name"] = command_name(result.response_cmd);
691 }
692 if (!result.response_hex.empty())
693 event_data["response_hex"] = result.response_hex;
694
695 hub_->fire_homeassistant_event(MANAGEMENT_RESULT_EVENT, event_data);
696}
697
698ManagementActionResult ManagementActions::rename_device(const std::string &device_id, const std::string &new_name) {
700 auto *dev = resolve_device_(MANAGEMENT_ACTION_RENAME_DEVICE, device_id, result);
701 if (dev == nullptr)
702 return result;
703
704 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE];
705 std::string normalized_name;
706 const DeviceNameValidationError name_error = encode_device_name_payload(new_name, payload, normalized_name);
707 result.requested_name = normalized_name.empty() ? trim_ascii_whitespace(new_name) : normalized_name;
708 if (name_error != DeviceNameValidationError::NONE) {
710 return result;
711 }
712
713 IoFrame request;
714 if (!create_set_name(request, node_id_, dev->node_id, dev->low_power, payload)) {
715 result.message = "failed to build rename request";
716 return result;
717 }
718
719 IoFrame response;
720 if (!send_authenticated_request_(request, response, "rename", result))
721 return result;
722
723 if (response.cmd == CMD_ERROR_RESP) {
724 if (apply_error_response(response, result)) {
725 result.message =
726 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
727 }
728 return result;
729 }
730
731 if (response.cmd != CMD_SET_NAME_RESP) {
732 std::array<char, UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE> buffer{};
733 std::snprintf(buffer.data(), buffer.size(), "unexpected rename response 0x%02X", response.cmd);
734 result.message = buffer.data();
735 return result;
736 }
737
738 result.success = true;
739 result.message = "rename acknowledged by device";
740
741 if (!hub_->request_device_name(result.device_id)) {
742 result.message = "rename acknowledged but verification readback failed";
743 return result;
744 }
745
746 auto *updated_device = registry_.get(result.device_id);
747 if (updated_device != nullptr)
748 result.applied_name = updated_device->name;
749
750 if (result.applied_name == normalized_name) {
751 result.verified = true;
752 result.message = "rename verified by device readback";
753 return result;
754 }
755
756 result.message = "rename acknowledged but readback did not match the requested name";
757 return result;
758}
759
760void ManagementActions::api_identify_device(const std::string &device_id) {
762}
763
766 // Deliberately no device-type gating beyond "registered on this hub" — see the doxygen note on
767 // the declaration for why.
768 auto *dev = resolve_device_(MANAGEMENT_ACTION_IDENTIFY_DEVICE, device_id, result);
769 if (dev == nullptr)
770 return result;
771
772 IoFrame request;
773 if (!create_identify(request, node_id_, dev->node_id, dev->low_power)) {
774 result.message = "failed to build identify request";
775 return result;
776 }
777
778 IoFrame response;
779 if (!send_authenticated_request_(request, response, "identify", result))
780 return result;
781
782 if (response.cmd == CMD_ERROR_RESP) {
783 // Deliberate deviation from rename's error handling: a device may answer CMD_IDENTIFY with
784 // CMD_ERROR_RESP and still have performed the jog, so this counts as success, not failure.
785 result.success = true;
786 if (apply_error_response(response, result)) {
787 result.message = "identify triggered (device reported " + std::string(command_result_name(result.result_code)) +
788 ": " + command_result_description(result.result_code) + ")";
789 } else {
790 result.message = "identify triggered (device returned an empty error response)";
791 }
792 return result;
793 }
794
795 // Any other endpoint-matched reply counts as acknowledgment; unlike rename there is no specific
796 // response command to check against, and no readback exists to set `verified`.
797 result.success = true;
798 result.message = "identify acknowledged by device";
799 return result;
800}
801
802void ManagementActions::api_force_open_device(const std::string &device_id) {
804}
805
808 auto *dev = resolve_device_(MANAGEMENT_ACTION_FORCE_OPEN_DEVICE, device_id, result);
809 if (dev == nullptr)
810 return result;
811
812 // Delegate to the hub's normal cover-command dispatch path (capability gating, poll tracking,
813 // settle handling, backoff already live there) instead of talking to the radio directly.
814 if (!hub_->queue_device_command(result.device_id, CoverCommand::FORCE_OPEN)) {
815 result.message = "device does not accept cover commands";
816 return result;
817 }
818
819 result.success = true;
820 result.message =
821 "force open queued (elevated-priority open; wind/rain lock bypass unconfirmed; movement result arrives via "
822 "cover state)";
823 return result;
824}
825
826void ManagementActions::api_heating_control(const std::string &device_id, const std::string &function,
827 const std::string &value) {
828 publish_result(heating_control(device_id, function, value));
829}
830
831ManagementActionResult ManagementActions::heating_control(const std::string &device_id, const std::string &function,
832 const std::string &value) {
834 auto *dev = resolve_device_(MANAGEMENT_ACTION_HEATING_CONTROL, device_id, result);
835 if (dev == nullptr)
836 return result;
837
838 HeatingFunction fn{};
839 float encoded_value = 0.0F;
840 std::string parse_error;
841 if (!parse_heating_arguments(function, value, fn, encoded_value, parse_error)) {
842 result.message = parse_error;
843 return result;
844 }
845
846 // Capability gate via the predicate only — no inline device-type or vendor list.
847 if (!device_supports_climate_control(dev->type)) {
848 result.message = "device is not a climate device";
849 return result;
850 }
851
852 // Snapshot any pre-existing CMD_ERROR_RESP code so a stale one (e.g. an old wind lockout from a
853 // different command) is not misattributed to this send.
854 const uint8_t prior_result_code = dev->last_result_code;
855 const uint32_t prior_result_at_ms = dev->last_result_at_ms;
856
857 // One shared transmit path (invariant): the climate entity calls the same method. A
858 // CMD_ERROR_RESP surfaced by this call is recorded on the device record by that path.
859 if (!hub_->send_heating_command(result.device_id, fn, encoded_value)) {
860 result.message = std::string("device did not acknowledge the ") + heating_function_name(fn) + " command";
861 if (const auto *updated = registry_.get(result.device_id);
862 updated != nullptr && updated->last_result_code != 0 &&
863 (updated->last_result_code != prior_result_code || updated->last_result_at_ms != prior_result_at_ms)) {
864 result.has_result_code = true;
865 result.result_code = updated->last_result_code;
866 result.message =
867 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
868 }
869 return result;
870 }
871
872 result.success = true;
873 // verified stays false: the set_* functions decode nothing back into an entity (the two 0x60
874 // reads have their ACK payload logged at DEBUG only), so nothing can confirm the write.
875 result.message = std::string("heating ") + heating_function_name(fn) + " acknowledged by device";
876 return result;
877}
878
880
881void ManagementActions::api_oneway_set_position(const std::string &controller_id, const std::string &position) {
882 // device_id carries the controller-identity handle: 1W addresses a class, so there is no device
883 // to name, and the identity is what the caller actually chose.
884 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_ONEWAY_SET_POSITION, controller_id);
885
886 if (hub_->oneway_controllers().get(controller_id) == nullptr) {
887 result.message = "no oneway_controllers identity with that id";
888 publish_result(result);
889 return;
890 }
891
892 // Parse the position string here and reject loudly on anything unparseable. Do NOT relax this to
893 // atoi()/strtoul()-with-default: a value that silently became 0 would send a fully-open command
894 // to every actuator bound to this 1W identity.
895 const std::string trimmed = trim_ascii_whitespace(position);
896 if (trimmed.empty() || trimmed.find_first_not_of("0123456789") != std::string::npos) {
897 result.message = "position must be a whole number between 0 and 100";
898 publish_result(result);
899 return;
900 }
901 const unsigned long parsed = strtoul(trimmed.c_str(), nullptr, 10); // NOLINT(google-runtime-int)
902 if (parsed > ONEWAY_POSITION_FULLY_CLOSED) {
903 result.message = "position must be between 0 and 100";
904 publish_result(result);
905 return;
906 }
907
908 hub_->send_oneway_position(controller_id, static_cast<uint8_t>(parsed));
909 result.success = true;
910 // Deliberately "queued", not "sent" or "applied": 1W reports nothing back, and neither should
911 // this. See the "Last 1W Command" sensor for what was actually transmitted.
912 result.message = "1W position command queued";
913 publish_result(result);
914}
915
916void ManagementActions::api_oneway_remove_controller(const std::string &controller_id) {
917 // device_id carries the controller-identity handle, same convention as api_oneway_set_position().
918 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER, controller_id);
919
920 if (hub_->oneway_controllers().get(controller_id) == nullptr) {
921 result.message = "no oneway_controllers identity with that id";
922 publish_result(result);
923 return;
924 }
925
926 hub_->send_oneway_unenroll(controller_id);
927 result.success = true;
928 // "Queued", not "removed": 1W has no reply, so nothing here can ever confirm a device actually
929 // forgot this identity — same framing as every other 1W action result.
930 result.message = "1W remove-controller (0x39) queued";
931 publish_result(result);
932}
933
934/// @brief Format one roll-call responder's report line(s).
935///
936/// Known responders get a single summary line; unknown responders get the same summary line
937/// plus a lead-in sentence and a ready-to-paste YAML block (or, if the decoded type has no
938/// ESPHome platform, an explanatory line instead of a blank) — the same "paste this in" framing
939/// a successful pairing prints.
940/// @param responder Decoded responder record.
941/// @param device_id Hex device ID string for this responder, rebuilt from `responder.src`.
942/// @param known True if this device is already registered on this hub.
943static std::string format_scan_reply_line(const ScanResponder &responder, const std::string &device_id, bool known) {
944 std::string line = " " + device_id + ": " + device_type_name(responder.type) +
945 " subtype=" + std::to_string(responder.subtype) + " rssi=" + std::to_string(responder.rssi_dbm) +
946 "dBm";
947 if (responder.has_extended) {
948 uint8_t const att = discovery_att_class(responder.flags);
949 uint8_t const power_save = discovery_power_save_mode(responder.flags);
950 line += std::string(" manufacturer=") + manufacturer_name(responder.manufacturer) +
951 " turnaround=" + att_class_name(att) + " power_save=" + power_save_mode_name(power_save);
952 }
953 line += known ? " [known]\n" : " [unknown]\n";
954
955 if (known)
956 return line;
957
958 const bool low_power = responder.has_extended && discovery_power_save_mode(responder.flags) == POWER_SAVE_LOW_POWER;
959 const std::string snippet = build_device_yaml_snippet(responder.type, responder.subtype, device_id,
960 responder.metadata_complete, responder.inverted, low_power);
961 if (!snippet.empty())
962 return line + " Paste this into your YAML to register it:\n" + snippet;
963
964 return line + " no ready-to-paste YAML: no ESPHome platform for io_device_type: " +
965 format_device_type_for_yaml(responder.type) + "\n";
966}
967
968/// @brief Outcome of add_scan_responder(), so callers can tell a harmless repeat from real loss.
969enum class ScanAddResult : uint8_t {
970 ADDED, ///< New responder recorded.
971 DUPLICATE, ///< Already recorded from an earlier reply; nothing changed.
972 FULL, ///< Dropped: SCAN_MAX_REPLIES distinct responders already recorded.
973};
974
975/// @brief Record a responder unless its address is already present.
976///
977/// Deduplication is by node ID across the whole scan, so a device that answers several of the
978/// three attempts — or twice inside one attempt — still yields one entry. The duplicate check
979/// runs before the capacity check so that repeat replies from already-recorded devices never
980/// look like overflow once the array is full.
981/// @param responders Accumulated array, appended to in place.
982/// @param count In: entries already present. Out: updated count.
983/// @param capacity Maximum entries `responders` can hold.
984/// @param frame Reply frame to decode and store.
985/// @param rssi_dbm RSSI of that reply.
986/// @return Which of the three outcomes occurred.
987static ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity,
988 const IoFrame &frame, int16_t rssi_dbm) {
989 for (uint8_t i = 0; i < count; i++) {
990 if (memcmp(responders[i].src, frame.src, NODE_ID_SIZE) == 0)
992 }
993 if (count >= capacity)
994 return ScanAddResult::FULL;
995
996 // decode_discovery_response() also produces the hex device-ID string, which is deliberately
997 // discarded here and rebuilt from `src` when the report is formatted. Keeping it would mean a
998 // std::string per entry — more memory than the entire ScanResponder — to save re-deriving six
999 // characters that fit in a small-string buffer. Do not "optimise" this by adding a string member.
1000 IoDevice device{};
1001 std::string unused_device_id;
1002 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, unused_device_id);
1003
1004 ScanResponder &entry = responders[count++];
1005 memcpy(entry.src, frame.src, NODE_ID_SIZE);
1006 entry.type = device.type;
1007 entry.subtype = device.subtype;
1008 entry.inverted = device.inverted;
1009 entry.rssi_dbm = rssi_dbm;
1010 entry.manufacturer = info.manufacturer;
1011 entry.flags = info.flags;
1012 entry.has_extended = info.has_extended;
1013 entry.metadata_complete = info.metadata_complete;
1014 return ScanAddResult::ADDED;
1015}
1016
1018 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES, "");
1019
1020 if (!*initialized_) {
1021 result.message = "hub is not initialized";
1022 return result;
1023 }
1024
1025 // One attempt per channel (see SCAN_CHANNELS), deduplicating responders across attempts — a
1026 // device that is awake and replies to more than one attempt must still only appear once in the
1027 // report. Each attempt gets a fresh request (create_discovery_request() draws a new random
1028 // nonce every call) rather than replaying the same frame three times. Every channel is always
1029 // tried, even once the array is full: stopping early would silently skip channels, which is
1030 // exactly the single-channel behaviour the three attempts exist to avoid.
1031 ScanResponder responders[SCAN_MAX_REPLIES];
1032 uint8_t count = 0;
1033 bool truncated = false;
1034 for (uint8_t attempt = 0; attempt < SCAN_CHANNEL_COUNT; attempt++) {
1035 IoFrame request;
1036 if (!create_discovery_request(request, node_id_, CMD_DISCOVER_SPE_REQ, BROADCAST_DISCOVER, /*low_power=*/false,
1037 /*payload_enabled=*/false, /*payload=*/0, system_key_)) {
1038 result.message = "failed to build roll-call request";
1039 return result;
1040 }
1041
1042 const uint8_t before = count;
1043 const uint8_t heard = engine_.collect_broadcast_responses(
1044 request, SCAN_CHANNELS[attempt], CMD_DISCOVER_SPE_RESP, tuning_->pairing_discovery_wait_ms,
1045 [&responders, &count, &truncated](const IoFrame &frame, int16_t rssi_dbm) {
1046 if (add_scan_responder(responders, count, SCAN_MAX_REPLIES, frame, rssi_dbm) == ScanAddResult::FULL) {
1047 truncated = true;
1048 }
1049 });
1050 // Diagnostic only (not part of the user-facing report). Reporting heard and new separately is
1051 // what makes it useful: "heard 3, 0 new" means devices are answering every attempt (so the
1052 // extra channels are redundant here). The Hz below is the TX channel for this attempt only —
1053 // collect_broadcast_responses() listens with ROTATE_SKIPPING_REQUEST (see SCAN_CHANNELS' doc
1054 // comment), so replies are never received on it. "heard 0" therefore says nothing about that
1055 // channel specifically; it means neither of the *other* two channels caught a reply in this
1056 // attempt's window.
1057 const uint8_t new_count = count - before;
1058 ESP_LOGD(detail::TAG, "Roll-call attempt %u/%u (tx %" PRIu32 " Hz, %u ms window): %u repl%s heard, %u new",
1059 attempt + 1, SCAN_CHANNEL_COUNT, SCAN_CHANNELS[attempt], tuning_->pairing_discovery_wait_ms, heard,
1060 heard == 1 ? "y" : "ies", new_count);
1061 }
1062
1063 // Grouped into known-first, unknown-second rather than interleaved in arrival order: the two
1064 // groups need very different follow-up (nothing to do vs. paste a YAML block), so burying an
1065 // unknown responder between two known ones makes it easy to miss.
1066 std::string known_body;
1067 std::string unknown_body;
1068 uint8_t unknown_count = 0;
1069 for (uint8_t i = 0; i < count; i++) {
1070 const std::string device_id = node_id_to_string(responders[i].src);
1071 const bool known = registry_.get(device_id) != nullptr;
1072 if (known) {
1073 known_body += format_scan_reply_line(responders[i], device_id, known);
1074 } else {
1075 unknown_count++;
1076 unknown_body += format_scan_reply_line(responders[i], device_id, known);
1077 }
1078 }
1079
1080 std::string body;
1081 if (!known_body.empty())
1082 body += "Known:\n" + known_body;
1083 if (!unknown_body.empty())
1084 body += "Unknown:\n" + unknown_body;
1085
1086 result.success = true;
1087 result.message = "Roll-call: " + std::to_string(count) + " device" + (count == 1 ? "" : "s") + " detected (" +
1088 std::to_string(count - unknown_count) + " known, " + std::to_string(unknown_count) + " unknown)\n";
1089 // Surfaced in the report itself, not only the log: a scan that silently listed a subset would
1090 // look like devices had gone missing.
1091 if (truncated) {
1092 result.message += "NOTE: more than " + std::to_string(SCAN_MAX_REPLIES) +
1093 " devices answered; the list below is truncated. Re-run to see whether other devices "
1094 "appear, and raise SCAN_MAX_REPLIES if this install really is larger.\n";
1095 }
1096 result.message += body;
1097 return result;
1098}
1099
1100void ManagementActions::api_probe_device(const std::string &device_id, const std::string &probe,
1101 const std::string &index) {
1102 publish_result(probe_device(device_id, probe, index));
1103}
1104
1105ManagementActionResult ManagementActions::probe_device(const std::string &device_id, const std::string &probe,
1106 const std::string &index) {
1107 // resolve_device_() reassigns its `result` argument wholesale (via make_management_result()),
1108 // so it must write into its own object rather than the one already carrying probe_name/index --
1109 // matches probe_sweep()'s resolve_result pattern below.
1110 ManagementActionResult resolve_result;
1111 auto *dev = resolve_device_(MANAGEMENT_ACTION_PROBE_DEVICE, device_id, resolve_result);
1112 if (dev == nullptr) {
1113 resolve_result.probe_name = probe;
1114 resolve_result.probe_index = index;
1115 return resolve_result;
1116 }
1117
1118 ManagementActionResult result = resolve_result;
1119 result.probe_name = probe;
1120 result.probe_index = index;
1121
1122 if (!hub_->diagnostic_probes_enabled()) {
1123 result.message = "diagnostic probes are not enabled; set diagnostic_probes: true in YAML";
1124 result.terminal_refusal = true;
1125 return result;
1126 }
1127 // An unknown frame into a mid-transaction device state machine is the one avoidable way a
1128 // read-shaped probe could cause harm.
1129 if (!effective_is_stopped(*dev)) {
1130 result.message = "device is moving; refusing to probe mid-transaction";
1131 result.terminal_refusal = true;
1132 return result;
1133 }
1134
1135 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1136 if (descriptor == nullptr)
1137 return result;
1138
1139 uint8_t index_byte = 0;
1140 if (descriptor->needs_index && !parse_probe_index(index, index_byte)) {
1141 result.message = "index must be a decimal or 0x-prefixed byte value (0-255)";
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(), hub_internal.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 return result;
1197}
1198
1199void ManagementActions::api_probe_sweep(const std::string &device_id, const std::string &probe,
1200 const std::string &first_index, const std::string &last_index) {
1201 publish_result(probe_sweep(device_id, probe, first_index, last_index));
1202}
1203
1204ManagementActionResult ManagementActions::probe_sweep(const std::string &device_id, const std::string &probe,
1205 const std::string &first_index, const std::string &last_index) {
1206 ManagementActionResult result =
1207 make_management_result(MANAGEMENT_ACTION_PROBE_SWEEP, normalize_device_id_argument(device_id));
1208 result.probe_name = probe;
1209
1210 // Validate the device and the probe name once, up front. Neither can start succeeding partway
1211 // through a range: an unregistered device or an unrecognized probe name fails identically for
1212 // every index, so without this check the loop below would run the full span and append the
1213 // same error line up to PROBE_SWEEP_MAX_INDICES times.
1214 ManagementActionResult resolve_result;
1215 if (resolve_device_(MANAGEMENT_ACTION_PROBE_SWEEP, device_id, resolve_result) == nullptr) {
1216 result.message = resolve_result.message;
1217 return result;
1218 }
1219 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1220 if (descriptor == nullptr)
1221 return result;
1222 if (!descriptor->needs_index) {
1223 result.message = "probe \"" + probe + "\" takes no index; use probe_device";
1224 return result;
1225 }
1226
1227 uint8_t first = 0;
1228 uint8_t last = 0;
1229 if (!parse_probe_index(first_index, first) || !parse_probe_index(last_index, last)) {
1230 result.message = "first_index/last_index must be decimal or 0x-prefixed byte values (0-255)";
1231 return result;
1232 }
1233 if (last < first) {
1234 result.message = "last_index must be >= first_index";
1235 return result;
1236 }
1237 const uint32_t span = static_cast<uint32_t>(last) - first + 1;
1238 if (span > PROBE_SWEEP_MAX_INDICES) {
1239 result.message =
1240 "sweep range too wide: " + std::to_string(span) + " indices, max " + std::to_string(PROBE_SWEEP_MAX_INDICES);
1241 return result;
1242 }
1243
1244 std::string report;
1245 uint32_t answered_count = 0;
1246 bool stopped_early = false;
1247 for (uint32_t idx = first; idx <= last; idx++) {
1248 if (idx != first) {
1249 App.feed_wdt();
1250 delay(PROBE_SWEEP_DELAY_MS);
1251 }
1252 const std::string index_str = std::to_string(idx);
1253 const ManagementActionResult step = probe_device(device_id, probe, index_str);
1254 report += "index=0x" + format_hex_byte(static_cast<uint8_t>(idx)) + ": ";
1255 if (step.success) {
1256 answered_count++;
1257 report += "cmd=0x" + format_hex_byte(step.response_cmd) + " hex=" + step.response_hex + "\n";
1258 } else {
1259 report += step.message + "\n";
1260 }
1261 // terminal_refusal (diagnostic probes not enabled, device moving) applies to every remaining
1262 // index the same way it applied to this one -- stop rather than repeat it N more times. A
1263 // structured flag, not a search over `step.message`: probe_device() sets it explicitly, so
1264 // rewording a refusal message can never silently break this check.
1265 if (step.terminal_refusal) {
1266 report += "(stopping sweep: " + step.message + ")\n";
1267 stopped_early = true;
1268 break;
1269 }
1270 }
1271
1272 // A sweep that ran its full requested range is a success even if nothing answered -- "no
1273 // device answered any index" is a valid, reportable outcome, the same philosophy
1274 // scan_paired_devices() uses for zero replies. A sweep cut short by a terminal refusal did not
1275 // complete what was asked of it and must not report success.
1276 result.success = !stopped_early;
1277 // Renders the same 0x-prefixed form as the report body, so the Home Assistant event carries the
1278 // swept range rather than a blank `index` field left over from the per-index probe_device()
1279 // results this loop discards.
1280 result.probe_index = "0x" + format_hex_byte(first) + "-0x" + format_hex_byte(last);
1281 result.message = "Sweep \"" + probe + "\" over [0x" + format_hex_byte(first) + ",0x" + format_hex_byte(last) +
1282 "]: " + std::to_string(answered_count) + " answered\n" + report;
1283 return result;
1284}
1285
1286} // namespace home_io_control
1287} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
The main IO-Homecontrol component.
Definition hub_core.h:90
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.
Internal helpers shared by the hub implementation .cpp files.
Hub-level management operations exposed as Home Assistant actions.
constexpr const char * TAG
Shared log tag for hub-level messages.
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.
bool create_identify(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build an authenticated device-identify request (0x1E).
const char * manufacturer_name(uint8_t id)
Get a human-readable manufacturer name from the protocol manufacturer byte.
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 discovery_power_save_mode(uint8_t flags)
Extract the power save mode field from a discovery response's Multi Information Byte.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
static ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity, const IoFrame &frame, int16_t rssi_dbm)
Record a responder unless its address is already present.
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 constexpr uint8_t CMD_ERROR_RESP
Error response to any command.
ScanAddResult
Outcome of add_scan_responder(), so callers can tell a harmless repeat from real loss.
@ DUPLICATE
Already recorded from an earlier reply; nothing changed.
@ FULL
Dropped: SCAN_MAX_REPLIES distinct responders already recorded.
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
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
Heating functions, one per user-pressable radiator button in the reference.
@ MIDNIGHT_SYNC
Reads register 0x0130 — the comfort/eco/auto setpoint block (iohcCozyDevice2W.cpp:248; AtlanticThermo...
@ POWER_ON
Wake / retrieve paired devices (iohcCozyDevice2W.cpp:105).
@ SET_PRESENCE
Presence / absence (iohcCozyDevice2W.cpp:195).
@ SET_TEMPERATURE
Setpoint in degrees Celsius (iohcCozyDevice2W.cpp:125).
@ SET_MODE
Operating mode (iohcCozyDevice2W.cpp:155).
@ SET_WINDOW
Open-window / frost-protection (iohcCozyDevice2W.cpp:218).
const char * att_class_name(uint8_t att_class)
Get a human-readable turnaround time string for an ATT class value.
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.
const char * power_save_mode_name(uint8_t mode)
Get a human-readable power save mode name.
static ManagementActionResult make_management_result(const std::string &action, const std::string &device_id)
@ FORCE_OPEN
Move to fully open at elevated priority; intended to bypass soft locks and environmental limits (conf...
bool create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static std::string bool_to_string(bool value)
bool device_supports_climate_control(DeviceType type)
Does this device type support 2W climate/heating control (CMD_WRITE_PRIVATE 0x20)?
constexpr float HEATING_TEMP_MAX_C
Highest setpoint this codec will encode.
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
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.
static constexpr uint8_t CMD_DISCOVER_SPE_RESP
Roll-call reply to CMD_DISCOVER_SPE_REQ, sent only by devices that already hold the requesting contro...
const char * heating_function_name(HeatingFunction fn)
Stable lowercase name for a heating function ("power_on", "set_temperature", ...).
static constexpr uint8_t FRAME_MAX_SIZE
Historical name for FRAME_MAX_DECLARED_SIZE, kept as an alias rather than a second literal so the two...
Definition proto_sizes.h:44
static constexpr uint8_t DEVICE_NAME_WRITE_PAYLOAD_SIZE
Fixed write payload: 15 visible chars plus trailing null/padding.
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
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_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
static constexpr uint8_t CMD_SET_NAME_RESP
Device-name write response.
DeviceNameValidationError
Validation result for outbound device-name writes.
constexpr float HEATING_TEMP_MIN_C
Lowest setpoint this codec will encode.
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
static constexpr uint8_t CMD_DISCOVER_SPE_REQ
Broadcast roll-call answered by every device that already holds this controller's system key,...
static constexpr uint8_t EXCHANGE_RETRY_COUNT
Attempts per command before reporting failure.
std::string build_device_yaml_snippet(DeviceType type, uint8_t subtype, const std::string &device_id, bool metadata_complete, bool inverted, bool low_power)
Build the ready-to-paste YAML block describing a device, for both a fully-decoded device and one whos...
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
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.
@ OFF
Off / standby; note the value is 0x04, not 0x03 (that is the reference's commented-out "special" mode...
@ PROG
Program mode; device runs its own stored weekly schedule (iohcCozyDevice2W.cpp:160).
@ MANUAL
Manual mode; follows the last SET_TEMPERATURE setpoint (iohcCozyDevice2W.cpp:159).
@ AUTO
Automatic mode; device manages the setpoint itself (iohcCozyDevice2W.cpp:158).
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
static constexpr uint8_t BROADCAST_DISCOVER[NODE_ID_SIZE]
Broadcast address for device discovery (0x00003B).
static std::string format_scan_reply_line(const ScanResponder &responder, const std::string &device_id, bool known)
Format one roll-call responder's report line(s).
bool create_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.
uint8_t discovery_att_class(uint8_t flags)
Extract the ATT class field from a discovery response's Multi Information Byte.
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...
static constexpr uint8_t POWER_SAVE_LOW_POWER
Device sleeps — needs long preamble to wake.
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.
static constexpr uint8_t ONEWAY_POSITION_FULLY_CLOSED
DiscoveryResponseInfo decode_discovery_response(const IoFrame &frame, IoDevice &device, std::string &device_id)
Decode a discovery-response payload (CMD_DISCOVER_RESP 0x29 or CMD_DISCOVER_SPE_RESP 0x2B — both carr...
const char * device_name_validation_error_description(DeviceNameValidationError error)
Return a human-readable explanation for a device-name validation result.
static bool apply_error_response(const IoFrame &response, ManagementActionResult &result)
Decode a CMD_ERROR_RESP frame's result code into result.
bool hex_to_bytes(const std::string &hex, uint8_t *out, uint8_t len)
Convert a hex string (e.g., "123ABC") to a byte array.
static 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.
Extended discovery-response fields (manufacturer, Multi Information Byte, backbone address,...
bool has_extended
data_len >= DISCOVERY_RESP_FULL_SIZE (mfr/flags/timestamp present).
bool metadata_complete
data_len >= DEVICE_METADATA_SIZE (type/subtype present).
uint8_t manufacturer
Raw manufacturer ID; name via manufacturer_name().
uint8_t flags
Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
A value token that maps to a fixed float (used by set_mode / set_presence / set_window).
Runtime state of a paired IO‑Homecontrol device.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:94
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
uint8_t data_len
Actual length of data.
Definition proto_frame.h:95
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...
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 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.
All runtime tunable parameters for pairing and radio diagnostics.