Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
diagnostic_probes.cpp
Go to the documentation of this file.
1/// @file diagnostic_probes.cpp
2/// @brief Diagnostic probes: the probe table and frame builders, probe_device(), probe_sweep() and the
3/// data-transport read.
4/// @ingroup hioc_hub
5
6#include "diagnostic_probes.h"
7
8#include "entity_helpers.h"
9#include "log_helpers.h"
10#include "proto_commands.h"
11#include "proto_device_types.h"
13
14#include "esphome/core/application.h"
15#include "esphome/core/hal.h"
16
17#include <algorithm>
18#include <cerrno>
19#include <cstdio>
20#include <cstdlib>
21#include <cstring>
22#include <iterator>
23#include <limits>
24#include <string>
25
26namespace esphome {
27namespace home_io_control {
28
29namespace {
30
31// --- Diagnostic probe names (probe_device()/probe_sweep() `probe` argument) ---
32constexpr const char *PROBE_NAME_PRIVATE_FN = "private_fn"; ///< Q0: create_private_function().
33constexpr const char *PROBE_NAME_PRIVATE_FN_SUB = "private_fn_sub"; ///< CMD_PRIVATE fn 0x09, chosen second byte.
34constexpr const char *PROBE_NAME_STATUS_EXT = "status_ext"; ///< Q1: create_get_status_extended().
35constexpr const char *PROBE_NAME_STATUS_EXT_FN6 = "status_ext_fn6"; ///< Extended CMD_PRIVATE at function ID 0x06.
36constexpr const char *PROBE_NAME_STATUS_EXT_FN9 = "status_ext_fn9"; ///< Extended CMD_PRIVATE at function ID 0x09.
37constexpr const char *PROBE_NAME_GET_INFO1 = "get_info1"; ///< create_get_info1() (0x54, no payload).
38constexpr const char *PROBE_NAME_GET_INFO2 = "get_info2"; ///< create_get_info2() (0x56, no payload).
39constexpr const char *PROBE_NAME_GENERAL_INFO3 = "general_info3"; ///< Q2: create_general_info3().
40constexpr const char *PROBE_NAME_PRIVATE2 = "private2"; ///< Q3 long form: create_private2_read().
41constexpr const char *PROBE_NAME_PRIVATE2_SHORT = "private2_short"; ///< Q3 short form: create_private2_read().
42constexpr const char *PROBE_NAME_STATUS_MP_FP = "status_mp_fp"; ///< create_status_mp_fp_read() (no index).
43constexpr const char *PROBE_NAME_STATUS_FP_HIGH =
44 "status_fp_high"; ///< create_status_mp_fp_read() with FPI1 0x00 and the index as the FPI2 bitmap.
45constexpr const char *PROBE_NAME_LIMITATION =
46 "limitation"; ///< create_limitation_status_read(); index 0 = minimum, 1 = maximum.
47/// Highest `limitation` index: the two limit types of the KLF 200 API's get-limitation command
48/// (LimitationType numbering). The index is never the on-air selector byte.
49constexpr uint8_t PROBE_LIMITATION_MAX_INDEX = 1;
50constexpr uint8_t PROBE_LIMITATION_INDEX_MINIMUM = 0; ///< `limitation` index that reads the resulting minimum.
51constexpr const char *PROBE_NAME_SUBSCRIBE_EVENT =
52 "subscribe_event"; ///< create_subscribe_event(); index 0 = battery level event, 1 = local sensor event.
53constexpr const char *PROBE_NAME_READ_OBJECT =
54 "read_object"; ///< Data-transport read of one device object (ADR 0062); index = TRANSFER_READABLE_OBJECTS row.
55constexpr const char *PROBE_NAME_REGISTER_00 =
56 "register_00"; ///< Read of register 0x00<index> over CMD_WRITE_PRIVATE (read direction only).
57constexpr const char *PROBE_NAME_REGISTER_01 =
58 "register_01"; ///< Read of register 0x01<index> over CMD_WRITE_PRIVATE (read direction only).
59/// Bytes of a register request that a `0x21` answer repeats: `0C 60 <hi> <lo>`.
60constexpr uint8_t PROBE_REGISTER_HEADER_LENGTH = 4;
61constexpr uint8_t PROBE_REGISTER_HIGH_00 = 0x00; ///< High register byte of `register_00`; family untested.
62constexpr uint8_t PROBE_REGISTER_HIGH_01 =
63 0x01; ///< High register byte of `register_01`; the family climate support reads.
64/// Highest `read_object` index: the last row of TRANSFER_READABLE_OBJECTS.
65constexpr uint8_t PROBE_READ_OBJECT_MAX_INDEX = TRANSFER_READABLE_OBJECT_COUNT - 1;
66/// Pause before each block request and before the close. An RS100 Solar io missed every block request sent
67/// right after its previous answer and answered every re-send, which left about 650 ms after that answer
68/// (issue #156). Waiting that long first saves one long-preamble transmission per block.
69constexpr uint32_t PROBE_READ_OBJECT_BLOCK_GAP_MS = 650;
70/// Longest object `read_object` pulls: 24 blocks (432 bytes), the smallest whole-block cap that covers the
71/// longest length a real device has announced (431 bytes, issue #156). A device announcing more is closed again
72/// unread, so a misreading cannot turn into a long run of transmissions on the shared band. The cost: on that
73/// low-power device a 14-block read blocked the loop for 16.4 s, so 24 blocks take about 28 s nominally and, with
74/// up to 3 tries per block, can pass 60 s. The ESPHome API tolerates that (its server only drops a client after
75/// 150 s without traffic), and the probe is manual, one object per call.
76constexpr uint16_t PROBE_READ_OBJECT_MAX_BYTES = 24 * TRANSFER_BLOCK_SIZE;
77/// Data bytes per log line when `read_object` prints what it read (short enough for the log's line limit).
78constexpr uint8_t PROBE_READ_OBJECT_LOG_BYTES_PER_LINE = 24;
79/// "object 0xNNNN class NNN handle 0xNN status NNN length NNNNN" plus the terminator.
80constexpr size_t PROBE_READ_OBJECT_HEADER_BUFFER_SIZE = 72;
81/// "event 0xNNNN, NNN data bytes" plus the terminator.
82constexpr size_t PROBE_EVENT_HEADER_BUFFER_SIZE = 40;
83/// Highest `subscribe_event` index: one per allowlisted event id. The index is never the on-air event id.
84constexpr uint8_t PROBE_SUBSCRIBE_EVENT_MAX_INDEX = 1;
85constexpr uint8_t PROBE_SUBSCRIBE_EVENT_INDEX_BATTERY = 0; ///< `subscribe_event` index for EVENT_BATTERY_LEVEL_CHANGE.
86constexpr const char *PROBE_NAME_UNSUBSCRIBE_EVENT =
87 "unsubscribe_event"; ///< create_unsubscribe_event(); same index mapping as `subscribe_event`.
88/// Function IDs the extended-shape probes hold fixed. Production software elsewhere describes
89/// these two as a battery read; on real hardware (17 solar devices plus our own mains motors)
90/// the *short* 3-byte form at these IDs returned position-family values, never a charge value.
91/// The extended 4-byte shape at these IDs has never been sent by anything.
92constexpr uint8_t PROBE_EXT_FUNCTION_ID_06 = 0x06;
93constexpr uint8_t PROBE_EXT_FUNCTION_ID_09 = 0x09;
94/// Function ID `private_fn_sub` holds fixed while `index` walks the second payload byte.
95constexpr uint8_t PROBE_PRIVATE_FN_SUB_FUNCTION_ID = 0x09;
96/// Extended-CMD_PRIVATE selector "status_ext" probes: the field-observed, never-decoded 0x80.
97/// Selector 0x20 (tilt) already has a permanent builder/action (create_get_status_tilt()) and is
98/// not part of this probe.
99constexpr uint8_t PROBE_STATUS_EXT_SELECTOR = 0x80;
100/// Bound on probe_sweep()'s index range — this transmits on a shared ISM band to what may be a
101/// battery device; an unbounded sweep is antisocial and would keep the device awake needlessly.
102constexpr uint8_t PROBE_SWEEP_MAX_INDICES = 16;
103/// Spacing between probe_sweep() indices. Sweep steps are sequential and blocking, so they never
104/// overlap regardless of this value; it exists for ISM-band duty cycling and to give a battery
105/// device real idle time between reads.
106constexpr uint32_t PROBE_SWEEP_DELAY_MS = 1000;
107
108/// @brief Uniform wrapper signature every probe builder is adapted to, so PROBE_TABLE below can
109/// hold one function-pointer type regardless of each create_*() builder's own parameter list.
110/// `low_power` is the target device's YAML-declared power class, forwarded to each builder so a
111/// probe to an always-alive device is shaped like one — the diagnostic subsystem has to work on
112/// the very device class a silent-device investigation reaches for it.
113using ProbeBuilderFn = bool (*)(IoFrame &, const uint8_t *, const uint8_t *, uint8_t index, bool low_power);
114
115bool build_probe_private_fn(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
116 return create_private_function(f, own, dst, low_power, index);
117}
118bool build_probe_private_fn_sub(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
119 return create_private_function(f, own, dst, low_power, PROBE_PRIVATE_FN_SUB_FUNCTION_ID, index);
120}
121bool build_probe_status_ext(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
122 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index);
123}
124bool build_probe_status_ext_fn6(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
125 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index, PROBE_EXT_FUNCTION_ID_06);
126}
127bool build_probe_status_ext_fn9(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
128 return create_get_status_extended(f, own, dst, low_power, PROBE_STATUS_EXT_SELECTOR, index, PROBE_EXT_FUNCTION_ID_09);
129}
130bool build_probe_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
131 return create_get_info1(f, own, dst, low_power);
132}
133bool build_probe_get_info2(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
134 return create_get_info2(f, own, dst, low_power);
135}
136bool build_probe_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
137 return create_general_info3(f, own, dst, low_power);
138}
139bool build_probe_private2_long(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
140 return create_private2_read(f, own, dst, index, /*long_form=*/true, low_power);
141}
142bool build_probe_private2_short(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
143 return create_private2_read(f, own, dst, index, /*long_form=*/false, low_power);
144}
145bool build_probe_status_mp_fp(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t /*index*/, bool low_power) {
146 return create_status_mp_fp_read(f, own, dst, low_power);
147}
148/// `status_fp_high`: main parameter plus the FP9-FP16 selected by the index bitmap. The builder itself
149/// refuses an empty or oversized selection, so the probe stays safe without probe_device()'s index_valid gate.
150bool build_probe_status_fp_high(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
151 return create_status_mp_fp_read(f, own, dst, low_power, /*fpi1=*/0x00, /*fpi2=*/index);
152}
153
154/// True when `bitmap` selects between 1 and STATUS_MP_FP_PARAM_COUNT parameters.
155bool status_fp_high_bitmap_valid(uint8_t bitmap) {
156 uint8_t bits = 0;
157 for (uint8_t v = bitmap; v != 0; v >>= 1)
158 bits += v & 1U;
159 return bits >= 1 && bits <= STATUS_MP_FP_PARAM_COUNT;
160}
161
162bool build_probe_limitation(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
163 if (index > PROBE_LIMITATION_MAX_INDEX) {
164 return false; // Unreachable through probe_device()/probe_sweep(), which gate on max_index.
165 }
166 const LimitationType type =
167 index == PROBE_LIMITATION_INDEX_MINIMUM ? LimitationType::MINIMUM : LimitationType::MAXIMUM;
168 return create_limitation_status_read(f, own, dst, type, low_power);
169}
170
171/// Maps the `subscribe_event` index onto an allowlisted event id; the id itself is checked again by
172/// create_subscribe_event().
173bool build_probe_subscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
174 if (index > PROBE_SUBSCRIBE_EVENT_MAX_INDEX)
175 return false; // Unreachable through probe_device(), which gates on max_index.
176 const uint16_t event = index == PROBE_SUBSCRIBE_EVENT_INDEX_BATTERY ? EVENT_BATTERY_LEVEL_CHANGE : EVENT_LOCAL_SENSOR;
177 return create_subscribe_event(f, own, dst, event, low_power);
178}
179
180/// The `subscribe_event` index mapping, for the unsubscribe request; create_unsubscribe_event()
181/// checks the id again.
182bool build_probe_unsubscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
183 if (index > PROBE_SUBSCRIBE_EVENT_MAX_INDEX)
184 return false; // Unreachable through probe_device(), which gates on max_index.
185 const uint16_t event = index == PROBE_SUBSCRIBE_EVENT_INDEX_BATTERY ? EVENT_BATTERY_LEVEL_CHANGE : EVENT_LOCAL_SENSOR;
186 return create_unsubscribe_event(f, own, dst, event, low_power);
187}
188
189/// Maps the `read_object` index onto its TRANSFER_READABLE_OBJECTS row; create_transfer_open() checks the
190/// object against the table again.
191bool build_probe_read_object(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
192 if (index > PROBE_READ_OBJECT_MAX_INDEX)
193 return false; // Unreachable through probe_device(), which gates on max_index.
194 return create_transfer_open(f, own, dst, TRANSFER_READABLE_OBJECTS[index], low_power);
195}
196
197/// Shared body of the register probes: frames a read of register `<reg_hi><index>`. The payload comes from
198/// encode_heating_register_read(), which cannot produce the write direction, so no probe input can put a
199/// register write on the air (ADR 0063).
200bool build_probe_register_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t reg_hi, uint8_t index,
201 bool low_power) {
202 uint8_t payload[HEATING_PAYLOAD_MAX_SIZE];
203 const size_t payload_len = encode_heating_register_read(reg_hi, index, payload);
204 return create_write_private(f, own, dst, low_power, payload, payload_len);
205}
206
207bool build_probe_register_00(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
208 return build_probe_register_read(f, own, dst, PROBE_REGISTER_HIGH_00, index, low_power);
209}
210
211bool build_probe_register_01(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t index, bool low_power) {
212 return build_probe_register_read(f, own, dst, PROBE_REGISTER_HIGH_01, index, low_power);
213}
214
215/// @brief One row per probe_device()/probe_sweep() `probe` argument value.
216///
217/// A row names the fields it sets; every field after `sweepable` defaults to the plain case (an
218/// unbounded index that the probe needs, no register gate), so a row states only what it changes.
219/// Rows list their fields in declaration order (C++20 designated initializers).
220struct ProbeDescriptor {
221 // --- Required: no default, so a row that leaves one out fails the host build
222 // (-Wmissing-field-initializers under -Werror). ---
223 const char *name;
224 ProbeBuilderFn builder;
225 /// False for a probe that changes state in the device (ADR 0060), or whose index is a bitmap rather
226 /// than an ordinal: probe_sweep() refuses it. Required so a new state-changing probe is never swept
227 /// because its author forgot a line. It decides only for probes that take an index: probe_sweep()
228 /// refuses a probe without one through `needs_index` either way.
229 bool sweepable;
230
231 // --- Optional: a default stands for the plain case. ---
232 /// Wording for the rejection when an index exceeds `max_index` or fails `index_valid`. Set exactly when
233 /// the index is bounded or has a bitmap check (probe_table_consistent()); null for an unbounded index.
234 const char *index_help{nullptr};
235 bool needs_index{true}; ///< False for the probes whose builders take no index/selector ("general_info3",
236 ///< "get_info1", "get_info2", "status_mp_fp").
237 uint8_t max_index{UINT8_MAX}; ///< Highest index the probe accepts; the full byte range when `index_help` is null.
238 bool reads_blocks{false}; ///< True for `read_object`: an accepted first reply is followed by block reads.
239 /// True for the register probes: refused unless device_accepts_register_probe() holds for the target.
240 bool register_read{false};
241 /// Optional check of the parsed index beyond max_index (a bitmap index); false is reported with
242 /// index_out_of_range_message().
243 bool (*index_valid)(uint8_t){nullptr};
244};
245
246/// @brief The full set of probes probe_device()/probe_sweep() can dispatch to.
247///
248/// Single source of truth for probe names. Adding, removing, or renaming a probe is a one-line
249/// change here rather than a change to both probe_device()'s dispatch and its "index must be..."
250/// error message. Deliberately has no "unknown4a" row -- see ADR 0024.
251constexpr ProbeDescriptor PROBE_TABLE[] = {
252 {.name = PROBE_NAME_PRIVATE_FN, .builder = build_probe_private_fn, .sweepable = true},
253 {.name = PROBE_NAME_PRIVATE_FN_SUB, .builder = build_probe_private_fn_sub, .sweepable = true},
254 {.name = PROBE_NAME_STATUS_EXT, .builder = build_probe_status_ext, .sweepable = true},
255 {.name = PROBE_NAME_STATUS_EXT_FN6, .builder = build_probe_status_ext_fn6, .sweepable = true},
256 {.name = PROBE_NAME_STATUS_EXT_FN9, .builder = build_probe_status_ext_fn9, .sweepable = true},
257 {.name = PROBE_NAME_GET_INFO1, .builder = build_probe_get_info1, .sweepable = true, .needs_index = false},
258 {.name = PROBE_NAME_GET_INFO2, .builder = build_probe_get_info2, .sweepable = true, .needs_index = false},
259 {.name = PROBE_NAME_GENERAL_INFO3, .builder = build_probe_general_info3, .sweepable = true, .needs_index = false},
260 {.name = PROBE_NAME_PRIVATE2, .builder = build_probe_private2_long, .sweepable = true},
261 {.name = PROBE_NAME_PRIVATE2_SHORT, .builder = build_probe_private2_short, .sweepable = true},
262 {.name = PROBE_NAME_STATUS_MP_FP, .builder = build_probe_status_mp_fp, .sweepable = true, .needs_index = false},
263 // The FP9-FP16 twin of status_mp_fp: the index is the FPI2 bitmap, not an ordinal, so it is not swept.
264 {.name = PROBE_NAME_STATUS_FP_HIGH,
265 .builder = build_probe_status_fp_high,
266 .sweepable = false,
267 .index_help = "FP9-FP16 bitmap, bit 7 = FP9 ... bit 0 = FP16, at most 7 bits (e.g. 0x43 = FP10, FP15, FP16)",
268 .index_valid = status_fp_high_bitmap_valid},
269 {.name = PROBE_NAME_LIMITATION,
270 .builder = build_probe_limitation,
271 .sweepable = true,
272 .index_help = "index 0 (minimum) or 1 (maximum)",
273 .max_index = PROBE_LIMITATION_MAX_INDEX},
274 // The two probes that change device state (ADR 0060): one stores a subscription, the other is an
275 // untested guess at removing one. Hence the two-event allowlist behind the index, and no sweeping.
276 {.name = PROBE_NAME_SUBSCRIBE_EVENT,
277 .builder = build_probe_subscribe_event,
278 .sweepable = false,
279 .index_help = "index 0 (battery level event 0x2001) or 1 (local sensor event 0x2005)",
280 .max_index = PROBE_SUBSCRIBE_EVENT_MAX_INDEX},
281 {.name = PROBE_NAME_UNSUBSCRIBE_EVENT,
282 .builder = build_probe_unsubscribe_event,
283 .sweepable = false,
284 .index_help = "index 0 (battery level event 0x2001) or 1 (local sensor event 0x2005)",
285 .max_index = PROBE_SUBSCRIBE_EVENT_MAX_INDEX},
286 // The data-transport read (ADR 0062): a multi-frame session, so it is not swept; it changes nothing in
287 // the device but a transfer handle that the probe closes again.
288 {.name = PROBE_NAME_READ_OBJECT,
289 .builder = build_probe_read_object,
290 .sweepable = false,
291 .index_help = "index 0 (actuator capacity 0x8100), 1 (actuator state 0x8103), 2 (name record 0x8003), 3 (general "
292 "info 1, 0x8001), 4 (general info 2, 0x8002), or 5 to 29 (further objects, see the diagnostic "
293 "probes documentation)",
294 .max_index = PROBE_READ_OBJECT_MAX_INDEX,
295 .reads_blocks = true},
296 // The register reads (ADR 0063): one CMD_WRITE_PRIVATE frame in the read direction, for register-protocol
297 // heaters and domestic hot water devices only. Read-only, so they sweep like the other single-frame probes.
298 {.name = PROBE_NAME_REGISTER_00, .builder = build_probe_register_00, .sweepable = true, .register_read = true},
299 {.name = PROBE_NAME_REGISTER_01, .builder = build_probe_register_01, .sweepable = true, .register_read = true},
300};
301constexpr uint8_t PROBE_TABLE_SIZE = std::size(PROBE_TABLE);
302
303/// @brief True when every row has `index_help` exactly when its index is bounded below UINT8_MAX or has a
304/// bitmap check, so index_out_of_range_message() never has to describe an index without help text.
305consteval bool probe_table_consistent() {
306 return std::ranges::all_of(PROBE_TABLE, [](const ProbeDescriptor &row) {
307 const bool restricted = row.max_index < UINT8_MAX || row.index_valid != nullptr;
308 return restricted == (row.index_help != nullptr);
309 });
310}
311static_assert(probe_table_consistent(), "a probe row has index_help exactly when its index is restricted");
312
313/// @brief Look up a probe by name.
314/// @return Pointer into PROBE_TABLE, or nullptr if `probe` names none of its rows.
315const ProbeDescriptor *find_probe_descriptor(const std::string &probe) {
316 for (const auto &descriptor : PROBE_TABLE) {
317 if (probe == descriptor.name)
318 return &descriptor;
319 }
320 return nullptr;
321}
322
323/// @brief The error message probe_device()/probe_sweep() report for an unrecognized `probe`
324/// argument, built from PROBE_TABLE so the list of names can never drift out of sync with it.
325std::string unknown_probe_message(const std::string &probe) {
326 std::string names;
327 for (uint8_t i = 0; i < PROBE_TABLE_SIZE; i++) {
328 if (i > 0)
329 names += (i + 1 == PROBE_TABLE_SIZE) ? ", or " : ", ";
330 names += PROBE_TABLE[i].name;
331 }
332 return "unknown probe \"" + probe + "\" (expected " + names + ")";
333}
334
335/// @brief The error message for an index above a probe's max_index (probe_device() and probe_sweep()).
336std::string index_out_of_range_message(const ProbeDescriptor &descriptor) {
337 return "probe \"" + std::string(descriptor.name) + "\" takes " + descriptor.index_help;
338}
339
340/// @brief True when `index` is within the probe's max_index and passes its optional bitmap check.
341bool probe_index_in_range(const ProbeDescriptor &descriptor, uint8_t index) {
342 if (index > descriptor.max_index)
343 return false;
344 return descriptor.index_valid == nullptr || descriptor.index_valid(index);
345}
346
347/// @brief Shared probe-name lookup for probe_device()/probe_sweep(): returns the descriptor, or
348/// nullptr after writing the "unknown probe" message into @p result. The device-resolution and
349/// index-parse steps stay per-method: probe_device keeps the resolved device (for the moving
350/// check and node_id), probe_sweep discards it, and one parses a single index while the other
351/// parses a first/last range.
352const ProbeDescriptor *resolve_probe_descriptor(const std::string &probe, ManagementActionResult &result) {
353 const ProbeDescriptor *descriptor = find_probe_descriptor(probe);
354 if (descriptor == nullptr)
355 result.message = unknown_probe_message(probe);
356 return descriptor;
357}
358
359/// Space-separated hex of data[first, end).
360std::string hex_range(const uint8_t *data, uint16_t first, uint16_t end) {
361 std::string out;
362 for (uint16_t i = first; i < end; i++) {
363 if (i > first)
364 out += ' ';
365 out += format_hex_byte(data[i]);
366 }
367 return out;
368}
369
370/// @brief Parse a probe_device()/probe_sweep() index argument into a byte.
371///
372/// Accepts both a bare decimal string ("6") and a "0x"-prefixed hex string ("0x06") — the two
373/// shapes a Home Assistant user is likely to type when copying a value out of a captured frame
374/// dump. Rejects anything else (empty string, trailing garbage, out-of-range value) rather than
375/// silently defaulting to 0, since a wrong index silently sent as 0 would misrepresent what was
376/// actually probed.
377/// @param text Argument as received from the native API call.
378/// @param out Parsed byte on success; left untouched on failure.
379/// @return true if `text` is exactly one well-formed byte value.
380bool parse_probe_index(const std::string &text, uint8_t &out) {
381 if (text.empty())
382 return false;
383 // A leading "0" followed by another digit (e.g. "010") is valid octal to strtoul() below and
384 // would silently probe a different index than a user copying a byte value out of a hex dump
385 // intended -- reject it explicitly rather than relying on strtoul()'s octal parsing, which only
386 // rejects the invalid-octal-digit cases ("08", "09"), not the valid ones.
387 if (text.size() > 1 && text[0] == '0' && text[1] != 'x' && text[1] != 'X')
388 return false;
389 errno = 0;
390 // NOLINTNEXTLINE(misc-const-correctness): strtoul()/strtof() take a `char **` end pointer.
391 char *end = nullptr;
392 // Base 0: strtoul() itself recognizes a "0x"/"0X" prefix as hex and a bare "0" as decimal
393 // zero, which is exactly the "6" / "0x06" / "0" shape probes need to accept.
394 const uint32_t value = std::strtoul(text.c_str(), &end, 0);
395 if (end != text.c_str() + text.size())
396 return false;
397 if (errno == ERANGE || value > std::numeric_limits<uint8_t>::max())
398 return false;
399 out = static_cast<uint8_t>(value);
400 return true;
401}
402
403/// A register read means something only to a register-protocol device; a cover, light or lock has no such register
404/// and is not sent the frame. By kind, not by name: IoDevice has no manufacturer. Terminal, because the kind does
405/// not change partway through a sweep.
406bool register_probe_refused(const ProbeDescriptor &descriptor, const IoDevice &dev, const std::string &probe,
407 ManagementActionResult &result) {
408 if (!descriptor.register_read || device_accepts_register_probe(dev.type, dev.subtype))
409 return false;
410 result.message = "probe \"" + probe +
411 "\" reads registers and only applies to a register-protocol heater or a domestic hot water "
412 "device; this device is neither";
413 result.terminal_refusal = true;
414 return true;
415}
416
417/// Reports what a register probe drew back (nothing for any other probe). A `0x21` that repeats the request's `0C 60
418/// <hi> <lo>` header splits the value off it into `result.decoded` and the message; the bytes are not interpreted,
419/// because what a register holds is what the probe exists to find out. Always logs one line: device, register, outcome.
420void report_register_reply(const ProbeDescriptor &descriptor, const IoFrame &request, const IoFrame &response,
421 const std::string &probe, ManagementActionResult &result) {
422 if (!descriptor.register_read)
423 return;
424 const bool echoed = response.cmd == CMD_WRITE_PRIVATE_ACK && response.data_len >= PROBE_REGISTER_HEADER_LENGTH &&
425 std::memcmp(response.data, request.data, PROBE_REGISTER_HEADER_LENGTH) == 0;
426 if (echoed) {
427 std::string value;
428 for (uint8_t i = PROBE_REGISTER_HEADER_LENGTH; i < response.data_len; i++)
429 value += (i == PROBE_REGISTER_HEADER_LENGTH ? "" : " ") + format_hex_byte(response.data[i]);
430 result.decoded = "value: " + value;
431 result.message += " [" + result.decoded + "]";
432 }
433 ESP_LOGI(detail::TAG, "%s register 0x%02X%02X on %s: %s", probe.c_str(), request.data[2], request.data[3],
434 result.device_id.c_str(), echoed ? result.decoded.c_str() : command_name(response.cmd));
435}
436
437} // namespace
438
439DiagnosticProbes::DiagnosticProbes(const uint8_t *node_id, ExchangeEngine &engine, DeviceRegistry &registry,
440 const bool *initialized, DiagnosticProbesHub hub)
441 : node_id_(node_id), engine_(engine), registry_(registry), initialized_(initialized), hub_(std::move(hub)) {}
442
443ManagementActionResult DiagnosticProbes::probe_device(const std::string &device_id, const std::string &probe,
444 const std::string &index) {
445 // resolve_management_device() reassigns its `result` argument wholesale (via make_management_result()),
446 // so it must write into its own object rather than the one already carrying probe_name/index --
447 // matches probe_sweep()'s resolve_result pattern below.
448 ManagementActionResult resolve_result;
449 auto *dev =
450 resolve_management_device(MANAGEMENT_ACTION_PROBE_DEVICE, device_id, *initialized_, registry_, resolve_result);
451 if (dev == nullptr) {
452 resolve_result.probe_name = probe;
453 resolve_result.probe_index = index;
454 return resolve_result;
455 }
456
457 ManagementActionResult result = resolve_result;
458 result.probe_name = probe;
459 result.probe_index = index;
460
461 if (!hub_.probes_enabled()) {
462 result.message = "diagnostic probes are not enabled; set diagnostic_probes: true in YAML";
463 result.terminal_refusal = true;
464 return result;
465 }
466 // An unknown frame into a mid-transaction device state machine is the one avoidable way a
467 // read-shaped probe could cause harm.
468 if (!effective_is_stopped(*dev)) {
469 result.message = "device is moving; refusing to probe mid-transaction";
470 result.terminal_refusal = true;
471 return result;
472 }
473
474 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
475 if (descriptor == nullptr)
476 return result;
477
478 if (register_probe_refused(*descriptor, *dev, probe, result))
479 return result;
480
481 uint8_t index_byte = 0;
482 if (descriptor->needs_index && !parse_probe_index(index, index_byte)) {
483 result.message = "index must be a decimal or 0x-prefixed byte value (0-255)";
484 return result;
485 }
486 if (descriptor->needs_index && !probe_index_in_range(*descriptor, index_byte)) {
487 result.message = index_out_of_range_message(*descriptor);
488 return result;
489 }
490
491 IoFrame request;
492 if (!descriptor->builder(request, node_id_, dev->node_id, index_byte, dev->low_power)) {
493 result.message = "failed to build probe request";
494 return result;
495 }
496 if (request.cmd == CMD_SUBSCRIBE_EVENT && request.data_len == 3) {
497 ESP_LOGI(detail::TAG, "%s %s %s event 0x%02X%02X (diagnostic)",
498 request.data[0] == EVENT_SUBSCRIPTION_UNSUBSCRIBE ? "Unsubscribing" : "Subscribing",
499 result.device_id.c_str(), request.data[0] == EVENT_SUBSCRIPTION_UNSUBSCRIBE ? "from" : "to",
500 request.data[1], request.data[2]);
501 }
502
503 IoFrame response;
504 // A probe exists to read back a payload, so -- like a status poll or name read, and unlike a
505 // bare CMD_EXECUTE -- SUCCESS_UNCONFIRMED (device accepted the request but sent nothing back)
506 // does not satisfy the caller here.
507 const ExchangeOutcome outcome = engine_.send_and_receive(request, response, FREQ_CH2);
509 engine_.log_debug(result.device_id.c_str());
511 ? "device accepted the probe request but sent no response"
512 : "no reply after " + std::to_string(EXCHANGE_RETRY_COUNT) +
513 " attempts (device asleep, unreachable, or silently ignoring this opcode)";
514 return result;
515 }
516
517 // Report the raw reply, not an interpretation -- the whole point of a probe is that we do not
518 // know what these bytes mean yet. This never touches a device record's status: DiagnosticProbes
519 // holds no hub pointer, only the three DiagnosticProbesHub calls, so a probe reply can never be
520 // misread as a position update.
521 uint8_t raw[FRAME_MAX_SIZE] = {0};
522 const uint8_t raw_len = serialize(response, raw, sizeof(raw));
524 render_frame_hex_redacted(raw, raw_len, hex, sizeof(hex));
525
526 // Log through the same "io_capture" structured tag every other received frame uses
527 // (log_component_capture(), log_helpers.h) rather than relying on IOHOME_FRAME_LOG -- that
528 // build flag is opt-in (only set in the loopback/monitor configs under config/), and the
529 // always-on receive path (process_received_packet_(), gated on !busy_) never sees a probe
530 // reply at all, since the reply is consumed here, inside a blocking send_and_receive() call,
531 // while busy_ is still true. Without this call a probe reply would only appear in the action's
532 // hex message/event on a default build -- not a form scripts/corpus/ingest.py parses -- despite
533 // the reply already having gone out over an authenticated, radio-verified exchange.
534 detail::log_component_capture(hub_.radio(), "probe_rx", raw, raw_len, &response);
535
536 result.success = true;
537 result.has_response_cmd = true;
538 result.response_cmd = response.cmd;
539 result.response_hex = hex;
540 result.message = "probe \"" + probe + "\" reply cmd=0x" + format_hex_byte(response.cmd) + " (" +
541 command_name(response.cmd) + ") hex=" + hex;
542 // The status-reply table is a decoded part of the protocol, unlike the probe payload itself --
543 // unlike the payload bytes, a result code is not something the probe exists to discover.
544 if (response.cmd == CMD_ERROR_RESP && apply_error_response(response, result)) {
545 result.message += " [" + std::string(command_result_name(result.result_code)) + ": " +
547 }
548 // A device event report (the answer a `subscribe_event` probe draws) is the first report of the
549 // subscription: it goes on the "Last Device Event" sensor next to the later ones, and its header is
550 // named. The event data stays raw: it is what the probe exists to collect.
551 StatusUpdateEvent event;
552 if (hub_.record_reply_event(response) && decode_status_update_event(response, event)) {
553 char header[PROBE_EVENT_HEADER_BUFFER_SIZE];
554 snprintf(header, sizeof(header), "event 0x%04X, %u data bytes", static_cast<unsigned>(event.event_id),
555 static_cast<unsigned>(event.data_len));
556 result.decoded = header;
557 result.message += " [" + result.decoded + "]";
558 }
559 report_register_reply(*descriptor, request, response, probe, result);
560 // The data-transport read continues past its first answer; everything it reports is raw bytes.
561 if (descriptor->reads_blocks)
562 read_object_blocks_(*dev, response, TRANSFER_READABLE_OBJECTS[index_byte], result);
563 // The decode depends on the reply, not on which probe drew it, so any probe that happens to
564 // draw a limitation reply reports it. Rendered, never applied: the device record is untouched.
565 if (response.cmd == CMD_LIMITATION_STATUS_RESP) {
567 result.message += " [" + result.decoded + "]";
568 }
569 return result;
570}
571
572void DiagnosticProbes::read_object_blocks_(const IoDevice &dev, const IoFrame &open_reply, const TransferObject &object,
573 ManagementActionResult &result) {
574 const uint16_t object_id = object.id;
575 // Anything but an open answer (an error frame, say) is already reported by the caller.
576 if (open_reply.cmd != CMD_TRANSFER_OPEN_RESP)
577 return;
579 if (!decode_transfer_open_reply(open_reply, open)) {
580 result.message += " [not a well-formed transfer-open answer]";
581 return;
582 }
583 char head[PROBE_READ_OBJECT_HEADER_BUFFER_SIZE];
584 snprintf(head, sizeof(head), "object 0x%04X class %u handle 0x%02X status %u length %u",
585 static_cast<unsigned>(object_id), static_cast<unsigned>(object.object_class),
586 static_cast<unsigned>(open.handle), static_cast<unsigned>(open.status), static_cast<unsigned>(open.length));
587 result.decoded = head;
588 result.message += " [" + result.decoded + "]";
589 if (open.status == TRANSFER_STATUS_NOTHING_TO_READ && open.length == 0) {
590 result.message += " [object is empty (status 7)]";
591 return;
592 }
593 if (!open.ok()) {
594 result.message += " [device refused the read]";
595 return;
596 }
597 if (open.length == 0) {
598 result.message += " [object is empty]";
599 return;
600 }
601
602 // Close the transfer on every path past this point: the device holds a handle until told otherwise.
603 // Every block request and the close wait PROBE_READ_OBJECT_BLOCK_GAP_MS after the device's last answer.
604 const auto pause_before_request = []() {
605 App.feed_wdt();
606 delay(PROBE_READ_OBJECT_BLOCK_GAP_MS);
607 };
608 const auto close_transfer = [&]() {
609 IoFrame close_request;
610 IoFrame close_reply;
611 pause_before_request();
612 if (create_transfer_read(close_request, node_id_, dev.node_id, open.handle, TRANSFER_CHUNK_CLOSE, dev.low_power))
613 engine_.send_and_receive(close_request, close_reply, FREQ_CH2);
614 };
615 if (open.length > PROBE_READ_OBJECT_MAX_BYTES) {
616 close_transfer();
617 result.message += " [longer than " + std::to_string(PROBE_READ_OBJECT_MAX_BYTES) + " bytes, closed unread]";
618 return;
619 }
620
621 uint8_t data[PROBE_READ_OBJECT_MAX_BYTES] = {0};
622 uint16_t have = 0;
623 const uint16_t chunks = transfer_chunk_count(open.length);
624 bool complete = true;
625 for (uint16_t chunk = chunks; chunk >= 1; chunk--) {
626 IoFrame request;
627 IoFrame reply;
628 pause_before_request();
629 if (!create_transfer_read(request, node_id_, dev.node_id, open.handle, chunk, dev.low_power) ||
630 engine_.send_and_receive(request, reply, FREQ_CH2) != ExchangeOutcome::SUCCESS_WITH_RESPONSE) {
631 complete = false;
632 break;
633 }
634 const uint8_t *block = nullptr;
635 uint8_t block_len = 0;
636 // The blocks arrive counting down but their data runs forward; a block that echoes another chunk
637 // is a late answer and ends the read rather than being spliced in.
638 if (!decode_transfer_block(reply, open.handle, chunk, block, block_len) ||
639 have + block_len > PROBE_READ_OBJECT_MAX_BYTES) {
640 complete = false;
641 break;
642 }
643 std::memcpy(data + have, block, block_len);
644 have = static_cast<uint16_t>(have + block_len);
645 }
646 close_transfer();
647
648 const std::string hex = hex_range(data, 0, have);
649 result.response_hex = hex;
650 ESP_LOGI(detail::TAG, "read_object %s object 0x%04X class %u: %u of %u bytes%s", result.device_id.c_str(),
651 static_cast<unsigned>(object_id), static_cast<unsigned>(object.object_class), static_cast<unsigned>(have),
652 static_cast<unsigned>(open.length), complete ? "" : " (incomplete)");
653 // One log line per few bytes: the logger truncates long lines (512 bytes on hardware).
654 for (uint16_t i = 0; i < have; i += PROBE_READ_OBJECT_LOG_BYTES_PER_LINE) {
655 ESP_LOGI(detail::TAG, " +%03X: %s", static_cast<unsigned>(i),
656 hex_range(data, i, std::min<uint16_t>(have, i + PROBE_READ_OBJECT_LOG_BYTES_PER_LINE)).c_str());
657 }
658 result.message += (complete ? " data=" : " incomplete, data so far=") + hex;
659}
660
661ManagementActionResult DiagnosticProbes::probe_sweep(const std::string &device_id, const std::string &probe,
662 const std::string &first_index, const std::string &last_index) {
665 result.probe_name = probe;
666
667 // Validate the device and the probe name once, up front. Neither can start succeeding partway
668 // through a range: an unregistered device or an unrecognized probe name fails identically for
669 // every index, so without this check the loop below would run the full span and append the
670 // same error line up to PROBE_SWEEP_MAX_INDICES times.
671 ManagementActionResult resolve_result;
672 if (resolve_management_device(MANAGEMENT_ACTION_PROBE_SWEEP, device_id, *initialized_, registry_, resolve_result) ==
673 nullptr) {
674 result.message = resolve_result.message;
675 return result;
676 }
677 const ProbeDescriptor *descriptor = resolve_probe_descriptor(probe, result);
678 if (descriptor == nullptr)
679 return result;
680 if (!descriptor->sweepable) {
681 result.message = "probe \"" + probe + "\" cannot be swept; use probe_device";
682 return result;
683 }
684 if (!descriptor->needs_index) {
685 result.message = "probe \"" + probe + "\" takes no index; use probe_device";
686 return result;
687 }
688
689 uint8_t first = 0;
690 uint8_t last = 0;
691 if (!parse_probe_index(first_index, first) || !parse_probe_index(last_index, last)) {
692 result.message = "first_index/last_index must be decimal or 0x-prefixed byte values (0-255)";
693 return result;
694 }
695 if (last < first) {
696 result.message = "last_index must be >= first_index";
697 return result;
698 }
699 if (last > descriptor->max_index) {
700 result.message = index_out_of_range_message(*descriptor);
701 return result;
702 }
703 const uint32_t span = static_cast<uint32_t>(last) - first + 1;
704 if (span > PROBE_SWEEP_MAX_INDICES) {
705 result.message =
706 "sweep range too wide: " + std::to_string(span) + " indices, max " + std::to_string(PROBE_SWEEP_MAX_INDICES);
707 return result;
708 }
709
710 std::string report;
711 uint32_t answered_count = 0;
712 bool stopped_early = false;
713 for (uint32_t idx = first; idx <= last; idx++) {
714 if (idx != first) {
715 App.feed_wdt();
716 delay(PROBE_SWEEP_DELAY_MS);
717 }
718 const std::string index_str = std::to_string(idx);
719 const ManagementActionResult step = probe_device(device_id, probe, index_str);
720 report += "index=0x" + format_hex_byte(static_cast<uint8_t>(idx)) + ": ";
721 if (step.success) {
722 answered_count++;
723 report += "cmd=0x" + format_hex_byte(step.response_cmd) + " hex=" + step.response_hex;
724 if (!step.decoded.empty()) {
725 report += " [" + step.decoded + "]";
726 }
727 report += "\n";
728 } else {
729 report += step.message + "\n";
730 }
731 // terminal_refusal (diagnostic probes not enabled, device moving) applies to every remaining
732 // index the same way it applied to this one -- stop rather than repeat it N more times. A
733 // structured flag, not a search over `step.message`: probe_device() sets it explicitly, so
734 // rewording a refusal message can never silently break this check.
735 if (step.terminal_refusal) {
736 report += "(stopping sweep: " + step.message + ")\n";
737 stopped_early = true;
738 break;
739 }
740 }
741
742 // A sweep that ran its full requested range is a success even if nothing answered -- "no
743 // device answered any index" is a valid, reportable outcome, the same philosophy
744 // scan_paired_devices() uses for zero replies. A sweep cut short by a terminal refusal did not
745 // complete what was asked of it and must not report success.
746 result.success = !stopped_early;
747 // Renders the same 0x-prefixed form as the report body, so the Home Assistant event carries the
748 // swept range rather than a blank `index` field left over from the per-index probe_device()
749 // results this loop discards.
750 result.probe_index = "0x" + format_hex_byte(first) + "-0x" + format_hex_byte(last);
751 result.message = "Sweep \"" + probe + "\" over [0x" + format_hex_byte(first) + ",0x" + format_hex_byte(last) +
752 "]: " + std::to_string(answered_count) + " answered\n" + report;
753 return result;
754}
755
756} // namespace home_io_control
757} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
DiagnosticProbes(const uint8_t *node_id, ExchangeEngine &engine, DeviceRegistry &registry, const bool *initialized, DiagnosticProbesHub hub)
Construct with the collaborators a probe exchange needs.
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.
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.
Opt-in diagnostic probes: single frames and bounded sweeps for protocol research.
Conversions and renderers shared by the hub and its Home Assistant entities.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
std::string describe_limitation_reply(const IoFrame &frame)
Render a CMD_LIMITATION_STATUS_RESP frame for a log line or probe report.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:32
void log_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.
constexpr size_t HEATING_PAYLOAD_MAX_SIZE
Largest payload any function produces: a set of a 16-bit value (4-byte header + 2 value bytes).
bool create_write_private(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, const uint8_t *payload, size_t payload_len)
Build a generic CMD_WRITE_PRIVATE (0x20) frame around a caller-supplied payload — the one builder beh...
bool decode_status_update_event(const IoFrame &frame, StatusUpdateEvent &out)
Decode the event header of a CMD_STATUS_UPDATE.
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.
bool create_limitation_status_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, LimitationType type, bool low_power)
Build the limitation read (see the header for the evidence and the read-only guarantee).
bool create_transfer_open(IoFrame &f, const uint8_t *own, const uint8_t *dst, const TransferObject &object, bool low_power)
Build the transfer-open request (see the header for the allowlist and the byte layout).
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
bool create_get_info1(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_INFO1 (0x54) request. No payload. See proto_commands.h for the evidence note.
bool apply_error_response(const IoFrame &response, ManagementActionResult &result)
Decode a CMD_ERROR_RESP frame's result code into result.
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
bool create_general_info3(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power)
Build a CMD_GET_GENERAL_INFO3 (0x58) request.
std::string format_hex_byte(uint8_t value)
Format a byte as two uppercase hex digits, no prefix.
bool decode_transfer_open_reply(const IoFrame &f, TransferOpenReply &out)
Decode a transfer-open reply (layout verified against a VELUX KLR 200 copy session).
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:72
constexpr const char * MANAGEMENT_ACTION_PROBE_DEVICE
Native API action names of the two probe actions, shared with ManagementActions' registration table.
const char * command_result_name(uint8_t result)
Return a stable symbolic name for a CMD_ERROR_RESP result code.
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.
constexpr size_t FRAME_LOG_HEX_BUFFER_SIZE
Fits a full 32-byte frame rendered as spaced hex text.
Definition log_frame.h:20
bool create_unsubscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint16_t event_id, bool low_power)
Build the unsubscribe request (see the header: the action value is untested).
bool create_transfer_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint8_t handle, uint16_t chunk, bool low_power)
Build the transfer-read request (see the header: exactly handle and chunk, no data).
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
uint16_t transfer_chunk_count(uint16_t length)
Number of CMD_UNKNOWN4A_REQ chunks that cover length bytes: ceil(length / TRANSFER_BLOCK_SIZE).
bool create_status_mp_fp_read(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t fpi1, uint8_t fpi2)
Build the KLF-style status read of the main parameter plus functional parameters (see the header for ...
bool create_subscribe_event(IoFrame &f, const uint8_t *own, const uint8_t *dst, uint16_t event_id, bool low_power)
Build the subscribe request (see the header for the allowlist).
size_t encode_heating_register_read(uint8_t reg_hi, uint8_t reg_lo, uint8_t out[HEATING_PAYLOAD_MAX_SIZE])
Encode a read ("get") of an arbitrary register into a CMD_WRITE_PRIVATE (0x20) payload.
IoDevice * resolve_management_device(const char *action, const std::string &device_id, bool initialized, DeviceRegistry &registry, ManagementActionResult &result)
Shared preamble for every device-addressed action: validate the hub and device ID,...
bool 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 decode_transfer_block(const IoFrame &f, uint8_t handle, uint16_t chunk, const uint8_t *&out, uint8_t &out_len)
Decode one CMD_UNKNOWN4A_RESP data block.
bool create_get_status_extended(IoFrame &f, const uint8_t *own, const uint8_t *dst, bool low_power, uint8_t selector, uint8_t block, uint8_t function_id)
Build an extended CMD_PRIVATE (0x03) request with a selector/block pair — the shape real hubs use for...
LimitationType
On-air selector byte (request data[0]) choosing which resulting limit to read.
@ MINIMUM
Resulting minimum limit; rain protection raises it (a floor on the position value).
ManagementActionResult make_management_result(const std::string &action, const std::string &device_id)
A result that carries only the action name and the target device ID.
bool device_accepts_register_probe(DeviceType type, uint8_t subtype)
May the diagnostic register-read probes (register_00, register_01) be sent to this device?
std::string normalize_device_id_argument(const std::string &device_id)
Trim and uppercase a device-ID argument as received from the native API.
constexpr const char * MANAGEMENT_ACTION_PROBE_SWEEP
uint8_t serialize(const IoFrame &f, uint8_t *buf, uint8_t buf_size)
Serialize a parsed frame into a wire buffer (without CRC).
Command builders for the IO‑Homecontrol protocol.
The IO node-type and subtype table: names, entity class, platform and behaviour flags.
Heating profiles: what a climate card offers for each kind of heater, and how.
The only three things a probe needs from the hub, injected by ManagementActions.
Runtime state of a paired IO‑Homecontrol device.
bool low_power
YAML-declared: this target is a low-power / duty-cycled receiver, so directed frames set CTRL1_LOW_PO...
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t data_len
Actual length of data.
Result of a hub-level management action such as rename.
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.
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 decoded
Rendering of the probe reply for the replies this codebase can decode (currently a limitation reply,...
std::string probe_name
Probe name for probe_device()/probe_sweep(), e.g. "private_fn".
std::string response_hex
Probe reply's full raw wire hex, for pasting into scripts/corpus/ingest.py.
bool success
Whether the requested management action succeeded.
std::string message
Human-readable outcome summary.
The event header of a well-formed CMD_STATUS_UPDATE payload.
uint8_t data_len
Number of event-data bytes; equals the payload length minus the header.
uint16_t event_id
EVENT_STATUS_CHANGE for a position update, any other id for a device event report.
One object a CMD_TRANSFER_OPEN can name: its class byte and its 16-bit id.
One decoded CMD_TRANSFER_OPEN_RESP payload.
uint8_t status
data[1]; TRANSFER_STATUS_ACCEPTED / _ADAPTED, anything else is an error.
uint16_t length
data[2..3] big-endian: bytes available to read; 0 means nothing to read.
uint8_t handle
data[0]; the first byte of every CMD_UNKNOWN4A_REQ that follows.