Compare commits

..
Author SHA1 Message Date
J. Nick Koston cf046b4348 Merge remote-tracking branch 'origin/neutral-ble-client' into radon-eye-single-node 2026-10-25 07:58:23 -10:00
J. Nick Koston 8ce475ccef Merge remote-tracking branch 'origin/dev' into neutral-ble-client 2026-10-25 07:57:38 -10:00
J. Nick Koston eb3394e332 Merge remote-tracking branch 'origin/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_client/ble_client.h
2026-10-21 07:23:08 -10:00
J. Nick Koston 88eedeec3f [ble_client] Run on_connect before the legacy engine releases the services 2026-10-20 19:08:38 -10:00
J. Nick Koston c9fe8b6b45 [ble_client] Run on_connect and on_disconnect as callback automations on both engines 2026-10-20 18:49:11 -10:00
J. Nick Koston b7e652399b Merge remote-tracking branch 'origin/dev' into pr-18205 2026-10-20 18:46:12 -10:00
J. Nick Koston 955998021c Merge remote-tracking branch 'origin/dev' into neutral-ble-client-fix
# Conflicts:
#	esphome/components/bluetooth_proxy/__init__.py
2026-10-20 15:28:55 -10:00
J. Nick Koston e7fb5f4a04 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-10-20 09:41:58 -10:00
J. Nick Koston 5a6983822d Keep a reconnect from the disconnect trigger alive, and settle the cancel window
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.
2026-10-20 09:41:50 -10:00
J. Nick Koston 11a3372843 Merge remote-tracking branch 'origin/dev' into neutral-ble-client 2026-10-20 09:34:11 -10:00
J. Nick Koston 86745e043f Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-10-18 07:40:50 -10:00
J. Nick Koston e388c459b3 Merge remote-tracking branch 'origin/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_client/__init__.py
2026-10-18 07:35:20 -10:00
J. Nick Koston 04ee66d76b Log the CCCD write status as a signed int and name the handle
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.
2026-10-13 20:18:15 -10:00
J. Nick Koston 9f25e3eba0 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-10-13 13:35:01 -10:00
J. Nick Koston c83ea861eb Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_client/automation.cpp
2026-10-13 13:34:04 -10:00
J. Nick Koston 6e24529ab9 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-10-13 10:16:48 -10:00
J. Nick Koston 955afc4847 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-10-13 10:12:14 -10:00
J. Nick Koston 844418b1cc Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-10-10 09:26:01 -10:00
J. Nick Koston 7a01c71022 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_client/__init__.py
2026-10-10 09:25:46 -10:00
J. Nick Koston fd2df8896f Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-10-06 21:35:46 -10:00
J. Nick Koston 9f254c69a1 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-10-06 21:32:51 -10:00
J. Nick Koston ee4322ecd0 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-10-02 08:19:44 -05:00
J. Nick Koston 1b6cc0bf18 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-10-02 08:15:50 -05:00
J. Nick Koston dc1ab535a2 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-09-17 09:19:31 -05:00
J. Nick Koston 0801211ba1 Merge remote-tracking branch 'upstream/dev' into HEAD 2026-09-17 08:48:59 -05:00
J. Nick Koston 5d8bbb380a Merge remote-tracking branch 'upstream/dev' into HEAD 2026-09-17 08:48:57 -05:00
J. Nick Koston 278079ceba Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-09-16 09:23:20 -05:00
J. Nick Koston 5a2f059be7 Match the backend's user-provided default constructor convention 2026-09-16 09:23:15 -05:00
J. Nick Koston 14998c0905 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-09-16 09:20:38 -05:00
J. Nick Koston bd8ff69452 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-09-14 11:54:25 -05:00
J. Nick Koston d69a1e9bf9 Charge the backoff on a degraded build, drop raced completions, one slot-budget map
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.
2026-09-14 11:54:14 -05:00
J. Nick Koston 8eb5d17f3d Merge remote-tracking branch 'origin/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_client/automation.h
2026-09-14 10:58:23 -05:00
J. Nick Koston b61594a9a6 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-09-02 11:36:05 +02:00
J. Nick Koston 808afc024d Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-09-02 11:34:17 +02:00
J. Nick Koston 41f6a3dd45 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-26 19:50:16 -05:00
J. Nick Koston 0bb98b715f Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-08-26 19:49:11 -05:00
J. Nick Koston a1815ad3d5 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-24 10:28:53 -05:00
J. Nick Koston 90af765fae Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-08-24 10:27:17 -05:00
J. Nick Koston ec681bb4fb Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-20 00:47:07 -05:00
J. Nick Koston f17b18d51a Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-08-20 00:45:33 -05:00
J. Nick Koston 3ee3d1b353 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-19 10:30:50 -05:00
J. Nick Koston 5f11c430cc Emit the materializer define only for the backend that has one
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).
2026-08-19 10:30:43 -05:00
J. Nick Koston 3fb5aa401b Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-19 09:18:06 -05:00
J. Nick Koston baf57195e2 Name the materializer define for what it gates
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).
2026-08-19 09:15:17 -05:00
J. Nick Koston 27a97f36af Key backend and engine selection on their own defines
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.
2026-08-19 09:14:17 -05:00
J. Nick Koston 0a28a5b023 Document the contract facts a backend author needs
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.
2026-08-19 09:10:05 -05:00
J. Nick Koston ae8760ded8 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:28:25 -05:00
J. Nick Koston 5c6e62ffe0 Pin the neutral cap branch a future hub platform will take 2026-08-12 19:28:21 -05:00
J. Nick Koston 588ca0de3c Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:12:12 -05:00
J. Nick Koston 0801b29617 Surface a degraded mixed client in component status
The table-build skip sets a warning cleared by the next successful fan-out.
2026-08-12 19:12:09 -05:00
J. Nick Koston 674215f7f7 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:03:19 -05:00
J. Nick Koston b00ad7ca8a Name the mixed-client invariant and fold the promote loop
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.
2026-08-12 19:03:16 -05:00
J. Nick Koston b2dd602cef Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:59:00 -05:00
J. Nick Koston 7ad51b4b8f Pin the carve alignment, name the node-abort contract, fix strays
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).
2026-08-12 18:58:56 -05:00
J. Nick Koston 6f9b5993b9 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:42:07 -05:00
J. Nick Koston 65c1b06f7f Keep legacy nodes connected through a table-build failure
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.
2026-08-12 18:42:03 -05:00
J. Nick Koston 93d352df83 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:38:08 -05:00
J. Nick Koston e158205e4c Surface the node-capacity guard in component status and log sync register failures 2026-08-12 18:38:04 -05:00
J. Nick Koston 014cccb67e Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:16:44 -05:00
J. Nick Koston f5debdad32 Drop the orphaned Automation TAG box and fix the tidy naming
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.
2026-08-12 18:16:40 -05:00
J. Nick Koston bbe643e16b Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:07:48 -05:00
J. Nick Koston 2a95fa44dc Pack ConnectBackoff into 4 bytes and regroup the table walk context
256 ms ticks in a uint16_t drop the struct from 8 bytes (3 padding) to 4 on
both engines; the materializer's uint8 fields move behind its uint16s.
2026-08-12 18:07:44 -05:00
J. Nick Koston 63ab68b58a Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:56:33 -05:00
J. Nick Koston 314557f83b Deduplicate the review-round additions
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.
2026-08-12 17:56:29 -05:00
J. Nick Koston e5cc588ab5 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:47:09 -05:00
J. Nick Koston e4e464a3b9 Log GATT failures the bridge previously fanned out silently
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.
2026-08-12 17:47:07 -05:00
J. Nick Koston 8ba15fa7af Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:24:38 -05:00
J. Nick Koston a41dbed69c Clear the bridge backoff on re-enable and fail the table build closed
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.
2026-08-12 17:24:34 -05:00
J. Nick Koston 527ba05474 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:09:40 -05:00
J. Nick Koston ba10832ed7 Tighten the bridge comments to repo style 2026-08-12 17:09:37 -05:00
J. Nick Koston f2bae23b4e Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:04:51 -05:00
J. Nick Koston d6b35f8ccf Keep the on_connect trigger out of a failed discovery's teardown
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.
2026-08-12 17:04:46 -05:00
J. Nick Koston 7bc00c8eda Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:58:38 -05:00
J. Nick Koston 03efcf6bb5 Hold off reconnects after materializer failures on the esp32 bridge
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.
2026-08-12 16:58:34 -05:00
J. Nick Koston dd76743909 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:38:37 -05:00
J. Nick Koston 0d46b1047e Name the failure in the discovery-bailout log
A failed search or count was indistinguishable from a service-less peer.
2026-08-12 16:38:33 -05:00
J. Nick Koston 313127d3dd Gate on the unified neutral-node-surface define 2026-08-12 16:33:50 -05:00
J. Nick Koston 50f2eb4459 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:33:21 -05:00
J. Nick Koston 456f5b3042 Apply the simplify pass to the single-interface rework
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.
2026-08-12 16:32:46 -05:00
J. Nick Koston 349ba6a02f Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:11:36 -05:00
J. Nick Koston 83c9603b4b Address review: one overcommit message per platform and a stall breadcrumb
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.
2026-08-12 16:11:20 -05:00
J. Nick Koston f9993aa010 Migrate radon_eye_rd200 onto the neutral ble_client node interface
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.
2026-08-12 16:09:44 -05:00
J. Nick Koston ad9a41ed61 Cover the engine feature gates in the schema tests 2026-08-12 16:04:57 -05:00
J. Nick Koston e6b7b3a028 One ble_write action on the neutral node interface
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.
2026-08-12 16:02:50 -05:00
J. Nick Koston 9a4a550fb9 Express ble_client engine capabilities per platform
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).
2026-08-12 15:58:25 -05:00
J. Nick Koston 42dc4025c9 Deliver the neutral node callbacks from the esp32 engine
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.
2026-08-12 15:55:56 -05:00
J. Nick Koston 901fd35a7e Extract the Bluedroid service-table materializer into a shared class
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.
2026-08-12 15:53:14 -05:00
J. Nick Koston 4344ffd682 Share one BLEClientNode between the engines and round out the neutral surface
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.
2026-08-12 15:47:43 -05:00
J. Nick Koston 1c671be1bd Use the merged ledger accessor in the explicit-connections test 2026-08-12 15:04:26 -05:00
J. Nick Koston 52dc51c7c6 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/__init__.py
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection.h
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/core/defines.h
#	tests/components/bluetooth_connection/__init__.py
2026-08-12 15:04:09 -05:00
J. Nick Koston 671b6216a7 Merge branch 'dev' into neutral-ble-client 2026-08-10 14:30:42 -05:00
J. Nick Koston 5764ca607d Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_device_base/ble_gatt_client.h
#	esphome/components/bluetooth_connection/__init__.py
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection.h
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/core/defines.h
#	tests/component_tests/bluetooth_proxy/test_platform_gates.py
#	tests/components/ble_device_base/__init__.py
#	tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml
2026-08-10 10:50:00 -05:00
J. Nick Koston 273c80ecd3 Address review: state the table lifetime where it is freed
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.
2026-08-09 19:59:06 -05:00
J. Nick Koston 1933126ad7 Address review: de-vacuous the reject test, loud node overflow, walker logs
- 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
2026-08-09 19:37:32 -05:00
J. Nick Koston 64034b5b23 Use the set_core_config fixture instead of hand-poking CORE.data
The conftest fixture is the one spelling for platform setup; the
ledger tests already use it.
2026-08-09 19:16:18 -05:00
J. Nick Koston 830854d661 Address review: pin the neutral arm's rejection of esp32-only keys
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.
2026-08-09 19:12:52 -05:00
J. Nick Koston 87b7076cd1 Address review: fail the empty table before CONNECTED, one slot spelling
- 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
2026-08-09 18:52:49 -05:00
J. Nick Koston 5ec7047151 Address review: empty-table failure semantics, async abort guard, StaticVector nodes
- 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
2026-08-09 18:34:13 -05:00
J. Nick Koston 45d921f77a Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 17:34:37 -05:00
J. Nick Koston 321b00143d Address review: latch the retry only for the current subscriber
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.
2026-08-09 17:34:33 -05:00
J. Nick Koston e6e9016ed4 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 17:07:45 -05:00
J. Nick Koston a292aa2654 Drop the disconnect-notification retry latch
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.
2026-08-09 17:07:42 -05:00
J. Nick Koston 4353f5ac9c Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 17:05:05 -05:00
J. Nick Koston 4925f6648b Address review: retry-latch hygiene on subscriber change, parity note
- 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
2026-08-09 17:05:01 -05:00
J. Nick Koston 47bd8f5ffc Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 17:03:13 -05:00
J. Nick Koston 31771da92c Address review: gate the cache clean where the paths converge
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.
2026-08-09 17:03:09 -05:00
J. Nick Koston 1978f203bc Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 16:32:41 -05:00
J. Nick Koston ae906ada94 Address review: quiet stack-down teardown, pin bitfield widths
- 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
2026-08-09 16:32:37 -05:00
J. Nick Koston 6a2465da63 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 16:24:06 -05:00
J. Nick Koston 9a5aab63d5 Address review: retry a dropped freed-slot notification too
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.
2026-08-09 16:24:03 -05:00
J. Nick Koston deeec17d0d Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 16:07:38 -05:00
J. Nick Koston 735f810f08 Cover the unknown-platform raise in frameworks_for_platforms 2026-08-09 16:07:35 -05:00
J. Nick Koston cd48023a56 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 16:03:30 -05:00
J. Nick Koston 9330204908 Address review: unknown platforms raise, the filter pin asserts equality
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.
2026-08-09 16:03:25 -05:00
J. Nick Koston 45501056d8 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:58:59 -05:00
J. Nick Koston 2e6ad94bd1 Tighten the listener doc to repo style 2026-08-09 15:58:56 -05:00
J. Nick Koston 5994105d34 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:57:24 -05:00
J. Nick Koston 0ac9a0f8bd Check the cache-clean status in release_services()
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.)
2026-08-09 15:57:20 -05:00
J. Nick Koston 77b8b384bd Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:55:00 -05:00
J. Nick Koston 3728669fa6 Restore the reconnect-during-teardown cancel fast path
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.
2026-08-09 15:54:56 -05:00
J. Nick Koston 578e4774a9 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:44:01 -05:00
J. Nick Koston 8696255ccc Give connection_index_ an in-class initializer like every sibling 2026-08-09 15:43:57 -05:00
J. Nick Koston 594931265b Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:15:37 -05:00
J. Nick Koston b0f753a7a2 Address review: check prefer-conn-params, pace the retry drain
- 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
2026-08-09 15:15:33 -05:00
J. Nick Koston fc69d8543e Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:54:05 -05:00
J. Nick Koston d586a6c8d4 Address review: check the reclaim close, log the mid-stream park
- 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
2026-08-09 14:54:01 -05:00
J. Nick Koston 9301391792 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:46:35 -05:00
J. Nick Koston 1b801427c4 Dry-pass round 2: comment accuracy polish
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.
2026-08-09 14:46:30 -05:00
J. Nick Koston be391feaa9 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
2026-08-09 14:38:33 -05:00
J. Nick Koston 7e369f332c Dry-pass round 1: one door to IDLE, complete teardown guards, residue
- 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
2026-08-09 14:37:57 -05:00
J. Nick Koston a40ecfdf9c Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:22:40 -05:00
J. Nick Koston 9a6765eb8f Log the deferred connections-free resend at verbose
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).
2026-08-09 14:22:37 -05:00
J. Nick Koston 1d0a87daa8 Address review: an empty service table with nodes logs a warning
A service-less device or a failed materialization both leave the nodes
unable to resolve; the client no longer sits inert without a trace.
2026-08-09 14:20:31 -05:00
J. Nick Koston c98dd74e11 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:13:53 -05:00
J. Nick Koston aceff27948 Retry a dropped connections-free update
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.
2026-08-09 14:13:50 -05:00
J. Nick Koston 211337cd6d Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:03:07 -05:00
J. Nick Koston 4276815734 Address review: scope the rp2 defines pin, contract-back the teardown semantics
- 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
2026-08-09 14:03:04 -05:00
J. Nick Koston 3c57064bf2 Address review: skip table materialization when no node will read it
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.
2026-08-09 13:45:33 -05:00
J. Nick Koston f9be82d1a6 Address review: on_notify_state joins the node interface
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.
2026-08-09 13:30:08 -05:00
J. Nick Koston 10a0bf0ff8 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 13:29:27 -05:00
J. Nick Koston 484674b85d Derive the bluedroid source-filter entry from the shared helper
frameworks_for_platforms() gains its production caller in the same file
as the platform registry; the derived set is identical to the hand list.
2026-08-09 13:29:00 -05:00
J. Nick Koston 49c1547fda Compile the gated lookup helpers into the ble_device_base test build
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.
2026-08-09 13:25:52 -05:00
J. Nick Koston 7b4353b417 Import the service-table materializer from #18198
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.
2026-08-09 13:23:44 -05:00
J. Nick Koston 5f20346d54 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_device_base/ble_gatt_client.h
2026-08-09 13:23:17 -05:00
J. Nick Koston 356c1d9134 Guard every GATT op on an unset conn id
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.
2026-08-09 13:22:28 -05:00
J. Nick Koston 9534a1df55 Move the dark service-table materializer and lookup helpers to #18205
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).
2026-08-09 13:21:36 -05:00
J. Nick Koston fb64b18047 Address review: notify/pairing breadcrumbs, aborted fan-out trace
- 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
2026-08-09 12:59:24 -05:00
J. Nick Koston 5572bf77dd Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 12:57:42 -05:00
J. Nick Koston d8ec76e02b Fail on premature enumeration terminators; explicit terminal states
- 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)
2026-08-09 12:57:36 -05:00
J. Nick Koston cf5faa454c Address review: rejection logs, honest service_table docstring, terse sweep
- 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
2026-08-09 12:47:17 -05:00
J. Nick Koston 55d14c6fbf Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
2026-08-09 12:43:57 -05:00
J. Nick Koston d86bdb36cf Address review: honor SEARCH_CMPL status, close a late OPEN, backstops
- 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)
2026-08-09 12:42:17 -05:00
J. Nick Koston c64684dd00 Tighten review-round comments to repo style 2026-08-09 12:36:55 -05:00
J. Nick Koston ed002feee9 Address review: mid-fan-out release, self-enforcing cap, scaffolding note
- 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
2026-08-09 12:19:43 -05:00
J. Nick Koston 38f31d85b6 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 12:19:14 -05:00
J. Nick Koston d04ef2378e Contract doc: writes without response also report on_write_result
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.
2026-08-09 12:18:35 -05:00
J. Nick Koston 5e26633231 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 12:07:13 -05:00
J. Nick Koston 6c78b480c4 Return the wrapper to 48 bytes - exact per-slot RAM parity with dev
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.
2026-08-09 12:07:10 -05:00
J. Nick Koston 3d33a16b71 Return the wrapper to 48 bytes - exact per-slot RAM parity with dev
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.
2026-08-09 12:06:44 -05:00
J. Nick Koston e3aa8773e6 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 11:50:26 -05:00
J. Nick Koston be5e00aefd Address review: discovery status, loop lifetime, event conn id
- 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)
2026-08-09 11:50:22 -05:00
J. Nick Koston 9c5ebdffc9 Address review: complete the node-facing surface, wiring test, comment placement
- 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
2026-08-09 11:26:47 -05:00
J. Nick Koston 9554e1a212 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 11:19:58 -05:00
J. Nick Koston 28491f83b4 Close the silent-failure paths from review
- 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)
2026-08-09 11:19:55 -05:00
J. Nick Koston 2f71ec16d7 Close the silent-failure paths from review
- 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)
2026-08-09 11:19:25 -05:00
J. Nick Koston 3daa1d2450 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 10:56:31 -05:00
J. Nick Koston a513cef4e2 Encode the pre-started search lifecycle as a state machine
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.
2026-08-09 10:56:27 -05:00
J. Nick Koston fa17215053 Address review: fan-out guard, refusal warning, sighting-less connect hint
- 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
2026-08-09 10:43:21 -05:00
J. Nick Koston 073d9a72a0 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 10:22:39 -05:00
J. Nick Koston 3a24d0b03b Reset the search latches per connection attempt
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.
2026-08-09 10:22:35 -05:00
J. Nick Koston b229bd4d8a Address review: explicit connections claim slots, re-enable clears backoff
- 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
2026-08-09 10:09:20 -05:00
J. Nick Koston c8e240198d Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 10:07:42 -05:00
J. Nick Koston 0512339c58 Deduplicate the stream-abort sequence and small cleanups
- 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
2026-08-09 10:05:42 -05:00
J. Nick Koston e95bebb7d0 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection.h
2026-08-09 09:54:08 -05:00
J. Nick Koston 084ff9d6e3 Restore the discovery/MTU overlap and latch streamer abort causes
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.
2026-08-09 09:52:58 -05:00
J. Nick Koston 4a9e5f5da9 Require the proxy in the wrapper's compile gate
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).
2026-08-09 09:44:23 -05:00
J. Nick Koston f6158597ac Address review: slot-cap testing mode, derived source filter, breadcrumbs
- 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
2026-08-09 09:42:50 -05:00
J. Nick Koston 8924640593 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_proxy/__init__.py
2026-08-09 09:37:59 -05:00
J. Nick Koston 26c000c417 Collapse shim-era indirection left by the merge of the two halves
- 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
2026-08-09 09:36:17 -05:00
J. Nick Koston 7cb32f9546 Clean up iteration residue from the review rounds
- 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)
2026-08-09 09:31:21 -05:00
J. Nick Koston 13de0275c8 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 09:19:35 -05:00
J. Nick Koston 22af86a65b Tighten new comments to repo style 2026-08-09 09:19:32 -05:00
J. Nick Koston 83b099d708 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 09:17:42 -05:00
J. Nick Koston 6957e2ef15 Type gattc_if_ at its real width instead of shrinking the timer 2026-08-09 09:17:38 -05:00
J. Nick Koston 743a10fa3e Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 09:04:35 -05:00
J. Nick Koston f7b1e9ae65 Shrink the disconnect timestamp to a tick so the backend packs to 48 bytes 2026-08-09 09:04:31 -05:00
J. Nick Koston 53a5c7b051 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 09:00:25 -05:00
J. Nick Koston 54c50c07c5 Merge remote-tracking branch 'origin/dev' into esp32-gatt-backend
# Conflicts:
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/core/defines.h
2026-08-09 08:59:12 -05:00
J. Nick Koston 64df0c1da1 The fixture comment follows the renamed predicate here 2026-08-09 08:56:18 -05:00
J. Nick Koston 9c31e08e95 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 08:55:53 -05:00
J. Nick Koston 93124f1f1f Align two comments with the code they describe 2026-08-09 08:55:49 -05:00
J. Nick Koston 8abb5f102d Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 04:20:42 -05:00
J. Nick Koston 12fb1b0697 Name the MTU default and bound the descriptor walk sensibly 2026-08-09 04:20:39 -05:00
J. Nick Koston 81216da8e7 Apply review: honest slot docstring, cap-coverage pin, write breadcrumb 2026-08-09 04:02:45 -05:00
J. Nick Koston ed2cdad9b4 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 03:44:14 -05:00
J. Nick Koston be818a5bf2 Apply review polish: bounded walk, real statuses, guarded settles 2026-08-09 03:44:10 -05:00
J. Nick Koston 444a217ee7 Apply review: honest teardown status and unresolved-write cause 2026-08-09 03:29:20 -05:00
J. Nick Koston 93f9d4a1ad Apply review: state before fan-out, refusal breadcrumbs, write status log 2026-08-09 03:13:06 -05:00
J. Nick Koston ac7d39a8c1 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/__init__.py
#	esphome/components/bluetooth_connection/bluetooth_connection_rp2.h
#	tests/component_tests/bluetooth_proxy/test_platform_gates.py
2026-08-09 03:09:32 -05:00
J. Nick Koston 612033062f Apply combined review: wire MTU parity, abort teardown parity, closure ownership 2026-08-09 03:08:47 -05:00
J. Nick Koston f9dbd24a2a Apply review: settle refused teardowns, per-platform schema cache, ledger tests 2026-08-09 02:50:01 -05:00
J. Nick Koston 833a835e0f Combine the flip into the backend PR and put the table define in the esp32 arm 2026-08-09 02:27:53 -05:00
J. Nick Koston 399dc26236 Follow the contract op rename 2026-08-09 02:17:36 -05:00
J. Nick Koston 0d9aea9594 Merge branch 'esp32-proxy-flip' into neutral-ble-client 2026-08-09 02:17:08 -05:00
J. Nick Koston f2261d6632 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-09 02:16:17 -05:00
J. Nick Koston d224a82e38 Fold the tracker shim into the backend and pass the MTU through 2026-08-09 02:16:14 -05:00
J. Nick Koston ce436ff8b4 Apply review: refusal resolves waiters, cancel skips backoff, wrap-safe hold-off 2026-08-09 01:54:16 -05:00
J. Nick Koston cbda1e71be Merge branch 'esp32-proxy-flip' into neutral-ble-client 2026-08-09 01:53:48 -05:00
J. Nick Koston ceabfeb6f4 Include the header that declares the bond removal 2026-08-09 01:52:39 -05:00
J. Nick Koston 5e11a886f1 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-09 01:51:59 -05:00
J. Nick Koston e771b2522a Terminate a released stream without services-done and keep the net armed 2026-08-09 01:51:42 -05:00
J. Nick Koston ee96293c4c Platform-split ble_client codegen and gate the raw-gattc node family 2026-08-09 01:45:34 -05:00
J. Nick Koston fa18f5a0ad Neutral ble_client engine on the GATT contract 2026-08-09 01:45:11 -05:00
J. Nick Koston 269d52ac2e Revive backend-only builds, the slot ledger, and the table gate 2026-08-09 01:33:45 -05:00
J. Nick Koston 06a52f682f Name the platform explicitly in schema builders for the dumper 2026-08-09 01:01:21 -05:00
J. Nick Koston 409ff027cc Collapse the identical ignore branches 2026-08-09 00:59:16 -05:00
J. Nick Koston 3c7e5000a5 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-09 00:44:41 -05:00
J. Nick Koston 7af211ab54 Deliver completions through a GattClientListener interface 2026-08-09 00:44:35 -05:00
J. Nick Koston cc81069d02 Keep the esp32 maintenance functions outside the gated surface 2026-08-09 00:28:54 -05:00
J. Nick Koston ba567cecc3 Shared backend codegen registry and flip cleanups 2026-08-09 00:23:34 -05:00
J. Nick Koston ee7fb83334 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
2026-08-09 00:20:09 -05:00
J. Nick Koston 9db31db24d Multi-consumer sink, table materializer, and review fixes for the backend 2026-08-09 00:19:39 -05:00
J. Nick Koston ceca66c01a Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:53:50 -05:00
J. Nick Koston dc97c5a161 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 23:53:48 -05:00
J. Nick Koston 97bbcf55d9 Describe the ladder arm's real safety net in the test docstring 2026-08-08 23:53:45 -05:00
J. Nick Koston d135ac97e5 Report teardown once at CLOSE_EVT and refuse connects on a busy slot 2026-08-08 23:27:04 -05:00
J. Nick Koston 296dd19361 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:21:58 -05:00
J. Nick Koston 138304cfbe Fail discovery on count errors and free idle slots at once 2026-08-08 23:21:57 -05:00
J. Nick Koston 9fef1862be Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:09:18 -05:00
J. Nick Koston 80758f11aa Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 23:09:16 -05:00
J. Nick Koston 9d8362738f Use the canonical board id and the sibling test conventions 2026-08-08 23:09:13 -05:00
J. Nick Koston b2c22b5802 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 22:45:31 -05:00
J. Nick Koston f62009ecc9 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 22:45:29 -05:00
J. Nick Koston c09e3e0d45 Check the alias defines through real codegen 2026-08-08 22:42:56 -05:00
J. Nick Koston cdd7bef74a Check the alias defines through real codegen 2026-08-08 22:40:36 -05:00
J. Nick Koston 0e1321a813 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 22:38:31 -05:00
J. Nick Koston 022b612366 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 22:38:29 -05:00
J. Nick Koston 2dc9fbec5e Pin the tracker alias three-place invariant 2026-08-08 22:38:26 -05:00
J. Nick Koston 9dafff6a61 Apply the simplify findings and fix the passive esp32 build 2026-08-08 22:32:36 -05:00
J. Nick Koston 3c7a1062c8 Flip the esp32 proxy onto the hub wrapper 2026-08-08 22:21:38 -05:00
J. Nick Koston 8164cdd018 Stream in place through a backend cut-through 2026-08-08 22:08:16 -05:00
J. Nick Koston 4ea491d46f Bound the streaming peak to one batch window 2026-08-08 21:59:08 -05:00
J. Nick Koston 7fdb6cd2d5 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 21:41:25 -05:00
J. Nick Koston 2d50e07e90 Fold the push-hub surface into the concept 2026-08-08 21:41:23 -05:00
J. Nick Koston 78a451a1c0 Apply the simplify findings 2026-08-08 21:39:48 -05:00
J. Nick Koston 2483621500 Bluedroid backend for the GATT client contract 2026-08-08 21:31:09 -05:00
J. Nick Koston 4f086b6b25 Drive the push and poll halves off one define 2026-08-08 21:23:54 -05:00
J. Nick Koston c4850a9320 Note the tracker-native calls in the esp32 arm 2026-08-08 21:03:19 -05:00
J. Nick Koston 91bcb14c09 Restore merged pairing dispatch and capability doc 2026-08-08 21:02:10 -05:00
J. Nick Koston 85e4a6cc33 Merge remote-tracking branch 'origin/dev' into esp32-hub-devirtualize 2026-08-08 21:00:53 -05:00
J. Nick Koston 149d7cd801 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 19:14:28 -05:00
J. Nick Koston ce2e113a6d Update stale comments left by the listener removal 2026-08-08 19:13:11 -05:00
J. Nick Koston ae5b6d271f Retire the scanner-state listener interface 2026-08-08 19:13:11 -05:00
J. Nick Koston e1572d7520 Drop the duplicated scanner-state define 2026-08-08 16:36:16 -05:00
J. Nick Koston 536afce394 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/ble_device_base/ble_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 16:02:18 -05:00
J. Nick Koston 152b6a5d50 Update stale comments left by the listener removal 2026-08-08 15:59:54 -05:00
J. Nick Koston f68ebcf7d1 Retire the scanner-state listener interface 2026-08-08 15:59:54 -05:00
J. Nick Koston ca5fa6dfaf Use the shared MAC validity helper 2026-08-08 15:59:53 -05:00
J. Nick Koston 962a82d157 Make the adapter MAC accessor const 2026-08-08 15:59:53 -05:00
J. Nick Koston c6d4ca7377 Use one stack query for the adapter MAC 2026-08-08 15:59:53 -05:00
J. Nick Koston 465a6a3596 Let esp32_ble own the adapter MAC lookup 2026-08-08 15:59:53 -05:00
J. Nick Koston 03c9b001f4 Ask the stack for the adapter MAC on hosted controllers 2026-08-08 15:59:53 -05:00
J. Nick Koston d680200e5a Read the adapter MAC through esp_read_mac 2026-08-08 15:59:53 -05:00
J. Nick Koston 98dae49440 Remove redundant access specifier 2026-08-08 15:59:53 -05:00
J. Nick Koston 7ec366c1ba Monotonic parse flag, client-scoped opt-out, drop dead include 2026-08-08 15:59:53 -05:00
J. Nick Koston b1e1b0d7c5 Retire the raw listener path and parser-type enum from the tracker 2026-08-08 15:59:53 -05:00
J. Nick Koston ec49eb12dd Finish the docstring reflow 2026-08-08 11:28:16 -05:00
J. Nick Koston 949474efc6 Name the alias header in the module docstring 2026-08-08 10:55:27 -05:00
J. Nick Koston 708f095b6f Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 10:55:27 -05:00
J. Nick Koston bf4af703b6 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 10:55:25 -05:00
J. Nick Koston 541616e19c Use the shared MAC validity helper 2026-08-08 10:55:22 -05:00
J. Nick Koston 952c3e6584 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 09:52:26 -05:00
J. Nick Koston 2ea55bee7b Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 09:52:24 -05:00
J. Nick Koston 1931ef32ee Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 09:52:12 -05:00
J. Nick Koston f2da6c1a56 Merge remote-tracking branch 'origin/dev' into esp32-proxy-hub-scanner-state
# Conflicts:
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	tests/component_tests/ble_device_base/test_slot_counter.py
2026-08-08 09:51:50 -05:00
J. Nick Koston 6e9e83bdfd Include defines.h where its define is tested 2026-08-08 04:05:45 -05:00
J. Nick Koston fe29a53b71 Require the push slot in the concept where it exists 2026-08-08 03:20:11 -05:00
J. Nick Koston fa2dafcbca Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 02:43:49 -05:00
J. Nick Koston dc18fced19 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 02:43:47 -05:00
J. Nick Koston 389d3629c2 Make the adapter MAC accessor const 2026-08-08 02:43:45 -05:00
J. Nick Koston 36a4d87611 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 02:43:21 -05:00
J. Nick Koston 65db614f22 Pin the scanner-state callback slot semantics 2026-08-08 02:43:09 -05:00
J. Nick Koston 591856e7dc Constrain the contract's return types 2026-08-08 02:30:10 -05:00
J. Nick Koston b680482092 Trim the surface doc to the semantics 2026-08-08 02:01:49 -05:00
J. Nick Koston 7fe447a4a2 Enforce the hub surface with a concept 2026-08-08 02:00:17 -05:00
J. Nick Koston 8fae4f6767 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:54:40 -05:00
J. Nick Koston 3d458bd9c8 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:54:38 -05:00
J. Nick Koston fd7510d6e5 Use one stack query for the adapter MAC 2026-08-08 01:54:36 -05:00
J. Nick Koston ffe55f7691 Trim the new comments to repo style 2026-08-08 01:52:40 -05:00
J. Nick Koston 40ac344bf2 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 01:51:20 -05:00
J. Nick Koston 667ffe98d9 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:50:51 -05:00
J. Nick Koston 270f776675 Let esp32_ble own the adapter MAC lookup 2026-08-08 01:50:49 -05:00
J. Nick Koston 965bbd4425 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:46:50 -05:00
J. Nick Koston e485e5af90 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:46:48 -05:00
J. Nick Koston c795d0734d Ask the stack for the adapter MAC on hosted controllers 2026-08-08 01:46:45 -05:00
J. Nick Koston 0e77d47df4 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:38:39 -05:00
J. Nick Koston 47c2f8f31a Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:38:36 -05:00
J. Nick Koston fe53d24e46 Read the adapter MAC through esp_read_mac 2026-08-08 01:38:33 -05:00
J. Nick Koston daf988fde8 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:31:26 -05:00
J. Nick Koston 6604a9cca7 Update stale comments left by the listener removal 2026-08-08 01:31:22 -05:00
J. Nick Koston ddd45e393b Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:26:09 -05:00
J. Nick Koston 0974e8a1f5 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:26:07 -05:00
J. Nick Koston 1478307280 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 01:26:06 -05:00
J. Nick Koston fc77cdcb7d Drop a leftover declaration and fix a stale test name 2026-08-08 01:26:03 -05:00
J. Nick Koston fcb9da489e Call the static capability query through the type 2026-08-08 01:22:53 -05:00
J. Nick Koston ab458fffab Bind BLEHub to the build's tracker at compile time 2026-08-08 01:18:21 -05:00
J. Nick Koston c0c3afe6b1 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:02:41 -05:00
J. Nick Koston b092f64744 Remove redundant access specifier 2026-08-08 01:02:39 -05:00
J. Nick Koston 6b2938c1f7 Retire the scanner-state listener interface 2026-08-08 00:56:59 -05:00
J. Nick Koston 0e5d902c95 Monotonic parse flag, client-scoped opt-out, drop dead include 2026-08-08 00:53:31 -05:00
J. Nick Koston 11fc8e4c07 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 00:52:35 -05:00
J. Nick Koston d9f0f40373 Simplify scanner state: aliased enum, define-gated slot, one guarded sender 2026-08-08 00:52:12 -05:00
J. Nick Koston e671a602e9 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 00:41:34 -05:00
J. Nick Koston 7f44b9b826 Merge branch 'esp32-proxy-hub-advertisements' into esp32-proxy-hub-scanner-state
# Conflicts:
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
2026-08-08 00:41:33 -05:00
J. Nick Koston 47804f8d08 Address review: one tracker pointer, portable format, doc fixes 2026-08-08 00:40:43 -05:00
J. Nick Koston 67cc43c8ef Retire the raw listener path and parser-type enum from the tracker 2026-08-08 00:36:22 -05:00
J. Nick Koston 3e9808162b Deliver scanner state through a hub push callback 2026-08-08 00:31:18 -05:00
J. Nick Koston a6f81f3185 Carry the address as uint64 in RawAdvertisement and unify the proxy setup 2026-08-08 00:12:50 -05:00
J. Nick Koston 5296539849 Pin the removed listener slot 2026-08-08 00:01:43 -05:00
J. Nick Koston de9074fb5e Move the esp32 proxy advertisement path to the hub raw callback 2026-08-08 00:00:57 -05:00
109 changed files with 3033 additions and 3482 deletions

