Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_internal.h
Go to the documentation of this file.
1#pragma once
2
3/// @file hub_internal.h
4/// @brief Internal helpers shared by the hub implementation .cpp files.
5/// @ingroup hioc_hub
6///
7/// This header is intentionally private to the component implementation. It keeps
8/// small cross-file helpers in one place while leaving hub_core.h focused on the
9/// public component shape and the member-function declarations.
10
11#include "hub_core.h"
12#include "log_frame.h"
13
14#include "esphome/core/log.h"
15
16#include <algorithm>
17#include <array>
18#include <cctype>
19#include <cinttypes>
20#include <cmath>
21#include <cstdio>
22#include <cstring>
23#include <map>
24#include <string>
25#include <vector>
26
27namespace esphome {
28namespace home_io_control {
29namespace detail {
30
31// ============================================================================
32// Shared constants
33// ============================================================================
34
35inline constexpr const char *TAG = "home_io_control"; ///< Shared log tag for hub-level messages.
36/// Suppress a repeated 1W log/poll for the same remote *and the same intent* within this window.
37/// Wide on purpose: it collapses both the 4×/40ms reliability burst and a held button into one
38/// logical press. A *different* intent from the same remote (a stop after a move) is not a
39/// duplicate and passes through immediately — see decisions::is_duplicate_1w_frame().
40inline constexpr uint32_t ONEWAY_DEDUP_WINDOW_MS = 2000;
42 50.0F; ///< Shared 0-100 cutoff: values below this mean binary "on".
43
44// ============================================================================
45// Percent conversion helpers
46// ============================================================================
47
48/// @brief Convert a 0.0-1.0 HA fraction (position, tilt, or brightness) to a 0-100 IO percent.
49///
50/// Rounds rather than truncates: HA quantizes call values to 0-255 before they ever reach us, so
51/// its "50%" is 128/255=0.50196, not exactly 0.5 — a truncating cast compounds that quantization
52/// into a consistent ~1% bias, caught on real hardware in both platform_cover.cpp (position and
53/// tilt) and platform_light.cpp (brightness). Callers apply their own invert/complement logic
54/// (e.g. `1.0F - fraction`) before calling this; it only owns the rounding.
55/// @param fraction Value in [0.0, 1.0].
56/// @return Rounded 0-100 percent.
57inline uint8_t round_percent(float fraction) { return static_cast<uint8_t>(std::lround(fraction * 100.0F)); }
58
59// ============================================================================
60// Capability and entity-profile helpers
61// ============================================================================
62
63/// @brief Is the given position value an on/off binary encoding?
64/// @param position Position value to test.
65/// @return true if position equals BINARY_ENTITY_ON_POSITION or BINARY_ENTITY_OFF_POSITION.
66inline bool is_binary_entity_position(uint8_t position) {
67 return position == BINARY_ENTITY_ON_POSITION || position == BINARY_ENTITY_OFF_POSITION;
68}
69
70/// @brief Does the device's type match the expected HA entity class?
71/// UNKNOWN devices always match to keep imported/discovered devices working.
72/// @param dev IoDevice to check.
73/// @param expected Desired capability class (COVER, LIGHT, SWITCH, etc.).
74/// @return true if device type matches or is UNKNOWN.
76 return dev.type == DeviceType::UNKNOWN || device_capability_class(dev.type) == expected;
77}
78
79/// @brief Does the device support status requests?
80/// UNKNOWN devices pass through.
81/// @param dev IoDevice to check.
82/// @return true if device type supports status requests or is UNKNOWN.
86
87/// @brief Can this device accept an execute (position) command?
88/// Checks capability and, for unknown types, allows binary positions for light/switch.
89/// @param dev IoDevice to check.
90/// @param position Position value being sent.
91/// @return true if operation is appropriate for this device type.
92inline bool known_device_accepts_execute_position(const IoDevice &dev, uint8_t position) {
93 if (dev.type == DeviceType::UNKNOWN)
94 return true;
96 return true;
97 // Dimmable lights (platform_light.cpp's dimmable: true) send arbitrary 0-100 IO positions, not
98 // just the two binary extremes — accept the full range for LIGHT here and trust the entity
99 // layer to only ever send binary values for a non-dimmable light. SWITCH and LOCK have no
100 // continuous concept, so they stay restricted to the binary encoding below.
102 return position <= BINARY_ENTITY_OFF_POSITION;
103 return is_binary_entity_position(position) &&
105}
106
107/// @brief Can this device accept a tilt command?
108/// @param dev IoDevice to check.
109/// @return true only if device type is known to support tilt.
112}
113
114// ============================================================================
115// Logging helpers
116// ============================================================================
117
118/// @brief Log a rejected operation with capability mismatch details.
119/// @param device_id Device ID string.
120/// @param dev IoDevice that rejected the command.
121/// @param operation Human‑readable operation name (e.g., "set position").
122/// @param expected Expected capability class or profile name.
123inline void log_rejected_operation(const std::string &device_id, const IoDevice &dev, const char *operation,
124 const char *expected) {
125 ESP_LOGW(TAG, "Rejecting %s for device %s: type=%s (%u) class=%s profile=%s expected=%s", operation,
126 device_id.c_str(), device_type_name(dev.type), static_cast<uint8_t>(dev.type),
128}
129
130/// @brief Log a frame at the "io_capture" tag with structured fields.
131/// Used for protocol‑level debugging (phases: component, tx, rx, parse_ok/parse_fail).
132/// @param radio Radio driver instance (provides chip name and capture).
133/// @param stage String label for the current phase.
134/// @param buf Raw bytes being logged.
135/// @param len Length of buf.
136/// @param frame Optional parsed IoFrame for decoded fields (cmd, src, dst).
137inline void log_component_capture(const RadioDriver *radio, const char *stage, const uint8_t *buf, uint8_t len,
138 const IoFrame *frame = nullptr) {
139 const RadioCaptureInfo &capture = radio->get_last_capture();
140 char payload_hex[FRAME_LOG_HEX_BUFFER_SIZE];
141 // Masks the 0x32 key-transfer payload exactly like log_frame() (log_frame.h) — this path is
142 // separate from log_frame() and runs on every received frame, including a passively overheard
143 // pairing exchange between two other devices, so it must carry the same redaction guarantee.
144 render_frame_hex_redacted(buf, len, payload_hex, sizeof(payload_hex));
145 if (frame != nullptr) {
146 ESP_LOGD("io_capture",
147 "chip=%s phase=component stage=%s freq=%" PRIu32 " ts=%" PRIu32
148 " len=%u cmd=0x%02X src=%02X%02X%02X dst=%02X%02X%02X payload=%s",
149 radio->chip_name(), stage, capture.freq_hz, capture.timestamp_ms, len, frame->cmd, frame->src[0],
150 frame->src[1], frame->src[2], frame->dst[0], frame->dst[1], frame->dst[2], payload_hex);
151 return;
152 }
153 ESP_LOGD("io_capture", "chip=%s phase=component stage=%s freq=%" PRIu32 " ts=%" PRIu32 " len=%u payload=%s",
154 radio->chip_name(), stage, capture.freq_hz, capture.timestamp_ms, len, payload_hex);
155}
156
157/// @brief Log a frame‑level issue (unregistered endpoints, unsupported commands).
158/// @param component Pointer to the component (for device lookup).
159/// @param direction "tx" or "rx".
160/// @param reason Short issue label (e.g., "unregistered_device").
161/// @param frame Parsed frame.
162/// @param len Serialized length.
163inline void log_frame_issue(IOHomeControlComponent *component, const char *direction, const char *reason,
164 const IoFrame &frame, uint8_t len) {
165 const std::string src_id = node_id_to_string(frame.src);
166 const std::string dst_id = node_id_to_string(frame.dst);
167 const bool src_registered = component->get_device(src_id) != nullptr;
168 const bool dst_registered = component->get_device(dst_id) != nullptr;
169
170 if (src_registered || dst_registered) {
171 ESP_LOGW(TAG, "%s issue=%s cmd=%s(0x%02X) src=%s%s dst=%s%s len=%u data_len=%u", direction, reason,
172 command_name(frame.cmd), frame.cmd, src_id.c_str(), src_registered ? " (registered)" : "", dst_id.c_str(),
173 dst_registered ? " (registered)" : "", len, frame.data_len);
174 return;
175 }
176
177 ESP_LOGD(TAG, "%s issue=%s cmd=%s(0x%02X) src=%s dst=%s len=%u data_len=%u", direction, reason,
178 command_name(frame.cmd), frame.cmd, src_id.c_str(), dst_id.c_str(), len, frame.data_len);
179}
180
181// ============================================================================
182// 1W remote frame decode
183// ============================================================================
184
185/// @brief Log an already-decoded 1W remote frame at DEBUG level.
186///
187/// Formats a concise DEBUG log line showing remote ID, target type, command intent, and
188/// priority. When the remote is linked to devices, appends the linked device IDs. Takes the
189/// already-decoded OneWayFrameInfo so callers that also build a HA event (see
190/// build_sender_event_data()) decode the frame once, not twice.
191/// @param info Already-decoded 1W frame info (see decode_1w_frame()).
192/// @param linked_devices Optional pointer to device IDs this remote is linked to.
193inline void log_1w_remote_frame(const OneWayFrameInfo &info, const std::vector<std::string> *linked_devices = nullptr) {
194 const std::string src_id = node_id_to_string(info.src);
195
196 // Resolve the broadcast target label: "all" for BROADCAST_ALL, otherwise the device type name.
197 const char *target_label =
199
200 // Build optional suffix showing linked devices.
201 std::string suffix;
202 if (linked_devices != nullptr && !linked_devices->empty()) {
203 suffix = " (linked →";
204 for (const auto &dev_id : *linked_devices) {
205 suffix += ' ';
206 suffix += dev_id;
207 }
208 suffix += ')';
209 }
210
211 if (info.has_intent) {
212 ESP_LOGD(TAG, "rx 1W remote %s targets %s: %s(0x%02X) %s originator=%s priority=%s%s", src_id.c_str(), target_label,
213 command_name(info.cmd), info.cmd, info.intent, originator_name(info.originator),
214 acei_level_name(info.acei_level), suffix.c_str());
215 return;
216 }
217
218 ESP_LOGD(TAG, "rx 1W remote %s targets %s: %s(0x%02X) data_len=%u%s", src_id.c_str(), target_label,
219 command_name(info.cmd), info.cmd, info.data_len, suffix.c_str());
220}
221
222/// @brief Home Assistant event fired when a decoded 1W frame carries a command intent from an
223/// exposed sender (a physical remote button press, or a wind/rain sensor's triggered command).
224inline constexpr const char *ONEWAY_SENDER_EVENT = "esphome.home_io_control_sender_event";
225
226/// @brief Whether a 1W sender is on the `exposed_senders` allowlist for the sender HA event.
227///
228/// "Sender" covers both remotes and wind/rain sensors — they use the identical 1W broadcast
229/// mechanism and differ only in the `originator` byte inside the payload, not in addressing.
230/// Overheard 1W traffic is always DEBUG-logged regardless of this check (see
231/// log_1w_remote_frame()); this only gates whether the event reaches Home Assistant. Deliberately
232/// separate from `linked_devices` — a sender can be event-enabled without controlling any
233/// registered device (e.g. to trigger an HA automation with no matching cover/light/switch), or
234/// vice versa.
235/// @param exposed_senders Configured allowlist (`exposed_senders` YAML key, empty by default).
236/// @param sender_id Node ID of the 1W sender that sent the frame.
237/// @return true if the sender is in the allowlist.
238inline bool is_exposed_sender(const std::vector<std::string> &exposed_senders, const std::string &sender_id) {
239 return std::find(exposed_senders.begin(), exposed_senders.end(), sender_id) != exposed_senders.end();
240}
241
242/// Buffer size for format_name_and_hex(): longest command name plus "(0xXX)" and a margin.
243inline constexpr size_t NAME_AND_HEX_BUFFER_SIZE = 40;
244
245/// @brief Format a name/value pair as "name(0xXX)", e.g. "execute(0x00)".
246inline std::string format_name_and_hex(const char *name, uint8_t value) {
247 std::array<char, NAME_AND_HEX_BUFFER_SIZE> buffer{};
248 std::snprintf(buffer.data(), buffer.size(), "%s(0x%02X)", name, value);
249 return std::string(buffer.data());
250}
251
252/// Buffer size for describe_learned_device_type()'s hex fallback: "io_device_type: 0xXX" plus margin.
253inline constexpr size_t LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE = 24;
254
255/// @brief Build the YAML line to add once a device's type is learned at runtime.
256///
257/// Logged when `io_device_type` was left unset in YAML and an INFO2 response just resolved it
258/// for the first time this boot (ADR 0018: nothing persists, so this repeats on every reboot
259/// until the user copies the line in). Reuses yaml_device_type_name() so the exact syntax always
260/// matches what the pairing snippet and the Python schema accept.
261/// @param type The now-known device type. Must not be DeviceType::UNKNOWN.
262/// @return The YAML line to add, e.g. `io_device_type: "venetian_blind"` or `io_device_type: 0x11`.
263inline std::string describe_learned_device_type(DeviceType type) {
264 const char *name = yaml_device_type_name(type);
265 if (name != nullptr)
266 return std::string("io_device_type: \"") + name + "\"";
267 std::array<char, LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE> buffer{};
268 std::snprintf(buffer.data(), buffer.size(), "io_device_type: 0x%02X", static_cast<uint8_t>(type));
269 return std::string(buffer.data());
270}
271
272/// @brief Build the Home Assistant event data map for a decoded 1W sender frame.
273///
274/// Only meaningful when `info.has_intent` is true (the caller gates emission on that); the
275/// `intent` field is only populated by decode_1w_frame() in that case.
276/// @param info Already-decoded 1W frame info (see decode_1w_frame()).
277/// @param linked True if this sender is linked to at least one registered device.
278/// @return Event data map ready for fire_homeassistant_event().
279inline std::map<std::string, std::string> build_sender_event_data(const OneWayFrameInfo &info, bool linked) {
280 return {
281 {"remote_id", node_id_to_string(info.src)},
282 {"target_class", address_class_name(info.address_class)},
283 {"target_type", device_type_name(info.target_type)},
284 {"cmd", format_name_and_hex(command_name(info.cmd), info.cmd)},
285 {"intent", info.intent},
286 {"originator", originator_name(info.originator)},
287 {"acei_level", acei_level_name(info.acei_level)},
288 {"linked", linked ? "true" : "false"},
289 };
290}
291
292// ============================================================================
293// Key-material display formatting
294// ============================================================================
295
296/// @brief Format a 16-byte key as an uppercase, unseparated hex string for display.
297///
298/// The one deliberate place system-key bytes are formatted for display, shared by both
299/// key-recovery features so neither forks its own copy: 2W "Accept Foreign Pairing"
300/// (key_extraction_responder.cpp::KeyExtractionResponder::log_result_()) and 1W controller-key adoption
301/// (build_oneway_adoption_report() below). See redaction.h for the masking rules this
302/// intentionally does not apply to — both callers are the deliberate exception, not a loosening
303/// of it.
304/// @param key Pointer to AES_KEY_SIZE key bytes.
305/// @return Uppercase hex string, e.g. "0102030405060708090A0B0C0D0E0F10".
306inline std::string format_key_hex(const uint8_t key[AES_KEY_SIZE]) {
307 std::string out;
308 out.reserve(AES_KEY_SIZE * 2);
309 char byte_buf[3];
310 for (uint8_t i = 0; i < AES_KEY_SIZE; i++) {
311 snprintf(byte_buf, sizeof(byte_buf), "%02X", key[i]);
312 out += byte_buf;
313 }
314 return out;
315}
316
317/// @brief Log `prefix` followed by `message`, one line per log call rather than one call for the
318/// whole (possibly multi-line) string.
319///
320/// ESPHome formats each log call into a fixed 512-byte buffer (`ESPHOME_LOGGER_TX_BUFFER_SIZE`,
321/// esphome/core/defines.h) and silently truncates anything longer; a multi-line report (a YAML
322/// snippet plus explanatory prose) routinely exceeds that and truncates mid-line if logged as a
323/// single call — confirmed on real hardware for both call sites this function serves:
324/// `scan_paired_devices()`'s report (a multi-device report cut off mid-snippet) and 1W
325/// controller-key adoption's report (the recovered `system_key` line itself never made it into
326/// the log at all). Splitting by line keeps every individual call's payload small regardless of
327/// how long the full message is. Shared rather than duplicated a third time — a second private
328/// copy is exactly how the 1W path ended up with the bug this fixes.
329/// @param tag Log tag.
330/// @param is_warning True to log at WARN, false for INFO.
331/// @param prefix Prepended to the message's first line only (e.g. "Management action X: ").
332/// @param message Message to log; may contain embedded `\n` line breaks.
333inline void log_multiline_result(const char *tag, bool is_warning, const std::string &prefix,
334 const std::string &message) {
335 size_t start = 0;
336 bool first = true;
337 while (true) {
338 const size_t end = message.find('\n', start);
339 const std::string line = (end == std::string::npos) ? message.substr(start) : message.substr(start, end - start);
340 const std::string out = first ? prefix + line : line;
341 if (is_warning) {
342 ESP_LOGW(tag, "%s", out.c_str());
343 } else {
344 ESP_LOGI(tag, "%s", out.c_str());
345 }
346 first = false;
347 if (end == std::string::npos || end + 1 >= message.size())
348 break;
349 start = end + 1;
350 }
351}
352
353// ============================================================================
354// 1W controller-key adoption reporting
355// ============================================================================
356
357/// @brief Human-readable name for a decoded 0x30's MAC-verification outcome.
358/// @param status Outcome from decode_1w_add_controller() (see OneWayAdoptedKey::mac_status).
359/// @return Short uppercase-style label used in both the summary log line and the report below.
360inline const char *oneway_mac_status_name(OneWayMacStatus status) {
361 switch (status) {
363 return "VERIFIED";
365 return "FAILED";
367 default:
368 return "not present";
369 }
370}
371
372/// @brief Build the full 1W controller-key-adoption report: MAC-verification status, the
373/// own-address transmission rationale, and the ready-to-paste `oneway_controllers:` YAML block.
374///
375/// Pure — takes already-decoded values, performs no I/O — so it is directly unit-testable
376/// without a live radio or a captured log line (ESP_LOG's host stub discards its arguments).
377/// This is the single intentional place `adopted.system_key` is formatted for display (via
378/// format_key_hex() above); the caller (oneway_key_adoption.cpp) passes the returned text to
379/// one ESP_LOGW(...,"%s",...) call and nowhere else.
380///
381/// `node_id` is deliberately never mentioned as something to fill in — a later step derives one
382/// from the hub's own node ID, and the report says so rather than asking the user to invent a
383/// 3-byte address. The report also explains that the hub always transmits under its own address:
384/// impersonating the sender would hijack that remote's rolling sequence counter and break it.
385///
386/// The emitted keys must track `ONEWAY_CONTROLLER_SCHEMA` (`__init__.py`) by hand — a newly
387/// required schema key needs a matching line here too. `make yaml-emitter-sync`
388/// (scripts/check-yaml-emitters.py) catches drift between the two statically; it does not tell
389/// you what to add here.
390///
391/// @param adopted Decoded controller identity from decode_1w_add_controller() (proto_codecs.h).
392/// @param observed_type_known True if this sender's other 1W traffic was observed while armed
393/// (see OnewayKeyAdoption::record_observed_class()); false prints a commented-out
394/// fallback pointing at the DEBUG log line that would reveal it instead.
395/// @param observed_type The observed target class; only meaningful when observed_type_known.
396/// @return Multi-line report text, ready to pass straight to a single ESP_LOGW(...,"%s",...) call.
397inline std::string build_oneway_adoption_report(const OneWayAdoptedKey &adopted, bool observed_type_known,
398 DeviceType observed_type) {
399 std::string sender_hex_lower = node_id_to_string(adopted.sender_node);
400 std::transform(sender_hex_lower.begin(), sender_hex_lower.end(), sender_hex_lower.begin(),
401 [](unsigned char c) { return std::tolower(c); });
402 const std::string key_hex = format_key_hex(adopted.system_key);
403
404 std::string mac_line;
405 switch (adopted.mac_status) {
407 mac_line = "MAC VERIFIED: this frame's MAC checked out under the recovered key -- the strongest evidence "
408 "available on the spot that it is correct.";
409 break;
411 mac_line = "MAC FAILED: this frame's MAC did NOT check out under the recovered key -- it is probably wrong. "
412 "Re-arm and repeat the key-copy gesture closer to the hub.";
413 break;
415 default:
416 mac_line = "MAC not present: this frame carried no MAC trailer to verify against -- treat this key as "
417 "unconfirmed until tested.";
418 break;
419 }
420
421 // Fits " manufacturer: 0xNN" plus its terminator with room to spare.
422 constexpr size_t manufacturer_line_size = 32;
423 char manufacturer_line[manufacturer_line_size];
424 snprintf(manufacturer_line, sizeof(manufacturer_line), " manufacturer: 0x%02X",
425 static_cast<unsigned>(adopted.manufacturer));
426
427 std::string type_lines;
428 if (observed_type_known) {
429 type_lines = " io_device_type: " + format_device_type_for_yaml(observed_type) +
430 " # observed from this sender's traffic; verify\n";
431 } else {
432 type_lines = " # io_device_type: unknown -- no other 1W traffic was observed from this sender while armed;\n"
433 " # check the DEBUG \"rx 1W remote ...\" log line once you see this sender transmit again.\n";
434 }
435
436 return mac_line +
437 "\nThe hub always transmits under its own node_id, never the sender's -- copying the sender's address "
438 "would hijack its rolling sequence counter and break its existing remote.\n"
439 "Copy the block below into your hub's YAML.\n"
440 "oneway_controllers:\n"
441 " # node_id omitted -> derived from your hub node_id; see the boot log\n"
442 " - id: adopted_" +
443 sender_hex_lower + "\n" + " system_key: \"" + key_hex + "\"\n" + manufacturer_line + "\n" + type_lines +
444 " commands: [open, close, stop]";
445}
446
447/// @brief Build the ready-to-paste 2W system-key-extraction report: `node_id:`/`system_key:` as a
448/// `home_io_control:` YAML block.
449///
450/// Pure — takes already-decoded values, performs no I/O — so it is directly unit-testable without
451/// a live radio, mirroring build_oneway_adoption_report() above; the two features end up sharing
452/// report *structure* as well as format_key_hex(). The caller (key_extraction_responder.cpp) logs the
453/// result through log_multiline_result() and nowhere else — this is the single intentional place
454/// the recovered `system_key` is formatted for display, a deliberate exception to redaction.h's
455/// masking.
456///
457/// The emitted keys must track the hub's own `CONFIG_SCHEMA` (`__init__.py`) by hand. `make
458/// yaml-emitter-sync` (scripts/check-yaml-emitters.py) catches drift between the two by
459/// cross-referencing this function's emitted key names against that schema statically.
460/// @param node_id Recovered hub node_id, 3 bytes.
461/// @param key Recovered system key, 16 bytes.
462/// @return Multi-line report text, ready to pass to log_multiline_result().
463inline std::string build_key_extraction_report(const uint8_t node_id[NODE_ID_SIZE], const uint8_t key[AES_KEY_SIZE]) {
464 return "SYSTEM KEY EXTRACTED -- DO NOT SHARE YOUR SYSTEM KEY\n"
465 "Anyone with this key and node_id can control every device on this installation.\n"
466 "This exchange has not been independently confirmed against your specific hub -- test\n"
467 "this key (e.g. by controlling a device with it) before relying on it.\n"
468 "Copy the block below into a new hub's YAML.\n"
469 "home_io_control:\n"
470 " node_id: \"" +
471 node_id_to_string(node_id) + "\"\n" + " system_key: \"" + format_key_hex(key) + "\"";
472}
473
474// ============================================================================
475// Status normalization helpers
476// ============================================================================
477
478/// @brief Normalize stopped state: some devices briefly report stopped before target/current converge.
479///
480/// Deliberately reads and writes observed fields only — never effective_*(). Its job is "the device
481/// said stopped but its own reported target and current disagree", a statement about observations;
482/// feeding it a prediction would push a guess back into an observed field. On the execute-ack path
483/// (`trust_position = false`) `dev.target` does not hold the commanded value, so this function does
484/// not use it to flip `is_stopped` back to false — effective_is_stopped() owns that.
485/// @param dev Device record to update (may clear is_stopped if positions differ).
487 // Some devices briefly report STATUS_STOPPED before current and target have numerically
488 // converged. Keep the device in the moving state until the decoded values are effectively equal.
489 if (dev.is_stopped && dev.target != UNKNOWN_POSITION && dev.position != UNKNOWN_POSITION &&
491 dev.is_stopped = false;
492 }
493}
494
495/// @brief Update per-device link-health stats from the radio's last capture.
496///
497/// Called for every frame whose `src` is a registered device, regardless of command type or
498/// whether that command's own payload was well-formed — any such frame is real evidence the
499/// device is reachable and at this signal strength. The two call sites cover both ways such a
500/// frame arrives: update_device_status_() (inbound status path) and
501/// execute_request_and_update_()'s explicit-refusal branch (a CMD_ERROR_RESP reply to our own
502/// request, which returns before reaching the status path). Always stamps `last_seen_ms`; only
503/// touches the RSSI fields when `radio` is non-null and its last capture is valid (real drivers
504/// always populate a valid capture before a frame is handed off, but tests calling this path
505/// directly without a radio, or without exercising RX through it, must not crash or fabricate
506/// an RSSI).
507/// @param dev Device that sent the frame.
508/// @param radio Radio driver to read the last capture from; may be nullptr.
509inline void update_link_health(IoDevice &dev, RadioDriver *radio) {
510 dev.last_seen_ms = millis();
511 if (radio == nullptr)
512 return;
513
514 const RadioCaptureInfo &capture = radio->get_last_capture();
515 if (!capture.valid)
516 return;
517
518 dev.last_rssi_dbm = capture.rssi_dbm;
520 // First sample: seed the EMA directly instead of blending from 0.
521 dev.rssi_ema_scaled = static_cast<int16_t>(capture.rssi_dbm * RSSI_EMA_SCALE);
522 return;
523 }
524 // Integer EMA kept in fixed point: `S += x − round(S/N)` blends each sample at weight 1/N
525 // while S stays scaled by N, so sub-dBm contributions accumulate instead of truncating to
526 // zero — a whole-dBm EMA would stall as soon as |sample − EMA| < N and never converge.
527 dev.rssi_ema_scaled =
528 static_cast<int16_t>(dev.rssi_ema_scaled + capture.rssi_dbm - rssi_scaled_to_dbm(dev.rssi_ema_scaled));
529}
530
531/// @brief Record that an outbound exchange to this device timed out (no valid response).
532///
533/// Called once per failed exchange from execute_request_and_update_()'s "no valid response"
534/// branch — the single place every device-directed exchange already reads
535/// ExchangeEngine::DebugInfo. Both counters saturate at UINT16_MAX instead of wrapping,
536/// matching PairingTelemetry's counters.
537/// @param dev Device the failed exchange was addressed to.
538/// @param tries Number of attempts the failed exchange made (`DebugInfo::tries`, 1-based).
539inline void record_exchange_timeout(IoDevice &dev, uint8_t tries) {
540 if (dev.exchange_timeout_count < UINT16_MAX)
543 static_cast<uint16_t>(std::min<uint32_t>(static_cast<uint32_t>(dev.exchange_attempt_count) + tries, UINT16_MAX));
544}
545
546/// @brief Offset of the last-command record within each status-bearing payload.
547///
548/// Both status-bearing frame types carry the same 4-byte record — three bytes of node ID for the
549/// controller that last commanded the device, then that command's Command Originator byte — and
550/// 0x71's whole payload is shifted +3 relative to 0x04's, exactly as its target/current position
551/// fields already are (PRIVATE_RESPONSE_TARGET_OFFSET vs STATUS_UPDATE_TARGET_OFFSET in
552/// hub_status.cpp). Confirmed on one device in one session across both frame types:
553/// tests/corpus/captures/statuspoll/somfy_rs100_statuspoll_kig300_sx1276.yaml, device E461E9,
554/// which names controller BE FE DB at 0x04 data[8..10] and 0x71 data[11..13] in the same capture —
555/// including in a 0x71 addressed to a *different* controller, which is what rules out "this is
556/// just the destination echoed back".
557inline constexpr uint8_t PRIVATE_RESPONSE_LAST_COMMAND_OFFSET = 8;
558inline constexpr uint8_t STATUS_UPDATE_LAST_COMMAND_OFFSET = 11;
559
560/// @brief Offset of the Command Originator byte in a CMD_STATUS_UPDATE (0x71) payload.
561///
562/// Deliberately not offset 1: `data[1]` on a 0x71 is the status byte (0x60/0x61, bit 0 = current
563/// position unknown), which matches no ORIGINATOR_* value, so reading it as an originator rendered
564/// "unknown" on every frame this project has ever captured. Every captured 0x71 carries 0x01
565/// (ORIGINATOR_USER_REMOTE) here.
566inline constexpr uint8_t STATUS_UPDATE_ORIGINATOR_OFFSET = 14;
568 "the Command Originator byte is the fourth byte of the last-command record; if one "
569 "offset moves the other must move with it");
570
571/// @brief One decoded last-command record.
573 uint8_t commander[NODE_ID_SIZE]{}; ///< Controller that last commanded the device.
574 uint8_t originator{0}; ///< That command's Command Originator (ORIGINATOR_*).
575 bool valid{false}; ///< False when the payload was too short, or the record was unpopulated.
576};
577
578/// @brief Decode the last-command record at `base` from a status-bearing payload.
579///
580/// Pure rather than inlined into update_device_status_() so it is testable against corpus bytes
581/// directly. An all-zero commander is reported as invalid: 00 00 00 is not a node ID any observed
582/// controller uses, so a device that pads this field rather than implementing it publishes nothing
583/// instead of a fabricated address.
584/// @param frame A CMD_PRIVATE_RESP or CMD_STATUS_UPDATE frame.
585/// @param base PRIVATE_RESPONSE_LAST_COMMAND_OFFSET or STATUS_UPDATE_LAST_COMMAND_OFFSET.
586/// @return The decoded record, or `valid == false`.
587inline LastCommandRecord decode_last_command_record(const IoFrame &frame, uint8_t base) {
588 LastCommandRecord record;
589 if (frame.data_len < base + NODE_ID_SIZE + 1)
590 return record;
591 memcpy(record.commander, &frame.data[base], NODE_ID_SIZE);
592 if (record.commander[0] == 0 && record.commander[1] == 0 && record.commander[2] == 0)
593 return record;
594 record.originator = frame.data[base + NODE_ID_SIZE];
595 record.valid = true;
596 return record;
597}
598
599/// @brief Store a decoded record on the device, if it is valid.
600///
601/// A short or unpopulated payload leaves whatever was last learned in place rather than clearing
602/// it: the record is inherently last-writer-wins and only refreshes when something commands the
603/// device, so a stale value is the honest answer and a blanked one is not.
604inline void apply_last_command_record(IoDevice &dev, const LastCommandRecord &record) {
605 if (!record.valid)
606 return;
607 memcpy(dev.last_commander, record.commander, NODE_ID_SIZE);
609 dev.has_last_command = true;
610}
611
612/// @brief Render the "Last Commanded By" sensor string.
613///
614/// Always leads with the raw node ID — that is the diagnostic value, and the only thing a user can
615/// match against a remote they own. The qualifier is additive, never a substitute: a device naming
616/// its own ID is NOT reliably "the button on the motor" (the one non-shutter this project has data
617/// on, a mains gate, names its own ID with an undefined originator), so the cause belongs to the
618/// separate originator sensor, not to this one's wording.
619/// @param dev Device record to read.
620/// @param hub_node_id This hub's own 3-byte node ID.
621/// @return e.g. "3B74DC", "C0FFEE (this hub)", "2FE2D2 (this device)"; empty before the first record.
622inline std::string describe_last_commander(const IoDevice &dev, const uint8_t *hub_node_id) {
623 if (!dev.has_last_command)
624 return {};
625 std::string out = node_id_to_string(dev.last_commander);
626 if (memcmp(dev.last_commander, hub_node_id, NODE_ID_SIZE) == 0) {
627 out += " (this hub)";
628 } else if (memcmp(dev.last_commander, dev.node_id, NODE_ID_SIZE) == 0) {
629 out += " (this device)";
630 }
631 return out;
632}
633
634/// @brief Render the "Last Command Source" sensor string.
635///
636/// Uses the same "name(0xXX)" rendering as describe_status_update_originator(), so a byte with no
637/// ORIGINATOR_* case reads "unknown(0x0A)" rather than being silently dropped or mislabelled. The
638/// decode is field-validated for roller shutters (a clean 0x00/0x01 split, remote vs. motor
639/// button); gates, lights and multi-channel units are not validated and are expected to surface
640/// undecoded values here — which is the point of keeping the raw hex in the string.
641/// @param dev Device record to read.
642/// @return e.g. "user_remote(0x01)"; empty before the first record.
643inline std::string describe_last_command_source(const IoDevice &dev) {
644 if (!dev.has_last_command)
645 return {};
647}
648
649/// @brief Describe a 0x71 status update's Command Originator as "name(0xXX)".
650///
651/// Pure rather than inlined into the log line it feeds, so the offset is testable: ESP_LOG* is a
652/// no-op stub in host tests, which makes the rendered line itself unobservable.
653/// @param frame A CMD_STATUS_UPDATE frame.
654/// @return "name(0xXX)", e.g. "user_remote(0x01)", or an empty string when the payload is too
655/// short to carry the byte. Shorter frames still carry usable position data — the branch
656/// gate is STATUS_UPDATE_MIN_DATA_LEN (11) — they just have no originator to report.
657inline std::string describe_status_update_originator(const IoFrame &frame) {
659 return {};
660 const uint8_t originator = frame.data[STATUS_UPDATE_ORIGINATOR_OFFSET];
661 return format_name_and_hex(originator_name(originator), originator);
662}
663
664/// @brief Describe the hub's live optimistic predictions where they disagree with the observation.
665///
666/// Pure rather than inlined into log_status_update() so it is testable (ESP_LOG* is a no-op stub in
667/// host tests). log_status_update() runs from the inbound frame handlers and reports what the device
668/// said; a hub-side prediction must never be substituted into those observed fields, so it is
669/// appended as a clearly-labelled annotation instead. Only terms that actually differ are rendered —
670/// a prediction that merely confirms the observation adds nothing.
671/// @param dev Device record to read.
672/// @return e.g. " [predicted: target=100% stopped]", or an empty string when no prediction stands
673/// or every prediction agrees with what the device reported.
674inline std::string describe_prediction(const IoDevice &dev) {
675 const bool target_differs = effective_target(dev) != dev.target;
676 const bool motion_differs = effective_is_stopped(dev) != dev.is_stopped;
677 if (!target_differs && !motion_differs)
678 return {};
679 std::string out = " [predicted:";
680 if (target_differs)
681 out += " target=" + format_position(effective_target(dev));
682 if (motion_differs)
683 out += effective_is_stopped(dev) ? " stopped" : " moving";
684 out += "]";
685 return out;
686}
687
688/// @brief Log a concise status‑update line used by inbound handlers.
689///
690/// The position/target/motion fields report what the device observed — never a hub prediction. A
691/// diverging live prediction is appended by describe_prediction(), clearly labelled, not merged in.
692/// @param id Device ID.
693/// @param dev Current device state.
694/// @param suffix Optional suffix added after the state string (e.g., " (status update)").
695inline void log_status_update(const std::string &id, const IoDevice &dev, const char *suffix = "") {
696 ESP_LOGI(TAG, "Device %s: position=%s target=%s %s%s%s", id.c_str(), format_position(dev.position).c_str(),
697 format_position(dev.target).c_str(), dev.is_stopped ? "stopped" : "moving", describe_prediction(dev).c_str(),
698 suffix);
699}
700
701/// @brief Log a decoded CMD_ERROR_RESP result with optional request-command context.
702/// @param id Device ID.
703/// @param result Result byte from CMD_ERROR_RESP data[0].
704/// @param request_cmd Original outbound request command when known.
705/// @param include_request_cmd True to include request_cmd in the log line.
706inline void log_command_result(const std::string &id, uint8_t result, uint8_t request_cmd = 0,
707 bool include_request_cmd = false) {
708 const char *kind = is_limitation_result(result) ? "limitation" : "error";
709 if (include_request_cmd) {
710 ESP_LOGW(TAG, "Device %s: %s (0x%02X) returned %s result=0x%02X %s (%s)", id.c_str(), command_name(request_cmd),
711 request_cmd, kind, result, command_result_name(result), command_result_description(result));
712 return;
713 }
714
715 ESP_LOGW(TAG, "Device %s: explicit %s result=0x%02X %s (%s)", id.c_str(), kind, result, command_result_name(result),
717}
718
719/// @brief Store a decoded CMD_ERROR_RESP result on the device and log it.
720///
721/// Single place both CMD_ERROR_RESP call sites (the unsolicited status path and the reply to
722/// our own EXECUTE) route through, so the store-and-log policy cannot drift between them. Does
723/// not notify subscribers itself — callers already call notify_device_update_() once per
724/// handled frame; call it after this.
725/// @param dev Device that returned the result.
726/// @param id Device ID (for the log line).
727/// @param result Result byte from CMD_ERROR_RESP data[0].
728/// @param request_cmd Original outbound request command when known.
729/// @param include_request_cmd True to include request_cmd context in the log line.
730inline void record_command_result(IoDevice &dev, const std::string &id, uint8_t result, uint8_t request_cmd = 0,
731 bool include_request_cmd = false) {
732 dev.last_result_code = result;
733 dev.last_result_at_ms = millis();
734 log_command_result(id, result, request_cmd, include_request_cmd);
735}
736
737/// @brief Clear a previously recorded CMD_ERROR_RESP result, if any.
738///
739/// A stale limitation reason (e.g. a rain lockout from an hour ago) is worse than none once the
740/// device has since replied normally, so every successful status/command reply for a device
741/// clears it. Called from the CMD_PRIVATE_RESP and CMD_STATUS_UPDATE branches of
742/// update_device_status_() — not from CMD_GET_NAME_RESP/CMD_GET_INFO2_RESP, which are metadata
743/// lookups unrelated to whether the device's last movement command succeeded. Like
744/// record_command_result(), does not notify subscribers itself — both existing call sites clear
745/// before their own notify_device_update_() call, which is what actually publishes this change.
746/// @param dev Device to clear.
748 dev.last_result_code = 0;
749 dev.last_result_at_ms = 0;
750}
751
752} // namespace detail
753} // namespace home_io_control
754} // namespace esphome
The main IO-Homecontrol component.
Definition hub_core.h:90
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:313
Abstract radio driver for IO-Homecontrol.
const RadioCaptureInfo & get_last_capture() const
Get the most recent radio capture info.
virtual const char * chip_name() const =0
Get a human‑readable chip name.
IO-Homecontrol ESPHome component — protocol controller.
Shared frame logging helpers for IO-Homecontrol.
bool known_device_accepts_execute_tilt(const IoDevice &dev)
Can this device accept a tilt command?
bool known_device_matches_entity_class(const IoDevice &dev, DeviceCapabilityClass expected)
Does the device's type match the expected HA entity class?
void log_rejected_operation(const std::string &device_id, const IoDevice &dev, const char *operation, const char *expected)
Log a rejected operation with capability mismatch details.
bool known_device_accepts_execute_position(const IoDevice &dev, uint8_t position)
Can this device accept an execute (position) command?
std::string format_name_and_hex(const char *name, uint8_t value)
Format a name/value pair as "name(0xXX)", e.g. "execute(0x00)".
std::map< std::string, std::string > build_sender_event_data(const OneWayFrameInfo &info, bool linked)
Build the Home Assistant event data map for a decoded 1W sender frame.
constexpr const char * TAG
Shared log tag for hub-level messages.
constexpr size_t NAME_AND_HEX_BUFFER_SIZE
Buffer size for format_name_and_hex(): longest command name plus "(0xXX)" and a margin.
constexpr uint8_t PRIVATE_RESPONSE_LAST_COMMAND_OFFSET
Offset of the last-command record within each status-bearing payload.
std::string describe_learned_device_type(DeviceType type)
Build the YAML line to add once a device's type is learned at runtime.
constexpr float BINARY_ENTITY_ON_POSITION_THRESHOLD
Shared 0-100 cutoff: values below this mean binary "on".
bool known_device_supports_status_requests(const IoDevice &dev)
Does the device support status requests?
void apply_last_command_record(IoDevice &dev, const LastCommandRecord &record)
Store a decoded record on the device, if it is valid.
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_1w_remote_frame(const OneWayFrameInfo &info, const std::vector< std::string > *linked_devices=nullptr)
Log an already-decoded 1W remote frame at DEBUG level.
void record_exchange_timeout(IoDevice &dev, uint8_t tries)
Record that an outbound exchange to this device timed out (no valid response).
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.
std::string describe_status_update_originator(const IoFrame &frame)
Describe a 0x71 status update's Command Originator as "name(0xXX)".
std::string describe_last_command_source(const IoDevice &dev)
Render the "Last Command Source" sensor string.
bool is_exposed_sender(const std::vector< std::string > &exposed_senders, const std::string &sender_id)
Whether a 1W sender is on the exposed_senders allowlist for the sender HA event.
constexpr const char * ONEWAY_SENDER_EVENT
Home Assistant event fired when a decoded 1W frame carries a command intent from an exposed sender (a...
void update_link_health(IoDevice &dev, RadioDriver *radio)
Update per-device link-health stats from the radio's last capture.
uint8_t round_percent(float fraction)
Convert a 0.0-1.0 HA fraction (position, tilt, or brightness) to a 0-100 IO percent.
constexpr uint8_t STATUS_UPDATE_LAST_COMMAND_OFFSET
std::string build_key_extraction_report(const uint8_t node_id[NODE_ID_SIZE], const uint8_t key[AES_KEY_SIZE])
Build the ready-to-paste 2W system-key-extraction report: node_id:/system_key: as a home_io_control: ...
std::string format_key_hex(const uint8_t key[AES_KEY_SIZE])
Format a 16-byte key as an uppercase, unseparated hex string for display.
void normalize_stopped_state(IoDevice &dev)
Normalize stopped state: some devices briefly report stopped before target/current converge.
bool is_binary_entity_position(uint8_t position)
Is the given position value an on/off binary encoding?
constexpr uint32_t ONEWAY_DEDUP_WINDOW_MS
Suppress a repeated 1W log/poll for the same remote and the same intent within this window.
const char * oneway_mac_status_name(OneWayMacStatus status)
Human-readable name for a decoded 0x30's MAC-verification outcome.
void log_status_update(const std::string &id, const IoDevice &dev, const char *suffix="")
Log a concise status‑update line used by inbound handlers.
constexpr uint8_t STATUS_UPDATE_ORIGINATOR_OFFSET
Offset of the Command Originator byte in a CMD_STATUS_UPDATE (0x71) payload.
constexpr size_t LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE
Buffer size for describe_learned_device_type()'s hex fallback: "io_device_type: 0xXX" plus margin.
std::string describe_last_commander(const IoDevice &dev, const uint8_t *hub_node_id)
Render the "Last Commanded By" sensor string.
void log_frame_issue(IOHomeControlComponent *component, const char *direction, const char *reason, const IoFrame &frame, uint8_t len)
Log a frame‑level issue (unregistered endpoints, unsupported commands).
std::string describe_prediction(const IoDevice &dev)
Describe the hub's live optimistic predictions where they disagree with the observation.
void clear_command_result(IoDevice &dev)
Clear a previously recorded CMD_ERROR_RESP result, if any.
void log_command_result(const std::string &id, uint8_t result, uint8_t request_cmd=0, bool include_request_cmd=false)
Log a decoded CMD_ERROR_RESP result with optional request-command context.
LastCommandRecord decode_last_command_record(const IoFrame &frame, uint8_t base)
Decode the last-command record at base from a status-bearing payload.
void record_command_result(IoDevice &dev, const std::string &id, uint8_t result, uint8_t request_cmd=0, bool include_request_cmd=false)
Store a decoded CMD_ERROR_RESP result on the device and log it.
std::string build_oneway_adoption_report(const OneWayAdoptedKey &adopted, bool observed_type_known, DeviceType observed_type)
Build the full 1W controller-key-adoption report: MAC-verification status, the own-address transmissi...
const char * device_operation_profile_name(DeviceType type)
Human‑readable operation profile name for a device type.
static constexpr float UNKNOWN_POSITION
Sentinel value meaning "position is not known yet".
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
std::string format_device_type_for_yaml(DeviceType type)
Build the YAML value for a device's io_device_type key.
OneWayMacStatus
Outcome of checking the out-of-length MAC trailer (IoFrame::has_mac) on a decoded CMD_ONEWAY_ADD_CONT...
@ VERIFIED
frame.has_mac was true and the MAC verified under the recovered key.
@ NOT_PRESENT
frame.has_mac was false; nothing to verify.
@ FAILED
frame.has_mac was true and the MAC did NOT verify under the recovered key.
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
@ UNKNOWN
Unknown/unspecified device.
DeviceCapabilityClass device_capability_class(DeviceType type)
Map a raw IO‑Homecontrol type to the closest ESPHome/Home Assistant entity family.
float effective_target(const IoDevice &dev)
The main-position target a consumer should act on: the prediction when one stands,...
bool device_supports_position_control(DeviceType type)
Does this device type support precise position control (0–100)?
const char * command_name(uint8_t cmd)
Get a human-readable name for any IO-Homecontrol command ID.
int16_t rssi_scaled_to_dbm(int16_t scaled)
Convert an rssi_ema_scaled fixed-point value to whole dBm (round half away from zero).
const char * device_type_name(DeviceType type)
Convert a DeviceType to a lowercase string identifier.
bool device_supports_binary_control(DeviceType type)
Does this device type support binary on/off control?
std::string format_position(float pos)
Format a position float as a human‑readable string (e.g.
Definition hub_core.h:1157
bool device_supports_lock_control(DeviceType type)
Does this device type support binary lock/unlock control via execute commands?
const char * command_result_description(uint8_t result)
Return a human-readable explanation for a CMD_ERROR_RESP result code.
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.
constexpr size_t FRAME_LOG_HEX_BUFFER_SIZE
Fits a full 32-byte frame rendered as spaced hex text.
Definition log_frame.h:19
static constexpr uint8_t BINARY_ENTITY_ON_POSITION
Position value written for binary ON commands (light on, switch on, lock unlock).
const char * device_capability_class_name(DeviceType type)
Get a human‑readable name for a capability class.
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
const char * acei_level_name(uint8_t level)
Get a human-readable name for an ACEI priority level (0–7).
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
static constexpr int16_t RSSI_EMA_SCALE
EMA weight denominator and fixed-point scale for IoDevice::rssi_ema_scaled.
@ BROADCAST_ALL
Broadcast to all devices of a type (address suffix 0x3F).
const char * address_class_name(AddressClass address_class)
Get a human-readable name for an address classification.
bool device_supports_status_requests(DeviceType type)
Does this device type support status request commands (0x03)?
bool has_reached_target_position(float target, float position)
Has the device reached its target within tolerance?
static constexpr uint8_t AES_KEY_SIZE
AES-128 key size.
Definition proto_sizes.h:23
const char * yaml_device_type_name(DeviceType type)
Return the YAML-friendly device-type name for types exposed in the Python schema.
DeviceCapabilityClass
High‑level capability class derived from DeviceType.
static constexpr int16_t RSSI_UNKNOWN_DBM
Sentinel value meaning "no RSSI sample recorded yet" for last_rssi_dbm/rssi_ema_scaled.
bool is_limitation_result(uint8_t result)
Check whether a result code represents an environmental or control limitation.
const char * originator_name(uint8_t originator)
Get a human-readable name for a command originator byte.
static constexpr uint8_t BINARY_ENTITY_OFF_POSITION
Position value written for binary OFF commands (light off, switch off, lock lock).
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
Runtime state of a paired IO‑Homecontrol device.
uint32_t last_result_at_ms
millis() timestamp of last_result_code, 0 when none recorded.
float target
Target position the device is moving toward.
uint16_t exchange_attempt_count
Cumulative attempts (ExchangeEngine::DebugInfo::tries, 1-based per exchange) across those timed-out e...
uint8_t last_result_code
Last CMD_ERROR_RESP result byte (0 = none recorded).
uint8_t last_commander[NODE_ID_SIZE]
Node ID of the controller that last commanded this device, as reported verbatim by the device in its ...
uint8_t last_command_originator
That command's Command Originator byte (ORIGINATOR_* in proto_constants.h).
int16_t last_rssi_dbm
Most recent raw RSSI sample (dBm), or RSSI_UNKNOWN_DBM.
float position
Current position: 0=open, 100=closed, or UNKNOWN_POSITION.
uint32_t last_seen_ms
millis() of the last frame received from this device (any command), 0 = never.
int16_t rssi_ema_scaled
Smoothed RSSI as fixed point in 1/RSSI_EMA_SCALE dBm — read through device_rssi_ema_dbm(),...
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
bool has_last_command
True once a status reply carried a well-formed last-command record.
DeviceType type
Device type (shutter, awning, etc.).
uint16_t exchange_timeout_count
Cumulative count of outbound exchanges to this device with no valid response (see detail::record_exch...
bool is_stopped
True if device is not moving.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:88
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:94
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:92
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:91
uint8_t data_len
Actual length of data.
Definition proto_frame.h:95
Recovered controller identity from a decoded CMD_ONEWAY_ADD_CONTROLLER (0x30) frame.
uint8_t manufacturer
Manufacturer ID byte from the payload (man_id).
OneWayMacStatus mac_status
MAC-verification outcome; see OneWayMacStatus.
uint8_t system_key[AES_KEY_SIZE]
Recovered network system key (crypto::crypt_1w_key() output).
uint8_t sender_node[NODE_ID_SIZE]
Sender's node address (frame.src) — the new identity's node.
Decoded representation of a 1W remote frame.
bool has_intent
True if originator/ACEI/intent fields were decoded.
uint8_t originator
Command originator byte (e.g., ORIGINATOR_USER_REMOTE).
DeviceType target_type
Target device class from broadcast address.
uint8_t acei_level
ACEI priority level (0–7).
uint8_t cmd
Command ID (e.g., CMD_EXECUTE, CMD_ACTIVATE_MODE).
AddressClass address_class
Classification of the broadcast address.
char intent[ONEWAY_INTENT_BUFFER_SIZE]
Human-readable command intent (e.g., "CLOSE").
uint8_t src[NODE_ID_SIZE]
Remote source node ID (3 bytes).
uint8_t data_len
Raw data length (for commands without decoded intent).
Diagnostic capture from a radio operation.
uint32_t timestamp_ms
Timestamp of capture (millis).
bool valid
True if capture is valid.
uint32_t freq_hz
RF frequency of capture (Hz).
int16_t rssi_dbm
Received signal strength (dBm).
uint8_t commander[NODE_ID_SIZE]
Controller that last commanded the device.
uint8_t originator
That command's Command Originator (ORIGINATOR_*).
bool valid
False when the payload was too short, or the record was unpopulated.