|
Home IO Control
ESPHome add-on for IO-Homecontrol devices
|
Status: Accepted · Recorded: 2026-08
Amended by ADR 0060 (a probe that changes state) and ADR 0062 (0x4A is sent, as a read) and ADR 0063 (0x20 is sent, as a register read) and ADR 0072 (the isolation rests on a collaborator with three injected hub calls).
A handful of opcodes seen in field logs have no builder anywhere in this codebase: CMD_PRIVATE (0x03) with a non-default function ID, extended CMD_PRIVATE selector 0x80, CMD_GET_GENERAL_INFO3 (0x58), and CMD_PRIVATE2 (0x0C). Nothing in this project can emit any of them, so the open questions around what they mean (battery/telemetry reads, a possible stored-position readback) cannot be closed by analysis alone — closing them needs a mechanism to send these frames to a real device and capture what comes back.
That mechanism is inherently different from the rest of this component's action surface. Every other action (rename, identify, force-open, scan) sends a known, decoded command. A diagnostic probe sends a command whose reply format is, by definition, not yet understood. Two risks follow directly from that:
probe_device/probe_sweep are that mechanism: a general-purpose tool for probing, debugging, and analyzing the parts of the IO-Homecontrol protocol this codebase does not yet understand — not a one-off for a single investigation. New opcodes that turn up undecoded in a future capture get a new probe added to the same mechanism, the same way the opcodes above did. This ADR is the durable record of the decisions made to build that mechanism safely.
Diagnostic probes (probe_device() / probe_sweep(), exposed as native API actions) are gated behind one config-time boolean: home_io_control.diagnostic_probes: true in YAML. Off by default — a build that doesn't set it never registers a code path that can send an undecoded probe opcode. There is no runtime switch, no arm/disarm state, and no session timeout or probe-count budget: the gate is "was this build compiled with `diagnostic_probes: true`," decided once at flash time, not a toggle a user flips per session. IOHomeControlComponent::set_diagnostic_probes_enabled() is called once from __init__.py's to_code() with the YAML value; diagnostic_probes_enabled() is what probe_device() checks before building or sending anything.
Every probe additionally refuses while the target device's last known state is "moving" (!dev->is_stopped): an unknown frame arriving mid-transaction is the one avoidable way a read-shaped probe could still cause harm, independent of the YAML gate above. This is the device's last reported movement state, not a check on the operation queue or on anything in flight — is_stopped is always true for a light/switch (this guard never refuses probes to those device types), and it can be stale if the device was started from a physical remote the hub never saw.
probe_device()/probe_sweep() resolve their target through the same resolve_device_() every other management action uses, which requires the device to already be registered on this hub. This is not incidental: it means a probe can only ever reach a device this hub has already successfully paired with and holds a key for. There is no path from this instrumentation to an arbitrary or unpaired device.
ManagementActions has no access to IOHomeControlComponent::update_device_status_() at all — that method is protected on the hub, and ManagementActions is not a friend class and holds no pointer-to-member into it. A probe reply is read directly out of ExchangeEngine::send_and_receive()'s response frame, serialized to hex, and placed into ManagementActionResult::response_hex/response_cmd for reporting. There is no code path by which a probe's reply can reach apply_private_response_status() or any other status-merging code — this is a structural property of the class boundary, not a runtime check that could be bypassed by a future edit that forgets it. HubManagement.ProbeDeviceFunctionIdReplyNeverUpdatesDevicePosition (hub_management_test.cpp) pins this as a regression: it feeds a 6-byte function-ID reply through probe_device() and asserts the device's position/target/tilt/stopped fields are untouched.
CMD_UNKNOWN4A_REQ = 0x4A exists as a named constant (so a received frame, if one is ever seen, renders by name instead of as UNKNOWN_CMD), and nothing else. No builder exists for it, no probe name reaches it, and no code path constructs a frame with that command byte. This is deliberate:
Grep for CMD_UNKNOWN4A_REQ at any time to verify: it should appear only in proto_constants.{h,cpp} (the constant and its command_name() case) and in comments explaining its exclusion — never in a frame builder or a probe table row.
CMD_WRITE_PRIVATE (0x20) gets no builder for the same class of reason — it is a write with unknown payload semantics, and a wrong write to a motor's stored configuration is not recoverable from the radio side.
Every probe reports the raw reply, not an interpretation — ManagementActionResult.message and the esphome.home_io_control_action_result event both carry the response command byte and full wire hex, deliberately uninterpreted, so the point of the instrumentation (finding out what these bytes mean) is not pre-empted by a guess baked into the reporting code.
ESPHome native-API action arguments are all required strings — there is no "leave the range alone and it falls back to a sensible default" path for first_index/last_index. Every call must pass an explicit range, which satisfies the original intent (nothing wide happens implicitly) more strictly than a default would have. Field-observed starting ranges live in docs/diagnostic-probes.md's per-probe "Start with" column instead.