Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
The neutral engine reports both the user disconnect trigger and the
connect action's abort hook from one callback list, so an on_disconnect
automation that calls ble_client.connect cancelled the attempt it had
just started and never ran the rest of its chain. The legacy engine
avoided this by keying the two on different events (CLOSE_EVT for the
trigger, DISCONNECT_EVT for the abort); the hook now skips the abort
while a fresh attempt is in flight.
connected() no longer reports a link with an accepted teardown in
flight, so a connect action started in that window stops taking the
already-connected fast path, and a second disconnect() in that window
is ignored rather than settling the link twice.
The write/notify completion contract claimed every completion arrives
from the event loop, which the rp2 backend contradicts for the two
local-only operations; it now names them and requires consumers to arm
their pending state before issuing an op.
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
The neutral write callback reports an int status, so %x printed
errno-style negatives as ffffff..; include the failing handle too.
Also correct the hex buffer comment: the size comes from the V2
minimum payload, not the longest message.
A table-build failure that keeps the link for legacy nodes now charges the
backoff, so an unwalkable database stops retrying at advertisement cadence.
The write action re-resolves from the current table only. The neutral engine
drops a discovery completion that raced a teardown. Slot charging and the
cap-check skip read one mapping, so a platform cannot be skipped uncharged.
The registry entry now carries its materializer gate, so rp2 builds stop
receiving a Bluedroid define. Also: parent before the capacity check in
register_gatt_node (neutral parity) and BluedroidServiceTable is
non-copyable (owning raw pointer).
USE_BLUEDROID_GATT_SERVICE_TABLE compiles the Bluedroid materializer and
nothing else; the old name read like a cross-backend consumer capability
(rp2 always materializes and ignores it).
Each backend arm now has a codegen-emitted USE_BLE_GATT_BACKEND_<NAME>
define, making the alias ladder order-independent and letting the proxy
capability constants describe the backend instead of the platform (a second
esp32 backend carries its own facts). The legacy raw-gattc engine is
selected by USE_BLE_CLIENT_LEGACY_ENGINE instead of platform identity, so
esp32's future move to the neutral engine is one line per language. The
address-scoped maintenance calls key on the stack define they wrap.
Connect-timing ownership (the backend integrates with its platform's
scan/connect arbitration), the asynchronous-completion rule for accepted
ops, and the reserved error ranges with the wire-frozen values - all learned
the hard way by the first out-of-tree backend.
has_legacy_nodes_() lives next to the invariant it derives from; both
bailout outcomes reach one promotion loop; the carve assert relaxes to the
descending chain the comment claims; the notify op string names the IDF
call like its siblings.
The static_assert enforces what the carve comment claims; the node header
now states that aborting inside on_connected() can leave on_disconnect
unpaired; the passive-proxy test comment names the current gate; the
pending-regs comment lands on the right member (lost to an aborted batch).
A failed search still poisons the whole discovery, but an allocation or walk
failure is table-specific: on a mixed client the legacy nodes keep the link
and only the gatt nodes skip that connection.
The write action's own tag left Automation::TAG with zero readers;
automation.cpp existed only to define it. Also notes the unpaired legacy
disconnect trigger on the failed-discovery path and re-anchors the
gatt_nodes_ comment.
One ConnectBackoff shared by both engines replaces the forked hold-off
fields and constants (the duration is derived from the failure count). The
pending-registration scan lives once, sized by its own bound instead of the
node counter. The failure-suppressing SEARCH_CMPL arm moves to the call site
so the dispatcher stays void, notify-state logging is one helper on the
neutral wording, and the op-check helper takes the backend's spelling.
Failed notify enables, reads, writes and unsubscribes now leave the same
breadcrumbs as the neutral engine; the shared-completion guarantee moves to
the node contract header.
set_enabled(true) resets the hold-off (neutral-engine parity), and every
failing build() return now resets the counts so a failed build can never
present a non-empty view.
A discovery the bridge classifies as failed now suppresses the legacy
SEARCH_CMPL fan-out, so user automations never observe the doomed link as a
connection (neutral-engine parity, including the mid-fan-out node abort).
Also documents that same-handle subscriptions from mixed node kinds are
unsupported during the migration window.
A peer that reliably fails discovery (walk error, allocation failure) would
otherwise reconnect on its next sighting forever, holding a controller slot
each cycle - the neutral engine already backs off on this path.
Neutral fan-out runs before the legacy nodes so on_connect triggers observe
resolved gatt nodes (the neutral engine's order). Pending notify
registrations move to a fixed array (no post-setup heap). Gatt nodes also
join nodes_, collapsing the twin state loops. One define now means 'the
neutral node surface is compiled in' on both engines. The service count
moves into BluedroidServiceTable, free() resets it, bridge ops log failures,
the write action carries its own tag, and the automation headers drop
includes that left with the write actions.
Platforms whose BLE stack owns a connection budget (esp32, rp2) report an
overcommit exactly once through that stack; the neutral cap check covers
future backend platforms without one. The write action logs unmatched
completions while a chain is parked, and the schema-dump gap is documented
as deliberate.
One implementation replaces the raw-gattc one and runs on esp32 and rp2
alike; user YAML is unchanged. The node resolves the V1/V2 variant from the
service table, owns its CCCD write, and keeps the drop-the-link-between-polls
protocol.
The raw-gattc and neutral twins collapse into ble_write_action.h, registered
through register_gatt_node on both engines. On esp32 this is the in-tree
exerciser of the node bridge; builds without a ble_write action compile no
bridge code.
BLEClientFeatures (gatt_node / raw_gattc / security) with a per-platform
provider set, capability-named validation errors, NODE_BLE_CLIENT_SCHEMA +
register_gatt_node for migrated components, and the security actions gated on
the feature instead of the platform. esp32 auto-loads bluetooth_connection
for the shared materializer (compiles empty without a neutral node).
Nodes registered through register_gatt_node() get the neutral surface:
service table at SEARCH_CMPL (honoring the search status the base ignores),
translated completion/notify fan-outs, pairing results, and teardown via
on_disconnect_complete. Bridge-initiated notify registrations are intercepted
before the base's automatic CCCD write - the CCCD is the node's job on the
neutral contract. Compiled only under USE_BLE_CLIENT_GATT_NODES; builds
without a neutral node are unchanged.
BluedroidServiceTable carries the two-pass build verbatim in its own TU; the
backend embeds one instance. ble_client's esp32 engine becomes the second
consumer next.
The neutral callbacks (plus a new on_pairing_result hook) live unconditionally
in ble_client_node.h; the raw esp32 surface stays beneath them for unmigrated
nodes. Adds find_descriptor beside find_cccd and pair()/unpair() on the
neutral client. Drops the node 'client' pointer and address, which nothing
reads.
Every teardown path routes through release_services(), so the
materialized table cannot outlive its link - said at free_service_table_
so the next reviewer need not re-derive it.
- The rejection test registers the hub and matches the extra-keys error,
so the missing-tracker error can no longer satisfy it vacuously
- register_ble_node logs and drops loudly at capacity: a push_back past
a StaticVector's bound is a silent no-op, and an undersized slot count
must show at boot, not as an unresolvable node
- Every walker failure names the failing call and status; the descriptor
cap gets its own message so a >64-descriptor characteristic is
diagnosable instead of a permanent silent backoff loop
The schema split's accept side was tested; the reject side was not - a
key leaking from the legacy arm into the neutral schema would have
passed silently.
- The empty-table bailout ran after state_ went CONNECTED, so its
teardown's terminal report read as a completed connection and fired
on_disconnect with no preceding on_connect - the exact trigger
contract violation the sibling error branch avoids. The check now
runs first and the teardown resolves through connect_failed; a
node-less client still reaches CONNECTED through the single
assignment after the guard
- The esp32 arm claims its slot through consume_gatt_slot like every
other claimant (behavior-identical: it forwards to the same esp32
validator), so the ledger's one-spelling contract holds
- A zero-service table on a client with nodes is treated as a failed
discovery (warn, release, charge the backoff, disconnect): a real GATT
peer always exposes at least GAP/GATT, so an empty table means the
materialization failed and the connection must retry instead of
sitting inert behind a successful on_connect
- The fan-out abort guard also tests cancel_requested_, covering the
normal async teardown a node starts from on_connected (the state-only
check caught just the synchronous refusal)
- nodes_ becomes a StaticVector sized by ESPHOME_BLE_CLIENT_MAX_NODES:
the client requests a baseline slot, each neutral write action (which
registers itself in its constructor) requests one, and the define
rides defines.h for analysis - no realloc machinery on the rp2 target
- The Bluedroid empty-table return logs its quiet cases; the
service_table=False stub notes the misconfiguration it implies
A gate-refused send to a peer that is not api_connection_ (the
subscribe-time path during a handover) must not arm a retry that
loop() would aim at a different connection.
Overkill for its window: the address latch, drain, reuse guard and
session hygiene bought coverage for a case the client's own timeouts
and the retried connections-free state already heal. The notification
goes out when it can; the connections-free retry (the hardware-verified
fix) stays.
- Both retry latches clear on subscribe/unsubscribe: a new subscriber
must not receive a connected=false for an address from the previous
session (it resyncs through its own subscribe requests)
- The app-register failure path states its BLEClientBase parity so the
no-retry behavior reads as deliberate
The stack-down settle skipped release_services() locally, but its
report routes into the wrapper's reset which calls it anyway - the
warn survived. Gating the clean on an active stack inside
release_services() covers every path, and the stack-down branch goes
back to the plain call.
- The stack-down settle resets the stream latches directly instead of
calling release_services(): the dying stack invalidates its own cache,
and the newly checked cache_clean would warn on every OTA or
ble.disable with a live connection
- static_asserts pin the exactly-sized state bitfields so a future
enumerator truncates loudly at compile time
The connections-free retry left its sibling asymmetric: on the same
full buffer the slot could report free while the device still read as
connected, with nothing resending the connected=false. A one-deep latch
(address + error) drains at the same 100 ms cadence and re-latches on a
repeat failure; a re-reservation of the address clears it, since the
client re-requesting the connect has already acted on the disconnect
and a late resend would shadow the new connection. A lost
connected=true stays client-timeout territory.
An unknown or misspelled platform name yielded an empty set - the file
would be filtered out of every build (link failure at the end of a long
compile) and the subset guard passed vacuously.
The one IDF call in the backend that skipped check_and_log_error_. A
failed clean leaves a stale database the next connection could serve as
authoritative, with nothing in the log to point at it. (The call fails
only at dispatch - stack down - not for an empty cache, so this cannot
warn-spam routine teardowns.)
The collapse of the CONNECTING branch dropped a real race fix: HA
re-requesting a connect while a scheduled teardown was still pending
used to cancel the teardown and let the in-flight open complete;
without it the request was ignored and HA paid a teardown plus a fresh
connect during exactly the reconnect churn this path sees most.
cancel_gatt_disconnect() joins the contract: true only for a scheduled
teardown that has not started closing (Bluedroid clears the latched
want_disconnect_); rp2 and the stubs return false since their teardowns
start inside gatt_disconnect(). The wrapper's cancel_teardown() returns
its state to CONNECTING and the proxy handler carries the dev branch
verbatim.
- Both esp_ble_gap_set_prefer_conn_params calls route through
check_and_log_error_ like the class this PR replaces did; a rejected
preference leaves the link on the controller default interval, which
is exactly the WiFi-coex failure the shared constants exist to avoid,
so it must be visible in logs
- The connections-free retry moves below the 100 ms gate so a full TCP
buffer is retried at the loop cadence instead of every iteration
- The late-OPEN_EVT reclaim was the one unchecked IDF call in the
backend; a failed close there leaks a live link nothing tracks
- The mid-stream services_released_ park now leaves a trace like its
API-lost sibling, so a stuck GetServices is explicable from logs
Round 2 found no code issues - the round-1 mechanisms verify clean on
disk (every IDLE transition through the one door, the teardown-guard
trio minimal-complete per event, the error latch airtight, the reset
lists disjoint by construction). What remained was comment drift:
disconnect_pending() spelling, the OPEN_EVT and cached-MTU comments,
the contract header re-wrap, the two claim-a-slot docstrings
disambiguated, and ragged wraps from earlier text excisions.
- set_idle_() is the single door back to IDLE (bootstrap, open-fail and
the DISCOVERED park went through bare set_state), so the per-attempt
reset list holds only per-attempt latches
- CFG_MTU joins OPEN_EVT and SEARCH_CMPL in suppressing reports while a
teardown owns the link - one spelling of the guard across all three
events, and the wrapper's race arm becomes defense instead of the only
cover
- The connections-free retry drain compiles on every proxy build (the
advertisement-only arm sends the message too; the latch already did)
- latch_pending_error_ makes first-cause-wins the mechanism at all three
latch sites; the dead freed-slot refused branch and its retired
vocabulary go; the initiate_connection/start_connect_ pair collapses
- UNSET_CONN_ID hoisted next to the field it initializes; count-status
shadow renamed; stale busy-error rationale replaced with the real one
(a repeat call would re-arm the teardown timer)
- Fixture states the batch-grouping caveat like its rp2 sibling; the
get_service_table stub carries a greppable direct-consumer warning
Log lines ride the same API connection; a debug line at the exact
moment the TCP buffer is full adds traffic when it can least afford it
(the api layer's own buffer-full log is verbose for the same reason).
Field bug on a Pico W: the slot freed device-side but the API client
kept free=0 with the address still allocated. send_message drops the
response when the TCP buffer is full (a boot storm makes that likely
right when HA reconnects), and nothing ever resent it - the client's
slot state is verbatim the last response received, so it stayed stale
until reboot. The cached response is current by construction, so a
pending bit retried from loop() is an idempotent resync; the
subscribe-time send heals through the same bit.
- The esp32 arm's ESPHOME_BLE_GATT_CLIENT_COUNT shadowed the unanchored
regex; the pin now searches the USE_RP2 platform block
- The contract states what the wrapper relies on: nonzero from
gatt_disconnect means nothing to tear down, an accepted teardown
always reaches a terminal report
A client with only connect/disconnect automations paid the build/free
cycle on every reconnect for nothing - post-setup heap churn on the
Bluedroid direct-consumer path. release_services() stays unconditional.
notify_characteristic() had no completion path to the node that asked;
the hook lands now while the surface has no external users, with the
client fanning out after its breadcrumb.
The contract test's own #define only covers its translation unit;
ble_gatt_client.cpp needs the define from the build to link the
find_characteristic/find_cccd definitions the lookup tests call.
The Bluedroid on-demand materializer, the neutral lookup helpers' host
tests, and the USE_BLE_GATT_SERVICE_TABLE define move here, next to
their first consumer (the neutral engine's table resolution and the
service_table codegen flag). One behavior fix rides along: hitting
MAX_DESCRIPTORS_PER_CHARACTERISTIC now fails the walk like every other
inconsistency instead of truncating the table silently.
discover_services() already returned the contract's not-connected value;
the read/write/notify/pair ops fell through to an arbitrary stack error
instead. Dead for the proxy (the wrapper gates on connected()), live for
a direct consumer racing an op against a teardown.
Nothing in this PR emits USE_BLE_GATT_SERVICE_TABLE, so the Bluedroid
materializer, the neutral find_service/find_characteristic/find_cccd
helpers, their host tests, and the defines.h entry were scaffolding with
no caller here. They move to the PR that introduces their first
consumer. The contract op stays: get_service_table() keeps its {} stub
(the proxy streams in place).
- on_notify_state and on_pairing_result are handled on the neutral
client: a failed registration or pairing logs instead of vanishing
into the default no-op on a frozen surface
- A node tearing the link down during the connect fan-out leaves a
warning, since on_disconnect then fires with no preceding on_connect
- An INVALID_OFFSET/NOT_FOUND before the count from the same cache is a
contradiction, not an end-of-range: the walker fails the build and the
streamer aborts with the real cause instead of silently truncating
(the walker's descriptor loop keeps its terminator - the 64 cap is its
only bound)
- unconditional_disconnect_'s unset-conn-id path drives the slot to a
terminal reported state instead of leaning on the scheduled-teardown
timer
- find_characteristic/find_cccd log a corrupt range before returning the
not-found sentinel (moved out of line - no ESP_LOG in headers)
- connect()/disconnect() log a rejected user action like the legacy
engine instead of silently ignoring it
- The service_table docstring states the flag is Bluedroid-only forward
scaffolding and that rp2's proxy hub must keep its materializer
regardless (it streams through get_service_table())
- Review-round comments tightened to repo style
- handle_search_cmpl_ takes the event status: a failed discovery no
longer reads as a clean zero-service success (the empty cached
database satisfies the count calls), and the table invalidation plus
param step-down still run on the failure path
- A late successful OPEN_EVT on a slot the teardown net already gave up
is closed instead of leaking a live controller link with no conn id
- The counting pass logs its failure like the two paths below it
- static_assert pins the wrapper's compile-time streamer detection; a
signature drift would silently fall back to a table proxy builds
compile without
- find_characteristic/find_cccd range math widened to 32-bit (correct by
type; the wrapped-end case was already loop-safe)
- The fan-out early return releases the borrowed table itself instead of
relying on the backend's own teardown release
- A registered non-esp32 backend platform without a slot cap now fails
validation loudly instead of failing open (the test pin remains)
- new_gatt_backend's docstring says the service_table flag is forward
scaffolding for the first esp32 direct consumer, not load-bearing on
any current build
Both backends report the no-response completion (Bluedroid at
WRITE_CHAR_EVT, rp2 synthesized or via the can-send path); the neutral
write action already relies on it.
The measured layout showed 56+48 = 104 B/slot vs dev's 96. Two changes
that only pay together (the wrapper is 8-aligned, so nothing under a
full 8 helps):
- The tail packs into 2 bytes of bitfields; remote_addr_type_ becomes a
start_connect_ parameter (written and read on adjacent lines only)
- The wrapper's duplicate 10 s teardown timer is gone: the backend owns
the whole safety window. Its timer now also arms for a teardown
scheduled during CONNECTING (the one case the wrapper covered alone),
a late OPEN_EVT on a given-up slot closes the link instead of
resurrecting it, and the wrapper's transient-refusal branch collapses
because both backends return nonzero only when already idle
48 + 48 = 96 B/slot, the split at dev parity.
The measured layout showed 56+48 = 104 B/slot vs dev's 96. Two changes
that only pay together (the wrapper is 8-aligned, so nothing under a
full 8 helps):
- The tail packs into 2 bytes of bitfields; remote_addr_type_ becomes a
start_connect_ parameter (written and read on adjacent lines only)
- The wrapper's duplicate 10 s teardown timer is gone: the backend owns
the whole safety window. Its timer now also arms for a teardown
scheduled during CONNECTING (the one case the wrapper covered alone),
a late OPEN_EVT on a given-up slot closes the link instead of
resurrecting it, and the wrapper's transient-refusal branch collapses
because both backends return nonzero only when already idle
48 + 48 = 96 B/slot, the split at dev parity.
- SEARCH_CMPL honors the event's own status: a failed discovery leaves
an empty cached database that the count calls read as a clean zero,
which would have become an authoritative empty service list
- The loop stays enabled while a link exists and settles only at IDLE -
the stack-down recovery needs a tick to run in ESTABLISHED, which the
settle-on-established optimization was silently blocking
- The pre-started search uses the OPEN_EVT conn id (replaced-class
parity)
- read_descriptor and notify_characteristic passthroughs join the frozen
surface so the first migrated node can subscribe without extending the
client; the comment now says which ops have callers today
- The choke-point test also validates through the public
BLE_CLIENT_SCHEMA, so removing the cv.All wiring fails a test
- DOMAIN no longer sits between the BTstack comment and the constant it
explains
- A refused MTU request no longer wedges the connection: OPEN_EVT
reports with the default MTU so the consumer proceeds (previously no
CFG_MTU_EVT meant no connected report and a slot that never freed)
- Disabling the BLE stack settles a live link with a connected=false
report before forgetting it, so the consumer frees its slot instead of
holding a phantom connection
- A refused security response answers the pairing request with the
failure instead of hanging it
- The service-table walk mismatch logs its reason before discarding
- The shared streamer's two bounds-check aborts use abort_service_stream
with an ATT Unlikely Error cause (new shared GATT_ERR_UNLIKELY)
- A refused MTU request no longer wedges the connection: OPEN_EVT
reports with the default MTU so the consumer proceeds (previously no
CFG_MTU_EVT meant no connected report and a slot that never freed)
- Disabling the BLE stack settles a live link with a connected=false
report before forgetting it, so the consumer frees its slot instead of
holding a phantom connection
- A refused security response answers the pairing request with the
failure instead of hanging it
- The service-table walk mismatch logs its reason before discarding
- The shared streamer's two bounds-check aborts use abort_service_stream
with an ATT Unlikely Error cause (new shared GATT_ERR_UNLIKELY)
The three coordinated flags become one SearchState (NONE / PRESTARTED /
PRESTART_DONE / CLAIMED / REPORT_PENDING): illegal combinations are
unrepresentable, delivery and the two reset sites collapse to single
assignments, and the requested-meets-done subtlety becomes a named
state. A second discover_services() while one is in flight now returns 0
instead of issuing a duplicate search (one completion is already owed).
Same RAM: the enum sits in a 4-bit bitfield in the same flags byte.
- The connected fan-out stops if a node tears the link down mid-loop
(latent until a backend settles a teardown synchronously)
- A synchronously refused disconnect logs its own warning - a backend/
client state divergence is no longer the most quietly logged outcome
- An action-initiated connect before any sighting warns that the public
address type is assumed (an opaque repeated failure becomes
self-diagnosing); the connect() doc no longer mentions a config key
that does not exist
- The explicit-connections ledger test asserts the exact charged count;
the host-test override comment names the current gate macro
The latches were cleared only in set_idle_(), but the stack-down branch
in loop() reaches IDLE through set_state() without it, so after a
ble.disable/enable cycle the next connection's discover_services() would
complete immediately from the previous connection's latched result and
HA would cache an authoritative empty service list. tracker_connect_()
now resets the latch alongside the two per-attempt flags it already
clears, and delivery consumes the whole latch so a re-discovery on a
live link issues a real search instead of re-reporting the first
result.
- Explicit `connections:` entries now charge the slot ledger like the
generated ones (dev let them evade the controller budget); pinned by a
test
- set_enabled(true) clears the connect backoff - a re-enable is an
explicit try-again
- The neutral engine's source filter uses the shared
frameworks_for_platforms helper; ledger tests use the conftest fixture
- abort_service_stream() on the wrapper (which owns the latch discipline)
replaces five identical field-poke sequences in the streamer
- The pre-start reuses check_and_log_error_ like the serialized path
- discover_services() sets the requested flag where it sticks instead of
set-then-rollback; deliver_pending_search_() returns void (no caller
reads it); loop()'s IDLE arm folds into the settle arm
- SEARCH_CMPL during a teardown skips the param/count work whose result
is never delivered
- frameworks_for_platforms() in config_helpers derives framework sets
from platform lists; the hub.cpp filter entry is now a named map pinned
by a test against the proxy's platform list
The split serialized service discovery behind the MTU exchange: the
backend reported connected only at CFG_MTU_EVT and the wrapper started
discovery on that report, costing one ATT round trip per uncached
connection. The backend now pre-starts the search at OPEN_EVT (it knows
the connection type) and completes the consumer's discover_services()
from it: SEARCH_CMPL latches silently until requested, the flush after
the connected report delivers in the same event drain, and a refused
pre-start falls back to the serialized path. Net RAM cost is zero (the
flags pack into one bitfield byte plus a status byte, replacing the two
existing bools).
Also from review: SEARCH_CMPL no longer clobbers a teardown in progress
with ESTABLISHED, the five streamer abort sites latch pending_error_ so
HA sees the real Bluedroid status instead of a generic HCI reason, and a
completed re-discovery frees a materialized service table before the
counts its offsets derive from move.
The hub wrapper serves the proxy's API surface, but its gate keyed on the
backend define alone while the backend guarded its own proxy pieces on
USE_BLUETOOTH_PROXY. Narrow BLUETOOTH_CONNECTION_HAS_GATT to require both
so a future backend-only consumer build agrees with the backend's guards.
No change to any build this PR can produce (only the proxy emits
USE_BLE_GATT_CLIENT here).
- Skip the hub-platform GATT slot cap in testing mode (mirrors
esp32_ble.validate_connection_slots) so grouped builds validate
- Derive the neutral engine's source filter from the backend registry so
a new platform cannot validate and then fail at link
- Log the real code on refused service discovery and cancelled connects;
read completions get the same breadcrumb writes have
- Comment accuracy: the dumper arm is the legacy shape, not a superset
- Ledger tests: real-validator end-to-end path and the testing-mode skip
- Fold the one-line event-handler forwarders, the state accessor renames,
and the report helper into direct calls; drop the dead forward
declaration and the tracker_client() accessor (the backend is the
tracker client)
- Hoist DEFAULT_ATT_MTU next to the other shared GATT constants and use
it in all three engines
- Emit BLUETOOTH_PROXY_MAX_CONNECTIONS once, in _connections_to_code
- Drop the unused esp_gatt_common_api.h include and DOMAIN constant
- Comment accuracy: stale shim/esp32-parity wording, registry roles,
request_gatt_client() caller
- Drop unused includes (ble_client_state.h, <utility>) and the unused
notify/indicate property constants (no CCCD path yet)
- Note the frozen node-facing surface on the uncalled backend forwarders
- Keep the legacy-only 'name' key out of the neutral schema
- Move the slot-ledger test to tests/component_tests/bluetooth_connection
- Comment accuracy fixes (choke-point wording, wrapped lines, backoff note)
description:Test an ESPHome display driver on a real panel with a camera. Use when a task asks to verify a display (LCD, e-paper, OLED, RLCD) on hardware, check orientation/rotation, partial updates, colours, contrast, or that text and shapes render correctly. Covers finding the USB port, flashing a test pattern, capturing the panel with any camera, and reading the photo.
allowed-tools:Read, Write, Edit, Bash, Glob, Grep
---
# Display testing with a camera
Point a camera at the panel to get visual evidence that a driver renders correctly. Any camera
and capture tool works; this skill covers the common ones. `$SKILL` below is this directory
(`.agents/skills/display-test`).
## Ask before using a camera
A camera can show people and rooms, not only the panel. Before the first capture, ask the user
for permission and for which camera to use. List the cameras the host has (the commands below
show how), name the one you intend to use, and wait for the user to confirm it. Do not pick a
camera yourself, and do not fall back to another camera if the chosen one fails; ask again.
The permission covers this session and the camera the user named. Ask again for a different
camera or in a new session. If the user declines, stop and report that the display was not
checked visually.
## Capturing a frame
Grab a still image, skipping the first few frames so auto-exposure and white balance settle.
Read the JPEG with the Read tool; the image is shown visually.
### macOS
The bundled Swift tool uses AVFoundation and avoids the frame-grabbing problems some USB
@@ -15,19 +15,26 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
1.**Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that
port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py <port> "`,
naming the port so a capture on another board survives; the bracket keeps the pattern from matching
the shell that runs it. Never run `esphome logs` in a
`while true` loop: the loop respawns it and holds the port.
the shell that runs it. When more than one agent session shares the bench, run a copy of the script
under a session-specific name (`cap<topic>.py`) so another session's `pkill` cannot take your capture
down. Never run `esphome logs` in a `while true` loop: the loop respawns it and holds the port.
2.**Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with
`!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs.
`!secret`. A copy of a device's own config keeps using the secrets file next to that config: two
checkouts can hold different networks, and the wrong one leaves the device unreachable after the OTA. Never paste a config with credentials into a PR, never commit scratch configs or logs.
3.**Build from the tree under test.**`python -m esphome` puts the current directory first on
`sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by
its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the
checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep
the build's `src/` for the change before trusting any number.
the build's `src/` for the change before trusting any number. Tool calls sent together share one
shell, so a `cd` in one can land another's compile in the wrong tree: pass `PYTHONPATH=<worktree>` on
every compile and upload command and print `esphome.__file__` into the compile log.
4.**PR build and baseline build get different `name:` values.** Builds share
`.esphome/build/<name>`, so the same name means `esphome upload` can flash the wrong binary. Saved
Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh
name every run.
Wi-Fi credentials are keyed by the config hash when the config has `networks:`, so a test that must
start unprovisioned gets a fresh name every run. A config with `ap:` only and no `networks:` uses one
fixed key shared by every such config on the board, so a new name does not help there: wipe once at
boot with an `on_boot` lambda that resets the preferences and saves a marker key
(`templates/dut-ap-only-wipe-once-bk72xx.yaml`).
5.**Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD`
checked out in a throwaway worktree), with the baseline commit named in the table. When a delta
looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it.
@@ -73,7 +80,7 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test |
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner |
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port |
@@ -86,12 +93,15 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
-`ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`,
`--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and
`--serial-of PORT` (the serial number behind a path).
-`serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped
capture, ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
-`serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append] [--ms]`: bounded timestamped
capture (`--ms` adds milliseconds for timing work), ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
deadline. OUT is truncated unless `--append` is given, so counts over it cover one run.
-`flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot
strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not.
-`flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN.
-`flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now
and then; retry up to three times.
-`api_button.py HOST KEY list|press NAME [...]`: list a device's template buttons or press them over the API; the
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame).
## Templates in `$SKILL/templates`
-`dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and
@@ -107,17 +124,51 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
-`client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real
credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a
plain provisioning check.
- Per-topic device configs, each described in `reference/recipes/<component>.md`: `online-image-dut.yaml`,
and the directories `store_yaml/` (package with secrets one directory up, per-board files),
`http_request_update/` (manifest URL setter shapes, per-board files), `modem/` (modem only and modem plus
Wi-Fi boot checks), `ble_midi/` (fake BLE MIDI instrument and the client), `http_request_action/` (request
action package with per-board files and an OTA flash variant) `uart_sim/` (simulator header, common package, per-build parts and run files) and `remote_base/` (common
package, transmit script, per-board templates and the ESP8266 software sender).
## When the PR keeps moving
Record every head in the stack (`gh pr view N --json headRefOid`), build, and recheck before flashing; if anything
moved, wait five minutes and check again so the test runs on a settled head. When a head moved only by merging dev,
compare the PR's own diff (`git diff $(git merge-base OLD origin/dev) OLD` against the same for NEW); if it is
identical and the dev commits do not touch the code under test, earlier results still describe it, but rebuild so the
comment quotes the exact commits. Fetch `origin dev` before computing a merge base.
## When something looks wrong
- A capture stopped early or a line is missing: check the timestamps before trusting it.
- A script that pipes into `tee` never ends although its work is done: a background command written as
`(cd dir && server & echo $!)` backgrounds the whole `cd && server` list, so a shell stays behind holding the
pipe open and `$!` is that shell, not the server. Write `(cd dir && exec server > out 2>&1 < /dev/null) &`.
- The client never joins the AP: another board may still run an AP with the same SSID, or the device
booted with saved credentials because the name was reused. One AP device at a time on the bench.
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3).
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse
too: two binaries that differ only in the build timestamp and in `__LINE__` values of log calls below
the changed code are the same build; map the offsets with `objdump -h` and `addr2line` first.
- A boot the host cannot capture (a power cut, an OTA-only board): keep the boot report in a flash
preference and repeat it in a periodic alive line, as `scripts/libretiny_prefs/ltprefs.hpp` does.
- Multicast from the host never arrives on some access points: test it device to device. Broadcast
is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate.
- The merge base comes from a freshly fetched `origin/dev`; a stale local `dev` puts dependency bumps into the
PR diff and builds the control from the wrong commit.
- A `.local` name can stop resolving in the middle of a run; keep the address from the boot log and drive by it.
-`wc -l` pads its count on macOS, so `tail -n +$n0` needs `$((n0+0))` or an arithmetic expansion.
-`api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for
soak tests.
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes/` has the proven
Each flow names the pull requests it was used on, so their result tables show what to post.
One file per component, named after it:
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client, store_yaml recovery over Noise or plaintext
- [`ble_midi.md`](ble_midi.md): BLE MIDI client against a fake ESP32 instrument, every packet shape and the send actions
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
- [`nrf52.md`](nrf52.md): dongle board support
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
## User defined action listing and execution without a client library (#18882)
`scripts/api/rawactions.py HOST list` speaks the plaintext native API over a raw socket and decodes every
`ListEntitiesServicesResponse` by field number (argument fields 1 name, 2 type, 3 description, 4 example, 5 optional,
6 default_value), so new wire fields show up without waiting for the client library. `rawactions.py HOST exec ACTION
k=v ...` sends `ExecuteServiceRequest` with `sint32` ints (field 5, not the legacy int32 field 2), fixed32 floats,
length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSING=1` leaves arguments out on purpose
(the device then drops the call). `templates/api-actions-dut.yaml` (D1 mini, three actions, optional variables with
defaults) and `templates/api-actions-strings-dut.yaml` (required strings) log what arrived. String arguments are
`StringRef`s that are not null terminated: copy them with `snprintf("%.*s", size, c_str())` into a buffer before
logging, and do not pass `%.*s` to `logger.log`, whose validator counts it as one pattern.
## store_yaml recovery over the native API (#16445)
`scripts/api/syrecover.py HOST KEY OUTDIR [ORIGDIR]` (KEY `-` for plaintext) builds the client library's
`APINoiseFrameHelper` around a stub `APIConnection` subclass (the compiled helper type checks the connection, and
`ConnectionParams` needs every field), sends Hello, Authentication (never answered without a password) and
`DeviceCapabilitiesRequest`, then `GetYamlRequest` (161), collects `GetYamlResponse` (162) frames by field number
(1 data, 2 done, 3 total_size, 4 encoding) until done, requests again, decompresses with `compression.zstd` (Python 3.14, or the `zstandard` package) and
unpacks the envelope with the tree's `unpack_envelope` (run with `PYTHONPATH` on that tree). Configs in
`templates/store_yaml/`: entry files in `boards/`, a package one directory above them with `!secret` and an inline
OTA password,
so the redaction and the `..` paths are exercised. Repetitive YAML compresses into one chunk even at 22 KB; a 6 KB
include of random base64 lines with `include_secrets: true` gives four chunks (1390 byte packets). Rough edge at
0bd3738430: `include_secrets: true` fails the build when a package in another directory uses `!secret` and has no
`secrets.yaml` beside it; a copy next to the package works around it.
## BLE MIDI client against a fake instrument (#18508)
No MIDI hardware needed: `templates/ble_midi/midiperi.yaml` turns a second ESP32 (an S3 with ethernet, so the
API stays reachable while BLE is busy) into a BLE MIDI peripheral with `esp32_ble_server`: the MIDI service
advertised, the MIDI characteristic with read, write, write without response and notify, an `on_write` that logs
the bytes, buttons that notify hand built packets (channel messages, two messages in one packet, running status,
SysEx in one packet, split over two, oversize, cut short, clock, aftertouch, orphan data bytes, a 50 packet burst)
and a restart button for the disconnect test. `midicli.yaml` is the component test config on a classic devkit with
`wifi:`, `api:`, the peripheral's BLE address in the `peri_mac` substitution, given on the command line with
`-s peri_mac <mac>` (the base MAC plus 2, printed by `esp32_ble` in `dump_config`), `on_sysex` and `on_message` lambdas that print the bytes, debug sensors and out of range send
buttons. Drive both over the plaintext API with `scripts/modem/mdrive.py HOST LOGFILE press:NAME wait:S` 3 s
apart and compare the peripheral's `TX` and `RX` lines with the client's decoded lines. Gotcha:
`ble_server.characteristic.set_value` latches the value of its first play and re-applies it before every notify,
so the peripheral script sets the value from a lambda (`id(midi_io).set_value(std::vector<uint8_t>(pkt));
id(midi_io).notify();`). At VERBOSE the client logs four lines per message, so a burst shows an
`esp32_ble took a long time` of a couple of seconds that is the serial port, not the parser.
## Restoring globals with storage: rtc and flash (#13954)
`templates/globals-storage-dut.yaml` is the shared package: five restoring globals (rtc int, flash int, default int,
rtc `std::string`, rtc `uint8_t[2]`), bumped and logged with the reset reason on every boot, saved and synced after
2 s, odd boots `App.safe_reboot()`. Platform files add the board (`packages: dut: !include globals-storage-dut.yaml`), the `debug` reset-reason text sensor and, on the
ESP32, `deep_sleep: run_duration: 12s, sleep_duration: 8s` (even boots sleep). No network needed: flash by USB,
`serial_capture.py PORT 110 OUT --reset` gives the reset pulse plus a run of soft restarts and sleeps; the ESP32's
EN pulse is a `POWERON_RESET` that empties RTC memory, the D1 mini's RST pulse (`rst cause:2`) keeps it, so the
ESP8266 needs a real power cut by hand; arm long `--released` captures on both ports first (the capture
reopens the port when it comes back, the very first boot lines are still lost, read the next boot's restored
values). Unchanged-build check: the config with every `storage:` removed, built on the PR and on the dev merge
commit under the PR's own commits (not the merge base, which is older dev), `cmp -l` plus an `nm -S` symbol diff;
only the template names differ. Results: https://github.com/esphome/esphome/pull/13954#issuecomment-6254794712
A report that arrives during a transition is held and applied when the transition ends, unless a call came after
it. `scripts/homeassistant_light/heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY]` (a key of `-` is plaintext) keeps one API connection to the mirror
device and one to the bulb so the steps land inside a 3 s fade: press "A 50 fade 3s", 0.7 s later set the bulb to
90% directly (the report is held), 1.4 s in press the same fade again (or "A many calls", 16 calls in one loop
pass). Pass: the mirror and the bulb end at the second call's target. Only `same` reproduces the bug: a build
without the fix logs `Applying the report that arrived during the transition` and the bulb stays at 90%. `many`
ends at 100% on both builds because the last call's target differs from the held report, so it is a regression
check only, not a reproduction. Separate button presses are too slow (about 1.5 s each) to land inside the fade.
The mirrors are not API entities, so read their state from the "Log state" button's line. Results: https://github.com/esphome/esphome/pull/21144#issuecomment-6278257695
## Update manifest URL changed at run time (#21373)
`scripts/http_request/manifest_server.py PORT [DELAY]` serves `stable.json`, `beta.json` and `slow.json` (answered
after DELAY s) with both chip families and logs every request. `templates/http_request_update/upd-common.yaml`
(secret `manifest_base` is the server URL) has the update entity on the stable manifest, buttons for every
`set_source_url` shape (`std::string` local, literal, temporary, `.c_str()` of a local, the current URL back in),
`Check Then Swap` (ESP32 task path: check the slow manifest and replace the URL in the same lambda), `Arm Swap In
Check` plus a `logger: on_message` lambda that replaces the URL from inside the inline check (ESP8266; the log line
from inside `on_message` is swallowed, judge by the next check's result), and a `Heap` button. `upd-base-common.yaml`
keeps only the shapes that compile on a tree with the bare pointer setter (for the base build, copy a board file
and point its `common` include at `../upd-base-common.yaml` with a different `name:`); `boards/upd8266.yaml` and `boards/upd32.yaml` are the
per-board files, one directory below the package their `../` include points at. Drive with `scripts/http_request/updrive.py HOST KEY LOGFILE press:NAME wait:S`, which prints every
update state (latest version, summary). The ESP8266 Arduino client gets `Error reading manifest: -1` on roughly a
quarter of the fetches from this Python server on every build, so count checks against the base before blaming a
PR. `http_request` on the ESP8266 needs `verify_ssl: false` even for plain http URLs.
A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives
each subscription's QoS, the device address (`New client connected from <ip> as <name>-<mac>`) and exact
subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription.
Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right
after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a
leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish.
`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a
paho script publishes and polls the serial capture file.
## WebSocket transports on ESP-IDF (#16056)
One build per transport from `templates/mqtt-transport-dut.yaml`, with `scripts/mqtt/mosquitto-ws.conf` as the
local broker (mosquitto 2.x from brew has `websockets` support; `log_type all` prints `New connection from <ip>:<port>
on port <listener>`, which proves which listener each client used) and `scripts/mqtt/make_test_ca.sh BROKER_IP` for
the TLS listener's certificate, both run in the scratch directory so keys, logs and outputs stay out of the checkout. `scripts/mqtt/cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart]` subscribes
to `NAME/#`, sends a switch command and an `on_message` topic, presses the 3000 byte publish button over the API,
restarts the local broker when the last argument is `restart` (the PID in `mosquitto.pid`, written when the broker
was started with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` in the working directory next to the
certificates), checks every listener in the broker config answers again, and prints the broker's message counts with the device log. `mosquitto_sub` cannot speak
WebSocket, so it subscribes on the plain listener of the same broker. Cases: `tcp` on 1883, `ws` on 9001, `wss` on
9002 with `certificate_authority` (the certificate names an address, so `skip_cert_cn_check: true`), `wss` to
test.mosquitto.org on 8081 with no CA for the certificate bundle, and two negatives that must never connect: a
different CA (`mbedtls_ssl_handshake returned -0x2700`) and the self-signed server through the bundle
(`esp-x509-crt-bundle: Failed to verify certificate`, `-0x3000`). `CONFIG_MQTT_TRANSPORT_WEBSOCKET` is on by
default in the generated sdkconfig. The TLS session costs about 29 kB of internal heap while connected; the public
broker carries other devices' `esphome/discover` traffic, which is noise. Results:
`scripts/mqtt/mac_suffix/run.sh X BUILD` compiles (from `TREE`) and flashes variant X (a defaults, b custom `topic_prefix`, c explicit topics,
d no suffix) of BUILD (`pr` or `base`) on an ESP32 and a D1 mini, records the broker with
`mosquitto_sub -F '@H:@M:@S r=%r %t %p'`, presses Restart through the `cmd_t` found in the discovery payloads, then
holds both chips in the bootloader for 40 s (`esptool --after no-reset chip-id`) so the broker sends the will; a
plain reset only gives "session taken over" and no will. The two boards' configs live in separate folders because
variants a to c share a `name` (the MAC suffix tells them apart), and the same name in one folder shares a build
directory. A D1 mini CH340 can garble the serial capture after an RTS reset, so read its topics from the broker.
`esphome logs --device MQTT` on a current tree needs paho-mqtt 2.x; an older venv with 1.6.1 fails, so use a venv
made from the tree's requirements.txt. A Restart press never delivered the shutdown message on any build tested.
## Shutdown message delivery (#21566)
`scripts/mqtt/shutdown/`: `sd-common.yaml` sets birth/will/shutdown payloads `hello`/`dead`/`bye` so the shutdown
message and the will cannot be confused, and takes `restart`, `disable` and `sleep` on `<name>/cmd`.
`sdrun.sh V N` (`TREE`, `P32` and `P66` in the environment) compiles and flashes the ESP32 and D1 mini, restarts each N times, disables MQTT once and deep sleeps the ESP32 three
times (never the D1 mini: without the GPIO16 to RST wire it never wakes); `match.py OUT` counts a `bye` within 5 s of
each command. Dev: ESP32 0/10 restarts, D1 mini 1 to 3/10. Put the broker address (`broker_ip`) in the secrets.yaml next to
the included package, and quote `"${name}/status"` inside flow mappings.
case list over aioesphomeapi and judges each from the serial capture. ESP-IDF facts: a stalled server
ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout, an early close with
`-1`, both before and after the PR; a body without Content-Length and without chunking reads as complete
(Size: 0) and finishes. A build without the fix hangs on the truncated, oversized header, empty, chunked PNG
and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs
a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list.
A case expected as `any` passes on `finished` or `error`, never on no outcome.
## PNG decode time limit (#21364, #21391)
The same server also serves any plain file in its www directory by name (404 for a missing file or a path
outside it). `scripts/online_image/make_big_pngs.py <www>` (after `make_images.py`) writes a 320x240 gradient (618 B, also as `ok.png` so the misbehaviour cases carry a
real multi-row PNG), a flat 640x480 PNG (1.6 KB for 307200 pixels), 231 KB of noise and a JPEG. The device is
`templates/online-image-dut.yaml` with `type: BINARY` on the main image (a 640x480 RGB565 does not fit a classic
ESP32 without PSRAM) and `debug` loop time and heap sensors at 2 s; the 256 byte buffer image stays RGB565, so do
not point big images at it (out of memory on every build). `scripts/online_image/pngdrive.py HOST KEY LOGFILE
BASEURL LABEL` (default case set `png`; `CASES=short` for the six case list) calls the actions, waits for the
finished or error line and reports the case time, the highest loop time and the lowest free heap. The per pass `online_image.image took a long time` values come from
the serial capture and only print on a new maximum, so read the first fetch of each image. The pass that decodes a
whole buffer is the decoder cost (before the change 510 ms gradient and 1980 ms flat, after it 77 and 140 ms); for
the noisy PNG the longest pass is the blocking 64 KB read on both builds (0.7 to 3.7 s). Alternate PR, base, PR
for a timing claim.
For a replacement PR, compare against the parent PR head, not dev. `CASES=noise` fetches the noisy PNG three times
(run it right after a boot, alternating builds); `CASES=end` runs flat and gradient as plain, `noclen_`, `chunked_`,
`half_` and `stall_` through the prefixed server cases below.
## JPEG decoded as it downloads (#21342)
`oi_server.py` serves prefixed cases on any file: `half_<file>` (first half, matching Content-Length),
`noclen_<file>` (close delimited), `chunked_<file>` (1 KB chunks), `stall_<file>` (a third, then the socket held
show(){ tail -n +$(($1+1))"$LOG"| sed 's/\x1b\[[0-9;]*m//g'| grep -a 'dfrobot\|Detected\|\[W\]\|\[E\]\|sen\]'| grep -a -v 'JYBSS\|took a long time'| cut -c1-200;}
# cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart] : subscribe to NAME/# on the broker, send a
# switch command and an on_message topic, press the 3000 byte publish button over the API (KEY, - for plaintext),
# optionally restart the local broker (its PID from mosquitto.pid in the working directory, written when it was started
# with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid`), and show the device log. Outputs (sub-NAME.txt, mosquitto.out) go to the working directory, which should be the scratch directory.
[ -n "$KEY"]&&[ -r "$LOG"]||{echo"usage: cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart] (CAPTURE must be a readable file)";exit 2;}
show(){ tail -n +$(($1+1))"$LOG"| sed 's/\x1b\[[0-9;]*m//g'| grep -a 'mqtt\|\[probe:\|cmd payload\|Relay\|\[W\]\|\[E\]\|Guru\|abort\|Backtrace\|ESPHome version'| grep -a -v 'took a long time\|Publish.*Counter\|sensor/counter'| cut -c1-170;}
for port in $(awk '/^listener/ {print $2}'"$WORK/mosquitto-ws.conf");do nc -z -w 3"$B""$port"||{echo"broker port $port not listening after the restart";exit 1;};done
tail -n +$((n1+1))"$LOG"| grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected"||{echo"the device did not reconnect after the broker restart";exit 1;}
else
echo"=== $N: no broker restart (pass 'restart' as the 7th argument for a local broker)"
fi
wait$sub_pid;sub_rc=$?
["$sub_rc"=124]||{echo"subscriber ended with status $sub_rc: $(cat "$WORK/sub-$N.err")";exit 1;}
grep -a 'mbedtls_ssl_handshake returned\|Failed to verify\|esp-tls'"$CAP"| strip | sed 's/^\[[^]]*\]//'| sort | uniq -c | sort -rn | head -4 | cut -c1-150
grep -a '\[probe:'"$CAP"| strip | sed 's/.*\[probe:[0-9]*\]: //'| sed -n '1p;$p'
[ -f "$PWD/mosquitto.log"]&&[ -n "$DEV"]&&echo"broker listeners used by $DEV: $(grep "New connection from $DEV""$PWD/mosquitto.log"| sed 's/.*on port //'| sort | uniq -c | tr '\n'';')"
Loaded 100 of 3215 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.