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_core.h"
14#include "log_helpers.h"
15#include "proto_commands.h"
16
17#include "esphome/core/application.h"
18#include "esphome/core/hal.h"
19
20#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
21#include "esphome/core/helpers.h"
22#endif
23
24#include <algorithm>
25#include <array>
26#include <cctype>
27#include <cerrno>
28#include <cmath>
29#include <cstdio>
30#include <cstdlib>
31#include <cstring>
32#include <functional>
33#include <limits>
34#include <map>
35#include <span>
36#include <vector>
37
38namespace esphome {
39namespace home_io_control {
40
41namespace {
42
43constexpr const char *MANAGEMENT_ACTION_RENAME_DEVICE = "rename_device";
44constexpr const char *MANAGEMENT_ACTION_IDENTIFY_DEVICE = "identify_device";
45constexpr const char *MANAGEMENT_ACTION_FORCE_OPEN_DEVICE = "force_open_device";
46constexpr const char *MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES = "scan_paired_devices";
47constexpr const char *MANAGEMENT_ACTION_ONEWAY_SET_POSITION = "oneway_set_position";
48constexpr const char *MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER = "oneway_remove_controller";
49constexpr const char *MANAGEMENT_ACTION_PROBE_DEVICE = "probe_device";
50constexpr const char *MANAGEMENT_ACTION_PROBE_SWEEP = "probe_sweep";
51constexpr const char *MANAGEMENT_ACTION_HEATING_CONTROL = "heating_control";
52constexpr const char *MANAGEMENT_RESULT_EVENT = "esphome.home_io_control_action_result";
53constexpr size_t RESULT_CODE_BUFFER_SIZE = 5;
54constexpr size_t UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE = 64;
55constexpr size_t HEATING_RANGE_MESSAGE_BUFFER_SIZE = 64;
56
57// --- Diagnostic probe names (probe_device()/probe_sweep() `probe` argument) ---
58constexpr const char *PROBE_NAME_PRIVATE_FN = "private_fn"; ///< Q0: create_private_function().
59constexpr const char *PROBE_NAME_PRIVATE_FN_SUB = "private_fn_sub"; ///< CMD_PRIVATE fn 0x09, chosen second byte.
60constexpr const char *PROBE_NAME_STATUS_EXT = "status_ext"; ///< Q1: create_get_status_extended().
61constexpr const char *PROBE_NAME_STATUS_EXT_FN6 = "status_ext_fn6"; ///< Extended CMD_PRIVATE at function ID 0x06.
62constexpr const char *PROBE_NAME_STATUS_EXT_FN9 = "status_ext_fn9"; ///< Extended CMD_PRIVATE at function ID 0x09.
63constexpr const char *PROBE_NAME_GET_INFO1 = "get_info1"; ///< create_get_info1() (0x54, no payload).
64constexpr const char *PROBE_NAME_GET_INFO2 = "get_info2"; ///< create_get_info2() (0x56, no payload).
65constexpr const char *PROBE_NAME_GENERAL_INFO3 = "general_info3"; ///< Q2: create_general_info3().
66constexpr const char *PROBE_NAME_PRIVATE2 = "private2"; ///< Q3 long form: create_private2_read().
67constexpr const char *PROBE_NAME_PRIVATE2_SHORT = "private2_short"; ///< Q3 short form: create_private2_read().
68/// Function IDs the extended-shape probes hold fixed. Production software elsewhere describes
69/// these two as a battery read; on real hardware (17 solar devices plus our own mains motors)
70/// the *short* 3-byte form at these IDs returned position-family values, never a charge value.
71/// The extended 4-byte shape at these IDs has never been sent by anything.
72constexpr uint8_t PROBE_EXT_FUNCTION_ID_06 = 0x06;
73constexpr uint8_t PROBE_EXT_FUNCTION_ID_09 = 0x09;
74/// Function ID `private_fn_sub` holds fixed while `index` walks the second payload byte.
75constexpr uint8_t PROBE_PRIVATE_FN_SUB_FUNCTION_ID = 0x09;
76/// Extended-CMD_PRIVATE selector "status_ext" probes: the field-observed, never-decoded 0x80.
77/// Selector 0x20 (tilt) already has a permanent builder/action (create_get_status_tilt()) and is
78/// not part of this probe.
79constexpr uint8_t PROBE_STATUS_EXT_SELECTOR = 0x80;
80/// Bound on probe_sweep()'s index range — this transmits on a shared ISM band to what may be a
81/// battery device; an unbounded sweep is antisocial and would keep the device awake needlessly.
82constexpr uint8_t PROBE_SWEEP_MAX_INDICES = 16;
83/// Spacing between probe_sweep() indices. Sweep steps are sequential and blocking, so they never
84/// overlap regardless of this value; it exists for ISM-band duty cycling and to give a battery
85/// device real idle time between reads.
86constexpr uint32_t PROBE_SWEEP_DELAY_MS = 1000;
87
88/// @brief Uniform wrapper signature every probe builder is adapted to, so PROBE_TABLE below can
89/// hold one function-pointer type regardless of each create_*() builder's own parameter list.
90/// `low_power` is the target device's YAML-declared power class, forwarded to each builder so a
91/// probe to an always-alive device is shaped like one — the diagnostic subsystem has to work on
92/// the very device class a silent-device investigation reaches for it.
93using ProbeBuilderFn = bool (*)(IoFrame &, const uint8_t *, const uint8_t *, uint8_t index, bool low_power);
94
95bool build_probe_private_fn(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
96 return create_private_function(f, own, dst, low_power, index);
97}
98bool build_probe_private_fn_sub(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
99 return create_private_function(f, own, dst, low_power, PROBE_PRIVATE_FN_SUB_FUNCTION_ID, index);
100}
101bool build_probe_status_ext(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
102 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index);
103}
104bool build_probe_status_ext_fn6(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
105 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index, PROBE_EXT_FUNCTION_ID_06);
106}
107bool build_probe_status_ext_fn9(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
108 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index, PROBE_EXT_FUNCTION_ID_09);
109}
110bool build_probe_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
111 return create_get_info1(f, own, dst, low_power);
112}
113bool build_probe_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
114 return create_get_info2(f, own, dst, low_power);
115}
116bool build_probe_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
117 return create_general_info3(f, own, dst, low_power);
118}
119bool build_probe_private2_long(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
120 return create_private2_read(f, own, dst, index, /*long_form=*/true, low_power);
121}
122bool build_probe_private2_short(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
123 return create_private2_read(f, own, dst, index, /*long_form=*/false, low_power);
124}
125
126/// @brief One row per probe_device()/probe_sweep() `probe` argument value.
127struct ProbeDescriptor {
128 const char *name;
129 bool needs_index; ///< False for the no-payload probes ("general_info3", "get_info1", "get_info2") -- their
130 ///< builders take no index/selector.
131 ProbeBuilderFn builder;
132};
133
134/// @brief The full set of probes probe_device()/probe_sweep() can dispatch to.
135///
136/// Single source of truth for probe names. Adding, removing, or renaming a probe is a one-line
137/// change here rather than a change to both probe_device()'s dispatch and its "index must be..."
138/// error message. Deliberately has no "unknown4a" row -- see ADR 0024.
139constexpr ProbeDescriptor PROBE_TABLE[] = {
140 {PROBE_NAME_PRIVATE_FN, true, build_probe_private_fn},
141 {PROBE_NAME_PRIVATE_FN_SUB, true, build_probe_private_fn_sub},
142 {PROBE_NAME_STATUS_EXT, true, build_probe_status_ext},
143 {PROBE_NAME_STATUS_EXT_FN6, true, build_probe_status_ext_fn6},
144 {PROBE_NAME_STATUS_EXT_FN9, true, build_probe_status_ext_fn9},
145 {PROBE_NAME_GET_INFO1, false, build_probe_get_info1},
146 {PROBE_NAME_GET_INFO2, false, build_probe_get_info2},
147 {PROBE_NAME_GENERAL_INFO3, false, build_probe_general_info3},
148 {PROBE_NAME_PRIVATE2, true, build_probe_private2_long},
149 {PROBE_NAME_PRIVATE2_SHORT, true, build_probe_private2_short},
150};
151constexpr uint8_t PROBE_TABLE_SIZE = sizeof(PROBE_TABLE) / sizeof(PROBE_TABLE[0]);
152
153/// @brief Look up a probe by name.
154/// @return Pointer into PROBE_TABLE, or nullptr if `probe` names none of its rows.
155const ProbeDescriptor *find_probe_descriptor(const std::string &probe) {
156 for (const auto &descriptor : PROBE_TABLE) {
157 if (probe == descriptor.name)
158 return &descriptor;
159 }
160 return nullptr;
161}
162
163/// @brief The error message probe_device()/probe_sweep() report for an unrecognized `probe`
164/// argument, built from PROBE_TABLE so the list of names can never drift out of sync with it.
165std::string unknown_probe_message(const std::string &probe) {
166 std::string names;
167 for (uint8_t i = 0; i < PROBE_TABLE_SIZE; i++) {
168 if (i > 0)
169 names += (i + 1 == PROBE_TABLE_SIZE) ? ", or " : ", ";
170 names += PROBE_TABLE[i].name;
171 }
172 return "unknown probe \"" + probe + "\" (expected " + names + ")";
173}
174
175/// @brief Shared probe-name lookup for probe_device()/probe_sweep(): returns the descriptor, or
176/// nullptr after writing the "unknown probe" message into @p result. The device-resolution and
177/// index-parse steps stay per-method: probe_device keeps the resolved device (for the moving
178/// check and node_id), probe_sweep discards it, and one parses a single index while the other
179/// parses a first/last range.
180const ProbeDescriptor *resolve_probe_descriptor(const std::string &probe, ManagementActionResult &result) {
181 const ProbeDescriptor *descriptor = find_probe_descriptor(probe);
182 if (descriptor == nullptr)
183 result.message = unknown_probe_message(probe);
184 return descriptor;
185}
186
187/// @brief Maximum distinct responders reported by one scan_paired_devices() call.
188///
189/// Sized against the loop task's stack, which is where this runs: ESPHome spawns its own
190/// `loopTask` with `ESPHOME_LOOP_TASK_STACK_SIZE` (8192 B) — not the 3.5 KB ESP-IDF main task.
191/// `ScanResponder` is 12 B, so 24 slots cost ≈288 B (~3.5 % of that stack) in a single array;
192/// replies are decoded on arrival rather than buffered as whole 33 B `IoFrame`s, which is what
193/// makes a limit this size cheap. Installs with 20+ actuators are real, so 8 (the original
194/// value, chosen when whole frames were retained) was too low to be useful. Overflow beyond
195/// this is reported in the scan's own output, never silent.
196constexpr uint8_t SCAN_MAX_REPLIES = 24;
197
198/// @brief One roll-call responder, decoded on arrival.
199///
200/// Deliberately holds only what the report needs, so the fixed array stays small: the raw
201/// `IoFrame` is discarded as soon as decode_discovery_response() has run, and the hex device-ID
202/// string is rebuilt from `src` at format time rather than stored (a `std::string` member would
203/// cost more per entry than this whole struct).
204struct ScanResponder {
205 uint8_t src[NODE_ID_SIZE]; ///< Responder's node ID; also the dedup key.
206 DeviceType type; ///< Decoded device type.
207 uint8_t subtype; ///< Decoded device subtype.
208 bool inverted; ///< Decoded position-inversion flag.
209 int16_t rssi_dbm; ///< RSSI of the reply that produced this entry.
210 uint8_t manufacturer; ///< Raw manufacturer ID; name via manufacturer_name().
211 uint8_t flags; ///< Multi Information Byte; decode with DISCOVERY_FLAGS_* masks.
212 bool has_extended; ///< Whether manufacturer/flags above are present.
213 bool metadata_complete; ///< Whether type/subtype were present in the payload.
214};
215
216/// @brief Channels scan_paired_devices() transmits its roll-call request on, one attempt per
217/// channel, per pass (see @ref ROLL_CALL_PASSES).
218///
219/// A single broadcast on one fixed channel only reaches a paired device that happens to be
220/// awake and listening on that exact channel at that exact instant — real hardware testing
221/// found paired devices duty-cycle across all three channels independently of the hub, so a
222/// one-shot broadcast on CH2 alone misses whichever devices are elsewhere in their cycle right
223/// then. Retrying the same request on the other two channels gives every device up to three
224/// chances per pass to be listening when the hub transmits. CH2 first since it is the protocol's
225/// designated TX channel (see FREQ_CH2's doc comment) and therefore the most likely to catch a
226/// reply on the first attempt.
227///
228/// This TX retry is a duty-cycle workaround, not a receive-side fix. The hub's listen path
229/// separately extends its dwell on a detected preamble/sync rather than hopping mid-frame, which
230/// is what keeps it from dropping replies it does hear.
231constexpr uint32_t SCAN_CHANNELS[] = {FREQ_CH2, FREQ_CH1, FREQ_CH3};
232constexpr uint8_t SCAN_CHANNEL_COUNT = sizeof(SCAN_CHANNELS) / sizeof(SCAN_CHANNELS[0]);
233
234/// @brief One roll-call frame shape, and how to listen for its replies.
235///
236/// `low_power` for the frame builder is derived from `power_save_mode` (`== POWER_SAVE_LOW_POWER`)
237/// rather than stored separately, and the pass name used in logs is
238/// `power_save_mode_name(power_save_mode)` — one vocabulary, shared with the responder's own
239/// self-reported power-save byte, instead of a second spelling table.
240struct RollCallPass {
241 uint8_t power_save_mode; ///< POWER_SAVE_LOW_POWER or POWER_SAVE_ALWAYS_ALIVE: the class this pass calls.
242 bool ack_capable; ///< Set CTRL1_ACK on the 0x2A.
243 ListenPolicy listen_policy; ///< Channel policy for this pass's reply windows.
244};
245
246/// @brief The roll-call's two fixed frame shapes, low-power first.
247///
248/// Low-power pass first: a sleeper that answers late is still collected by the later
249/// always-alive windows (collect_broadcast_responses() accepts any 0x2B addressed to us,
250/// regardless of which pass is currently running); in the opposite order a late low-power reply
251/// would fall off the end of the scan with nothing left to catch it.
252///
253/// Low-power pass, `CTRL1 = 0x30` (LOW_POWER | ACK): the exact header a real VELUX KLR300 uses
254/// for its own roll-call, whose low-power shutters answered it. Setting CTRL1_LOW_POWER selects
255/// LONG_PREAMBLE through the shared start-preamble rule (request_preamble_for()) — no preamble is
256/// picked here. `ROTATE_ALL_CHANNELS`: those VELUX low-power replies were observed landing on the
257/// request channel, which a skipping listen would miss entirely.
258///
259/// Always-alive pass, `CTRL1 = 0x00` at `normal_start_preamble`: an always-alive VELUX
260/// installation ignores a 1024-byte start preamble and answers the 32-byte one; Somfy roll-call
261/// replies land on the request channel about 1 in 149 times, so `ROTATE_SKIPPING_REQUEST` keeps
262/// the window on the channels replies actually use.
263///
264/// Both shapes are fixed, named constants: `pairing_discovery_ack_capable` belongs to Discover &
265/// Pair and does not influence the roll-call either way.
266constexpr RollCallPass ROLL_CALL_PASSES[] = {
267 {POWER_SAVE_LOW_POWER, /*ack_capable=*/true, ListenPolicy::ROTATE_ALL_CHANNELS},
268 {POWER_SAVE_ALWAYS_ALIVE, /*ack_capable=*/false, ListenPolicy::ROTATE_SKIPPING_REQUEST},
269};
270
271/// A broadcast has no pinned conversation to hold a channel for, so HOLD_REQUEST_CHANNEL can
272/// never be a valid roll-call listen policy — checked at compile time rather than at every call
273/// site.
274constexpr bool no_roll_call_pass_holds_request_channel() {
275 return std::ranges::all_of(ROLL_CALL_PASSES, [](const RollCallPass &pass) {
276 return pass.listen_policy != ListenPolicy::HOLD_REQUEST_CHANNEL;
277 });
278}
279static_assert(no_roll_call_pass_holds_request_channel(),
280 "a broadcast roll-call pass has no pinned conversation to hold the request channel for");
281
282} // namespace
283
284namespace detail {
285
286#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
287
288// ESPHome 2026.9 added a `std::span<char> scratch` parameter to
289// UserServiceDescriptor::encode_list_service_response(): on ESP8266 the arg-name
290// string literals live in PROGMEM and are copied into `scratch`, so the returned
291// message is only valid while `scratch` is. 2026.8.x (incl. beta) still has the
292// zero-arg signature, hence the >= 2026.9.0 gate. Detect that ABI here so our
293// override keeps matching the pure virtual across ESPHome versions. The nested #if
294// is deliberate: VERSION_CODE is undefined on older ESPHome and in the host test
295// stubs, and a skipped outer group is not parsed, so the macro call never leaks.
296// The host unit-test build always takes the zero-arg branch (its api stub mirrors
297// stable), so the scratch branch is only exercised by the weekly ESPHome-dev CI.
298#if defined(ESPHOME_VERSION_CODE) && defined(VERSION_CODE)
299#if ESPHOME_VERSION_CODE >= VERSION_CODE(2026, 9, 0)
300#define IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH 1
301#endif
302#endif
303
304/// @brief Native API descriptor shared by every management action.
305///
306/// ESPHome 2026.x does not expose the generated YAML action helper runtime to external
307/// components, so Home IO Control registers the action descriptor directly with APIServer.
308/// This keeps the HA action surface identical to native ESPHome actions while avoiding
309/// the unresolved link path behind CustomAPIDevice::register_service().
310///
311/// One descriptor class serves every action: it is parametrized by name, argument list,
312/// and a callback that unpacks the request's string args and forwards them to a
313/// ManagementActions method. The callback captures a ManagementActions* and calls only
314/// public methods on it, so no friend declaration into the hub is needed. Adding action
315/// N+1 is therefore a new register_user_service() call in register_actions(), not a new
316/// descriptor class.
317class ManagementServiceDescriptor : public api::UserServiceDescriptor {
318 public:
319 ManagementServiceDescriptor(const char *name, std::vector<const char *> arg_names,
320 std::function<void(const api::ExecuteServiceRequest &)> callback)
321 : name_(name), key_(fnv1_hash(name)), arg_names_(std::move(arg_names)), callback_(std::move(callback)) {}
322
323 // name_ and arg_names_ point at ordinary (non-PROGMEM) .rodata string literals, so
324 // the StringRefs stay valid after return on any target and the scratch buffer is
325 // unused -- same reasoning as upstream's own UserServiceDynamic.
326#ifdef IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH
327 api::ListEntitiesServicesResponse encode_list_service_response(std::span<char> /*scratch*/) override {
328#else
329 api::ListEntitiesServicesResponse encode_list_service_response() override {
330#endif
331 api::ListEntitiesServicesResponse response;
332 response.name = StringRef(this->name_);
333 response.key = this->key_;
334 response.supports_response = api::enums::SUPPORTS_RESPONSE_NONE;
335 response.args.init(this->arg_names_.size());
336 for (const char *arg_name : this->arg_names_) {
337 auto &arg = response.args.emplace_back();
338 arg.name = StringRef(arg_name);
339 arg.type = api::enums::SERVICE_ARG_TYPE_STRING;
340 }
341 return response;
342 }
343
344 bool execute_service(const api::ExecuteServiceRequest &request) override {
345 if (request.key != this->key_ || request.args.size() != this->arg_names_.size())
346 return false;
347 this->callback_(request);
348 return true;
349 }
350
351#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
352 bool execute_service(const api::ExecuteServiceRequest &request, uint32_t) override {
353 return this->execute_service(request);
354 }
355#endif
356
357 protected:
358 const char *name_;
359 uint32_t key_;
360 std::vector<const char *> arg_names_;
361 std::function<void(const api::ExecuteServiceRequest &)> callback_;
362};
363
364#undef IOHOME_USERSERVICE_ENCODE_TAKES_SCRATCH
365#endif
366
367} // namespace detail
368
369// --- Helper free functions (file-local) ---
370
371static std::string normalize_device_id_argument(const std::string &device_id) {
372 std::string normalized = trim_ascii_whitespace(device_id);
373 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
374 [](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
375 return normalized;
376}
377
378static std::string bool_to_string(bool value) { return value ? "true" : "false"; }
379
380/// @brief Format a byte as two uppercase hex digits, no prefix. Named neutrally rather than
381/// after any one caller: used for CMD_ERROR_RESP result codes, probe reply/index command bytes,
382/// and sweep index values alike -- none of those is a "result code" except the first.
383static std::string format_hex_byte(uint8_t value) {
384 std::array<char, RESULT_CODE_BUFFER_SIZE> buffer{};
385 std::snprintf(buffer.data(), buffer.size(), "%02X", value);
386 return std::string(buffer.data());
387}
388
389static ManagementActionResult make_management_result(const std::string &action, const std::string &device_id) {
391 result.action = action;
392 result.device_id = device_id;
393 return result;
394}
395
396/// @brief Decode a CMD_ERROR_RESP frame's result code into `result`.
397///
398/// Populates has_result_code/result_code but deliberately leaves `result.message` untouched:
399/// rename and identify_device report different wording for the same decoded code, so message
400/// composition stays with each caller. On an empty error response, sets a stock message itself
401/// (there is no code to report) and returns false; callers should treat that the same way as a
402/// decoded code, just without result-code-specific wording.
403/// @param response Frame whose cmd is CMD_ERROR_RESP.
404/// @param result Result to populate.
405/// @return true if a result code was decoded, false if the response carried no data.
406static bool apply_error_response(const IoFrame &response, ManagementActionResult &result) {
407 if (response.data_len == 0) {
408 result.message = "device returned an empty error response";
409 return false;
410 }
411 result.has_result_code = true;
412 result.result_code = response.data[0];
413 return true;
414}
415
416/// @brief Parse a probe_device()/probe_sweep() index argument into a byte.
417///
418/// Accepts both a bare decimal string ("6") and a "0x"-prefixed hex string ("0x06") — the two
419/// shapes a Home Assistant user is likely to type when copying a value out of a captured frame
420/// dump. Rejects anything else (empty string, trailing garbage, out-of-range value) rather than
421/// silently defaulting to 0, since a wrong index silently sent as 0 would misrepresent what was
422/// actually probed.
423/// @param text Argument as received from the native API call.
424/// @param out Parsed byte on success; left untouched on failure.
425/// @return true if `text` is exactly one well-formed byte value.
426static bool parse_probe_index(const std::string &text, uint8_t &out) {
427 if (text.empty())
428 return false;
429 // A leading "0" followed by another digit (e.g. "010") is valid octal to strtoul() below and
430 // would silently probe a different index than a user copying a byte value out of a hex dump
431 // intended -- reject it explicitly rather than relying on strtoul()'s octal parsing, which only
432 // rejects the invalid-octal-digit cases ("08", "09"), not the valid ones.
433 if (text.size() > 1 && text[0] == '0' && text[1] != 'x' && text[1] != 'X')
434 return false;
435 errno = 0;
436 char *end = nullptr;
437 // Base 0: strtoul() itself recognizes a "0x"/"0X" prefix as hex and a bare "0" as decimal
438 // zero, which is exactly the "6" / "0x06" / "0" shape probes need to accept.
439 const uint32_t value = std::strtoul(text.c_str(), &end, 0);
440 if (end != text.c_str() + text.size())
441 return false;
442 if (errno == ERANGE || value > std::numeric_limits<uint8_t>::max())
443 return false;
444 out = static_cast<uint8_t>(value);
445 return true;
446}
447
448/// @brief Lowercase + ASCII-trim a native-API string argument.
449static std::string normalize_lower_argument(const std::string &value) {
450 std::string normalized = trim_ascii_whitespace(value);
451 std::transform(normalized.begin(), normalized.end(), normalized.begin(),
452 [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
453 return normalized;
454}
455
456/// @brief A `value` token that maps to a fixed float (used by set_mode / set_presence / set_window).
458 const char *token;
459 float value;
460};
461
462/// @brief Match `token` against `table`; on a hit set `out` and return true.
463static bool match_heating_named_value(const std::string &token, const HeatingNamedValue *table, size_t table_len,
464 float &out) {
465 for (size_t i = 0; i < table_len; i++) {
466 if (token == table[i].token) {
467 out = table[i].value;
468 return true;
469 }
470 }
471 return false;
472}
473
474/// @brief Parse `set_temperature`'s value into degrees Celsius, range-checked.
475static bool parse_heating_temperature(const std::string &value, float &value_out, std::string &error) {
476 const std::string text = trim_ascii_whitespace(value);
477 errno = 0;
478 char *end = nullptr;
479 const float parsed = std::strtof(text.c_str(), &end);
480 if (text.empty() || end != text.c_str() + text.size() || errno == ERANGE || !std::isfinite(parsed)) {
481 error = "temperature must be a number in degrees Celsius";
482 return false;
483 }
484 if (parsed < HEATING_TEMP_MIN_C || parsed > HEATING_TEMP_MAX_C) {
485 std::array<char, HEATING_RANGE_MESSAGE_BUFFER_SIZE> buffer{};
486 std::snprintf(buffer.data(), buffer.size(), "temperature must be between %.1f and %.1f",
487 static_cast<double>(HEATING_TEMP_MIN_C), static_cast<double>(HEATING_TEMP_MAX_C));
488 error = buffer.data();
489 return false;
490 }
491 value_out = parsed;
492 return true;
493}
494
495/// @brief Parse the (`function`, `value`) argument pair of the `heating_control` action.
496///
497/// Every native-API argument is a string (ManagementServiceDescriptor hardcodes
498/// SERVICE_ARG_TYPE_STRING), so this turns the two strings into a HeatingFunction plus the float
499/// encode_heating_payload() expects: degrees C for `set_temperature`, a HeatingMode value for
500/// `set_mode`, 0/1 for `set_presence` / `set_window`, and an ignored 0 for `power_on` /
501/// `midnight_sync`. On any malformed input it fills `error` with a caller-facing message and
502/// returns false rather than coercing a value. Kept next to the action (not in proto_heating)
503/// because the climate entity receives typed enums from Home Assistant and needs no string
504/// parsing.
505/// @param function Function name argument.
506/// @param value Value argument.
507/// @param fn_out Parsed function on success.
508/// @param value_out Parsed value on success (0 for the value-less functions).
509/// @param error Caller-facing message on failure.
510/// @return true on a fully valid pair.
511static bool parse_heating_arguments(const std::string &function, const std::string &value, HeatingFunction &fn_out,
512 float &value_out, std::string &error) {
513 static constexpr HeatingNamedValue MODE_VALUES[] = {
514 {"auto", static_cast<float>(HeatingMode::AUTO)},
515 {"manual", static_cast<float>(HeatingMode::MANUAL)},
516 {"prog", static_cast<float>(HeatingMode::PROG)},
517 {"off", static_cast<float>(HeatingMode::OFF)},
518 };
519 static constexpr HeatingNamedValue PRESENCE_VALUES[] = {{"on", 1.0F}, {"off", 0.0F}};
520 static constexpr HeatingNamedValue WINDOW_VALUES[] = {{"open", 1.0F}, {"close", 0.0F}};
521
522 const std::string fn = normalize_lower_argument(function);
523 value_out = 0.0F;
524
525 if (fn == "power_on") {
527 return true;
528 }
529 if (fn == "midnight_sync") {
531 return true;
532 }
533 if (fn == "set_temperature") {
535 return parse_heating_temperature(value, value_out, error);
536 }
537 if (fn == "set_mode") {
539 if (match_heating_named_value(normalize_lower_argument(value), MODE_VALUES, std::size(MODE_VALUES), value_out))
540 return true;
541 error = "mode must be one of auto, manual, prog, off";
542 return false;
543 }
544 if (fn == "set_presence") {
546 if (match_heating_named_value(normalize_lower_argument(value), PRESENCE_VALUES, std::size(PRESENCE_VALUES),
547 value_out))
548 return true;
549 error = "presence must be 'on' or 'off'";
550 return false;
551 }
552 if (fn == "set_window") {
554 if (match_heating_named_value(normalize_lower_argument(value), WINDOW_VALUES, std::size(WINDOW_VALUES), value_out))
555 return true;
556 error = "window must be 'open' or 'close'";
557 return false;
558 }
559
560 error = "unknown heating function '" + trim_ascii_whitespace(function) + "'";
561 return false;
562}
563
564// --- ManagementActions ---
565
566ManagementActions::ManagementActions(const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning,
567 ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized,
569 : node_id_(node_id),
570 system_key_(system_key),
571 tuning_(tuning),
572 engine_(engine),
573 registry_(registry),
574 initialized_(initialized),
575 hub_(hub) {}
576
578#if defined(USE_API_USER_DEFINED_ACTIONS) && defined(USE_API_CUSTOM_SERVICES)
579 if (api::global_api_server == nullptr) {
580 ESP_LOGW(detail::TAG, "Native API server not available, management actions will not be registered");
581 return;
582 }
583
584 // One row per user-visible action: name, argument-name list (every argument is exposed as
585 // SERVICE_ARG_TYPE_STRING — `oneway_set_position` parses its "position" string itself rather
586 // than widening a shipped API surface), whether it is gated behind `diagnostic_probes: true`,
587 // and the callback that unpacks its arguments. Adding an action is one row here.
588 struct ActionReg {
589 const char *name;
590 std::vector<const char *> arg_names;
591 bool diagnostic_only;
592 std::function<void(const api::ExecuteServiceRequest &)> callback;
593 };
594 const ActionReg actions[] = {
595 {MANAGEMENT_ACTION_RENAME_DEVICE,
596 {"device_id", "new_name"},
597 false,
598 [this](const api::ExecuteServiceRequest &r) {
599 this->api_rename_device(r.args[0].string_.str(), r.args[1].string_.str());
600 }},
601 {MANAGEMENT_ACTION_IDENTIFY_DEVICE,
602 {"device_id"},
603 false,
604 [this](const api::ExecuteServiceRequest &r) { this->api_identify_device(r.args[0].string_.str()); }},
605 {MANAGEMENT_ACTION_FORCE_OPEN_DEVICE,
606 {"device_id"},
607 false,
608 [this](const api::ExecuteServiceRequest &r) { this->api_force_open_device(r.args[0].string_.str()); }},
609 {MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES,
610 {},
611 false,
612 [this](const api::ExecuteServiceRequest &) { this->api_scan_paired_devices(); }},
613 {MANAGEMENT_ACTION_ONEWAY_SET_POSITION,
614 {"controller_id", "position"},
615 false,
616 [this](const api::ExecuteServiceRequest &r) {
617 this->api_oneway_set_position(r.args[0].string_.str(), r.args[1].string_.str());
618 }},
619 {MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER,
620 {"controller_id"},
621 false,
622 [this](const api::ExecuteServiceRequest &r) { this->api_oneway_remove_controller(r.args[0].string_.str()); }},
623 {MANAGEMENT_ACTION_HEATING_CONTROL,
624 {"device_id", "function", "value"},
625 false, // a real user feature, gated by documentation not by diagnostic_probes:
626 [this](const api::ExecuteServiceRequest &r) {
627 this->api_heating_control(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
628 }},
629 {MANAGEMENT_ACTION_PROBE_DEVICE,
630 {"device_id", "probe", "index"},
631 true,
632 [this](const api::ExecuteServiceRequest &r) {
633 this->api_probe_device(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str());
634 }},
635 {MANAGEMENT_ACTION_PROBE_SWEEP,
636 {"device_id", "probe", "first_index", "last_index"},
637 true,
638 [this](const api::ExecuteServiceRequest &r) {
639 this->api_probe_sweep(r.args[0].string_.str(), r.args[1].string_.str(), r.args[2].string_.str(),
640 r.args[3].string_.str());
641 }},
642 };
643
644 // The two probe actions are registered only when diagnostic_probes: true was set in YAML, so the
645 // action list stays clean on a default build. diagnostic_probes_enabled() already holds its
646 // final YAML-configured value here: __init__.py's to_code() emits set_diagnostic_probes_enabled()
647 // as a plain property-setter call in generated main.cpp, which runs before App.setup() calls this
648 // component's setup() (and therefore this method), not after.
649 const bool probes_enabled = hub_->diagnostic_probes_enabled();
650 for (const auto &action : actions) {
651 if (action.diagnostic_only && !probes_enabled)
652 continue;
653 api::global_api_server->register_user_service( // NOLINT
654 new detail::ManagementServiceDescriptor(action.name, action.arg_names, action.callback));
655 }
656#endif
657}
658
659IoDevice *ManagementActions::resolve_device_(const char *action, const std::string &device_id,
660 ManagementActionResult &result) {
661 const std::string normalized_device_id = normalize_device_id_argument(device_id);
662 result = make_management_result(action, normalized_device_id);
663
664 if (!*initialized_) {
665 result.message = "hub is not initialized";
666 return nullptr;
667 }
668
669 uint8_t parsed_device_id[NODE_ID_SIZE]{};
670 if (!hex_to_bytes(normalized_device_id, parsed_device_id, NODE_ID_SIZE)) {
671 result.message = "device ID must be exactly 6 hexadecimal characters";
672 return nullptr;
673 }
674
675 auto *dev = registry_.get(normalized_device_id);
676 if (dev == nullptr) {
677 result.message = "device is not registered on this hub";
678 return nullptr;
679 }
680
681 return dev;
682}
683
684bool ManagementActions::send_authenticated_request_(const IoFrame &request, IoFrame &response, const char *action_verb,
685 ManagementActionResult &result) {
686 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
688 return true;
689 engine_.log_debug(result.device_id.c_str());
690 // A management action's whole point is the payload it reads back (a name, an info block), so an
691 // unconfirmed acceptance still cannot satisfy the caller — but it is a materially different
692 // situation from silence, and saying so saves the user chasing a link problem that isn't one.
693 result.message = outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED
694 ? std::string("device accepted the ") + action_verb + " request but sent no response"
695 : std::string("no valid response to ") + action_verb + " request";
696 return false;
697}
698
699void ManagementActions::api_rename_device(const std::string &device_id, const std::string &new_name) {
700 publish_result(rename_device(device_id, new_name));
701}
702
704 // device_id is empty for actions with no single target (e.g. scan_paired_devices' roll-call);
705 // the "for device %s" clause is omitted rather than rendering as "for device :".
706 const bool has_device = !result.device_id.empty();
707 std::string prefix = "Management action " + result.action;
708 if (has_device)
709 prefix += " for device " + result.device_id;
710 prefix += result.success ? ": " : " failed: ";
712
713 if (!hub_->is_connected())
714 return;
715
716 std::map<std::string, std::string> event_data{{"action", result.action},
717 {"device_id", result.device_id},
718 {"success", bool_to_string(result.success)},
719 {"verified", bool_to_string(result.verified)},
720 {"message", result.message}};
721
722 if (!result.requested_name.empty())
723 event_data["requested_name"] = result.requested_name;
724 if (!result.applied_name.empty())
725 event_data["applied_name"] = result.applied_name;
726 if (result.has_result_code) {
727 event_data["result_code"] = format_hex_byte(result.result_code);
728 event_data["result_code_name"] = command_result_name(result.result_code);
729 }
730 if (!result.probe_name.empty()) {
731 event_data["probe"] = result.probe_name;
732 // probe_index is empty only until a sweep has parsed its range; both probe_device() and
733 // probe_sweep() fill it before publishing, so an absent key means "no index applies" rather
734 // than "the index was blank".
735 if (!result.probe_index.empty())
736 event_data["index"] = result.probe_index;
737 }
738 if (result.has_response_cmd) {
739 event_data["response_cmd"] = format_hex_byte(result.response_cmd);
740 event_data["response_cmd_name"] = command_name(result.response_cmd);
741 }
742 if (!result.response_hex.empty())
743 event_data["response_hex"] = result.response_hex;
744
745 hub_->fire_homeassistant_event(MANAGEMENT_RESULT_EVENT, event_data);
746}
747
748ManagementActionResult ManagementActions::rename_device(const std::string &device_id, const std::string &new_name) {
750 auto *dev = resolve_device_(MANAGEMENT_ACTION_RENAME_DEVICE, device_id, result);
751 if (dev == nullptr)
752 return result;
753
754 uint8_t payload[DEVICE_NAME_WRITE_PAYLOAD_SIZE];
755 std::string normalized_name;
756 const DeviceNameValidationError name_error = encode_device_name_payload(new_name, payload, normalized_name);
757 result.requested_name = normalized_name.empty() ? trim_ascii_whitespace(new_name) : normalized_name;
758 if (name_error != DeviceNameValidationError::NONE) {
760 return result;
761 }
762
763 IoFrame request;
764 if (!create_set_name(request, node_id_, dev->node_id, dev->low_power, payload)) {
765 result.message = "failed to build rename request";
766 return result;
767 }
768
769 IoFrame response;
770 if (!send_authenticated_request_(request, response, "rename", result))
771 return result;
772
773 if (response.cmd == CMD_ERROR_RESP) {
774 if (apply_error_response(response, result)) {
775 result.message =
776 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
777 }
778 return result;
779 }
780
781 if (response.cmd != CMD_SET_NAME_RESP) {
782 std::array<char, UNEXPECTED_RESPONSE_MESSAGE_BUFFER_SIZE> buffer{};
783 std::snprintf(buffer.data(), buffer.size(), "unexpected rename response 0x%02X", response.cmd);
784 result.message = buffer.data();
785 return result;
786 }
787
788 result.success = true;
789 result.message = "rename acknowledged by device";
790
791 if (!hub_->request_device_name(result.device_id)) {
792 result.message = "rename acknowledged but verification readback failed";
793 return result;
794 }
795
796 auto *updated_device = registry_.get(result.device_id);
797 if (updated_device != nullptr)
798 result.applied_name = updated_device->name;
799
800 if (result.applied_name == normalized_name) {
801 result.verified = true;
802 result.message = "rename verified by device readback";
803 return result;
804 }
805
806 result.message = "rename acknowledged but readback did not match the requested name";
807 return result;
808}
809
810void ManagementActions::api_identify_device(const std::string &device_id) {
812}
813
816 // Deliberately no device-type gating beyond "registered on this hub" — see the doxygen note on
817 // the declaration for why.
818 auto *dev = resolve_device_(MANAGEMENT_ACTION_IDENTIFY_DEVICE, device_id, result);
819 if (dev == nullptr)
820 return result;
821
822 IoFrame request;
823 if (!create_identify(request, node_id_, dev->node_id, dev->low_power)) {
824 result.message = "failed to build identify request";
825 return result;
826 }
827
828 IoFrame response;
829 if (!send_authenticated_request_(request, response, "identify", result))
830 return result;
831
832 if (response.cmd == CMD_ERROR_RESP) {
833 // Deliberate deviation from rename's error handling: a device may answer CMD_IDENTIFY with
834 // CMD_ERROR_RESP and still have performed the jog, so this counts as success, not failure.
835 result.success = true;
836 if (apply_error_response(response, result)) {
837 result.message = "identify triggered (device reported " + std::string(command_result_name(result.result_code)) +
838 ": " + command_result_description(result.result_code) + ")";
839 } else {
840 result.message = "identify triggered (device returned an empty error response)";
841 }
842 return result;
843 }
844
845 // Any other endpoint-matched reply counts as acknowledgment; unlike rename there is no specific
846 // response command to check against, and no readback exists to set `verified`.
847 result.success = true;
848 result.message = "identify acknowledged by device";
849 return result;
850}
851
852void ManagementActions::api_force_open_device(const std::string &device_id) {
854}
855
858 auto *dev = resolve_device_(MANAGEMENT_ACTION_FORCE_OPEN_DEVICE, device_id, result);
859 if (dev == nullptr)
860 return result;
861
862 // Delegate to the hub's normal cover-command dispatch path (capability gating, poll tracking,
863 // settle handling, backoff already live there) instead of talking to the radio directly.
864 if (!hub_->queue_device_command(result.device_id, CoverCommand::FORCE_OPEN)) {
865 result.message = "device does not accept cover commands";
866 return result;
867 }
868
869 result.success = true;
870 result.message =
871 "force open queued (elevated-priority open; wind/rain lock bypass unconfirmed; movement result arrives via "
872 "cover state)";
873 return result;
874}
875
876void ManagementActions::api_heating_control(const std::string &device_id, const std::string &function,
877 const std::string &value) {
878 publish_result(heating_control(device_id, function, value));
879}
880
881ManagementActionResult ManagementActions::heating_control(const std::string &device_id, const std::string &function,
882 const std::string &value) {
884 auto *dev = resolve_device_(MANAGEMENT_ACTION_HEATING_CONTROL, device_id, result);
885 if (dev == nullptr)
886 return result;
887
888 HeatingFunction fn{};
889 float encoded_value = 0.0F;
890 std::string parse_error;
891 if (!parse_heating_arguments(function, value, fn, encoded_value, parse_error)) {
892 result.message = parse_error;
893 return result;
894 }
895
896 // Capability gate via the predicate only — no inline device-type or vendor list.
897 if (!device_supports_climate_control(dev->type)) {
898 result.message = "device is not a climate device";
899 return result;
900 }
901
902 // Snapshot any pre-existing CMD_ERROR_RESP code so a stale one (e.g. an old wind lockout from a
903 // different command) is not misattributed to this send.
904 const uint8_t prior_result_code = dev->last_result_code;
905 const uint32_t prior_result_at_ms = dev->last_result_at_ms;
906
907 // One shared transmit path (invariant): the climate entity calls the same method. A
908 // CMD_ERROR_RESP surfaced by this call is recorded on the device record by that path.
909 if (!hub_->send_heating_command(result.device_id, fn, encoded_value)) {
910 result.message = std::string("device did not acknowledge the ") + heating_function_name(fn) + " command";
911 if (const auto *updated = registry_.get(result.device_id);
912 updated != nullptr && updated->last_result_code != 0 &&
913 (updated->last_result_code != prior_result_code || updated->last_result_at_ms != prior_result_at_ms)) {
914 result.has_result_code = true;
915 result.result_code = updated->last_result_code;
916 result.message =
917 std::string(command_result_name(result.result_code)) + ": " + command_result_description(result.result_code);
918 }
919 return result;
920 }
921
922 result.success = true;
923 // verified stays false: the set_* functions decode nothing back into an entity (the two 0x60
924 // reads have their ACK payload logged at DEBUG only), so nothing can confirm the write.
925 result.message = std::string("heating ") + heating_function_name(fn) + " acknowledged by device";
926 return result;
927}
928
930
931void ManagementActions::api_oneway_set_position(const std::string &controller_id, const std::string &position) {
932 // device_id carries the controller-identity handle: 1W addresses a class, so there is no device
933 // to name, and the identity is what the caller actually chose.
934 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_ONEWAY_SET_POSITION, controller_id);
935
936 if (hub_->oneway_controllers().get(controller_id) == nullptr) {
937 result.message = "no oneway_controllers identity with that id";
938 publish_result(result);
939 return;
940 }
941
942 // Parse the position string here and reject loudly on anything unparseable. Do NOT relax this to
943 // atoi()/strtoul()-with-default: a value that silently became 0 would send a fully-open command
944 // to every actuator bound to this 1W identity.
945 const std::string trimmed = trim_ascii_whitespace(position);
946 if (trimmed.empty() || trimmed.find_first_not_of("0123456789") != std::string::npos) {
947 result.message = "position must be a whole number between 0 and 100";
948 publish_result(result);
949 return;
950 }
951 const unsigned long parsed = strtoul(trimmed.c_str(), nullptr, 10); // NOLINT(google-runtime-int)
952 if (parsed > ONEWAY_POSITION_FULLY_CLOSED) {
953 result.message = "position must be between 0 and 100";
954 publish_result(result);
955 return;
956 }
957
958 hub_->send_oneway_position(controller_id, static_cast<uint8_t>(parsed));
959 result.success = true;
960 // Deliberately "queued", not "sent" or "applied": 1W reports nothing back, and neither should
961 // this. See the "Last 1W Command" sensor for what was actually transmitted.
962 result.message = "1W position command queued";
963 publish_result(result);
964}
965
966void ManagementActions::api_oneway_remove_controller(const std::string &controller_id) {
967 // device_id carries the controller-identity handle, same convention as api_oneway_set_position().
968 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_ONEWAY_REMOVE_CONTROLLER, controller_id);
969
970 if (hub_->oneway_controllers().get(controller_id) == nullptr) {
971 result.message = "no oneway_controllers identity with that id";
972 publish_result(result);
973 return;
974 }
975
976 hub_->send_oneway_unenroll(controller_id);
977 result.success = true;
978 // "Queued", not "removed": 1W has no reply, so nothing here can ever confirm a device actually
979 // forgot this identity — same framing as every other 1W action result.
980 result.message = "1W remove-controller (0x39) queued";
981 publish_result(result);
982}
983
984/// @brief Format one roll-call responder's report line(s).
985///
986/// Known responders get a single summary line, plus an indented `hint:` line when their
987/// self-reported power-save class disagrees with the registered device's YAML `low_power`
988/// property (the responder must carry extended metadata for the comparison to be possible at
989/// all). Unknown responders get the same summary line plus a lead-in sentence and a
990/// ready-to-paste YAML block (or, if the decoded type has no ESPHome platform, an explanatory
991/// line instead of a blank) — the same "paste this in" framing a successful pairing prints.
992/// @param responder Decoded responder record.
993/// @param device_id Hex device ID string for this responder, rebuilt from `responder.src`.
994/// @param registered The matching registry entry, or nullptr if this responder is not known.
995static std::string format_scan_reply_line(const ScanResponder &responder, const std::string &device_id,
996 const IoDevice *registered) {
997 const bool known = registered != nullptr;
998 std::string line = " " + device_id + ": " + device_type_name(responder.type) +
999 " subtype=" + std::to_string(responder.subtype) + " rssi=" + std::to_string(responder.rssi_dbm) +
1000 "dBm";
1001 uint8_t power_save = 0;
1002 if (responder.has_extended) {
1003 uint8_t const att = discovery_att_class(responder.flags);
1004 power_save = discovery_power_save_mode(responder.flags);
1005 line += std::string(" manufacturer=") + manufacturer_name(responder.manufacturer) +
1006 " turnaround=" + att_class_name(att) + " power_save=" + power_save_mode_name(power_save);
1007 }
1008 line += known ? " [known]\n" : " [unknown]\n";
1009
1010 if (known) {
1011 // Advice only: this never touches the registry or the device's runtime low_power property
1012 // (an explicit YAML override stays authoritative). A responder without extended metadata has
1013 // nothing to compare, so it gets no hint either way. A power_save value outside the two known
1014 // classes (2 or 3) is not evidence of either class, so it gets no hint either — only an exact
1015 // match against one of the two known classes triggers advice.
1016 if (responder.has_extended) {
1017 if (power_save == POWER_SAVE_LOW_POWER && !registered->low_power) {
1018 line += " hint: reports power_save=" + std::string(power_save_mode_name(power_save)) +
1019 " but its YAML has no low_power: true; add it or directed commands may miss it while it "
1020 "sleeps\n";
1021 } else if (power_save == POWER_SAVE_ALWAYS_ALIVE && registered->low_power) {
1022 line += " hint: reports power_save=" + std::string(power_save_mode_name(power_save)) +
1023 " but its YAML sets low_power: true; remove it, the " + std::to_string(LONG_PREAMBLE) +
1024 "-byte wake-up preamble can stop an always-alive receiver answering\n";
1025 }
1026 }
1027 return line;
1028 }
1029
1030 const bool low_power = responder.has_extended && power_save == POWER_SAVE_LOW_POWER;
1031 const std::string snippet = build_device_yaml_snippet(responder.type, responder.subtype, device_id,
1032 responder.metadata_complete, responder.inverted, low_power);
1033 if (!snippet.empty())
1034 return line + " Paste this into your YAML to register it:\n" + snippet;
1035
1036 return line + " no ready-to-paste YAML: no ESPHome platform for io_device_type: " +
1037 format_device_type_for_yaml(responder.type) + "\n";
1038}
1039
1040/// @brief Outcome of add_scan_responder(), so callers can tell a harmless repeat from real loss.
1041enum class ScanAddResult : uint8_t {
1042 ADDED, ///< New responder recorded.
1043 DUPLICATE, ///< Already recorded from an earlier reply; nothing changed.
1044 FULL, ///< Dropped: SCAN_MAX_REPLIES distinct responders already recorded.
1045};
1046
1047/// @brief Record a responder unless its address is already present.
1048///
1049/// Deduplication is by node ID across the whole scan, so a device that answers several of the
1050/// six attempts — or twice inside one attempt — still yields one entry. The duplicate check
1051/// runs before the capacity check so that repeat replies from already-recorded devices never
1052/// look like overflow once the array is full.
1053/// @param responders Accumulated array, appended to in place.
1054/// @param count In: entries already present. Out: updated count.
1055/// @param capacity Maximum entries `responders` can hold.
1056/// @param frame Reply frame to decode and store.
1057/// @param rssi_dbm RSSI of that reply.
1058/// @param entry Out: the newly recorded entry (ADDED), the already-recorded entry with the same
1059/// `src` (DUPLICATE), or nullptr (FULL) — lets a caller read back the stored
1060/// manufacturer/flags without decoding the frame a second time.
1061/// @return Which of the three outcomes occurred.
1062static ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity,
1063 const IoFrame &frame, int16_t rssi_dbm, const ScanResponder *&entry) {
1064 for (uint8_t i = 0; i < count; i++) {
1065 if (memcmp(responders[i].src, frame.src, NODE_ID_SIZE) == 0) {
1066 entry = &responders[i];
1068 }
1069 }
1070 if (count >= capacity) {
1071 entry = nullptr;
1072 return ScanAddResult::FULL;
1073 }
1074
1075 // decode_discovery_response() also produces the hex device-ID string, which is deliberately
1076 // discarded here and rebuilt from `src` when the report is formatted. Keeping it would mean a
1077 // std::string per entry — more memory than the entire ScanResponder — to save re-deriving six
1078 // characters that fit in a small-string buffer. Do not "optimise" this by adding a string member.
1079 IoDevice device{};
1080 std::string unused_device_id;
1081 const DiscoveryResponseInfo info = decode_discovery_response(frame, device, unused_device_id);
1082
1083 ScanResponder &new_entry = responders[count++];
1084 memcpy(new_entry.src, frame.src, NODE_ID_SIZE);
1085 new_entry.type = device.type;
1086 new_entry.subtype = device.subtype;
1087 new_entry.inverted = device.inverted;
1088 new_entry.rssi_dbm = rssi_dbm;
1089 new_entry.manufacturer = info.manufacturer;
1090 new_entry.flags = info.flags;
1091 new_entry.has_extended = info.has_extended;
1092 new_entry.metadata_complete = info.metadata_complete;
1093 entry = &new_entry;
1094 return ScanAddResult::ADDED;
1095}
1096
1097/// @brief Accumulated state for one scan_paired_devices() call, threaded through every attempt.
1099 ScanResponder responders[SCAN_MAX_REPLIES];
1100 uint8_t count{0};
1101 bool truncated{false};
1102};
1103
1104/// @brief How many of ROLL_CALL_PASSES `selection` actually calls.
1105static uint8_t count_enabled_passes(ScanPowerClasses selection) {
1106 uint8_t count = 0;
1107 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
1108 if (scan_power_classes_include(selection, pass.power_save_mode))
1109 count++;
1110 }
1111 return count;
1112}
1113
1114/// @brief Per-attempt facts a roll-call reply's DEBUG line needs, kept in one small struct so the
1115/// reply lambda's capture list stays a couple of pointers rather than growing one field at a time.
1117 const RollCallPass *pass;
1118 uint8_t attempt; ///< Global attempt number (1-based, across all passes).
1119 uint32_t tx_freq_hz; ///< Channel this attempt transmitted the request on.
1120};
1121
1122/// @brief Record one accepted roll-call reply into the scan state, and log it.
1123///
1124/// Reads back `add_scan_responder()`'s out-parameter rather than decoding the frame a second time
1125/// to learn the responder's self-reported power-save class. RSSI in the log line is this reply's
1126/// own `info.rssi_dbm`, not the value stored on first sighting, so a later, stronger or weaker
1127/// reception of an already-known responder is still visible. Duplicates are logged too — a repeat
1128/// still answers "which pass heard this device".
1129static void record_roll_call_reply(ScanState &state, const RollCallAttemptContext &ctx, const IoFrame &frame,
1131 const ScanResponder *entry = nullptr;
1132 if (add_scan_responder(state.responders, state.count, SCAN_MAX_REPLIES, frame, info.rssi_dbm, entry) ==
1134 state.truncated = true;
1135 }
1136
1137 const char *power_save = (entry != nullptr && entry->has_extended)
1139 : "unknown";
1140 ESP_LOGD(detail::TAG,
1141 "Roll-call reply src=%s pass=%s attempt=%u tx=%" PRIu32 " rx=%" PRIu32 " +%" PRIu32
1142 "ms rssi=%d power_save=%s",
1143 node_id_to_string(frame.src).c_str(), power_save_mode_name(ctx.pass->power_save_mode), ctx.attempt,
1144 ctx.tx_freq_hz, info.rx_freq_hz, info.after_tx_ms, info.rssi_dbm, power_save);
1145}
1146
1147/// @brief Outcome of run_roll_call_attempt(), so the sweep loop can surface a build failure the
1148/// same way scan_paired_devices() always has.
1149enum class RollCallAttemptResult : uint8_t {
1150 OK, ///< Request built and transmitted (with or without replies).
1151 BUILD_FAILED, ///< create_discovery_request() failed; the caller should abort the whole scan.
1152};
1153
1154/// @brief Transmit one roll-call attempt and collect its replies into `state`.
1155///
1156/// Builds a fresh request every call (create_discovery_request() draws a new random nonce each
1157/// time) rather than replaying one frame across attempts, and logs one attempt-summary DEBUG line
1158/// after collection closes. The pass determines the frame shape (CTRL1, and therefore preamble via
1159/// request_preamble_for()'s rule) and the listen policy; this function does not choose either.
1160static RollCallAttemptResult run_roll_call_attempt(ExchangeEngine &engine, const uint8_t *node_id,
1161 const uint8_t *system_key, const TuningConfig &tuning,
1162 const RollCallPass &pass, uint32_t tx_freq_hz, uint8_t attempt,
1163 uint8_t total_attempts, ScanState &state) {
1164 IoFrame request;
1165 const bool low_power = pass.power_save_mode == POWER_SAVE_LOW_POWER;
1166 if (!create_discovery_request(request, node_id, CMD_DISCOVER_SPE_REQ, BROADCAST_DISCOVER, low_power, pass.ack_capable,
1167 /*payload_enabled=*/false, /*payload=*/0, system_key)) {
1169 }
1170
1171 const uint8_t before = state.count;
1172 RollCallAttemptContext ctx{&pass, attempt, tx_freq_hz};
1173 const uint8_t heard = engine.collect_broadcast_responses(
1174 request, tx_freq_hz, CMD_DISCOVER_SPE_RESP, tuning.pairing_discovery_wait_ms,
1175 [&state, &ctx](const IoFrame &frame, const ExchangeEngine::BroadcastReplyInfo &info) {
1176 record_roll_call_reply(state, ctx, frame, info);
1177 },
1178 pass.listen_policy);
1179
1180 // Diagnostic only (not part of the user-facing report). Reporting heard and new separately is
1181 // what makes it useful: "heard 3, 0 new" means devices are answering every attempt (so the
1182 // extra channels are redundant here). ctrl1 is read back from the built frame rather than
1183 // re-derived from the pass, so this line can never disagree with what was actually transmitted.
1184 const uint8_t new_count = state.count - before;
1185 ESP_LOGD(detail::TAG,
1186 "Roll-call attempt %u/%u (pass=%s, ctrl1=0x%02X, tx %" PRIu32 " Hz, %u ms window): %u repl%s heard, %u new",
1187 attempt, total_attempts, power_save_mode_name(pass.power_save_mode), request.ctrl1, tx_freq_hz,
1188 tuning.pairing_discovery_wait_ms, heard, heard == 1 ? "y" : "ies", new_count);
1190}
1191
1192/// @brief Build the final report: known/unknown grouping, header, the selection NOTE, and the
1193/// truncation NOTE.
1194static std::string build_scan_report(const ScanState &state, DeviceRegistry &registry, ScanPowerClasses selection) {
1195 // Grouped into known-first, unknown-second rather than interleaved in arrival order: the two
1196 // groups need very different follow-up (nothing to do vs. paste a YAML block), so burying an
1197 // unknown responder between two known ones makes it easy to miss.
1198 std::string known_body;
1199 std::string unknown_body;
1200 uint8_t unknown_count = 0;
1201 for (uint8_t i = 0; i < state.count; i++) {
1202 const std::string device_id = node_id_to_string(state.responders[i].src);
1203 const IoDevice *registered = registry.get(device_id);
1204 if (registered != nullptr) {
1205 known_body += format_scan_reply_line(state.responders[i], device_id, registered);
1206 } else {
1207 unknown_count++;
1208 unknown_body += format_scan_reply_line(state.responders[i], device_id, nullptr);
1209 }
1210 }
1211
1212 std::string body;
1213 if (!known_body.empty())
1214 body += "Known:\n" + known_body;
1215 if (!unknown_body.empty())
1216 body += "Unknown:\n" + unknown_body;
1217
1218 std::string message = "Roll-call: " + std::to_string(state.count) + " device" + (state.count == 1 ? "" : "s") +
1219 " detected (" + std::to_string(state.count - unknown_count) + " known, " +
1220 std::to_string(unknown_count) + " unknown)\n";
1221 // A non-default selection must say so in the report itself, not just the log: a user who left
1222 // this tunable on always_alive must not read a missing solar/battery device as "gone". Derived
1223 // from ROLL_CALL_PASSES rather than a switch/literal, so a third power class would not need a
1224 // second place taught about it.
1225 if (selection != ScanPowerClasses::BOTH) {
1226 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
1227 if (scan_power_classes_include(selection, pass.power_save_mode))
1228 continue;
1229 message += "NOTE: scan_power_classes=" + scan_power_classes_to_string(selection) + ": " +
1230 power_save_mode_name(pass.power_save_mode) + " devices were not called.\n";
1231 }
1232 }
1233 // Surfaced in the report itself, not only the log: a scan that silently listed a subset would
1234 // look like devices had gone missing.
1235 if (state.truncated) {
1236 message += "NOTE: more than " + std::to_string(SCAN_MAX_REPLIES) +
1237 " devices answered; the list below is truncated. Re-run to see whether other devices "
1238 "appear, and raise SCAN_MAX_REPLIES if this install really is larger.\n";
1239 }
1240 message += body;
1241 return message;
1242}
1243
1245 ManagementActionResult result = make_management_result(MANAGEMENT_ACTION_SCAN_PAIRED_DEVICES, "");
1246
1247 if (!*initialized_) {
1248 result.message = "hub is not initialized";
1249 return result;
1250 }
1251
1252 // Outer loop over passes (low-power first, see ROLL_CALL_PASSES), skipping any pass the
1253 // scan_power_classes tunable excludes; inner loop over SCAN_CHANNELS. Attempt numbers are
1254 // global and 1-based across the passes actually run, matching the "n/N" log line and the
1255 // per-reply `attempt=` field. Every attempt always runs, even once the array is full: stopping
1256 // early would silently skip channels/passes, which is exactly the single-channel behaviour the
1257 // repeated attempts exist to avoid.
1258 const ScanPowerClasses selection = tuning_->scan_power_classes;
1259 const uint8_t total_attempts = count_enabled_passes(selection) * SCAN_CHANNEL_COUNT;
1260 ScanState state{};
1261 uint8_t attempt = 0;
1262 for (const RollCallPass &pass : ROLL_CALL_PASSES) {
1263 if (!scan_power_classes_include(selection, pass.power_save_mode))
1264 continue;
1265 for (const uint32_t tx_freq_hz : SCAN_CHANNELS) {
1266 attempt++;
1267 if (run_roll_call_attempt(engine_, node_id_, system_key_, *tuning_, pass, tx_freq_hz, attempt, total_attempts,
1269 result.message = "failed to build roll-call request";
1270 return result;
1271 }
1272 }
1273 }
1274
1275 result.success = true;
1276 result.message = build_scan_report(state, registry_, selection);
1277 return result;
1278}
1279
1280void ManagementActions::api_probe_device(const std::string &device_id, const std::string &probe,
1281 const std::string &index) {
1282 publish_result(probe_device(device_id, probe, index));
1283}
1284
1285ManagementActionResult ManagementActions::probe_device(const std::string &device_id, const std::string &probe,
1286 const std::string &index) {
1287 // resolve_device_() reassigns its `result` argument wholesale (via make_management_result()),
1288 // so it must write into its own object rather than the one already carrying probe_name/index --
1289 // matches probe_sweep()'s resolve_result pattern below.
1290 ManagementActionResult resolve_result;
1291 auto *dev = resolve_device_(MANAGEMENT_ACTION_PROBE_DEVICE, device_id, resolve_result);
1292 if (dev == nullptr) {
1293 resolve_result.probe_name = probe;
1294 resolve_result.probe_index = index;
1295 return resolve_result;
1296 }
1297
1298 ManagementActionResult result = resolve_result;
1299 result.probe_name = probe;
1300 result.probe_index = index;
1301
1302 if (!hub_->diagnostic_probes_enabled()) {
1303 result.message = "diagnostic probes are not enabled; set diagnostic_probes: true in YAML";
1304 result.terminal_refusal = true;
1305 return result;
1306 }
1307 // An unknown frame into a mid-transaction device state machine is the one avoidable way a
1308 // read-shaped probe could cause harm.
1309 if (!effective_is_stopped(*dev)) {
1310 result.message = "device is moving; refusing to probe mid-transaction";
1311 result.terminal_refusal = true;
1312 return result;
1313 }
1314
1315 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1316 if (descriptor == nullptr)
1317 return result;
1318
1319 uint8_t index_byte = 0;
1320 if (descriptor->needs_index && !parse_probe_index(index, index_byte)) {
1321 result.message = "index must be a decimal or 0x-prefixed byte value (0-255)";
1322 return result;
1323 }
1324
1325 IoFrame request;
1326 if (!descriptor->builder(request, node_id_, dev->node_id, index_byte, dev->low_power)) {
1327 result.message = "failed to build probe request";
1328 return result;
1329 }
1330
1331 IoFrame response;
1332 // A probe exists to read back a payload, so -- like a status poll or name read, and unlike a
1333 // bare CMD_EXECUTE -- SUCCESS_UNCONFIRMED (device accepted the request but sent nothing back)
1334 // does not satisfy the caller here.
1335 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
1337 engine_.log_debug(result.device_id.c_str());
1339 ? "device accepted the probe request but sent no response"
1340 : "no reply after " + std::to_string(EXCHANGE_RETRY_COUNT) +
1341 " attempts (device asleep, unreachable, or silently ignoring this opcode)";
1342 return result;
1343 }
1344
1345 // Report the raw reply, not an interpretation -- the whole point of a probe is that we do not
1346 // know what these bytes mean yet. This never touches update_device_status_(): ManagementActions
1347 // has no access to that protected hub method at all (see hub_core.h), so a probe reply can
1348 // never be misread as a position update.
1349 uint8_t raw[FRAME_MAX_SIZE] = {0};
1350 const uint8_t raw_len = serialize(response, raw, sizeof(raw));
1351 char hex[FRAME_LOG_HEX_BUFFER_SIZE];
1352 render_frame_hex_redacted(raw, raw_len, hex, sizeof(hex));
1353
1354 // Log through the same "io_capture" structured tag every other received frame uses
1355 // (log_component_capture(), log_helpers.h) rather than relying on IOHOME_FRAME_LOG -- that
1356 // build flag is opt-in (only set in the loopback/monitor configs under config/), and the
1357 // always-on receive path (process_received_packet_(), gated on !busy_) never sees a probe
1358 // reply at all, since the reply is consumed here, inside a blocking send_and_receive() call,
1359 // while busy_ is still true. Without this call a probe reply would only appear in the action's
1360 // hex message/event on a default build -- not a form scripts/corpus/ingest.py parses -- despite
1361 // the reply already having gone out over an authenticated, radio-verified exchange.
1362 detail::log_component_capture(hub_->get_radio(), "probe_rx", raw, raw_len, &response);
1363
1364 result.success = true;
1365 result.has_response_cmd = true;
1366 result.response_cmd = response.cmd;
1367 result.response_hex = hex;
1368 result.message = "probe \"" + probe + "\" reply cmd=0x" + format_hex_byte(response.cmd) + " (" +
1369 command_name(response.cmd) + ") hex=" + hex;
1370 // The status-reply table is a decoded part of the protocol, unlike the probe payload itself --
1371 // unlike the payload bytes, a result code is not something the probe exists to discover.
1372 if (response.cmd == CMD_ERROR_RESP && apply_error_response(response, result)) {
1373 result.message += " [" + std::string(command_result_name(result.result_code)) + ": " +
1375 }
1376 return result;
1377}
1378
1379void ManagementActions::api_probe_sweep(const std::string &device_id, const std::string &probe,
1380 const std::string &first_index, const std::string &last_index) {
1381 publish_result(probe_sweep(device_id, probe, first_index, last_index));
1382}
1383
1384ManagementActionResult ManagementActions::probe_sweep(const std::string &device_id, const std::string &probe,
1385 const std::string &first_index, const std::string &last_index) {
1386 ManagementActionResult result =
1387 make_management_result(MANAGEMENT_ACTION_PROBE_SWEEP, normalize_device_id_argument(device_id));
1388 result.probe_name = probe;
1389
1390 // Validate the device and the probe name once, up front. Neither can start succeeding partway
1391 // through a range: an unregistered device or an unrecognized probe name fails identically for
1392 // every index, so without this check the loop below would run the full span and append the
1393 // same error line up to PROBE_SWEEP_MAX_INDICES times.
1394 ManagementActionResult resolve_result;
1395 if (resolve_device_(MANAGEMENT_ACTION_PROBE_SWEEP, device_id, resolve_result) == nullptr) {
1396 result.message = resolve_result.message;
1397 return result;
1398 }
1399 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
1400 if (descriptor == nullptr)
1401 return result;
1402 if (!descriptor->needs_index) {
1403 result.message = "probe \"" + probe + "\" takes no index; use probe_device";
1404 return result;
1405 }
1406
1407 uint8_t first = 0;
1408 uint8_t last = 0;
1409 if (!parse_probe_index(first_index, first) || !parse_probe_index(last_index, last)) {
1410 result.message = "first_index/last_index must be decimal or 0x-prefixed byte values (0-255)";
1411 return result;
1412 }
1413 if (last < first) {
1414 result.message = "last_index must be >= first_index";
1415 return result;
1416 }
1417 const uint32_t span = static_cast<uint32_t>(last) - first + 1;
1418 if (span > PROBE_SWEEP_MAX_INDICES) {
1419 result.message =
1420 "sweep range too wide: " + std::to_string(span) + " indices, max " + std::to_string(PROBE_SWEEP_MAX_INDICES);
1421 return result;
1422 }
1423
1424 std::string report;
1425 uint32_t answered_count = 0;
1426 bool stopped_early = false;
1427 for (uint32_t idx = first; idx <= last; idx++) {
1428 if (idx != first) {
1429 App.feed_wdt();
1430 delay(PROBE_SWEEP_DELAY_MS);
1431 }
1432 const std::string index_str = std::to_string(idx);
1433 const ManagementActionResult step = probe_device(device_id, probe, index_str);
1434 report += "index=0x" + format_hex_byte(static_cast<uint8_t>(idx)) + ": ";
1435 if (step.success) {
1436 answered_count++;
1437 report += "cmd=0x" + format_hex_byte(step.response_cmd) + " hex=" + step.response_hex + "\n";
1438 } else {
1439 report += step.message + "\n";
1440 }
1441 // terminal_refusal (diagnostic probes not enabled, device moving) applies to every remaining
1442 // index the same way it applied to this one -- stop rather than repeat it N more times. A
1443 // structured flag, not a search over `step.message`: probe_device() sets it explicitly, so
1444 // rewording a refusal message can never silently break this check.
1445 if (step.terminal_refusal) {
1446 report += "(stopping sweep: " + step.message + ")\n";
1447 stopped_early = true;
1448 break;
1449 }
1450 }
1451
1452 // A sweep that ran its full requested range is a success even if nothing answered -- "no
1453 // device answered any index" is a valid, reportable outcome, the same philosophy
1454 // scan_paired_devices() uses for zero replies. A sweep cut short by a terminal refusal did not
1455 // complete what was asked of it and must not report success.
1456 result.success = !stopped_early;
1457 // Renders the same 0x-prefixed form as the report body, so the Home Assistant event carries the
1458 // swept range rather than a blank `index` field left over from the per-index probe_device()
1459 // results this loop discards.
1460 result.probe_index = "0x" + format_hex_byte(first) + "-0x" + format_hex_byte(last);
1461 result.message = "Sweep \"" + probe + "\" over [0x" + format_hex_byte(first) + ",0x" + format_hex_byte(last) +
1462 "]: " + std::to_string(answered_count) + " answered\n" + report;
1463 return result;
1464}
1465
1466} // namespace home_io_control
1467} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
IoDevice * get(const std::string &device_id)
Retrieve a registered device by ID.
uint8_t collect_broadcast_responses(const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms, const BroadcastReplyHandler &on_reply, ListenPolicy policy=ListenPolicy::ROTATE_SKIPPING_REQUEST)
Transmit request once and hand every matching broadcast reply to on_reply within window_ms.
The main IO-Homecontrol component.
Definition hub_core.h:91
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.
IO-Homecontrol ESPHome component — protocol controller.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
Hub-level management operations exposed as Home Assistant actions.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:31
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:83
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.
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
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.
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...
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.
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.
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", ...).
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.
static uint8_t count_enabled_passes(ScanPowerClasses selection)
How many of ROLL_CALL_PASSES selection actually calls.
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
DeviceNameValidationError
Validation result for outbound device-name writes.
constexpr float HEATING_TEMP_MIN_C
Lowest setpoint this codec will encode.
RollCallAttemptResult
Outcome of run_roll_call_attempt(), so the sweep loop can surface a build failure the same way scan_p...
@ BUILD_FAILED
create_discovery_request() failed; the caller should abort the whole scan.
@ OK
Request built and transmitted (with or without replies).
ListenPolicy
Which channels a listen covers.
@ ROTATE_SKIPPING_REQUEST
The two channels that are not the request channel.
@ HOLD_REQUEST_CHANNEL
Never retunes, never slices. Unicast replies.
std::string trim_ascii_whitespace(const std::string &value)
Trim leading and trailing ASCII whitespace from a string.
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...
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.
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 create_discovery_request(IoFrame &f, const uint8_t *own, uint8_t command, const uint8_t *dst, bool low_power, bool ack_capable, bool payload_enabled, uint8_t payload, const uint8_t *system_key)
Build a configurable discovery request command (0x28, 0x2A, or 0x2E).
static RollCallAttemptResult run_roll_call_attempt(ExchangeEngine &engine, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig &tuning, const RollCallPass &pass, uint32_t tx_freq_hz, uint8_t attempt, uint8_t total_attempts, ScanState &state)
Transmit one roll-call attempt and collect its replies into state.
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 void record_roll_call_reply(ScanState &state, const RollCallAttemptContext &ctx, const IoFrame &frame, const ExchangeEngine::BroadcastReplyInfo &info)
Record one accepted roll-call reply into the scan state, and log it.
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.
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 ScanAddResult add_scan_responder(ScanResponder *responders, uint8_t &count, uint8_t capacity, const IoFrame &frame, int16_t rssi_dbm, const ScanResponder *&entry)
Record a responder unless its address is already present.
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).
static std::string format_scan_reply_line(const ScanResponder &responder, const std::string &device_id, const IoDevice *registered)
Format one roll-call responder's report line(s).
static std::string build_scan_report(const ScanState &state, DeviceRegistry &registry, ScanPowerClasses selection)
Build the final report: known/unknown grouping, header, the selection NOTE, and the truncation NOTE.
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.
Per-reply facts collect_broadcast_responses() hands its caller alongside the frame.
uint32_t rx_freq_hz
Channel the reply was received on (RadioRxPacket::freq_hz).
int16_t rssi_dbm
RSSI of this reply (from the radio's last capture).
uint32_t after_tx_ms
Milliseconds from the request's transmit completing to this reply's delivery.
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).
bool low_power
YAML-declared: this target is a low-power / duty-cycled receiver, so directed frames set CTRL1_LOW_PO...
uint8_t subtype
Device subtype (manufacturer‑specific).
DeviceType type
Device type (shutter, awning, etc.).
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t data_len
Actual length of data.
uint8_t ctrl1
Control byte 1: low power, beacon, etc.
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.
Per-attempt facts a roll-call reply's DEBUG line needs, kept in one small struct so the reply lambda'...
uint32_t tx_freq_hz
Channel this attempt transmitted the request on.
uint8_t attempt
Global attempt number (1-based, across all passes).
Accumulated state for one scan_paired_devices() call, threaded through every attempt.
ScanResponder responders[SCAN_MAX_REPLIES]
All runtime tunable parameters for pairing and radio diagnostics.
uint16_t pairing_discovery_wait_ms
Total wait window after sending discovery commands.