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 Helpers shared only by the hub's own implementation files (`hub_*.cpp`).
5/// @ingroup hioc_hub
6///
7/// Collaborators and entities include log_helpers.h / entity_helpers.h instead; `make
8/// include-graph` enforces this. Keeping these helpers here leaves hub_core.h focused on the
9/// public component shape and the member-function declarations.
10
11#include "hub_core.h"
12#include "entity_helpers.h"
13#include "log_frame.h"
14#include "log_helpers.h"
15#include "proto_codecs.h"
16
17#include "esphome/core/log.h"
18
19#include <algorithm>
20#include <array>
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
35/// Suppress a repeated 1W log/poll for the same remote *and the same intent* within this window.
36/// Wide on purpose: it collapses both the 4×/40ms reliability burst and a held button into one
37/// logical press. A *different* intent from the same remote (a stop after a move) is not a
38/// duplicate and passes through immediately — see decisions::is_duplicate_1w_frame().
39inline constexpr uint32_t ONEWAY_DEDUP_WINDOW_MS = 2000;
40
41// ============================================================================
42// Capability and entity-profile helpers
43// ============================================================================
44
45/// @brief Is the given position value an on/off binary encoding?
46/// @param position Position value to test.
47/// @return true if position equals BINARY_ENTITY_ON_POSITION or BINARY_ENTITY_OFF_POSITION.
48inline bool is_binary_entity_position(uint8_t position) {
49 return position == BINARY_ENTITY_ON_POSITION || position == BINARY_ENTITY_OFF_POSITION;
50}
51
52/// @brief Does the device's type match the expected HA entity class?
53/// UNKNOWN devices always match to keep imported/discovered devices working.
54/// @param dev IoDevice to check.
55/// @param expected Desired capability class (COVER, LIGHT, SWITCH, etc.).
56/// @return true if device type matches or is UNKNOWN.
58 return dev.type == DeviceType::UNKNOWN || device_capability_class(dev.type) == expected;
59}
60
61/// @brief Does the device support status requests?
62/// UNKNOWN devices pass through.
63/// @param dev IoDevice to check.
64/// @return true if device type supports status requests or is UNKNOWN.
68
69/// @brief Can this device accept an execute (position) command?
70/// Checks capability and, for unknown types, allows binary positions for light/switch.
71/// @param dev IoDevice to check.
72/// @param position Position value being sent.
73/// @return true if operation is appropriate for this device type.
74inline bool known_device_accepts_execute_position(const IoDevice &dev, uint8_t position) {
75 if (dev.type == DeviceType::UNKNOWN)
76 return true;
78 return true;
79 // Dimmable lights (platform_light.cpp's dimmable: true) send arbitrary 0-100 IO positions, not
80 // just the two binary extremes — accept the full range for LIGHT here and trust the entity
81 // layer to only ever send binary values for a non-dimmable light. SWITCH and LOCK have no
82 // continuous concept, so they stay restricted to the binary encoding below.
84 return position <= BINARY_ENTITY_OFF_POSITION;
85 return is_binary_entity_position(position) &&
87}
88
89/// @brief Can this device accept a tilt command?
90/// @param dev IoDevice to check.
91/// @return true only if device type is known to support tilt.
94}
95
96// ============================================================================
97// Logging helpers
98// ============================================================================
99
100/// @brief Log a rejected operation with capability mismatch details.
101/// @param device_id Device ID string.
102/// @param dev IoDevice that rejected the command.
103/// @param operation Human‑readable operation name (e.g., "set position").
104/// @param expected Expected capability class or profile name.
105inline void log_rejected_operation(const std::string &device_id, const IoDevice &dev, const char *operation,
106 const char *expected) {
107 ESP_LOGW(TAG, "Rejecting %s for device %s: type=%s (%u) class=%s profile=%s expected=%s", operation,
108 device_id.c_str(), device_type_name(dev.type), static_cast<uint8_t>(dev.type),
110}
111
112/// @brief Log a frame‑level issue (unregistered endpoints, unsupported commands).
113/// @param component Pointer to the component (for device lookup).
114/// @param direction "tx" or "rx".
115/// @param reason Short issue label (e.g., "unregistered_device").
116/// @param frame Parsed frame.
117/// @param len Serialized length.
118inline void log_frame_issue(IOHomeControlComponent *component, const char *direction, const char *reason,
119 const IoFrame &frame, uint8_t len) {
120 const std::string src_id = node_id_to_string(frame.src);
121 const std::string dst_id = node_id_to_string(frame.dst);
122 const bool src_registered = component->get_device(src_id) != nullptr;
123 const bool dst_registered = component->get_device(dst_id) != nullptr;
124
125 if (src_registered || dst_registered) {
126 ESP_LOGW(TAG, "%s issue=%s cmd=%s(0x%02X) src=%s%s dst=%s%s len=%u data_len=%u", direction, reason,
127 command_name(frame.cmd), frame.cmd, src_id.c_str(), src_registered ? " (registered)" : "", dst_id.c_str(),
128 dst_registered ? " (registered)" : "", len, frame.data_len);
129 return;
130 }
131
132 ESP_LOGD(TAG, "%s issue=%s cmd=%s(0x%02X) src=%s dst=%s len=%u data_len=%u", direction, reason,
133 command_name(frame.cmd), frame.cmd, src_id.c_str(), dst_id.c_str(), len, frame.data_len);
134}
135
136// ============================================================================
137// 1W remote frame decode
138// ============================================================================
139
140/// @brief Log an already-decoded 1W remote frame at DEBUG level.
141///
142/// Formats a concise DEBUG log line showing remote ID, destination class, command intent, and
143/// priority. When the remote is linked to devices, appends the linked device IDs. Takes the
144/// already-decoded OneWayFrameInfo so callers that also build a HA event (see
145/// build_sender_event_data()) decode the frame once, not twice.
146/// @param info Already-decoded 1W frame info (see decode_1w_frame()).
147/// @param linked_devices Optional pointer to device IDs this remote is linked to.
148inline void log_1w_remote_frame(const OneWayFrameInfo &info, const std::vector<std::string> *linked_devices = nullptr) {
149 const std::string src_id = node_id_to_string(info.src);
150
151 // Resolve the broadcast target label: "all" for BROADCAST_ALL, otherwise the device type name.
152 // Shared with the TX path (OneWayTransmitter::send_burst()) via oneway_target_label() so the two
153 // cannot render the same address differently.
154 //
155 // Rendered as `dst-class=`, never "targets X": this is the class encoded in the frame's
156 // destination, not a statement about the device the remote drives. A Somfy remote sends every
157 // press twice, once to the device-class broadcast and once to the all-devices broadcast,
158 // whatever the channel actually controls — so an awning channel logs a light-class frame. Worded
159 // as "targets light" it read as a claim about the device, and cost an issue reporter two rounds
160 // chasing a channel mix-up that never happened.
161 const char *target_label = oneway_target_label(info);
162
163 // Build optional suffix showing linked devices.
164 std::string suffix;
165 if (linked_devices != nullptr && !linked_devices->empty()) {
166 suffix = " (linked →";
167 for (const auto &dev_id : *linked_devices) {
168 suffix += ' ';
169 suffix += dev_id;
170 }
171 suffix += ')';
172 }
173
174 if (info.has_intent) {
175 ESP_LOGD(TAG, "rx 1W remote %s dst-class=%s: %s(0x%02X) %s originator=%s priority=%s%s", src_id.c_str(),
176 target_label, command_name(info.cmd), info.cmd, info.intent, originator_name(info.originator),
177 acei_level_name(info.acei_level), suffix.c_str());
178 return;
179 }
180
181 ESP_LOGD(TAG, "rx 1W remote %s dst-class=%s: %s(0x%02X) data_len=%u%s", src_id.c_str(), target_label,
182 command_name(info.cmd), info.cmd, info.data_len, suffix.c_str());
183}
184
185/// @brief Home Assistant event fired when a decoded 1W frame carries a command intent from an
186/// exposed sender (a physical remote button press, or a wind/rain sensor's triggered command).
187inline constexpr const char *ONEWAY_SENDER_EVENT = "esphome.home_io_control_sender_event";
188
189/// @brief Whether a 1W sender is on the `exposed_senders` allowlist for the sender HA event.
190///
191/// "Sender" covers both remotes and wind/rain sensors — they use the identical 1W broadcast
192/// mechanism and differ only in the `originator` byte inside the payload, not in addressing.
193/// Overheard 1W traffic is always DEBUG-logged regardless of this check (see
194/// log_1w_remote_frame()); this only gates whether the event reaches Home Assistant. Deliberately
195/// separate from `linked_devices` — a sender can be event-enabled without controlling any
196/// registered device (e.g. to trigger an HA automation with no matching cover/light/switch), or
197/// vice versa.
198/// @param exposed_senders Configured allowlist (`exposed_senders` YAML key, empty by default).
199/// @param sender_id Node ID of the 1W sender that sent the frame.
200/// @return true if the sender is in the allowlist.
201inline bool is_exposed_sender(const std::vector<std::string> &exposed_senders, const std::string &sender_id) {
202 return std::find(exposed_senders.begin(), exposed_senders.end(), sender_id) != exposed_senders.end();
203}
204
205/// Buffer size for describe_learned_device_type()'s hex fallback: "io_device_type: 0xXX" plus margin.
206inline constexpr size_t LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE = 24;
207
208/// @brief Build the YAML line to add once a device's type is learned at runtime.
209///
210/// Logged when `io_device_type` was left unset in YAML and an INFO2 response just resolved it
211/// for the first time this boot (ADR 0018: nothing persists, so this repeats on every reboot
212/// until the user copies the line in). Reuses yaml_device_type_name() so the exact syntax always
213/// matches what the pairing snippet and the Python schema accept.
214/// @param type The now-known device type. Must not be DeviceType::UNKNOWN.
215/// @return The YAML line to add, e.g. `io_device_type: "venetian_blind"` or `io_device_type: 0x11`.
216inline std::string describe_learned_device_type(DeviceType type) {
217 const char *name = yaml_device_type_name(type);
218 if (name != nullptr)
219 return std::string("io_device_type: \"") + name + "\"";
220 std::array<char, LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE> buffer{};
221 std::snprintf(buffer.data(), buffer.size(), "io_device_type: 0x%02X", static_cast<uint8_t>(type));
222 return std::string(buffer.data());
223}
224
225/// @brief Build the Home Assistant event data map for a decoded 1W sender frame.
226///
227/// Only meaningful when `info.has_intent` is true (the caller gates emission on that); the
228/// `intent` field is only populated by decode_1w_frame() in that case.
229/// @param info Already-decoded 1W frame info (see decode_1w_frame()).
230/// @param linked True if this sender is linked to at least one registered device.
231/// @return Event data map ready for fire_homeassistant_event().
232inline std::map<std::string, std::string> build_sender_event_data(const OneWayFrameInfo &info, bool linked) {
233 return {
234 {"remote_id", node_id_to_string(info.src)},
235 {"target_class", address_class_name(info.address_class)},
236 {"target_type", device_type_name(info.target_type)},
237 {"cmd", format_name_and_hex(command_name(info.cmd), info.cmd)},
238 {"intent", info.intent},
239 {"originator", originator_name(info.originator)},
240 {"acei_level", acei_level_name(info.acei_level)},
241 {"linked", linked ? "true" : "false"},
242 };
243}
244
245// ============================================================================
246// Status normalization helpers
247// ============================================================================
248
249/// @brief Normalize stopped state: some devices briefly report stopped before target/current converge.
250///
251/// Deliberately reads and writes observed fields only — never effective_*(). Its job is "the device
252/// said stopped but its own reported target and current disagree", a statement about observations;
253/// feeding it a prediction would push a guess back into an observed field. On the execute-ack path
254/// (`trust_position = false`) `dev.target` does not hold the commanded value, so this function does
255/// not use it to flip `is_stopped` back to false — effective_is_stopped() owns that.
256/// @param dev Device record to update (may clear is_stopped if positions differ).
258 // Some devices briefly report STATUS_STOPPED before current and target have numerically
259 // converged. Keep the device in the moving state until the decoded values are effectively equal.
260 if (dev.is_stopped && dev.target != UNKNOWN_POSITION && dev.position != UNKNOWN_POSITION &&
262 dev.is_stopped = false;
263 }
264}
265
266/// @brief Update per-device link-health stats from the radio's last capture.
267///
268/// Called for every frame whose `src` is a registered device, regardless of command type or
269/// whether that command's own payload was well-formed — any such frame is real evidence the
270/// device is reachable and at this signal strength. The call sites cover every way such a frame
271/// arrives: update_device_status_() (inbound status path), execute_request_and_update_()'s
272/// explicit-refusal branch (a CMD_ERROR_RESP reply to our own request, which returns before
273/// reaching the status path), and its failure branch when a 0x3C challenge was seen (the device
274/// transmitted, even though the exchange did not complete). Always stamps `last_seen_ms`; only
275/// touches the RSSI fields when `radio` is non-null and its last capture is valid (real drivers
276/// always populate a valid capture before a frame is handed off, but tests calling this path
277/// directly without a radio, or without exercising RX through it, must not crash or fabricate
278/// an RSSI).
279/// @param dev Device that sent the frame.
280/// @param radio Radio driver to read the last capture from; may be nullptr.
281inline void update_link_health(IoDevice &dev, RadioDriver *radio) {
282 dev.last_seen_ms = millis();
283 if (radio == nullptr)
284 return;
285
286 const RadioCaptureInfo &capture = radio->get_last_capture();
287 if (!capture.valid)
288 return;
289
290 dev.last_rssi_dbm = capture.rssi_dbm;
291 if (dev.rssi_ema_scaled == RSSI_UNKNOWN_DBM) {
292 // First sample: seed the EMA directly instead of blending from 0.
293 dev.rssi_ema_scaled = static_cast<int16_t>(capture.rssi_dbm * RSSI_EMA_SCALE);
294 return;
295 }
296 // Integer EMA kept in fixed point: `S += x − round(S/N)` blends each sample at weight 1/N
297 // while S stays scaled by N, so sub-dBm contributions accumulate instead of truncating to
298 // zero — a whole-dBm EMA would stall as soon as |sample − EMA| < N and never converge.
299 dev.rssi_ema_scaled =
300 static_cast<int16_t>(dev.rssi_ema_scaled + capture.rssi_dbm - rssi_scaled_to_dbm(dev.rssi_ema_scaled));
301}
302
303/// @brief Record that an outbound exchange to this device timed out (no valid response).
304///
305/// Called once per failed exchange from execute_request_and_update_()'s "no valid response"
306/// branch — the single place every device-directed exchange already reads
307/// ExchangeEngine::DebugInfo. Both counters saturate at UINT16_MAX instead of wrapping,
308/// matching PairingTelemetry's counters.
309/// @param dev Device the failed exchange was addressed to.
310/// @param tries Number of attempts the failed exchange made (`DebugInfo::tries`, 1-based).
311inline void record_exchange_timeout(IoDevice &dev, uint8_t tries) {
312 if (dev.exchange_timeout_count < UINT16_MAX)
315 static_cast<uint16_t>(std::min<uint32_t>(static_cast<uint32_t>(dev.exchange_attempt_count) + tries, UINT16_MAX));
316}
317
318/// @brief Record what an outbound exchange's ending says about the device, whichever way the caller
319/// then classifies the outcome.
320///
321/// - `ExchangeOutcome::SUCCESS_UNCONFIRMED` (the device answered the request with a challenge, we
322/// answered that, and the closing reply never arrived) counts in exchange_unconfirmed_count. A
323/// CMD_EXECUTE treats that outcome as success and records no failure, so this counter is the only
324/// place it is visible to the diagnostics at all. Saturates at UINT16_MAX instead of wrapping,
325/// like the counters above.
326/// - A CMD_EXECUTE that got a reply (`ExchangeOutcome::SUCCESS_WITH_RESPONSE`, a status or an error
327/// alike, since either one closes the exchange) marks the device as one that confirms, which is
328/// what lets a later unconfirmed EXECUTE to it be sent again.
329/// @param dev Device the exchange was addressed to.
330/// @param request_cmd Command byte of the request.
331/// @param outcome How the exchange ended.
332inline void record_exchange_outcome(IoDevice &dev, uint8_t request_cmd, ExchangeOutcome outcome) {
333 if (outcome == ExchangeOutcome::SUCCESS_UNCONFIRMED && dev.exchange_unconfirmed_count < UINT16_MAX)
335 if (outcome == ExchangeOutcome::SUCCESS_WITH_RESPONSE && request_cmd == CMD_EXECUTE)
336 dev.confirms_execute = true;
337}
338
339/// @brief Store a decoded record on the device, if it is valid.
340///
341/// A short or unpopulated payload leaves whatever was last learned in place rather than clearing
342/// it: the record is inherently last-writer-wins and only refreshes when something commands the
343/// device, so a stale value is the honest answer and a blanked one is not.
344inline void apply_last_command_record(IoDevice &dev, const LastCommandRecord &record) {
345 if (!record.valid)
346 return;
347 memcpy(dev.last_commander, record.commander, NODE_ID_SIZE);
349 dev.has_last_command = true;
350}
351
352/// @brief Describe a 0x71 status update's Command Originator as "name(0xXX)".
353///
354/// Pure rather than inlined into the log line it feeds, so the offset is testable: ESP_LOG* is a
355/// no-op stub in host tests, which makes the rendered line itself unobservable.
356/// @param frame A CMD_STATUS_UPDATE frame.
357/// @return "name(0xXX)", e.g. "user_remote(0x01)", or an empty string when the payload is too
358/// short to carry the byte. Shorter frames still carry usable position data — the branch
359/// gate is STATUS_UPDATE_MIN_DATA_LEN (11) — they just have no originator to report.
360inline std::string describe_status_update_originator(const IoFrame &frame) {
362 return {};
364}
365
366/// @brief Describe the hub's live optimistic predictions where they disagree with the observation.
367///
368/// Pure rather than inlined into log_status_update() so it is testable (ESP_LOG* is a no-op stub in
369/// host tests). log_status_update() runs from the inbound frame handlers and reports what the device
370/// said; a hub-side prediction must never be substituted into those observed fields, so it is
371/// appended as a clearly-labelled annotation instead. Only terms that actually differ are rendered —
372/// a prediction that merely confirms the observation adds nothing.
373/// @param dev Device record to read.
374/// @return e.g. " [predicted: target=100% stopped]", or an empty string when no prediction stands
375/// or every prediction agrees with what the device reported.
376inline std::string describe_prediction(const IoDevice &dev) {
377 const bool target_differs = effective_target(dev) != dev.target;
378 const bool motion_differs = effective_is_stopped(dev) != dev.is_stopped;
379 if (!target_differs && !motion_differs)
380 return {};
381 std::string out = " [predicted:";
382 if (target_differs)
383 out += " target=" + format_position(effective_target(dev));
384 if (motion_differs)
385 out += effective_is_stopped(dev) ? " stopped" : " moving";
386 out += "]";
387 return out;
388}
389
390/// @brief Log a concise status‑update line used by inbound handlers.
391///
392/// The position/target/motion fields report what the device observed — never a hub prediction. A
393/// diverging live prediction is appended by describe_prediction(), clearly labelled, not merged in.
394/// @param id Device ID.
395/// @param dev Current device state.
396/// @param suffix Optional suffix added after the state string (e.g., " (status update)").
397inline void log_status_update(const std::string &id, const IoDevice &dev, const char *suffix = "") {
398 ESP_LOGI(TAG, "Device %s: position=%s target=%s %s%s%s", id.c_str(), format_position(dev.position).c_str(),
399 format_position(dev.target).c_str(), dev.is_stopped ? "stopped" : "moving", describe_prediction(dev).c_str(),
400 suffix);
401}
402
403/// @brief Log a decoded CMD_ERROR_RESP result with optional request-command context.
404/// @param id Device ID.
405/// @param result Result byte from CMD_ERROR_RESP data[0].
406/// @param request_cmd Original outbound request command when known.
407/// @param include_request_cmd True to include request_cmd in the log line.
408inline void log_command_result(const std::string &id, uint8_t result, uint8_t request_cmd = 0,
409 bool include_request_cmd = false) {
410 const char *kind = is_limitation_result(result) ? "limitation" : "error";
411 if (include_request_cmd) {
412 ESP_LOGW(TAG, "Device %s: %s (0x%02X) returned %s result=0x%02X %s (%s)", id.c_str(), command_name(request_cmd),
413 request_cmd, kind, result, command_result_name(result), command_result_description(result));
414 return;
415 }
416
417 ESP_LOGW(TAG, "Device %s: explicit %s result=0x%02X %s (%s)", id.c_str(), kind, result, command_result_name(result),
419}
420
421/// @brief Store a decoded CMD_ERROR_RESP result on the device and log it.
422///
423/// Single place both CMD_ERROR_RESP call sites (the unsolicited status path and the reply to
424/// our own EXECUTE) route through, so the store-and-log policy cannot drift between them. Does
425/// not notify subscribers itself — callers already call notify_device_update_() once per
426/// handled frame; call it after this.
427/// @param dev Device that returned the result.
428/// @param id Device ID (for the log line).
429/// @param result Result byte from CMD_ERROR_RESP data[0].
430/// @param request_cmd Original outbound request command when known.
431/// @param include_request_cmd True to include request_cmd context in the log line.
432inline void record_command_result(IoDevice &dev, const std::string &id, uint8_t result, uint8_t request_cmd = 0,
433 bool include_request_cmd = false) {
434 dev.last_result_code = result;
435 dev.last_result_at_ms = millis();
436 log_command_result(id, result, request_cmd, include_request_cmd);
437}
438
439/// @brief Clear a previously recorded CMD_ERROR_RESP result, if any.
440///
441/// A stale limitation reason (e.g. a rain lockout from an hour ago) is worse than none once the
442/// device has since replied normally, so every successful status/command reply for a device
443/// clears it. Called from the CMD_PRIVATE_RESP and CMD_STATUS_UPDATE branches of
444/// update_device_status_() — not from CMD_GET_NAME_RESP/CMD_GET_INFO2_RESP, which are metadata
445/// lookups unrelated to whether the device's last movement command succeeded. Like
446/// record_command_result(), does not notify subscribers itself — both existing call sites clear
447/// before their own notify_device_update_() call, which is what actually publishes this change.
448/// @param dev Device to clear.
450 dev.last_result_code = 0;
451 dev.last_result_at_ms = 0;
452}
453
454} // namespace detail
455} // namespace home_io_control
456} // namespace esphome
The main IO-Homecontrol component.
Definition hub_core.h:91
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:334
Abstract radio driver for IO-Homecontrol.
const RadioCaptureInfo & get_last_capture() const
Get the most recent radio capture info.
Conversions and renderers shared by the hub and its Home Assistant entities.
IO-Homecontrol ESPHome component — protocol controller.
Shared frame logging helpers for IO-Homecontrol.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
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)".
Definition log_helpers.h:41
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.
Definition log_helpers.h:31
std::string describe_learned_device_type(DeviceType type)
Build the YAML line to add once a device's type is learned at runtime.
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_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).
std::string describe_status_update_originator(const IoFrame &frame)
Describe a 0x71 status update's Command Originator as "name(0xXX)".
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.
void record_exchange_outcome(IoDevice &dev, uint8_t request_cmd, ExchangeOutcome outcome)
Record what an outbound exchange's ending says about the device, whichever way the caller then classi...
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.
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 size_t LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE
Buffer size for describe_learned_device_type()'s hex fallback: "io_device_type: 0xXX" plus margin.
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.
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 format_originator(uint8_t originator)
Render a Command Originator byte as "name(0xXX)", e.g.
const char * device_operation_profile_name(DeviceType type)
Human‑readable operation profile name for a device type.
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 * oneway_target_label(const OneWayFrameInfo &info)
The destination label a decoded 1W frame renders as in a log line.
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?
constexpr uint8_t STATUS_UPDATE_ORIGINATOR_OFFSET
Offset of the Command Originator byte in a CMD_STATUS_UPDATE (0x71) payload.
std::string format_position(float pos)
Format a position float as a human‑readable string (e.g.
Definition hub_core.h:1180
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.
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.
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.
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?
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.
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.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
Device-name, address-classification and 1W-frame codecs.
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...
uint16_t exchange_unconfirmed_count
Cumulative count of exchanges this device authenticated and then never closed: it answered with a 0x3...
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(),...
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.
bool confirms_execute
True once this device has answered a CMD_EXECUTE with a reply that closed the exchange (a status,...
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes). Never includes mac.
Definition proto_frame.h:99
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:97
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:96
uint8_t data_len
Actual length of data.
One decoded last-command record.
uint8_t commander[NODE_ID_SIZE]
Controller that last commanded the device.
bool valid
False when the payload was too short, or the record was unpopulated.
uint8_t originator
That command's Command Originator (ORIGINATOR_*).
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.
int16_t rssi_dbm
Received signal strength (dBm).