|
Home IO Control
ESPHome add-on for IO-Homecontrol devices
|
#include <exchange_engine.h>
Classes | |
| struct | BroadcastReplyInfo |
| Per-reply facts collect_broadcast_responses() hands its caller alongside the frame. More... | |
| struct | DebugInfo |
| Snapshot of the last exchange attempt for diagnostics. More... | |
| struct | Counters |
| Free-running counters for the engine's own retry/parse behavior — not per-device (see the RSSI/Exchange-Failures per-device sensors for that) and not per-attempt (see DebugInfo for that). More... | |
Public Types | |
| enum class | ChallengeResult : uint8_t { VERIFIED , NO_ANSWER , MISMATCH } |
| What one challenge (our 0x3C, the device's 0x3D) came to. More... | |
| enum class | WakeBeliefUse : uint8_t { NOT_LOW_POWER , OVERRIDE , SWITCHED_OFF , NO_PROVIDER , APPLIED } |
| Whether an exchange's start preamble followed the low-power wake belief, and if not, why. More... | |
| using | BroadcastReplyHandler = std::function<void(const IoFrame &frame, const BroadcastReplyInfo &info)> |
| Invoked for each matching broadcast reply, as it arrives. | |
| using | TargetEvidenceProvider = std::function<bool(const uint8_t *dst, decisions::TargetEvidence &out)> |
| Looks up what the hub knows about a destination node (decisions::TargetEvidence). | |
| using | DeviceKeyProvider = std::function<const uint8_t *(const uint8_t *node)> |
| Looks up the system key a device keeps of its own. | |
| using | WakeEvidenceSpentHandler = std::function<void(const uint8_t *dst)> |
| Forgets a destination's moving evidence. | |
Public Member Functions | |
| ExchangeEngine (RadioDriver **radio_ptr, const uint8_t *node_id, const uint8_t *system_key, const TuningConfig *tuning) | |
| Construct the engine with double-pointer indirection into the hub's RadioDriver pointer and direct pointers to the node/key byte arrays and the tuning config. | |
| ExchangeEngine (const ExchangeEngine &)=delete | |
| ExchangeEngine & | operator= (const ExchangeEngine &)=delete |
| ExchangeOutcome | send_and_receive (const IoFrame &request, IoFrame &response, uint32_t freq, uint8_t max_tries=EXCHANGE_RETRY_COUNT, uint16_t request_preamble_override=0) |
| Execute an outbound authenticated exchange with retry. | |
| bool | authenticate_request (const IoFrame &request, uint32_t freq) |
| Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC. | |
| const uint8_t * | key_for (const uint8_t *node) const |
| The key an exchange with node uses: the device's own key when the provider has one, else the hub key. | |
| bool | authenticate_request (const IoFrame &request, uint32_t freq, const uint8_t *key) |
| Same as authenticate_request(), but checks the device's 0x3D against key instead of the hub's system key. | |
| ChallengeResult | challenge_sender (const IoFrame &request, uint32_t freq, const uint8_t *key) |
| One challenge of request's sender under key, telling a lost answer from a wrong one. | |
| uint8_t | collect_broadcast_responses (const IoFrame &request, uint32_t freq, uint8_t expected_cmd, uint32_t window_ms, const BroadcastReplyHandler &on_reply, ListenPolicy policy=ListenPolicy::ROTATE_SKIPPING_REQUEST) |
| Transmit request once and hand every matching broadcast reply to on_reply within window_ms. | |
| ListenOutcome | listen (const ListenSpec &spec, RadioRxPacket &packet, IoFrame &frame, const ReplyHandler &on_frame) |
| The one listen primitive every radio wait loop in this project is built on. | |
| bool | transmit_frame (const IoFrame &frame, uint32_t freq, uint16_t preamble) |
| Transmit a raw IoFrame with the given preamble length. | |
| uint16_t | request_preamble_for (const IoFrame &request) const |
| Preamble length for an outbound request frame. | |
| bool | answer_challenge (const IoFrame &request, const IoFrame &challenge, uint32_t freq) |
| Send a 0x3D challenge response over request's transcript, proving knowledge of the system key to whoever sent challenge. | |
| void | hop_frequency (uint32_t skip_freq=0) |
| Advance the receiver one step along the protocol's channel rotation (CH1→CH2→CH3→CH1). | |
| void | maybe_hop () |
| Hop only if the minimum dwell has elapsed and no frame is currently arriving on this channel — RadioDriver::reception_in_progress() gates the hop so a reception in progress is never destroyed mid-arrival (issue #81). | |
| void | reset_hop_timestamp () |
| Reset the hop-timer (called after radio init in hub setup()). | |
| void | set_transmit_observer (TransmitObserver *observer) |
| Attach the observer transmit_frame() reports LBT deferrals and sent frames to. | |
| void | set_target_evidence_provider (TargetEvidenceProvider provider) |
| Install the source of per-target evidence. | |
| void | set_device_key_provider (DeviceKeyProvider provider) |
| Install the source of per-device system keys. | |
| void | set_wake_evidence_spent_handler (WakeEvidenceSpentHandler handler) |
| Install what to do when an exchange planned on an AWAKE belief drew no frame at all: the receiver did not answer the short preamble a moving one hears, nor the wake-up preamble, so the belief that it is travelling is spent and the next exchange must lead with the wake-up preamble. | |
| void | reset_debug (uint8_t request_cmd) |
| Clear the debug snapshot and record the upcoming request command. | |
| void | record_debug (const char *stage, uint8_t tries, bool saw_challenge) |
| Update the debug snapshot with the current stage and radio capture. | |
| void | log_debug (const char *device_id) const |
| Log the debug snapshot as a WARN-level structured line. | |
| void | log_debug_unconfirmed (const char *device_id) const |
| Log the debug snapshot for an exchange that ended accepted-but-unconfirmed, at INFO. | |
| const DebugInfo & | get_debug () const |
| Read-only access to the current debug snapshot. | |
| const Counters & | counters () const |
| Read-only access to the running counters. | |
| void | reset_counters () |
| Zero every counter (e.g. | |
Static Public Member Functions | |
| static const char * | wake_belief_use_name (WakeBeliefUse use) |
| Log label for a WakeBeliefUse that is not APPLIED (an applied one logs the belief itself). | |
Definition at line 74 of file exchange_engine.h.
| using esphome::home_io_control::ExchangeEngine::BroadcastReplyHandler = std::function<void(const IoFrame &frame, const BroadcastReplyInfo &info)> |
Invoked for each matching broadcast reply, as it arrives.
| frame | Parsed reply frame (responder's address is frame.src). |
| info | RSSI, receive channel, and post-transmit latency for this reply. |
Definition at line 161 of file exchange_engine.h.
| using esphome::home_io_control::ExchangeEngine::DeviceKeyProvider = std::function<const uint8_t *(const uint8_t *node)> |
Looks up the system key a device keeps of its own.
| node | Node ID (NODE_ID_SIZE bytes) of the device on the other end of the exchange. |
Definition at line 340 of file exchange_engine.h.
| using esphome::home_io_control::ExchangeEngine::TargetEvidenceProvider = std::function<bool(const uint8_t *dst, decisions::TargetEvidence &out)> |
Looks up what the hub knows about a destination node (decisions::TargetEvidence).
| dst | Destination node ID (NODE_ID_SIZE bytes) of the request being sent. |
| out | Filled with that device's evidence when it is known. |
Definition at line 323 of file exchange_engine.h.
| using esphome::home_io_control::ExchangeEngine::WakeEvidenceSpentHandler = std::function<void(const uint8_t *dst)> |
Forgets a destination's moving evidence.
| dst | Destination node ID (NODE_ID_SIZE bytes) of the request that went unanswered. |
Definition at line 352 of file exchange_engine.h.
|
strong |
What one challenge (our 0x3C, the device's 0x3D) came to.
Definition at line 133 of file exchange_engine.h.
|
strong |
Whether an exchange's start preamble followed the low-power wake belief, and if not, why.
Reported as the belief= field of the exchange-failure log line, so a posted log says which of these applied instead of one ambiguous "not applied".
| Enumerator | |
|---|---|
| NOT_LOW_POWER | Not a low-power start frame: there is no wake-up preamble to reorder. |
| OVERRIDE | The caller forced a preamble (pairing's directed frames). |
| SWITCHED_OFF | The low_power_wake_belief tuning switch is off. |
| NO_PROVIDER | No evidence source installed (set_target_evidence_provider()). |
| APPLIED | The tries followed the belief in DebugInfo::wake_belief. |
Definition at line 371 of file exchange_engine.h.
| esphome::home_io_control::ExchangeEngine::ExchangeEngine | ( | RadioDriver ** | radio_ptr, |
| const uint8_t * | node_id, | ||
| const uint8_t * | system_key, | ||
| const TuningConfig * | tuning ) |
Construct the engine with double-pointer indirection into the hub's RadioDriver pointer and direct pointers to the node/key byte arrays and the tuning config.
Pointers must remain valid for the lifetime of the engine (guaranteed because hub owns all referenced members).
| radio_ptr | Address of the hub's RadioDriver *radio_ member. |
| node_id | Pointer to the hub's node_id_[NODE_ID_SIZE] array. |
| system_key | Pointer to the hub's system_key_[AES_KEY_SIZE] array. |
| tuning | Pointer to the hub's TuningConfig tuning_ member. |
Definition at line 37 of file exchange_engine.cpp.
|
delete |
| bool esphome::home_io_control::ExchangeEngine::answer_challenge | ( | const IoFrame & | request, |
| const IoFrame & | challenge, | ||
| uint32_t | freq ) |
Send a 0x3D challenge response over request's transcript, proving knowledge of the system key to whoever sent challenge.
The one place that builds and sends a 0x3D, so the 0x3D transcript rule (HMAC over the challenged frame's cmd + data, create_challenge_resp()) and the response_preamble() choice have exactly one owner, shared by the normal inbound-challenge path (handle_authentication_()) and pairing's own post-0x32 challenge wait (PairingEngine::wait_for_key_confirm_()). Does not touch challenge_round_trips — callers that want it counted (handle_authentication_()) increment it themselves; the pairing path deliberately does not, per that counter's own "no pairing path" doc.
| request | The frame whose cmd+data the challenger wants proof of (the transcript). |
| challenge | The inbound 0x3C carrying the challenge bytes in challenge.data. |
| freq | RF channel frequency (Hz) to transmit the 0x3D on. |
Definition at line 635 of file exchange_engine.cpp.
| bool esphome::home_io_control::ExchangeEngine::authenticate_request | ( | const IoFrame & | request, |
| uint32_t | freq ) |
Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.
| request | The received inbound frame (e.g., CMD_STATUS_UPDATE). |
| freq | RF channel the frame arrived on. |
Definition at line 914 of file exchange_engine.cpp.
| bool esphome::home_io_control::ExchangeEngine::authenticate_request | ( | const IoFrame & | request, |
| uint32_t | freq, | ||
| const uint8_t * | key ) |
Same as authenticate_request(), but checks the device's 0x3D against key instead of the hub's system key.
Used by the key pull to make a device prove it holds the key it just sent.
| request | The device's frame the challenge is about (its cmd + data are the transcript). |
| freq | RF channel to challenge on. |
| key | 16-byte key the device's 0x3D must verify against. |
Definition at line 926 of file exchange_engine.cpp.
| ExchangeEngine::ChallengeResult esphome::home_io_control::ExchangeEngine::challenge_sender | ( | const IoFrame & | request, |
| uint32_t | freq, | ||
| const uint8_t * | key ) |
One challenge of request's sender under key, telling a lost answer from a wrong one.
A caller may retry a NO_ANSWER; a MISMATCH means the device does not hold key.
| request | The device's frame the challenge is about (its cmd + data are the transcript). |
| freq | RF channel to challenge on. |
| key | 16-byte key the device's 0x3D must verify against. |
Definition at line 930 of file exchange_engine.cpp.
| uint8_t esphome::home_io_control::ExchangeEngine::collect_broadcast_responses | ( | const IoFrame & | request, |
| uint32_t | freq, | ||
| uint8_t | expected_cmd, | ||
| uint32_t | window_ms, | ||
| const BroadcastReplyHandler & | on_reply, | ||
| ListenPolicy | policy = ListenPolicy::ROTATE_SKIPPING_REQUEST ) |
Transmit request once and hand every matching broadcast reply to on_reply within window_ms.
Unlike send_and_receive(), this neither retries the transmit nor performs any authentication — a broadcast roll-call has no per-transaction proof, so replies are informational only (see the caller's protocol notes). Every candidate packet is parsed and checked against expected_cmd and node_id_ (as the frame's destination); anything that fails parse(), carries a different cmd, or is not addressed to us is ignored without ending collection.
policy selects which channels the listen covers, defaulting to ListenPolicy::ROTATE_SKIPPING_REQUEST (measured: 1 of 149 Somfy always-alive roll-call replies landed on the request channel — leaving it before the first listen costs almost nothing). Pass ListenPolicy::ROTATE_ALL_CHANNELS for a roll-call whose responders can reply on the request channel (real VELUX low-power 0x2Bs were observed there). ListenPolicy::HOLD_REQUEST_CHANNEL is not a valid broadcast policy — a broadcast has no pinned conversation to hold — and callers must not pass it; there is no runtime check for this because the only non-default caller fixes its policy at compile time.
This method stores nothing and imposes no capacity: it neither buffers replies nor deduplicates them, so the same responder answering twice within one window invokes on_reply twice. Storage, deduplication, and any capacity limit belong to the caller, which knows how little of each reply it actually needs to keep — see ManagementActions::scan_paired_devices(), which decodes each frame on arrival into a compact record rather than retaining whole frames. Collection always runs to the deadline.
| request | Frame to transmit once (cmd + endpoints already filled). |
| freq | RF channel frequency (Hz) for the initial transmit. |
| expected_cmd | Command byte a reply must carry to be considered. |
| window_ms | How long to listen after the transmit, in milliseconds. The caller owns this budget explicitly (rather than this method reading tuning_->pairing_discovery_wait_ms itself) because a caller that transmits more than once needs to divide one total time budget across several calls — see ManagementActions::scan_paired_devices(). |
| on_reply | Invoked once per matching reply, before the next packet is awaited, so the handler must be cheap and must not block. Keep captures to a few pointers: small callables avoid std::function's heap fallback on the implementations this project builds against. |
| policy | Which channels to listen on; see above. Defaults to ListenPolicy::ROTATE_SKIPPING_REQUEST. |
Definition at line 719 of file exchange_engine.cpp.
|
inlinenodiscard |
Read-only access to the running counters.
No production caller yet — see the Counters doc comment above.
Definition at line 474 of file exchange_engine.h.
|
inlinenodiscard |
Read-only access to the current debug snapshot.
Definition at line 437 of file exchange_engine.h.
| void esphome::home_io_control::ExchangeEngine::hop_frequency | ( | uint32_t | skip_freq = 0 | ) |
Advance the receiver one step along the protocol's channel rotation (CH1→CH2→CH3→CH1).
| skip_freq | Channel to pass over, or 0 to rotate through all three. Used by the always-alive roll-call pass (and discovery), whose replies almost never come back on the channel that asked; the low-power roll-call pass rotates through all three instead. |
Definition at line 131 of file exchange_engine.cpp.
|
nodiscard |
The key an exchange with node uses: the device's own key when the provider has one, else the hub key.
| node | Node ID (NODE_ID_SIZE bytes) of the device on the other end. |
Definition at line 918 of file exchange_engine.cpp.
| ListenOutcome esphome::home_io_control::ExchangeEngine::listen | ( | const ListenSpec & | spec, |
| RadioRxPacket & | packet, | ||
| IoFrame & | frame, | ||
| const ReplyHandler & | on_frame ) |
The one listen primitive every radio wait loop in this project is built on.
Listens for up to spec.window_ms, applying spec.policy (hold the current channel, rotate all three, or rotate skipping the request channel), and hands every packet the radio delivers to on_frame before deciding whether to keep waiting. See ListenPolicy for the measurements behind each policy and ListenSpec for what each field controls.
Parses each received packet into frame, so on ListenOutcome::ACCEPTED the caller's frame already holds the accepted frame and packet already holds its raw bytes — no copy is needed. A packet that fails to parse is still handed to on_frame (with a null parsed pointer), so a caller that wants to log or count unparsable frames still can.
Any richer result than accept/refuse/timeout — a disposition with more than three values, a captured "did we see any traffic at all" flag — is the caller's business: capture it in on_frame's closure and return ACCEPT/IGNORE. ListenOutcome itself never grows a fourth value; that is how a shared primitive would turn back into one loop per caller.
| spec | How this listen window is to be spent. |
| packet | Scratch space for the whole listen: holds the last received packet on return. On ACCEPTED that is the accepted packet; on ABORTED, the one on_frame refused; on TIMED_OUT, whatever arrived last (or the caller's initial value, if nothing did). |
| frame | Same lifetime as packet, parsed from it: holds the last received frame on return, with the same ACCEPTED/ABORTED/TIMED_OUT correspondence as packet above. |
| on_frame | Invoked for every packet the radio delivers; decides whether to accept, abort, or keep listening. See ReplyHandler. |
Definition at line 852 of file exchange_engine.cpp.
| void esphome::home_io_control::ExchangeEngine::log_debug | ( | const char * | device_id | ) | const |
Log the debug snapshot as a WARN-level structured line.
| device_id | Human-readable device identifier for the log line. |
Definition at line 108 of file exchange_engine.cpp.
| void esphome::home_io_control::ExchangeEngine::log_debug_unconfirmed | ( | const char * | device_id | ) | const |
Log the debug snapshot for an exchange that ended accepted-but-unconfirmed, at INFO.
Same fields as log_debug(), different prefix and level: the device challenged us, so this is not a failure and must not read like one. It is logged at all because the snapshot is the only evidence of what happened after our challenge answer went out — whether the radio saw nothing (our answer likely never arrived, and the device never acted) or received something it could not use (the device answered and this side lost the reply). Without it the whole path is silent, and a field report has no way to tell those apart.
| device_id | Human-readable device identifier for the log line. |
Definition at line 114 of file exchange_engine.cpp.
| void esphome::home_io_control::ExchangeEngine::maybe_hop | ( | ) |
Hop only if the minimum dwell has elapsed and no frame is currently arriving on this channel — RadioDriver::reception_in_progress() gates the hop so a reception in progress is never destroyed mid-arrival (issue #81).
A deferred hop does not reset the dwell timer: it fires on the first call after the reception clears, not a further HOP_TIME_US later. Called from the hub's loop() to honour passive channel scanning.
Definition at line 153 of file exchange_engine.cpp.
|
delete |
| void esphome::home_io_control::ExchangeEngine::record_debug | ( | const char * | stage, |
| uint8_t | tries, | ||
| bool | saw_challenge ) |
Update the debug snapshot with the current stage and radio capture.
Definition at line 62 of file exchange_engine.cpp.
|
nodiscard |
Preamble length for an outbound request frame.
A non-start frame keeps the chip's short response preamble. A start frame gets LONG_PREAMBLE only when it carries CTRL1_LOW_POWER (its target is a duty-cycled receiver that must be woken); every other start frame gets the runtime-tunable normal_start_preamble. For a directed frame, the target's per-device low_power property sets the bit; for the roll-call broadcast, the pass being sent sets it (see ManagementActions::scan_paired_devices()).
This is the asleep / always-alive rule. send_and_receive() may pick a shorter preamble per try for a low-power target it believes awake (see set_target_evidence_provider()), so the bit and the preamble agree here but not necessarily on every try there; callers that send once, like the discover-confirm step, always get the rule above.
Public so any caller building its own start frame outside send_and_receive() — pairing's discover-confirm step (0x2C) is one such caller — follows the same ADR 0029 rule instead of re-implementing it next to a second copy.
| request | Frame the preamble is being chosen for. |
Definition at line 494 of file exchange_engine.cpp.
|
inline |
Zero every counter (e.g.
to start a fresh measurement window). No production caller yet — see the Counters doc comment above.
Definition at line 478 of file exchange_engine.h.
| void esphome::home_io_control::ExchangeEngine::reset_debug | ( | uint8_t | request_cmd | ) |
Clear the debug snapshot and record the upcoming request command.
Definition at line 45 of file exchange_engine.cpp.
| void esphome::home_io_control::ExchangeEngine::reset_hop_timestamp | ( | ) |
Reset the hop-timer (called after radio init in hub setup()).
Definition at line 129 of file exchange_engine.cpp.
| ExchangeOutcome esphome::home_io_control::ExchangeEngine::send_and_receive | ( | const IoFrame & | request, |
| IoFrame & | response, | ||
| uint32_t | freq, | ||
| uint8_t | max_tries = EXCHANGE_RETRY_COUNT, | ||
| uint16_t | request_preamble_override = 0 ) |
Execute an outbound authenticated exchange with retry.
| request | Frame to transmit (cmd + endpoints already filled). |
| response | Populated only for ExchangeOutcome::SUCCESS_WITH_RESPONSE. |
| freq | RF channel frequency (Hz). |
| max_tries | Cap on transmit attempts for this exchange, clamped to [1, EXCHANGE_RETRY_COUNT]. Callers whose failure is already re-armed elsewhere (a scheduler-owned status poll) pass SCHEDULED_POLL_MAX_TRIES so a dead device does not block loop() for the full retry product. |
| request_preamble_override | Preamble for the request frame in bytes, or 0 (the default) for request_preamble_for()'s rule. Only pairing passes a value: it sends its directed start frames with a preamble the device has just proven it hears (see PairingEngine::pairing_start_preamble_()). The 0x3D challenge response keeps the driver's response_preamble() either way. An override also switches the low-power wake belief off for that exchange: the caller has already chosen the preamble. |
Definition at line 352 of file exchange_engine.cpp.
|
inline |
Install the source of per-device system keys.
Installed once, when the hub is constructed. The engine picks the key per frame: our 0x3D is signed with the key of the request's destination, and an inbound 0x3D is verified against the key of the request's sender. A node the provider does not know, or one without a key of its own, uses the hub key, so every call site of send_and_receive() stays unchanged. With no provider installed every exchange uses the hub key.
| provider | Key lookup, or an empty function to detach. |
Definition at line 348 of file exchange_engine.h.
|
inline |
Install the source of per-target evidence.
Installed once, when the hub is constructed. The provider only looks evidence up; every decision drawn from it stays in the engine — the wake belief (decisions::wake_belief(), gated by the low_power_wake_belief tuning switch) and the re-send of an unconfirmed CMD_EXECUTE (decisions::retry_after_unconfirmed_accept_is_safe()), so each decision lives in one place. With no provider installed every low-power exchange keeps LONG_PREAMBLE on every try.
| provider | Evidence lookup, or an empty function to detach. |
Definition at line 332 of file exchange_engine.h.
|
inline |
Attach the observer transmit_frame() reports LBT deferrals and sent frames to.
One slot: attaching replaces the previous observer. PairingEngine attaches its PairingTelemetry for the duration of a discover_and_pair() attempt and detaches it afterwards; outside that, the slot is empty and reporting is a no-op.
| observer | Non-owning pointer, or nullptr to detach. |
Definition at line 313 of file exchange_engine.h.
|
inline |
Install what to do when an exchange planned on an AWAKE belief drew no frame at all: the receiver did not answer the short preamble a moving one hears, nor the wake-up preamble, so the belief that it is travelling is spent and the next exchange must lead with the wake-up preamble.
The engine decides when; the hub owns the device record and clears it. Installed once, next to set_wake_evidence_provider().
| handler | Evidence reset, or an empty function to detach. |
Definition at line 360 of file exchange_engine.h.
| bool esphome::home_io_control::ExchangeEngine::transmit_frame | ( | const IoFrame & | frame, |
| uint32_t | freq, | ||
| uint16_t | preamble ) |
Transmit a raw IoFrame with the given preamble length.
A frame that opens a session (CTRL0 START) listens before talk on the TX channel itself (the radio checks it inside send_packet(); see RadioTxConfig::cca_threshold_dbm); a frame without START answers within a session and goes out at once, unchecked (ADR 0045).
| frame | Frame to transmit. |
| freq | RF frequency in Hz. |
| preamble | Preamble length in bytes (e.g. LONG_PREAMBLE, SHORT_PREAMBLE, or a tuning-configured value such as normal_start_preamble). |
Definition at line 184 of file exchange_engine.cpp.
|
staticnodiscard |
Log label for a WakeBeliefUse that is not APPLIED (an applied one logs the belief itself).
| use | Value to name. |
Definition at line 503 of file exchange_engine.cpp.