No files matched your search

+2 -11
View File
@@ -1555,17 +1555,8 @@ void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequ
// Event is a special case - unlike other entities with simple state fields,
// events store their state in a member accessed via obj->get_last_event_type()
void APIConnection::send_event(event::Event *event) {
const uint8_t type_index = event->get_last_event_type_index();
const size_t size_before = this->deferred_batch_.size();
if (this->deferred_batch_.contains(event, EventResponse::MESSAGE_TYPE)) {
// Sending now would overtake the older event of this entity that still waits in the batch
this->schedule_message_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
} else {
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
}
// Events are rare, so a queued one sends the batch now rather than after batch_delay
if (this->deferred_batch_.size() > size_before)
this->make_batch_due_now_();
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE,
event->get_last_event_type_index());
}
uint16_t APIConnection::try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
uint32_t remaining_size) {
+68 -5
View File
@@ -2,7 +2,6 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_deferred_batch.h"
#include "api_frame_helper.h"
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
@@ -21,6 +20,7 @@
#include "esphome/core/string_ref.h"
#include <functional>
#include <limits>
#include <vector>
namespace esphome {
@@ -656,6 +656,73 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for message encoding
using MessageCreatorPtr = uint16_t (*)(EntityBase *, APIConnection *, uint32_t remaining_size);
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Dedup: O(n) scan but optimized for RAM over performance
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return; // Already queued
}
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Swap to front avoids expensive vector::insert which shifts all elements
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (this->items.size() > 1) {
std::swap(this->items.front(), this->items.back());
}
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
// DeferredBatch here (16 bytes, 4-byte aligned)
DeferredBatch deferred_batch_;
@@ -728,10 +795,6 @@ class APIConnection final : public APIServerConnectionBase {
static constexpr size_t MAX_BATCH_PACKET_SIZE = 1390; // MTU
bool schedule_batch_();
// Sends the batch on the next loop, so a queued event does not wait for batch_delay; queued states go with it
void make_batch_due_now_() {
this->deferred_batch_.batch_start_time = App.get_loop_component_start_time() - this->get_batch_delay_ms_();
}
void process_batch_();
void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size)
__attribute__((noinline));
@@ -1,88 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_pb2.h"
#include "esphome/core/entity_base.h"
#include <algorithm>
#include <cstdint>
#include <limits>
#include <vector>
namespace esphome::api {
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// O(n) scan but optimized for RAM over performance
bool contains(EntityBase *entity, uint16_t message_type) const {
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return true;
}
return false;
}
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
if (this->contains(entity, message_type))
return; // Already queued
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Shift rather than swap, so the queued items keep their order (two events of one entity must not swap)
this->items.push_back({});
std::move_backward(this->items.begin(), this->items.end() - 1, this->items.end());
this->items.front() = {entity, message_type, estimated_size, AUX_DATA_UNUSED};
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
} // namespace esphome::api
#endif // USE_API
+316 -116
View File
@@ -1,11 +1,23 @@
from collections.abc import Coroutine
from collections.abc import Callable, Coroutine
import functools
from typing import Any
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
from esphome.components.esp32_ble import BTLoggers
from esphome.components import ble_device_base, bluetooth_connection
from esphome.components.ble_device_base import (
BT_UUID16_FORMAT as bt_uuid16_format,
BT_UUID32_FORMAT as bt_uuid32_format,
BT_UUID128_FORMAT as bt_uuid128_format,
as_hex,
as_reversed_hex_array,
bt_uuid,
)
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_CHARACTERISTIC_UUID,
@@ -18,15 +30,55 @@ from esphome.const import (
CONF_SERVICE_UUID,
CONF_TRIGGER_ID,
CONF_VALUE,
PLATFORM_ESP32,
PlatformFramework,
)
from esphome.core import ID
from esphome.core import CORE, ID
from esphome.enum import StrEnum
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
AUTO_LOAD = ["esp32_ble_client"]
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily inside
# the esp32 schema/codegen arms: importing those modules registers esp32-only
# automations as a side effect, which must not leak into the neutral
# platforms' registries (the bluetooth_proxy pattern).
def _legacy_engine() -> bool:
"""True when the build uses the legacy raw-gattc engine - one line to
flip when esp32 moves to the neutral engine (with
USE_BLE_CLIENT_LEGACY_ENGINE in _to_code_esp32)."""
return CORE.is_esp32
def AUTO_LOAD() -> list[str]:
"""The engine's closure per platform: the legacy esp32 engine builds on
esp32_ble_client plus bluetooth_connection (the shared service-table
materializer; its sources compile empty in builds without a neutral
node), the neutral engine on the bluetooth_connection backend. The
platform-less arm is the union for manifest-resolving tooling."""
if _legacy_engine() or CORE.target_platform is None:
return ["bluetooth_connection", "esp32_ble_client"]
return ["bluetooth_connection"]
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
DEPENDENCIES = ["esp32_ble_tracker"]
DOMAIN = "ble_client"
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"ble_client.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
# Every framework of every non-esp32 registry platform: a platform
# that validates the neutral arm must also compile the neutral engine.
"ble_client_gatt.cpp": frameworks_for_platforms(
set(bluetooth_connection.GATT_CLIENT_PLATFORMS) - {PLATFORM_ESP32}
),
}
)
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
CONF_ON_NOTIFY = "on_notify"
@@ -62,16 +114,12 @@ def notify_from_on_notify(config: ConfigType) -> ConfigType:
ble_client_ns = cg.esphome_ns.namespace("ble_client")
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
# One codegen class for both engines: the exclusively-gated headers resolve
# the name to exactly one C++ definition per build.
BLEClient = ble_client_ns.class_("BLEClient", cg.Component)
BLEClientNode = ble_client_ns.class_("BLEClientNode")
BLEClientNodeConstRef = BLEClientNode.operator("ref").operator("const")
# Triggers
BLEClientConnectTrigger = ble_client_ns.class_(
"BLEClientConnectTrigger", automation.Trigger.template(BLEClientNodeConstRef)
)
BLEClientDisconnectTrigger = ble_client_ns.class_(
"BLEClientDisconnectTrigger", automation.Trigger.template(BLEClientNodeConstRef)
)
BLEClientPasskeyRequestTrigger = ble_client_ns.class_(
"BLEClientPasskeyRequestTrigger", automation.Trigger.template(BLEClientNodeConstRef)
)
@@ -109,62 +157,175 @@ CONF_AUTO_CONNECT = "auto_connect"
MULTI_CONF = True
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLEClient),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_NAME): cv.string,
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientConnectTrigger
),
}
),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientDisconnectTrigger
),
}
),
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyRequestTrigger
),
}
),
cv.Optional(CONF_ON_PASSKEY_NOTIFICATION): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyNotificationTrigger
),
}
),
cv.Optional(
CONF_ON_NUMERIC_COMPARISON_REQUEST
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientNumericComparisonRequestTrigger
),
}
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
esp32_ble.consume_connection_slots(1, "ble_client"),
# Keys shared by both engines' schemas.
_COMMON_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLEClient),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA)
# Both engines register these on the client; the Bluedroid-security triggers below stay trigger classes
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
)
@functools.cache
def _esp32_config_schema() -> cv.All:
"""The legacy engine's schema, byte-compatible with what esp32 always had
(including the Bluedroid security triggers)."""
from esphome.components import esp32_ble_tracker
return cv.All(
_COMMON_SCHEMA.extend(
{
# Accepted-but-unused legacy key; not propagated to the
# neutral schema.
cv.Optional(CONF_NAME): cv.string,
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyRequestTrigger
),
}
),
cv.Optional(
CONF_ON_PASSKEY_NOTIFICATION
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyNotificationTrigger
),
}
),
cv.Optional(
CONF_ON_NUMERIC_COMPARISON_REQUEST
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientNumericComparisonRequestTrigger
),
}
),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
bluetooth_connection.consume_gatt_slot("ble_client"),
)
@functools.cache
def _gatt_config_schema(platform: str) -> cv.All:
"""The neutral engine's schema: the shared keys plus the hub reference
(parsed-advertisement sightings) and the GATT backend declaration.
Keyed by platform - the backend fragment differs per platform."""
return cv.All(
_COMMON_SCHEMA.extend(ble_device_base.BLE_DEVICE_SCHEMA).extend(
bluetooth_connection.gatt_client_schema(platform)
),
bluetooth_connection.consume_gatt_slot("ble_client"),
)
@schema_extractor("schema")
def _validate_platform(config: ConfigType) -> ConfigType:
if config is SCHEMA_EXTRACT:
# Deliberate gap (the bluetooth_proxy pattern): the dumper gets only
# this shape, so the neutral arm's ble_hub_id is absent from editor
# schemas and the esp32-only keys are advertised on every platform.
# The language-schema dumper runs without a platform; expose the
# esp32 (legacy-engine) shape.
return _esp32_config_schema()
if _legacy_engine():
return _esp32_config_schema()(config)
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
return _gatt_config_schema(CORE.target_platform)(config)
raise cv.Invalid(f"ble_client is not supported on {CORE.target_platform}")
CONFIG_SCHEMA = _validate_platform
CONF_BLE_CLIENT_ID = "ble_client_id"
class BLEClientFeatures(StrEnum):
"""Per-platform engine capabilities consumers declare against."""
# The platform-neutral node interface (on_connected/table + completion
# callbacks) - every platform with a ble_client engine.
GATT_NODE = "gatt_node"
# The raw esp32 GATT client event stream (gattc/gap handlers,
# node_state) - the legacy engine only.
RAW_GATTC = "raw_gattc"
# Pairing dialog replies and bond management (Bluedroid GAP/SMP).
SECURITY = "security"
def _engine_features() -> set[BLEClientFeatures]:
"""Features the validated platform's engine provides."""
if _legacy_engine():
return {
BLEClientFeatures.GATT_NODE,
BLEClientFeatures.RAW_GATTC,
BLEClientFeatures.SECURITY,
}
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
return {BLEClientFeatures.GATT_NODE}
return set()
def requires_feature(
feature: BLEClientFeatures, description: str
) -> Callable[[Any], Any]:
"""Validator gating a consumer to platforms whose engine provides
`feature`, naming the missing capability in the error."""
def validator(value: Any) -> Any:
features = _engine_features()
if feature not in features:
available = (
f"; this platform's engine provides: {', '.join(sorted(features))}"
if features
else ""
)
raise cv.Invalid(
f"{description} requires the ble_client '{feature}' feature, "
f"which {CORE.target_platform} does not provide{available}"
)
return value
return validator
# The one choke point for every node component still on the raw esp32 event
# stream; migrating to the neutral interface (NODE_BLE_CLIENT_SCHEMA +
# register_gatt_node) lifts it.
_legacy_engine_only = requires_feature(
BLEClientFeatures.RAW_GATTC,
"This component drives the raw ESP32 GATT client events and has not "
"been migrated to the platform-neutral node interface yet; it",
)
BLE_CLIENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.use_id(BLEClient),
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
cv.use_id(BLEClient), _legacy_engine_only
),
}
)
# For node components on the neutral interface: valid wherever ble_client
# itself is.
NODE_BLE_CLIENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
cv.use_id(BLEClient),
requires_feature(BLEClientFeatures.GATT_NODE, "This component"),
),
}
)
@@ -174,6 +335,26 @@ async def register_ble_node(var, config):
cg.add(parent.register_ble_node(var))
def _request_gatt_node_build() -> None:
"""Node storage and the one define meaning "the neutral node surface is
compiled in", plus the esp32 bridge/materializer defines."""
_request_node_slot()
cg.add_define("USE_BLE_CLIENT_GATT_NODES")
if _legacy_engine():
# Deliberately not ble_device_base.request_gatt_client(): that would
# claim a phantom backend slot on combined proxy builds.
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_GATT_BACKEND_BLUEDROID")
cg.add_define("USE_BLUEDROID_GATT_SERVICE_TABLE")
async def register_gatt_node(var, config):
"""Register a node on the platform-neutral interface (both engines)."""
parent = await cg.get_variable(config[CONF_BLE_CLIENT_ID])
_request_gatt_node_build()
cg.add(parent.register_gatt_node(var))
def new_ble_node_component(
config: ConfigType, *args: Any
) -> Coroutine[Any, Any, cg.MockObj]:
@@ -184,8 +365,8 @@ def new_ble_node_component(
BLE_WRITE_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
cv.Required(CONF_CHARACTERISTIC_UUID): esp32_ble_tracker.bt_uuid,
cv.Required(CONF_SERVICE_UUID): bt_uuid,
cv.Required(CONF_CHARACTERISTIC_UUID): bt_uuid,
cv.Required(CONF_VALUE): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
}
)
@@ -196,25 +377,34 @@ BLE_CONNECT_ACTION_SCHEMA = maybe_simple_id(
}
)
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
}
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
}
),
)
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
}
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
}
),
)
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
}
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
}
),
)
@@ -242,6 +432,8 @@ automation.register_simple_action(
)
async def ble_write_to_code(config, action_id, template_arg, args):
parent = await cg.get_variable(config[CONF_ID])
# The action registers itself as a neutral node in its constructor.
_request_gatt_node_build()
var = cg.new_Pvariable(action_id, template_arg, parent)
value = config[CONF_VALUE]
@@ -256,38 +448,20 @@ async def ble_write_to_code(config, action_id, template_arg, args):
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*value))
cg.add(var.set_value_simple(arr, len(value)))
if len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(
var.set_service_uuid16(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
)
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid32_format):
cg.add(
var.set_service_uuid32(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
)
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid128_format):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(config[CONF_SERVICE_UUID])
if len(config[CONF_SERVICE_UUID]) == len(bt_uuid16_format):
cg.add(var.set_service_uuid16(as_hex(config[CONF_SERVICE_UUID])))
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid32_format):
cg.add(var.set_service_uuid32(as_hex(config[CONF_SERVICE_UUID])))
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid128_format):
uuid128 = as_reversed_hex_array(config[CONF_SERVICE_UUID])
cg.add(var.set_service_uuid128(uuid128))
if len(config[CONF_CHARACTERISTIC_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(
var.set_char_uuid16(
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
)
)
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
esp32_ble_tracker.bt_uuid32_format
):
cg.add(
var.set_char_uuid32(
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
)
)
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
esp32_ble_tracker.bt_uuid128_format
):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(
config[CONF_CHARACTERISTIC_UUID]
)
if len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid16_format):
cg.add(var.set_char_uuid16(as_hex(config[CONF_CHARACTERISTIC_UUID])))
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid32_format):
cg.add(var.set_char_uuid32(as_hex(config[CONF_CHARACTERISTIC_UUID])))
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid128_format):
uuid128 = as_reversed_hex_array(config[CONF_CHARACTERISTIC_UUID])
cg.add(var.set_char_uuid128(uuid128))
return var
@@ -341,22 +515,48 @@ automation.register_simple_action(
)
async def to_code(config):
async def _to_code_esp32(config: ConfigType) -> cg.MockObj:
from esphome.components import esp32_ble, esp32_ble_tracker
from esphome.components.esp32_ble import BTLoggers
# Register the loggers this component needs
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP)
cg.add_define("USE_ESP32_BLE_UUID")
cg.add_define("USE_BLE_CLIENT_LEGACY_ENGINE")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await esp32_ble_tracker.register_client(var, config)
return var
# Sizes the neutral client's node storage; the client itself requests a
# baseline slot so the define exists on every build that compiles the engine.
_request_node_slot = cg.slot_counter("ESPHOME_BLE_CLIENT_MAX_NODES")
async def _to_code_gatt(config: ConfigType) -> cg.MockObj:
# The engine always carries the node surface (the client itself owns the
# baseline slot).
_request_gatt_node_build()
backend = await bluetooth_connection.new_gatt_backend(config)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_backend(backend))
# Sighting-gated connects: the client listens for the peer's parsed
# advertisements through the hub.
await ble_device_base.register_ble_device(var, config)
return var
async def to_code(config: ConfigType) -> None:
if _legacy_engine():
var = await _to_code_esp32(config)
else:
var = await _to_code_gatt(config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
cg.add(var.set_auto_connect(config[CONF_AUTO_CONNECT]))
for conf in config.get(CONF_ON_CONNECT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_DISCONNECT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
for conf in config.get(CONF_ON_PASSKEY_REQUEST, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
@@ -1,11 +0,0 @@
#ifdef USE_ESP32
#include "automation.h"
namespace esphome::ble_client {
const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266
} // namespace esphome::ble_client
#endif
+2 -192
View File
@@ -1,27 +1,14 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/core/defines.h"
#include <utility>
#include <vector>
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include "esphome/core/automation.h"
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
namespace esphome::ble_client {
// placeholder class for static TAG .
class Automation {
public:
// could be made inline with C++17
static const char *const TAG;
};
// Base for nodes that never read the parent's services.
// The parent releases its services only once every node reports Established, so a node that never
// reports it keeps that memory allocated for the life of the connection.
@@ -39,45 +26,6 @@ class BLEClientServicelessNode : public BLEClientNode {
virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {}
};
// implement on_connect automation.
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
public:
explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); }
void loop() override {}
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
this->node_state = espbt::ClientState::ESTABLISHED;
this->trigger();
}
}
};
// on_disconnect automation
class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode {
public:
explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); }
void loop() override {}
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
// test for CLOSE and not DISCONNECT - DISCONNECT can occur even if no virtual connection (OPEN event) occurred.
// So this will not trigger unless a complete open has previously succeeded.
switch (event) {
case ESP_GATTC_SEARCH_CMPL_EVT: {
this->node_state = espbt::ClientState::ESTABLISHED;
break;
}
case ESP_GATTC_CLOSE_EVT: {
this->trigger();
break;
}
default: {
break;
}
}
}
};
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode {
public:
explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
@@ -110,144 +58,6 @@ class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>,
}
};
// implement the ble_client.ble_write action.
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientWriteAction(BLEClient *ble_client) {
ble_client->register_ble_node(this);
ble_client_ = ble_client;
}
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
this->value_.func = func;
this->len_ = -1; // Sentinel value indicates template mode
}
// Store pointer to static data in flash (no RAM copy)
void set_value_simple(const uint8_t *data, size_t len) {
this->value_.data = data;
this->len_ = len; // Length >= 0 indicates static mode
}
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
this->var_ = std::make_tuple(x...);
bool result;
if (this->len_ >= 0) {
// Static mode: write directly from flash pointer
result = this->write(this->value_.data, this->len_);
} else {
// Template mode: call function and write the vector
std::vector<uint8_t> value = this->value_.func(x...);
result = this->write(value);
}
// on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work.
if (!result)
this->play_next_(x...);
}
/**
* Note about logging: the esph_log_X macros are used here because the CI checks complain about use of the ESP LOG
* macros in header files (Can't even write it in a comment!)
* Not sure why, because they seem to work just fine.
* The problem is that the implementation of a templated class can't be placed in a .cpp file when using C++ less than
* 17, so the methods have to be here. The esph_log_X macros are equivalent in function, but don't trigger the CI
* errors.
*/
// initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event.
bool write(const uint8_t *data, size_t len) {
if (this->node_state != espbt::ClientState::ESTABLISHED) {
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected");
return false;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
#endif
esp_err_t err =
esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len,
const_cast<uint8_t *>(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE);
if (err != ESP_OK) {
esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err));
return false;
}
return true;
}
bool write(const std::vector<uint8_t> &value) { return this->write(value.data(), value.size()); }
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
switch (event) {
case ESP_GATTC_WRITE_CHAR_EVT:
// upstream code checked the MAC address, verify the characteristic.
if (param->write.handle == this->char_handle_)
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
break;
case ESP_GATTC_DISCONNECT_EVT:
if (this->num_running_ != 0)
this->stop_complex();
break;
case ESP_GATTC_SEARCH_CMPL_EVT: {
auto *chr = this->parent()->get_characteristic(this->service_uuid_, this->char_uuid_);
if (chr == nullptr) {
char char_buf[esp32_ble::UUID_STR_LEN];
char service_buf[esp32_ble::UUID_STR_LEN];
esph_log_w("ble_write_action", "Characteristic %s was not found in service %s",
this->char_uuid_.to_str(char_buf), this->service_uuid_.to_str(service_buf));
break;
}
this->char_handle_ = chr->handle;
this->char_props_ = chr->properties;
if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE) {
this->write_type_ = ESP_GATT_WRITE_TYPE_RSP;
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_RSP");
} else if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) {
this->write_type_ = ESP_GATT_WRITE_TYPE_NO_RSP;
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_NO_RSP");
} else {
char char_buf[esp32_ble::UUID_STR_LEN];
esph_log_e(Automation::TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
break;
}
this->node_state = espbt::ClientState::ESTABLISHED;
char char_buf[esp32_ble::UUID_STR_LEN];
esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
ble_client_->address_str());
break;
}
default:
break;
}
}
private:
BLEClient *ble_client_;
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
union Value {
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
const uint8_t *data; // Pointer to static data in flash
} value_;
espbt::ESPBTUUID service_uuid_;
espbt::ESPBTUUID char_uuid_;
std::tuple<Ts...> var_{};
uint16_t char_handle_{};
esp_gatt_char_prop_t char_props_{};
esp_gatt_write_type_t write_type_{};
};
template<typename... Ts> class BLEClientPasskeyReplyAction final : public Action<Ts...> {
public:
BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; }
@@ -0,0 +1,103 @@
// Neutral twins of the shared ble_client automations. Class names, namespace,
// and codegen-visible signatures are IDENTICAL to automation.h so generated
// main.cpp compiles against whichever engine the build gates in; only the
// internals differ (client callbacks and the neutral node interface instead
// of raw gattc events). The Bluedroid-security automations (passkey, numeric
// comparison, remove bond) have no neutral equivalent and stay esp32-only.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include <tuple>
#include "ble_client_gatt.h"
#include "esphome/core/automation.h"
namespace esphome::ble_client {
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...> {
public:
BLEClientConnectAction(BLEClient *ble_client) {
ble_client_ = ble_client;
ble_client->add_on_connect_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
// A connect attempt that dies (or a later disconnect) terminates the
// chain - unless a fresh attempt is already in flight, meaning a user
// trigger on this same list started this action (an on_disconnect that
// reconnects) and the wait belongs to that attempt. Legacy split these:
// it aborted on DISCONNECT_EVT, the trigger fired on the later CLOSE_EVT.
auto terminate = [this]() {
if (this->num_running_ != 0 && this->ble_client_->idle())
this->stop_complex();
};
ble_client->add_on_connect_failed_callback(terminate);
ble_client->add_on_disconnect_callback(terminate);
}
// not used since we override play_complex_
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
// it makes no sense to have multiple instances of this running at the
// same time; cancel a re-trigger while still running.
if (this->num_running_ != 0) {
this->stop_complex();
return;
}
this->num_running_++;
if (this->ble_client_->connected()) {
this->play_next_(x...);
} else {
this->var_ = std::make_tuple(x...);
// No-op while already connecting; the callback resolves the wait.
this->ble_client_->connect();
}
}
private:
BLEClient *ble_client_;
std::tuple<Ts...> var_{};
};
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...> {
public:
BLEClientDisconnectAction(BLEClient *ble_client) {
ble_client_ = ble_client;
// Both terminal outcomes resolve the wait: a completed teardown and a
// connect attempt that died on the way down.
ble_client->add_on_disconnect_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
ble_client->add_on_connect_failed_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
}
// not used since we override play_complex_
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
if (this->ble_client_->idle()) {
this->play_next_(x...);
} else {
this->var_ = std::make_tuple(x...);
this->ble_client_->disconnect();
}
}
private:
BLEClient *ble_client_;
std::tuple<Ts...> var_{};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
+278 -3
View File
@@ -2,10 +2,16 @@
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#ifdef USE_BLE_CLIENT_GATT_NODES
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "esphome/components/bluetooth_connection/gatt_service_table_bluedroid.h"
#endif
namespace esphome::ble_client {
@@ -30,6 +36,10 @@ void BLEClient::dump_config() {
bool BLEClient::parse_device(const espbt::ESPBTDevice &device) {
if (!this->enabled)
return false;
#ifdef USE_BLE_CLIENT_GATT_NODES
if (device.address_uint64() == this->address_ && this->gatt_backoff_.holding_off())
return false;
#endif
return BLEClientBase::parse_device(device);
}
@@ -40,24 +50,65 @@ void BLEClient::set_enabled(bool enabled) {
if (!enabled) {
ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str());
this->disconnect();
return;
}
#ifdef USE_BLE_CLIENT_GATT_NODES
// A re-enable clears the backoff (neutral-engine parity).
this->gatt_backoff_.reset();
#endif
}
bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
esp_ble_gattc_cb_param_t *param) {
#ifdef USE_BLE_CLIENT_GATT_NODES
// Bridge-initiated registrations bypass the base's REG_FOR_NOTIFY handling:
// its automatic CCCD write would double the node's own.
// Handle-keyed: mixed legacy/neutral subscriptions to one characteristic
// are unsupported during the migration window.
if (event == ESP_GATTC_REG_FOR_NOTIFY_EVT && esp_gattc_if == this->gattc_if_ &&
this->take_pending_gatt_reg_(param->reg_for_notify.handle)) {
if (this->pending_notify_regs_ > 0)
this->pending_notify_regs_--;
int err = param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status;
this->notify_state_to_gatt_nodes_(param->reg_for_notify.handle, true, err);
// A retiring last registration must still release the cache.
this->maybe_release_services_();
return true;
}
#endif
if (!BLEClientBase::gattc_event_handler(event, esp_gattc_if, param))
return false;
#ifdef USE_BLE_CLIENT_GATT_NODES
// Before the legacy fan-out so gatt nodes resolve before any trigger fires.
if (!this->gatt_nodes_.empty()) {
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
// A failed discovery tears the link down; the on_connect trigger must
// not fire into the teardown.
if (!this->handle_gatt_search_cmpl_(param->search_cmpl.status))
return true;
} else {
this->dispatch_gatt_event_(event, param);
}
}
#endif
for (auto *node : this->nodes_)
node->gattc_event_handler(event, esp_gattc_if, param);
// The automations run after the nodes; on_connect runs before the release so it can still use the services
if (event == ESP_GATTC_SEARCH_CMPL_EVT)
this->connect_callbacks_.call();
this->maybe_release_services_();
if (event == ESP_GATTC_CLOSE_EVT)
this->disconnect_callbacks_.call();
return true;
}
void BLEClient::maybe_release_services_() {
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
this->release_services();
ESP_LOGD(TAG, "All clients established, services released");
}
return true;
}
void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
@@ -65,10 +116,19 @@ void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_p
for (auto *node : this->nodes_)
node->gap_event_handler(event, param);
#ifdef USE_BLE_CLIENT_GATT_NODES
if (event == ESP_GAP_BLE_AUTH_CMPL_EVT && this->check_addr(param->ble_security.auth_cmpl.bd_addr)) {
int status = param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason;
for (auto *node : this->gatt_nodes_)
node->on_pairing_result(status);
}
#endif
}
void BLEClient::set_state(espbt::ClientState state) {
BLEClientBase::set_state(state);
// ESTABLISHED never flows through here; gatt nodes are promoted after the
// on_connected fan-out.
for (auto &node : nodes_)
node->node_state = state;
}
@@ -83,6 +143,221 @@ bool BLEClient::all_nodes_established_() {
return true;
}
#ifdef USE_BLE_CLIENT_GATT_NODES
void BLEClient::register_gatt_node(BLEClientNode *node) {
// Parent before the capacity check so a dropped node still has a usable
// parent() (neutral-engine parity).
node->set_ble_client_parent(this);
if (this->gatt_nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
// push_back past capacity is a silent no-op; an undersized slot count
// must be loud at boot, not an unresolvable node at runtime.
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str());
this->status_set_error(LOG_STR("node capacity exceeded"));
return;
}
this->gatt_nodes_.push_back(node);
// nodes_ covers the shared state bookkeeping; gatt_nodes_ is the neutral
// fan-out subset.
this->register_ble_node(node);
}
int BLEClient::find_pending_gatt_reg_(uint16_t handle) const {
for (uint8_t i = 0; i < this->pending_gatt_reg_count_; i++) {
if (this->pending_gatt_regs_[i] == handle)
return i;
}
return -1;
}
bool BLEClient::take_pending_gatt_reg_(uint16_t handle) {
int i = this->find_pending_gatt_reg_(handle);
if (i < 0)
return false;
// No duplicates (notify_characteristic refuses a re-push); swap-with-last.
this->pending_gatt_regs_[i] = this->pending_gatt_regs_[--this->pending_gatt_reg_count_];
return true;
}
void BLEClient::notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str(),
enabled ? "enable" : "disable", handle, error);
}
for (auto *node : this->gatt_nodes_)
node->on_notify_state(handle, enabled, error);
}
void BLEClient::dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param) {
switch (event) {
case ESP_GATTC_READ_CHAR_EVT:
case ESP_GATTC_READ_DESCR_EVT: {
bool ok = param->read.status == ESP_GATT_OK;
if (!ok) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str(), param->read.handle,
param->read.status);
}
for (auto *node : this->gatt_nodes_) {
node->on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0,
ok ? 0 : param->read.status);
}
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
case ESP_GATTC_WRITE_DESCR_EVT:
if (param->write.status != ESP_GATT_OK) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str(), param->write.handle,
param->write.status);
}
for (auto *node : this->gatt_nodes_) {
node->on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
}
break;
case ESP_GATTC_NOTIFY_EVT:
for (auto *node : this->gatt_nodes_) {
node->on_notify(param->notify.handle, param->notify.value, param->notify.value_len);
}
break;
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT:
// The base does no CCCD work for unregister; no interception needed.
this->notify_state_to_gatt_nodes_(
param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
break;
default:
break;
}
}
bool BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) {
// The base ignores the search status; the neutral contract must not.
uint16_t service_total = 0;
bool counted = status == ESP_GATT_OK && bluetooth_connection::BluedroidServiceTable::count_services(
this->gattc_if_, this->conn_id_, &service_total);
if (!counted || service_total == 0) {
// A failed search poisons the whole discovery, legacy nodes included.
ESP_LOGW(TAG, "[%s] Discovery failed (status=%d, services=%u)", this->address_str(), status, service_total);
this->gatt_backoff_.register_failure(this->address_str());
this->disconnect();
return false;
}
// Stack-owned; nodes copy their handles during on_connected().
bluetooth_connection::BluedroidServiceTable table;
if (!table.build(this->gattc_if_, this->conn_id_, service_total, this->connection_index_)) {
if (!this->has_legacy_nodes_()) {
ESP_LOGW(TAG, "[%s] Service table build failed; treating as failed discovery", this->address_str());
this->gatt_backoff_.register_failure(this->address_str());
this->disconnect();
return false;
}
// Only the table build failed; legacy nodes read the base's services_
// and keep the link. Gatt nodes catch the next connection.
ESP_LOGW(TAG, "[%s] Service table build failed; gatt nodes skip this connection", this->address_str());
this->status_set_warning(LOG_STR("gatt nodes inactive: service table build failed"));
// A peer whose database never walks must not retry the build at
// advertisement cadence once the legacy nodes drop the link.
this->gatt_backoff_.register_failure(this->address_str());
} else {
this->gatt_connected_ = true;
auto view = table.view();
for (auto *node : this->gatt_nodes_) {
node->on_connected(view);
if (this->state() != espbt::ClientState::ESTABLISHED) {
// The node tore the link down; remaining nodes get on_disconnected
// with no preceding on_connected, so leave a trace of why.
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str());
return false;
}
}
this->gatt_backoff_.reset();
this->status_clear_warning();
}
// Promote so the legacy release condition can fire.
for (auto *node : this->gatt_nodes_)
node->node_state = espbt::ClientState::ESTABLISHED;
return true;
}
void BLEClient::on_disconnect_complete(esp_err_t reason) {
this->pending_gatt_reg_count_ = 0;
if (!this->gatt_connected_)
return; // Never-established links report nothing (neutral parity).
this->gatt_connected_ = false;
for (auto *node : this->gatt_nodes_)
node->on_disconnected();
}
int BLEClient::check_and_log_error_(const char *operation, esp_err_t err) {
if (err != ESP_OK)
this->log_gattc_warning_(operation, err);
return err;
}
int BLEClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return this->check_and_log_error_(
"esp_ble_gattc_write_char",
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::read_characteristic(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_,
handle, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::read_descriptor(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return this->check_and_log_error_(
"esp_ble_gattc_read_char_descr",
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return this->check_and_log_error_(
"esp_ble_gattc_write_char_descr",
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::notify_characteristic(uint16_t handle, bool enable) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
if (enable) {
if (this->find_pending_gatt_reg_(handle) >= 0) {
// ESP_OK: the in-flight registration's completion fans out to all nodes.
ESP_LOGW(TAG, "[%s] Notify registration already pending for handle 0x%04x", this->address_str(), handle);
return ESP_OK;
}
if (this->pending_gatt_reg_count_ == MAX_PENDING_NOTIFY_REGS) {
// An untracked registration would let the base's auto-CCCD through.
ESP_LOGE(TAG, "[%s] Too many pending notify registrations", this->address_str());
return ble_device_base::GATT_ERR_NO_MEMORY;
}
// The base helper's pending count holds the service-release until the
// (intercepted) completion.
esp_err_t err = this->register_for_notify(handle);
if (err == ESP_OK)
this->pending_gatt_regs_[this->pending_gatt_reg_count_++] = handle;
return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err);
}
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify",
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
}
int BLEClient::unpair() { return bluetooth_connection::unpair_device(this->get_address()); }
#endif // USE_BLE_CLIENT_GATT_NODES
} // namespace esphome::ble_client
#endif
+63 -33
View File
@@ -1,18 +1,20 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include "ble_client_node.h"
#include "connect_backoff.h"
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_ESP32
#include <esp_bt_defs.h>
#include <esp_gap_ble_api.h>
#include <esp_gatt_common_api.h>
#include <esp_gattc_api.h>
#include <array>
#include <string>
#include <vector>
namespace esphome::ble_client {
@@ -21,34 +23,6 @@ namespace espbt = esphome::esp32_ble_tracker;
using namespace esp32_ble_client;
class BLEClient;
class BLEClientNode {
public:
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {};
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
virtual void loop() {}
void set_address(uint64_t address) { address_ = address; }
espbt::ESPBTClient *client;
// This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed.
// The parent frees the peer's GATT cache once every node reports Established.
// Never report Established while an operation that reads that cache is outstanding.
// - esp_ble_gattc_register_for_notify() completes asynchronously.
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
espbt::ClientState node_state;
BLEClient *parent() { return this->parent_; }
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
protected:
BLEClient *parent_;
uint64_t address_;
};
class BLEClient final : public BLEClientBase {
public:
void setup() override;
@@ -64,19 +38,75 @@ class BLEClient final : public BLEClientBase {
void set_enabled(bool enabled);
void register_ble_node(BLEClientNode *node) {
node->client = this;
node->set_ble_client_parent(this);
this->nodes_.push_back(node);
}
// on_connect fires once discovery completes; on_disconnect on CLOSE_EVT, so only after a completed open
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callbacks_.add(std::forward<F>(callback));
}
bool enabled;
void set_state(espbt::ClientState state) override;
#ifdef USE_BLE_CLIENT_GATT_NODES
// ---- the neutral node surface (signatures shared with the non-esp32
// engine, so nodes on the neutral interface compile against either) ----
void register_gatt_node(BLEClientNode *node);
bool idle() const { return this->state() == espbt::ClientState::IDLE; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
int read_characteristic(uint16_t handle);
int read_descriptor(uint16_t handle);
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
/// Local registration only; per the neutral contract the CCCD write is the
/// node's job (the legacy auto-CCCD is suppressed for these handles).
int notify_characteristic(uint16_t handle, bool enable);
// pair() comes from BLEClientBase, matching the neutral engine's.
int unpair();
#endif
protected:
bool all_nodes_established_();
void maybe_release_services_();
#ifdef USE_BLE_CLIENT_GATT_NODES
int check_and_log_error_(const char *operation, esp_err_t err);
int find_pending_gatt_reg_(uint16_t handle) const;
void notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error);
void dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param);
// False = failed discovery: the link comes down and the caller suppresses
// the legacy fan-out.
bool handle_gatt_search_cmpl_(esp_gatt_status_t status);
bool take_pending_gatt_reg_(uint16_t handle);
void on_disconnect_complete(esp_err_t reason) override;
#endif
std::vector<BLEClientNode *> nodes_;
LazyCallbackManager<void()> connect_callbacks_;
LazyCallbackManager<void()> disconnect_callbacks_;
#ifdef USE_BLE_CLIENT_GATT_NODES
// Raise if a migrated node needs more concurrent registrations.
static constexpr uint8_t MAX_PENDING_NOTIFY_REGS = 4;
// Nodes on the neutral surface; fed the translated callbacks and
// auto-established after the on_connected fan-out. Every gatt node is
// also in nodes_ (registration pushes into both).
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> gatt_nodes_;
bool has_legacy_nodes_() const { return this->nodes_.size() > this->gatt_nodes_.size(); }
// Reconnect backoff after materializer failures.
ConnectBackoff gatt_backoff_;
// Bridge-initiated notify registrations awaiting REG_FOR_NOTIFY_EVT.
uint16_t pending_gatt_regs_[MAX_PENDING_NOTIFY_REGS];
uint8_t pending_gatt_reg_count_{0};
// on_connected fan-out started; on_disconnected is owed at teardown.
bool gatt_connected_{false};
#endif
};
} // namespace esphome::ble_client
@@ -0,0 +1,261 @@
#include "ble_client_gatt.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
ESPHOME_LOG_TAG(TAG, "ble_client");
void BLEClient::register_ble_node(BLEClientNode *node) {
node->set_ble_client_parent(this);
if (this->nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
// push_back past capacity is a silent no-op; an undersized slot count
// must be loud at boot, not an unresolvable node at runtime.
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str_);
this->status_set_error(LOG_STR("node capacity exceeded"));
return;
}
this->nodes_.push_back(node);
}
void BLEClient::set_address(uint64_t address) {
this->address_ = address;
uint8_t mac[6];
ble_device_base::uint64_to_mac_msb_first(address, mac);
format_mac_addr_upper(mac, this->address_str_);
}
void BLEClient::set_enabled(bool enabled) {
if (enabled == this->enabled)
return;
ESP_LOGI(TAG, "[%s] %s", this->address_str_, enabled ? "Enabled" : "Disabled");
this->enabled = enabled;
if (!enabled) {
this->disconnect();
return;
}
// A re-enable clears the backoff; the next sighting connects (legacy
// parity: enabling does not itself connect).
this->backoff_.reset();
}
bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != this->address_)
return false;
// The sighting is the source of truth for the address type.
this->address_type_ = device.get_address_type();
this->address_type_known_ = true;
if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE)
return true;
if (this->backoff_.holding_off())
return true;
this->attempt_connect_();
return true;
}
void BLEClient::connect() {
if (this->state_ != State::IDLE) {
ESP_LOGD(TAG, "[%s] Connect requested while busy, ignoring", this->address_str_);
return;
}
// An absent peer can inhibit scanning for the backend's full connect
// timeout, so this is worth a breadcrumb - but it is a supported action.
ESP_LOGI(TAG, "[%s] Connecting on request", this->address_str_);
if (!this->address_type_known_) {
// Legacy parity: without a sighting the address type defaults to
// public, which never matches a random-static peer.
ESP_LOGW(TAG, "[%s] No sighting yet; assuming a public address type", this->address_str_);
}
this->attempt_connect_();
}
void BLEClient::attempt_connect_() {
int err = this->backend_->connect(this->address_, this->address_type_);
if (err != 0) {
// A refused connect never produces a callback: stay idle, charge the
// backoff, and resolve any waiting connect action through the failure
// path so its chain terminates.
ESP_LOGW(TAG, "[%s] Connect refused, err=%d", this->address_str_, err);
this->backoff_.register_failure(this->address_str_);
this->defer([this]() { this->connect_failed_callbacks_.call(); });
return;
}
ESP_LOGD(TAG, "[%s] Connecting", this->address_str_);
this->state_ = State::CONNECTING;
}
void BLEClient::disconnect() {
if (this->state_ == State::IDLE) {
ESP_LOGD(TAG, "[%s] Disconnect requested while idle, ignoring", this->address_str_);
return;
}
if (this->cancel_requested_) {
// The accepted teardown still reports; a refused second gatt_disconnect()
// would settle the link twice (phantom connect failure, backoff charge).
ESP_LOGD(TAG, "[%s] Disconnect already in flight, ignoring", this->address_str_);
return;
}
// A deliberate teardown's failure report must not feed the backoff.
this->cancel_requested_ = true;
int err = this->backend_->gatt_disconnect();
if (err != 0) {
// Refused synchronously: backend and client disagree about the link
// state. Warn, then settle through the deliberate-cancel path.
ESP_LOGW(TAG, "[%s] Disconnect refused, err=%d; settling locally", this->address_str_, err);
this->on_connection_state(false, 0, err);
}
}
void BLEClient::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected) {
this->state_ = State::DISCOVERING;
int discover_err = this->backend_->discover_services();
if (discover_err != 0) {
// Synchronous refusal: no discovery completion will follow.
ESP_LOGW(TAG, "[%s] Service discovery refused, err=%d", this->address_str_, discover_err);
this->backoff_.register_failure(this->address_str_);
// Deliberate teardown: its report must not charge the backoff again.
this->disconnect();
}
return;
}
bool was_connected = this->state_ == State::CONNECTED;
bool cancelled = this->cancel_requested_;
this->cancel_requested_ = false;
this->state_ = State::IDLE;
if (was_connected) {
ESP_LOGI(TAG, "[%s] Disconnected, status=%d", this->address_str_, error);
for (auto *node : this->nodes_) {
node->on_disconnected();
}
// Continuations leave the backend's event-drain stack first.
this->defer([this]() { this->disconnect_callbacks_.call(); });
} else {
if (cancelled) {
// status carries the refusal code when the teardown settled
// synchronously; 0 on a backend-completed cancel.
ESP_LOGD(TAG, "[%s] Connect attempt cancelled, status=%d", this->address_str_, error);
} else {
ESP_LOGW(TAG, "[%s] Connect failed, status=%d", this->address_str_, error);
this->backoff_.register_failure(this->address_str_);
}
this->defer([this]() { this->connect_failed_callbacks_.call(); });
}
}
void BLEClient::on_service_discovery_done(int error) {
if (this->state_ != State::DISCOVERING || this->cancel_requested_) {
// Raced a teardown; the terminal report settles the link.
ESP_LOGD(TAG, "[%s] Discovery completed during teardown, ignoring", this->address_str_);
this->backend_->release_services();
return;
}
if (error != 0) {
ESP_LOGW(TAG, "[%s] Service discovery failed, status=%d", this->address_str_, error);
this->backoff_.register_failure(this->address_str_);
// The teardown is deliberate: do not charge the backoff again for its
// connection report.
this->disconnect();
return;
}
ble_device_base::GattServiceTable table{};
if (!this->nodes_.empty()) {
// Materialize only when a node will read it: a client with no nodes
// would pay the build/free cycle on every (re)connect for nothing.
table = this->backend_->get_service_table();
if (table.service_count == 0) {
// A failed materialization is indistinguishable from a service-less
// peer, and a real GATT peer always exposes at least GAP/GATT: fail
// the discovery before CONNECTED so the teardown resolves through
// connect_failed, never a spurious on_disconnect.
ESP_LOGW(TAG, "[%s] Service table is empty; treating as failed discovery", this->address_str_);
this->backend_->release_services();
this->backoff_.register_failure(this->address_str_);
this->disconnect();
return;
}
}
// CONNECTED before the fan-out so nodes may consult connected() from
// their own on_connected().
this->state_ = State::CONNECTED;
for (auto *node : this->nodes_) {
node->on_connected(table);
if (this->state_ != State::CONNECTED || this->cancel_requested_) {
// A node tore the link down mid-fan-out: on_disconnect fires with no
// preceding on_connect, so leave a trace of why.
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str_);
this->backend_->release_services();
return;
}
}
this->backend_->release_services();
this->backoff_.reset();
ESP_LOGI(TAG, "[%s] Connected", this->address_str_);
this->defer([this]() { this->connect_callbacks_.call(); });
}
void BLEClient::on_write_result(uint16_t handle, int error) {
if (error != 0) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str_, handle, error);
}
for (auto *node : this->nodes_) {
node->on_write_result(handle, error);
}
}
void BLEClient::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
if (error != 0) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str_, handle, error);
}
for (auto *node : this->nodes_) {
node->on_read_result(handle, data, len, error);
}
}
void BLEClient::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
// Every node sees every notification and filters by handle (legacy parity).
for (auto *node : this->nodes_) {
node->on_notify(handle, data, len);
}
}
void BLEClient::on_notify_state(uint16_t handle, bool enabled, int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str_,
enabled ? "enable" : "disable", handle, error);
}
for (auto *node : this->nodes_) {
node->on_notify_state(handle, enabled, error);
}
}
void BLEClient::on_pairing_result(int status) {
if (status != 0) {
ESP_LOGW(TAG, "[%s] Pairing failed, status=%d", this->address_str_, status);
} else {
ESP_LOGI(TAG, "[%s] Paired", this->address_str_);
}
for (auto *node : this->nodes_) {
node->on_pairing_result(status);
}
}
void BLEClient::dump_config() {
ESP_LOGCONFIG(TAG,
"BLE Client:\n"
" Address: %s\n"
" Auto connect: %s",
this->address_str_, YESNO(this->auto_connect_));
if (this->enabled && this->state_ == State::IDLE) {
ESP_LOGCONFIG(TAG, " Waiting for an advertisement from the device");
}
}
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
@@ -0,0 +1,151 @@
// Platform-neutral ble_client engine on the ble_device_base GATT contract.
//
// Compiled on every platform with a GATT backend except esp32, which keeps
// the legacy BLEClientBase engine (ble_client.h) until its raw-gattc node
// family migrates - the exclusive gates make the same class names resolve to
// exactly one definition per build, so codegen is shared.
//
// Connects are sighting-gated like the legacy engine: the client is a parsed
// advertisement listener, captures the peer's address type from the sighting,
// and asks the backend to connect only when enabled and idle.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include "ble_client_node.h"
#include "connect_backoff.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#include <functional>
namespace esphome::ble_client {
class BLEClient : public Component,
public ble_device_base::ESPBTDeviceListener,
public ble_device_base::GattClientListener {
public:
void dump_config() override;
// Public field for legacy parity (the switch platform republishes it).
bool enabled{true};
void set_backend(ble_device_base::BLEGattConnection *backend) {
this->backend_ = backend;
backend->set_listener(this);
}
void set_address(uint64_t address);
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }
void set_enabled(bool enabled);
const char *address_str() const { return this->address_str_; }
void register_ble_node(BLEClientNode *node);
// One registration spelling shared with the esp32 engine's bridge.
void register_gatt_node(BLEClientNode *node) { this->register_ble_node(node); }
// An accepted teardown already owns the link: the pending report cannot be
// a connect. state_ stays put so that report still fires on_disconnect.
bool connected() const { return this->state_ == State::CONNECTED && !this->cancel_requested_; }
bool idle() const { return this->state_ == State::IDLE; }
/// Action-initiated connect (no sighting needed; uses the last captured
/// address type, public until a sighting arrives). No-op unless idle.
void connect();
void disconnect();
/// Legacy-named deferral used by the automation twins: neutral listener
/// callbacks run inside the backend's event drain, so automation chain
/// continuations must leave that stack first.
void run_later(std::function<void()> &&f) { this->defer(std::move(f)); } // NOLINT
// Backend ops for nodes and actions - the frozen node-facing surface.
// Only write_characteristic has an in-tree caller; subscribing means
// notify_characteristic plus a CCCD write_descriptor (the caller's job
// per the contract).
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
return this->backend_->write_characteristic(handle, data, len, response);
}
int read_characteristic(uint16_t handle) { return this->backend_->read_characteristic(handle); }
int read_descriptor(uint16_t handle) { return this->backend_->read_descriptor(handle); }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return this->backend_->write_descriptor(handle, data, len);
}
int notify_characteristic(uint16_t handle, bool enable) {
return this->backend_->notify_characteristic(handle, enable);
}
int pair() { return this->backend_->pair(); }
int unpair() { return bluetooth_connection::unpair_device(this->address_); }
// Automation callback registration.
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callbacks_.add(std::forward<F>(callback));
}
// Fired when a connect attempt dies before being established; the user
// on_disconnect trigger deliberately does NOT fire here (legacy parity).
template<typename F> void add_on_connect_failed_callback(F &&callback) {
this->connect_failed_callbacks_.add(std::forward<F>(callback));
}
// ---- ble_device_base::ESPBTDeviceListener ----
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
// ---- ble_device_base::GattClientListener ----
void on_connection_state(bool connected, uint16_t mtu, int error) override;
void on_service_discovery_done(int error) override;
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
void on_write_result(uint16_t handle, int error) override;
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_notify_state(uint16_t handle, bool enabled, int error) override;
void on_pairing_result(int status) override;
protected:
enum class State : uint8_t { IDLE, CONNECTING, DISCOVERING, CONNECTED };
void attempt_connect_();
// Group 1: pointers / containers
ble_device_base::BLEGattConnection *backend_{nullptr};
// Codegen-sized (ESPHOME_BLE_CLIENT_MAX_NODES); filled during setup.
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> nodes_;
// Group 2: 8-byte types
uint64_t address_{0};
// Group 3: callback managers (pointer-sized when empty)
LazyCallbackManager<void()> connect_callbacks_;
LazyCallbackManager<void()> disconnect_callbacks_;
LazyCallbackManager<void()> connect_failed_callbacks_;
// Group 4: 4-byte types
// Backoff so an undiscoverable database or a dead peer cannot produce a
// battery-draining connect loop.
ConnectBackoff backoff_;
// Group 5: arrays
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
// Group 6: 1-byte types
State state_{State::IDLE};
uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting
// Distinguishes a captured public type from the never-sighted default.
bool address_type_known_{false};
bool auto_connect_{true};
// A user-initiated teardown in flight; its failure report is not a
// connect failure and must not feed the backoff.
bool cancel_requested_{false};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
@@ -0,0 +1,68 @@
// The single BLEClientNode both ble_client engines share. The neutral
// callback surface is the one interface node components build on; the raw
// esp32 surface below it remains for components that have not migrated yet.
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#endif
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
#endif
#include <cstdint>
namespace esphome::ble_client {
class BLEClient;
class BLEClientNode {
public:
#ifdef USE_BLE_CLIENT_GATT_NODES
// Neutral surface, delivered by both engines. The table is borrowed: copy
// handles during on_connected(). All nodes see all completions; filter by
// handle.
// A node that disconnects from inside on_connected() aborts the fan-out;
// the user's on_disconnect may then fire without a preceding on_connect.
virtual void on_connected(const ble_device_base::GattServiceTable &table) {}
virtual void on_disconnected() {}
virtual void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {}
// One in-flight registration per handle; its completion fans out to every
// node, so a refused duplicate request still sees on_notify_state.
virtual void on_notify_state(uint16_t handle, bool enabled, int error) {}
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
virtual void on_write_result(uint16_t handle, int error) {}
virtual void on_pairing_result(int status) {}
#endif
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
// Legacy raw surface; components overriding these need the legacy engine
// until migrated to the neutral surface above.
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {}
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
virtual void loop() {}
// This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed.
// The parent frees the peer's GATT cache once every node reports Established.
// Never report Established while an operation that reads that cache is outstanding.
// - esp_ble_gattc_register_for_notify() completes asynchronously.
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
esp32_ble_tracker::ClientState node_state;
#endif
BLEClient *parent() const { return this->parent_; }
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
protected:
BLEClient *parent_{nullptr};
};
} // namespace esphome::ble_client
@@ -0,0 +1,172 @@
// The ble_client.ble_write action: a node on the platform-neutral interface,
// so one implementation serves both engines (the esp32 bridge and the
// neutral engine).
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_CLIENT_GATT_NODES
#include <tuple>
#include <vector>
// One of the two engine headers resolves per build.
#include "ble_client.h"
#include "ble_client_gatt.h"
#include "ble_client_node.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
static const char *const BLE_WRITE_TAG = "ble_client.automation";
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientWriteAction(BLEClient *ble_client) {
ble_client->register_gatt_node(this);
ble_client_ = ble_client;
}
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
this->value_.func = func;
this->len_ = -1; // Sentinel value indicates template mode
}
// Store pointer to static data in flash (no RAM copy)
void set_value_simple(const uint8_t *data, size_t len) {
this->value_.data = data;
this->len_ = len; // Length >= 0 indicates static mode
}
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
this->var_ = std::make_tuple(x...);
bool result;
if (this->len_ >= 0) {
result = this->write(this->value_.data, this->len_);
} else {
std::vector<uint8_t> value = this->value_.func(x...);
result = this->write(value.data(), value.size());
}
// on write failure, continue the automation chain rather than stopping so
// that e.g. disconnect can work.
if (!result)
this->play_next_(x...);
}
// Initiate the write; the completion arrives in on_write_result. The
// response-less path can complete synchronously inside the call, so the
// handle is armed before the backend is touched.
bool write(const uint8_t *data, size_t len) {
if (!this->ble_client_->connected()) {
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - not connected");
return false;
}
if (!this->resolved_) {
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - characteristic was not resolved");
return false;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
esph_log_vv(BLE_WRITE_TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
#endif
int err = this->ble_client_->write_characteristic(this->char_handle_, data, len, this->write_response_);
if (err != 0) {
esph_log_e(BLE_WRITE_TAG, "Error writing to characteristic: %d!", err);
return false;
}
return true;
}
void on_connected(const ble_device_base::GattServiceTable &table) override {
// Resolution comes from this table only; a re-discovery must not keep a
// stale handle.
this->resolved_ = false;
this->char_handle_ = 0;
const auto *service = ble_device_base::find_service(table, this->service_uuid_);
const auto *chr =
service == nullptr ? nullptr : ble_device_base::find_characteristic(table, *service, this->char_uuid_);
if (chr == nullptr) {
char char_buf[ble_device_base::UUID_STR_LEN];
char service_buf[ble_device_base::UUID_STR_LEN];
esph_log_w(BLE_WRITE_TAG, "Characteristic %s was not found in service %s", this->char_uuid_.to_str(char_buf),
this->service_uuid_.to_str(service_buf));
return;
}
if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE) {
this->write_response_ = true;
} else if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE_NO_RSP) {
this->write_response_ = false;
} else {
char char_buf[ble_device_base::UUID_STR_LEN];
esph_log_e(BLE_WRITE_TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
return;
}
this->char_handle_ = chr->value_handle;
this->resolved_ = true;
char char_buf[ble_device_base::UUID_STR_LEN];
esph_log_d(BLE_WRITE_TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
this->ble_client_->address_str());
}
void on_disconnected() override {
this->resolved_ = false;
this->char_handle_ = 0;
if (this->num_running_ != 0)
this->stop_complex();
}
void on_write_result(uint16_t handle, int error) override {
if (this->num_running_ == 0) {
return;
}
if (!this->resolved_ || handle != this->char_handle_) {
// A parked chain waiting on a completion that never matches would
// otherwise stall silently until disconnect.
esph_log_d(BLE_WRITE_TAG, "Write result for handle 0x%04x ignored, waiting on 0x%04x", handle,
this->char_handle_);
return;
}
if (error != 0) {
// Continue the chain (legacy parity) but leave a breadcrumb.
esph_log_w(BLE_WRITE_TAG, "Write completed with status %d", error);
}
this->ble_client_->run_later([this]() { this->play_next_tuple_(this->var_); });
}
private:
BLEClient *ble_client_;
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
union Value {
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
const uint8_t *data; // Pointer to static data in flash
} value_;
ble_device_base::ESPBTUUID service_uuid_;
ble_device_base::ESPBTUUID char_uuid_;
std::tuple<Ts...> var_{};
uint16_t char_handle_{};
bool write_response_{false};
bool resolved_{false};
};
} // namespace esphome::ble_client
#endif // USE_BLE_CLIENT_GATT_NODES
@@ -0,0 +1,43 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cstdint>
namespace esphome::ble_client {
/// Reconnect backoff after repeated connect/discovery failures, shared by
/// both engines. 256 ms ticks in a uint16_t keep it 4 bytes; the ~4.7 h tick
/// wrap can at worst reinstate one stale hold-off of a minute.
class ConnectBackoff {
public:
bool holding_off() const {
return this->failures_ != 0 && static_cast<uint16_t>(now() - this->start_) < this->failures_ * STEP_TICKS;
}
void register_failure(const char *address_str) {
if (this->failures_ < MAX_STEPS)
this->failures_++;
this->start_ = now();
esph_log_w("ble_client", "[%s] Holding off reconnect for %u s", address_str, this->failures_ * 10u);
}
void reset() { this->failures_ = 0; }
private:
// ~10 s per consecutive failure, capped so a flapping peer retries within
// a minute at worst.
static constexpr uint16_t STEP_TICKS = 40; // x 256 ms
static constexpr uint8_t MAX_STEPS = 6;
static uint16_t now() { return static_cast<uint16_t>(millis() >> 8); }
uint16_t start_{0};
uint8_t failures_{0};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT
@@ -0,0 +1,48 @@
#include "ble_gatt_client.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/core/log.h"
namespace esphome::ble_device_base {
ESPHOME_LOG_TAG(TAG, "ble_device_base");
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid) {
// 32-bit range math: a corrupt first/count pair cannot wrap past the check.
uint32_t end = uint32_t(service.first_characteristic) + service.characteristic_count;
if (end > table.characteristic_count) {
ESP_LOGW(TAG, "characteristic range out of bounds");
return nullptr;
}
for (uint32_t i = service.first_characteristic; i < end; i++) {
if (table.characteristics[i].uuid == uuid)
return &table.characteristics[i];
}
return nullptr;
}
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
const ESPBTUUID &uuid) {
uint32_t end = uint32_t(characteristic.first_descriptor) + characteristic.descriptor_count;
if (end > table.descriptor_count) {
// Corrupt range, not a missing descriptor.
ESP_LOGW(TAG, "descriptor range out of bounds");
return nullptr;
}
for (uint32_t i = characteristic.first_descriptor; i < end; i++) {
if (table.descriptors[i].uuid == uuid)
return &table.descriptors[i];
}
return nullptr;
}
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
const GattDescriptor *desc = find_descriptor(table, characteristic, ESPBTUUID::from_uint16(CCCD_UUID));
return desc != nullptr ? desc->handle : 0;
}
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -11,13 +11,16 @@
// interface. All listener calls are delivered on the ESPHome main loop;
// borrowed data pointers are valid only for the duration of the call.
//
// Error domain (plain int, forwarded to the API without translation):
// Error domain (plain int, forwarded to the API without translation, so the
// values are wire-frozen - API clients interpret them):
// 0 success
// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree)
// 1..0x11 ATT error codes (Bluetooth spec) - reserved; a backend whose
// native error codes land in this window must remap them out
// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw
// ESP_FAIL from the stack shares this value; both read as a
// failed, unusable connection on the client side)
// GATT_ERR_NO_MEMORY (-2) backend storage exhausted
// -1..-15 reserved for future contract sentinels
// anything else: platform stack error/status code, surfaced opaquely.
// Connection events carry HCI status/disconnect reason codes (same code
// space on every controller).
@@ -99,9 +102,23 @@ class GattClientListener {
// The BLEGattConnection op surface, asserted where the alias binds
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
// through the listener) or a synchronous error (busy, not connected, stack
// rejection); one operation may be outstanding at a time. Semantics beyond
// the signatures:
// rejection); one operation may be outstanding at a time. A connection-state
// or discovery completion always arrives from the event loop, never
// synchronously from inside the op call - a synchronous terminal
// on_connection_state from within gatt_disconnect() would re-enter the
// consumer mid-teardown. A local-only completion may instead report inline,
// before the op returns: notify_characteristic (a subscription-list edit on
// the rp2 backend) and a write without response whose payload the stack took
// immediately. Consumers must therefore arm whatever state their
// on_write_result/on_notify_state reads before issuing the op, never after
// the call returns. Semantics beyond the signatures:
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
// Returning 0 means the request is accepted, not that the radio acted: the
// backend owns integration with its platform's scan/connect arbitration
// (Bluedroid parks the request for the tracker's promote loop, which owns
// scan-stop/coex/one-connect-at-a-time; the rp2 backend opens immediately
// and relies on sighting-gated consumers). Consumers must not assume
// connect timing.
// - gatt_disconnect: also cancels a connect in progress (named to coexist
// with a platform stack's own void disconnect() on one backend class).
// Nonzero means nothing to tear down and no completion will follow; an
@@ -143,6 +160,41 @@ concept BLEGattConnectionContract = requires(T conn, GattClientListener *listene
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
};
// ---- service table lookup helpers ----
//
// Neutral, bounds-checked walks over a materialized GattServiceTable for
// direct consumers that resolve a known device's handles by UUID (streaming
// consumers forward the raw database and never need these). Linear search:
// the table exists only between discovery and release_services(), for one
// small known device.
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
static constexpr uint16_t CCCD_UUID = 0x2902;
// Characteristic property bits (the Bluetooth-spec declaration byte carried
// in GattCharacteristic::properties; the ESP-IDF macros for these do not
// exist on the other platforms).
static constexpr uint8_t GATT_CHAR_PROP_WRITE_NO_RSP = 0x04;
static constexpr uint8_t GATT_CHAR_PROP_WRITE = 0x08;
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
for (uint16_t i = 0; i < table.service_count; i++) {
if (table.services[i].uuid == uuid)
return &table.services[i];
}
return nullptr;
}
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid);
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
const ESPBTUUID &uuid);
/// Handle of the characteristic's Client Characteristic Configuration
/// descriptor (0x2902), or 0 when it has none.
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic);
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -1,12 +1,14 @@
"""Per-platform GATT connection backends and the helpers to embed one.
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the
Bluetooth proxy's codegen declares and registers the backend instances
through gatt_client_schema()/hub_connection_schema() + new_gatt_backend().
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
consumer's codegen declares and registers the backend instances — the
Bluetooth proxy through its per-slot connection wrappers (a streaming
consumer), and the neutral ble_client through gatt_client_schema() +
new_gatt_backend().
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.components import rp2040_ble
@@ -23,7 +25,8 @@ from esphome.types import ConfigType
def AUTO_LOAD() -> list[str]:
"""ble_device_base plus the platform BLE stack the build's backend
registers with (the Bluedroid header includes the tracker's), so
consumers need not know. The platform-less arm serves manifest tooling."""
consumers stay platform-blind. The platform-less arm serves tooling that
resolves the manifest without a target."""
if CORE.is_esp32:
return ["ble_device_base", "esp32_ble_tracker"]
if CORE.is_rp2:
@@ -63,6 +66,8 @@ DOMAIN = "bluetooth_connection"
@dataclass
class _ConnectionData:
rp2_backend_count: int = 0
# GATT connection slots claimed this run, for the platform cap check.
slot_consumers: list[str] = field(default_factory=list)
def _get_data() -> _ConnectionData:
@@ -119,6 +124,10 @@ class _PlatformBackend:
backend_class: cg.MockObjClass
schema_fragment: Callable[[], cv.Schema]
register: Callable[[cg.MockObj, ConfigType], Awaitable[None]]
# Selects the backend's alias-ladder arm (order-independent arms).
define: str
# The backend's on-demand materializer gate, when it has one.
materializer_define: str | None = None
# The single registry of platforms with a GATT client backend; a platform
@@ -126,11 +135,20 @@ class _PlatformBackend:
# platform's arm.
_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
PLATFORM_ESP32: _PlatformBackend(
BluedroidGattClient, _esp32_schema_fragment, _esp32_register
BluedroidGattClient,
_esp32_schema_fragment,
_esp32_register,
"USE_BLE_GATT_BACKEND_BLUEDROID",
materializer_define="USE_BLUEDROID_GATT_SERVICE_TABLE",
),
PLATFORM_RP2: _PlatformBackend(
RP2GattClient, _rp2_schema_fragment, _rp2_register, "USE_BLE_GATT_BACKEND_RP2"
),
PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register),
}
# Gates dedicated-backend consumers (cv.only_on).
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
def _backend_entry(platform: str | None = None) -> _PlatformBackend:
key = platform if platform is not None else CORE.target_platform
@@ -165,21 +183,97 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema:
)
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
def _charge_esp32_budget(count: int, consumer: str, config: ConfigType) -> None:
from esphome.components import esp32_ble
esp32_ble.consume_connection_slots(count, consumer)(config)
def _charge_rp2_budget(count: int, consumer: str, config: ConfigType) -> None:
rp2040_ble.consume_connection_slots(count, consumer)(config)
# Platforms whose BLE stack owns its own connection budget: the claim is
# charged there and that stack's final validation is the one place an
# overcommit is reported (never two messages for one misconfiguration). One
# mapping drives both, so a platform can never be skipped without a charge.
_STACK_BUDGETS: dict[str, Callable[[int, str, ConfigType], None]] = {
PLATFORM_ESP32: _charge_esp32_budget,
PLATFORM_RP2: _charge_rp2_budget,
}
def consume_gatt_slot(
consumer: str, count: int = 1
) -> Callable[[ConfigType], ConfigType]:
"""Validator claiming GATT connection slots - the one spelling for every
claimant. The neutral ledger covers backend platforms without a stack
budget (the cap check in FINAL_VALIDATE_SCHEMA)."""
def validator(config: ConfigType) -> ConfigType:
_get_data().slot_consumers.extend([consumer] * count)
if (charge := _STACK_BUDGETS.get(CORE.target_platform)) is not None:
charge(count, consumer, config)
return config
return validator
def _validate_slot_totals(config: ConfigType) -> ConfigType:
# Skipped in testing mode so grouped component builds can co-exist
# (mirrors esp32_ble.validate_connection_slots).
if CORE.testing_mode:
return config
if CORE.target_platform in _STACK_BUDGETS:
return config
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
# Any backend platform without a stack budget must carry a cap here
# or fail loudly, never fail open.
if CORE.target_platform in _PLATFORM_BACKENDS:
raise cv.Invalid(
f"{CORE.target_platform} has a GATT backend but no slot cap "
"in HUB_MAX_CONNECTIONS"
)
return config
claimed = _get_data().slot_consumers
if len(claimed) > cap:
raise cv.Invalid(
f"{CORE.target_platform} supports at most {cap} GATT client "
f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}"
)
return config
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
async def new_gatt_backend(
config: ConfigType, *, service_table: bool = True
) -> cg.MockObj:
"""Instantiate the backend declared by gatt_client_schema() and register
it with its platform stack. The connection slot is claimed at validation
(the proxy's slot validators), not here.
(the consume_gatt_slot validators), not here.
service_table is honored by the Bluedroid backend only: forward
scaffolding for the first esp32 direct consumer, load-bearing on no
current build (rp2 ignores the define and always materializes - its
proxy hub streams through get_service_table(), so it must keep the
materializer regardless of the flag).
"""
from esphome.components import ble_device_base
entry = _backend_entry()
ble_device_base.request_gatt_client()
cg.add_define(entry.define)
if service_table and entry.materializer_define is not None:
cg.add_define(entry.materializer_define)
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
# The backend is the slot's real Component: component keys from the
# connection entry (setup_priority, ...) apply to it. Consumers whose own
# schema carries keys that register_component would misapply to the
# backend (e.g. a polling interval) must not put them in this config.
await cg.register_component(backend, config)
await _backend_entry().register(backend, config)
await entry.register(backend, config)
return backend
@@ -187,6 +281,7 @@ async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
# list (this module cannot import bluetooth_proxy to derive it).
SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = {
"bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
"gatt_service_table_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
# Every hub platform the proxy admits (the file compiles empty where
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
# cannot hit a missing-symbol trap here.
@@ -46,7 +46,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
namespace esphome::bluetooth_connection {
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
@@ -65,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) {
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32 && USE_BLE_GATT_CLIENT
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
@@ -49,15 +49,16 @@ static constexpr conn_err_t CONN_OK = 0;
// GATT contract so backend and wrapper cannot drift.
static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
// What the platform's connection backend supports beyond GATT operations;
// the proxy derives its feature flags and legacy version from these.
#if defined(USE_ESP32)
// What the build's connection backend supports beyond GATT operations; the
// proxy derives its feature flags and legacy version from these. Keyed on
// the backend define, never the platform, so a second backend on one
// platform carries its own facts.
#if defined(USE_BLE_GATT_BACKEND_BLUEDROID)
static constexpr bool SUPPORTS_PAIRING = true;
static constexpr bool SUPPORTS_CACHE_CLEARING = true;
#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
#elif defined(USE_BLE_GATT_BACKEND_RP2)
// The rp2 BTstack backend pairs (just works + bonding); it has no service
// cache to clear. Keyed on the backend, not the generic client define, so a
// future backend without pairing keeps the stub arm below.
// cache to clear.
static constexpr bool SUPPORTS_PAIRING = true;
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
#else
@@ -65,13 +66,14 @@ static constexpr bool SUPPORTS_PAIRING = false;
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
#endif
// Address-scoped (not connection-scoped) maintenance requests.
#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
// Address-scoped (not connection-scoped) maintenance requests; keyed on the
// stack (the calls need no backend instance).
#if (defined(USE_ESP32_BLE) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
conn_err_t unpair_device(uint64_t address);
#else
inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; }
#endif
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
conn_err_t clear_gatt_cache(uint64_t address);
#else
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
@@ -2,10 +2,11 @@
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
// The in-place streamer serves the proxy's service-discovery API; backend-only
// builds compile without the proxy headers or the streamer.
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "bluetooth_connection.h"
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -345,6 +346,9 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
void BluedroidGattClient::release_services() {
this->service_total_ = 0;
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
this->table_.free();
#endif
// Always set: terminates any in-flight stream on every cache config.
this->services_released_ = true;
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
@@ -357,6 +361,24 @@ void BluedroidGattClient::release_services() {
#endif
}
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
// Lifetime: every teardown path (CLOSE_EVT, the safety timeout, stack-down,
// passive DISCONNECT) routes through release_services(), so a materialized
// table cannot outlive its link.
if (this->table_.empty() &&
(this->services_released_ || this->service_total_ == 0 ||
!this->table_.build(this->gattc_if_, this->conn_id_, this->service_total_, this->connection_index_))) {
// Released / no services / failed build all collapse to empty; the
// build failures warned above, log the quiet two.
ESP_LOGD(TAG, "[%d] No service table (released=%d, services=%u)", this->connection_index_, this->services_released_,
this->service_total_);
return {};
}
return this->table_.view();
}
#endif // USE_BLUEDROID_GATT_SERVICE_TABLE
// ---- internals ----
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
@@ -403,6 +425,11 @@ void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
// ---- service streaming ----
int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
// Re-discovery moves the counts the table view derives offsets from; free
// the stale table.
this->table_.free();
#endif
// Step down from the fast discovery params.
this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
if (status != ESP_GATT_OK) {
@@ -11,6 +11,9 @@
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
#include "gatt_service_table_bluedroid.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
@@ -76,11 +79,16 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
int notify_characteristic(uint16_t handle, bool enable);
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
// Contract stub: the proxy streams in place; the on-demand materializer
// for direct consumers lands with #18205. NOTE: a direct consumer reaching
// this stub gets an empty table indistinguishable from a service-less
// peer - do not ship one against this backend before the materializer.
// On-demand table for direct consumers; the proxy streams instead, so the
// materializer compiles only under USE_BLUEDROID_GATT_SERVICE_TABLE (emitted by
// direct-consumer codegen, never by the proxy).
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable get_service_table();
#else
// A direct consumer reaching this stub misconfigured its codegen
// (service_table=False): the empty table reads as a service-less peer.
ble_device_base::GattServiceTable get_service_table() { return {}; }
#endif
void release_services();
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
@@ -110,6 +118,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
// Group 1: pointers / composed objects
ble_device_base::GattClientListener *listener_{nullptr};
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
BluedroidServiceTable table_;
#endif
// Group 2: 4-byte types
uint32_t disconnecting_started_{0};
@@ -12,10 +12,12 @@
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#if defined(USE_RP2040_BLE)
// Arms are keyed on codegen-emitted per-backend defines (_PLATFORM_BACKENDS
// in __init__.py), so they are order-independent.
#if defined(USE_BLE_GATT_BACKEND_RP2)
#include "bluetooth_connection_rp2.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
#elif defined(USE_ESP32_BLE)
#elif defined(USE_BLE_GATT_BACKEND_BLUEDROID)
#include "bluetooth_connection_bluedroid.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
@@ -0,0 +1,198 @@
#include "gatt_service_table_bluedroid.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
#include "esphome/core/log.h"
namespace esphome::bluetooth_connection {
ESPHOME_LOG_TAG(TAG, "bluetooth_connection");
// A stack that never reports end-of-range would otherwise walk forever.
static constexpr uint16_t MAX_DESCRIPTORS_PER_CHARACTERISTIC = 64;
// Shared enumeration for both build passes: an identical walk order is what
// lets the counting pass size the block the filling pass fills.
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
template<typename ServiceFn, typename CharFn, typename DescFn>
bool BluedroidServiceTable::walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
for (uint16_t s = 0; s < this->service_total_; s++) {
esp_gattc_service_elem_t svc;
uint16_t svc_count = 1;
auto svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s);
if (svc_status != ESP_GATT_OK || svc_count == 0) {
this->log_walk_warning_("esp_ble_gattc_get_service", svc_status);
return false;
}
if (!on_service(s, svc)) {
return false;
}
uint16_t svc_chars = 0;
auto count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
svc.start_handle, svc.end_handle, 0, &svc_chars);
if (count_status != ESP_GATT_OK) {
this->log_walk_warning_("esp_ble_gattc_get_attr_count", count_status);
return false;
}
for (uint16_t c = 0; c < svc_chars; c++) {
esp_gattc_char_elem_t chr;
uint16_t char_count = 1;
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
if (status != ESP_GATT_OK || char_count == 0) {
// An early terminator contradicts svc_chars from the same cache;
// never build a silently truncated table.
this->log_walk_warning_("esp_ble_gattc_get_all_char", status);
return false;
}
if (!on_char(svc, chr)) {
return false;
}
for (uint16_t d = 0;; d++) {
if (d == MAX_DESCRIPTORS_PER_CHARACTERISTIC) {
// A stack that never reports end-of-range; fail like every other
// inconsistency instead of truncating the table silently.
ESP_LOGW(TAG, "[%d] Descriptor walk exceeded %u entries", this->log_index_,
MAX_DESCRIPTORS_PER_CHARACTERISTIC);
return false;
}
esp_gattc_descr_elem_t desc;
uint16_t desc_count = 1;
auto desc_status =
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || desc_count == 0) {
this->log_walk_warning_("esp_ble_gattc_get_all_descr", desc_status);
return false;
}
if (!on_desc(chr, desc)) {
return false;
}
}
}
}
return true;
}
bool BluedroidServiceTable::count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total) {
uint16_t primary = 0;
uint16_t secondary = 0;
if (esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_PRIMARY_SERVICE, 0x0001, 0xFFFF, 0, &primary) !=
ESP_GATT_OK ||
esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_SECONDARY_SERVICE, 0x0001, 0xFFFF, 0, &secondary) !=
ESP_GATT_OK) {
// A failed count must not read as an authoritative empty database.
return false;
}
*total = primary + secondary;
return true;
}
bool BluedroidServiceTable::build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index) {
this->free();
this->gattc_if_ = gattc_if;
this->conn_id_ = conn_id;
this->service_total_ = service_total;
this->log_index_ = log_index;
// Pass 1: count, so one exact-size block holds the whole table.
uint16_t char_total = 0;
uint16_t desc_total = 0;
bool counted = this->walk_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
char_total++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
desc_total++;
return true;
});
if (!counted) {
ESP_LOGW(TAG, "[%d] Service table walk failed during count", this->log_index_);
this->free();
return false;
}
// The arrays share one block; carving stays aligned because each struct's
// strictest member is the UUID and array sizes are multiples of it.
static_assert(alignof(ble_device_base::GattService) >= alignof(ble_device_base::GattCharacteristic) &&
alignof(ble_device_base::GattCharacteristic) >= alignof(ble_device_base::GattDescriptor));
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->storage_ = allocator.allocate(total_bytes);
if (this->storage_ == nullptr) {
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->log_index_,
static_cast<unsigned>(total_bytes));
this->free();
return false;
}
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->storage_);
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes);
auto *descriptors = reinterpret_cast<ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes);
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
// means the cached database changed between the passes; fail the build
// rather than serve an inconsistent table (the consumer retries).
uint16_t char_index = 0;
uint16_t desc_index = 0;
ble_device_base::GattService *cur_service = nullptr;
ble_device_base::GattCharacteristic *cur_char = nullptr;
bool filled = this->walk_(
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
cur_service = &services[s];
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
cur_service->start_handle = svc.start_handle;
cur_service->end_handle = svc.end_handle;
cur_service->first_characteristic = char_index;
cur_service->characteristic_count = 0;
return true;
},
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
if (char_index >= char_total) {
return false;
}
cur_char = &characteristics[char_index++];
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
cur_char->value_handle = chr.char_handle;
// Bluedroid addresses descriptors by characteristic handle, so the
// table's end_handle only needs the service-bounded upper bound.
cur_char->end_handle = svc.end_handle;
cur_char->properties = chr.properties;
cur_char->first_descriptor = desc_index;
cur_char->descriptor_count = 0;
cur_service->characteristic_count++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
if (desc_index >= desc_total) {
return false;
}
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
descriptors[desc_index].handle = desc.handle;
desc_index++;
cur_char->descriptor_count++;
return true;
});
if (!filled || char_index != char_total || desc_index != desc_total) {
// Walk error or the database changed between passes; better an empty
// table than a corrupt one.
ESP_LOGW(TAG, "[%d] Service table walk mismatch, discarding", this->log_index_);
this->free();
return false;
}
this->char_total_ = char_total;
this->desc_total_ = desc_total;
return true;
}
void BluedroidServiceTable::log_walk_warning_(const char *operation, int code) {
ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->log_index_, operation, code);
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
@@ -0,0 +1,82 @@
// Owning two-pass materializer of one Bluedroid GATT database snapshot into
// the neutral GattServiceTable layout, shared by the BluedroidGattClient
// backend and ble_client's esp32 engine.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/core/helpers.h"
#include <esp_gattc_api.h>
namespace esphome::bluetooth_connection {
class BluedroidServiceTable {
public:
~BluedroidServiceTable() { this->free(); }
// Owns storage_; a copy would double-free. The default constructor is user
// provided, not "= default", so value-initializing the enclosing backend
// cannot zero-fill .bss that is already zero.
BluedroidServiceTable() {}
BluedroidServiceTable(const BluedroidServiceTable &) = delete;
BluedroidServiceTable &operator=(const BluedroidServiceTable &) = delete;
/// The service count build() requires: the stack's PRIMARY+SECONDARY
/// attribute totals, never the SEARCH_RES event count.
static bool count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total);
/// Two-pass build from the stack's cached database (service_total from
/// count_services()). log_index labels warnings. Frees any previous table
/// first; on failure the table is left empty.
bool build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index);
// The view is carved from the storage block and the counts on each call
// (a cold path) rather than cached, saving a per-instance table member.
ble_device_base::GattServiceTable view() const {
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = this->char_total_ * sizeof(ble_device_base::GattCharacteristic);
return {reinterpret_cast<const ble_device_base::GattService *>(this->storage_),
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes),
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes),
this->service_total_,
this->char_total_,
this->desc_total_};
}
// Always resets the counts: a failed build must never leave a non-zero
// service_total_ behind a null table.
void free() {
if (this->storage_ != nullptr) {
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
allocator.deallocate(this->storage_, 0);
this->storage_ = nullptr;
}
this->service_total_ = 0;
this->char_total_ = 0;
this->desc_total_ = 0;
}
bool empty() const { return this->storage_ == nullptr; }
private:
template<typename ServiceFn, typename CharFn, typename DescFn>
bool walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
void log_walk_warning_(const char *operation, int code);
uint8_t *storage_{nullptr};
uint16_t service_total_{0};
uint16_t char_total_{0};
uint16_t desc_total_{0};
// Walk context, set by build().
uint16_t conn_id_{0};
esp_gatt_if_t gattc_if_{}; // uint8_t width
uint8_t log_index_{0};
};
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
+18 -6
View File
@@ -94,10 +94,20 @@ def _esp32_config_schema() -> cv.All:
CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
def validate_connections(config: ConfigType) -> ConfigType:
if CONF_CONNECTIONS in config or not config[CONF_ACTIVE]:
if CONF_CONNECTIONS in config:
# Explicit entries claim slots like the generated ones; dev
# historically skipped this, letting an explicit-connections
# config evade the controller budget.
bluetooth_connection.consume_gatt_slot(
"bluetooth_proxy", len(config[CONF_CONNECTIONS])
)(config)
return config
if not config[CONF_ACTIVE]:
return config
connection_slots: int = config[CONF_CONNECTION_SLOTS]
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
config
)
return {
**config,
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
@@ -149,14 +159,14 @@ def _rp2_config_schema() -> cv.All:
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
def populate_connections(config: ConfigType) -> ConfigType:
from esphome.components import rp2040_ble
# One wrapper + backend pair per slot, declared during validation so
# their ids exist for codegen (the esp32 arm's `connections` pattern).
if not config[CONF_ACTIVE]:
return config
connection_slots: int = config[CONF_CONNECTION_SLOTS]
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
config
)
return {
**config,
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
@@ -206,7 +216,9 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
# sends those requests and their handlers and encoders are dead.
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
for connection_conf in connections:
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
backend = await bluetooth_connection.new_gatt_backend(
connection_conf, service_table=False
)
connection = cg.new_Pvariable(connection_conf[CONF_ID])
cg.add(connection.set_backend(backend))
cg.add(var.register_connection(connection))
+2 -16
View File
@@ -31,25 +31,11 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) {
}
#if CONFIG_IDF_TARGET_ARCH_RISCV
// Bounds of this image's flash-mapped code, from the ESP-IDF linker script.
extern "C" char _instruction_reserved_start;
extern "C" char _instruction_reserved_end;
// Check that [start, end) lies in this image's flash-mapped code or in IRAM.
// The IROM range is larger than what the MMU maps, and reading an unmapped
// address faults.
static inline bool is_readable_code(uint32_t start, uint32_t end) {
auto irom_start = reinterpret_cast<uint32_t>(&_instruction_reserved_start);
auto irom_end = reinterpret_cast<uint32_t>(&_instruction_reserved_end);
return (start >= irom_start && end <= irom_end) || (start >= SOC_IRAM_LOW && end <= SOC_IRAM_HIGH);
}
// Check if a code address is a real return address by verifying the preceding
// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during
// panic) so flash cache is available. The address may come from an earlier
// build, so check that the bytes before it are mapped before reading them.
// panic) so flash cache is available and both IRAM and IROM are safely readable.
static inline bool is_return_addr(uint32_t addr) {
if (!is_code_addr(addr) || !is_readable_code(addr - 4, addr))
if (!is_code_addr(addr) || addr < 4)
return false;
// A return address on the stack points to the instruction after a call.
// Check for 4-byte JAL/JALR call instruction before this address.
@@ -271,9 +271,7 @@ void ESP32Camera::loop() {
fb->timestamp = timestamp;
}
#endif
// A request that arrives while this image is handed out is kept for the next frame
const uint8_t single_requesters = this->single_requesters_.exchange(0);
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, single_requesters | this->stream_requesters_);
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, this->single_requesters_ | this->stream_requesters_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
ESP_LOGV(TAG, "Got Image: len=%u", fb->len);
@@ -293,6 +291,7 @@ void ESP32Camera::loop() {
listener->on_camera_image(this->current_image_);
}
this->last_update_ = now;
this->single_requesters_ = 0;
}
/* ---------------- constructors ---------------- */
+1 -5
View File
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import climate_ir
import esphome.config_validation as cv
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
from esphome.const import CONF_MODEL
from esphome.types import ConfigType
from . import gree_ns
@@ -19,7 +19,6 @@ MODELS = Model.options(
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
{
cv.Required(CONF_MODEL): cv.enum(MODELS, lower=True),
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
}
)
@@ -27,8 +26,5 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_model(config[CONF_MODEL]))
if config[CONF_USE_FAHRENHEIT]:
cg.add_define("USE_GREE_FAHRENHEIT")
cg.add(var.set_use_fahrenheit(True))
if config[CONF_MODEL] == "yaw1f":
cg.add_define("USE_GREE_YAW1F")
+9 -30
View File
@@ -1,6 +1,4 @@
#include "gree.h"
#include <cmath>
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
@@ -36,21 +34,6 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
this->transmit_state();
}
#ifdef USE_GREE_FAHRENHEIT
uint8_t fahrenheit_half_steps(float target_celsius) {
if (!std::isfinite(target_celsius)) {
return 0;
}
// Whole degrees shown at k = 0 and at the top of the range
static constexpr int MIN_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MIN));
static constexpr int MAX_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MAX));
const int desired_f = static_cast<int>(roundf(clamp<float>(celsius_to_fahrenheit(target_celsius), MIN_F, MAX_F)));
// The display at k is (10 * 32 + 18 * GREE_TEMP_MIN + 9 * k) / 10; pick the smallest k that reaches desired_f
const int tenths_above_min = 10 * desired_f - (10 * 32 + 18 * GREE_TEMP_MIN);
return static_cast<uint8_t>((tenths_above_min + 8) / 9);
}
#endif
void GreeClimate::transmit_state() {
uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
const Model model = this->model_;
@@ -58,12 +41,7 @@ void GreeClimate::transmit_state() {
const bool yag_layout = model == GREE_YAG || model == GREE_YAW1F;
remote_state[0] = this->fan_speed_() | this->operation_mode_();
#ifdef USE_GREE_FAHRENHEIT
const uint8_t half_steps = this->use_fahrenheit_ ? fahrenheit_half_steps(this->target_temperature) : 0;
remote_state[1] = this->use_fahrenheit_ ? half_steps >> 1 : this->temperature_();
#else
remote_state[1] = this->temperature_();
#endif
if (model == GREE_YAN) {
remote_state[3] = 0x50; // bits 4..7 always 0101
@@ -129,15 +107,16 @@ void GreeClimate::transmit_state() {
remote_state[0] |= GREE_PRESET_SLEEP_BIT;
}
#ifdef USE_GREE_FAHRENHEIT
if (this->use_fahrenheit_) {
remote_state[3] |= GREE_FAHRENHEIT_BIT;
if (half_steps & 1)
remote_state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
// Calculate the checksum
if (model == GREE_YAN) {
remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0);
} else {
remote_state[7] =
((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
}
#endif
remote_state[7] = frame_checksum(remote_state);
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
-27
View File
@@ -1,7 +1,6 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/core/defines.h"
namespace esphome::gree {
@@ -91,26 +90,6 @@ static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
// Byte 3 Fahrenheit bits
static constexpr uint8_t GREE_FAHRENHEIT_BIT = 0x08;
static constexpr uint8_t GREE_TEMP_HALF_DEGREE_BIT = 0x04; // Half a degree Celsius added to the temperature
/// Checksum of a state frame: the low nibbles of bytes 0-3 plus the high nibbles of bytes 4-6 plus 0x0A, in the high
/// nibble.
inline uint8_t frame_checksum(const uint8_t *remote_state) {
return (
(((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
}
#ifdef USE_GREE_FAHRENHEIT
/// Return k = 2 * temp_field + half_degree_bit (0..28) for a Celsius target. The unit displays
/// trunc(celsius_to_fahrenheit(GREE_TEMP_MIN + k / 2)), so k is the smallest value that shows the target in F.
uint8_t fahrenheit_half_steps(float target_celsius);
#endif
// Model codes
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG, GREE_YAW1F };
@@ -124,9 +103,6 @@ class GreeClimate final : public climate_ir::ClimateIR {
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
void set_model(Model model);
#ifdef USE_GREE_FAHRENHEIT
void set_use_fahrenheit(bool use_fahrenheit) { this->use_fahrenheit_ = use_fahrenheit; }
#endif
void set_mode_bit(uint8_t bit_mask, bool enabled);
protected:
@@ -142,9 +118,6 @@ class GreeClimate final : public climate_ir::ClimateIR {
Model model_{};
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
#ifdef USE_GREE_FAHRENHEIT
bool use_fahrenheit_{false};
#endif
};
} // namespace esphome::gree
@@ -277,14 +277,14 @@ void I2SAudioMicrophone::mic_task(void *params) {
xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_RUNNING);
while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) {
if (this_microphone->has_data_callbacks_()) {
if (this_microphone->data_callbacks_.size() > 0) {
samples.resize(bytes_to_read);
size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS);
samples.resize(bytes_read);
if (this_microphone->correct_dc_offset_) {
this_microphone->fix_dc_offset_(samples);
}
this_microphone->publish_audio_(samples);
this_microphone->data_callbacks_.call(samples);
} else {
vTaskDelay(pdMS_TO_TICKS(READ_DURATION_MS));
}
-16
View File
@@ -1,7 +1,6 @@
#include "image.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -166,21 +165,6 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off
}
#ifdef USE_LVGL
lv_image_dsc_t *Image::get_lv_image_dsc() {
// No pixels yet. A widget keeps this descriptor and reads it on every draw, and LVGL warns on every draw of an image
// without data, so give it a transparent pixel instead
static constexpr uint8_t TRANSPARENT_PIXEL = 0;
if (this->data_start_ == nullptr) {
if (this->dsc_.data != &TRANSPARENT_PIXEL) {
memset(&this->dsc_, 0, sizeof(this->dsc_));
this->dsc_.header.cf = LV_COLOR_FORMAT_A8;
this->dsc_.header.w = 1;
this->dsc_.header.h = 1;
this->dsc_.header.stride = 1;
this->dsc_.data_size = 1;
this->dsc_.data = &TRANSPARENT_PIXEL;
}
return &this->dsc_;
}
// lazily construct lvgl image_dsc.
if (this->dsc_.data != this->data_start_) {
this->dsc_.data = this->data_start_;
+1 -6
View File
@@ -43,12 +43,7 @@ class Image : public display::BaseImage {
bool has_transparency() const { return this->transparency_ != TRANSPARENCY_OPAQUE; }
#ifdef USE_LVGL
virtual lv_image_dsc_t *get_lv_image_dsc();
#endif
#ifdef USE_LVGL_IMAGE
/// Called when an LVGL image widget is given this image. An image whose size can change keeps the widget, so it can
/// give the widget its source again when the size changes: the widget keeps the size it read from the descriptor.
virtual void add_lvgl_widget(lv_obj_t *obj) {}
lv_image_dsc_t *get_lv_image_dsc();
#endif
protected:
/// Per-pixel draw of the clipped range, rows (true) or columns (false) outer.
+1 -4
View File
@@ -82,10 +82,7 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) {
::lv_image_set_src(obj, image->get_lv_image_dsc());
image->add_lvgl_widget(obj);
}
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
+4 -39
View File
@@ -4,8 +4,6 @@
#include <cstddef>
#include <cstdint>
#include <type_traits>
#include <utility>
#include <vector>
#include "esphome/core/helpers.h"
@@ -18,38 +16,16 @@ enum State : uint8_t {
STATE_STOPPING,
};
/// @brief Adapts a data callback to the (data, capture_time_us) form.
///
/// A callback that already takes the capture time is returned as is; one that only takes the data is wrapped so the
/// capture time is dropped.
template<typename F> auto with_capture_time(F &&callback) {
using Fn = std::decay_t<F>;
if constexpr (std::is_invocable_v<const Fn &, const std::vector<uint8_t> &, int64_t>) {
return Fn(std::forward<F>(callback));
} else {
static_assert(std::is_invocable_v<const Fn &, const std::vector<uint8_t> &>,
"A data callback takes (const std::vector<uint8_t> &) or (const std::vector<uint8_t> &, int64_t)");
return [callback = Fn(std::forward<F>(callback))](const std::vector<uint8_t> &data, int64_t) { callback(data); };
}
}
class Microphone {
public:
virtual void start() = 0;
virtual void stop() = 0;
/// @brief Registers a callback for captured audio.
///
/// The callback takes either (const std::vector<uint8_t> &data) or (const std::vector<uint8_t> &data,
/// int64_t capture_time_us). capture_time_us is when the first frame in data was captured, in esp_timer_get_time()
/// microseconds, or 0 if unknown. Callbacks run on the microphone's own task.
template<typename F> void add_data_callback(F &&data_callback) {
this->data_callbacks_.add([this, callback = with_capture_time(std::forward<F>(data_callback))](
const std::vector<uint8_t> &data, int64_t capture_time_us) {
this->data_callbacks_.add([this, data_callback](const std::vector<uint8_t> &data) {
if (this->mute_state_) {
callback(std::vector<uint8_t>(data.size(), 0), capture_time_us);
data_callback(std::vector<uint8_t>(data.size(), 0));
} else {
callback(data, capture_time_us);
data_callback(data);
}
});
}
@@ -63,23 +39,12 @@ class Microphone {
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
protected:
/// @brief Passes captured audio to every registered callback.
/// @param data Captured audio in the microphone's format
/// @param capture_time_us When the first frame in data was captured, in esp_timer_get_time() microseconds, or 0 if
/// unknown
void publish_audio_(const std::vector<uint8_t> &data, int64_t capture_time_us = 0) {
this->data_callbacks_.call(data, capture_time_us);
}
bool has_data_callbacks_() const { return this->data_callbacks_.size() > 0; }
State state_{STATE_STOPPED};
bool mute_state_{false};
audio::AudioStreamInfo audio_stream_info_;
private:
CallbackManager<void(const std::vector<uint8_t> &, int64_t)> data_callbacks_{};
CallbackManager<void(const std::vector<uint8_t> &)> data_callbacks_{};
};
} // namespace esphome::microphone
@@ -45,11 +45,8 @@ class MicrophoneSource final {
/// @param channel 0-indexed channel number to enable
void add_channel(uint8_t channel) { this->channels_.set(channel); }
/// @brief Registers a callback for the processed audio. Takes the same callback forms as
/// Microphone::add_data_callback and passes the capture time through unchanged.
template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, callback = with_capture_time(std::forward<F>(data_callback))](
const std::vector<uint8_t> &data, int64_t capture_time_us) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
if (this->enabled_ || this->passive_) {
if (this->processed_samples_ == nullptr) {
// Create vector if its unused
@@ -59,7 +56,7 @@ class MicrophoneSource final {
// Take temporary ownership of samples vector to avoid deallocation before the callback finishes
std::shared_ptr<std::vector<uint8_t>> output_samples = this->processed_samples_;
this->process_audio_(data, *output_samples);
callback(*output_samples, capture_time_us);
data_callback(*output_samples);
}
});
}
+11 -14
View File
@@ -69,9 +69,8 @@ PackedBits = modbus.modbus_ns.class_("PackedBits")
# The exception code passed to on_error handlers.
ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
# Lambda arguments: the shared handlers and send's on_response get the address of their own request first,
# then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
_ADDRESS_ARG = (cg.uint8, "address")
# Lambda argument types for the reply handlers: the device address the send targeted, and the
# request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit
@@ -136,11 +135,11 @@ _ACTION_BASE_SCHEMA = cv.Schema(
cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.ModbusClient),
cv.Required(CONF_ADDRESS): cv.templatable(cv.hex_uint8_t),
# Optional handlers. on_sent fires when the frame reaches the wire; the reply handlers arrive
# later (fire-and-continue), so all run with the address and request/reply available - not the
# outer automation's variables.
# later (fire-and-continue), so all run with the request/reply available - not the outer
# automation's variables.
cv.Optional(CONF_ON_SENT): _handler_schema(),
cv.Optional(CONF_ON_ERROR): _handler_schema(),
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `address`/`request`,
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `request`,
# returns true to have the hub retry the frame) OR a `then:` automation of actions; the automation
# form may also carry an optional `retry:` returning lambda to run actions AND decide the retry.
cv.Optional(CONF_ON_NO_RESPONSE): cv.All(
@@ -200,7 +199,7 @@ async def register_client_action(
)
if sent_conf := config.get(CONF_ON_SENT):
await automation.build_automation(
var.get_sent_trigger(), [_ADDRESS_ARG, (_PDU_SPAN, "request")], sent_conf
var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf
)
if response_conf := config.get(CONF_ON_RESPONSE):
await automation.build_automation(
@@ -218,7 +217,7 @@ async def register_client_action(
if error_conf := config.get(CONF_ON_ERROR):
await automation.build_automation(
var.get_error_trigger(),
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
[(_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
error_conf,
)
if (no_response_conf := config.get(CONF_ON_NO_RESPONSE)) is not None:
@@ -229,20 +228,18 @@ async def register_client_action(
else:
await automation.build_automation(
var.get_no_response_trigger(),
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
[(_PDU_SPAN, "request")],
no_response_conf,
)
retry_conf = no_response_conf.get(CONF_RETRY)
if retry_conf is not None:
retry_lambda = await cg.process_lambda(
retry_conf, [_ADDRESS_ARG, (_PDU_SPAN, "request")], return_type=cg.bool_
retry_conf, [(_PDU_SPAN, "request")], return_type=cg.bool_
)
cg.add(var.set_retry(retry_lambda))
if not_sent_conf := config.get(CONF_ON_NOT_SENT):
await automation.build_automation(
var.get_not_sent_trigger(),
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
not_sent_conf,
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
)
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
# matching direction so a write action never generates a read option's setter.
@@ -268,7 +265,7 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args):
var,
config,
args,
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
[(_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
)
@@ -13,51 +13,45 @@ namespace esphome::modbus_client {
/// the hub routes every reply (or its lack) straight back to the action that sent it, so there is no
/// central client object and no request matching. The device address is templatable; it is stamped on the
/// device at play() time; the hub routes each reply by device pointer, so a changed address never
/// mis-routes an earlier reply. The handlers wired here and send's on_response get `address` first: the
/// address of their own request, which the hub takes from its frame, so overlapping sends each report their own.
/// mis-routes an earlier reply. (The address is not passed to the reply triggers - under overlapping
/// sends it could misreport, and the handler can recompute the expression it configured.)
template<typename... Ts> class ClientActionBase : public Action<Ts...>, public modbus::ModbusClientDevice {
public:
TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address
Trigger<uint8_t, std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() {
return &this->error_trigger_;
}
Trigger<uint8_t, std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
Trigger<std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; }
Trigger<std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
/// The retry decision for on_no_response: given the request (address, PDU), return true to have the hub re-queue
/// The retry decision for on_no_response: given the request PDU, return true to have the hub re-queue
/// the frame. Set from the lambda form or a then: automation's nested retry lambda; may coexist with
/// the no_response trigger (actions run, then this decides the retry).
using retry_func_t = bool (*)(uint8_t, std::span<const uint8_t>);
using retry_func_t = bool (*)(std::span<const uint8_t>);
void set_retry(retry_func_t f) { this->retry_func_ = f; }
/// The frame was written to the wire: fires once per transmission, before any reply, and never for a
/// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data).
void on_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->sent_trigger_.trigger(address, request_pdu);
}
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_trigger_.trigger(request_pdu); }
/// Never reached the wire, from either of two sources. The hub calls this for a request it accepted
/// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus
/// device going offline, say. Everything the hub refuses at the door instead returns false from
/// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same
/// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder.
void on_not_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->not_sent_trigger_.trigger(address, request_pdu);
}
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); }
/// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing:
/// register_client_action() wires on_error for all of them, so a derived class must not have to
/// remember the override.
void on_error(uint8_t address, std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
this->error_trigger_.trigger(address, request_pdu, exception_code);
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
this->error_trigger_.trigger(request_pdu, exception_code);
}
/// No reply within send_wait_time. Run the on_no_response actions (empty in the pure-lambda form),
/// then let the retry lambda, if set, decide whether the hub re-queues the frame (true = retry). The
/// two coexist: a then: automation can also carry a retry lambda. No lambda = no retry.
bool on_no_response(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->no_response_trigger_.trigger(address, request_pdu);
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_trigger_.trigger(request_pdu);
if (this->retry_func_ != nullptr)
return this->retry_func_(address, request_pdu);
return this->retry_func_(request_pdu);
return false;
}
/// Stamp the templated device address before every play(): subclasses cannot forget it, and the hub
@@ -76,13 +70,13 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
if (!this->queue_pdu(pdu, options))
this->on_not_sent(this->address_, pdu);
this->on_not_sent(pdu);
}
Trigger<uint8_t, std::span<const uint8_t>> sent_trigger_;
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> no_response_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> not_sent_trigger_;
Trigger<std::span<const uint8_t>> sent_trigger_;
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<std::span<const uint8_t>> no_response_trigger_;
Trigger<std::span<const uint8_t>> not_sent_trigger_;
retry_func_t retry_func_{nullptr};
};
@@ -125,7 +119,7 @@ template<typename... Ts> class WriteCommandOptions {
};
/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is
/// delivered raw - on_response(address, request, response) - deliberately bypassing the typed dispatch, so
/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so
/// non-standard/custom transactions pass through untouched.
/// The PDU is a stack-allocated modbus::helpers::PduBuffer, so a pdu lambda can build one with the
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
@@ -139,7 +133,7 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
return &this->response_trigger_;
}
@@ -149,13 +143,12 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
this->send_or_resolve_(this->pdu_.value(x...), options);
}
void on_response(uint8_t address, std::span<const uint8_t> request_pdu,
std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(address, request_pdu, response_pdu);
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(request_pdu, response_pdu);
}
protected:
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
};
/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs
+16 -10
View File
@@ -2,18 +2,24 @@ import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Number
from .. import Number, number_ns
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
NumberSensor = number_ns.class_("NumberSensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(NumberSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
# Number has the same float state callback as Sensor
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
async def to_code(config):
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await sensor.new_sensor(config, source)
await cg.register_component(var, config)
@@ -0,0 +1,16 @@
#include "number_sensor.h"
#include "esphome/core/log.h"
namespace esphome::number {
ESPHOME_LOG_TAG(TAG, "number.sensor");
void NumberSensor::setup() {
this->source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void NumberSensor::dump_config() { LOG_SENSOR("", "Number Sensor", this); }
} // namespace esphome::number
@@ -0,0 +1,19 @@
#pragma once
#include "../number.h"
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::number {
class NumberSensor final : public sensor::Sensor, public Component {
public:
explicit NumberSensor(Number *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Number *source_;
};
} // namespace esphome::number
-3
View File
@@ -42,7 +42,6 @@ ONLINE_IMAGE_SCHEMA = (
# AUTO (Content-Type detection) is online_image specific; not in the shared registry
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, "AUTO", upper=True),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(runtime_image.CONF_DOUBLE_BUFFER, default=False): cv.boolean,
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
@@ -142,8 +141,6 @@ async def setup_online_image(config: ConfigType) -> None:
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
if settings.double_buffer:
cg.add(var.set_double_buffer(True))
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
if isinstance(value, Lambda):
@@ -428,8 +428,9 @@ void OnlineImage::finish_download_() {
void OnlineImage::end_connection_() {
// Abort any in-progress decode; the decoder object is kept warm for the next decode.
// Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
if (this->is_decoding()) {
this->abort_decode();
RuntimeImage::release();
}
if (this->downloader_) {
this->downloader_->end();
@@ -1,123 +1,150 @@
#include "radon_eye_rd200.h"
#include "esphome/components/esp32_ble/ble_uuid.h"
#ifdef USE_BLE_CLIENT_GATT_NODES
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cstring>
#ifdef USE_ESP32
namespace esphome::radon_eye_rd200 {
ESPHOME_LOG_TAG(TAG, "radon_eye_rd200");
static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID_V1 =
esp32_ble_tracker::ESPBTUUID::from_raw("00001523-1212-efde-1523-785feabcd123");
static const esp32_ble_tracker::ESPBTUUID WRITE_CHARACTERISTIC_UUID_V1 =
esp32_ble_tracker::ESPBTUUID::from_raw("00001524-1212-efde-1523-785feabcd123");
static const esp32_ble_tracker::ESPBTUUID READ_CHARACTERISTIC_UUID_V1 =
esp32_ble_tracker::ESPBTUUID::from_raw("00001525-1212-efde-1523-785feabcd123");
using ble_device_base::ESPBTUUID;
// V1 (RD200 firmware < 2.0) exposes a vendor service; V2 (>= 2.0) moved to
// Bluetooth-base (16-bit) UUIDs with a different command byte and payload
// layout.
static const char *const SERVICE_UUID_V1 = "00001523-1212-efde-1523-785feabcd123";
static const char *const WRITE_CHARACTERISTIC_UUID_V1 = "00001524-1212-efde-1523-785feabcd123";
static const char *const READ_CHARACTERISTIC_UUID_V1 = "00001525-1212-efde-1523-785feabcd123";
static const uint8_t WRITE_COMMAND_V1 = 0x50;
static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID_V2 =
esp32_ble_tracker::ESPBTUUID::from_raw("00001523-0000-1000-8000-00805f9b34fb");
static const esp32_ble_tracker::ESPBTUUID WRITE_CHARACTERISTIC_UUID_V2 =
esp32_ble_tracker::ESPBTUUID::from_raw("00001524-0000-1000-8000-00805f9b34fb");
static const esp32_ble_tracker::ESPBTUUID READ_CHARACTERISTIC_UUID_V2 =
esp32_ble_tracker::ESPBTUUID::from_raw("00001525-0000-1000-8000-00805f9b34fb");
static const uint16_t SERVICE_UUID_V2 = 0x1523;
static const uint16_t WRITE_CHARACTERISTIC_UUID_V2 = 0x1524;
static const uint16_t READ_CHARACTERISTIC_UUID_V2 = 0x1525;
static const uint8_t WRITE_COMMAND_V2 = 0x40;
void RadonEyeRD200::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
switch (event) {
case ESP_GATTC_OPEN_EVT: {
if (param->open.status == ESP_GATT_OK) {
ESP_LOGI(TAG, "Connected successfully!");
}
break;
}
// Minimum notification payload carrying all three measurements.
static const uint16_t MESSAGE_MIN_LEN_V1 = 20;
static const uint16_t MESSAGE_MIN_LEN_V2 = 68;
case ESP_GATTC_DISCONNECT_EVT: {
ESP_LOGW(TAG, "Disconnected!");
break;
}
RadonEyeRD200::RadonEyeRD200() : PollingComponent(10000) {}
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (this->parent()->get_service(SERVICE_UUID_V1) != nullptr) {
service_uuid_ = SERVICE_UUID_V1;
sensors_write_characteristic_uuid_ = WRITE_CHARACTERISTIC_UUID_V1;
sensors_read_characteristic_uuid_ = READ_CHARACTERISTIC_UUID_V1;
write_command_ = WRITE_COMMAND_V1;
} else if (this->parent()->get_service(SERVICE_UUID_V2) != nullptr) {
service_uuid_ = SERVICE_UUID_V2;
sensors_write_characteristic_uuid_ = WRITE_CHARACTERISTIC_UUID_V2;
sensors_read_characteristic_uuid_ = READ_CHARACTERISTIC_UUID_V2;
write_command_ = WRITE_COMMAND_V2;
} else {
ESP_LOGW(TAG, "No supported device has been found, disconnecting");
parent()->set_enabled(false);
break;
}
this->read_handle_ = 0;
auto *chr = this->parent()->get_characteristic(service_uuid_, sensors_read_characteristic_uuid_);
if (chr == nullptr) {
char service_buf[esp32_ble::UUID_STR_LEN];
char char_buf[esp32_ble::UUID_STR_LEN];
ESP_LOGW(TAG, "No sensor read characteristic found at service %s char %s", service_uuid_.to_str(service_buf),
sensors_read_characteristic_uuid_.to_str(char_buf));
break;
}
this->read_handle_ = chr->handle;
auto *write_chr = this->parent()->get_characteristic(service_uuid_, sensors_write_characteristic_uuid_);
if (write_chr == nullptr) {
char service_buf[esp32_ble::UUID_STR_LEN];
char char_buf[esp32_ble::UUID_STR_LEN];
ESP_LOGW(TAG, "No sensor write characteristic found at service %s char %s", service_uuid_.to_str(service_buf),
sensors_write_characteristic_uuid_.to_str(char_buf));
break;
}
this->write_handle_ = write_chr->handle;
esp_err_t status =
esp_ble_gattc_register_for_notify(gattc_if, this->parent()->get_remote_bda(), this->read_handle_);
if (status) {
ESP_LOGW(TAG, "Error registering for sensor notify, status=%d", status);
}
break;
}
case ESP_GATTC_WRITE_DESCR_EVT: {
if (param->write.status != ESP_GATT_OK) {
ESP_LOGE(TAG, "write descr failed, error status = %x", param->write.status);
break;
}
ESP_LOGV(TAG, "Write descr success, writing 0x%02X at write_handle=%d", this->write_command_,
this->write_handle_);
esp_err_t status =
esp_ble_gattc_write_char(gattc_if, this->parent()->get_conn_id(), this->write_handle_, sizeof(write_command_),
(uint8_t *) &write_command_, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status) {
ESP_LOGW(TAG, "Error writing 0x%02x command, status=%d", write_command_, status);
}
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (param->notify.is_notify) {
ESP_LOGV(TAG, "ESP_GATTC_NOTIFY_EVT, receive notify value, %d bytes", param->notify.value_len);
} else {
ESP_LOGV(TAG, "ESP_GATTC_NOTIFY_EVT, receive indicate value, %d bytes", param->notify.value_len);
}
read_sensors_(param->notify.value, param->notify.value_len);
break;
}
default:
break;
void RadonEyeRD200::update() {
if (this->parent()->connected())
return;
if (!this->parent()->enabled) {
ESP_LOGW(TAG, "Reconnecting to device");
this->parent()->set_enabled(true);
} else {
ESP_LOGW(TAG, "Connection in progress");
}
}
void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) {
void RadonEyeRD200::on_connected(const ble_device_base::GattServiceTable &table) {
if (!this->resolve_handles_(table)) {
// Retried on the next poll (update() re-enables the client).
this->parent()->set_enabled(false);
return;
}
// Local notification registration; the CCCD write follows in
// on_notify_state (the contract leaves the CCCD to the node).
if (this->parent()->notify_characteristic(this->read_handle_, true) != 0) {
this->parent()->set_enabled(false);
}
}
bool RadonEyeRD200::resolve_handles_(const ble_device_base::GattServiceTable &table) {
struct Variant {
ESPBTUUID service;
ESPBTUUID write_chr;
ESPBTUUID read_chr;
uint8_t command;
};
// Built on the stack per (cold) discovery so the UUID objects stay out of
// static RAM; the V1 strings live in flash.
const Variant variants[] = {
{ESPBTUUID::from_raw(SERVICE_UUID_V1), ESPBTUUID::from_raw(WRITE_CHARACTERISTIC_UUID_V1),
ESPBTUUID::from_raw(READ_CHARACTERISTIC_UUID_V1), WRITE_COMMAND_V1},
{ESPBTUUID::from_uint16(SERVICE_UUID_V2), ESPBTUUID::from_uint16(WRITE_CHARACTERISTIC_UUID_V2),
ESPBTUUID::from_uint16(READ_CHARACTERISTIC_UUID_V2), WRITE_COMMAND_V2},
};
for (const auto &variant : variants) {
const auto *service = ble_device_base::find_service(table, variant.service);
if (service == nullptr) {
continue;
}
const auto *read_chr = ble_device_base::find_characteristic(table, *service, variant.read_chr);
const auto *write_chr = ble_device_base::find_characteristic(table, *service, variant.write_chr);
if (read_chr == nullptr || write_chr == nullptr) {
ESP_LOGW(TAG, "Service found but a sensor characteristic is missing");
return false;
}
this->cccd_handle_ = ble_device_base::find_cccd(table, *read_chr);
if (this->cccd_handle_ == 0) {
ESP_LOGW(TAG, "Sensor read characteristic has no CCCD");
return false;
}
this->read_handle_ = read_chr->value_handle;
this->write_handle_ = write_chr->value_handle;
this->write_command_ = variant.command;
return true;
}
ESP_LOGW(TAG, "No supported device has been found, disconnecting");
return false;
}
void RadonEyeRD200::on_notify_state(uint16_t handle, bool enabled, int error) {
if (handle != this->read_handle_) {
return;
}
if (error != 0) {
ESP_LOGW(TAG, "Error registering for sensor notify, status=%d", error);
this->parent()->set_enabled(false);
return;
}
if (!enabled) {
return;
}
static const uint8_t ENABLE_NOTIFY[2] = {0x01, 0x00};
if (this->parent()->write_descriptor(this->cccd_handle_, ENABLE_NOTIFY, sizeof(ENABLE_NOTIFY)) != 0) {
this->parent()->set_enabled(false);
}
}
void RadonEyeRD200::on_write_result(uint16_t handle, int error) {
// The command write (no response) also lands here; only the CCCD
// completion advances the sequence.
if (handle != this->cccd_handle_) {
return;
}
if (error != 0) {
ESP_LOGE(TAG, "CCCD write on handle 0x%04x failed, status=%d", handle, error);
this->parent()->set_enabled(false);
return;
}
ESP_LOGV(TAG, "Write descr success, writing 0x%02X at write_handle=%d", this->write_command_, this->write_handle_);
if (this->parent()->write_characteristic(this->write_handle_, &this->write_command_, sizeof(this->write_command_),
false) != 0) {
ESP_LOGW(TAG, "Error writing 0x%02x command", this->write_command_);
this->parent()->set_enabled(false);
}
}
void RadonEyeRD200::on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {
if (handle != this->read_handle_) {
return;
}
ESP_LOGV(TAG, "Received notify value, %d bytes", len);
this->read_sensors_(data, len);
// This instance must not stay connected so other clients can connect to it
// (e.g. the mobile app).
this->parent()->set_enabled(false);
}
void RadonEyeRD200::read_sensors_(const uint8_t *value, uint16_t value_len) {
if (value_len < 1) {
ESP_LOGW(TAG, "Unexpected empty message");
return;
@@ -125,7 +152,8 @@ void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) {
uint8_t command = value[0];
if ((command == WRITE_COMMAND_V1 && value_len < 20) || (command == WRITE_COMMAND_V2 && value_len < 68)) {
if ((command == WRITE_COMMAND_V1 && value_len < MESSAGE_MIN_LEN_V1) ||
(command == WRITE_COMMAND_V2 && value_len < MESSAGE_MIN_LEN_V2)) {
ESP_LOGW(TAG, "Unexpected command 0x%02X message length %d", command, value_len);
return;
}
@@ -134,8 +162,11 @@ void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) {
// 501085EBB9400000000000000000220025000000
// Example data V2:
// 4042323230313033525532303338330652443230304e56322e302e3200014a00060a00080000000300010079300000e01108001c00020000003822005c8f423fa4709d3f
ESP_LOGV(TAG, "radon sensors raw bytes");
ESP_LOG_BUFFER_HEX_LEVEL(TAG, value, value_len, ESP_LOG_VERBOSE);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
// Sized for the V2 minimum payload; format_hex_to truncates longer messages.
char hex_buf[format_hex_size(MESSAGE_MIN_LEN_V2)];
ESP_LOGV(TAG, "radon sensors raw bytes: %s", format_hex_to(hex_buf, value, value_len));
#endif
// Convert from pCi/L to Bq/m³
constexpr float convert_to_bwpm3 = 37.0;
@@ -185,22 +216,6 @@ void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) {
" Measurements (pCi/L) now: %0.03f, day: %0.03f, month: %0.03f",
radon_now, radon_day, radon_month, radon_now / convert_to_bwpm3, radon_day / convert_to_bwpm3,
radon_month / convert_to_bwpm3);
// This instance must not stay connected
// so other clients can connect to it (e.g. the
// mobile app).
parent()->set_enabled(false);
}
void RadonEyeRD200::update() {
if (this->node_state != esp32_ble_tracker::ClientState::ESTABLISHED) {
if (!parent()->enabled) {
ESP_LOGW(TAG, "Reconnecting to device");
parent()->set_enabled(true);
} else {
ESP_LOGW(TAG, "Connection in progress");
}
}
}
void RadonEyeRD200::dump_config() {
@@ -208,8 +223,6 @@ void RadonEyeRD200::dump_config() {
LOG_SENSOR(" ", "Radon Long Term", this->radon_long_term_sensor_);
}
RadonEyeRD200::RadonEyeRD200() : PollingComponent(10000) {}
} // namespace esphome::radon_eye_rd200
#endif // USE_ESP32
#endif // USE_BLE_CLIENT_GATT_NODES
@@ -1,15 +1,29 @@
// RD200 radon sensor on the platform-neutral ble_client node interface -
// one implementation for every platform with a GATT client engine (esp32
// and rp2 / Pico W today).
//
// Poll cycle: enable the client (it connects on the peer's next sighting) →
// resolve the V1/V2 variant and handles from the service table during
// on_connected() → local notify registration → explicit CCCD write (the
// contract makes the CCCD the node's job) → write the read command → parse
// the notification → disable. The link is dropped after every reading so the
// vendor mobile app can connect between polls.
#pragma once
#ifdef USE_ESP32
#include "esphome/core/defines.h"
#include <esp_gattc_api.h>
#include <algorithm>
#include <iterator>
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#ifdef USE_BLE_CLIENT_GATT_NODES
#include "esphome/components/ble_client/ble_client_node.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include "esphome/components/ble_client/ble_client.h"
#else
#include "esphome/components/ble_client/ble_client_gatt.h"
#endif
namespace esphome::radon_eye_rd200 {
@@ -20,26 +34,28 @@ class RadonEyeRD200 final : public PollingComponent, public ble_client::BLEClien
void dump_config() override;
void update() override;
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void set_radon(sensor::Sensor *radon) { this->radon_sensor_ = radon; }
void set_radon_long_term(sensor::Sensor *radon_long_term) { this->radon_long_term_sensor_ = radon_long_term; }
void set_radon(sensor::Sensor *radon) { radon_sensor_ = radon; }
void set_radon_long_term(sensor::Sensor *radon_long_term) { radon_long_term_sensor_ = radon_long_term; }
// ---- ble_client::BLEClientNode (unused events keep the no-op defaults) ----
void on_connected(const ble_device_base::GattServiceTable &table) override;
void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_notify_state(uint16_t handle, bool enabled, int error) override;
void on_write_result(uint16_t handle, int error) override;
protected:
void read_sensors_(uint8_t *value, uint16_t value_len);
bool resolve_handles_(const ble_device_base::GattServiceTable &table);
void read_sensors_(const uint8_t *value, uint16_t value_len);
sensor::Sensor *radon_sensor_{nullptr};
sensor::Sensor *radon_long_term_sensor_{nullptr};
uint8_t write_command_;
uint16_t read_handle_;
uint16_t write_handle_;
esp32_ble_tracker::ESPBTUUID service_uuid_;
esp32_ble_tracker::ESPBTUUID sensors_write_characteristic_uuid_;
esp32_ble_tracker::ESPBTUUID sensors_read_characteristic_uuid_;
uint16_t read_handle_{0};
uint16_t write_handle_{0};
uint16_t cccd_handle_{0};
uint8_t write_command_{0};
};
} // namespace esphome::radon_eye_rd200
#endif // USE_ESP32
#endif // USE_BLE_CLIENT_GATT_NODES
+2 -2
View File
@@ -37,7 +37,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.polling_component_schema("5min"))
.extend(ble_client.BLE_CLIENT_SCHEMA),
.extend(ble_client.NODE_BLE_CLIENT_SCHEMA),
)
@@ -45,7 +45,7 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
await ble_client.register_gatt_node(var, config)
sensors = sensor.sub_sensors(config)
await sensors(CONF_RADON, var.set_radon)
@@ -3004,73 +3004,3 @@ register_protocol_action(
(CONF_CHANNELS, "channels", cg.uint16),
(CONF_COMMAND, "command", cg.uint8),
)
# Cardin S438
(
CardinS438Data,
CardinS438BinarySensor,
CardinS438Trigger,
_,
CardinS438Dumper,
) = declare_protocol("CardinS438")
CARDIN_S438_CHANNELS = {
"A": 0x02,
"B": 0x00,
"C": 0x03,
"D": 0x01,
}
def _validate_cardin_s438_code(code):
"""Accept the DIP switches as a 14-character binary string."""
code = cv.string_strict(code)
if len(code) != 14 or any(bit not in "01" for bit in code):
raise cv.Invalid("code must be exactly 14 binary digits, e.g. '10100110101010'")
return int(code, 2)
CARDIN_S438_SCHEMA = cv.Schema(
{
cv.Required(CONF_CODE): _validate_cardin_s438_code,
cv.Optional(CONF_CHANNEL, default="A"): cv.enum(
CARDIN_S438_CHANNELS, upper=True
),
cv.Optional(CONF_COMMAND_REPEATS, default=3): cv.int_range(min=1, max=255),
}
)
@register_binary_sensor("cardin_s438", CardinS438BinarySensor, CARDIN_S438_SCHEMA)
def cardin_s438_binary_sensor(var: MockObj, config: ConfigType) -> None:
cg.add(
var.set_data(
cg.StructInitializer(
CardinS438Data,
("code", config[CONF_CODE]),
("channel", config[CONF_CHANNEL]),
)
)
)
@register_trigger("cardin_s438", CardinS438Trigger, CardinS438Data)
def cardin_s438_trigger(var: MockObj, config: ConfigType) -> None:
"""The trigger takes no options beyond the automation."""
@register_dumper("cardin_s438", CardinS438Dumper)
def cardin_s438_dumper(var: MockObj, config: ConfigType) -> None:
"""The dumper takes no options."""
register_protocol_action(
"cardin_s438",
"CardinS438",
CARDIN_S438_SCHEMA,
(CONF_CODE, "code", cg.uint16),
(CONF_CHANNEL, "channel", cg.uint8),
(CONF_COMMAND_REPEATS, "repeats", cg.uint8),
)
@@ -1,159 +0,0 @@
#include "cardin_s438_protocol.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::remote_base {
ESPHOME_LOG_TAG(TAG, "remote.cardin_s438");
namespace {
/*
* Cardin S438 wire format
*
* Frame: 6400 us sync mark + 40 data bits (5 bytes, LSB-first).
* 0 = 1460 us space, 750 us mark
* 1 = 750 us space, 1460 us mark
*
* Bytes:
* [0..2] Encoded pin fields and seed
* [2] (seed + marker 0x87), modulo 256
* Carries propagate into bytes 1 and 0.
* [3..4] Sum of bytes [0..2], little-endian
*
* Pin code: 14 bits, MSB-first, mapped to X/Y fields:
* Y7,Y6,X7,X6,Y5,X5,Y4,X4,Y3,X3,Y2,Y1,Y0,X2
* Channels in X bits 1:0: A=10, B=00, C=11, D=01.
*
* Three frames per transmission, with 63650 us LOW gaps.
* Seed chosen randomly; no pattern found in captures.
*/
// Frame format and timing, in microseconds.
constexpr uint8_t MARKER = 0x87;
constexpr uint16_t SYNC_MARK_US = 6400;
constexpr uint32_t FRAME_GAP_US = 63650;
constexpr uint8_t WIRE_SIZE = 5;
// Each bit is a space then a mark, and both lengths carry it
constexpr BitTimings CARDIN_TIMINGS{
.one_space = 750, .zero_space = 1460, .one_mark = 1460, .zero_mark = 750, .space_first = true};
// Maps pincode bits, most-significant first, to a bit of the X or Y field: PIN_Y marks Y, the low bits are the bit
// number. In flash, so it stays out of RAM on ESP8266.
constexpr uint8_t PIN_Y = 0x08;
constexpr uint8_t PINCODE_BITS = 14;
constexpr uint8_t PINCODE_MAP[PINCODE_BITS] PROGMEM = {
PIN_Y | 7, PIN_Y | 6, 7, 6, PIN_Y | 5, 5, PIN_Y | 4, 4, PIN_Y | 3, 3, PIN_Y | 2, PIN_Y | 1, PIN_Y | 0, 2,
};
// Convert the pincode and channel to the two decoded pin fields.
void encode_pin_fields(uint16_t pincode, uint8_t channel, uint8_t &plain0, uint8_t &plain1) {
uint8_t x = channel;
uint8_t y = 0;
for (uint8_t i = 0; i < PINCODE_BITS; ++i) {
if (((pincode >> (PINCODE_BITS - 1 - i)) & 1U) == 0)
continue;
const uint8_t entry = progmem_read_byte(&PINCODE_MAP[i]);
(entry & PIN_Y ? y : x) |= 1U << (entry & 0x07);
}
plain1 = ~y;
plain0 = plain1 - x + 1;
}
// Construct the wire bytes for one seed.
void build_wire(uint8_t wire[WIRE_SIZE], uint8_t seed, uint8_t plain0, uint8_t plain1) {
uint16_t sum = MARKER + seed;
wire[2] = sum;
uint8_t key = seed + (sum >> 8);
sum = plain1 + key;
wire[1] = sum;
key += sum >> 8;
wire[0] = plain0 + key;
// checksum over the first three bytes, little-endian.
const uint16_t checksum = sum16_bytes(wire, 3);
wire[3] = checksum;
wire[4] = checksum >> 8;
}
} // namespace
void CardinS438Protocol::encode(RemoteTransmitData *dst, const CardinS438Data &data) {
uint8_t plain0;
uint8_t plain1;
encode_pin_fields(data.code & 0x3FFF, data.channel & 0x03, plain0, plain1);
for (uint8_t frame = 0; frame < data.repeats; ++frame) {
uint8_t wire[WIRE_SIZE];
uint8_t seed = static_cast<uint8_t>(random_uint32() & 0xFF);
build_wire(wire, seed, plain0, plain1);
dst->mark(SYNC_MARK_US);
dst->item_bytes<CARDIN_TIMINGS>(wire, WIRE_SIZE);
dst->space(FRAME_GAP_US);
}
}
optional<CardinS438Data> CardinS438Protocol::decode(RemoteReceiveData src) {
while (src.is_valid()) {
// Find the frame's long sync mark.
if (!src.expect_mark(SYNC_MARK_US)) {
src.advance();
continue;
}
uint8_t wire[WIRE_SIZE];
if (!src.expect_bytes<CARDIN_TIMINGS>(wire, WIRE_SIZE) ||
encode_value_le<uint16_t>(wire + 3) != sum16_bytes(wire, 3)) {
continue;
}
// Recover the seed and undo the encoder's carry transform.
uint8_t seed = wire[2] - MARKER;
uint8_t key = seed;
key += (seed + MARKER) >> 8;
uint8_t plain1 = wire[1] - key;
key += (plain1 + key) >> 8;
uint8_t plain0 = wire[0] - key;
// Recover the original X and Y fields.
uint8_t x = plain1 - plain0 + 1;
uint8_t y = ~plain1;
// Reconstruct the 14-bit code in transmission order.
uint16_t code = 0;
for (const uint8_t &slot : PINCODE_MAP) {
const uint8_t entry = progmem_read_byte(&slot);
code = (code << 1) | (((entry & PIN_Y ? y : x) >> (entry & 0x07)) & 1U);
}
return CardinS438Data{
.code = code,
.channel = static_cast<uint8_t>(x & 0x03),
.seed = seed,
};
}
return {};
}
void CardinS438Protocol::dump(const CardinS438Data &data) {
// The DIP switches as the config takes them; channels as CARDIN_S438_CHANNELS in __init__.py maps them
char code[PINCODE_BITS + 1];
for (uint8_t i = 0; i < PINCODE_BITS; ++i)
code[i] = (data.code >> (PINCODE_BITS - 1 - i)) & 1U ? '1' : '0';
code[PINCODE_BITS] = '\0';
ESP_LOGI(TAG, "Received Cardin S438: code=%s channel=%c seed=0x%02X", code, "BDAC"[data.channel & 0x03], data.seed);
}
} // namespace esphome::remote_base
@@ -1,25 +0,0 @@
#pragma once
#include "esphome/components/remote_base/remote_base.h"
namespace esphome::remote_base {
struct CardinS438Data {
uint16_t code{0};
uint8_t channel{0};
uint8_t seed{0};
uint8_t repeats{3};
bool operator==(const CardinS438Data &rhs) const { return code == rhs.code && channel == rhs.channel; }
};
class CardinS438Protocol : public RemoteProtocol<CardinS438Data> {
public:
void encode(RemoteTransmitData *dst, const CardinS438Data &data);
optional<CardinS438Data> decode(RemoteReceiveData src);
void dump(const CardinS438Data &data);
};
DECLARE_REMOTE_PROTOCOL(CardinS438)
} // namespace esphome::remote_base
@@ -137,7 +137,7 @@ void ResamplerMicrophone::process_audio_(const std::vector<uint8_t> &data) {
}
if (this->resampler_ == nullptr) {
this->publish_audio_(data);
this->data_callbacks_.call(data);
return;
}
@@ -162,7 +162,7 @@ void ResamplerMicrophone::process_audio_(const std::vector<uint8_t> &data) {
if (results.frames_generated > 0) {
this->output_buffer_.resize(this->audio_stream_info_.frames_to_bytes(results.frames_generated));
this->publish_audio_(this->output_buffer_);
this->data_callbacks_.call(this->output_buffer_);
} else if (results.frames_used == 0) {
break; // No progress; drop the rest of the chunk instead of spinning
}
@@ -19,7 +19,6 @@ AUTO_LOAD = ["image"]
CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"]
DOMAIN = "runtime_image"
CONF_DOUBLE_BUFFER = "double_buffer"
CONF_PLACEHOLDER = "placeholder"
CONF_TRANSPARENCY = "transparency"
@@ -213,7 +212,6 @@ class RuntimeImageSettings:
transparent: cg.MockObj
byte_order_big_endian: bool
placeholder: cg.MockObj | None
double_buffer: bool
async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
@@ -258,5 +256,4 @@ async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
transparent=transparent,
byte_order_big_endian=byte_order_big_endian,
placeholder=placeholder,
double_buffer=config.get(CONF_DOUBLE_BUFFER, False),
)
@@ -8,8 +8,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <new>
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
@@ -96,38 +94,7 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
RAMAllocator<JPEGDEC> allocator;
JPEGDEC *jpeg = allocator.allocate(1);
if (jpeg == nullptr) {
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
return;
}
this->jpeg_ = new (jpeg) JPEGDEC();
}
JpegDecoder::~JpegDecoder() {
if (this->jpeg_ != nullptr) {
this->jpeg_->~JPEGDEC();
RAMAllocator<JPEGDEC> allocator;
allocator.deallocate(this->jpeg_, 1);
}
}
int JpegDecoder::prepare(size_t expected_size) {
// Check before the base prepare() so a failure never leaves an active session
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
return ImageDecoder::prepare(expected_size);
}
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
// JPEG decoder requires complete data
// If we know the expected size, wait for it
if (this->expected_size_ > 0 && size < this->expected_size_) {
@@ -138,22 +105,22 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
// If size unknown, try to decode and see if it's valid
// The JPEGDEC library will fail gracefully if data is incomplete
if (!this->jpeg_->openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
if (!this->jpeg_.openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
return DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0)
return err;
if (!this->jpeg_->decode(0, 0, this->scale_option_()))
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_->close();
this->jpeg_.close();
return size;
}
int JpegDecoder::start_() {
auto jpeg_type = this->jpeg_->getJPEGType();
auto jpeg_type = this->jpeg_.getJPEGType();
if (jpeg_type == JPEG_MODE_INVALID) {
ESP_LOGE(TAG, "Unsupported JPEG image");
return DECODE_ERROR_INVALID_TYPE;
@@ -161,12 +128,11 @@ int JpegDecoder::start_() {
ESP_LOGE(TAG, "Progressive JPEG images not supported");
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_->getWidth(), this->jpeg_->getHeight(),
this->jpeg_->getBpp());
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
this->jpeg_->setUserPointer(this);
const int width = this->jpeg_->getWidth();
const int height = this->jpeg_->getHeight();
this->jpeg_.setUserPointer(this);
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
// The tables for drawing are only built once the decoded size is known, below
if (!this->resize_image_(width, height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
@@ -189,17 +155,17 @@ int JpegDecoder::start_() {
if (this->direct_rgb565_) {
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
this->jpeg_->setPixelType(RGB565_LITTLE_ENDIAN);
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
} else {
this->jpeg_->setPixelType(RGB8888);
this->jpeg_.setPixelType(RGB8888);
}
return 0;
}
int JpegDecoder::fail_() {
const bool read_failed = this->source_ != nullptr && this->source_failed_;
const int error = this->jpeg_->getLastError();
this->jpeg_->close();
const int error = this->jpeg_.getLastError();
this->jpeg_.close();
this->source_ = nullptr;
if (read_failed) {
ESP_LOGE(TAG, "Reading the image failed");
@@ -223,7 +189,7 @@ int JpegDecoder::fail_() {
void JpegDecoder::reset() {
// A decode from a source that was given up part way still has the image open
if (this->source_ != nullptr) {
this->jpeg_->close();
this->jpeg_.close();
this->source_ = nullptr;
}
ImageDecoder::reset();
@@ -252,10 +218,6 @@ int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
}
int JpegDecoder::begin_source(DecodeSource *source) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
return DECODE_ERROR_INVALID_TYPE;
@@ -266,19 +228,19 @@ int JpegDecoder::begin_source(DecodeSource *source) {
}
this->source_ = source;
this->source_failed_ = false;
if (!this->jpeg_->open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
// The caller may recover, for example by reading the whole image first, so it reports the failure
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
this->source_ = nullptr;
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0) {
this->jpeg_->close();
this->jpeg_.close();
this->source_ = nullptr;
return err;
}
if (!this->jpeg_->decodeBegin(0, 0, this->scale_option_()))
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
return this->fail_();
return 0;
}
@@ -286,7 +248,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
int HOT JpegDecoder::decode_step() {
if (this->source_ == nullptr)
return DECODE_ERROR_INVALID_TYPE;
const int result = this->jpeg_->decodeRows(1);
const int result = this->jpeg_.decodeRows(1);
// JPEGDEC may decode on with the missing data, so check the read before its result
if (this->source_failed_)
return this->fail_();
@@ -297,7 +259,7 @@ int HOT JpegDecoder::decode_step() {
if (result == 0)
return this->fail_();
this->decoded_bytes_ = this->expected_size_;
this->jpeg_->close();
this->jpeg_.close();
this->source_ = nullptr;
return DECODE_STEP_DONE;
}
@@ -18,10 +18,9 @@ class JpegDecoder : public ImageDecoder {
*
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(RuntimeImage *image);
~JpegDecoder() override;
JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {}
~JpegDecoder() override {}
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
void reset() override;
@@ -47,8 +46,7 @@ class JpegDecoder : public ImageDecoder {
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
JPEGDEC *jpeg_{nullptr};
JPEGDEC jpeg_{};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool source_failed_{false};
@@ -3,7 +3,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <utility>
#include <cstdint>
#include <cstring>
#include <limits>
@@ -49,14 +48,7 @@ RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Tra
fixed_height_(fixed_height),
placeholder_(placeholder) {}
RuntimeImage::~RuntimeImage() {
#ifdef USE_LVGL_IMAGE
for (lv_obj_t *obj : this->lvgl_widgets_)
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
this->lvgl_widgets_.clear();
#endif
this->release();
}
RuntimeImage::~RuntimeImage() { this->release(); }
int RuntimeImage::resize(int width, int height) {
// Use fixed dimensions if specified (0 means auto-resize)
@@ -77,7 +69,6 @@ int RuntimeImage::resize(int width, int height) {
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
return result;
}
@@ -185,52 +176,6 @@ void RuntimeImage::map_chroma_key(Color &color) {
}
}
#ifdef USE_LVGL
lv_image_dsc_t *RuntimeImage::get_lv_image_dsc() {
if (this->data_start_ == nullptr && this->placeholder_ != nullptr) {
this->dsc_ = *this->placeholder_->get_lv_image_dsc();
return &this->dsc_;
}
return Image::get_lv_image_dsc();
}
#endif
#ifdef USE_LVGL_IMAGE
void RuntimeImage::add_lvgl_widget(lv_obj_t *obj) {
if (std::find(this->lvgl_widgets_.begin(), this->lvgl_widgets_.end(), obj) != this->lvgl_widgets_.end())
return;
this->lvgl_widgets_.push_back(obj);
lv_obj_add_event_cb(obj, lvgl_widget_deleted, LV_EVENT_DELETE, this);
}
void RuntimeImage::lvgl_widget_deleted(lv_event_t *event) {
auto *image = static_cast<RuntimeImage *>(lv_event_get_user_data(event));
auto &widgets = image->lvgl_widgets_;
widgets.erase(std::remove(widgets.begin(), widgets.end(), lv_event_get_target_obj(event)), widgets.end());
}
#endif
void RuntimeImage::refresh_lvgl_() {
#ifdef USE_LVGL
// Not a virtual call: this also runs from the destructor
lv_image_dsc_t *dsc = this->RuntimeImage::get_lv_image_dsc();
#ifdef USE_LVGL_IMAGE
auto &widgets = this->lvgl_widgets_;
for (size_t i = 0; i < widgets.size();) {
lv_obj_t *obj = widgets[i];
if (lv_image_get_src(obj) == dsc) {
// Setting the same source makes the widget read the size again, and redraws it
::lv_image_set_src(obj, dsc);
i++;
} else {
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
widgets.erase(widgets.begin() + i);
}
}
#endif
#endif
}
void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
if (this->data_start_) {
// If we have a complete image, use the base class draw method
@@ -318,21 +263,11 @@ bool RuntimeImage::end_decode() {
}
// Finalize the image for display
if (this->is_double_buffered_()) {
if (this->buffer_ != nullptr) {
// Show the new image, and decode into the old one's buffer next time
std::swap(this->front_buffer_, this->buffer_);
std::swap(this->width_, this->buffer_width_);
std::swap(this->height_, this->buffer_height_);
this->data_start_ = this->front_buffer_;
this->refresh_lvgl_();
}
} else if (!this->progressive_display_) {
if (!this->progressive_display_) {
// Only now make the image visible
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
// End the session; the decoder object stays warm so the next decode can
@@ -349,15 +284,6 @@ bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && thi
void RuntimeImage::release() {
this->release_buffer_();
if (this->front_buffer_) {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->front_buffer_, this->get_buffer_size(this->width_, this->height_));
this->front_buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
// End any active decode session; decoders free the format-specific working buffers
// they can (PNG), while the decoder object itself is kept warm for the next decode.
if (this->decoder_) {
@@ -365,16 +291,6 @@ void RuntimeImage::release() {
}
}
void RuntimeImage::abort_decode() {
if (!this->is_double_buffered_()) {
this->release();
return;
}
if (this->decoder_) {
this->decoder_->reset();
}
}
void RuntimeImage::release_buffer_() {
if (this->buffer_) {
if (this->external_buffer_) {
@@ -386,25 +302,19 @@ void RuntimeImage::release_buffer_() {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->buffer_, this->get_buffer_size(this->buffer_width_, this->buffer_height_));
}
const bool shown = this->data_start_ == this->buffer_;
this->buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
// With double buffering the buffer decoded into is not the one on show
if (shown) {
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
#ifdef USE_LVGL
memset(&this->dsc_, 0, sizeof(this->dsc_));
#endif
}
}
bool RuntimeImage::set_external_buffer(uint8_t *buffer, int width, int height) {
if (this->is_double_buffered_()) {
ESP_LOGE(TAG, "An external buffer cannot be double buffered");
return false;
}
this->release_buffer_();
if (buffer == nullptr || this->get_buffer_size(width, height) == 0) {
// Keep the released state rather than remembering a buffer that cannot be decoded into: an
@@ -3,8 +3,6 @@
#include "esphome/components/image/image.h"
#include "esphome/core/helpers.h"
#include <vector>
#include "image_decoder.h"
#include "image_format.h"
@@ -56,13 +54,6 @@ class RuntimeImage : public image::Image {
void map_chroma_key(Color &color);
int get_buffer_width() const { return this->buffer_width_; }
int get_buffer_height() const { return this->buffer_height_; }
#ifdef USE_LVGL
/// Until an image is loaded, the placeholder's descriptor if there is one
lv_image_dsc_t *get_lv_image_dsc() override;
#endif
#ifdef USE_LVGL_IMAGE
void add_lvgl_widget(lv_obj_t *obj) override;
#endif
// Image drawing interface
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
@@ -127,9 +118,7 @@ class RuntimeImage : public image::Image {
/**
* @brief Check if an image is currently loaded.
*/
bool is_loaded() const {
return this->is_double_buffered_() ? this->front_buffer_ != nullptr : this->buffer_ != nullptr;
}
bool is_loaded() const { return this->buffer_ != nullptr; }
/**
* @brief Get the image format.
@@ -145,14 +134,6 @@ class RuntimeImage : public image::Image {
*/
void release();
/**
* @brief End a decode that will not be finished.
*
* With double buffering the image on show stays and the buffer decoded into is kept for the next
* decode. Otherwise the same as release(), since the image on show was being decoded over.
*/
void abort_decode();
/**
* @brief Decode into a buffer the caller owns, instead of one allocated here.
*
@@ -176,8 +157,6 @@ class RuntimeImage : public image::Image {
* A null buffer or dimensions the image cannot decode at are refused, leaving the image with
* no buffer at all.
*
* Refused when double buffering is on.
*
* @param buffer Memory for a picture of the given size, at least get_buffer_size() bytes.
* @param width Width of the buffer in pixels.
* @param height Height of the buffer in pixels.
@@ -200,13 +179,6 @@ class RuntimeImage : public image::Image {
*/
void set_progressive_display(bool progressive) { this->progressive_display_ = progressive; }
/**
* @brief Set whether to decode into a second buffer, keeping the image on show until the new one is complete.
*
* Both buffers are kept, so a new image of the same size allocates nothing. Ignored with progressive display.
*/
void set_double_buffer(bool double_buffer) { this->double_buffer_ = double_buffer; }
protected:
/**
* @brief Resize the image buffer to the requested dimensions.
@@ -224,11 +196,6 @@ class RuntimeImage : public image::Image {
*/
void release_buffer_();
bool is_double_buffered_() const { return this->double_buffer_ && !this->progressive_display_; }
/// Bring the LVGL descriptor up to date after the image shown changed, and give it again to the widgets showing it
void refresh_lvgl_();
/**
* @brief Get the position in the buffer for a pixel.
*/
@@ -243,8 +210,6 @@ class RuntimeImage : public image::Image {
// Memory management
uint8_t *buffer_{nullptr};
/// With double buffering, the buffer on show, width_ by height_. buffer_ is then the one decoded into
uint8_t *front_buffer_{nullptr};
// Decoder management
std::unique_ptr<ImageDecoder> decoder_{nullptr};
@@ -281,14 +246,8 @@ class RuntimeImage : public image::Image {
// Configuration
bool progressive_display_{false};
bool double_buffer_{false};
/** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */
bool external_buffer_{false};
#ifdef USE_LVGL_IMAGE
static void lvgl_widget_deleted(lv_event_t *event);
/// Widgets given this image. One moved to another image is dropped at the next refresh
std::vector<lv_obj_t *> lvgl_widgets_;
#endif
friend class ImageDecoder;
};
+5 -230
View File
@@ -4,29 +4,16 @@ from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import (
audio,
esp32,
mdns,
microphone,
network,
psram,
socket,
wifi,
)
from esphome.components import esp32, mdns, network, psram, socket, wifi
from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER
from esphome.components.mdns import DOMAIN as MDNS_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_BUFFER_DURATION,
CONF_BUFFER_SIZE,
CONF_CHANNELS,
CONF_ESPHOME,
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_MICROPHONE,
CONF_MODEL,
CONF_NAME,
CONF_PROJECT,
@@ -38,7 +25,6 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -73,14 +59,6 @@ CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code"
CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded"
CONF_ON_PAIRING_FAILED = "on_pairing_failed"
CONF_CODEC = "codec"
CONF_CHUNK_DURATION = "chunk_duration"
CONF_OPUS_BITRATE = "opus_bitrate"
CONF_OPUS_COMPLEXITY = "opus_complexity"
CONF_ON_STREAMING_STARTED = "on_streaming_started"
CONF_ON_STREAMING_STOPPED = "on_streaming_stopped"
CONF_PRESENT = "present"
# A static pairing code is exactly 8 decimal digits.
STATIC_PAIRING_CODE_DIGITS = 8
@@ -126,15 +104,6 @@ CODECS = {
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
OPUS_SAMPLE_RATE = 48000
SOURCE_CODECS = {
CODEC_OPUS: CODEC_FORMAT_OPUS,
CODEC_PCM: CODEC_FORMAT_PCM,
}
# The source role's limits, matching SourceRoleConfig in sendspin-cpp.
SOURCE_BITS_PER_SAMPLE = (16, 24, 32)
SOURCE_OPUS_SAMPLE_RATES = (8000, 12000, 16000, 24000, 48000)
SOURCE_OPUS_CHUNK_DURATIONS_MS = (5, 10, 20, 40, 60)
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
@@ -148,7 +117,6 @@ AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObj
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
SourceRoleConfig = sendspin_library_ns.struct("SourceRoleConfig")
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
# preference for the player role's transfer buffers.
@@ -208,11 +176,6 @@ class SendspinConfiguration:
visualizer_support: bool = False
pairing_code_display_support: bool = False
switch_types: set[str] = field(default_factory=set)
# Set by the sendspin.source actions and conditions, which need a source block.
source_referenced: bool = False
source_line_sense: bool = False
# The microphone's sample rate, read during final validation.
source_sample_rate: int | None = None
artwork_preferences: list[ConfigType] = field(default_factory=list)
player_config: ConfigType | None = None
@@ -307,82 +270,6 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
return config
_SOURCE_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STREAMING_STARTED, "add_on_source_streaming_started_callback"
),
automation.CallbackAutomation(
CONF_ON_STREAMING_STOPPED, "add_on_source_streaming_stopped_callback"
),
)
def _validate_source_format(config: ConfigType) -> ConfigType:
bits_per_sample = config[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]
if bits_per_sample not in SOURCE_BITS_PER_SAMPLE:
raise cv.Invalid(
f"{CONF_BITS_PER_SAMPLE} must be one of {SOURCE_BITS_PER_SAMPLE}",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
if config[CONF_CODEC] == CODEC_OPUS:
if bits_per_sample != 16:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires a {CONF_BITS_PER_SAMPLE} of 16",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
if (
config[CONF_CHUNK_DURATION].total_milliseconds
not in SOURCE_OPUS_CHUNK_DURATIONS_MS
):
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires {CONF_CHUNK_DURATION} to be one of "
f"{', '.join(f'{ms}ms' for ms in SOURCE_OPUS_CHUNK_DURATIONS_MS)}",
path=[CONF_CHUNK_DURATION],
)
return config
SOURCE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_MICROPHONE): microphone.microphone_source_schema(
min_bits_per_sample=16,
max_bits_per_sample=32,
min_channels=1,
max_channels=2,
),
cv.Optional(CONF_CODEC, default=CODEC_PCM): cv.one_of(
*SOURCE_CODECS, lower=True
),
cv.Optional(CONF_CHUNK_DURATION, default="20ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(
min=cv.TimePeriod(milliseconds=5),
max=cv.TimePeriod(milliseconds=150),
),
),
# sendspin-cpp refuses any single write longer than 5/8 of the buffer, so a short buffer
# would reject every microphone read.
cv.Optional(CONF_BUFFER_DURATION, default="500ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=100)),
),
cv.Optional(audio.CONF_BUFFER_MEMORY): cv.one_of(
*MEMORY_LOCATIONS, lower=True
),
cv.Optional(CONF_OPUS_BITRATE, default="64kbps"): cv.All(
cv.bps, cv.int_, cv.int_range(min=500, max=512000)
),
cv.Optional(CONF_OPUS_COMPLEXITY, default=2): cv.int_range(min=0, max=10),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_ON_STREAMING_STARTED): automation.validate_automation({}),
cv.Optional(CONF_ON_STREAMING_STOPPED): automation.validate_automation({}),
}
),
_validate_source_format,
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -408,7 +295,6 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}),
cv.Optional(CONF_SOURCE): SOURCE_SCHEMA,
}
),
cv.only_on_esp32,
@@ -449,40 +335,9 @@ def _final_validate(config: ConfigType) -> ConfigType:
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
_final_validate_source(config)
return config
def _final_validate_source(config: ConfigType) -> None:
data = _get_data()
if (source := config.get(CONF_SOURCE)) is None:
if data.source_referenced:
raise cv.Invalid(
f"The sendspin.source actions and conditions require a '{CONF_SOURCE}' "
"block on the sendspin component"
)
return
mic_source = source[CONF_MICROPHONE]
microphone.final_validate_microphone_source_schema(DOMAIN)(mic_source)
# MicrophoneSource cannot resample, so the stream takes the microphone's own rate.
mic_config = fv.get_declaring_config(mic_source[CONF_MICROPHONE])
sample_rate = mic_config.get(audio.CONF_MAX_SAMPLE_RATE)
if sample_rate is None or mic_config.get(audio.CONF_MIN_SAMPLE_RATE) != sample_rate:
raise cv.Invalid(
"The microphone used by the sendspin source must have a fixed sample rate",
path=[CONF_SOURCE, CONF_MICROPHONE],
)
if source[CONF_CODEC] == CODEC_OPUS and sample_rate not in SOURCE_OPUS_SAMPLE_RATES:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires the microphone {CONF_SAMPLE_RATE} to be one of "
f"{', '.join(str(rate) for rate in SOURCE_OPUS_SAMPLE_RATES)}",
path=[CONF_SOURCE, CONF_CODEC],
)
data.source_sample_rate = sample_rate
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -492,8 +347,7 @@ def _request_controller_role(config: ConfigType) -> ConfigType:
return config
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. The
# source action and condition also use it, and need a source block.
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role.
SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
@@ -527,41 +381,6 @@ automation.register_apply_action(
)
def _request_source(config: ConfigType) -> ConfigType:
"""Mark that a source action or condition is used, so a missing source block is reported."""
_get_data().source_referenced = True
return config
def _request_source_line_sense(config: ConfigType) -> ConfigType:
"""Advertise line sense for the sendspin.source.set_signal action."""
_get_data().source_line_sense = True
return _request_source(config)
automation.register_apply_condition(
"sendspin.source.is_streaming",
cv.All(SENDSPIN_HUB_ACTION_SCHEMA, _request_source),
"is_source_streaming()",
)
# Using the action advertises line sense, so the server knows to expect signal reports.
automation.register_apply_action(
"sendspin.source.set_signal",
cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinHub),
cv.Required(CONF_PRESENT): cv.templatable(cv.boolean),
}
),
_request_source_line_sense,
),
automation.ApplyField(CONF_PRESENT, "set_source_signal", cg.bool_),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -614,8 +433,9 @@ async def to_code(config: ConfigType) -> None:
data = _get_data()
# The color role is not yet wired up in ESPHome; disable it in the library for now.
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
@@ -705,52 +525,7 @@ async def to_code(config: ConfigType) -> None:
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
if (source := config.get(CONF_SOURCE)) is not None:
cg.add_define("USE_SENDSPIN_SOURCE", True)
mic_source = await microphone.microphone_source_to_code(source[CONF_MICROPHONE])
cg.add(var.set_microphone_source(mic_source))
source_psram_stack = source.get(CONF_TASK_STACK_IN_PSRAM, False)
if source_psram_stack:
psram.request_external_task_stack()
# Designated initializers, so the fields follow SourceRoleConfig's declaration order.
source_struct_fields = [
("sample_rate", data.source_sample_rate),
("chunk_duration_ms", source[CONF_CHUNK_DURATION]),
("capture_buffer_ms", source[CONF_BUFFER_DURATION]),
("opus_bitrate", source[CONF_OPUS_BITRATE]),
]
if (buffer_memory := source.get(audio.CONF_BUFFER_MEMORY)) is not None:
source_struct_fields.append(
("buffer_location", MEMORY_LOCATION_ENUM[buffer_memory])
)
source_struct_fields += [
("codec", SOURCE_CODECS[source[CONF_CODEC]]),
("opus_complexity", source[CONF_OPUS_COMPLEXITY]),
("channels", len(source[CONF_MICROPHONE][CONF_CHANNELS])),
("bit_depth", source[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]),
("line_sense", data.source_line_sense),
("psram_stack", source_psram_stack),
]
cg.add(
var.set_source_config(
cg.StructInitializer(SourceRoleConfig, *source_struct_fields)
)
)
await automation.build_callback_automations(
var, source, _SOURCE_CALLBACK_AUTOMATIONS
)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
player_uses_opus = (
data.player_support and CODEC_OPUS in data.player_config[CONF_CODECS]
)
source_uses_opus = source is not None and source[CONF_CODEC] == CODEC_OPUS
if not player_uses_opus and not source_uses_opus:
if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
if data.visualizer_support:
@@ -158,16 +158,6 @@ void SendspinHub::setup() {
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
#ifdef USE_SENDSPIN_SOURCE
this->source_role_ = &this->client_->add_source(this->source_config_);
this->source_role_->set_listener(this);
// THREAD CONTEXT: Microphone task, the role's single producer. A capture time of 0 (unknown) makes the library
// stamp the buffer as ending now.
this->microphone_source_->add_data_callback([this](const std::vector<uint8_t> &data, int64_t capture_time_us) {
this->source_role_->write_audio(data.data(), data.size(), capture_time_us);
});
#endif
// Set before setup() by codegen; an unpaired access switch sets it later, from its own setup().
if (this->unpaired_access_.has_value()) {
this->client_->set_unpaired_access_enabled(*this->unpaired_access_);
@@ -233,23 +223,6 @@ void SendspinHub::dump_config() {
preference.display_offset_ms);
}
#endif
#ifdef USE_SENDSPIN_SOURCE
ESP_LOGCONFIG(TAG,
" Source:\n"
" Codec: %s\n"
" Sample rate: %" PRIu32 " Hz\n"
" Channels: %u\n"
" Bits per sample: %u\n"
" Chunk duration: %" PRIu32 " ms\n"
" Buffer duration: %" PRIu32 " ms\n"
" Line sense: %s",
this->source_config_.codec == sendspin::SendspinCodecFormat::OPUS ? LOG_STR_LITERAL("Opus")
: LOG_STR_LITERAL("PCM"),
this->source_config_.sample_rate, this->source_config_.channels, this->source_config_.bit_depth,
this->source_config_.chunk_duration_ms, this->source_config_.capture_buffer_ms,
YESNO(this->source_config_.line_sense));
#endif
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
@@ -591,27 +564,6 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
}
#endif
#ifdef USE_SENDSPIN_SOURCE
// THREAD CONTEXT: Main loop (invoked from the sendspin.source.set_signal action)
void SendspinHub::set_source_signal(bool present) {
if (this->source_role_ != nullptr) {
this->source_role_->set_signal(present ? sendspin::SourceSignal::PRESENT : sendspin::SourceSignal::ABSENT);
}
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_started() {
this->microphone_source_->start();
this->source_streaming_started_callbacks_.call();
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_stopped() {
this->microphone_source_->stop();
this->source_streaming_stopped_callbacks_.call();
}
#endif
} // namespace esphome::sendspin_
#endif // USE_ESP_IDF
@@ -31,11 +31,6 @@
#ifdef USE_SENDSPIN_PLAYER
#include <sendspin/player_role.h>
#endif
#ifdef USE_SENDSPIN_SOURCE
#include "esphome/components/microphone/microphone_source.h"
#include <sendspin/source_role.h>
#endif
#include <array>
#include <cstddef>
@@ -80,7 +75,6 @@ enum SendspinFeature : uint32_t {
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
/// child thereafter, since no fan-out is needed.
/// - The source role streams a microphone. Its data callback runs on the microphone's task.
///
/// The sendspin-cpp library follows this design:
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
@@ -97,9 +91,6 @@ class SendspinHub final : public Component,
#endif
#ifdef USE_SENDSPIN_METADATA
public sendspin::MetadataRoleListener,
#endif
#ifdef USE_SENDSPIN_SOURCE
public sendspin::SourceRoleListener,
#endif
public sendspin::SendspinClientListener,
public sendspin::SendspinNetworkProvider,
@@ -294,29 +285,6 @@ class SendspinHub final : public Component,
sendspin::PlayerRole *get_player_role();
#endif
#ifdef USE_SENDSPIN_SOURCE
void set_source_config(const sendspin::SourceRoleConfig &config) { this->source_config_ = config; }
void set_microphone_source(microphone::MicrophoneSource *microphone_source) {
this->microphone_source_ = microphone_source;
}
/// @brief Reports whether the capture input has a signal; exposed as the sendspin.source.set_signal action.
///
/// Using the action enables line sense in the source config. Ignored before setup(). Must be called from the main
/// loop thread.
void set_source_signal(bool present);
/// @brief Returns whether the source is streaming microphone audio to the server.
bool is_source_streaming() const { return this->source_role_ != nullptr && this->source_role_->is_streaming(); }
template<typename F> void add_on_source_streaming_started_callback(F &&callback) {
this->source_streaming_started_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_source_streaming_stopped_callback(F &&callback) {
this->source_streaming_stopped_callbacks_.add(std::forward<F>(callback));
}
#endif
protected:
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
@@ -415,20 +383,6 @@ class SendspinHub final : public Component,
sendspin::PlayerRoleConfig player_config_{};
#endif
#ifdef USE_SENDSPIN_SOURCE
// The server opens and closes the stream; the microphone runs only while it is open.
void on_streaming_started() override;
void on_streaming_stopped() override;
sendspin::SourceRoleConfig source_config_{};
sendspin::SourceRole *source_role_{nullptr};
microphone::MicrophoneSource *microphone_source_{nullptr};
// Lazy: each is fed by an optional YAML automation.
LazyCallbackManager<void()> source_streaming_started_callbacks_{};
LazyCallbackManager<void()> source_streaming_stopped_callbacks_{};
#endif
// --- Core member variables ---
// Built once in setup(): make_preference() allocates a backend that is never freed.
@@ -2,22 +2,29 @@ import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.core.entity_helpers import FollowEntity
from esphome.types import ConfigType
from .. import Switch
from .. import Switch, switch_ns
CODEOWNERS = ["@ssieb"]
_FOLLOW_SWITCH = FollowEntity("add_on_state_callback", ((cg.bool_, "x"),), "x")
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(binary_sensor.BinarySensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
SwitchBinarySensor = switch_ns.class_(
"SwitchBinarySensor", binary_sensor.BinarySensor, cg.Component
)
async def to_code(config: ConfigType) -> None:
CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(SwitchBinarySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
_FOLLOW_SWITCH(var, await cg.get_variable(config[CONF_SOURCE_ID]))
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
@@ -0,0 +1,15 @@
#include "switch_binary_sensor.h"
#include "esphome/core/log.h"
namespace esphome::switch_ {
ESPHOME_LOG_TAG(TAG, "switch.binary_sensor");
void SwitchBinarySensor::setup() {
source_->add_on_state_callback([this](bool value) { this->publish_state(value); });
this->publish_state(source_->state);
}
void SwitchBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Switch Binary Sensor", this); }
} // namespace esphome::switch_
@@ -0,0 +1,19 @@
#pragma once
#include "../switch.h"
#include "esphome/core/component.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome::switch_ {
class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component {
public:
void set_source(Switch *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
Switch *source_;
};
} // namespace esphome::switch_
@@ -4,16 +4,23 @@ import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Text
from .. import Text, text_ns
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
TextTextSensor = text_ns.class_("TextTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(TextTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
# Text has the same string state callback as TextSensor
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await text_sensor.new_text_sensor(config, source)
await cg.register_component(var, config)
@@ -0,0 +1,16 @@
#include "text_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::text {
ESPHOME_LOG_TAG(TAG, "text.text_sensor");
void TextTextSensor::setup() {
this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void TextTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Text Text Sensor", this); }
} // namespace esphome::text
@@ -0,0 +1,19 @@
#pragma once
#include "../text.h"
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::text {
class TextTextSensor final : public text_sensor::TextSensor, public Component {
public:
explicit TextTextSensor(Text *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Text *source_;
};
} // namespace esphome::text
+8 -3
View File
@@ -96,7 +96,6 @@
#define USE_GPIO_SWITCH_INTERLOCK
#define USE_GRAPH
#define USE_GRAPHICAL_DISPLAY_MENU
#define USE_GREE_FAHRENHEIT
#define USE_GREE_YAW1F
#define USE_HOERMANN_HCP_IDENTITY
#define USE_HOMEASSISTANT_TIME
@@ -202,7 +201,6 @@
#define USE_REMOTE_PROTOCOL_BRENNENSTUHL
#define USE_REMOTE_PROTOCOL_BYRONSX
#define USE_REMOTE_PROTOCOL_CANALSAT
#define USE_REMOTE_PROTOCOL_CARDIN_S438
#define USE_REMOTE_PROTOCOL_COOLIX
#define USE_REMOTE_PROTOCOL_DISH
#define USE_REMOTE_PROTOCOL_DOOYA
@@ -494,6 +492,11 @@
#define USE_ESP32_BLE_TRACKER
#define USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
#define USE_BLE_GATT_CLIENT
#define USE_BLE_GATT_BACKEND_BLUEDROID
#define USE_BLUEDROID_GATT_SERVICE_TABLE
#define USE_BLE_CLIENT_GATT_NODES
#define USE_BLE_CLIENT_LEGACY_ENGINE
#define ESPHOME_BLE_CLIENT_MAX_NODES 1
#define ESPHOME_BLE_GATT_CLIENT_COUNT 1
#define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1
#define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1
@@ -518,7 +521,6 @@
#define USE_SENDSPIN_CONTROLLER
#define USE_SENDSPIN_METADATA
#define USE_SENDSPIN_PLAYER
#define USE_SENDSPIN_SOURCE
#define USE_SENDSPIN_SWITCH
#define USE_SENDSPIN_VISUALIZER
#define USE_SENDSPIN_PORT 8928 // NOLINT
@@ -747,7 +749,10 @@
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_BLE_SCAN_RESPONSE_MERGER
#define USE_BLE_GATT_CLIENT
#define USE_BLE_GATT_BACKEND_RP2
#define USE_BLE_CLIENT_GATT_NODES
#define ESPHOME_BLE_GATT_CLIENT_COUNT 3
#define ESPHOME_BLE_CLIENT_MAX_NODES 1
#define USE_RP2040_VARIANT_RP2040
#define USE_RP2_WATCHDOG_TIMEOUT 8000
#define USE_SPI
+7 -7
View File
@@ -483,6 +483,13 @@ uint32_t HOT Scheduler::call(uint32_t now) {
// Not reached timeout yet, done for this call
break;
}
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
// Check if item is marked for removal
// This handles two cases:
// 1. Item was marked for removal after cleanup_() but before we got here
@@ -507,13 +514,6 @@ uint32_t HOT Scheduler::call(uint32_t now) {
}
#endif
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
#ifdef ESPHOME_DEBUG_SCHEDULER
{
SchedulerNameLog name_log;
+9 -21
View File
@@ -326,25 +326,6 @@ def load_test_manifest_overrides(
ManifestOverrideLoader = Callable[[list[str]], None]
def resolve_dependencies_with_overrides(
components: list[str], manifest_override_loader: ManifestOverrideLoader
) -> list[str]:
"""Return the components and all their dependencies, with overrides applied.
Overrides are applied before each dependency search, so dependencies added by
any override_manifest(), including one of a component found only as a
dependency, are included. The loader is called again with components it has
already seen, so it must apply each override only once.
"""
resolved = sorted(components)
while True:
manifest_override_loader(resolved)
expanded = sorted(get_all_dependencies(set(resolved)))
if expanded == resolved:
return resolved
resolved = expanded
def compile_and_get_binary(
config: dict,
components: list[str],
@@ -385,12 +366,19 @@ def compile_and_get_binary(
else:
config.setdefault(key, value)
# Apply manifest overrides before dependency resolution so that any
# dependency additions made by override_manifest() are picked up.
manifest_override_loader(components)
# Obtain possible dependencies BEFORE validate_config, because
# get_all_dependencies calls CORE.reset() which clears build_path.
components_with_dependencies = resolve_dependencies_with_overrides(
components, manifest_override_loader
components_with_dependencies: list[str] = sorted(
get_all_dependencies(set(components))
)
# Apply overrides for any transitively discovered dependencies.
manifest_override_loader(components_with_dependencies)
CORE.config_path = tests_dir / "dummy.yaml"
CORE.dashboard = None
@@ -19,9 +19,21 @@ from esphome.const import (
CONF_NOTIFY,
CONF_SERVICE_UUID,
CONF_TYPE,
PlatformFramework,
)
from esphome.core import CORE
from esphome.types import ConfigType
from ..types import SetCoreConfigCallable
@pytest.fixture(autouse=True)
def esp32_platform(set_core_config: SetCoreConfigCallable) -> None:
# The raw-gattc node family gates through BLE_CLIENT_SCHEMA's
# _legacy_engine_only choke point; these schema tests exercise the esp32 arm.
set_core_config(PlatformFramework.ESP32_IDF)
DESCRIPTOR_CONFIG: ConfigType = {
CONF_NAME: "test",
CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
@@ -84,3 +96,72 @@ def test_on_notify_implies_notify() -> None:
def test_notify_unchanged_without_on_notify() -> None:
config: ConfigType = {CONF_NOTIFY: False}
assert notify_from_on_notify(config)[CONF_NOTIFY] is False
def test_legacy_node_choke_point_rejects_other_platforms(
set_core_config: SetCoreConfigCallable,
) -> None:
from esphome.components import ble_client
from esphome.core import ID
set_core_config(PlatformFramework.RP2_ARDUINO)
with pytest.raises(cv.Invalid, match="not been migrated"):
ble_client._legacy_engine_only(ID("x"))
# Through the public schema too, so removing the cv.All wiring fails here.
with pytest.raises(cv.Invalid, match="not been migrated"):
ble_client.BLE_CLIENT_SCHEMA({})
def test_neutral_arm_rejects_esp32_only_keys(
set_core_config: SetCoreConfigCallable,
) -> None:
# Pins the schema split's rejection side: the legacy-only keys must not
# leak into the neutral arm. The hub is registered so the extra key is
# the only error - without it the missing-tracker error would satisfy
# the raises vacuously.
from esphome.components import ble_client, ble_device_base
set_core_config(PlatformFramework.RP2_ARDUINO)
ble_device_base.register_hub_provider("rp2_ble_tracker")
CORE.loaded_integrations.add("rp2_ble_tracker")
for key in ("name", "on_passkey_request", "on_passkey_notification"):
with pytest.raises(cv.Invalid, match="extra keys not allowed"):
ble_client.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF", key: "x"})
def test_security_actions_reject_platforms_without_the_feature(
set_core_config: SetCoreConfigCallable,
) -> None:
from esphome.components import ble_client
set_core_config(PlatformFramework.RP2_ARDUINO)
for schema in (
ble_client.BLE_PASSKEY_REPLY_ACTION_SCHEMA,
ble_client.BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA,
ble_client.BLE_REMOVE_BOND_ACTION_SCHEMA,
):
with pytest.raises(cv.Invalid, match="'security' feature, which rp2"):
schema({})
def test_node_schema_passes_on_every_gatt_platform(
set_core_config: SetCoreConfigCallable,
) -> None:
# The neutral node schema carries no engine gate: raw_gattc components
# stay choked, gatt_node components validate wherever ble_client does.
from esphome.components import ble_client
for pf in (PlatformFramework.ESP32_IDF, PlatformFramework.RP2_ARDUINO):
set_core_config(pf)
assert ble_client.NODE_BLE_CLIENT_SCHEMA({})
def test_feature_error_names_the_available_features(
set_core_config: SetCoreConfigCallable,
) -> None:
from esphome.components import ble_client
from esphome.core import ID
set_core_config(PlatformFramework.RP2_ARDUINO)
with pytest.raises(cv.Invalid, match="provides: gatt_node"):
ble_client._legacy_engine_only(ID("x"))
Whitespace-only changes.
@@ -0,0 +1,79 @@
"""Tests for the cross-component GATT slot ledger."""
import pytest
from esphome import config_validation as cv
from esphome.components import (
ble_client,
ble_device_base,
bluetooth_connection,
bluetooth_proxy,
rp2040_ble,
)
from esphome.const import CONF_MAC_ADDRESS, PlatformFramework
from esphome.core import CORE
from ..types import SetCoreConfigCallable
def test_gatt_slot_ledger_rejects_overcommit_on_rp2(
set_core_config: SetCoreConfigCallable,
) -> None:
# rp2 owns its budget: the stack's validation reports the overcommit and
# the neutral cap check stays silent (one message per misconfiguration).
set_core_config(PlatformFramework.RP2_ARDUINO)
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
bluetooth_connection.consume_gatt_slot("ble_client")({})
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
with pytest.raises(cv.Invalid, match="rp2 maximum is 3"):
rp2040_ble.validate_connection_slots()
def test_gatt_slot_ledger_skipped_in_testing_mode(
set_core_config: SetCoreConfigCallable,
) -> None:
# Grouped component builds merge fixtures past the cap; the check defers
# to testing mode like esp32_ble.validate_connection_slots.
set_core_config(PlatformFramework.RP2_ARDUINO)
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
bluetooth_connection.consume_gatt_slot("ble_client")({})
CORE.testing_mode = True
try:
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
rp2040_ble.validate_connection_slots()
finally:
CORE.testing_mode = False
def test_real_validators_charge_the_ledger_on_rp2(
set_core_config: SetCoreConfigCallable,
) -> None:
# End to end through the component CONFIG_SCHEMAs (no hand charges):
# removing either consumer's consume_gatt_slot call fails this test.
set_core_config(PlatformFramework.RP2_ARDUINO)
ble_device_base.register_hub_provider("rp2_ble_tracker")
CORE.loaded_integrations.add("rp2_ble_tracker")
bluetooth_proxy.CONFIG_SCHEMA({})
ble_client.CONFIG_SCHEMA({CONF_MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"})
# The proxy defaults to 3 slots on rp2; ble_client's claim overcommits
# and rp2's own budget names every claimant.
with pytest.raises(
cv.Invalid,
match="Components: bluetooth_proxy, bluetooth_proxy, bluetooth_proxy, "
"ble_client",
):
rp2040_ble.validate_connection_slots()
def test_neutral_cap_check_guards_future_hub_platforms(
set_core_config: SetCoreConfigCallable, monkeypatch: pytest.MonkeyPatch
) -> None:
# Both current platforms defer to their stack budgets; pin the message
# and boundary of the branch a future budget-less hub platform takes.
set_core_config(PlatformFramework.RP2_ARDUINO)
monkeypatch.setattr(bluetooth_connection, "_STACK_BUDGETS", {})
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({})
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
bluetooth_connection.consume_gatt_slot("ble_client")({})
with pytest.raises(cv.Invalid, match="supports at most 3 GATT client connection"):
bluetooth_connection.FINAL_VALIDATE_SCHEMA({})
@@ -186,6 +186,18 @@ def test_rp2_rejects_esp32_only_keys_by_name(
bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]})
def test_esp32_explicit_connections_claim_gatt_slots(
set_core_config: SetCoreConfigCallable,
) -> None:
# Explicit `connections:` entries must charge the slot ledger like the
# generated ones; dev historically let them evade the budget.
set_core_config(PlatformFramework.ESP32_IDF)
bluetooth_proxy.CONFIG_SCHEMA({"active": True, "connections": [{}, {}]})
# Exact match (one entry per slot): catches a missed charge and a
# double charge alike.
assert bluetooth_connection._get_data().slot_consumers == ["bluetooth_proxy"] * 2
def test_hub_source_filter_covers_every_hub_platform() -> None:
# bluetooth_connection cannot import this module to derive the hub.cpp
# framework set, so pin it here: a platform admitted to the proxy but
@@ -229,6 +241,12 @@ def test_every_registered_hub_platform_has_a_schema_arm() -> None:
# Hub platforms must also be in the backend registry the shared codegen
# helpers dispatch on.
assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS)
# Every non-esp32 backend platform must carry a slot cap: without one the
# ledger's FINAL_VALIDATE accepts unlimited claims silently (esp32's cap
# is the controller budget in esp32_ble).
assert set(bluetooth_connection._PLATFORM_BACKENDS) - {"esp32"} <= set(
bluetooth_connection.HUB_MAX_CONNECTIONS
)
# The outer walkable schema's bound must stay the loosest platform cap.
assert (
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
@@ -1,31 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO4
i2s_bclk_pin: GPIO5
microphone:
- platform: i2s_audio
id: mic
i2s_din_pin: GPIO39
adc_type: external
sample_rate: 48000
sendspin:
source:
microphone: mic
codec: opus
buffer_memory: internal
on_streaming_started:
- sendspin.source.set_signal:
present: true
@@ -1,26 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO4
i2s_bclk_pin: GPIO5
microphone:
- platform: i2s_audio
id: mic
i2s_din_pin: GPIO39
adc_type: external
sample_rate: 16000
sendspin:
source:
microphone: mic
@@ -1,185 +0,0 @@
"""Validation and code generation tests for the sendspin source role.
These cover the rejection branches of the source block and the generated role
config, which a compile test cannot check: a `test*.yaml` can only assert that a
configuration is accepted.
"""
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.audio import CONF_MAX_SAMPLE_RATE, CONF_MIN_SAMPLE_RATE
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
from esphome.components.sendspin import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
_get_data,
_request_source,
)
from esphome.config import Config
from esphome.const import CONF_ID, CONF_SOURCE, PlatformFramework
from esphome.core import CORE, ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "sendspin_hub"
MIC_ID = "mic"
def _full_config(min_sample_rate: int = 16000, max_sample_rate: int = 16000) -> Config:
"""A full config carrying one microphone, as the ID pass leaves it."""
mic: ConfigType = {
CONF_ID: ID(MIC_ID, is_declaration=True, type="microphone"),
CONF_MIN_SAMPLE_RATE: min_sample_rate,
CONF_MAX_SAMPLE_RATE: max_sample_rate,
"min_channels": 1,
"max_channels": 2,
"min_bits_per_sample": 16,
"max_bits_per_sample": 32,
}
full = Config()
full["microphone"] = [mic]
full.declare_ids.append((mic[CONF_ID], ["microphone", 0, CONF_ID]))
return full
def _hub_config(**source: Any) -> ConfigType:
"""Validate a hub config with a source block, allowing field overrides."""
source_config: ConfigType = {
"microphone": {"microphone": ID(MIC_ID, is_declaration=False)}
}
source_config.update(source)
return CONFIG_SCHEMA({CONF_ID: HUB_ID, CONF_SOURCE: source_config})
@pytest.fixture
def microphone(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config())
@pytest.mark.usefixtures("microphone")
def test_defaults() -> None:
config = _hub_config()
source = config[CONF_SOURCE]
assert source["codec"] == "pcm"
assert source["chunk_duration"].total_milliseconds == 20
assert source["buffer_duration"].total_milliseconds == 500
@pytest.mark.usefixtures("microphone")
def test_final_validate_records_sample_rate() -> None:
"""The stream takes the microphone's own rate, since nothing can resample it."""
FINAL_VALIDATE_SCHEMA(_hub_config())
assert _get_data().source_sample_rate == 16000
@pytest.mark.usefixtures("microphone")
def test_unsupported_bits_per_sample_rejected() -> None:
with pytest.raises(cv.Invalid, match="bits_per_sample must be one of"):
_hub_config(microphone={"microphone": MIC_ID, "bits_per_sample": 20})
@pytest.mark.usefixtures("microphone")
def test_opus_requires_16_bits() -> None:
with pytest.raises(cv.Invalid, match="requires a bits_per_sample of 16"):
_hub_config(
codec="opus", microphone={"microphone": MIC_ID, "bits_per_sample": 24}
)
@pytest.mark.usefixtures("microphone")
def test_opus_chunk_duration_rejected() -> None:
"""Opus encodes each chunk as one frame, so only its frame sizes work."""
with pytest.raises(cv.Invalid, match="requires chunk_duration to be one of"):
_hub_config(codec="opus", chunk_duration="15ms")
@pytest.mark.usefixtures("microphone")
def test_short_buffer_rejected() -> None:
"""A buffer too short for one microphone read would refuse every write."""
with pytest.raises(cv.Invalid, match="must be at least"):
_hub_config(buffer_duration="50ms")
def test_variable_sample_rate_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(min_sample_rate=16000, max_sample_rate=48000),
)
with pytest.raises(cv.Invalid, match="must have a fixed sample rate"):
FINAL_VALIDATE_SCHEMA(_hub_config())
def test_opus_sample_rate_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(min_sample_rate=44100, max_sample_rate=44100),
)
with pytest.raises(
cv.Invalid, match="requires the microphone sample_rate to be one of"
):
FINAL_VALIDATE_SCHEMA(_hub_config(codec="opus"))
def test_source_automation_without_source_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The source actions and condition have nothing to act on without a source block."""
set_core_config(PlatformFramework.ESP32_IDF)
_request_source({})
with pytest.raises(cv.Invalid, match="require a 'source' block"):
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA({CONF_ID: HUB_ID}))
def _sdkconfig_option(name: str) -> Any:
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
def test_opus_source_config_generated(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The set_signal action turns on line sense, and an Opus source keeps Opus in the library."""
main_cpp = generate_main(component_config_path("source_opus.yaml"))
assert "set_source_config(" in main_cpp
assert ".sample_rate = 48000" in main_cpp
assert ".capture_buffer_ms = 500" in main_cpp
assert ".buffer_location = sendspin::MemoryLocation::PREFER_INTERNAL" in main_cpp
assert ".codec = sendspin::SendspinCodecFormat::OPUS" in main_cpp
assert ".line_sense = true" in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is None
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is None
def test_pcm_source_config_generated(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without the set_signal action or buffer_memory, neither is set, and unused Opus is left out."""
main_cpp = generate_main(component_config_path("source_pcm.yaml"))
assert ".sample_rate = 16000" in main_cpp
assert ".codec = sendspin::SendspinCodecFormat::PCM" in main_cpp
assert ".line_sense = false" in main_cpp
assert ".buffer_location" not in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is False
def test_no_source_disables_the_role(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
assert "set_source_config(" not in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is False
@@ -1,49 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/api/api_deferred_batch.h"
namespace esphome::api::testing {
class DeferredBatchTest : public ::testing::Test {
protected:
static constexpr uint16_t STATE = 1;
static constexpr uint16_t PING = 2;
EntityBase entities_[3];
DeferredBatch batch_;
};
TEST_F(DeferredBatchTest, FrontItemKeepsTheQueuedOrder) {
for (auto &entity : this->entities_)
this->batch_.add_item(&entity, STATE, 8);
this->batch_.add_item_front(nullptr, PING, 4);
// A swap would have moved the oldest item to the back
std::vector<EntityBase *> order;
order.reserve(this->batch_.size());
for (size_t i = 0; i < this->batch_.size(); i++)
order.push_back(this->batch_[i].entity);
EXPECT_EQ(order, (std::vector<EntityBase *>{nullptr, &this->entities_[0], &this->entities_[1], &this->entities_[2]}));
EXPECT_EQ(this->batch_[0].message_type, PING);
}
TEST_F(DeferredBatchTest, FrontItemIntoAnEmptyBatch) {
this->batch_.add_item_front(nullptr, PING, 4);
ASSERT_EQ(this->batch_.size(), 1u);
EXPECT_EQ(this->batch_[0].message_type, PING);
}
TEST_F(DeferredBatchTest, StatesAreQueuedOnce) {
this->batch_.add_item(&this->entities_[0], STATE, 8);
this->batch_.add_item(&this->entities_[0], STATE, 8);
EXPECT_EQ(this->batch_.size(), 1u);
}
TEST_F(DeferredBatchTest, ContainsMatchesEntityAndMessageType) {
this->batch_.add_item(&this->entities_[0], STATE, 8);
EXPECT_TRUE(this->batch_.contains(&this->entities_[0], STATE));
EXPECT_FALSE(this->batch_.contains(&this->entities_[0], PING));
EXPECT_FALSE(this->batch_.contains(&this->entities_[1], STATE));
}
} // namespace esphome::api::testing
@@ -0,0 +1,28 @@
ble_client:
- mac_address: 01:02:03:04:05:06
id: test_blec
on_connect:
then:
- ble_client.ble_write:
id: test_blec
service_uuid: '1802'
characteristic_uuid: '2a06'
value: [0x04, 0x05, 0x06]
on_disconnect:
then:
# Reconnect from the disconnect trigger: the connect action must
# survive the same dispatch that started it, so the log still runs.
- ble_client.connect: test_blec
- logger.log: reconnected
button:
- platform: template
name: Connect button
on_press:
- ble_client.connect: test_blec
- ble_client.ble_write:
id: test_blec
service_uuid: '1802'
characteristic_uuid: '2a06'
value: !lambda return {0x01, 0x02};
- ble_client.disconnect: test_blec
@@ -0,0 +1,6 @@
# The neutral engine: the BTstack backend and rp2040_ble come in through
# bluetooth_connection's auto-load; the tracker hub supplies the sightings.
packages:
common: !include common-gatt.yaml
rp2_ble_tracker:
@@ -10,6 +10,9 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# and the listener vector it dispatches into (codegen-sized by consumers).
async def to_code_testing(config):
cg.add_define("USE_BLE_DEVICE_IRK")
# The gatt contract test exercises the gated lookup helpers; compile
# their definitions (ble_gatt_client.cpp) into the test build.
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4)
@@ -79,4 +79,54 @@ TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
EXPECT_EQ(table.descriptor_count, 0);
}
// A radon_eye_rd200-shaped table: two services, the second holding a
// notifying characteristic with a CCCD and a bare write characteristic.
class ServiceTableLookup : public ::testing::Test {
protected:
void SetUp() override {
this->services_[0] = {ESPBTUUID::from_uint16(0x1800), 0x0001, 0x0005, 0, 1};
this->services_[1] = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 1, 2};
this->characteristics_[0] = {ESPBTUUID::from_uint16(0x2A00), 0x0003, 0x0003, 0x02, 0, 0};
this->characteristics_[1] = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 1};
this->characteristics_[2] = {ESPBTUUID::from_uint16(0x1524), 0x0016, 0x0016, 0x04, 1, 0};
this->descriptors_[0] = {ESPBTUUID::from_uint16(0x2902), 0x0013};
this->table_ = {this->services_, this->characteristics_, this->descriptors_, 2, 3, 1};
}
GattService services_[2];
GattCharacteristic characteristics_[3];
GattDescriptor descriptors_[1];
GattServiceTable table_;
};
TEST_F(ServiceTableLookup, FindsServicesAndCharacteristicsByUuid) {
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
ASSERT_NE(service, nullptr);
EXPECT_EQ(service->start_handle, 0x0010);
EXPECT_EQ(find_service(this->table_, ESPBTUUID::from_uint16(0xFFFF)), nullptr);
const GattCharacteristic *characteristic =
find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
ASSERT_NE(characteristic, nullptr);
EXPECT_EQ(characteristic->value_handle, 0x0012);
// The lookup is scoped to the service: 0x2A00 lives in the other service.
EXPECT_EQ(find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x2A00)), nullptr);
}
TEST_F(ServiceTableLookup, FindsTheCccdAndReportsItsAbsence) {
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
const GattCharacteristic *notify_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
EXPECT_EQ(find_cccd(this->table_, *notify_char), 0x0013);
const GattCharacteristic *write_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1524));
EXPECT_EQ(find_cccd(this->table_, *write_char), 0);
}
TEST_F(ServiceTableLookup, RejectsRangesThatOverrunTheTable) {
// A corrupt index range must fail the lookup, not walk out of bounds.
GattService bad_service = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 2, 5};
EXPECT_EQ(find_characteristic(this->table_, bad_service, ESPBTUUID::from_uint16(0x1524)), nullptr);
GattCharacteristic bad_char = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 9};
EXPECT_EQ(find_cccd(this->table_, bad_char), 0);
}
} // namespace esphome::ble_device_base::testing
@@ -1,5 +1,6 @@
# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is
# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the
# compiled (USE_BLE_GATT_CLIENT unset), which pins the
# USE_BLUETOOTH_PROXY_CONNECTIONS gating and the
# address-scoped maintenance path that a connections build never exercises.
# Under batch grouping the active default build is what runs; the standalone
# compile of this fixture is what exercises the passive gating.
@@ -5,8 +5,6 @@
#include <gtest/gtest.h>
#include <chrono>
#include <memory>
#include <string>
#include <thread>
namespace esphome::core::testing {
@@ -43,40 +41,4 @@ TEST(Scheduler, TimeoutAddedByACancelledCallbackIsVisibleAfterCall) {
scheduler.trim_freelist();
}
// A cancelled timeout of a component that has since failed has to be counted as removed when call()
// reaches it, or the leftover count makes the next few cancellations trigger a full cleanup early.
TEST(Scheduler, CancelledItemOfFailedComponentIsCountedAsRemoved) {
Scheduler scheduler;
SchedulerTestComponent healthy;
SchedulerTestComponent failing;
// The healthy item is due first, so cleanup_() stops at it and the cancelled item is only reached in the run loop
scheduler.set_timeout(&healthy, "first", 1, []() {});
scheduler.set_timeout(&failing, "cancelled", 2, []() {});
scheduler.process_to_add();
scheduler.cancel_timeout(&failing, "cancelled");
failing.mark_failed();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
scheduler.call(millis());
// Four cancelled items behind a live one stay below the full cleanup threshold of five, so their callbacks are
// released only once they reach the top of the heap
auto token = std::make_shared<int>(0);
scheduler.set_timeout(&healthy, "keep", 10000, []() {});
static constexpr const char *NAMES[] = {"c0", "c1", "c2", "c3"};
for (const char *name : NAMES)
scheduler.set_timeout(&healthy, name, 20000, [token]() {});
scheduler.process_to_add();
for (const char *name : NAMES)
scheduler.cancel_timeout(&healthy, name);
ASSERT_EQ(token.use_count(), 5);
scheduler.call(millis());
EXPECT_EQ(token.use_count(), 5);
scheduler.cancel_timeout(&healthy, "keep");
scheduler.call(millis());
EXPECT_EQ(token.use_count(), 1);
scheduler.trim_freelist();
}
} // namespace esphome::core::testing
-2
View File
@@ -8,8 +8,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# Fahrenheit support is define-gated; emit it so the tests cover that path.
cg.add_define("USE_GREE_FAHRENHEIT")
# The YAW1F follow-up message is compiled in only for configs that use that model
cg.add_define("USE_GREE_YAW1F")
-7
View File
@@ -4,13 +4,6 @@ climate:
id: my_gree_ac
model: YAN
transmitter_id: xmitr
# YX1FF, the model use_fahrenheit was verified on; it has no mode switches, so they stay on YAN
- platform: gree
name: GREE Fahrenheit
id: my_gree_ac_fahrenheit
model: YX1FF
use_fahrenheit: true
transmitter_id: xmitr
switch:
- platform: gree
-71
View File
@@ -1,10 +1,8 @@
#include <gtest/gtest.h>
#include <array>
#include <cmath>
#include "esphome/components/gree/gree.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
namespace esphome::gree::testing {
@@ -26,75 +24,6 @@ TEST(GreeClimateTest, HeatCoolOverrideAdvertisedWithoutHeat) {
EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT));
}
#ifdef USE_GREE_FAHRENHEIT
// Expectations from frames captured off a real YX1FF remote and the panel of a Blueridge mini-split.
TEST(GreeClimateTest, FahrenheitHalfStepsMatchCapturedRemoteFrames) {
struct Expectation {
float celsius;
uint8_t temp_field;
bool extra_degree;
};
const Expectation expectations[] = {
{18.33f, 2, true}, // 65 F -- captured remote frame b1=02 b3=5C
{18.89f, 3, false}, // 66 F -- captured remote frame b1=03 b3=58
{21.67f, 6, false}, // 71 F
{22.22f, 6, true}, // 72 F
{25.0f, 9, false}, // 77 F
{25.56f, 10, false}, // 78 F
};
for (const auto &expectation : expectations) {
const uint8_t half_steps = fahrenheit_half_steps(expectation.celsius);
EXPECT_EQ(expectation.temp_field, half_steps >> 1) << "celsius " << expectation.celsius;
EXPECT_EQ(expectation.extra_degree, (half_steps & 1) != 0) << "celsius " << expectation.celsius;
}
}
TEST(GreeClimateTest, FahrenheitHalfStepsCoverEveryWholeDegreeInRange) {
// 61..86 F is the range a YX1FF remote offers.
for (int fahrenheit = 61; fahrenheit <= 86; fahrenheit++) {
const uint8_t half_steps = fahrenheit_half_steps(fahrenheit_to_celsius(static_cast<float>(fahrenheit)));
const float encoded = static_cast<float>(GREE_TEMP_MIN) + 0.5f * static_cast<float>(half_steps);
EXPECT_EQ(fahrenheit, static_cast<int>(celsius_to_fahrenheit(encoded))) << "fahrenheit " << fahrenheit;
}
}
TEST(GreeClimateTest, FahrenheitHalfStepsHandleNonFiniteTarget) {
// transmit_state() can run from setup() via restore_state_(), when the target may still be NaN.
EXPECT_EQ(0, fahrenheit_half_steps(NAN));
}
#endif // USE_GREE_FAHRENHEIT
// Byte 7 is zeroed in these captured YX1FF frames because frame_checksum() computes it.
TEST(GreeClimateTest, FrameChecksumMatchesCapturedFrames) {
const uint8_t frame_65f[GREE_STATE_FRAME_SIZE] = {0x09, 0x02, 0x60, 0x5C, 0x00, 0x20, 0x00, 0x00};
EXPECT_EQ(0x30, frame_checksum(frame_65f));
const uint8_t frame_78f[GREE_STATE_FRAME_SIZE] = {0x09, 0x0A, 0x60, 0x58, 0x00, 0x20, 0x00, 0x00};
EXPECT_EQ(0x70, frame_checksum(frame_78f));
}
// The removed yan/yx1ff checksum `(b0 << 4) + (b1 << 4) + 0xC0` equals frame_checksum() for every frame they emit.
TEST(GreeClimateTest, FrameChecksumMatchesTheRemovedFoldedFormula) {
int mismatches = 0;
for (int byte0 = 0; byte0 < 256; byte0++) {
for (int byte1 = 0; byte1 < 256; byte1++) {
// byte 2 low nibble is always 0
static constexpr uint8_t BYTE2_VALUES[] = {0x00, 0x20, 0x60, 0x70};
for (const uint8_t byte2 : BYTE2_VALUES) {
for (uint8_t byte4 = 0; byte4 <= GREE_VDIR_DOWN; byte4++) {
const uint8_t frame[GREE_STATE_FRAME_SIZE] = {
static_cast<uint8_t>(byte0), static_cast<uint8_t>(byte1), byte2, 0x50, byte4, 0x20, 0x00, 0x00};
const uint8_t folded = static_cast<uint8_t>((byte0 << 4) + (byte1 << 4) + 0xC0);
if (frame_checksum(frame) != folded) {
mismatches++;
}
}
}
}
}
EXPECT_EQ(0, mismatches);
}
class CapturingGreeTransmitter final : public remote_base::RemoteTransmitterBase {
public:
CapturingGreeTransmitter() : RemoteTransmitterBase(nullptr) {}
@@ -7,8 +7,6 @@
#include "common.h"
#include "esphome/components/modbus/modbus.h"
#include "esphome/components/modbus_client/modbus_client.h"
#include "esphome/core/base_automation.h"
#include "esphome/core/hal.h"
namespace esphome::modbus::testing {
@@ -2547,116 +2545,4 @@ TEST(ModbusClientHubAddress, BroadcastReportsAddressZero) {
hub.send_next_for_test();
EXPECT_EQ(device.sent_, BROADCAST_ADDRESS);
}
namespace {
// A handler on one modbus_client trigger that records the address argument of every firing.
template<typename... Ts> struct AddressRecorder {
explicit AddressRecorder(Trigger<uint8_t, Ts...> *trigger) : automation(trigger) {
this->automation.add_action(&this->action);
}
std::vector<uint8_t> addresses;
LambdaAction<uint8_t, Ts...> action{[this](uint8_t address, Ts...) { this->addresses.push_back(address); }};
Automation<uint8_t, Ts...> automation;
};
} // namespace
// One action, overlapping sends with a moving address: each handler reports its own request's address, not the
// action's latest stamp. The requests dropped in between cover on_not_sent from the hub.
TEST(ModbusClientHubRequestAddress, ActionHandlersReportTheirOwnRequestsAddress) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
modbus_client::ModbusClientSendAction<> action;
action.set_parent(&hub);
action.set_pdu([]() { return read_pdu(); });
static uint8_t target;
action.set_target_address([]() -> uint8_t { return target; });
AddressRecorder<std::span<const uint8_t>> sent(action.get_sent_trigger());
AddressRecorder<std::span<const uint8_t>, std::span<const uint8_t>> answered(action.get_response_trigger());
AddressRecorder<std::span<const uint8_t>, ExceptionCode> failed(action.get_error_trigger());
AddressRecorder<std::span<const uint8_t>> unanswered(action.get_no_response_trigger());
AddressRecorder<std::span<const uint8_t>> dropped(action.get_not_sent_trigger());
const uint8_t exception_response[] = {0x83, 0x02};
auto drop = [&hub](uint8_t address) {
hub.clear_tx_queue_for_address(address);
hub.sweep_for_test();
};
for (target = 0x02; target <= 0x08; target++)
action.play_complex(); // the stamp ends at 0x08, which no handler below may report
hub.send_next_for_test();
drop(0x05);
hub.receive_frame_for_test(0x02, OK_RESPONSE);
hub.send_next_for_test();
drop(0x06);
hub.receive_frame_for_test(0x03, exception_response);
hub.send_next_for_test();
drop(0x07);
hub.timeout_waiting();
EXPECT_EQ(sent.addresses, (std::vector<uint8_t>{0x02, 0x03, 0x04}));
EXPECT_EQ(answered.addresses, std::vector<uint8_t>{0x02});
EXPECT_EQ(failed.addresses, std::vector<uint8_t>{0x03});
EXPECT_EQ(unanswered.addresses, std::vector<uint8_t>{0x04});
EXPECT_EQ(dropped.addresses, (std::vector<uint8_t>{0x05, 0x06, 0x07}));
}
// A full queue refuses the send at the door: on_not_sent reports the address of the refused request, also
// while the action's address moves from one play to the next.
TEST(ModbusClientHubRequestAddress, FullQueueReportsTheRefusedRequestsAddress) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
modbus_client::ModbusClientSendAction<> action;
action.set_parent(&hub);
action.set_pdu([]() { return read_pdu(); });
static uint8_t target;
action.set_target_address([]() -> uint8_t { return target; });
AddressRecorder<std::span<const uint8_t>> dropped(action.get_not_sent_trigger());
for (target = 1; target <= MODBUS_TX_BUFFER_SIZE; target++)
action.play_complex(); // one entry per address fills the queue
ASSERT_TRUE(dropped.addresses.empty());
target = 200;
action.play_complex();
target = 201;
action.play_complex();
EXPECT_EQ(dropped.addresses, (std::vector<uint8_t>{200, 201}));
}
// The on_no_response handler re-plays the action at 0x03 and the hub refuses it (empty PDU), which reports
// 0x03 to on_not_sent. The retry lambda that runs after the handler still gets 0x02, the timed-out request.
TEST(ModbusClientHubRequestAddress, RetryGetsTheTimedOutRequestsAddress) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
modbus_client::ModbusClientSendAction<> action;
action.set_parent(&hub);
action.set_pdu([]() { return read_pdu(); });
action.set_target_address([]() -> uint8_t { return 0x02; });
static uint8_t retry_address;
action.set_retry([](uint8_t address, std::span<const uint8_t>) {
retry_address = address;
return false;
});
LambdaAction<uint8_t, std::span<const uint8_t>> replay{[&action](uint8_t, std::span<const uint8_t>) {
action.set_target_address([]() -> uint8_t { return 0x03; });
action.set_pdu([]() { return helpers::PduBuffer{}; });
action.play_complex();
}};
Automation<uint8_t, std::span<const uint8_t>> on_no_response(action.get_no_response_trigger());
on_no_response.add_action(&replay);
AddressRecorder<std::span<const uint8_t>> dropped(action.get_not_sent_trigger());
action.play_complex();
hub.send_next_for_test();
hub.timeout_waiting();
EXPECT_EQ(dropped.addresses, std::vector<uint8_t>{0x03});
EXPECT_EQ(retry_address, 0x02);
}
} // namespace esphome::modbus::testing
+8 -9
View File
@@ -1,7 +1,6 @@
# The modbus_client actions are self-contained hub devices: each takes the hub (auto-resolved when there
# is a single modbus client hub) and a templatable device address; no component block is needed. The
# shared handlers (on_sent, on_error, on_no_response, on_not_sent) and send's on_response get `address`
# first: the address of their own request, even when the template has moved on.
# address is not passed back to reply handlers - recompute the configured expression if needed.
# The hub does not bound retries, so a retry lambda must (here: a counter capped at 3), or a dead
# device is retried forever. Reset the counter before the send or on a terminal outcome (on_response)
# so the cap is per transaction, not per device lifetime. Never reset in on_sent: it fires again on
@@ -48,7 +47,7 @@ button:
# on_no_response lambda form: return true to retry. `request` is the timed-out PDU.
on_no_response: !lambda "return !request.empty() && request[0] == 0x03 && id(read_retries)++ < 3;"
# Per-send inline reply handlers (fire-and-continue): they run when this send's outcome is known;
# `address` is the address this send went to.
# the targeted address is not passed back - recompute the configured expression if needed.
# A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result.
- modbus_client.send:
address: 0x01
@@ -59,23 +58,23 @@ button:
pdu: !lambda "return {0x03, 0x00, 0x10, 0x00, 0x01};"
on_sent:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "sent fc 0x%X to %u", request.empty() ? 0 : request[0], address);'
- lambda: 'ESP_LOGI("modbus_client.test", "sent fc 0x%X", request.empty() ? 0 : request[0]);'
on_response:
then:
- lambda: |-
id(combined_retries) = 0;
ESP_LOGI("modbus_client.test", "device %u: got %d bytes", address, (int) response.size());
ESP_LOGI("modbus_client.test", "got %d bytes", (int) response.size());
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
# on_no_response combined form: run actions on timeout AND decide the retry via nested retry:.
on_no_response:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "no reply from %u for fc 0x%X", address, request.empty() ? 0 : request[0]);'
retry: !lambda "return address != 0 && !request.empty() && request[0] == 0x03 && id(combined_retries)++ < 3;"
- lambda: 'ESP_LOGW("modbus_client.test", "no reply for fc 0x%X", request.empty() ? 0 : request[0]);'
retry: !lambda "return !request.empty() && request[0] == 0x03 && id(combined_retries)++ < 3;"
on_not_sent:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "not sent to %u: fc 0x%X", address, request.empty() ? 0 : request[0]);'
- lambda: 'ESP_LOGW("modbus_client.test", "not sent fc 0x%X", request.empty() ? 0 : request[0]);'
- platform: template
name: "Typed Actions"
on_press:
@@ -101,7 +100,7 @@ button:
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
on_no_response:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "typed read timeout from %u", address);'
- logger.log: "typed read timeout"
- modbus_client.read_input_registers:
continuous: !lambda "return false;"
address: 0x01
@@ -42,7 +42,6 @@ image:
format: PNG
type: RGB
transparency: chroma_key
double_buffer: true
- platform: online_image
id: online_rgb111_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
@@ -1,5 +1,3 @@
esp32_ble_tracker:
ble_client:
- mac_address: 01:02:03:04:05:06
id: radon_eye_blec
@@ -1,4 +1,3 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
<<: !include common.yaml
radon_eye_rd200: !include common.yaml
@@ -0,0 +1,7 @@
# The neutral node interface: the BTstack backend and rp2040_ble come in
# through bluetooth_connection's auto-load; the tracker hub supplies the
# sightings.
packages:
radon_eye_rd200: !include common.yaml
rp2_ble_tracker:
-1
View File
@@ -23,7 +23,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
"pioneer_wyt",
"drayton",
"panasonic",
"cardin_s438",
):
request_protocol(protocol)
# receiver_dumpers_test.cpp registers dumpers by hand
@@ -1,186 +0,0 @@
#include <array>
#include <gtest/gtest.h>
#include "esphome/components/remote_base/cardin_s438_protocol.h"
namespace esphome::remote_base::testing {
namespace {
constexpr int32_t SYNC_MARK_US = 6400;
constexpr int32_t SHORT_US = 750;
constexpr int32_t LONG_US = 1460;
constexpr int32_t FRAME_GAP_US = 63650;
constexpr size_t FRAME_TIMINGS_SIZE = 1 + 5 * 8 * 2 + 1;
// Captured frame: FA C3 EC A9 02, seed 0x65.
constexpr std::array<uint8_t, 5> CAPTURED_FRAME = {0xFA, 0xC3, 0xEC, 0xA9, 0x02};
// Reference frame for code 0x11FF, channel A, seed 0x65.
constexpr std::array<uint8_t, 5> REFERENCE_FRAME = {0xC9, 0x05, 0xEC, 0xBA, 0x01};
RawTimings encode(const CardinS438Data &data) {
RemoteTransmitData tx;
CardinS438Protocol().encode(&tx, data);
return tx.get_data();
}
optional<CardinS438Data> decode(const RawTimings &timings) {
return CardinS438Protocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE));
}
RawTimings frame_timings(const std::array<uint8_t, 5> &wire) {
RawTimings timings{SYNC_MARK_US};
for (uint8_t byte : wire) {
for (uint8_t bit = 0; bit < 8; ++bit) {
if ((byte >> bit) & 1U) {
timings.insert(timings.end(), {-SHORT_US, LONG_US});
} else {
timings.insert(timings.end(), {-LONG_US, SHORT_US});
}
}
}
timings.push_back(-FRAME_GAP_US);
return timings;
}
} // namespace
TEST(CardinS438ProtocolTest, DecodesCapturedFrame) {
auto decoded = decode(frame_timings(CAPTURED_FRAME));
ASSERT_TRUE(decoded.has_value());
ASSERT_EQ(decoded->seed, 0x65);
EXPECT_EQ(decoded->code, 0x2E12);
EXPECT_EQ(decoded->channel, 0x02);
}
TEST(CardinS438ProtocolTest, DecodesReferenceFrame) {
auto decoded = decode(frame_timings(REFERENCE_FRAME));
ASSERT_TRUE(decoded.has_value());
EXPECT_EQ(decoded->code, 0x11FF);
EXPECT_EQ(decoded->channel, 0x02);
EXPECT_EQ(decoded->seed, 0x65);
}
TEST(CardinS438ProtocolTest, RoundTrips) {
for (uint16_t code : {0x0000, 0x0001, 0x11FF, 0x2AAA, 0x3FFF}) {
for (uint8_t channel = 0; channel < 4; ++channel) {
CardinS438Data sent{};
sent.code = code;
sent.channel = channel;
sent.repeats = 1;
const RawTimings timings = encode(sent);
ASSERT_EQ(timings.size(), FRAME_TIMINGS_SIZE);
auto decoded = decode(timings);
ASSERT_TRUE(decoded.has_value());
EXPECT_TRUE(*decoded == sent);
}
}
}
TEST(CardinS438ProtocolTest, RejectsBadChecksum) {
auto wire = CAPTURED_FRAME;
wire[3] ^= 1;
EXPECT_FALSE(decode(frame_timings(wire)).has_value());
}
TEST(CardinS438ProtocolTest, SkipsLeadingNoise) {
RawTimings timings{300, -1200, 150, -90};
const RawTimings frame = frame_timings(CAPTURED_FRAME);
timings.insert(timings.end(), frame.begin(), frame.end());
auto decoded = decode(timings);
ASSERT_TRUE(decoded.has_value());
EXPECT_EQ(decoded->code, 0x2E12);
}
TEST(CardinS438ProtocolTest, FindsValidFrameAfterBadChecksum) {
auto bad_wire = CAPTURED_FRAME;
bad_wire[3] ^= 1;
RawTimings timings = frame_timings(bad_wire);
const RawTimings good = frame_timings(CAPTURED_FRAME);
timings.insert(timings.end(), good.begin(), good.end());
EXPECT_TRUE(decode(timings).has_value());
}
TEST(CardinS438ProtocolTest, RejectsBadStart) {
RawTimings timings = frame_timings(CAPTURED_FRAME);
timings[0] = 2000;
EXPECT_FALSE(decode(timings).has_value());
}
TEST(CardinS438ProtocolTest, RejectsTruncatedFrame) {
RawTimings timings = frame_timings(CAPTURED_FRAME);
timings.resize(timings.size() - 6);
EXPECT_FALSE(decode(timings).has_value());
}
TEST(CardinS438ProtocolTest, RejectsBadBitTiming) {
RawTimings timings = frame_timings(CAPTURED_FRAME);
timings[1] = -2000;
EXPECT_FALSE(decode(timings).has_value());
}
TEST(CardinS438ProtocolTest, DecodesSeedCarryBoundaries) {
struct SeedVector {
uint8_t seed;
std::array<uint8_t, 5> wire;
};
// All vectors encode code=0x11FF, channel A.
// 0x78: marker sum is 0xFF; second carry occurs.
// 0x79: marker and second carry both occur.
// 0xFF: marker carry wraps the key to zero.
constexpr SeedVector frames[] = {
{0x78, {0xDC, 0x18, 0xFF, 0xF3, 0x01}},
{0x79, {0xDE, 0x1A, 0x00, 0xF8, 0x00}},
{0xFF, {0x63, 0xA0, 0x86, 0x89, 0x01}},
};
for (const auto &frame : frames) {
const auto decoded = decode(frame_timings(frame.wire));
ASSERT_TRUE(decoded.has_value()) << "seed=0x" << std::hex << unsigned(frame.seed);
EXPECT_EQ(decoded->code, 0x11FF);
EXPECT_EQ(decoded->channel, 0x02);
EXPECT_EQ(decoded->seed, frame.seed);
}
}
TEST(CardinS438ProtocolTest, EncodesRequestedFrameCount) {
for (uint8_t repeats : {1, 3, 5}) {
CardinS438Data sent{};
sent.code = 0x11FF;
sent.channel = 0x02;
sent.repeats = repeats;
const RawTimings timings = encode(sent);
ASSERT_EQ(timings.size(), FRAME_TIMINGS_SIZE * repeats);
// Check each independently seeded frame, without assuming seeds differ.
for (size_t i = 0; i < repeats; ++i) {
const auto first = timings.begin() + i * FRAME_TIMINGS_SIZE;
const RawTimings frame(first, first + FRAME_TIMINGS_SIZE);
const auto decoded = decode(frame);
ASSERT_TRUE(decoded.has_value()) << "frame=" << i;
EXPECT_EQ(decoded->code, sent.code);
EXPECT_EQ(decoded->channel, sent.channel);
}
}
}
} // namespace esphome::remote_base::testing
@@ -207,8 +207,3 @@ on_ira211:
- logger.log:
format: "on_ira211: %u %.1f"
args: ["static_cast<uint8_t>(x.command)", "x.get_temperature()"]
on_cardin_s438:
then:
- logger.log:
format: "on_cardin_s438: %u %u"
args: ["x.code", "x.channel"]
@@ -564,12 +564,6 @@ button:
on_press:
- remote_transmitter.transmit_pd_pioneer:
code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80]
- platform: template
name: Cardin S438
on_press:
remote_transmitter.transmit_cardin_s438:
code: "01001001110100"
channel: B
- platform: template
name: Repeat on every protocol
on_press:
@@ -619,12 +613,6 @@ button:
repeat:
times: 2
wait_time: 10ms
- remote_transmitter.transmit_cardin_s438:
code: "01001001110100"
channel: B
repeat:
times: 2
wait_time: 10ms
- remote_transmitter.transmit_coolix:
first: 0xB2BF40
repeat:
@@ -907,10 +895,6 @@ button:
device: !lambda return 0x25;
address: !lambda return 0x01;
command: 0x1C
- remote_transmitter.transmit_cardin_s438:
code: !lambda return 0x1274;
channel: !lambda return 1;
command_repeats: !lambda return 3;
- remote_transmitter.transmit_coolix:
first: !lambda return 0xB2BF40;
second: !lambda return 0xB2BF40;
@@ -1,5 +1,3 @@
import esphome.codegen as cg
from esphome.components.lvgl import LVGL_VERSION
from esphome.components.runtime_image import enable_format
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
@@ -14,11 +12,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
enable_format("JPEG")
enable_format("PNG")
enable_format("QOI")
# The LVGL image descriptor is tested too, so build with the LVGL library
cg.add_library("lvgl/lvgl", LVGL_VERSION)
cg.add_build_flag("-DLV_CONF_SKIP=1")
cg.add_define("USE_LVGL")
cg.add_define("USE_LVGL_IMAGE")
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + ["lvgl"]
@@ -1,146 +0,0 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::testing {
// 3x2 24bpp BMP (rows padded to 4 bytes)
static const uint8_t BMP_3X2[] = {
0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00,
0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x22, 0x11, 0x77, 0x88, 0x99, 0xEF, 0xCD, 0xAB, 0x00,
0x00, 0x00, 0x20, 0x10, 0xE0, 0x40, 0xC0, 0x30, 0xA0, 0x60, 0x50, 0x00, 0x00, 0x00,
};
// 2x1 24bpp BMP (row padded to 8 bytes)
static const uint8_t BMP_2X1[] = {
0x42, 0x4D, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00,
0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x02, 0x01, 0x06, 0x05, 0x04, 0x00, 0x00,
};
template<size_t N> static void load(RuntimeImage &img, const uint8_t (&bmp)[N]) {
std::vector<uint8_t> data(bmp, bmp + N);
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
}
class DoubleBufferedImage : public RuntimeImage {
public:
DoubleBufferedImage() : RuntimeImage(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE) {
this->set_double_buffer(true);
}
};
// The image on show is not decoded over: the new one appears only when it is complete
TEST(RuntimeImageDoubleBuffer, ImageOnShowStaysUntilTheNextIsDecoded) {
DoubleBufferedImage img;
load(img, BMP_3X2);
const uint8_t *first = img.get_data_start();
ASSERT_NE(first, nullptr);
const std::vector<uint8_t> shown(first, first + 3 * 2 * 3);
std::vector<uint8_t> data(BMP_2X1, BMP_2X1 + sizeof(BMP_2X1));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
EXPECT_EQ(img.get_data_start(), first);
EXPECT_EQ(img.get_width(), 3);
EXPECT_EQ(img.get_height(), 2);
EXPECT_EQ(std::vector<uint8_t>(first, first + shown.size()), shown);
ASSERT_TRUE(img.end_decode());
EXPECT_NE(img.get_data_start(), first);
EXPECT_EQ(img.get_width(), 2);
EXPECT_EQ(img.get_height(), 1);
}
// Images of one size take turns in the same two buffers
TEST(RuntimeImageDoubleBuffer, SameSizeImagesReuseBothBuffers) {
DoubleBufferedImage img;
load(img, BMP_3X2);
const uint8_t *first = img.get_data_start();
load(img, BMP_3X2);
const uint8_t *second = img.get_data_start();
EXPECT_NE(second, first);
load(img, BMP_3X2);
EXPECT_EQ(img.get_data_start(), first);
load(img, BMP_3X2);
EXPECT_EQ(img.get_data_start(), second);
}
TEST(RuntimeImageDoubleBuffer, AbortedDecodeLeavesTheImageOnShow) {
DoubleBufferedImage img;
load(img, BMP_3X2);
const uint8_t *first = img.get_data_start();
std::vector<uint8_t> data(BMP_2X1, BMP_2X1 + sizeof(BMP_2X1));
ASSERT_TRUE(img.begin_decode(data.size()));
img.feed_data(data.data(), data.size() / 2 + 8);
img.abort_decode();
EXPECT_FALSE(img.is_decoding());
EXPECT_EQ(img.get_data_start(), first);
EXPECT_EQ(img.get_width(), 3);
EXPECT_TRUE(img.is_loaded());
load(img, BMP_2X1);
EXPECT_EQ(img.get_width(), 2);
}
// An aborted first decode leaves nothing on show
TEST(RuntimeImageDoubleBuffer, AbortedFirstDecodeIsNotLoaded) {
DoubleBufferedImage img;
std::vector<uint8_t> data(BMP_3X2, BMP_3X2 + sizeof(BMP_3X2));
ASSERT_TRUE(img.begin_decode(data.size()));
img.feed_data(data.data(), data.size() / 2 + 8);
img.abort_decode();
EXPECT_EQ(img.get_data_start(), nullptr);
EXPECT_FALSE(img.is_loaded());
}
// Without double buffering an aborted decode has drawn over the image, so it goes
TEST(RuntimeImageDoubleBuffer, AbortedDecodeWithOneBufferReleasesTheImage) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
load(img, BMP_3X2);
std::vector<uint8_t> data(BMP_3X2, BMP_3X2 + sizeof(BMP_3X2));
ASSERT_TRUE(img.begin_decode(data.size()));
img.feed_data(data.data(), data.size() / 2 + 8);
img.abort_decode();
EXPECT_EQ(img.get_data_start(), nullptr);
EXPECT_FALSE(img.is_loaded());
}
// Both buffers are freed; LeakSanitizer reports one that is not
TEST(RuntimeImageDoubleBuffer, ReleaseFreesBothBuffers) {
DoubleBufferedImage img;
load(img, BMP_3X2);
load(img, BMP_3X2);
img.release();
EXPECT_EQ(img.get_data_start(), nullptr);
EXPECT_EQ(img.get_width(), 0);
EXPECT_FALSE(img.is_loaded());
}
TEST(RuntimeImageDoubleBuffer, ExternalBufferIsRefused) {
DoubleBufferedImage img;
std::vector<uint8_t> buffer(img.get_buffer_size(3, 2));
EXPECT_FALSE(img.set_external_buffer(buffer.data(), 3, 2));
load(img, BMP_3X2);
EXPECT_NE(img.get_data_start(), buffer.data());
}
// Progressive display turns double buffering off, so an external buffer is allowed
TEST(RuntimeImageDoubleBuffer, ExternalBufferIsAllowedWithProgressiveDisplay) {
DoubleBufferedImage img;
img.set_progressive_display(true);
std::vector<uint8_t> buffer(img.get_buffer_size(3, 2));
EXPECT_TRUE(img.set_external_buffer(buffer.data(), 3, 2));
img.release();
}
} // namespace esphome::runtime_image::testing
@@ -1,39 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/runtime_image/jpeg_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
#ifdef USE_RUNTIME_IMAGE_JPEG
namespace esphome::runtime_image::testing {
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state is handed back before the base
// destructor runs, so the teardown stays with JpegDecoder.
class StatelessJpegDecoder : public JpegDecoder {
public:
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(this->jpeg_) {
this->jpeg_ = nullptr;
}
~StatelessJpegDecoder() override { this->jpeg_ = this->state_; }
protected:
JPEGDEC *state_;
};
// The ~18 KB of JPEGDEC state lives in its own allocation, so it can come from PSRAM
TEST(JpegOutOfMemory, DecoderStateIsAllocatedSeparately) { EXPECT_LT(sizeof(JpegDecoder), sizeof(JPEGDEC) / 4); }
TEST(JpegOutOfMemory, MissingStateFailsWithoutStartingASession) {
RuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);
StatelessJpegDecoder decoder(&img);
EXPECT_EQ(decoder.prepare(100), DECODE_ERROR_OUT_OF_MEMORY);
EXPECT_FALSE(decoder.is_active());
uint8_t data[4] = {};
EXPECT_EQ(decoder.decode(data, sizeof(data)), DECODE_ERROR_OUT_OF_MEMORY);
EXPECT_EQ(decoder.begin_source(nullptr), DECODE_ERROR_OUT_OF_MEMORY);
decoder.reset();
}
} // namespace esphome::runtime_image::testing
#endif // USE_RUNTIME_IMAGE_JPEG
@@ -1,217 +0,0 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/runtime_image/runtime_image.h"
#ifdef USE_LVGL
namespace esphome::runtime_image::testing {
// 3x2 24bpp BMP (rows padded to 4 bytes)
static const uint8_t BMP_24BPP[] = {
0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00,
0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x22, 0x11, 0x77, 0x88, 0x99, 0xEF, 0xCD, 0xAB, 0x00,
0x00, 0x00, 0x20, 0x10, 0xE0, 0x40, 0xC0, 0x30, 0xA0, 0x60, 0x50, 0x00, 0x00, 0x00,
};
// A widget keeps the descriptor it was given and reads it on every draw. Once the image lets go of its pixels,
// for example when it starts loading another image, the descriptor must still be one LVGL can draw.
TEST(RuntimeImageLvgl, ReleasedImageLeavesADrawableDescriptor) {
lv_init();
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
std::vector<uint8_t> data(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
lv_image_dsc_t *dsc = img.get_lv_image_dsc();
lv_image_header_t header;
ASSERT_EQ(lv_image_decoder_get_info(dsc, &header), LV_RESULT_OK);
EXPECT_EQ(header.w, 3);
EXPECT_EQ(header.h, 2);
img.release();
EXPECT_EQ(lv_image_decoder_get_info(dsc, &header), LV_RESULT_OK);
EXPECT_EQ(header.w, 1);
EXPECT_EQ(header.h, 1);
lv_deinit();
}
// Before the first image is loaded, a widget gets a descriptor it can draw too
TEST(RuntimeImageLvgl, UnloadedImageHasADrawableDescriptor) {
lv_init();
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
lv_image_header_t header;
ASSERT_EQ(lv_image_decoder_get_info(img.get_lv_image_dsc(), &header), LV_RESULT_OK);
EXPECT_EQ(header.w, 1);
EXPECT_EQ(header.h, 1);
lv_deinit();
}
// With a placeholder, a widget shows it before the first image and while the next one loads
TEST(RuntimeImageLvgl, PlaceholderIsShownWithoutAnImage) {
lv_init();
static constexpr uint8_t PLACEHOLDER_PIXELS[4 * 2 * 3] = {};
image::Image placeholder(PLACEHOLDER_PIXELS, 4, 2, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, &placeholder);
lv_image_dsc_t *dsc = img.get_lv_image_dsc();
EXPECT_EQ(dsc->data, PLACEHOLDER_PIXELS);
lv_image_header_t header;
ASSERT_EQ(lv_image_decoder_get_info(dsc, &header), LV_RESULT_OK);
EXPECT_EQ(header.w, 4);
EXPECT_EQ(header.h, 2);
std::vector<uint8_t> data(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
EXPECT_EQ(img.get_lv_image_dsc(), dsc);
EXPECT_EQ(dsc->data, img.get_data_start());
EXPECT_EQ(dsc->header.w, 3);
img.release();
EXPECT_EQ(dsc->data, PLACEHOLDER_PIXELS);
EXPECT_EQ(dsc->header.w, 4);
EXPECT_EQ(dsc->header.h, 2);
lv_deinit();
}
#ifdef USE_LVGL_IMAGE
static constexpr int32_t SCREEN_WIDTH = 16;
static constexpr int32_t SCREEN_HEIGHT = 8;
static void flush_ready(lv_display_t *display, const lv_area_t *area, uint8_t *px_map) {
lv_display_flush_ready(display);
}
// A display to draw on, so ASan sees any read LVGL makes past an image's pixels
class RuntimeImageLvglWidget : public ::testing::Test {
protected:
void SetUp() override {
lv_init();
this->display_ = lv_display_create(SCREEN_WIDTH, SCREEN_HEIGHT);
lv_display_set_flush_cb(this->display_, flush_ready);
lv_display_set_buffers(this->display_, this->draw_buffer_.data(), nullptr, this->draw_buffer_.size(),
LV_DISPLAY_RENDER_MODE_FULL);
this->widget_ = lv_image_create(lv_screen_active());
}
void TearDown() override { lv_deinit(); }
// What the lvgl component's lv_image_set_src() overload does
void set_src_(image::Image *image) {
::lv_image_set_src(this->widget_, image->get_lv_image_dsc());
image->add_lvgl_widget(this->widget_);
}
static void load(RuntimeImage &img) {
std::vector<uint8_t> data(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
}
// Redraw the widget, as something drawn over it would make LVGL do during a load
void draw_() {
lv_obj_invalidate(this->widget_);
lv_refr_now(this->display_);
}
lv_display_t *display_{};
lv_obj_t *widget_{};
std::vector<uint8_t> draw_buffer_ = std::vector<uint8_t>(SCREEN_WIDTH * SCREEN_HEIGHT * 4);
};
// The widget keeps the size it read when given the descriptor, and draws that much of whatever the descriptor holds.
// The image gives the widget its source again whenever what it shows changes size.
TEST_F(RuntimeImageLvglWidget, WidgetFollowsTheImageSize) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
this->set_src_(&img);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 1);
load(img);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
EXPECT_EQ(lv_image_get_src_height(this->widget_), 2);
this->draw_();
img.release();
EXPECT_EQ(lv_image_get_src_width(this->widget_), 1);
EXPECT_EQ(lv_image_get_src_height(this->widget_), 1);
this->draw_();
load(img);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
this->draw_();
}
// The placeholder is smaller than the image, and on the heap, so drawing it at the image's size trips ASan
TEST_F(RuntimeImageLvglWidget, WidgetFollowsThePlaceholderSize) {
std::vector<uint8_t> placeholder_pixels(2 * 1 * 3);
image::Image placeholder(placeholder_pixels.data(), 2, 1, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, &placeholder);
load(img);
this->set_src_(&img);
this->draw_();
img.release();
EXPECT_EQ(lv_image_get_src_width(this->widget_), 2);
EXPECT_EQ(lv_image_get_src_height(this->widget_), 1);
this->draw_();
}
// A widget given another image is left alone, however many times it was given this one
TEST_F(RuntimeImageLvglWidget, WidgetGivenAnotherImageIsLeftAlone) {
static constexpr uint8_t OTHER_PIXELS[4 * 5 * 3] = {};
image::Image other(OTHER_PIXELS, 4, 5, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
load(img);
this->set_src_(&img);
this->set_src_(&img);
this->set_src_(&other);
img.release();
EXPECT_EQ(lv_image_get_src(this->widget_), other.get_lv_image_dsc());
EXPECT_EQ(lv_image_get_src_width(this->widget_), 4);
// Given back, it is followed again
this->set_src_(&img);
load(img);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
}
// With double buffering the widget shows the old image while the new one decodes, then the new one at its size
TEST_F(RuntimeImageLvglWidget, DoubleBufferedWidgetKeepsTheImageWhileDecoding) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
img.set_double_buffer(true);
load(img);
this->set_src_(&img);
const void *first = static_cast<const lv_image_dsc_t *>(lv_image_get_src(this->widget_))->data;
std::vector<uint8_t> data(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
EXPECT_EQ(static_cast<const lv_image_dsc_t *>(lv_image_get_src(this->widget_))->data, first);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
this->draw_();
ASSERT_TRUE(img.end_decode());
EXPECT_NE(static_cast<const lv_image_dsc_t *>(lv_image_get_src(this->widget_))->data, first);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
this->draw_();
}
TEST_F(RuntimeImageLvglWidget, DeletedWidgetIsForgotten) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
load(img);
this->set_src_(&img);
lv_obj_delete(this->widget_);
img.release();
}
#endif // USE_LVGL_IMAGE
} // namespace esphome::runtime_image::testing
#endif // USE_LVGL
@@ -1,46 +0,0 @@
packages:
sendspin: !include common.yaml
microphone:
- platform: i2s_audio
id: sendspin_mic_id
i2s_audio_id: i2s_audio_bus
i2s_din_pin: ${i2s_din_pin}
adc_type: external
channel: stereo
sample_rate: 48000
bits_per_sample: 16bit
sendspin:
source:
microphone:
microphone: sendspin_mic_id
channels: [0, 1]
bits_per_sample: 16
gain_factor: 2
codec: opus
chunk_duration: 10ms
buffer_duration: 200ms
buffer_memory: internal
opus_bitrate: 96kbps
opus_complexity: 3
task_stack_in_psram: true
on_streaming_started:
then:
- logger.log: "Source streaming started"
- sendspin.source.set_signal:
present: true
on_streaming_stopped:
then:
- logger.log: "Source streaming stopped"
- sendspin.source.set_signal:
present: !lambda return false;
interval:
- interval: 10s
then:
- if:
condition:
sendspin.source.is_streaming:
then:
- logger.log: "Source is streaming"
@@ -1,6 +0,0 @@
substitutions:
i2s_din_pin: GPIO39
packages:
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
sendspin: !include common-source.yaml
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more