|
Home IO Control
ESPHome add-on for IO-Homecontrol devices
|
Classes | |
| struct | LastCommandRecord |
| One decoded last-command record. More... | |
Functions | |
| uint8_t | round_percent (float fraction) |
| Convert a 0.0-1.0 HA fraction (position, tilt, or brightness) to a 0-100 IO percent. | |
| bool | is_binary_entity_position (uint8_t position) |
| Is the given position value an on/off binary encoding? | |
| bool | known_device_matches_entity_class (const IoDevice &dev, DeviceCapabilityClass expected) |
| Does the device's type match the expected HA entity class? | |
| bool | known_device_supports_status_requests (const IoDevice &dev) |
| Does the device support status requests? | |
| bool | known_device_accepts_execute_position (const IoDevice &dev, uint8_t position) |
| Can this device accept an execute (position) command? | |
| bool | known_device_accepts_execute_tilt (const IoDevice &dev) |
| Can this device accept a tilt command? | |
| 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. | |
| 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. | |
| 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). | |
| 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. | |
| 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. | |
| 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::string | describe_learned_device_type (DeviceType type) |
| Build the YAML line to add once a device's type is learned at runtime. | |
| 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. | |
| 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 | 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 multi-line) string. | |
| const char * | oneway_mac_status_name (OneWayMacStatus status) |
| Human-readable name for a decoded 0x30's MAC-verification outcome. | |
| 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 transmission rationale, and the ready-to-paste oneway_controllers: YAML block. | |
| 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: YAML block. | |
| void | normalize_stopped_state (IoDevice &dev) |
| Normalize stopped state: some devices briefly report stopped before target/current converge. | |
| void | update_link_health (IoDevice &dev, RadioDriver *radio) |
| Update per-device link-health stats from the radio's last capture. | |
| void | record_exchange_timeout (IoDevice &dev, uint8_t tries) |
| Record that an outbound exchange to this device timed out (no valid response). | |
| LastCommandRecord | decode_last_command_record (const IoFrame &frame, uint8_t base) |
| Decode the last-command record at base from a status-bearing payload. | |
| void | apply_last_command_record (IoDevice &dev, const LastCommandRecord &record) |
| Store a decoded record on the device, if it is valid. | |
| std::string | describe_last_commander (const IoDevice &dev, const uint8_t *hub_node_id) |
| Render the "Last Commanded By" sensor string. | |
| std::string | describe_last_command_source (const IoDevice &dev) |
| Render the "Last Command Source" sensor string. | |
| std::string | describe_status_update_originator (const IoFrame &frame) |
| Describe a 0x71 status update's Command Originator as "name(0xXX)". | |
| std::string | describe_prediction (const IoDevice &dev) |
| Describe the hub's live optimistic predictions where they disagree with the observation. | |
| void | log_status_update (const std::string &id, const IoDevice &dev, const char *suffix="") |
| Log a concise status‑update line used by inbound handlers. | |
| 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. | |
| void | clear_command_result (IoDevice &dev) |
| Clear a previously recorded CMD_ERROR_RESP result, if any. | |
Variables | |
| constexpr const char * | TAG = "home_io_control" |
| Shared log tag for hub-level messages. | |
| constexpr uint32_t | ONEWAY_DEDUP_WINDOW_MS = 2000 |
| Suppress a repeated 1W log/poll for the same remote and the same intent within this window. | |
| constexpr float | BINARY_ENTITY_ON_POSITION_THRESHOLD |
| Shared 0-100 cutoff: values below this mean binary "on". | |
| constexpr const char * | ONEWAY_SENDER_EVENT = "esphome.home_io_control_sender_event" |
| Home Assistant event fired when a decoded 1W frame carries a command intent from an exposed sender (a physical remote button press, or a wind/rain sensor's triggered command). | |
| constexpr size_t | NAME_AND_HEX_BUFFER_SIZE = 40 |
| Buffer size for format_name_and_hex(): longest command name plus "(0xXX)" and a margin. | |
| constexpr size_t | LEARNED_DEVICE_TYPE_HEX_BUFFER_SIZE = 24 |
| Buffer size for describe_learned_device_type()'s hex fallback: "io_device_type: 0xXX" plus margin. | |
| constexpr uint8_t | PRIVATE_RESPONSE_LAST_COMMAND_OFFSET = 8 |
| Offset of the last-command record within each status-bearing payload. | |
| constexpr uint8_t | STATUS_UPDATE_LAST_COMMAND_OFFSET = 11 |
| constexpr uint8_t | STATUS_UPDATE_ORIGINATOR_OFFSET = 14 |
| Offset of the Command Originator byte in a CMD_STATUS_UPDATE (0x71) payload. | |
|
inline |
Store a decoded record on the device, if it is valid.
A short or unpopulated payload leaves whatever was last learned in place rather than clearing it: the record is inherently last-writer-wins and only refreshes when something commands the device, so a stale value is the honest answer and a blanked one is not.
Definition at line 604 of file hub_internal.h.
|
inline |
Build the ready-to-paste 2W system-key-extraction report: node_id:/system_key: as a home_io_control: YAML block.
Pure — takes already-decoded values, performs no I/O — so it is directly unit-testable without a live radio, mirroring build_oneway_adoption_report() above; the two features end up sharing report structure as well as format_key_hex(). The caller (key_extraction_responder.cpp) logs the result through log_multiline_result() and nowhere else — this is the single intentional place the recovered system_key is formatted for display, a deliberate exception to redaction.h's masking.
The emitted keys must track the hub's own CONFIG_SCHEMA (__init__.py) by hand. make yaml-emitter-sync (scripts/check-yaml-emitters.py) catches drift between the two by cross-referencing this function's emitted key names against that schema statically.
| node_id | Recovered hub node_id, 3 bytes. |
| key | Recovered system key, 16 bytes. |
Definition at line 463 of file hub_internal.h.
|
inline |
Build the full 1W controller-key-adoption report: MAC-verification status, the own-address transmission rationale, and the ready-to-paste oneway_controllers: YAML block.
Pure — takes already-decoded values, performs no I/O — so it is directly unit-testable without a live radio or a captured log line (ESP_LOG's host stub discards its arguments). This is the single intentional place adopted.system_key is formatted for display (via format_key_hex() above); the caller (oneway_key_adoption.cpp) passes the returned text to one ESP_LOGW(...,"%s",...) call and nowhere else.
node_id is deliberately never mentioned as something to fill in — a later step derives one from the hub's own node ID, and the report says so rather than asking the user to invent a 3-byte address. The report also explains that the hub always transmits under its own address: impersonating the sender would hijack that remote's rolling sequence counter and break it.
The emitted keys must track ONEWAY_CONTROLLER_SCHEMA (__init__.py) by hand — a newly required schema key needs a matching line here too. make yaml-emitter-sync (scripts/check-yaml-emitters.py) catches drift between the two statically; it does not tell you what to add here.
| adopted | Decoded controller identity from decode_1w_add_controller() (proto_codecs.h). |
| observed_type_known | True if this sender's other 1W traffic was observed while armed (see OnewayKeyAdoption::record_observed_class()); false prints a commented-out fallback pointing at the DEBUG log line that would reveal it instead. |
| observed_type | The observed target class; only meaningful when observed_type_known. |
Definition at line 397 of file hub_internal.h.
|
inline |
Build the Home Assistant event data map for a decoded 1W sender frame.
Only meaningful when info.has_intent is true (the caller gates emission on that); the intent field is only populated by decode_1w_frame() in that case.
| info | Already-decoded 1W frame info (see decode_1w_frame()). |
| linked | True if this sender is linked to at least one registered device. |
Definition at line 279 of file hub_internal.h.
|
inline |
Clear a previously recorded CMD_ERROR_RESP result, if any.
A stale limitation reason (e.g. a rain lockout from an hour ago) is worse than none once the device has since replied normally, so every successful status/command reply for a device clears it. Called from the CMD_PRIVATE_RESP and CMD_STATUS_UPDATE branches of update_device_status_() — not from CMD_GET_NAME_RESP/CMD_GET_INFO2_RESP, which are metadata lookups unrelated to whether the device's last movement command succeeded. Like record_command_result(), does not notify subscribers itself — both existing call sites clear before their own notify_device_update_() call, which is what actually publishes this change.
| dev | Device to clear. |
Definition at line 747 of file hub_internal.h.
|
inline |
Decode the last-command record at base from a status-bearing payload.
Pure rather than inlined into update_device_status_() so it is testable against corpus bytes directly. An all-zero commander is reported as invalid: 00 00 00 is not a node ID any observed controller uses, so a device that pads this field rather than implementing it publishes nothing instead of a fabricated address.
| frame | A CMD_PRIVATE_RESP or CMD_STATUS_UPDATE frame. |
| base | PRIVATE_RESPONSE_LAST_COMMAND_OFFSET or STATUS_UPDATE_LAST_COMMAND_OFFSET. |
Definition at line 587 of file hub_internal.h.
|
inline |
Render the "Last Command Source" sensor string.
Uses the same "name(0xXX)" rendering as describe_status_update_originator(), so a byte with no ORIGINATOR_* case reads "unknown(0x0A)" rather than being silently dropped or mislabelled. The decode is field-validated for roller shutters (a clean 0x00/0x01 split, remote vs. motor button); gates, lights and multi-channel units are not validated and are expected to surface undecoded values here — which is the point of keeping the raw hex in the string.
| dev | Device record to read. |
Definition at line 643 of file hub_internal.h.
|
inline |
Render the "Last Commanded By" sensor string.
Always leads with the raw node ID — that is the diagnostic value, and the only thing a user can match against a remote they own. The qualifier is additive, never a substitute: a device naming its own ID is NOT reliably "the button on the motor" (the one non-shutter this project has data on, a mains gate, names its own ID with an undefined originator), so the cause belongs to the separate originator sensor, not to this one's wording.
| dev | Device record to read. |
| hub_node_id | This hub's own 3-byte node ID. |
Definition at line 622 of file hub_internal.h.
|
inline |
Build the YAML line to add once a device's type is learned at runtime.
Logged when io_device_type was left unset in YAML and an INFO2 response just resolved it for the first time this boot (ADR 0018: nothing persists, so this repeats on every reboot until the user copies the line in). Reuses yaml_device_type_name() so the exact syntax always matches what the pairing snippet and the Python schema accept.
| type | The now-known device type. Must not be DeviceType::UNKNOWN. |
Definition at line 263 of file hub_internal.h.
|
inline |
Describe the hub's live optimistic predictions where they disagree with the observation.
Pure rather than inlined into log_status_update() so it is testable (ESP_LOG* is a no-op stub in host tests). log_status_update() runs from the inbound frame handlers and reports what the device said; a hub-side prediction must never be substituted into those observed fields, so it is appended as a clearly-labelled annotation instead. Only terms that actually differ are rendered — a prediction that merely confirms the observation adds nothing.
| dev | Device record to read. |
Definition at line 674 of file hub_internal.h.
|
inline |
Describe a 0x71 status update's Command Originator as "name(0xXX)".
Pure rather than inlined into the log line it feeds, so the offset is testable: ESP_LOG* is a no-op stub in host tests, which makes the rendered line itself unobservable.
| frame | A CMD_STATUS_UPDATE frame. |
Definition at line 657 of file hub_internal.h.
|
inline |
Format a 16-byte key as an uppercase, unseparated hex string for display.
The one deliberate place system-key bytes are formatted for display, shared by both key-recovery features so neither forks its own copy: 2W "Accept Foreign Pairing" (key_extraction_responder.cpp::KeyExtractionResponder::log_result_()) and 1W controller-key adoption (build_oneway_adoption_report() below). See redaction.h for the masking rules this intentionally does not apply to — both callers are the deliberate exception, not a loosening of it.
| key | Pointer to AES_KEY_SIZE key bytes. |
Definition at line 306 of file hub_internal.h.
|
inline |
Format a name/value pair as "name(0xXX)", e.g. "execute(0x00)".
Definition at line 246 of file hub_internal.h.
|
inline |
Is the given position value an on/off binary encoding?
| position | Position value to test. |
Definition at line 66 of file hub_internal.h.
|
inline |
Whether a 1W sender is on the exposed_senders allowlist for the sender HA event.
"Sender" covers both remotes and wind/rain sensors — they use the identical 1W broadcast mechanism and differ only in the originator byte inside the payload, not in addressing. Overheard 1W traffic is always DEBUG-logged regardless of this check (see log_1w_remote_frame()); this only gates whether the event reaches Home Assistant. Deliberately separate from linked_devices — a sender can be event-enabled without controlling any registered device (e.g. to trigger an HA automation with no matching cover/light/switch), or vice versa.
| exposed_senders | Configured allowlist (exposed_senders YAML key, empty by default). |
| sender_id | Node ID of the 1W sender that sent the frame. |
Definition at line 238 of file hub_internal.h.
|
inline |
Can this device accept an execute (position) command?
Checks capability and, for unknown types, allows binary positions for light/switch.
| dev | IoDevice to check. |
| position | Position value being sent. |
Definition at line 92 of file hub_internal.h.
|
inline |
Can this device accept a tilt command?
| dev | IoDevice to check. |
Definition at line 110 of file hub_internal.h.
|
inline |
Does the device's type match the expected HA entity class?
UNKNOWN devices always match to keep imported/discovered devices working.
| dev | IoDevice to check. |
| expected | Desired capability class (COVER, LIGHT, SWITCH, etc.). |
Definition at line 75 of file hub_internal.h.
|
inline |
Does the device support status requests?
UNKNOWN devices pass through.
| dev | IoDevice to check. |
Definition at line 83 of file hub_internal.h.
|
inline |
Log an already-decoded 1W remote frame at DEBUG level.
Formats a concise DEBUG log line showing remote ID, target type, command intent, and priority. When the remote is linked to devices, appends the linked device IDs. Takes the already-decoded OneWayFrameInfo so callers that also build a HA event (see build_sender_event_data()) decode the frame once, not twice.
| info | Already-decoded 1W frame info (see decode_1w_frame()). |
| linked_devices | Optional pointer to device IDs this remote is linked to. |
Definition at line 193 of file hub_internal.h.
|
inline |
Log a decoded CMD_ERROR_RESP result with optional request-command context.
| id | Device ID. |
| result | Result byte from CMD_ERROR_RESP data[0]. |
| request_cmd | Original outbound request command when known. |
| include_request_cmd | True to include request_cmd in the log line. |
Definition at line 706 of file hub_internal.h.
|
inline |
Log a frame at the "io_capture" tag with structured fields.
Used for protocol‑level debugging (phases: component, tx, rx, parse_ok/parse_fail).
| radio | Radio driver instance (provides chip name and capture). |
| stage | String label for the current phase. |
| buf | Raw bytes being logged. |
| len | Length of buf. |
| frame | Optional parsed IoFrame for decoded fields (cmd, src, dst). |
Definition at line 137 of file hub_internal.h.
|
inline |
Log a frame‑level issue (unregistered endpoints, unsupported commands).
| component | Pointer to the component (for device lookup). |
| direction | "tx" or "rx". |
| reason | Short issue label (e.g., "unregistered_device"). |
| frame | Parsed frame. |
| len | Serialized length. |
Definition at line 163 of file hub_internal.h.
|
inline |
Log prefix followed by message, one line per log call rather than one call for the whole (possibly multi-line) string.
ESPHome formats each log call into a fixed 512-byte buffer (ESPHOME_LOGGER_TX_BUFFER_SIZE, esphome/core/defines.h) and silently truncates anything longer; a multi-line report (a YAML snippet plus explanatory prose) routinely exceeds that and truncates mid-line if logged as a single call — confirmed on real hardware for both call sites this function serves: scan_paired_devices()'s report (a multi-device report cut off mid-snippet) and 1W controller-key adoption's report (the recovered system_key line itself never made it into the log at all). Splitting by line keeps every individual call's payload small regardless of how long the full message is. Shared rather than duplicated a third time — a second private copy is exactly how the 1W path ended up with the bug this fixes.
| tag | Log tag. |
| is_warning | True to log at WARN, false for INFO. |
| prefix | Prepended to the message's first line only (e.g. "Management action X: "). |
| message | Message to log; may contain embedded \n line breaks. |
Definition at line 333 of file hub_internal.h.
|
inline |
Log a rejected operation with capability mismatch details.
| device_id | Device ID string. |
| dev | IoDevice that rejected the command. |
| operation | Human‑readable operation name (e.g., "set position"). |
| expected | Expected capability class or profile name. |
Definition at line 123 of file hub_internal.h.
|
inline |
Log a concise status‑update line used by inbound handlers.
The position/target/motion fields report what the device observed — never a hub prediction. A diverging live prediction is appended by describe_prediction(), clearly labelled, not merged in.
| id | Device ID. |
| dev | Current device state. |
| suffix | Optional suffix added after the state string (e.g., " (status update)"). |
Definition at line 695 of file hub_internal.h.
|
inline |
Normalize stopped state: some devices briefly report stopped before target/current converge.
Deliberately reads and writes observed fields only — never effective_*(). Its job is "the device said stopped but its own reported target and current disagree", a statement about observations; feeding it a prediction would push a guess back into an observed field. On the execute-ack path (trust_position = false) dev.target does not hold the commanded value, so this function does not use it to flip is_stopped back to false — effective_is_stopped() owns that.
| dev | Device record to update (may clear is_stopped if positions differ). |
Definition at line 486 of file hub_internal.h.
|
inline |
Human-readable name for a decoded 0x30's MAC-verification outcome.
| status | Outcome from decode_1w_add_controller() (see OneWayAdoptedKey::mac_status). |
Definition at line 360 of file hub_internal.h.
|
inline |
Store a decoded CMD_ERROR_RESP result on the device and log it.
Single place both CMD_ERROR_RESP call sites (the unsolicited status path and the reply to our own EXECUTE) route through, so the store-and-log policy cannot drift between them. Does not notify subscribers itself — callers already call notify_device_update_() once per handled frame; call it after this.
| dev | Device that returned the result. |
| id | Device ID (for the log line). |
| result | Result byte from CMD_ERROR_RESP data[0]. |
| request_cmd | Original outbound request command when known. |
| include_request_cmd | True to include request_cmd context in the log line. |
Definition at line 730 of file hub_internal.h.
|
inline |
Record that an outbound exchange to this device timed out (no valid response).
Called once per failed exchange from execute_request_and_update_()'s "no valid response" branch — the single place every device-directed exchange already reads ExchangeEngine::DebugInfo. Both counters saturate at UINT16_MAX instead of wrapping, matching PairingTelemetry's counters.
| dev | Device the failed exchange was addressed to. |
| tries | Number of attempts the failed exchange made (DebugInfo::tries, 1-based). |
Definition at line 539 of file hub_internal.h.
|
inline |
Convert a 0.0-1.0 HA fraction (position, tilt, or brightness) to a 0-100 IO percent.
Rounds rather than truncates: HA quantizes call values to 0-255 before they ever reach us, so its "50%" is 128/255=0.50196, not exactly 0.5 — a truncating cast compounds that quantization into a consistent ~1% bias, caught on real hardware in both platform_cover.cpp (position and tilt) and platform_light.cpp (brightness). Callers apply their own invert/complement logic (e.g. 1.0F - fraction) before calling this; it only owns the rounding.
| fraction | Value in [0.0, 1.0]. |
Definition at line 57 of file hub_internal.h.
|
inline |
Update per-device link-health stats from the radio's last capture.
Called for every frame whose src is a registered device, regardless of command type or whether that command's own payload was well-formed — any such frame is real evidence the device is reachable and at this signal strength. The two call sites cover both ways such a frame arrives: update_device_status_() (inbound status path) and execute_request_and_update_()'s explicit-refusal branch (a CMD_ERROR_RESP reply to our own request, which returns before reaching the status path). Always stamps last_seen_ms; only touches the RSSI fields when radio is non-null and its last capture is valid (real drivers always populate a valid capture before a frame is handed off, but tests calling this path directly without a radio, or without exercising RX through it, must not crash or fabricate an RSSI).
| dev | Device that sent the frame. |
| radio | Radio driver to read the last capture from; may be nullptr. |
Definition at line 509 of file hub_internal.h.
|
inlineconstexpr |
Shared 0-100 cutoff: values below this mean binary "on".
Definition at line 41 of file hub_internal.h.
|
inlineconstexpr |
Buffer size for describe_learned_device_type()'s hex fallback: "io_device_type: 0xXX" plus margin.
Definition at line 253 of file hub_internal.h.
|
inlineconstexpr |
Buffer size for format_name_and_hex(): longest command name plus "(0xXX)" and a margin.
Definition at line 243 of file hub_internal.h.
|
inlineconstexpr |
Suppress a repeated 1W log/poll for the same remote and the same intent within this window.
Wide on purpose: it collapses both the 4×/40ms reliability burst and a held button into one logical press. A different intent from the same remote (a stop after a move) is not a duplicate and passes through immediately — see decisions::is_duplicate_1w_frame().
Definition at line 40 of file hub_internal.h.
|
inlineconstexpr |
Home Assistant event fired when a decoded 1W frame carries a command intent from an exposed sender (a physical remote button press, or a wind/rain sensor's triggered command).
Definition at line 224 of file hub_internal.h.
|
inlineconstexpr |
Offset of the last-command record within each status-bearing payload.
Both status-bearing frame types carry the same 4-byte record — three bytes of node ID for the controller that last commanded the device, then that command's Command Originator byte — and 0x71's whole payload is shifted +3 relative to 0x04's, exactly as its target/current position fields already are (PRIVATE_RESPONSE_TARGET_OFFSET vs STATUS_UPDATE_TARGET_OFFSET in hub_status.cpp). Confirmed on one device in one session across both frame types: tests/corpus/captures/statuspoll/somfy_rs100_statuspoll_kig300_sx1276.yaml, device E461E9, which names controller BE FE DB at 0x04 data[8..10] and 0x71 data[11..13] in the same capture — including in a 0x71 addressed to a different controller, which is what rules out "this is just the destination echoed back".
Definition at line 557 of file hub_internal.h.
|
inlineconstexpr |
Definition at line 558 of file hub_internal.h.
|
inlineconstexpr |
Offset of the Command Originator byte in a CMD_STATUS_UPDATE (0x71) payload.
Deliberately not offset 1: data[1] on a 0x71 is the status byte (0x60/0x61, bit 0 = current position unknown), which matches no ORIGINATOR_* value, so reading it as an originator rendered "unknown" on every frame this project has ever captured. Every captured 0x71 carries 0x01 (ORIGINATOR_USER_REMOTE) here.
Definition at line 566 of file hub_internal.h.
|
inlineconstexpr |
Shared log tag for hub-level messages.
Definition at line 35 of file hub_internal.h.