|
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 | 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). | |
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. | |
| 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 LBT and 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 | 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 133 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 292 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 312 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 34 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 571 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 849 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 654 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 415 of file exchange_engine.h.
|
inlinenodiscard |
Read-only access to the current debug snapshot.
Definition at line 378 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 128 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 787 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 105 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 111 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 150 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 59 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 431 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 419 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 42 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 126 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 309 of file exchange_engine.cpp.
|
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 301 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 282 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 LBT and the given preamble length.
| 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 167 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 440 of file exchange_engine.cpp.