This is a maintainer-facing reference for how EyeBond Local decides how a
collector is connected, who owns a live collector session, and whether the
integration may touch the collector's server endpoint. It is the contract that
the phase 2–8 refactor established; read it before changing anything in
connection/, runtime/link/, runtime/coordinator/,
collector/transport/, or passive_discovery.py.
The whole point of this design is that these decisions are explicit and opaque. They are not inferred from hostnames, peer IP addresses, or collector type at runtime.
Framed collector identity acquisition tries FC2 parameter2 first, then one FC1 full-PN challenge if no usable identity arrives. Both attempts stay pinned to the same physical session; this is not a fallback to another transport dialect. The pending-socket path and the claimed-session reader use the same method order. Explicit runtime dialect probes remain single-method. AT uses DTUPN unchanged. FC1 certification requires the request's TID/function and a valid PN longer than the ordinary 14-byte heartbeat prefix. Unsolicited heartbeat, peer address and cached PN do not certify identity. A valid foreign PN is rejected by the existing matcher, not retried until another method returns a preferred answer. Recovery still requires its own fresh-session proof; identity does not certify reboot, endpoint management, inverter telemetry or controls.
Every config entry carries three durable, opaque axes. They are resolved by
connection/connection_policy.py (which reads the explicit persisted value, else
derives it from legacy fields exactly as migrate_entry_axes does).
| Axis | Values | Meaning |
|---|---|---|
connection_strategy |
inbound | callback_on_demand |
Who dials whom. |
endpoint_control_policy |
external | integration_managed |
May the integration write/restore the collector's server endpoint. |
proxy_enabled |
bool | Retired compatibility field; new options writes keep it disabled. |
inbound— the collector connects to Home Assistant on its own (an ESP bridge, or a collector whose endpoint was already pointed at Home Assistant). Runtime never sends a UDP callback trigger; it claims or waits for the inbound session.callback_on_demand— Home Assistant asks the collector to dial back. Runtime sends exactly one UDP trigger per connect attempt (see below).
ESP EyeBond Collector persists a valid discovery redirect as its server endpoint. The options form may prefill the bridge's current management-readback endpoint, but that value is presentation context only: it is not a recovery proof and does not bypass the verified inbound transition. For that capability profile, callback is therefore a bootstrap route rather than a stable operating profile. Its normal options surface manages the Home Assistant endpoint and runs either callback-origin → inbound verification or a verified inbound → inbound endpoint relocation through this same transition authority. It never exposes a cloud rollback chooser.
An unknown collector is not a positively identified local-only collector. Missing inverter identity therefore cannot require HA-only operation, hide the connection form, or prune callback diagnostics. Its factory cloud capabilities remain unconfirmed; opening connection setup grants no write permission. The existing consent, live management checks and same-PN recovery proof still own every physical transition.
connection_strategy has exactly one owner: entry.data. Every authority
that can change it writes there:
| Authority | Writes |
|---|---|
| Config flow onboarding (verified) | data |
| Options form ("how the collector connects") | data, via one atomic async_update_entry(data=…, options=…) |
| HA-only / Cloud+HA operation-mode action | data (_persist_connection_axes) |
| Bind to HA / rollback endpoint | data |
entry.options must never hold an active copy. Before v4 it did: the options
form wrote the strategy into options while the endpoint actions wrote it into
data, and the resolver read options first — so a stale options value silently
shadowed a successful Cloud+HA / HA-only switch and the user had to re-pick the
strategy by hand. resolve_connection_strategy now reads data first and falls
back to options only as a pre-migration legacy source;
_connection_strategy_source reports explicit_data vs
legacy_options_pre_migration so a support bundle shows which one was used.
The options form commits data+options in a single async_update_entry, so the
entry reloads exactly once and only after the state is consistent (the terminal
async_create_entry(data=options) then writes an unchanged value, which HA
ignores without firing a second listener).
endpoint_control_policy and the endpoint write-provenance
(endpoint_written_value/at) live in entry.data for the same reason.
The runtime gates on the axis, never on the operation mode / hostname /
collector type: EybondLocalCoordinator._configure_reverse_discovery_mode uses
may_run_steady_reverse_discovery(connection_strategy).
The collector's server endpoint (SmartESS param-21 / CLDSRVHOST1) is an
opaque string. Do not parse a provider or an ownership claim out of its
hostname.
external— the integration observes the endpoint but never silently writes, restores, or auto-heals it. It may remember an observed external endpoint (for a later explicit rollback), which is provenance recording, not mutation.integration_managed— set only after the integration actually wrote the endpoint through an explicit user action (bind, or a mode switch that wrote). Only then may the per-poll reconcile keep the endpoint aligned to Home Assistant.
The per-poll reconcile is _async_reconcile_collector_operation_mode_endpoint,
gated by may_auto_manage_endpoint(endpoint_control_policy).
This field is retained only so older entries migrate without losing schema
compatibility. The former options toggle promised continuous HA + cloud
forwarding, but no runtime consumer implemented that behavior, so the toggle is
no longer exposed and new options writes normalize the field to false.
Temporary Collector traffic capture is a separate, explicit diagnostics action with its own route lease and timer; it is not controlled by this field.
The byte-transparent capture lease is also the exclusive first-reader of any causally new socket compatible with that route. While the lease is active, the normal runtime must not identity-probe or claim the reserved socket, even when its peer address or a previously observed PN looks familiar. Reading or writing before the transparent relay owns the socket would alter the real collector/cloud handshake. Baseline sessions and sockets whose observed wire shape cannot belong to the transparent route remain available to their normal owners. Unregistering the lease releases this reservation.
Capture shutdown owns the complete producer tree: both relay directions must finish before the recording sink closes. Cancellation of a coroutine awaiting an executor write does not stop the worker, so the trace writer retains its lock until that write/flush actually completes. Closing the trace is serialized with writes, and repeated cancellation cannot interrupt the mandatory drain. Trace errors must not bypass socket cleanup. These lifetime guarantees do not certify endpoint restoration, which still requires the separate live management check.
Temporary proxy/shadow startup does not own the primary callback listener's
health state. A failed auxiliary bind, upstream connection, or cancelled startup
must release the temporary handler/route/lease and propagate the error to the
cloud-tool transaction without calling the primary listener's error recorder.
Otherwise a healthy primary listener would stop issuing callback requests and
could not verify endpoint restoration. A successful temporary-route retry must
not clear an actual primary-listener error either. Only the primary lifecycle
changes that status. Route/handler references are published only after all
awaited startup steps succeed, including the lease's transition to running.
test_cloud_route_failures_preserve_primary_listener_and_callback_recovery
covers both cloud tools, failure stages, cancellation, retry and callback
suppression while a temporary route is active; a separate primary-bind failure
regression keeps genuine listener failures visible.
collector/transport/tcp_acceptor.py owns only listening sockets and provisional
stream creation. It uses the event loop's public reader registration and
socket-to-stream APIs. It does not modify Python's event loop, parse a protocol,
send a probe or make an identity decision. This avoids the asynchronous
asyncio.Server attachment window in CPython #109564.
- Accept and record raw socket ownership synchronously. Up to 100 provisional stream conversions may be in flight; established collectors do not consume these slots. Descriptor-pressure retries belong to this same acceptor.
- Transfer an initialized stream to
_SharedEybondListener._handle_connectiononly while admission is open. Before transfer, cancellation or failure closes the socket even if its task never started. After transfer, the listener owns the stream and its existing framed/AT/raw sniffing and routing rules apply. - On final listener release, fence admission, remove readiness callbacks and retries, cancel and join provisional handoffs, then dispose the listener's pending and routed sessions. Cancellation of the close waiter cannot abandon that drain. Already-queued callbacks cannot repopulate the retired inventory.
tests_ha/test_ha_tcp_acceptor.py forces closure between kernel acceptance and
the stream task's first instruction, during stream creation and during a held
handoff. It also covers repeated cancellation, bind rollback, resource pressure,
late readiness callbacks and final shared-listener cleanup with real loopback
sockets. The HA repair/reload cancellation test remains enabled and unchanged;
neither expected-error suppression nor sleeps are used to hide the race.
connection/session_registry.py (CallbackSessionRegistry) is the single object
that answers "which config entry owns which inbound session". Identity rules:
- Full collector PN is durable identity. Two sessions with different full PNs are always distinct collectors, even behind one NAT peer IP.
- Short PN is transient discovery identity only. A short PN observed from a
weak source may be a prefix of the full PN read later; the registry enriches
a short-PN claim into the full PN (
reconcile_pn) rather than creating a second owner. Prefix matching requires ≥10 chars (pn_is_same_identity). - Session id is transient socket identity only.
- Peer IP is diagnostic / UDP-target only, never an ownership or dedup key.
- A session is owned by exactly one entry.
Short/full PN reconciliation lives only in the registry (reconcile_pn /
pn_is_same_identity); the transport, link, and passive discovery delegate to it
instead of re-implementing prefix logic.
The live-wire truth comes from the registry too: runtime/link/ reads the
entry-claimed SessionHandle (via the runtime-scoped registry over the public
SharedEybondTransport.observed_collector_sessions() facade) and negotiates the
wire from the observed session, not from a persisted collector_session_protocol
hint. Untrusted states (route_identity_mismatch, waiting_for_route_identity,
parked_*, closed_*) can never override a claimed, routed session.
If a first-contact FC1 probe on an initially silent socket reveals only a short framed heartbeat, the exact-session negotiator may send one FC2 PN query on that same now-observed framed stream, within the caller's remaining deadline. This is a same-dialect identity upgrade, not an AT fallback or permission to claim the weak PN. Failure preserves the known framed candidate for a later normal attempt; only a still-unknown dialect is retired before trying another.
The framed runtime reader is fail-closed at the physical socket boundary. An idle socket may remain silent indefinitely, but once the first byte of a frame arrives, header and payload completion are bounded and the decoded local-runtime header must have a supported function code and plausible length. A malformed or truncated frame closes only that socket, fails its pending request, and records a typed close reason in the session inventory; a later callback starts with a new reader. The reader never scans forward for a convenient byte pattern and never reclassifies an unexpected raw RTU response as a framed reply, because either would risk dispatching bytes from different transactions together.
At an established framed boundary the reader also accepts an AT metadata reply
with one leading CR/LF pair, but only after a bounded lookahead confirms
\r\nAT+ followed by an uppercase command letter. That letter occupies the
binary function-code position and cannot be a supported EyeBond function.
All other prefixes remain untouched: CR/LF can be a valid binary transaction ID,
and AT-looking text inside a binary payload is still payload. This is separator
handling for the existing mixed metadata reader, not protocol negotiation or
permission to send inverter commands over raw AT passthrough. Header timeouts,
payload limits, auxiliary ownership and malformed-frame rejection still apply.
runtime/link/ sends exactly one async_probe_target datagram per connect
attempt, then bounded-waits for the inbound session. There is no continuous
DiscoveryAnnouncer loop in the connect path. Typed outcomes are recorded in
_last_callback_state and surfaced via callback_trigger_diagnostics() into
snapshot values (and the support bundle):
callback_connectedcallback_timeoutcallback_identity_mismatch(a foreign PN answered; none matched)callback_session_claimed_by_other_entry(a matching session is owned by a different entry)callback_listener_unavailable/callback_listener_error
inbound entries send zero UDP triggers, even while disconnected.
A callback-mode entry is legitimately idle between polls. Before a
callback_on_demand → inbound transition can write the persistent Home
Assistant endpoint, the coordinator therefore asks the existing runtime
connection path to establish one management session. This is not a second
recovery implementation: it uses the normal one-shot callback trigger and
causality lease, then pins the registry's exact currently-observed socket. The
neutral transition authority still refuses without that session and still owns
the active identity/restart/reconnect proof. The whole bootstrap and transition
hold the coordinator's runtime-operation lock, so ordinary polling cannot race
their wire traffic.
A strategy transition sends its endpoint write/apply or reboot through one exact, registry-owned physical session. The temporary management facade is session-pinned and its teardown preserves that socket; it cannot switch to a same-PN sibling and cannot create reconnect evidence by closing the socket itself.
Compound runtime endpoint reads compare the live registry-owned session id and
listener port before and after the operation. The periodic session-generation
counter is diagnostic, not the production readback authority: its observer may
tick after a new socket is already usable. A different socket invalidates the
read before metadata publication, even if the counter has not ticked yet.
The archive records session_identity_changed without exposing socket addresses
or command values. This check neither retries writes nor treats cached metadata
as proof that recovery succeeded.
The negotiated wire adapter owns the distinction between applying staged collector settings and requesting a reboot. On AT-text sessions these are separate vendor operations; an acknowledgement proves only command acceptance. The recovery authority still requires observable reset activity and a new same-PN session before it confirms a completed restart.
Reset activity is observed against the complete pre-action session cohort, not against one selected socket's EOF. Some collectors briefly keep several same-PN sockets alive: a baseline sibling may close, or a new socket may open, while the management socket survives. This activity only opens the recovery wait. A proof still requires a new session id outside the whole baseline, a strong exact PN, the expected listener route, and a successful same-owner registry retarget. Baseline sessions and foreign-PN sockets can never certify a transition.
- The integration never silently redirects a collector.
- Pointing a collector at Home Assistant (bind) and restoring its previous endpoint (rollback) are explicit, reversible actions.
- Explicit rollback is the only restore path for a previous endpoint. It
restores the remembered endpoint and flips
endpoint_control_policyback toexternal. integration_managedimplies the integration wrote the endpoint and recorded provenance (endpoint_written_value/at).
Endpoint serialization and route semantics are separate facts. The declarative collector cloud-profile catalog is the single authority for both:
host_port_protocolandhost_portcollectors expose an editable advertised port while preserving their required wire shape.host_onlycollectors store only the host, but the catalog default port is still part of the route. For the legacy binary family this is TCP502. When configuring the persistent HA-only endpoint, transition UI therefore does not ask for a port the firmware cannot store; listener preparation, reconnect matching and persistedadvertised_tcp_portall use502whileendpoint_written_valueremains host-only.- A
set>server=host:portcallback route is a separate transport fact. It does not inherit the persistent endpoint's host-only shape or implicit port. Its advertised port is explicit (and may be an external NAT/VPN forwarding port), whilelistener_portremains the local configured callback listener. The integration passively listens on the conventional502,8899and18899ports, but those listener defaults are not a global allowlist for advertised callback ports. - A pre-existing host-only record with generic-port metadata is never rewritten as a migration guess. Selecting the current HA-only profile routes through the normal restart/same-PN verification and commits the corrected route only after the collector reconnects on the fixed listener.
An active shadow-learning route temporarily owns the collector's endpoint
as the safety boundary of the scan. While it runs, the per-poll endpoint
reconcile is a no-op for every policy
(_async_shadow_learning_owns_endpoint returns early with
shadow_learning_active). This is route ownership, not operation-mode coupling,
and it never writes or restores.
- Hostname/endpoint-string ownership heuristics. Endpoint strings are opaque. Framing/protocol profiles may use a configured cloud family for byte framing, but never to decide ownership.
- Peer IP as identity or dedup key. Two collectors behind one public IP must stay distinguishable by PN/session. Peer IP is diagnostic / UDP-target only.
- Collector-type ownership heuristics. A bridge does not, by itself, define the payload session or the endpoint policy.
collector_operation_modeas a primary runtime driver. It survives as a compatibility/migration/display concept only; runtime branches on the three explicit axes.- Silent endpoint restore / auto-heal. Under
external, the integration must not write, restore, or auto-heal the endpoint. Only explicit user actions do. - A continuous callback announcer loop in the connect path.
callback_on_demandis one trigger per attempt.
async_migrate_entry in __init__.py; new entries are stamped at creation by the
config flow (EybondLocalConfigFlow.VERSION).
| Step | What it does |
|---|---|
| 1 → 2 | Derives the axes from legacy fields via migrate_entry_axes (hostname-free). |
| 2 → 3 | Corrective re-migration of the provably-unreachable inbound cloud-primary shape only (never manual/known-IP). |
| 3 → 4 | Makes entry.data the canonical owner of connection_strategy: freezes the entry's real pre-upgrade effective value into data and deletes the options copy. |
The v4 step uses legacy_effective_connection_strategy(), which reproduces the
OLD options-first resolution exactly. That is deliberate: when data and options
disagree, the entry really behaved as the options value said, so migration
preserves that actual behavior instead of "healing" the conflict toward data. The
frozen value is never re-derived from hostname, endpoint, cloud provider,
collector kind or peer IP.
After v4, entry.options is consulted for the strategy only as a legacy
fallback for an entry that has not migrated yet.