Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
esphome::home_io_control::ExchangeEngine Class Reference

#include <exchange_engine.h>

Collaboration diagram for esphome::home_io_control::ExchangeEngine:

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).

Detailed Description

Definition at line 74 of file exchange_engine.h.

Member Typedef Documentation

◆ BroadcastReplyHandler

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.

Parameters
frameParsed reply frame (responder's address is frame.src).
infoRSSI, receive channel, and post-transmit latency for this reply.

Definition at line 133 of file exchange_engine.h.

◆ TargetEvidenceProvider

Looks up what the hub knows about a destination node (decisions::TargetEvidence).

Parameters
dstDestination node ID (NODE_ID_SIZE bytes) of the request being sent.
outFilled with that device's evidence when it is known.
Returns
false when the destination is not a registered device (no evidence to give).

Definition at line 292 of file exchange_engine.h.

Member Enumeration Documentation

◆ WakeBeliefUse

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.

Constructor & Destructor Documentation

◆ ExchangeEngine() [1/2]

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).

Parameters
radio_ptrAddress of the hub's RadioDriver *radio_ member.
node_idPointer to the hub's node_id_[NODE_ID_SIZE] array.
system_keyPointer to the hub's system_key_[AES_KEY_SIZE] array.
tuningPointer to the hub's TuningConfig tuning_ member.

Definition at line 34 of file exchange_engine.cpp.

◆ ExchangeEngine() [2/2]

esphome::home_io_control::ExchangeEngine::ExchangeEngine ( const ExchangeEngine & )
delete
Here is the call graph for this function:

Member Function Documentation

◆ answer_challenge()

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.

Parameters
requestThe frame whose cmd+data the challenger wants proof of (the transcript).
challengeThe inbound 0x3C carrying the challenge bytes in challenge.data.
freqRF channel frequency (Hz) to transmit the 0x3D on.
Returns
true if the 0x3D was built and transmitted; false on a build or transmit failure.

Definition at line 571 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ authenticate_request()

bool esphome::home_io_control::ExchangeEngine::authenticate_request ( const IoFrame & request,
uint32_t freq )

Authenticate an inbound device command via 0x3C challenge / 0x3D HMAC.

Parameters
requestThe received inbound frame (e.g., CMD_STATUS_UPDATE).
freqRF channel the frame arrived on.
Returns
true if HMAC verified; false on timeout or mismatch.

Definition at line 849 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ collect_broadcast_responses()

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.

Parameters
requestFrame to transmit once (cmd + endpoints already filled).
freqRF channel frequency (Hz) for the initial transmit.
expected_cmdCommand byte a reply must carry to be considered.
window_msHow 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_replyInvoked 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.
policyWhich channels to listen on; see above. Defaults to ListenPolicy::ROTATE_SKIPPING_REQUEST.
Returns
Number of matching replies handed to on_reply (duplicates included).

Definition at line 654 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ counters()

const Counters & esphome::home_io_control::ExchangeEngine::counters ( ) const
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.

◆ get_debug()

const DebugInfo & esphome::home_io_control::ExchangeEngine::get_debug ( ) const
inlinenodiscard

Read-only access to the current debug snapshot.

Definition at line 378 of file exchange_engine.h.

◆ hop_frequency()

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).

Parameters
skip_freqChannel 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.

Here is the call graph for this function:

◆ listen()

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.

Parameters
specHow this listen window is to be spent.
packetScratch 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).
frameSame 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_frameInvoked for every packet the radio delivers; decides whether to accept, abort, or keep listening. See ReplyHandler.
Returns
ACCEPTED or ABORTED as on_frame decided, or TIMED_OUT if spec.window_ms elapsed first.

Definition at line 787 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ log_debug()

void esphome::home_io_control::ExchangeEngine::log_debug ( const char * device_id) const

Log the debug snapshot as a WARN-level structured line.

Parameters
device_idHuman-readable device identifier for the log line.

Definition at line 105 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ log_debug_unconfirmed()

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.

Parameters
device_idHuman-readable device identifier for the log line.

Definition at line 111 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ maybe_hop()

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.

Here is the call graph for this function:

◆ operator=()

ExchangeEngine & esphome::home_io_control::ExchangeEngine::operator= ( const ExchangeEngine & )
delete
Here is the call graph for this function:

◆ record_debug()

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.

◆ request_preamble_for()

uint16_t esphome::home_io_control::ExchangeEngine::request_preamble_for ( const IoFrame & request) const
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.

Parameters
requestFrame the preamble is being chosen for.
Returns
Preamble length in bytes.

Definition at line 431 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ reset_counters()

void esphome::home_io_control::ExchangeEngine::reset_counters ( )
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.

◆ reset_debug()

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.

◆ reset_hop_timestamp()

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.

◆ send_and_receive()

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.

Parameters
requestFrame to transmit (cmd + endpoints already filled).
responsePopulated only for ExchangeOutcome::SUCCESS_WITH_RESPONSE.
freqRF channel frequency (Hz).
max_triesCap 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_overridePreamble 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.
Returns
What the device actually told us — see ExchangeOutcome.

Definition at line 309 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ set_target_evidence_provider()

void esphome::home_io_control::ExchangeEngine::set_target_evidence_provider ( TargetEvidenceProvider provider)
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.

Parameters
providerEvidence lookup, or an empty function to detach.

Definition at line 301 of file exchange_engine.h.

◆ set_transmit_observer()

void esphome::home_io_control::ExchangeEngine::set_transmit_observer ( TransmitObserver * observer)
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.

Parameters
observerNon-owning pointer, or nullptr to detach.

Definition at line 282 of file exchange_engine.h.

◆ transmit_frame()

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.

Parameters
frameFrame to transmit.
freqRF frequency in Hz.
preamblePreamble length in bytes (e.g. LONG_PREAMBLE, SHORT_PREAMBLE, or a tuning-configured value such as normal_start_preamble).
Returns
true if the radio accepted the packet; false otherwise.

Definition at line 167 of file exchange_engine.cpp.

Here is the call graph for this function:

◆ wake_belief_use_name()

const char * esphome::home_io_control::ExchangeEngine::wake_belief_use_name ( WakeBeliefUse use)
staticnodiscard

Log label for a WakeBeliefUse that is not APPLIED (an applied one logs the belief itself).

Parameters
useValue to name.
Returns
"not_low_power", "override", "off", "no_provider" or "applied".

Definition at line 440 of file exchange_engine.cpp.


The documentation for this class was generated from the following files: