Compare commits

..
Author SHA1 Message Date
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 2016884210 [copy] Follow the source with plain sensors and text sensors (#21610) 2026-10-25 07:52:01 -10:00
J. Nick Koston d3f4f44c7b [core] Add follow helpers for plain sensors and text sensors (#21609) 2026-10-25 07:50:58 -10:00
Jonathan Swoboda 6df15be714 [tests] Cover remote, modbus and bus action options (#21616) 2026-10-25 07:50:12 -10:00
Jonathan Swoboda a03fe3c38e [tests] Cover network and scripting action options (#21615) 2026-10-25 07:49:52 -10:00
Jonathan Swoboda afc8c51f93 [core] Keep components that failed before setup out of the loop (#21622) 2026-10-25 07:49:32 -10:00
Jonathan Swoboda ef7ed69f9d [lightwaverf] Stop requiring the unused name on send_raw (#21620) 2026-10-25 07:48:57 -10:00
Jonathan Swoboda ec32802662 [core] Avoid division by zero in Color::random_color() (#21623) 2026-10-25 07:48:34 -10:00
Jonathan Swoboda 433d8046b1 [espota2] Stop waiting when the device closes mid-reply (#21624) 2026-10-25 07:48:07 -10:00
Jonathan Swoboda 8ecb981656 [tests] Cover device and lvgl action options (#21617) 2026-10-25 07:47:36 -10:00
Jonathan Swoboda 8c990573d7 [core] Generate valid C++ for infinite float values (#21625) 2026-10-25 07:47:24 -10:00
J. Nick Koston bdec0c1485 [copy] Add an integration test for the sensor, binary sensor and text sensor platforms (#21608) 2026-10-25 10:18:37 -04:00
Jonathan Swobodaandclydebarrow 70e85d939d [lvgl] Make canvas gradient and dashed line options compile (#21580)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-10-25 08:59:32 -04:00
Clyde Stubbs 25b3c2820a [core][display] Let users take over a display's enable pins (#21605) 2026-10-25 20:47:50 +11:00
Jonathan Swoboda ef53e675ff [lvgl] Apply opa on indicator.update (#21579) 2026-10-24 21:43:38 -10:00
Anton 10b569491d [nextion] Add tft_upload_http_buffer_size_tx option for TFT upload redirects (#21435) 2026-10-24 19:51:43 -10:00
Bogdan Rădulescu 71b1b58e8d [ble_midi] Add BLE MIDI component (#18508) 2026-10-24 19:04:41 -10:00
Rodrigo Martín 4ab67d867f [mqtt] fix/implement session persistence (#12426) 2026-10-24 19:02:01 -10:00
a2a46944ab [climate][water_heater] Add unit_of_measurement to the template climate and water heater (#20759)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-24 16:16:46 -10:00
537d1643cb [delta_wallbox] Added ability to set the max current (#21587)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-24 16:15:11 -10:00
07744bb53e [adafruit_ble][nrf52][zephyr_ble_server] Initial adafruit ble dfu ota support (#20481)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-24 16:03:16 -10:00
c5d69ce22b [wifi] Add ap_mode: ALWAYS_ON and allow a captive portal with ap_mode: WISP (#20077)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-24 16:02:45 -10:00
21fb25a45f [rx8111] Add backup, FOUT, and EVIN events (#21490)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-24 15:10:44 -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 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 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 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 955afc4847 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-10-13 10:12:14 -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 9f254c69a1 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-10-06 21:32:51 -10: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 0801211ba1 Merge remote-tracking branch 'upstream/dev' into HEAD 2026-09-17 08:48:59 -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 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 808afc024d Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-09-02 11:34:17 +02: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 90af765fae Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-08-24 10:27:17 -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 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 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 5c6e62ffe0 Pin the neutral cap branch a future hub platform will take 2026-08-12 19:28:21 -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 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 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 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 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 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 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 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 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 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 ba10832ed7 Tighten the bridge comments to repo style 2026-08-12 17:09:37 -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 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 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 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 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 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
246 changed files with 12262 additions and 1000 deletions

No files matched your search

+3
View File
@@ -16,6 +16,8 @@ esphome/components/a01nyub/* @MrSuicideParrot
esphome/components/a02yyuw/* @TH-Braemer
esphome/components/absolute_humidity/* @DAVe3283
esphome/components/ac_dimmer/* @glmnet
esphome/components/adafruit_ble/* @omersiar
esphome/components/adafruit_ble/ota/* @omersiar
esphome/components/adc/* @esphome/core
esphome/components/adc128s102/* @DeerMaximum
esphome/components/addressable_light/* @justfalter
@@ -86,6 +88,7 @@ esphome/components/bl0940/* @dan-s-github @tobias-
esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow
esphome/components/ble_device_base/* @Bl00d-B0b
esphome/components/ble_midi/* @bogdanr
esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_connection/* @bdraco @jesserockz
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
+3 -2
View File
@@ -348,8 +348,9 @@ class ApplyField:
Double a literal brace in a template. ``conf_key`` may be a path into nested sections.
``type_`` may be a C++ type string using ``{parent}`` when the type is only known per
instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp``
is not the right spelling (unit conversion belongs in the validator); a lambda or an id
bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing.
is not the right spelling, or the value depends on another entity's config, such as the unit
a climate reports in; a lambda or an id bypasses it, so the target must also take a plain
``type_``. An absent key emits nothing.
"""
conf_key: str | tuple[str, ...]
@@ -0,0 +1,2 @@
CODEOWNERS = ["@omersiar"]
DOMAIN = "adafruit_ble"
+603
View File
@@ -0,0 +1,603 @@
"""Host-side Adafruit nRF52 bootloader BLE OTA (Nordic legacy DFU).
The Adafruit bootloader only serves its DFU service while it is in DFU mode, so
an update has two steps: ask the running application to reboot into the
bootloader (the trigger service built into the ``adafruit_ble`` OTA component),
then push the DFU package to the bootloader itself.
Reference implementation of the protocol:
https://github.com/recrof/nrf_dfu_py/blob/main/dfu_lib.py
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
import json
import logging
from pathlib import Path
import struct
import zipfile
from bleak import BleakClient, BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.exc import (
BleakDBusError,
BleakDeviceNotFoundError,
BleakError,
BleakGATTProtocolError,
BleakGATTProtocolErrorCode,
)
from esphome.components.nrf52.ble_logger import MAC_ADDRESS_PATTERN
from esphome.core import EsphomeError
from esphome.espota2 import ProgressBar
_LOGGER = logging.getLogger(__name__)
# Nordic legacy DFU service, used by the Adafruit nRF52 bootloader.
DFU_SERVICE_UUID = "00001530-1212-efde-1523-785feabcd123"
DFU_CONTROL_POINT_UUID = "00001531-1212-efde-1523-785feabcd123"
DFU_PACKET_UUID = "00001532-1212-efde-1523-785feabcd123"
# ESPHome DFU trigger service, exposed by the application (ota_adafruit_ble.cpp).
# Kept distinct from the bootloader's DFU service so the two can be told apart.
TRIGGER_SERVICE_UUID = "e5b10001-9c3a-4f5d-8a1b-2c3d4e5f6071"
TRIGGER_CHARACTERISTIC_UUID = "e5b10002-9c3a-4f5d-8a1b-2c3d4e5f6071"
TRIGGER_MAGIC = b"DFU"
_OP_START_DFU = 0x01
_OP_INIT_DFU_PARAMS = 0x02
_OP_RECEIVE_FIRMWARE_IMAGE = 0x03
_OP_VALIDATE = 0x04
_OP_ACTIVATE_AND_RESET = 0x05
_OP_RESET = 0x06
_OP_PACKET_RECEIPT_NOTIF_REQ = 0x08
_OP_RESPONSE_CODE = 0x10
_OP_PACKET_RECEIPT_NOTIF = 0x11
_UPLOAD_MODE_APPLICATION = 0x04
_RESPONSE_SUCCESS = 0x01
_RESPONSE_INVALID_STATE = 0x02
_RESPONSE_STATUS_NAMES = {
0x02: "invalid state",
0x03: "not supported",
0x04: "data size exceeds limit",
0x05: "CRC error",
0x06: "operation failed",
}
# The bootloader runs out of memory when more than 8 packets are unacknowledged.
_PRN = 8
# The bootloader needs a moment between START_DFU and the image size packet;
# sending the size too early leaves the start command unanswered.
_START_DELAY = 0.6
# A dropped link mid-update is usually transient, so the sequence is retried.
_ATTEMPTS = 3
_RETRY_DELAY = 3.0
# ATT payload cap; the negotiated MTU is normally smaller.
_MAX_CHUNK_SIZE = 244
# The bootloader rejects image sizes and data packets that are not whole words.
_WORD_SIZE = 4
_SCAN_TIMEOUT = 10.0
_RESPONSE_TIMEOUT = 30.0
# Starting the update erases flash and finishing it writes the last pages.
_LONG_RESPONSE_TIMEOUT = 60.0
_RECEIPT_TIMEOUT = 2.0
# Time the bootloader needs to start advertising after the DFU request.
_REBOOT_TIMEOUT = 20.0
# Without MAC addresses (macOS), the bootloader is the one DFU device that was
# not there before the request: the scan that lists those before it, and how
# long to keep watching for a second new one after the first appears.
_DFU_SNAPSHOT_TIMEOUT = 3.0
_NEW_DEVICE_SETTLE = 3.0
# macOS reports a device once per scan session, so a fresh session is needed to
# see whether the application still advertises.
_APP_CHECK_TIMEOUT = 3.0
# Error responses to the DFU mode request that pairing the device would fix.
_PAIRING_ERRORS = {
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHENTICATION,
BleakGATTProtocolErrorCode.INSUFFICIENT_AUTHORIZATION,
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION,
BleakGATTProtocolErrorCode.INSUFFICIENT_ENCRYPTION_KEY_SIZE,
}
BOOTLOADER_HINT = (
"A device left in its bootloader by a failed upload advertises at its MAC "
"address + 1; pass that address with --device (on macOS, the UUID of the "
"DFU device)."
)
class _DfuRejectedError(EsphomeError):
"""The bootloader answered with a failure status; retrying will not help."""
class _DfuStaleStateError(_DfuRejectedError):
"""START found the bootloader mid-update from an interrupted transfer.
The failed START resets the bootloader, so one more attempt can succeed.
"""
def _rejected_error(request_op: int, step: str, status: int) -> _DfuRejectedError:
name = _RESPONSE_STATUS_NAMES.get(status, "unknown")
error = (
_DfuStaleStateError
if request_op == _OP_START_DFU and status == _RESPONSE_INVALID_STATE
else _DfuRejectedError
)
return error(
f"DFU {step} (opcode {request_op:#04x}) rejected by the bootloader "
f"with status {status:#04x} ({name})"
)
class _DfuSession:
"""Control point notifications and packet receipts of one DFU run."""
def __init__(self) -> None:
self._responses: asyncio.Queue[tuple[int, int]] = asyncio.Queue()
self._receipt = asyncio.Event()
self._rejection: tuple[int, int] | None = None
self.receipts = 0
def notification_handler(self, _sender: int, data: bytearray) -> None:
if not data:
_LOGGER.debug("DFU: ignoring an empty notification")
return
opcode = data[0]
if opcode == _OP_RESPONSE_CODE and len(data) >= 3:
self._responses.put_nowait((data[1], data[2]))
if data[2] != _RESPONSE_SUCCESS:
# Wakes a receipt wait, which no receipt would end after a rejection
self._rejection = (data[1], data[2])
self._receipt.set()
elif opcode == _OP_PACKET_RECEIPT_NOTIF:
self.receipts += 1
self._receipt.set()
else:
_LOGGER.debug("DFU: ignoring notification %s", data.hex())
async def expect_success(
self, request_op: int, step: str, timeout: float = _RESPONSE_TIMEOUT
) -> None:
try:
response_op, status = await asyncio.wait_for(self._responses.get(), timeout)
except TimeoutError as err:
raise EsphomeError(
f"DFU timed out waiting for the response to opcode {request_op:#04x}"
) from err
if response_op != request_op:
raise EsphomeError(
f"DFU got a response for opcode {response_op:#04x}, "
f"expected {request_op:#04x}"
)
if status != _RESPONSE_SUCCESS:
raise _rejected_error(request_op, step, status)
def raise_if_rejected(self, step: str) -> None:
"""Raise if the bootloader already answered a command with a failure."""
if self._rejection is not None:
request_op, status = self._rejection
raise _rejected_error(request_op, step, status)
async def wait_for_receipts(self, count: int, step: str) -> None:
"""Wait until ``count`` packet receipts have arrived in total."""
try:
async with asyncio.timeout(_RECEIPT_TIMEOUT):
while self.receipts < count:
self.raise_if_rejected(step)
# Cleared only while the count is short, so no receipt is lost
self._receipt.clear()
await self._receipt.wait()
except TimeoutError as err:
raise EsphomeError(
f"DFU timed out waiting for packet receipt {count}"
) from err
def _service_uuids(advertisement) -> set[str]:
return {uuid.lower() for uuid in (advertisement.service_uuids or [])}
def _next_address(address: str) -> str | None:
"""The Adafruit bootloader can come up one address above the application."""
if not MAC_ADDRESS_PATTERN.match(address):
return None
prefix, last = address[:-2], int(address[-2:], 16)
return f"{prefix}{((last + 1) & 0xFF):02X}"
async def _dfu_devices() -> set[str]:
"""Addresses of the devices advertising the DFU service right now."""
found = await BleakScanner.discover(timeout=_DFU_SNAPSHOT_TIMEOUT, return_adv=True)
return {
device.address
for device, advertisement in found.values()
if DFU_SERVICE_UUID in _service_uuids(advertisement)
}
@dataclass(frozen=True)
class DfuPackage:
"""The application image and init packet of an Adafruit DFU package."""
name: str
image: bytes
init_packet: bytes
def load_firmware(firmware: Path) -> DfuPackage:
"""Read and check a DFU package, before the device is asked to reboot."""
if not firmware.is_file():
raise EsphomeError(
f"DFU package not found: {firmware}. Compile the firmware first."
)
try:
return _read_package(firmware)
except (zipfile.BadZipFile, KeyError, ValueError) as err:
raise EsphomeError(f"Invalid DFU package {firmware}: {err}") from err
def _read_package(firmware: Path) -> DfuPackage:
with zipfile.ZipFile(firmware) as archive:
names = archive.namelist()
if "manifest.json" in names:
bin_file, dat_file = _manifest_files(
firmware, json.loads(archive.read("manifest.json"))
)
else:
bins = [name for name in names if name.endswith(".bin")]
dats = [name for name in names if name.endswith(".dat")]
if len(bins) > 1 or len(dats) > 1:
raise ValueError("more than one .bin or .dat file and no manifest")
if not bins or not dats:
raise EsphomeError(
f"Could not find the application image in {firmware}"
)
bin_file, dat_file = bins[0], dats[0]
image = archive.read(bin_file)
init_packet = archive.read(dat_file)
if not image:
raise EsphomeError(f"Empty application image in {firmware}")
if len(image) % _WORD_SIZE:
raise EsphomeError(
f"The application image in {firmware} is {len(image)} bytes, "
f"which the bootloader rejects as it is not a multiple of {_WORD_SIZE}"
)
return DfuPackage(firmware.name, image, init_packet)
def _manifest_files(firmware: Path, manifest: object) -> tuple[str, str]:
"""The image and init packet names of the application in ``manifest``."""
if isinstance(manifest, dict):
manifest = manifest.get("manifest", manifest)
if not isinstance(manifest, dict):
raise ValueError("manifest.json is not a JSON object")
application = manifest.get("application")
if application is None:
raise EsphomeError(f"{firmware} is not an application update package")
if not isinstance(application, dict):
raise ValueError("the manifest application entry is not a JSON object")
bin_file = application.get("bin_file")
dat_file = application.get("dat_file")
if not isinstance(bin_file, str) or not isinstance(dat_file, str):
raise ValueError("the manifest names no bin_file and dat_file")
return bin_file, dat_file
class _DeviceNotFoundError(EsphomeError):
"""The device did not answer at the address it was asked for."""
def _raise_if_not_paired(address: str, err: Exception) -> None:
if isinstance(err, BleakDBusError) and "NotPermitted" in err.dbus_error:
raise EsphomeError(
f"Cannot connect to {address}: Make sure the device is paired."
) from err
@asynccontextmanager
async def _connect(address: str) -> AsyncIterator[BleakClient]:
client = BleakClient(address, timeout=_SCAN_TIMEOUT)
try:
await client.connect()
except BleakDeviceNotFoundError as err:
raise _DeviceNotFoundError(f"Device {address} was not found") from err
except (BleakError, OSError) as err:
_raise_if_not_paired(address, err)
raise EsphomeError(f"Could not connect to {address}: {err}") from err
try:
yield client
finally:
with suppress(Exception):
await client.disconnect()
async def enter_dfu_mode(address: str) -> str:
"""Return the address of the bootloader in DFU mode.
A running application is first asked to reboot into the bootloader. A
device that is not found may still be in its bootloader from a failed
upload, which can advertise one address above the application.
"""
request_error: Exception | None = None
known_dfu: set[str] | None = None
try:
async with _connect(address) as client:
services = {str(service.uuid).lower() for service in client.services}
if DFU_SERVICE_UUID in services:
return address
if TRIGGER_SERVICE_UUID not in services:
raise EsphomeError(
f"{address} does not offer an OTA service. Put the device "
"into DFU mode and try again."
)
if not MAC_ADDRESS_PATTERN.match(address):
# macOS hides MAC addresses and gives the bootloader its own UUID
known_dfu = await _dfu_devices()
_LOGGER.info("Requesting DFU mode from %s...", address)
try:
await client.write_gatt_char(
TRIGGER_CHARACTERISTIC_UUID, TRIGGER_MAGIC, response=True
)
except (BleakError, OSError) as err:
_raise_if_not_paired(address, err)
# The application reboots as soon as the write is handled, so a
# disconnect while waiting for the response is expected. Windows
# reports that as a cancelled operation (OSError), not a BleakError.
# The error is only reported if the bootloader then does not show
# up, except an error response (find_dfu_bootloader).
_LOGGER.debug("DFU mode request ended with %s", err)
request_error = err
except _DeviceNotFoundError:
_LOGGER.info("%s was not found, looking for its DFU bootloader", address)
return await find_dfu_bootloader(address, request_error, known_dfu)
async def _find_new_dfu_devices(
known_dfu: set[str], app_address: str
) -> tuple[list[str], bool]:
"""Scan for DFU devices that are not in ``known_dfu``.
Also reports whether ``app_address`` still advertised after one appeared: an application that really restarted
into its bootloader stops advertising, so a new DFU device seen while it keeps going belongs to another board.
"""
known = {address.lower() for address in known_dfu}
app = app_address.lower()
new: dict[str, str] = {}
appeared = asyncio.Event()
app_still_advertising = False
def _on_advertisement(device: BLEDevice, advertisement: AdvertisementData) -> None:
nonlocal app_still_advertising
key = device.address.lower()
if key == app:
app_still_advertising = app_still_advertising or appeared.is_set()
return
if DFU_SERVICE_UUID in _service_uuids(advertisement) and key not in known:
new.setdefault(key, device.address)
appeared.set()
async with BleakScanner(detection_callback=_on_advertisement):
with suppress(TimeoutError):
await asyncio.wait_for(appeared.wait(), _REBOOT_TIMEOUT)
if appeared.is_set():
await asyncio.sleep(_NEW_DEVICE_SETTLE)
return list(new.values()), app_still_advertising
async def _is_advertising(address: str) -> bool:
"""Whether ``address`` shows up in a fresh scan session."""
wanted = address.lower()
device = await BleakScanner.find_device_by_filter(
lambda device, _advertisement: device.address.lower() == wanted,
timeout=_APP_CHECK_TIMEOUT,
)
return device is not None
def _raise_if_request_rejected(address: str, request_error: Exception | None) -> None:
"""Raise if the application answered the DFU mode request with an error.
An error response comes from the running application, so it did not
restart. A dropped link stays acceptable, as the reboot can look like that.
"""
if not isinstance(request_error, BleakGATTProtocolError):
return
message = f"{address} rejected the DFU mode request: {request_error.args[-1]}."
if request_error.code in _PAIRING_ERRORS:
message += " Make sure the device is paired."
raise EsphomeError(message) from request_error
async def find_dfu_bootloader(
address: str,
request_error: Exception | None = None,
known_dfu: set[str] | None = None,
) -> str:
"""Scan for the bootloader of the device that was asked to enter DFU mode.
Only ``address`` and the address above it are accepted, so a different
device that happens to be in DFU mode nearby is never flashed. With
``known_dfu`` (the DFU devices seen before the request, for hosts without
MAC addresses) the one DFU device that is new is accepted instead, and only
if exactly one appeared and the application no longer advertises. A request
the application rejected fails at once, without scanning.
"""
_raise_if_request_rejected(address, request_error)
_LOGGER.info("Scanning for the DFU bootloader...")
if known_dfu is not None:
new, app_still_advertising = await _find_new_dfu_devices(known_dfu, address)
if len(new) == 1 and not app_still_advertising:
app_still_advertising = await _is_advertising(address)
if not app_still_advertising:
return new[0]
if app_still_advertising:
_LOGGER.warning(
"%s kept advertising, so it did not restart into its bootloader",
address,
)
raise _not_found_error(address, request_error, known_dfu | set(new))
wanted = {address.lower()}
if (next_address := _next_address(address)) is not None:
wanted.add(next_address.lower())
others: set[str] = set()
def _is_bootloader(device: BLEDevice, advertisement: AdvertisementData) -> bool:
if DFU_SERVICE_UUID not in _service_uuids(advertisement):
return False
if device.address.lower() in wanted:
return True
others.add(device.address)
return False
device = await BleakScanner.find_device_by_filter(
_is_bootloader, timeout=_REBOOT_TIMEOUT
)
if device is not None:
return device.address
raise _not_found_error(address, request_error, others)
def _not_found_error(
address: str, request_error: Exception | None, others: set[str]
) -> EsphomeError:
message = f"The DFU bootloader of {address} was not found."
if request_error is not None:
message += f" The DFU mode request failed: {request_error}."
if others:
message += f" Other devices in DFU mode: {', '.join(sorted(others))}."
return EsphomeError(f"{message} {BOOTLOADER_HINT}")
async def _write_control(client: BleakClient, op: int, *payload: int) -> None:
await client.write_gatt_char(
DFU_CONTROL_POINT_UUID, bytes([op, *payload]), response=True
)
async def _stream_firmware(
client: BleakClient, session: _DfuSession, image: bytes
) -> None:
# Every data packet must be whole words; the image size is too, so the last
# packet is as well.
chunk_size = max(20, min(client.mtu_size - 3, _MAX_CHUNK_SIZE))
chunk_size -= chunk_size % _WORD_SIZE
_LOGGER.debug("DFU uses %d byte chunks for %d bytes", chunk_size, len(image))
progress = ProgressBar("Uploading")
progress.update(0)
try:
for packet, offset in enumerate(range(0, len(image), chunk_size), start=1):
session.raise_if_rejected("upload")
chunk = image[offset : offset + chunk_size]
await client.write_gatt_char(DFU_PACKET_UUID, chunk, response=False)
progress.update((offset + len(chunk)) / len(image))
# The packet that completes the image is answered with the
# RECEIVE_FIRMWARE_IMAGE response instead of a receipt.
if packet % _PRN == 0 and offset + len(chunk) < len(image):
await session.wait_for_receipts(packet // _PRN, "upload")
finally:
progress.done()
async def _perform_update(
client: BleakClient, image: bytes, init_packet: bytes
) -> None:
session = _DfuSession()
await client.start_notify(DFU_CONTROL_POINT_UUID, session.notification_handler)
try:
_LOGGER.debug("DFU: starting the update")
await _write_control(client, _OP_START_DFU, _UPLOAD_MODE_APPLICATION)
await asyncio.sleep(_START_DELAY)
await client.write_gatt_char(
DFU_PACKET_UUID, struct.pack("<III", 0, 0, len(image)), response=False
)
try:
await session.expect_success(
_OP_START_DFU, "start", timeout=_LONG_RESPONSE_TIMEOUT
)
except EsphomeError:
with suppress(BleakError, OSError):
await _write_control(client, _OP_RESET)
raise
_LOGGER.debug("DFU: sending the init packet")
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x00)
await client.write_gatt_char(DFU_PACKET_UUID, init_packet, response=False)
await _write_control(client, _OP_INIT_DFU_PARAMS, 0x01)
await session.expect_success(_OP_INIT_DFU_PARAMS, "init packet")
await _write_control(
client, _OP_PACKET_RECEIPT_NOTIF_REQ, *struct.pack("<H", _PRN)
)
_LOGGER.debug("DFU: streaming the image")
await _write_control(client, _OP_RECEIVE_FIRMWARE_IMAGE)
await _stream_firmware(client, session, image)
await session.expect_success(
_OP_RECEIVE_FIRMWARE_IMAGE, "upload", timeout=_LONG_RESPONSE_TIMEOUT
)
await _write_control(client, _OP_VALIDATE)
await session.expect_success(_OP_VALIDATE, "validation")
# The bootloader resets as soon as it handles this, so the link drops
# before the write can be acknowledged (an OSError on Windows). An error
# response means it answered and refused.
try:
await _write_control(client, _OP_ACTIVATE_AND_RESET)
except BleakGATTProtocolError as err:
raise _DfuRejectedError(
f"DFU activation rejected by the bootloader: {err}"
) from err
except (BleakError, OSError) as err:
_LOGGER.debug("DFU activation ended with %s", err)
finally:
with suppress(Exception):
await client.stop_notify(DFU_CONTROL_POINT_UUID)
async def _dfu_attempt(address: str, image: bytes, init_packet: bytes) -> None:
"""Run the whole DFU sequence once."""
async with _connect(address) as client:
try:
await _perform_update(client, image, init_packet)
except (BleakError, OSError) as err:
raise EsphomeError(f"DFU failed: {err}") from err
async def dfu_upload(address: str, package: DfuPackage) -> None:
"""Flash ``package`` (from ``load_firmware``) to ``address``."""
stale_state_retried = False
for attempt in range(1, _ATTEMPTS + 1):
_LOGGER.info(
"Uploading %s to %s (%d bytes)%s...",
package.name,
address,
len(package.image),
f", attempt {attempt}/{_ATTEMPTS}" if attempt > 1 else "",
)
try:
await _dfu_attempt(address, package.image, package.init_packet)
except _DfuStaleStateError as err:
if stale_state_retried or attempt == _ATTEMPTS:
raise
stale_state_retried = True
_LOGGER.warning(
"%s; it was left mid-update and has been reset, retrying", err
)
await asyncio.sleep(_RETRY_DELAY)
except _DfuRejectedError:
raise
except EsphomeError as err:
if attempt == _ATTEMPTS:
raise
_LOGGER.warning("DFU attempt %d/%d failed: %s", attempt, _ATTEMPTS, err)
await asyncio.sleep(_RETRY_DELAY)
else:
_LOGGER.info("DFU finished; the device restarts into the new firmware")
return
@@ -0,0 +1,83 @@
import logging
import esphome.codegen as cg
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT
from esphome.components.ota import (
BASE_OTA_SCHEMA,
CONF_ON_ABORT,
CONF_ON_END,
CONF_ON_PROGRESS,
OTAComponent,
ota_to_code,
)
from esphome.components.zephyr import zephyr_data
from esphome.components.zephyr.const import KEY_BOOTLOADER
from esphome.components.zephyr_ble_server import (
CONF_ON_NUMERIC_COMPARISON_REQUEST,
DOMAIN as ZEPHYR_BLE_SERVER_DOMAIN,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ON_ERROR, Framework
from esphome.core import CoroPriority, coroutine_with_priority
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@omersiar"]
DEPENDENCIES = ["zephyr", "zephyr_ble_server"]
AdafruitBLEOTAComponent = cg.esphome_ns.namespace("adafruit_ble").class_(
"OTAComponent", OTAComponent
)
# The device reboots into the bootloader before the transfer starts, so only
# on_begin and on_state_change can fire.
_UNSUPPORTED_TRIGGERS = (CONF_ON_PROGRESS, CONF_ON_END, CONF_ON_ERROR, CONF_ON_ABORT)
CONFIG_SCHEMA = cv.All(
BASE_OTA_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(AdafruitBLEOTAComponent),
**{
cv.Optional(key): cv.invalid(
f"'{key}' never fires with Adafruit BLE OTA: the bootloader "
"runs the update after the device restarts into it"
)
for key in _UNSUPPORTED_TRIGGERS
},
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_with_framework(Framework.ZEPHYR),
)
def _final_validate(config: ConfigType) -> None:
# Every Adafruit bootloader variant is named adafruit*
bootloader = zephyr_data().get(KEY_BOOTLOADER, "")
if not bootloader.startswith(BOOTLOADER_ADAFRUIT):
raise cv.Invalid(
f"Adafruit BLE OTA requires an Adafruit bootloader, but '{bootloader}' "
"is configured. Set 'bootloader:' to an Adafruit one."
)
server = fv.full_config.get().get(ZEPHYR_BLE_SERVER_DOMAIN) or {}
if CONF_ON_NUMERIC_COMPARISON_REQUEST not in server:
_LOGGER.warning(
"Adafruit BLE OTA without pairing lets any nearby device restart this "
"one into the bootloader; add '%s' to '%s' to require pairing",
CONF_ON_NUMERIC_COMPARISON_REQUEST,
ZEPHYR_BLE_SERVER_DOMAIN,
)
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
async def to_code(config: ConfigType) -> None:
# The Adafruit bootloader serves the update itself; the component provides
# the BLE service that asks the application to restart into it.
var = cg.new_Pvariable(config[CONF_ID])
# Registered before ota_to_code waits past safe_mode, so safe mode keeps the trigger
await cg.register_component(var, config)
await ota_to_code(var, config)
@@ -0,0 +1,73 @@
#include "ota_adafruit_ble.h"
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include <cstring>
#include <hal/nrf_power.h>
#include <zephyr/bluetooth/gatt.h>
namespace esphome::adafruit_ble {
ESPHOME_LOG_TAG(TAG, "adafruit_ble");
// Written by the upload tool (dfu.py) to ask for a restart into the bootloader,
// which then serves the DFU transfer. The bootloader only advertises its own DFU
// service while it is in DFU mode, so this service is what identifies the
// running application.
static constexpr uint8_t DFU_TRIGGER_MAGIC[] = {'D', 'F', 'U'};
// GPREGRET value the Adafruit nRF52 bootloader reads to enter OTA (BLE DFU) mode
static constexpr uint8_t DFU_GPREGRET_OTA = 0xA8;
// Matches the MCUmgr BLE transport default: a paired (authenticated) link once pairing is enabled
#ifdef CONFIG_BT_SMP
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE_AUTHEN;
#else
static constexpr uint16_t DFU_TRIGGER_PERM = BT_GATT_PERM_WRITE;
#endif
static OTAComponent *global_ota_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static ssize_t dfu_trigger_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len,
uint16_t offset, uint8_t flags) {
if (offset != 0) {
return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
}
if (len != sizeof(DFU_TRIGGER_MAGIC) || memcmp(buf, DFU_TRIGGER_MAGIC, sizeof(DFU_TRIGGER_MAGIC)) != 0) {
return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED);
}
global_ota_component->request_dfu_mode();
return len;
}
// Registered by bt_enable(), which zephyr_ble_server calls during its setup.
BT_GATT_SERVICE_DEFINE( // NOLINT
dfu_trigger_svc,
BT_GATT_PRIMARY_SERVICE(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10001, 0x9c3a, 0x4f5d, 0x8a1b,
0x2c3d4e5f6071))),
BT_GATT_CHARACTERISTIC(BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0xe5b10002, 0x9c3a, 0x4f5d, 0x8a1b, 0x2c3d4e5f6071)),
BT_GATT_CHRC_WRITE, DFU_TRIGGER_PERM, nullptr, dfu_trigger_write, nullptr));
OTAComponent::OTAComponent() { global_ota_component = this; }
void OTAComponent::request_dfu_mode() {
// Restart from the main loop, so the OTA listeners run there and the write is answered first
this->defer([this]() {
ESP_LOGI(TAG, "DFU mode requested, restarting into the bootloader");
#ifdef USE_OTA_STATE_LISTENER
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
#endif
// NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- NRF_POWER is MMIO at a fixed address
NRF_POWER->GPREGRET = DFU_GPREGRET_OTA;
App.reboot();
});
}
void OTAComponent::dump_config() {
ESP_LOGCONFIG(TAG, "Over-The-Air Updates:\n"
" bootloader: Adafruit nRF52 (BLE DFU)");
}
} // namespace esphome::adafruit_ble
#endif
@@ -0,0 +1,19 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ZEPHYR) && defined(USE_NRF52)
#include "esphome/components/ota/ota_backend.h"
namespace esphome::adafruit_ble {
// The update itself is served by the Adafruit bootloader; this component only
// hosts the BLE trigger service defined in ota_adafruit_ble.cpp.
class OTAComponent : public ota::OTAComponent {
public:
OTAComponent();
void dump_config() override;
// Called from the Bluetooth thread when the trigger is written
void request_dfu_mode();
};
} // namespace esphome::adafruit_ble
#endif
+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
+213
View File
@@ -0,0 +1,213 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_client
import esphome.config_validation as cv
from esphome.const import (
CONF_CHANNEL,
CONF_DATA,
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_ON_MESSAGE,
CONF_PAYLOAD,
CONF_VALUE,
)
from esphome.types import ConfigType
CODEOWNERS = ["@bogdanr"]
DEPENDENCIES = ["ble_client"]
DOMAIN = "ble_midi"
MULTI_CONF = True
CONF_CONTROL_NUMBER = "control_number"
CONF_MAX_SYSEX_SIZE = "max_sysex_size"
CONF_NOTE = "note"
CONF_ON_CONTROL_CHANGE = "on_control_change"
CONF_ON_NOTE_OFF = "on_note_off"
CONF_ON_NOTE_ON = "on_note_on"
CONF_ON_PITCH_BEND = "on_pitch_bend"
CONF_ON_PROGRAM_CHANGE = "on_program_change"
CONF_ON_SYSEX = "on_sysex"
CONF_PAIR = "pair"
CONF_PROGRAM = "program"
CONF_SYSEX_BUFFER_ID = "sysex_buffer_id"
CONF_VELOCITY = "velocity"
ble_midi_ns = cg.esphome_ns.namespace("ble_midi")
BLEMidi = ble_midi_ns.class_("BLEMidi", cg.Component, ble_client.BLEClientNode)
# The message bytes as a view: data.size() and data[i] work, and nothing is copied
ByteSpan = cg.std_span.template(cg.uint8.operator("const"))
# MIDI data bytes are 7-bit and channels are 4-bit, matching what goes on the wire and what the triggers report.
# Channel 0 is the channel that MIDI hardware usually labels "1".
midi_data_byte = cv.int_range(min=0, max=127)
midi_channel = cv.int_range(min=0, max=15)
_NOTE_ARGS = [
(cg.uint8, CONF_NOTE),
(cg.uint8, CONF_VELOCITY),
(cg.uint8, CONF_CHANNEL),
]
_BYTES_ARGS = [(ByteSpan, CONF_DATA)]
# data points into a notification or a reused buffer, so these automations cannot wait
_BYTES_TRIGGERS = (CONF_ON_SYSEX, CONF_ON_MESSAGE)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"),
automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"),
automation.CallbackAutomation(
CONF_ON_NOTE_ON, "add_on_note_on_callback", _NOTE_ARGS
),
automation.CallbackAutomation(
CONF_ON_NOTE_OFF, "add_on_note_off_callback", _NOTE_ARGS
),
automation.CallbackAutomation(
CONF_ON_CONTROL_CHANGE,
"add_on_control_change_callback",
[
(cg.uint8, CONF_CONTROL_NUMBER),
(cg.uint8, CONF_VALUE),
(cg.uint8, CONF_CHANNEL),
],
),
automation.CallbackAutomation(
CONF_ON_PROGRAM_CHANGE,
"add_on_program_change_callback",
[(cg.uint8, CONF_PROGRAM), (cg.uint8, CONF_CHANNEL)],
),
automation.CallbackAutomation(
CONF_ON_PITCH_BEND,
"add_on_pitch_bend_callback",
[(cg.int16, CONF_VALUE), (cg.uint8, CONF_CHANNEL)],
),
automation.CallbackAutomation(CONF_ON_SYSEX, "add_on_sysex_callback", _BYTES_ARGS),
automation.CallbackAutomation(
CONF_ON_MESSAGE, "add_on_message_callback", _BYTES_ARGS
),
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLEMidi),
cv.GenerateID(CONF_SYSEX_BUFFER_ID): cv.declare_id(cg.uint8),
cv.Optional(CONF_MAX_SYSEX_SIZE, default=256): cv.int_range(
min=8, max=2048
),
cv.Optional(CONF_PAIR, default=False): cv.boolean,
**{
cv.Optional(entry.conf_key): (
automation.validate_synchronous_automation(
f"ble_midi {entry.conf_key}", "data"
)
if entry.conf_key in _BYTES_TRIGGERS
else automation.validate_automation({})
)
for entry in _CALLBACK_AUTOMATIONS
},
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_client.BLE_CLIENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
size = config[CONF_MAX_SYSEX_SIZE]
# SysEx messages are reassembled in a static array, so nothing is allocated at run time
buf = config[CONF_SYSEX_BUFFER_ID].id
cg.add_global(cg.RawStatement(f"static uint8_t {buf}[{size}];"))
var = await ble_client.new_ble_node_component(
config, cg.RawExpression(buf), size, config[CONF_PAIR]
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
def _action_schema(keys: dict) -> cv.Schema:
return cv.Schema(
{
cv.GenerateID(): cv.use_id(BLEMidi),
**keys,
cv.Optional(CONF_CHANNEL, default=0): cv.templatable(midi_channel),
}
)
_NOTE_ACTION_SCHEMA = _action_schema(
{
cv.Required(CONF_NOTE): cv.templatable(midi_data_byte),
cv.Optional(CONF_VELOCITY, default=64): cv.templatable(midi_data_byte),
}
)
# Wider than the wire values so the C++ range check sees a lambda's result before it is narrowed
_CHANNEL = (CONF_CHANNEL, cg.int32)
_NOTE_CALL_ARGS = ((CONF_NOTE, cg.int32), (CONF_VELOCITY, cg.int32), _CHANNEL)
automation.register_apply_action(
"ble_midi.send_note_on",
_NOTE_ACTION_SCHEMA,
automation.ApplyCall("send_note_on({}, {}, {})", _NOTE_CALL_ARGS),
)
automation.register_apply_action(
"ble_midi.send_note_off",
_NOTE_ACTION_SCHEMA,
automation.ApplyCall("send_note_off({}, {}, {})", _NOTE_CALL_ARGS),
)
automation.register_apply_action(
"ble_midi.send_control_change",
_action_schema(
{
cv.Required(CONF_CONTROL_NUMBER): cv.templatable(midi_data_byte),
cv.Required(CONF_VALUE): cv.templatable(midi_data_byte),
}
),
automation.ApplyCall(
"send_control_change({}, {}, {})",
((CONF_CONTROL_NUMBER, cg.int32), (CONF_VALUE, cg.int32), _CHANNEL),
),
)
automation.register_apply_action(
"ble_midi.send_program_change",
_action_schema({cv.Required(CONF_PROGRAM): cv.templatable(midi_data_byte)}),
automation.ApplyCall(
"send_program_change({}, {})", ((CONF_PROGRAM, cg.int32), _CHANNEL)
),
)
automation.register_apply_action(
"ble_midi.send_pitch_bend",
_action_schema(
{
cv.Required(CONF_VALUE): cv.templatable(cv.int_range(min=-8192, max=8191)),
}
),
automation.ApplyCall("send_pitch_bend({}, {})", ((CONF_VALUE, cg.int32), _CHANNEL)),
)
def _bytes_action_schema(key: str) -> cv.Schema:
return cv.Schema(
{
cv.GenerateID(): cv.use_id(BLEMidi),
cv.Required(key): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
}
)
automation.register_apply_action(
"ble_midi.send_sysex",
_bytes_action_schema(CONF_PAYLOAD),
automation.ApplyBytes(CONF_PAYLOAD, "send_sysex({})"),
)
automation.register_apply_action(
"ble_midi.send_raw",
_bytes_action_schema(CONF_DATA),
automation.ApplyBytes(CONF_DATA, "send_raw({})"),
)
automation.register_apply_condition(
"ble_midi.connected", cv.Schema({cv.GenerateID(): cv.use_id(BLEMidi)}), "is_ready()"
)
+310
View File
@@ -0,0 +1,310 @@
#include "ble_midi.h"
#ifdef USE_ESP32
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
namespace esphome::ble_midi {
ESPHOME_LOG_TAG(TAG, "ble_midi");
// Standard BLE-MIDI service and characteristic, as defined by the BLE-MIDI
// specification.
static constexpr const char *SERVICE_UUID = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700";
static constexpr const char *CHARACTERISTIC_UUID = "7772E5DB-3868-4112-A1A9-F2669D106BF3";
// MIDI status bytes for the messages this component can transmit.
static constexpr uint8_t STATUS_NOTE_OFF = 0x80;
static constexpr uint8_t STATUS_NOTE_ON = 0x90;
static constexpr uint8_t STATUS_CONTROL_CHANGE = 0xB0;
static constexpr uint8_t STATUS_PROGRAM_CHANGE = 0xC0;
static constexpr uint8_t STATUS_PITCH_BEND = 0xE0;
// Number of received bytes included in verbose packet logs.
static constexpr size_t LOG_MAX_BYTES = 32;
void BLEMidi::dump_config() {
ESP_LOGCONFIG(TAG,
"BLE MIDI:\n"
" MAC address: %s\n"
" Maximum SysEx size: %u\n"
" Pair on connect: %s",
this->parent()->address_str(), this->parser_.sysex_capacity(), YESNO(this->pair_));
}
void BLEMidi::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)
break;
// A warning left by an earlier session no longer applies
this->status_clear_warning();
this->parser_.reset();
if (this->pair_) {
esp_err_t err = this->parent()->pair();
if (err != ESP_OK) {
ESP_LOGW(TAG, "Pairing failed, error=%d", err);
}
}
break;
}
case ESP_GATTC_CFG_MTU_EVT: {
if (param->cfg_mtu.status == ESP_GATT_OK)
this->mtu_ = param->cfg_mtu.mtu;
break;
}
case ESP_GATTC_DISCONNECT_EVT:
case ESP_GATTC_CLOSE_EVT: {
this->parser_.reset();
this->characteristic_handle_ = 0;
this->cccd_handle_ = 0;
this->mtu_ = 23;
// Both events can arrive for the same disconnect, so the automation runs
// only for the one that follows an established MIDI connection.
const bool was_ready = this->notify_registered_;
this->notify_registered_ = false;
this->node_state = espbt::ClientState::IDLE;
if (was_ready)
this->disconnect_callback_.call();
break;
}
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (param->search_cmpl.status != ESP_GATT_OK) {
ESP_LOGW(TAG, "Service discovery failed, status=%d", param->search_cmpl.status);
this->fail_session_();
break;
}
auto *characteristic = this->parent()->get_characteristic(espbt::ESPBTUUID::from_raw(SERVICE_UUID),
espbt::ESPBTUUID::from_raw(CHARACTERISTIC_UUID));
// Reconnecting would not help a device without them, so the link is left as it is
if (characteristic == nullptr) {
this->status_set_warning(LOG_STR("No BLE MIDI characteristic found on this device"));
break;
}
auto *cccd = characteristic->get_descriptor(ESP_GATT_UUID_CHAR_CLIENT_CONFIG);
if (cccd == nullptr) {
this->status_set_warning(LOG_STR("BLE MIDI characteristic has no notification descriptor"));
break;
}
this->characteristic_handle_ = characteristic->handle;
this->cccd_handle_ = cccd->handle;
this->write_without_response_ = (characteristic->properties & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) != 0;
esp_err_t err = this->parent()->register_for_notify(characteristic->handle);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register_for_notify failed, error=%d", err);
this->fail_session_();
}
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
if (param->reg_for_notify.handle != this->characteristic_handle_)
break;
if (param->reg_for_notify.status != ESP_GATT_OK) {
ESP_LOGW(TAG, "Registering for notifications failed, status=%d", param->reg_for_notify.status);
this->fail_session_();
}
// On success the parent writes the descriptor; the device has accepted it once WRITE_DESCR_EVT reports OK
break;
}
case ESP_GATTC_WRITE_DESCR_EVT: {
if (this->cccd_handle_ == 0 || param->write.handle != this->cccd_handle_ || this->notify_registered_)
break;
if (param->write.status != ESP_GATT_OK) {
ESP_LOGW(TAG, "Device refused to enable notifications, status=%d", param->write.status);
this->fail_session_();
break;
}
this->notify_registered_ = true;
this->node_state = espbt::ClientState::ESTABLISHED;
ESP_LOGI(TAG, "MIDI notifications enabled");
// The device can only be sent to once notifications are enabled, so this
// is the point where on_connect is useful.
this->connect_callback_.call();
break;
}
case ESP_GATTC_WRITE_CHAR_EVT: {
// A write with response reports its outcome here; one without response reports nothing
if (param->write.handle == this->characteristic_handle_ && param->write.status != ESP_GATT_OK) {
ESP_LOGW(TAG, "Write rejected, status=%d", param->write.status);
}
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (param->notify.handle != this->characteristic_handle_ || param->notify.value_len == 0)
break;
// format_hex_to() truncates to the buffer size, so long packets are logged
// only in part.
char hex[format_hex_size(LOG_MAX_BYTES)];
ESP_LOGV(TAG, "Received %u bytes: %s", param->notify.value_len,
format_hex_to(hex, param->notify.value, param->notify.value_len));
this->parser_.feed(param->notify.value, param->notify.value_len, BLEMidi::dispatch_message, this);
break;
}
default:
break;
}
}
void BLEMidi::fail_session_() {
this->status_set_warning();
// ble_client reconnects, which runs the setup again
this->parent()->disconnect();
}
void BLEMidi::handle_message_(const MidiMessage &message) {
ESP_LOGV(
TAG, "Decoded %s",
LOG_STR_ARG(MessageTypeNames::get_log_str(static_cast<uint8_t>(message.type), MessageTypeNames::LAST_INDEX)));
// Only decoded messages reach on_message
if (message.type == MessageType::SYSEX_INCOMPLETE) {
ESP_LOGW(TAG, "SysEx of %u bytes dropped: cut short, or longer than max_sysex_size (%u)", message.length,
this->parser_.sysex_capacity());
return;
}
if (message.type == MessageType::INCOMPLETE) {
ESP_LOGW(TAG, "MIDI message 0x%02X dropped: cut short by another status byte", message.status());
return;
}
if (message.type == MessageType::UNKNOWN) {
ESP_LOGW(TAG, "Undecodable MIDI byte 0x%02X", message.status());
return;
}
this->message_callback_.call(ByteSpan(message.data, message.length));
switch (message.type) {
case MessageType::NOTE_ON:
this->note_on_callback_.call(message.data1(), message.data2(), message.channel());
break;
case MessageType::NOTE_OFF:
this->note_off_callback_.call(message.data1(), message.data2(), message.channel());
break;
case MessageType::CONTROL_CHANGE:
this->control_change_callback_.call(message.data1(), message.data2(), message.channel());
break;
case MessageType::PROGRAM_CHANGE:
this->program_change_callback_.call(message.data1(), message.channel());
break;
case MessageType::PITCH_BEND:
this->pitch_bend_callback_.call(message.pitch_bend(), message.channel());
break;
case MessageType::SYSEX:
// The payload without the 0xF0/0xF7 wrappers; a complete SysEx always has both
this->sysex_callback_.call(ByteSpan(message.data + 1, message.length - 2));
break;
default:
// Polyphonic and channel aftertouch, system common and real-time messages
// are only reported through on_message.
break;
}
}
bool BLEMidi::write_packet_(uint8_t *packet, size_t midi_length) {
if (!this->is_ready()) {
ESP_LOGW(TAG, "Cannot send: not connected");
return false;
}
// Every BLE-MIDI packet has to fit one ATT packet
const size_t packet_length = midi_length + 2;
const size_t limit = std::min<size_t>(MAX_TX_MIDI_BYTES + 2, this->mtu_ - 3);
if (packet_length > limit) {
ESP_LOGW(TAG, "Cannot send: message of %u bytes exceeds the %u byte limit", static_cast<unsigned>(midi_length),
static_cast<unsigned>(limit - 2));
return false;
}
packet[0] = PACKET_HEADER;
packet[1] = PACKET_TIMESTAMP;
esp_err_t err = esp_ble_gattc_write_char(
this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->characteristic_handle_, packet_length,
packet, this->write_without_response_ ? ESP_GATT_WRITE_TYPE_NO_RSP : ESP_GATT_WRITE_TYPE_RSP,
ESP_GATT_AUTH_REQ_NONE);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Write failed, error=%d", err);
return false;
}
char hex[format_hex_size(LOG_MAX_BYTES)];
ESP_LOGV(TAG, "Sent %u bytes: %s", static_cast<unsigned>(packet_length), format_hex_to(hex, packet, packet_length));
return true;
}
bool BLEMidi::write_midi_(const uint8_t *midi_bytes, size_t length) {
if (length == 0 || length > MAX_TX_MIDI_BYTES) {
ESP_LOGW(TAG, "Cannot send: message of %u bytes, the limit is %u", static_cast<unsigned>(length),
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
return false;
}
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
std::memcpy(packet + 2, midi_bytes, length);
return this->write_packet_(packet, length);
}
void BLEMidi::send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length) {
// Validation covers constants only; a lambda can return any value, and masking it would send a different message.
// The values arrive unnarrowed so a negative or wrapped one is caught here (the unsigned compare covers negatives).
if (static_cast<uint32_t>(channel) > 0x0F || static_cast<uint32_t>(data1) > 0x7F ||
static_cast<uint32_t>(data2) > 0x7F) {
ESP_LOGW(TAG, "Cannot send: channel %" PRId32 " or value %" PRId32 "/%" PRId32 " out of range", channel, data1,
data2);
return;
}
const uint8_t bytes[3] = {static_cast<uint8_t>(status | channel), static_cast<uint8_t>(data1),
static_cast<uint8_t>(data2)};
this->write_midi_(bytes, length);
}
void BLEMidi::send_note_on(int32_t note, int32_t velocity, int32_t channel) {
this->send_channel_(STATUS_NOTE_ON, channel, note, velocity, 3);
}
void BLEMidi::send_note_off(int32_t note, int32_t velocity, int32_t channel) {
this->send_channel_(STATUS_NOTE_OFF, channel, note, velocity, 3);
}
void BLEMidi::send_control_change(int32_t control_number, int32_t value, int32_t channel) {
this->send_channel_(STATUS_CONTROL_CHANGE, channel, control_number, value, 3);
}
void BLEMidi::send_program_change(int32_t program, int32_t channel) {
this->send_channel_(STATUS_PROGRAM_CHANGE, channel, program, 0, 2);
}
void BLEMidi::send_pitch_bend(int32_t value, int32_t channel) {
if (value < -8192 || value > 8191) {
ESP_LOGW(TAG, "Cannot send: pitch bend %" PRId32 " out of range", value);
return;
}
const uint16_t unsigned_value = static_cast<uint16_t>(value + 8192);
this->send_channel_(STATUS_PITCH_BEND, channel, unsigned_value & 0x7F, unsigned_value >> 7, 3);
}
void BLEMidi::send_sysex(const uint8_t *payload, size_t length) {
uint8_t packet[MAX_TX_MIDI_BYTES + 2];
const size_t framed = frame_sysex(payload, length, packet + 2, MAX_TX_MIDI_BYTES);
if (framed == 0) {
ESP_LOGW(TAG, "Cannot send: SysEx of %u bytes is empty or exceeds the %u byte limit", static_cast<unsigned>(length),
static_cast<unsigned>(MAX_TX_MIDI_BYTES));
return;
}
this->write_packet_(packet, framed);
}
void BLEMidi::send_raw(const uint8_t *bytes, size_t length) {
// The packet carries one timestamp, in front of the first byte, so the payload has to be a single complete
// message with its status byte first; a SysEx goes through send_sysex
bool one_message = length > 0 && (bytes[0] & 0x80) != 0 && bytes[0] != STATUS_SYSEX_START &&
bytes[0] != STATUS_SYSEX_END && length == 1u + MidiParser::expected_data_bytes(bytes[0]);
for (size_t i = 1; one_message && i < length; i++) {
one_message = (bytes[i] & 0x80) == 0;
}
if (!one_message) {
ESP_LOGW(TAG, "Cannot send: raw payload must be one message, a status byte followed by data bytes");
return;
}
this->write_midi_(bytes, length);
}
} // namespace esphome::ble_midi
#endif // USE_ESP32
+138
View File
@@ -0,0 +1,138 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#ifdef USE_ESP32
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "midi_parser.h"
#include <esp_gattc_api.h>
#include <cstdint>
#include <span>
namespace esphome::ble_midi {
namespace espbt = esphome::esp32_ble_tracker;
/// The bytes of a received message, valid for the duration of the automation.
using ByteSpan = std::span<const uint8_t>;
/// Longest MIDI message that can be transmitted in a single BLE-MIDI packet, header and timestamp excluded.
static constexpr size_t MAX_TX_MIDI_BYTES = 128;
/// Client for devices implementing the standard BLE-MIDI service, e.g. wireless
/// MIDI keyboards and pad controllers. Incoming messages are exposed as
/// automation triggers; outgoing messages are sent with the ble_midi.send_*
/// actions.
///
/// Specification: "Specification for MIDI over Bluetooth Low Energy
/// (BLE-MIDI)", MIDI Association, document M1-2017-11-01.
class BLEMidi final : public Component, public ble_client::BLEClientNode {
public:
/// sysex_buffer holds sysex_capacity bytes; longer SysEx messages are truncated.
BLEMidi(uint8_t *sysex_buffer, uint16_t sysex_capacity, bool pair)
: parser_(sysex_buffer, sysex_capacity), pair_(pair) {}
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
/// True once the MIDI characteristic has been discovered and notifications
/// are enabled.
bool is_ready() const { return this->notify_registered_; }
// Channel is 0-15, note/velocity/controller/value/program are 0-127; anything else is refused with a warning.
void send_note_on(int32_t note, int32_t velocity, int32_t channel);
void send_note_off(int32_t note, int32_t velocity, int32_t channel);
void send_control_change(int32_t control_number, int32_t value, int32_t channel);
void send_program_change(int32_t program, int32_t channel);
/// Pitch bend value is -8192 to 8191, centered on zero.
void send_pitch_bend(int32_t value, int32_t channel);
/// Send a System Exclusive message. The 0xF0/0xF7 wrappers are added if the
/// payload does not already carry them.
void send_sysex(const uint8_t *payload, size_t length);
/// Send raw MIDI bytes (status byte followed by its data bytes).
void send_raw(const uint8_t *bytes, size_t length);
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callback_.add(std::forward<F>(callback));
}
/// (note, velocity, channel)
template<typename F> void add_on_note_on_callback(F &&callback) {
this->note_on_callback_.add(std::forward<F>(callback));
}
/// (note, velocity, channel); a Note On with velocity zero is reported here as well
template<typename F> void add_on_note_off_callback(F &&callback) {
this->note_off_callback_.add(std::forward<F>(callback));
}
/// (control_number, value, channel)
template<typename F> void add_on_control_change_callback(F &&callback) {
this->control_change_callback_.add(std::forward<F>(callback));
}
/// (program, channel)
template<typename F> void add_on_program_change_callback(F &&callback) {
this->program_change_callback_.add(std::forward<F>(callback));
}
/// (value, channel), value being -8192 to 8191
template<typename F> void add_on_pitch_bend_callback(F &&callback) {
this->pitch_bend_callback_.add(std::forward<F>(callback));
}
/// The SysEx payload without the 0xF0/0xF7 wrappers
template<typename F> void add_on_sysex_callback(F &&callback) {
this->sysex_callback_.add(std::forward<F>(callback));
}
/// The raw bytes of every decoded message
template<typename F> void add_on_message_callback(F &&callback) {
this->message_callback_.add(std::forward<F>(callback));
}
protected:
static void dispatch_message(void *ctx, const MidiMessage &message) {
static_cast<BLEMidi *>(ctx)->handle_message_(message);
}
void handle_message_(const MidiMessage &message);
/// Wrap MIDI bytes in a BLE-MIDI packet and write them to the MIDI characteristic.
bool write_midi_(const uint8_t *midi_bytes, size_t length);
/// Write a packet whose MIDI bytes already sit at packet + 2; the header and timestamp are filled in.
bool write_packet_(uint8_t *packet, size_t midi_length);
/// Channel-voice message: the status byte takes the channel; out-of-range values are refused.
void send_channel_(uint8_t status, int32_t channel, int32_t data1, int32_t data2, size_t length);
/// Set a warning and drop the link so that ble_client reconnects and the setup runs again.
void fail_session_();
MidiParser parser_;
LazyCallbackManager<void()> connect_callback_;
LazyCallbackManager<void()> disconnect_callback_;
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_on_callback_;
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> note_off_callback_;
LazyCallbackManager<void(uint8_t, uint8_t, uint8_t)> control_change_callback_;
LazyCallbackManager<void(uint8_t, uint8_t)> program_change_callback_;
LazyCallbackManager<void(int16_t, uint8_t)> pitch_bend_callback_;
LazyCallbackManager<void(ByteSpan)> sysex_callback_;
LazyCallbackManager<void(ByteSpan)> message_callback_;
uint16_t characteristic_handle_{0};
// Notification descriptor of the MIDI characteristic; the link is ready once the device accepts the write to it
uint16_t cccd_handle_{0};
// The ATT MTU the link negotiated; every packet has to fit it
uint16_t mtu_{23};
bool notify_registered_{false};
// Write without response has lower latency and is preferred when the
// characteristic supports it.
bool write_without_response_{false};
const bool pair_;
};
} // namespace esphome::ble_midi
#endif // USE_ESP32
+198
View File
@@ -0,0 +1,198 @@
#include "midi_parser.h"
#include <cstring>
namespace esphome::ble_midi {
static MessageType type_for_status(uint8_t status) {
if (status >= 0xF8)
return MessageType::SYSTEM_REAL_TIME;
if (status >= 0xF1 && status <= 0xF6)
return MessageType::SYSTEM_COMMON;
switch (status & 0xF0) {
case 0x80:
return MessageType::NOTE_OFF;
case 0x90:
return MessageType::NOTE_ON;
case 0xA0:
return MessageType::POLY_AFTERTOUCH;
case 0xB0:
return MessageType::CONTROL_CHANGE;
case 0xC0:
return MessageType::PROGRAM_CHANGE;
case 0xD0:
return MessageType::CHANNEL_AFTERTOUCH;
case 0xE0:
return MessageType::PITCH_BEND;
default:
return MessageType::UNKNOWN;
}
}
// Data bytes that follow a channel-voice or system-common status byte
uint8_t MidiParser::expected_data_bytes(uint8_t status) {
if (status >= 0xF0) {
switch (status) {
case 0xF1: // MIDI Time Code Quarter Frame
case 0xF3: // Song Select
return 1;
case 0xF2: // Song Position Pointer
return 2;
default: // 0xF4, 0xF5 undefined, 0xF6 Tune Request
return 0;
}
}
switch (status & 0xF0) {
case 0xC0: // Program Change
case 0xD0: // Channel Aftertouch
return 1;
default:
return 2;
}
}
void MidiParser::reset() {
this->running_status_ = 0;
this->sysex_active_ = false;
this->sysex_length_ = 0;
this->current_length_ = 0;
}
void MidiParser::sysex_push_(uint8_t byte) {
if (this->sysex_length_ < this->sysex_capacity_) {
this->sysex_buffer_[this->sysex_length_++] = byte;
} else {
this->sysex_overflow_ = true;
}
}
// Ends the SysEx being assembled; it is complete when 0xF7 closed it and every byte fit the buffer
void MidiParser::end_sysex_(bool complete, MidiMessageHandler handler, void *ctx) {
this->sysex_active_ = false;
this->running_status_ = 0;
this->emit_(complete && !this->sysex_overflow_ ? MessageType::SYSEX : MessageType::SYSEX_INCOMPLETE,
this->sysex_buffer_, this->sysex_length_, handler, ctx);
}
void MidiParser::emit_current_(MidiMessageHandler handler, void *ctx) {
const uint8_t status = this->current_message_[0];
MessageType type = type_for_status(status);
// A Note On with velocity zero is canonically a Note Off.
if (type == MessageType::NOTE_ON && this->current_message_[2] == 0)
type = MessageType::NOTE_OFF;
// System Common clears running status; channel-voice messages set it.
this->running_status_ = status >= 0xF0 ? 0 : status;
const uint16_t length = this->current_length_;
this->current_length_ = 0;
this->emit_(type, this->current_message_, length, handler, ctx);
}
// Takes a data byte for the message being assembled, or starts one from the running status
void MidiParser::take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx) {
if (this->current_length_ == 0) {
if (this->running_status_ == 0) {
// Data byte without any status context.
this->emit_(MessageType::UNKNOWN, byte, 1, handler, ctx);
return;
}
this->current_message_[0] = this->running_status_;
this->current_length_ = 1;
this->current_expected_ = expected_data_bytes(this->running_status_);
}
this->current_message_[this->current_length_++] = *byte;
if (this->current_length_ > this->current_expected_)
this->emit_current_(handler, ctx);
}
void MidiParser::feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx) {
// A BLE-MIDI packet is at least a header byte plus one timestamp or SysEx
// continuation byte, and the header byte always has bit 7 set. Anything else
// is malformed.
if (length < 2 || (data[0] & 0x80) == 0) {
for (size_t i = 0; i < length; i++)
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
return;
}
// A timestamp byte precedes every status byte, even a real-time byte inside another message, so a byte with
// bit 7 set is a status byte only right after a timestamp and a timestamp everywhere else
bool expect_timestamp = true;
for (size_t i = 1; i < length; i++) {
const uint8_t byte = data[i];
if ((byte & 0x80) == 0) {
// A data byte continues the SysEx, the message being assembled, or the running status
if (this->sysex_active_) {
this->sysex_push_(byte);
} else {
this->take_data_(data + i, handler, ctx);
}
expect_timestamp = true;
continue;
}
if (expect_timestamp) {
expect_timestamp = false;
continue;
}
expect_timestamp = true;
if (byte >= 0xF8) {
// Real-time messages are single bytes that interrupt anything, a SysEx included, without affecting it
this->emit_(MessageType::SYSTEM_REAL_TIME, data + i, 1, handler, ctx);
continue;
}
// Any other status byte cuts a message still being assembled short
if (this->current_length_ > 0) {
const uint16_t length = this->current_length_;
this->current_length_ = 0;
this->emit_(MessageType::INCOMPLETE, this->current_message_, length, handler, ctx);
}
if (byte == STATUS_SYSEX_END) {
if (this->sysex_active_) {
this->sysex_push_(STATUS_SYSEX_END);
this->end_sysex_(true, handler, ctx);
} else {
this->emit_(MessageType::UNKNOWN, data + i, 1, handler, ctx);
}
continue;
}
// Any status byte but 0xF7 ends a SysEx still open
if (this->sysex_active_)
this->end_sysex_(false, handler, ctx);
if (byte == STATUS_SYSEX_START) {
this->sysex_active_ = true;
this->sysex_length_ = 0;
this->sysex_overflow_ = false;
this->sysex_push_(STATUS_SYSEX_START);
continue;
}
this->current_message_[0] = byte;
this->current_length_ = 1;
this->current_expected_ = expected_data_bytes(byte);
// A status byte without data bytes, e.g. Tune Request, is a message on its own
if (this->current_expected_ == 0)
this->emit_current_(handler, ctx);
}
}
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size) {
if (length == 0)
return 0;
const size_t skip_start = payload[0] == STATUS_SYSEX_START ? 1 : 0;
const size_t skip_end = payload[length - 1] == STATUS_SYSEX_END ? 1 : 0;
const size_t body = length - skip_start - skip_end;
// 0xF0, the body, then the timestamp before 0xF7
const size_t framed = body + 3;
if (framed > out_size)
return 0;
// A body byte with bit 7 set would be read as a timestamp or status byte
for (size_t i = 0; i < body; i++) {
if (payload[skip_start + i] & 0x80)
return 0;
}
out[0] = STATUS_SYSEX_START;
std::memcpy(out + 1, payload + skip_start, body);
out[body + 1] = PACKET_TIMESTAMP;
out[body + 2] = STATUS_SYSEX_END;
return framed;
}
} // namespace esphome::ble_midi
+131
View File
@@ -0,0 +1,131 @@
#pragma once
#include "esphome/core/progmem.h"
#include <cstddef>
#include <cstdint>
namespace esphome::ble_midi {
// Decoded MIDI message types. Status bytes have the high bit set; the low nibble of a channel-voice status byte
// (0x80-0xEF) is the channel, system messages (0xF0-0xFF) have none.
enum class MessageType : uint8_t {
NOTE_OFF, // 0x80
NOTE_ON, // 0x90
POLY_AFTERTOUCH, // 0xA0
CONTROL_CHANGE, // 0xB0
PROGRAM_CHANGE, // 0xC0
CHANNEL_AFTERTOUCH, // 0xD0
PITCH_BEND, // 0xE0
SYSEX, // 0xF0 ... 0xF7, possibly spread over several BLE packets
SYSEX_INCOMPLETE, // a SysEx cut short by another status byte or longer than the buffer
SYSTEM_COMMON, // 0xF1-0xF6, e.g. quarter frame, song position
SYSTEM_REAL_TIME, // 0xF8-0xFF, e.g. clock, start, stop
INCOMPLETE, // a channel or system common message cut short by another status byte
UNKNOWN, // malformed/undecodable byte
};
PROGMEM_STRING_TABLE(MessageTypeNames, "NOTE_OFF", "NOTE_ON", "POLY_AFTERTOUCH", "CONTROL_CHANGE", "PROGRAM_CHANGE",
"CHANNEL_AFTERTOUCH", "PITCH_BEND", "SYSEX", "SYSEX_INCOMPLETE", "SYSTEM_COMMON",
"SYSTEM_REAL_TIME", "INCOMPLETE", "UNKNOWN");
/// A decoded MIDI message. `data` points into the parser's storage or the packet being fed, so it is only valid
/// for the duration of the handler.
struct MidiMessage {
// Status byte followed by its data bytes; a SysEx includes the 0xF0/0xF7 wrappers
const uint8_t *data{nullptr};
uint16_t length{0};
MessageType type{MessageType::UNKNOWN};
uint8_t status() const { return this->data[0]; }
uint8_t channel() const { return this->data[0] & 0x0F; }
// The data bytes of a complete channel-voice message: note and velocity, controller and value, or program
uint8_t data1() const { return this->data[1]; }
uint8_t data2() const { return this->data[2]; }
// PITCH_BEND: 14-bit value re-centered on zero (-8192..8191).
int16_t pitch_bend() const {
return static_cast<int16_t>((static_cast<uint16_t>(this->data[2]) << 7) | this->data[1]) - 8192;
}
};
/// Called once per decoded message with the context passed to feed().
using MidiMessageHandler = void (*)(void *ctx, const MidiMessage &message);
/// Decoder for the BLE-MIDI packet format (Apple/MMA "Bluetooth Low Energy
/// MIDI" specification, section 3).
///
/// Packet layout:
/// [header] [timestamp] [MIDI 0] ... ([timestamp] [MIDI n])*
///
/// - header : 0b10hhhhhh, bit 7 set, bits 5-0 hold the high bits of the
/// timestamp
/// - timestamp : 0b1lllllll, bit 7 set, bits 6-0 hold the low bits of the
/// timestamp
///
/// A timestamp byte precedes every status byte, except where running status
/// applies. SysEx messages may span several BLE packets and may be interrupted
/// by real-time messages (0xF8-0xFF).
///
/// Timestamps are used for jitter reconstruction by MIDI hosts and are ignored
/// here.
///
/// This class holds no BLE or ESPHome state so that it can be exercised in
/// isolation.
class MidiParser final {
public:
/// `sysex_buffer` reassembles SysEx messages; one longer than `sysex_capacity` bytes is reported incomplete.
MidiParser(uint8_t *sysex_buffer, uint16_t sysex_capacity)
: sysex_buffer_(sysex_buffer), sysex_capacity_(sysex_capacity) {}
uint16_t sysex_capacity() const { return this->sysex_capacity_; }
/// Drop all parser state. Call on disconnect so running status and partial
/// messages do not leak into the next session.
void reset();
/// Feed one BLE notification payload. Undecodable bytes are reported as MessageType::UNKNOWN so the
/// caller can log them.
void feed(const uint8_t *data, size_t length, MidiMessageHandler handler, void *ctx);
/// Number of data bytes that follow a status byte other than SysEx.
static uint8_t expected_data_bytes(uint8_t status);
protected:
void emit_(MessageType type, const uint8_t *data, uint16_t length, MidiMessageHandler handler, void *ctx) {
handler(ctx, MidiMessage{data, length, type});
}
void emit_current_(MidiMessageHandler handler, void *ctx);
void take_data_(const uint8_t *byte, MidiMessageHandler handler, void *ctx);
void end_sysex_(bool complete, MidiMessageHandler handler, void *ctx);
void sysex_push_(uint8_t byte);
// SysEx reassembly across packets.
uint8_t *const sysex_buffer_;
const uint16_t sysex_capacity_;
uint16_t sysex_length_{0};
bool sysex_active_{false};
bool sysex_overflow_{false};
// Last channel-voice status byte, sticky across bytes and packets until a new
// status byte or a system-common message clears it (MIDI running status).
uint8_t running_status_{0};
// Message being assembled: status byte plus up to two data bytes.
uint8_t current_message_[3]{};
uint8_t current_length_{0};
uint8_t current_expected_{0};
};
// A BLE-MIDI packet starts with a header byte and a timestamp byte, both with bit 7 set. The remaining
// timestamp bits serve jitter reconstruction only and are left at zero when transmitting.
static constexpr uint8_t PACKET_HEADER = 0x80;
static constexpr uint8_t PACKET_TIMESTAMP = 0x80;
static constexpr uint8_t STATUS_SYSEX_START = 0xF0;
static constexpr uint8_t STATUS_SYSEX_END = 0xF7;
/// Frame a SysEx payload for one BLE-MIDI packet: 0xF0 and 0xF7 are added when the payload lacks them, with
/// the timestamp byte the specification requires before 0xF7. Returns the bytes written to `out`, or 0 when
/// the payload is empty, holds a byte with bit 7 set, or does not fit `out_size` once framed.
size_t frame_sysex(const uint8_t *payload, size_t length, uint8_t *out, size_t out_size);
} // namespace esphome::ble_midi
@@ -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))
+77 -24
View File
@@ -1,3 +1,4 @@
from functools import partial
from typing import Any
from esphome import automation
@@ -46,16 +47,24 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
CONF_TEMPERATURE_STEP,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
CONF_WEB_SERVER,
UNIT_KELVIN,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
TemperatureUnitResolver,
convert_visual_temperatures,
declare_fixed_temperature_unit,
entity_duplicate_validator,
queue_entity_register,
setup_entity,
setup_entity_temperature_unit,
temperature_in_entity_unit,
)
from esphome.cpp_generator import MockObj, MockObjClass
import esphome.final_validate as fv
from esphome.types import ConfigType, SafeExpType
IS_PLATFORM_COMPONENT = True
@@ -138,30 +147,33 @@ CONF_MIN_HUMIDITY = "min_humidity"
CONF_MAX_HUMIDITY = "max_humidity"
CONF_TARGET_HUMIDITY = "target_humidity"
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema(
{
cv.Required(CONF_TARGET_TEMPERATURE): visual_temperature,
cv.Required(CONF_CURRENT_TEMPERATURE): visual_temperature,
cv.Required(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
cv.Required(CONF_CURRENT_TEMPERATURE): cv.temperature_with_unit,
}
)
# Remove before 2027.5.0: external components imported it before temperature_with_unit replaced it
visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?")
def visual_temperature_step(value: Any) -> ConfigType:
# Allow defining target/current temperature steps separately
if isinstance(value, dict):
return VISUAL_TEMPERATURE_STEP_SCHEMA(value)
# Otherwise, use the single value for both properties
value = visual_temperature(value)
return VISUAL_TEMPERATURE_STEP_SCHEMA(
{
CONF_TARGET_TEMPERATURE: value,
CONF_CURRENT_TEMPERATURE: value,
}
)
value = cv.temperature_with_unit(value)
return {CONF_TARGET_TEMPERATURE: value, CONF_CURRENT_TEMPERATURE: value}
_visual_temperatures_in_entity_unit = partial(
convert_visual_temperatures,
delta_keys=(CONF_TARGET_TEMPERATURE, CONF_CURRENT_TEMPERATURE),
delta_section=CONF_TEMPERATURE_STEP,
)
_CLIMATE_SCHEMA = (
@@ -172,8 +184,8 @@ _CLIMATE_SCHEMA = (
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTClimateComponent),
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
{
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_TEMPERATURE_STEP): visual_temperature_step,
cv.Optional(CONF_MIN_HUMIDITY): cv.percentage_int,
cv.Optional(CONF_MAX_HUMIDITY): cv.percentage_int,
@@ -250,6 +262,24 @@ _CLIMATE_SCHEMA = (
_CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate"))
_CLIMATE_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
def _final_validate(config: list[ConfigType]) -> None:
# Runs once for the whole domain; converts what waited for a platform's unit resolver
full_config = fv.full_config.get()
for entry in config:
_visual_temperatures_in_entity_unit(entry, full_config=full_config)
FINAL_VALIDATE_SCHEMA = _final_validate
def _unit_mqtt_can_report(value: str) -> str:
# MQTT discovery only knows °C and °F
if value == UNIT_KELVIN and "mqtt" in CORE.loaded_integrations:
raise cv.Invalid("MQTT reports climate temperatures in °C or °F only")
return value
def climate_schema(
@@ -257,10 +287,26 @@ def climate_schema(
*,
entity_category: str = cv.UNDEFINED,
icon: str = cv.UNDEFINED,
temperature_unit: bool = False,
fixed_temperature_unit: str | TemperatureUnitResolver | None = None,
) -> cv.Schema:
"""``temperature_unit`` adds ``unit_of_measurement`` for a platform whose temperatures are all
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``.
``fixed_temperature_unit`` is for a device that reports in that unit only, set by its traits,
or a resolver ``(entity config, full config) -> unit`` when its own options pick the unit."""
if temperature_unit and fixed_temperature_unit is not None:
raise ValueError(
"a platform takes unit_of_measurement or has a fixed unit, not both"
)
if fixed_temperature_unit is not None:
declare_fixed_temperature_unit(class_, fixed_temperature_unit)
schema = {
cv.GenerateID(): cv.declare_id(class_),
}
if temperature_unit:
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.All(
cv.temperature_unit, _unit_mqtt_can_report
)
for key, default, validator in [
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
@@ -305,6 +351,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
if (max_humidity := visual.get(CONF_MAX_HUMIDITY)) is not None:
cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES")
cg.add(var.set_visual_max_humidity_override(max_humidity))
setup_entity_temperature_unit(var, config)
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
mqtt_ = cg.new_Pvariable(mqtt_id, var)
@@ -479,9 +526,9 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(Climate),
cv.Optional(CONF_MODE): cv.templatable(validate_climate_mode),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_AWAY): cv.invalid("Use preset instead"),
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable(
@@ -497,17 +544,23 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
)
TARGET_TEMPERATURE_APPLY_FIELDS = tuple(
automation.ApplyField(key, setter, cg.float_, temperature_in_entity_unit)
for key, setter in (
(CONF_TARGET_TEMPERATURE, "set_target_temperature"),
(CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low"),
(CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high"),
)
)
TARGET_TEMPERATURE_KEYS = tuple(
field.conf_key for field in TARGET_TEMPERATURE_APPLY_FIELDS
)
automation.register_apply_action(
"climate.control",
CLIMATE_CONTROL_ACTION_SCHEMA,
automation.ApplyField(CONF_MODE, "set_mode", ClimateMode),
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
),
*TARGET_TEMPERATURE_APPLY_FIELDS,
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
automation.ApplyField(
+6 -2
View File
@@ -77,6 +77,10 @@ class ClimateTraits {
friend class Climate; // Climate wires its custom mode lists into traits
public:
// The visual range a platform starts from, in °C
static constexpr float DEFAULT_VISUAL_MIN_TEMPERATURE = 10.0f;
static constexpr float DEFAULT_VISUAL_MAX_TEMPERATURE = 30.0f;
// Out of line on purpose: an inlined default init makes GCC copy the defaults from a .rodata
// template per traits() override, and .rodata is RAM on ESP8266.
ClimateTraits();
@@ -193,8 +197,8 @@ class ClimateTraits {
void set_supported_custom_presets_(const ClimateCustomModes *presets) { this->supported_custom_presets_ = presets; }
uint32_t feature_flags_{0};
float visual_min_temperature_{10};
float visual_max_temperature_{30};
float visual_min_temperature_{DEFAULT_VISUAL_MIN_TEMPERATURE};
float visual_max_temperature_{DEFAULT_VISUAL_MAX_TEMPERATURE};
float visual_target_temperature_step_{0.1};
float visual_current_temperature_step_{0.1};
float visual_min_humidity_{30};
+8 -2
View File
@@ -33,9 +33,11 @@ ClimateIR = climate_ir_ns.class_(
def climate_ir_schema(
class_: MockObjClass,
*,
fixed_temperature_unit: str | None = None,
) -> cv.Schema:
return (
climate.climate_schema(class_)
climate.climate_schema(class_, fixed_temperature_unit=fixed_temperature_unit)
.extend(
{
cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean,
@@ -52,8 +54,12 @@ def climate_ir_schema(
def climate_ir_with_receiver_schema(
class_: MockObjClass,
*,
fixed_temperature_unit: str | None = None,
) -> cv.Schema:
return climate_ir_schema(class_).extend(
return climate_ir_schema(
class_, fixed_temperature_unit=fixed_temperature_unit
).extend(
{
cv.Optional(remote_base.CONF_RECEIVER_ID): cv.use_id(
remote_base.RemoteReceiverBase
+5 -16
View File
@@ -13,19 +13,10 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(CopySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -40,6 +31,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.sensor");
void CopySensor::setup() {
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::copy {
class CopySensor final : public sensor::Sensor, public Component {
public:
void set_source(sensor::Sensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
sensor::Sensor *source_;
};
} // namespace esphome::copy
@@ -5,19 +5,10 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(CopyTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -28,6 +19,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
void CopyTextSensor::setup() {
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::copy {
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
public:
void set_source(text_sensor::TextSensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
text_sensor::TextSensor *source_;
};
} // namespace esphome::copy
@@ -5,6 +5,7 @@ from esphome.const import CONF_PASSWORD, CONF_PIN, CONF_TIME_ID
from esphome.types import ConfigType
CODEOWNERS = ["@epicRE"]
AUTO_LOAD = ["number"]
DEPENDENCIES = ["ble_client"]
DOMAIN = "delta_wallbox"
MULTI_CONF = True
@@ -4,6 +4,7 @@
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -54,6 +55,8 @@ static constexpr uint8_t CMD_GET_ERROR_CODE[] = {0x01, 0x03, 0x01, 0xB5, 0x00, 0
static constexpr uint8_t CMD_START_CHARGING[] = {0x01, 0x06, 0x01, 0x02, 0x00, 0x01};
static constexpr uint8_t CMD_STOP_CHARGING[] = {0x01, 0x06, 0x01, 0x03, 0x00, 0x01};
static constexpr uint16_t REG_MAX_CURRENT = 0x0112;
// Indexed by DeltaState
PROGMEM_STRING_TABLE(DeltaStateStrings, "CHECK_PAIRING", "CONNECTED", "GET_CHARGER_FW_VERSION", "GET_BT_FW_VERSION",
"GET_LOGIN_RESULT", "GET_LOGIN_RESULT0", "GET_LOGIN_ROLE", "GET_LOGIN_USER", "GET_MODEL",
@@ -103,6 +106,13 @@ static bool verify_crc(const uint8_t *data, size_t len) {
return calculated == received;
}
static uint64_t read_be(const uint8_t *p, size_t n) {
uint64_t value = 0;
for (size_t i = 0; i < n; i++)
value = (value << 8) | p[i];
return value;
}
// Login write body: 22-byte password and 6-byte user, both left-padded with zeros
static std::array<uint8_t, LOGIN_BODY_LEN> build_login_body(StringRef user, StringRef password) {
std::array<uint8_t, LOGIN_BODY_LEN> frame = {
@@ -448,15 +458,16 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
uint8_t byte_count = frame[2];
if (byte_count == CHARGING_INFO_BYTE_COUNT) {
// Parsing 0x011B charging info (9 registers)
uint32_t power_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
uint32_t energy_raw = (frame[7] << 24) | (frame[8] << 16) | (frame[9] << 8) | frame[10];
uint8_t minutes = frame[15];
uint8_t hours = frame[16];
uint64_t power_raw = std::min<uint64_t>(read_be(&frame[3], 7), 999900);
uint64_t energy_raw = read_be(&frame[10], 4);
uint8_t hours = std::min<uint8_t>(frame[14], 99);
uint8_t minutes = std::min<uint8_t>(frame[15], 59);
uint8_t seconds = std::min<uint8_t>(frame[16], 59);
uint8_t state_byte = frame[17];
float power_kw = power_raw / 1000.0f;
float energy_kwh = energy_raw / 1000.0f;
float charging_minutes = hours * 60 + minutes;
float charging_minutes = hours * 60.0f + minutes + seconds / 60.0f;
if (this->power_sensor_ != nullptr)
this->power_sensor_->publish_state(power_kw);
@@ -471,11 +482,13 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
this->send_frame_(CMD_GET_MAX_CURRENT, sizeof(CMD_GET_MAX_CURRENT));
} else if (byte_count == TWO_REGISTER_BYTE_COUNT) {
// Parsing 0x0112 max output setting (2 registers)
uint32_t current_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
float amps = current_raw / 10.0f;
if (this->max_current_sensor_ != nullptr) {
this->max_current_sensor_->publish_state(amps);
}
// frame[4] is the configured max (set by DIP switch in device)
// frame[5] the active limit
this->max_current_limit_ = frame[4];
if (this->max_current_sensor_ != nullptr)
this->max_current_sensor_->publish_state(frame[5]);
if (this->max_current_number_ != nullptr)
this->max_current_number_->publish_state(frame[5]);
// Poll the error code register every cycle
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
@@ -488,8 +501,15 @@ void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
this->error_code_text_sensor_->publish_state(hex_str);
}
} else if (frame[1] == FC_WRITE_SINGLE_REGISTER) {
// Confirmation frame of Start/Stop write actions
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
const uint16_t register_addr = (static_cast<uint16_t>(frame[2]) << 8) | frame[3];
if (register_addr == REG_MAX_CURRENT) {
ESP_LOGI(TAG, "Wallbox max current command acknowledged: %u A", frame[5]);
if (this->max_current_number_ != nullptr)
this->max_current_number_->publish_state(frame[5]);
} else {
// Confirmation frame of Start/Stop write actions
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
}
}
break;
}
@@ -503,6 +523,7 @@ void DeltaWallbox::reset_state_() {
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
this->rx_len_ = 0;
this->state_ = DeltaState::DELTA_STATE_IDLE;
this->max_current_limit_ = 0; // refuse writes until the next 0x0112 read
this->pairing_attempts_ = 0;
this->pairing_in_progress_ = false;
this->status_set_warning();
@@ -533,6 +554,35 @@ void DeltaWallbox::set_charging_state(bool state) {
}
}
void DeltaWallbox::set_max_current(float current) {
if (!this->is_connected()) {
ESP_LOGW(TAG, "Cannot set maximum current while wallbox is not fully connected.");
return;
}
const uint8_t upper = this->max_current_limit_;
if (upper == 0) {
ESP_LOGW(TAG, "Wallbox current limit not read yet; refusing to set %.0f A", current);
return;
}
const int32_t rounded = std::lround(current);
if (rounded < 1) {
ESP_LOGW(TAG, "Rejecting maximum current %d A", static_cast<int>(rounded));
return;
}
const uint8_t requested = static_cast<uint8_t>(std::min<int32_t>(rounded, upper));
if (rounded > upper) {
ESP_LOGW(TAG, "Requested %d A exceeds wallbox limit %u A; using %u A", static_cast<int>(rounded), upper, upper);
}
ESP_LOGI(TAG, "Setting wallbox maximum current to %u A", requested);
const std::array<uint8_t, 6> body = {0x01,
FC_WRITE_SINGLE_REGISTER,
static_cast<uint8_t>(REG_MAX_CURRENT >> 8),
static_cast<uint8_t>(REG_MAX_CURRENT & 0xFF),
0x00,
requested};
this->send_frame_(body.data(), body.size());
}
void DeltaWallbox::start_charging() { this->set_charging_state(true); }
void DeltaWallbox::stop_charging() { this->set_charging_state(false); }
@@ -5,6 +5,7 @@
#include <array>
#include "esphome/core/component.h"
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/components/number/number.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/core/helpers.h"
@@ -56,12 +57,14 @@ class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingCo
SUB_SENSOR(charging_time)
SUB_SENSOR(charger_state)
SUB_SENSOR(max_current)
SUB_NUMBER(max_current) // settable max-current number entity
SUB_TEXT_SENSOR(serial_number)
SUB_TEXT_SENSOR(fw_version)
SUB_TEXT_SENSOR(bt_fw_version)
SUB_TEXT_SENSOR(model_name)
SUB_TEXT_SENSOR(error_code)
void set_charging_state(bool state);
void set_max_current(float current);
void start_charging();
void stop_charging();
@@ -95,6 +98,7 @@ class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingCo
uint16_t tx_char_handle_{0};
DeltaState state_{DeltaState::DELTA_STATE_IDLE};
uint8_t pairing_attempts_{0};
uint8_t max_current_limit_{0};
bool has_pin_{false};
bool pairing_in_progress_{false};
std::array<uint8_t, MAX_FRAME_LEN> rx_buffer_{};
@@ -0,0 +1,34 @@
import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import CONF_MAX_CURRENT, ENTITY_CATEGORY_CONFIG, UNIT_AMPERE
from esphome.types import ConfigType
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
DEPENDENCIES = ["delta_wallbox"]
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
cv.Optional(CONF_MAX_CURRENT): number.number_schema(
number.ApplyNumber,
unit_of_measurement=UNIT_AMPERE,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:flash",
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
numbers = number.sub_numbers(config, parent=config[CONF_DELTA_WALLBOX_ID])
await numbers(
CONF_MAX_CURRENT,
hub.set_max_current_number,
min_value=6,
max_value=32,
step=1,
apply="set_max_current(value)",
)
+30 -1
View File
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Any, Protocol
from esphome import automation, core
from esphome import automation, core, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components.const import (
@@ -13,6 +14,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_DIMENSIONS,
CONF_ENABLE_PIN,
CONF_FROM,
CONF_HEIGHT,
CONF_ID,
@@ -54,6 +56,33 @@ DISPLAY_ROTATIONS = {
}
class DisplayModel(Protocol):
"""The parts of a display model used by enable_pin_schema()."""
def get_default(self, key: str, fallback: Any = ...) -> Any: ...
def enable_pin_schema(
model: DisplayModel, config: ConfigType
) -> dict[cv.Optional, Any]:
"""
Get the schema entry for a display's enable pins.
Pins given in the user config are used as they are. Otherwise the model's default enable
pins are used, except any that is also used elsewhere in the config, so a user can take
control of that pin, e.g. with a switch.
:param config: The display config as given by the user
"""
schema = cv.ensure_list(pins.gpio_output_pin_schema)
default = model.get_default(CONF_ENABLE_PIN, None)
if default is None or CONF_ENABLE_PIN in config:
return {cv.Optional(CONF_ENABLE_PIN): schema}
return {
cv.Optional(
CONF_ENABLE_PIN, default=default
): pins.PIN_SCHEMA_REGISTRY.optional(schema)
}
def validate_rotation(value):
value = cv.string(value)
value = value.removesuffix("°")
+1 -1
View File
@@ -146,7 +146,7 @@ def model_schema(config):
cv.GenerateID(): cv.declare_id(class_name),
cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(model),
model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema),
**display.enable_pin_schema(model, config),
model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list(
map_sequence
),
@@ -1,8 +1,8 @@
from typing import Any, Self
from esphome.components.mipi import check_model_requirements
import esphome.config_validation as cv
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
@@ -128,24 +128,5 @@ class EpaperModel:
return model
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
"""
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
"""Check that the components this model needs are configured, see check_model_requirements()."""
check_model_requirements(self.name, self.defaults)
+5 -2
View File
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import climate_ir, remote_base
import esphome.config_validation as cv
from esphome.const import CONF_MODEL, CONF_SENSOR
from esphome.const import CONF_MODEL, CONF_SENSOR, UNIT_FAHRENHEIT
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
@@ -13,7 +13,10 @@ FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR)
Model = friedrich_ns.enum("Model", is_class=True)
MODELS = Model.options("MW12Y3H", prefix="MODEL_")
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend(
# The unit reports in Fahrenheit (traits() in friedrich.cpp), so visual temperatures are kept in it
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(
FriedrichClimate, fixed_temperature_unit=UNIT_FAHRENHEIT
).extend(
{
cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True),
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
@@ -15,34 +15,25 @@ ESPHOME_LOG_TAG(TAG, "http_request.ota");
void OtaHttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates via HTTP request"); };
OtaHttpRequestComponent::Request &OtaHttpRequestComponent::get_request_() {
if (this->request_ == nullptr)
this->request_ = make_unique<Request>();
return *this->request_;
}
void OtaHttpRequestComponent::set_md5_url(const std::string &url) {
if (!this->validate_url_(url)) {
if (this->request_ != nullptr)
this->request_->md5_url.clear(); // URL was not valid; prevent flashing until it is
this->md5_url_.clear(); // URL was not valid; prevent flashing until it is
return;
}
Request &request = this->get_request_();
request.md5_url = url;
request.md5_expected.clear(); // to be retrieved later
this->md5_url_ = url;
this->md5_expected_.clear(); // to be retrieved later
}
void OtaHttpRequestComponent::set_url(const std::string &url) {
if (!this->validate_url_(url)) {
if (this->request_ != nullptr)
this->request_->url.clear(); // URL was not valid; prevent flashing until it is
this->url_.clear(); // URL was not valid; prevent flashing until it is
return;
}
this->get_request_().url = url;
this->url_ = url;
}
void OtaHttpRequestComponent::flash() {
if (this->request_ == nullptr || this->request_->url.empty()) {
if (this->url_.empty()) {
ESP_LOGE(TAG, "URL not set; cannot start update");
return;
}
@@ -67,8 +58,8 @@ void OtaHttpRequestComponent::flash() {
#ifdef USE_OTA_STATE_LISTENER
this->notify_state_(ota::OTA_ERROR, 0.0f, ota_status);
#endif
this->request_->md5_computed.clear(); // will be reset at next attempt
this->request_->md5_expected.clear(); // will be reset at next attempt
this->md5_computed_.clear(); // will be reset at next attempt
this->md5_expected_.clear(); // will be reset at next attempt
break;
}
}
@@ -90,18 +81,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
md5::MD5Digest md5_receive;
char md5_receive_str[33];
if (this->request_->md5_expected.empty() && !this->http_get_md5_()) {
if (this->md5_expected_.empty() && !this->http_get_md5_()) {
return OTA_MD5_INVALID;
}
ESP_LOGD(TAG, "MD5 expected: %s", this->request_->md5_expected.c_str());
ESP_LOGD(TAG, "MD5 expected: %s", this->md5_expected_.c_str());
auto url_with_auth = this->get_url_with_auth_(this->request_->url);
auto url_with_auth = this->get_url_with_auth_(this->url_);
if (url_with_auth.empty()) {
return OTA_BAD_URL;
}
ESP_LOGVV(TAG, "url_with_auth: %s", url_with_auth.c_str());
ESP_LOGI(TAG, "Connecting to: %s", this->request_->url.c_str());
ESP_LOGI(TAG, "Connecting to: %s", this->url_.c_str());
auto container = this->parent_->get(url_with_auth);
@@ -188,9 +179,9 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
// verify MD5 is as expected and act accordingly
md5_receive.calculate();
md5_receive.get_hex(md5_receive_str);
this->request_->md5_computed = md5_receive_str;
if (strncmp(this->request_->md5_computed.c_str(), this->request_->md5_expected.c_str(), MD5_SIZE) != 0) {
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->request_->md5_computed.c_str());
this->md5_computed_ = md5_receive_str;
if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) {
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str());
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH;
} else {
@@ -232,15 +223,11 @@ static std::string url_encode(const std::string &str) {
return result;
}
void OtaHttpRequestComponent::set_password(const std::string &password) {
this->get_request_().password = url_encode(password);
}
void OtaHttpRequestComponent::set_username(const std::string &username) {
this->get_request_().username = url_encode(username);
}
void OtaHttpRequestComponent::set_password(const std::string &password) { this->password_ = url_encode(password); }
void OtaHttpRequestComponent::set_username(const std::string &username) { this->username_ = url_encode(username); }
std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url) {
if (this->request_->username.empty() || this->request_->password.empty()) {
if (this->username_.empty() || this->password_.empty()) {
return url;
}
@@ -253,23 +240,23 @@ std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url)
ESP_LOGD(TAG, "Using basic HTTP authentication");
start_char += 3; // skip '://' characters
auto url_with_auth = url.substr(0, start_char) + this->request_->username + ":" + this->request_->password + "@" +
url.substr(start_char);
auto url_with_auth =
url.substr(0, start_char) + this->username_ + ":" + this->password_ + "@" + url.substr(start_char);
return url_with_auth;
}
bool OtaHttpRequestComponent::http_get_md5_() {
if (this->request_->md5_url.empty()) {
if (this->md5_url_.empty()) {
return false;
}
auto url_with_auth = this->get_url_with_auth_(this->request_->md5_url);
auto url_with_auth = this->get_url_with_auth_(this->md5_url_);
if (url_with_auth.empty()) {
return false;
}
ESP_LOGVV(TAG, "url_with_auth: %s", url_with_auth.c_str());
ESP_LOGI(TAG, "Connecting to: %s", this->request_->md5_url.c_str());
ESP_LOGI(TAG, "Connecting to: %s", this->md5_url_.c_str());
auto container = this->parent_->get(url_with_auth);
if (container == nullptr) {
ESP_LOGE(TAG, "Failed to connect to MD5 URL");
@@ -286,8 +273,8 @@ bool OtaHttpRequestComponent::http_get_md5_() {
return false;
}
this->request_->md5_expected.resize(MD5_SIZE);
auto result = http_read_fully(container.get(), (uint8_t *) this->request_->md5_expected.data(), MD5_SIZE, MD5_SIZE,
this->md5_expected_.resize(MD5_SIZE);
auto result = http_read_fully(container.get(), (uint8_t *) this->md5_expected_.data(), MD5_SIZE, MD5_SIZE,
this->parent_->get_timeout());
container->end();
@@ -27,36 +27,29 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void set_md5_url(const std::string &md5_url);
void set_md5(const std::string &md5) { this->get_request_().md5_expected = md5; }
void set_md5(const std::string &md5) { this->md5_expected_ = md5; }
void set_password(const std::string &password);
void set_url(const std::string &url);
void set_username(const std::string &username);
std::string md5_computed() { return this->request_ != nullptr ? this->request_->md5_computed : std::string(); }
std::string md5_expected() { return this->request_ != nullptr ? this->request_->md5_expected : std::string(); }
std::string md5_computed() { return this->md5_computed_; }
std::string md5_expected() { return this->md5_expected_; }
void flash();
protected:
/// Allocated by the first setter, which only runs before a flash, and never freed, so a flash never loses it.
struct Request {
std::string md5_computed;
std::string md5_expected;
std::string md5_url;
std::string password;
std::string username;
std::string url;
};
Request &get_request_();
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container, bool abort_backend);
// Only reached from flash(), which returns early while request_ is null
uint8_t do_ota_();
std::string get_url_with_auth_(const std::string &url);
bool http_get_md5_();
bool validate_url_(const std::string &url);
std::unique_ptr<Request> request_;
std::string md5_computed_{};
std::string md5_expected_{};
std::string md5_url_{};
std::string password_{};
std::string username_{};
std::string url_{};
int status_ = -1;
static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size
};
+5 -2
View File
@@ -2,7 +2,7 @@ import esphome.codegen as cg
from esphome.components import climate_ir
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
import esphome.config_validation as cv
from esphome.const import CONF_SENSOR
from esphome.const import CONF_SENSOR, UNIT_FAHRENHEIT
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
@@ -11,7 +11,10 @@ CODEOWNERS = ["@ipopov"]
islandaire_ns = cg.esphome_ns.namespace("islandaire")
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
# The unit reports in Fahrenheit (traits() in islandaire.cpp), so visual temperatures are kept in it
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(
IslandaireClimate, fixed_temperature_unit=UNIT_FAHRENHEIT
).extend(
{
# The unit has no auto (heat/cool) mode
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
+1 -3
View File
@@ -274,9 +274,7 @@ def _model_schema(config: ConfigType) -> cv.Schema:
cv.Optional(CONF_UPDATE_MODE): update_mode,
# One or more GPIOs driven high during setup to power on the panel
# (e.g. board power-enable rails), before reset and init.
cv.Optional(
CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, [])
): cv.ensure_list(pins.gpio_output_pin_schema),
**display.enable_pin_schema(model, config),
cv.Optional(CONF_RESET_DURATION): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=core.TimePeriod(milliseconds=500)),
+16 -2
View File
@@ -1,3 +1,5 @@
import logging
from esphome import automation, pins
import esphome.codegen as cg
import esphome.config_validation as cv
@@ -15,6 +17,8 @@ from esphome.core import CORE
from esphome.cpp_helpers import gpio_pin_expression
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@max246"]
DOMAIN = "lightwaverf"
@@ -35,10 +39,20 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.polling_component_schema("1s"))
def _drop_unused_name(config: ConfigType) -> ConfigType:
# Remove before 2027.5.0
if config.pop(CONF_NAME, None) is not None:
_LOGGER.warning(
"[lightwaverf] 'name' on lightwaverf.send_raw has no effect; remove it. "
"Removed in 2027.5.0"
)
return config
LIGHTWAVE_SEND_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent),
cv.Required(CONF_NAME): cv.string,
cv.Optional(CONF_NAME): cv.string,
cv.Required(CONF_CODE): cv.All(
[cv.Any(cv.hex_uint8_t)],
cv.Length(min=10),
@@ -47,7 +61,7 @@ LIGHTWAVE_SEND_SCHEMA = cv.Schema(
cv.Optional(CONF_INVERTED, default=False): cv.boolean,
cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_,
}
)
).add_extra(_drop_unused_name)
automation.register_apply_action(
+33 -8
View File
@@ -28,6 +28,7 @@ from esphome.const import (
CONF_X,
CONF_Y,
)
from esphome.cpp_generator import MockObj
from esphome.cpp_types import FixedVector
from ..automation import action_to_code
@@ -41,6 +42,7 @@ from ..defines import (
CONF_RADIUS,
CONF_SRC,
CONF_START_ANGLE,
LValidator,
addr,
literal,
)
@@ -57,7 +59,7 @@ from ..lv_validation import (
)
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
from ..types import LvType, ObjUpdateAction, lv_gradient_t, lv_point_precise_t
from . import Widget, WidgetType, get_widgets
from .img import CONF_IMAGE
@@ -221,7 +223,6 @@ RECT_PROPS = {
"radius",
"bg_opa",
"bg_color",
"bg_grad",
"border_color",
"border_width",
"border_opa",
@@ -239,6 +240,30 @@ RECT_PROPS = {
"shadow_opa",
)
}
RECT_PROPS.update(
bg_grad=LValidator(
cv.use_id(lv_gradient_t), lv_gradient_t, retmapper=lambda v: MockObj(f"*{v}")
)
)
RECT_SCHEMA = {
**{cv.Optional(prop): validator for prop, validator in RECT_PROPS.items()},
cv.Optional(CONF_OPA): cv.invalid(
"Rectangles have no single opacity; use bg_opa, border_opa, outline_opa or shadow_opa"
),
}
POLYGON_PROPS = {
p: RECT_PROPS[p] for p in ("border_color", "border_width", "border_opa")
}
POLYGON_SCHEMA = {
**{cv.Optional(prop): validator for prop, validator in POLYGON_PROPS.items()},
**{
cv.Optional(prop): cv.invalid(
"Polygons are drawn as an outline; use border_color, border_width and border_opa"
)
for prop in (*RECT_PROPS, CONF_OPA)
if prop not in POLYGON_PROPS
},
}
def _draw_line(layer, dsc, points):
@@ -264,10 +289,10 @@ def _draw_line(layer, dsc, points):
ObjUpdateAction,
cv.Schema(
{
**DRAW_OPA_SCHEMA,
**DRAW_SCHEMA,
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
**RECT_SCHEMA,
}
),
synchronous=True,
@@ -423,8 +448,8 @@ async def canvas_draw_image(config, action_id, template_arg, args):
LINE_PROPS = {
"width": STYLE_PROPS["line_width"],
"color": STYLE_PROPS["line_color"],
"dash-width": STYLE_PROPS["line_dash_width"],
"dash-gap": STYLE_PROPS["line_dash_gap"],
"dash_width": STYLE_PROPS["line_dash_width"],
"dash_gap": STYLE_PROPS["line_dash_gap"],
"round_start": lv_bool,
"round_end": lv_bool,
}
@@ -474,7 +499,7 @@ async def canvas_draw_line(config, action_id, template_arg, args):
{
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
**POLYGON_SCHEMA,
},
).add_extra(_validate_points),
synchronous=True,
@@ -503,7 +528,7 @@ async def canvas_draw_polygon(config, action_id, template_arg, args):
_draw_line(layer, line_dsc, points)
return await draw_to_code(
config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args
config, "rect", POLYGON_PROPS, do_draw_polygon, action_id, template_arg, args
)
+19 -5
View File
@@ -17,6 +17,7 @@ from esphome.const import (
CONF_WIDTH,
CONF_X,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.cpp_types import nullptr
@@ -64,7 +65,7 @@ from ..lv_validation import (
)
from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj
from ..schemas import STATE_SCHEMA
from ..styles import LVStyle
from ..styles import LVStyle, create_style
from ..types import (
LV_EVENT,
LvCompound,
@@ -100,6 +101,12 @@ CONF_SCALES = "scales"
CONF_STRIDE = "stride"
CONF_TICK_STYLE = "tick_style"
def _arc_style_name(indicator_id: str) -> str:
"""Name of the style variable generated for an arc indicator."""
return f"meter_arc_{indicator_id}_style"
# LVGL 9.4 Migration: Use scale widget instead of removed meter widget
#
# The lv_meter widget was removed in LVGL 9.4 and replaced with the more
@@ -423,11 +430,9 @@ class MeterType(WidgetType):
get_warnings().add(
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
)
arc_style = LVStyle(f"meter_arc_{iid.id}", props)
style_var = await create_style(_arc_style_name(iid.id), props)
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
lv.scale_section_set_style(
tvar, LV_PART.MAIN, await arc_style.get_var()
)
lv.scale_section_set_style(tvar, LV_PART.MAIN, style_var)
lw = Widget.create(iid, tvar, arc_indicator_type)
lw.parent = scale_var
await set_indicator_values(scale_var, lw, v)
@@ -610,6 +615,15 @@ async def indicator_update_to_code(config, action_id, template_arg, args):
async def set_value(w: Widget):
await set_indicator_values(w.parent, w, config)
if (opa := config.get(CONF_OPA)) is None:
return
opa = await opacity.process(opa)
if w.type is arc_indicator_type:
style = await cg.get_variable(ID(_arc_style_name(str(w.var))))
lv.style_set_arc_opa(style, opa)
lv_obj.invalidate(w.parent)
else:
lv_obj.set_style_opa(w.obj, opa, LV_PART.MAIN)
return await action_to_code(widget, set_value, action_id, template_arg, args)
+56 -20
View File
@@ -4,10 +4,12 @@
from collections.abc import Callable
import functools
import importlib.util
from typing import Any, Self
import voluptuous as vol
from esphome import pins
from esphome.components.const import CONF_COLOR_DEPTH
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
import esphome.config_validation as cv
@@ -23,6 +25,7 @@ from esphome.const import (
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL,
CONF_NUMBER,
CONF_OFFSET_HEIGHT,
CONF_OFFSET_WIDTH,
CONF_PAGES,
@@ -306,6 +309,57 @@ def model_schema_extractor(
return decorate
def _is_pin_expander(key: str) -> bool:
"""Check if a pin config key names a component that provides pins, e.g. an I/O expander."""
from esphome import loader
if key not in pins.PIN_SCHEMA_REGISTRY:
if importlib.util.find_spec(f"esphome.components.{key}") is None:
return False
# Loading the component registers its pin schema. If it fails to load (the
# loader logs why), still require it rather than quietly dropping it.
if loader.get_component(key) is None:
return True
return key in pins.PIN_SCHEMA_REGISTRY
def _pin_expanders(value: Any) -> set[str]:
"""Find the pin expander components used by any pin configs in a model's defaults."""
if isinstance(value, (list, tuple)):
return set().union(*(_pin_expanders(v) for v in value))
if not isinstance(value, dict):
return set()
found = set().union(*(_pin_expanders(v) for v in value.values()))
if CONF_NUMBER in value:
found.update(k for k in value if k != CONF_NUMBER and _is_pin_expander(k))
return found
def check_model_requirements(name: str, defaults: dict[str, Any]) -> None:
"""
Raise a friendly error if any component a display model requires is not configured.
These are the components in its ``requires`` default plus any pin expander used by its
default pins, which is needed even if the user config overrides those pins.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
"""
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
requirements = set(defaults.get("requires", ())) | _pin_expanders(defaults)
if missing := {x for x in requirements if x not in global_config}:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
class DriverChip:
"""
A class representing a MIPI DBI driver chip model.
@@ -671,32 +725,14 @@ class DriverChip:
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
Check that the components this model needs are configured, see check_model_requirements().
Also logs a warning if the model is deprecated.
"""
if deprecation_reason := self.get_default("deprecation_reason"):
LOGGER.warning(
"Display model %s is deprecated: %s", self.name, deprecation_reason
)
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
check_model_requirements(self.name, self.defaults)
def requires_buffer(config) -> bool:
+8 -11
View File
@@ -85,7 +85,7 @@ COLOR_DEPTHS = {
}
def model_schema(config: ConfigType) -> cv.All:
def model_schema(config: ConfigType) -> cv.Schema:
model = MODELS[config[CONF_MODEL].upper()]
transform = model.transform_schema()
# CUSTOM model will need to provide a custom init sequence
@@ -97,16 +97,14 @@ def model_schema(config: ConfigType) -> cv.All:
# Dimensions are optional if the model has a default width
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
schema = display.FULL_DISPLAY_SCHEMA.extend(
return display.FULL_DISPLAY_SCHEMA.extend(
{
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
cv.GenerateID(): cv.declare_id(MipiDsi),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
model.get_default(CONF_DRAW_ROUNDING, 1)
),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True),
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
@@ -140,11 +138,6 @@ def model_schema(config: ConfigType) -> cv.All:
model.option(CONF_VSYNC_FRONT_PORCH): cv.int_,
}
)
return cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32P4]),
)
@model_schema_extractor(MODELS, model_schema)
@@ -155,9 +148,13 @@ def _config_schema(config: ConfigType) -> ConfigType:
},
extra=cv.ALLOW_EXTRA,
)(config)
config = model_schema(config)(config)
config = cv.All(cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32P4]))(
config
)
# Check before the schema, which would fail on default pins of a missing expander
model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
config = model_schema(config)(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
@@ -14,7 +14,7 @@ DsiDriverChip(
pclk_frequency="60MHz",
lane_bit_rate="730Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
@@ -69,7 +69,7 @@ TAB5_ST7123 = DsiDriverChip(
pclk_frequency="80MHz",
lane_bit_rate="960Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0x01,),
@@ -122,7 +122,7 @@ DsiDriverChip(
pclk_frequency="70MHz",
lane_bit_rate="965Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0x01,),
+1 -1
View File
@@ -17,7 +17,7 @@ DsiDriverChip(
color_order="RGB",
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
reset_pin={"xl9535": None, "number": 2},
requires={"psram", "xl9535"},
requires={"psram"},
initsequence=(
(0xE0, 0x00),
(0xE1, 0x93),
+3 -5
View File
@@ -154,9 +154,7 @@ def model_schema(config: ConfigType) -> cv.Schema:
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
model.get_default(CONF_DRAW_ROUNDING, 1)
),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True),
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
@@ -226,14 +224,14 @@ def _config_schema(config: ConfigType) -> ConfigType:
},
extra=cv.ALLOW_EXTRA,
)(config)
schema = model_schema(config)
config = cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]),
)(config)
# Check before the schema, which would fail on default pins of a missing expander
model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
config = model_schema(config)(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
+2 -2
View File
@@ -9,7 +9,7 @@ ST7701S(
spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 17},
reset_pin={"xl9535": None, "number": 5},
requires={"psram", "xl9535"},
requires={"psram"},
hsync_pin=39,
vsync_pin=40,
pclk_pin=41,
@@ -57,7 +57,7 @@ t_rgb = ST7701S(
invert_colors=False,
spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 3},
requires={"psram", "xl9535"},
requires={"psram"},
de_pin=45,
hsync_pin=47,
vsync_pin=41,
@@ -115,7 +115,7 @@ st7701s.extend(
vsync_pulse_width=8,
vsync_back_porch=20,
cs_pin={"pca9554": None, "number": 4},
requires={"psram", "pca9554"},
requires={"psram"},
de_pin=18,
hsync_pin=16,
vsync_pin=17,
@@ -12,7 +12,7 @@ wave_4_3 = RgbDriverChip(
pclk_frequency="16MHz",
reset_pin={"ch422g": None, "number": 3},
enable_pin={"ch422g": None, "number": 2},
requires={"psram", "ch422g"},
requires={"psram"},
de_pin=5,
hsync_pin={"number": 46, "ignore_strapping_warning": True},
vsync_pin={"number": 3, "ignore_strapping_warning": True},
+1 -3
View File
@@ -197,9 +197,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
{
cv.GenerateID(): cv.declare_id(MipiSpi),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
COLOR_ORDERS, upper=True
),
@@ -22,7 +22,6 @@ DriverChip(
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
use_axis_flips=True,
reset_delay=20,
requires={"pca9554"},
initsequence=(
delay(100),
(0xFF, 0x20, 0x10, 0x10),
+15 -1
View File
@@ -12,8 +12,12 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_UART_ID,
CONF_UPDATE_INTERVAL,
CONF_USE_FAHRENHEIT,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
)
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
@@ -69,7 +73,17 @@ def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType:
return config
_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend(
def _temperature_unit(config: ConfigType, full_config: ConfigType) -> str:
# The hub's use_fahrenheit picks the unit in traits(); a legacy climate-owned hub has no option
if (hub_id := config.get(CONF_MITSUBISHI_CN105_ID)) is None:
return UNIT_CELSIUS
hub = fv.get_declaring_config(hub_id, full_config)
return UNIT_FAHRENHEIT if hub.get(CONF_USE_FAHRENHEIT) else UNIT_CELSIUS
_BASE_SCHEMA = climate.climate_schema(
MitsubishiCN105Climate, fixed_temperature_unit=_temperature_unit
).extend(
{
cv.Optional(
CONF_SUPPORTED_SWING_MODES, default="OFF"
+11 -1
View File
@@ -1,6 +1,6 @@
from collections.abc import Iterator
from esphome import automation
from esphome import automation, preferences
import esphome.codegen as cg
from esphome.components import logger, network, socket
from esphome.components.const import CONF_VERIFY_SSL
@@ -107,6 +107,7 @@ def AUTO_LOAD():
CONF_TLS = "tls"
CONF_IDF_SEND_ASYNC = "idf_send_async"
CONF_WAIT_FOR_CONNECTION = "wait_for_connection"
CONF_PERSIST_SUBSCRIPTIONS = "persist_subscriptions"
CONF_TRANSPORT = "transport"
CONF_WS_PATH = "ws_path"
@@ -304,6 +305,10 @@ def validate_config(value):
raise cv.Invalid(
f"'{CONF_WS_PATH}' is only valid when 'transport' is 'ws' or 'wss'"
)
if value.get(CONF_PERSIST_SUBSCRIPTIONS) and value.get(CONF_CLEAN_SESSION):
raise cv.Invalid(
f"'{CONF_PERSIST_SUBSCRIPTIONS}' requires '{CONF_CLEAN_SESSION}: false'"
)
# Populate default fields
out = value.copy()
@@ -394,6 +399,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_USERNAME, default=""): cv.string,
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean,
cv.Optional(CONF_PERSIST_SUBSCRIPTIONS, default=False): cv.boolean,
cv.Optional(CONF_CLIENT_ID): cv.string,
cv.Optional(
CONF_TRANSPORT, default=MQTT_TRANSPORT_TCP
@@ -666,6 +672,10 @@ async def to_code(config):
cg.add(var.set_username(config[CONF_USERNAME]))
cg.add(var.set_password(config[CONF_PASSWORD]))
cg.add(var.set_clean_session(config[CONF_CLEAN_SESSION]))
if config[CONF_PERSIST_SUBSCRIPTIONS]:
cg.add_define("USE_MQTT_SESSION_PERSISTENCE")
# The session fingerprint, a uint32_t kept in RTC memory
preferences.reserve_preferences(1, 4, in_flash=False)
cg.add_optional(var.set_client_id, config.get(CONF_CLIENT_ID))
discovery = config[CONF_DISCOVERY]
+1 -1
View File
@@ -39,7 +39,7 @@ class MQTTBackend {
public:
using on_connect_callback_t = void(bool session_present);
using on_disconnect_callback_t = void(MQTTClientDisconnectReason reason);
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t qos);
using on_subscribe_callback_t = void(uint16_t packet_id, uint8_t return_code);
using on_unsubscribe_callback_t = void(uint16_t packet_id);
using on_message_callback_t = void(const char *topic, const char *payload, size_t len, size_t index, size_t total);
using on_publish_user_callback_t = void(uint16_t packet_id);
+13 -2
View File
@@ -170,8 +170,9 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) {
case MQTT_EVENT_SUBSCRIBED:
ESP_LOGV(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event.msg_id);
// hardcode QoS to 0. QoS is not used in this context but required to mirror the AsyncMqtt interface
this->on_subscribe_.call((int) event.msg_id, 0);
// One topic per SUBSCRIBE, so data[0] is its return code
this->on_subscribe_.call((int) event.msg_id,
event.data.empty() ? SUBACK_FAILURE : static_cast<uint8_t>(event.data[0]));
break;
case MQTT_EVENT_UNSUBSCRIBED:
ESP_LOGV(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event.msg_id);
@@ -224,6 +225,16 @@ void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t b
if (msg_id == instance->tracked_msg_id_.load())
instance->tracked_acked_.store(1);
}
#ifdef USE_MQTT_SESSION_PERSISTENCE
// Also counted here: the queue holds 31 events, too few for the SUBACKs of a device with many command topics
if (event_id == MQTT_EVENT_CONNECTED) {
instance->subacks_.reset();
} else if (event_id == MQTT_EVENT_SUBSCRIBED) {
const auto *mqtt_event = static_cast<esp_mqtt_event_t *>(event_data);
// One topic per SUBSCRIBE, so data[0] is its return code
instance->subacks_.add(mqtt_event->data_len > 0 ? static_cast<uint8_t>(mqtt_event->data[0]) : SUBACK_FAILURE);
}
#endif
// queue event to decouple processing from ESP-IDF MQTT task to main loop
auto *event = instance->mqtt_event_pool_.allocate();
if (event == nullptr) {
@@ -14,6 +14,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h"
#include "esphome/core/event_pool.h"
#include "mqtt_subscription_fingerprint.h"
namespace esphome::mqtt {
@@ -214,6 +215,9 @@ class MQTTBackendESP32 final : public MQTTBackend {
using MQTTBackend::publish;
bool publish_tracked(const MQTTMessage &message) final;
bool publish_acknowledged() const final { return this->tracked_acked_.load(std::memory_order_acquire) != 0; }
#ifdef USE_MQTT_SESSION_PERSISTENCE
const SubackCounter &get_subacks() const { return this->subacks_; }
#endif
void loop() final;
@@ -294,6 +298,10 @@ class MQTTBackendESP32 final : public MQTTBackend {
std::atomic<uint16_t> acked_msg_id_{0};
// uint8_t, not bool: GCC on Xtensa emits an out-of-line call for atomic<bool>
std::atomic<uint8_t> tracked_acked_{0};
#ifdef USE_MQTT_SESSION_PERSISTENCE
// Counted in the ESP-IDF MQTT task, see mqtt_event_handler()
SubackCounter subacks_;
#endif
// Pool sized to queue capacity (SIZE-1) because LockFreeQueue<T,N> is a ring
// buffer that holds N-1 elements (one slot distinguishes full from empty).
// This guarantees allocate() returns nullptr before push() can fail, which:
+144 -6
View File
@@ -21,6 +21,16 @@
#ifdef USE_API
#include "esphome/components/api/api_server.h"
#endif
#ifdef USE_MQTT_SESSION_PERSISTENCE
#include "mqtt_subscription_fingerprint.h"
#ifdef USE_ESP8266
#include "esphome/core/preferences.h"
#elif defined(USE_ESP32)
#include <esp_attr.h>
#include <soc/soc_caps.h>
#include "esphome/core/preferences_rtc.h"
#endif
#endif
#ifdef USE_DASHBOARD_IMPORT
#include "esphome/components/dashboard_import/dashboard_import.h"
#endif
@@ -32,6 +42,39 @@ ESPHOME_LOG_TAG(TAG, "mqtt");
// Maximum number of MQTT component resends per loop iteration.
// Limits work to avoid triggering the task watchdog on reconnect.
static constexpr uint8_t MAX_RESENDS_PER_LOOP = 8;
#ifdef USE_MQTT_SESSION_PERSISTENCE
static constexpr uint32_t SESSION_FINGERPRINT_HASH = fnv1a_hash("mqtt_session_fingerprint");
#ifdef USE_ESP8266
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static ESPPreferenceObject s_session_fingerprint_pref;
#elif defined(USE_ESP32) && SOC_RTC_MEM_SUPPORTED
#define USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT
// Own 8 bytes rather than core's RTC preference pool, which would add 248 bytes of RTC RAM (usable memory on
// variants with RTC fast memory only). The fingerprint and its checksum; power-on garbage fails the checksum.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static RTC_NOINIT_ATTR uint32_t s_session_fingerprint[2];
#endif
// RTC memory where available; RAM only otherwise, so a reset resubscribes
static void load_session_fingerprint([[maybe_unused]] uint32_t *fingerprint) {
#ifdef USE_ESP8266
s_session_fingerprint_pref = global_preferences->make_preference<uint32_t>(SESSION_FINGERPRINT_HASH, false);
s_session_fingerprint_pref.load(fingerprint);
#elif defined(USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT)
rtc_pref_decode(s_session_fingerprint, SESSION_FINGERPRINT_HASH, 1, reinterpret_cast<uint8_t *>(fingerprint),
sizeof(*fingerprint));
#endif
}
static void store_session_fingerprint([[maybe_unused]] const uint32_t *fingerprint) {
#ifdef USE_ESP8266
s_session_fingerprint_pref.save(fingerprint);
#elif defined(USE_MQTT_SESSION_FINGERPRINT_RTC_NOINIT)
rtc_pref_encode(s_session_fingerprint, SESSION_FINGERPRINT_HASH, 1, reinterpret_cast<const uint8_t *>(fingerprint),
sizeof(*fingerprint));
#endif
}
#endif
// Disconnect reason strings indexed by MQTTClientDisconnectReason enum (0-8)
PROGMEM_STRING_TABLE(MQTTDisconnectReasonStrings, "TCP disconnected", "Unacceptable Protocol Version",
@@ -80,6 +123,19 @@ void MQTTClientComponent::setup() {
this->state_ = MQTT_CLIENT_DISCONNECTED;
this->disconnect_reason_ = reason;
});
#ifdef USE_MQTT_SESSION_PERSISTENCE
this->mqtt_backend_.set_on_connect([this](bool session_present) {
this->session_present_ = session_present;
#ifndef USE_ESP32
// AsyncMqttClient delivers CONNACK and SUBACK in the same context, so the counter has one writer
this->subacks_.reset();
#endif
});
#ifndef USE_ESP32
this->mqtt_backend_.set_on_subscribe([this](uint16_t, uint8_t return_code) { this->subacks_.add(return_code); });
#endif
load_session_fingerprint(&this->session_fingerprint_);
#endif
#ifdef USE_LOGGER
if (this->is_log_message_enabled() && logger::global_logger != nullptr) {
logger::global_logger->add_log_callback(
@@ -361,6 +417,9 @@ void MQTTClientComponent::start_connect_() {
this->last_will_.payload.c_str());
}
#ifdef USE_MQTT_SESSION_PERSISTENCE
this->session_present_ = false;
#endif
this->mqtt_backend_.connect();
this->state_ = MQTT_CLIENT_CONNECTING;
this->connect_begin_ = millis();
@@ -385,14 +444,30 @@ void MQTTClientComponent::check_connected() {
// MQTT Client needs some time to be fully set up.
delay(100); // NOLINT
#ifdef USE_MQTT_SESSION_PERSISTENCE
this->subscribes_sent_ = 0;
const bool resumed =
session_has_subscriptions(this->session_present_, this->session_fingerprint_, this->subscriptions_fingerprint_());
this->subacks_settled_ = resumed;
if (resumed) {
for (auto &subscription : this->subscriptions_)
subscription.subscribed = true;
} else {
// Untrusted until all are acknowledged again, so a reset part way through does not trust a partial session;
// topics only an older firmware used stay subscribed in the session
this->set_session_fingerprint_(UNTRUSTED_SESSION_FINGERPRINT);
}
#endif
this->resubscribe_subscriptions_();
#ifdef USE_MQTT_CRASH_LOG
// Best effort: a refused subscription is tried again at the next connection
if (this->crash_log_offered_) {
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
format_esphome_topic(topic, "logs");
this->subscribe_(topic, 0);
#ifdef USE_MQTT_SESSION_PERSISTENCE
// Otherwise sent once the SUBACKs are in, so its own SUBACK is never counted
if (resumed) {
this->subscribe_crash_log_();
}
#else
this->subscribe_crash_log_();
#endif
#endif
this->send_device_info_();
@@ -441,7 +516,12 @@ void MQTTClientComponent::loop() {
}
this->last_connected_ = now;
#ifdef USE_MQTT_SESSION_PERSISTENCE
if (this->resubscribe_subscriptions_() && !this->subacks_settled_)
this->check_subacks_();
#else
this->resubscribe_subscriptions_();
#endif
// Process pending resends for all MQTT components centrally
// Limit work per loop iteration to avoid triggering task WDT on reconnect
@@ -499,15 +579,69 @@ void MQTTClientComponent::resubscribe_subscription_(MQTTSubscription *sub) {
if (do_resub) {
sub->subscribed = this->subscribe_topic_(sub->topic, sub->qos);
sub->resubscribe_timeout = now;
#ifdef USE_MQTT_SESSION_PERSISTENCE
// Only the subscriptions the fingerprint covers are counted
if (sub->subscribed)
this->subscribes_sent_++;
#endif
}
}
void MQTTClientComponent::resubscribe_subscriptions_() {
bool MQTTClientComponent::resubscribe_subscriptions_() {
bool all_subscribed = true;
for (auto &subscription : this->subscriptions_) {
this->resubscribe_subscription_(&subscription);
all_subscribed &= subscription.subscribed;
}
return all_subscribed;
}
#ifdef USE_MQTT_SESSION_PERSISTENCE
// Out of line so the topic buffer is not part of loop()'s stack frame
__attribute__((noinline)) uint32_t MQTTClientComponent::subscriptions_fingerprint_() {
// Seeded with the config hash, so a new firmware config sends every subscription once
uint32_t fingerprint = App.get_config_hash();
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
for (const auto &subscription : this->subscriptions_)
fingerprint = add_subscription_fingerprint(fingerprint, subscription.topic.build(buf).c_str(), subscription.qos);
return fingerprint;
}
void MQTTClientComponent::check_subacks_() {
#ifdef USE_ESP32
const SubackTally tally = this->mqtt_backend_.get_subacks().tally(this->subscribes_sent_);
#else
const SubackTally tally = this->subacks_.tally(this->subscribes_sent_);
#endif
if (tally == SubackTally::SUBACK_TALLY_PENDING)
return;
this->subacks_settled_ = true;
// Trusted only once the broker granted every SUBSCRIBE, so a reset or a lost link before that sends them again,
// and a refused one is tried again at the next connect
if (tally == SubackTally::SUBACK_TALLY_ALL_GRANTED)
this->set_session_fingerprint_(this->subscriptions_fingerprint_());
#ifdef USE_MQTT_CRASH_LOG
this->subscribe_crash_log_();
#endif
}
void MQTTClientComponent::set_session_fingerprint_(uint32_t fingerprint) {
if (this->session_fingerprint_ == fingerprint)
return;
this->session_fingerprint_ = fingerprint;
store_session_fingerprint(&this->session_fingerprint_);
}
#endif
#ifdef USE_MQTT_CRASH_LOG
void MQTTClientComponent::subscribe_crash_log_() {
// Best effort: a refused subscription is tried again at the next connection
if (!this->crash_log_offered_)
return;
char topic[ESPHOME_TOPIC_BUFFER_SIZE];
format_esphome_topic(topic, "logs");
this->subscribe_(topic, 0);
}
void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, const std::string &payload) {
// Only the CLI's empty request counts: a log_topic pointing at this topic would otherwise replay the crash
// log for every line it logs
@@ -572,6 +706,10 @@ __attribute__((noinline)) static bool topic_equals(const MQTTTopicSource &source
}
void MQTTClientComponent::unsubscribe(const std::string &topic) {
#ifdef USE_MQTT_SESSION_PERSISTENCE
// Before the broker drops the topic, so a reset in between cannot leave a matching fingerprint
this->set_session_fingerprint_(UNTRUSTED_SESSION_FINGERPRINT);
#endif
bool ret = this->mqtt_backend_.unsubscribe(topic.c_str());
yield();
if (ret) {
+24 -1
View File
@@ -23,6 +23,9 @@
#elif defined(USE_LIBRETINY)
#include "mqtt_backend_libretiny.h"
#endif
#if defined(USE_MQTT_SESSION_PERSISTENCE) && !defined(USE_ESP32)
#include "mqtt_subscription_fingerprint.h"
#endif
#include "lwip/ip_addr.h"
#include <span>
@@ -380,10 +383,18 @@ class MQTTClientComponent final : public Component {
}
void erase_removed_subscriptions_();
#ifdef USE_MQTT_CRASH_LOG
void subscribe_crash_log_();
void handle_crash_log_request_(const std::string &topic, const std::string &payload);
#endif
void add_subscription_(MQTTTopicSource topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos);
void resubscribe_subscriptions_();
/// Returns true when every subscription is subscribed
bool resubscribe_subscriptions_();
#ifdef USE_MQTT_SESSION_PERSISTENCE
uint32_t subscriptions_fingerprint_();
/// Once every SUBACK of this connection is in, saves the fingerprint if all were granted
void check_subacks_();
void set_session_fingerprint_(uint32_t fingerprint);
#endif
MQTTCredentials credentials_;
/// The last will message. Disabled optional denotes it being default and
@@ -440,6 +451,18 @@ class MQTTClientComponent final : public Component {
bool wait_for_connection_{false};
bool dispatching_{false};
bool removed_while_dispatching_{false};
#ifdef USE_MQTT_SESSION_PERSISTENCE
bool session_present_{false};
/// Set once every SUBACK of this connection is in (or the session was resumed without subscribing)
bool subacks_settled_{false};
/// SUBSCRIBEs sent on this connection for the subscriptions the fingerprint covers
uint16_t subscribes_sent_{0};
#ifndef USE_ESP32
SubackCounter subacks_;
#endif
/// Fingerprint of the set the broker session is known to hold in full; 0 while a rebuild is in progress
uint32_t session_fingerprint_{0};
#endif
};
extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -0,0 +1,65 @@
#pragma once
#include <atomic>
#include <cstdint>
#include "esphome/core/helpers.h"
namespace esphome::mqtt {
/// Adds one subscription to a fingerprint of a set of subscriptions; the order they are added in does not matter.
inline uint32_t add_subscription_fingerprint(uint32_t fingerprint, const char *topic, uint8_t qos) {
return fingerprint + fnv1a_hash_extend(fnv1a_hash(topic), qos);
}
/// Session fingerprint while a rebuild is in progress, and after a power loss or a cold boot.
static constexpr uint32_t UNTRUSTED_SESSION_FINGERPRINT = 0;
/// True when a resumed session holds in full the subscriptions it is trusted with, and they are the current ones.
constexpr bool session_has_subscriptions(bool session_present, uint32_t trusted, uint32_t current) {
return session_present && trusted != UNTRUSTED_SESSION_FINGERPRINT && trusted == current;
}
/// First SUBACK return code that refuses a subscription; also stands in for a SUBACK with no return code.
static constexpr uint8_t SUBACK_FAILURE = 0x80;
/// True when a SUBACK return code grants the subscription.
constexpr bool subscribe_granted(uint8_t return_code) { return return_code < SUBACK_FAILURE; }
enum class SubackTally : uint8_t {
SUBACK_TALLY_PENDING,
SUBACK_TALLY_ALL_GRANTED,
SUBACK_TALLY_NOT_ALL_GRANTED,
};
/// SUBACKs received on the current connection. Written only by the context that delivers broker events (so plain
/// loads and stores, no read-modify-write), read by the main loop.
class SubackCounter {
public:
void reset() {
this->received_.store(0, std::memory_order_relaxed);
this->granted_.store(0, std::memory_order_relaxed);
}
void add(uint8_t return_code) {
if (subscribe_granted(return_code))
this->granted_.store(this->granted_.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
// Released after granted_, so a reader that sees this SUBACK in received_ also sees it in granted_
this->received_.store(this->received_.load(std::memory_order_relaxed) + 1, std::memory_order_release);
}
/// Compares the SUBACKs received with the number of SUBSCRIBEs sent on this connection.
SubackTally tally(uint16_t sent) const {
const uint16_t received = this->received_.load(std::memory_order_acquire);
if (received < sent)
return SubackTally::SUBACK_TALLY_PENDING;
// More than sent means a SUBACK from an earlier connection was counted, so none can be trusted
return received == sent && this->granted_.load(std::memory_order_relaxed) == sent
? SubackTally::SUBACK_TALLY_ALL_GRANTED
: SubackTally::SUBACK_TALLY_NOT_ALL_GRANTED;
}
protected:
std::atomic<uint16_t> received_{0};
std::atomic<uint16_t> granted_{0};
};
} // namespace esphome::mqtt
@@ -31,6 +31,7 @@ CONF_PRECISION = "precision"
CONF_SKIP_CONNECTION_HANDSHAKE = "skip_connection_handshake"
CONF_START_UP_PAGE = "start_up_page"
CONF_STARTUP_OVERRIDE_MS = "startup_override_ms"
CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX = "tft_upload_http_buffer_size_tx"
CONF_TFT_UPLOAD_HTTP_RETRIES = "tft_upload_http_retries"
CONF_TFT_UPLOAD_HTTP_TIMEOUT = "tft_upload_http_timeout"
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT = "tft_upload_watchdog_timeout"
+16 -4
View File
@@ -33,6 +33,7 @@ from .base_component import (
CONF_SKIP_CONNECTION_HANDSHAKE,
CONF_START_UP_PAGE,
CONF_STARTUP_OVERRIDE_MS,
CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX,
CONF_TFT_UPLOAD_HTTP_RETRIES,
CONF_TFT_UPLOAD_HTTP_TIMEOUT,
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT,
@@ -53,10 +54,9 @@ def AUTO_LOAD() -> list[str]:
def _validate_tft_upload(config):
if CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT in config and not CORE.is_esp32:
raise cv.Invalid(
f"{CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT} is only available on ESP32"
)
for key in (CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT, CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX):
if key in config and not CORE.is_esp32:
raise cv.Invalid(f"{key} is only available on ESP32")
return config
@@ -105,6 +105,9 @@ CONFIG_SCHEMA = cv.All(
),
),
cv.Optional(CONF_START_UP_PAGE): cv.uint8_t,
cv.Optional(CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX): cv.int_range(
min=512, max=8192
),
cv.Optional(CONF_TFT_UPLOAD_HTTP_RETRIES): cv.int_range(min=1, max=255),
cv.Optional(CONF_TFT_UPLOAD_HTTP_TIMEOUT): cv.All(
cv.positive_time_period_milliseconds,
@@ -136,6 +139,7 @@ CONFIG_SCHEMA = cv.All(
(
CONF_TFT_UPLOAD_HTTP_TIMEOUT,
CONF_TFT_UPLOAD_HTTP_RETRIES,
CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX,
CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT,
),
CONF_TFT_URL,
@@ -244,6 +248,14 @@ async def to_code(config):
)
)
# TFT upload HTTP TX buffer size (ESP32 only; unset = ESP-IDF default)
if CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX in config:
cg.add(
var.set_tft_upload_http_buffer_size_tx(
config[CONF_TFT_UPLOAD_HTTP_BUFFER_SIZE_TX]
)
)
# TFT upload HTTP retries (default: 5)
if CONF_TFT_UPLOAD_HTTP_RETRIES in config:
cg.add(
+15 -1
View File
@@ -1089,6 +1089,19 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
void set_tft_upload_http_timeout(uint16_t timeout_ms) { this->tft_upload_http_timeout_ = timeout_ms; }
#ifdef USE_ESP32
/**
* @brief Set the HTTP client TX buffer size for TFT upload requests.
*
* The TX buffer must hold the request line, which carries the full URL. After a redirect
* to a long signed URL (e.g. a GitHub release asset, ~600+ characters) the ESP-IDF default
* is too small and the request fails with "HTTP_CLIENT: Out of buffer".
*
* A value of 0 keeps the ESP-IDF default (default).
*
* @param size Buffer size in bytes.
*/
void set_tft_upload_http_buffer_size_tx(uint16_t size) { this->tft_upload_http_buffer_size_tx_ = size; }
/**
* @brief Set the watchdog timeout during TFT upload.
*
@@ -1573,7 +1586,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
uint8_t tft_upload_http_retries_{5}; ///< HTTP retry count (default: 5)
#ifdef USE_ESP32
uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment)
uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment)
uint16_t tft_upload_http_buffer_size_tx_{0}; ///< HTTP TX buffer in bytes (0 = ESP-IDF default)
/**
* will request 4096 bytes chunks from the web server
@@ -283,6 +283,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
.timeout_ms = static_cast<int>(this->tft_upload_http_timeout_),
.disable_auto_redirect = false,
.max_redirection_count = 10,
.buffer_size_tx = this->tft_upload_http_buffer_size_tx_, // 0 = ESP-IDF default
};
// Initialize the HTTP client with the configuration
esp_http_client_handle_t http_client = esp_http_client_init(&config);
+65 -19
View File
@@ -44,6 +44,7 @@ from esphome.const import (
CONF_INVERTED,
CONF_NUMBER,
CONF_OTA,
CONF_PLATFORM,
CONF_RESET_PIN,
CONF_SAFE_MODE,
CONF_TOOLCHAIN,
@@ -309,9 +310,8 @@ CONFIG_SCHEMA = cv.All(
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
): cv.Schema(
{
cv.Optional(
CONF_ENABLE_OTA_ROLLBACK, default=True
): cv.boolean,
# Defaults to whether MCUboot is present, see _final_validate
cv.Optional(CONF_ENABLE_OTA_ROLLBACK): cv.boolean,
}
),
}
@@ -370,7 +370,21 @@ def _final_validate(config):
"Set 'libc_nano: false' under 'framework:' to use the full newlib."
)
if advanced[CONF_ENABLE_OTA_ROLLBACK]:
# Rollback confirms a freshly flashed image through MCUboot, which is either
# the bootloader or, for zephyr_mcumgr, a second stage after it.
mcuboot = (
config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT
or "zephyr_mcumgr" in CORE.loaded_integrations
)
if advanced.get(CONF_ENABLE_OTA_ROLLBACK) and not mcuboot:
# Older configs set it explicitly; it never had an effect without MCUboot
_LOGGER.warning(
"'%s' requires MCUboot, which the '%s' bootloader does not have; ignoring it",
CONF_ENABLE_OTA_ROLLBACK,
config[KEY_BOOTLOADER],
)
advanced[CONF_ENABLE_OTA_ROLLBACK] = False
if advanced.setdefault(CONF_ENABLE_OTA_ROLLBACK, mcuboot):
# "disabled: false" means safe mode *is* enabled.
safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True})
safe_mode_enabled = not safe_mode_config[CONF_DISABLED]
@@ -834,26 +848,58 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
raise EsphomeError(f"Upload failed with result: {result}")
return True # Handled: PYOCD upload
# Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
from .ble_logger import is_ble_address
from .ota import smpmgr_scan, smpmgr_upload
if host == "BLE":
mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))
if is_ble_address(host):
mcumgr_device = host
if mcumgr_device:
firmware = Path(
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
).resolve()
asyncio.run(smpmgr_upload(mcumgr_device, firmware))
return True # Handled: mcumgr OTA upload
# MCUboot over USB CDC: the serial port speaks the MCUmgr SMP protocol
asyncio.run(_smpmgr_upload(mcumgr_device))
return True # Handled: mcumgr serial upload
# Deferred imports: bleak and friends are heavy, only load for BLE paths
from .ble_logger import is_ble_address
if host == "BLE" or is_ble_address(host):
asyncio.run(_upload_over_ble(config, host))
return True # Handled: BLE OTA upload
return False # Not handled: let caller try default upload methods
async def _smpmgr_upload(device: str) -> None:
"""Upload the MCUboot application image over the MCUmgr SMP protocol."""
from .ota import smpmgr_upload
firmware = Path(
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
).resolve()
await smpmgr_upload(device, firmware)
async def _upload_over_ble(config: ConfigType, host: str) -> None:
"""Upload over the OTA protocol the firmware was built with."""
from .ota import smpmgr_scan
# Read the config, not CORE.loaded_integrations: the validated config cache
# skips validation, which is what fills loaded_integrations.
if not any(
ota.get(CONF_PLATFORM) == "adafruit_ble" for ota in config.get(CONF_OTA, [])
):
address = await smpmgr_scan(CORE.name) if host == "BLE" else host
await _smpmgr_upload(address)
return
from esphome.components.adafruit_ble import dfu
# A broken package must not leave a working device waiting in its bootloader
package = dfu.load_firmware(CORE.relative_pioenvs_path(CORE.name) / DFU_PACKAGE)
address = host
if host == "BLE":
try:
address = await smpmgr_scan(CORE.name)
except EsphomeError as err:
raise EsphomeError(f"{err}. {dfu.BOOTLOADER_HINT}") from err
bootloader_address = await dfu.enter_dfu_mode(address)
await dfu.dfu_upload(bootloader_address, package)
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
address = devices[0]
from .ble_logger import is_ble_address, logger_connect, logger_scan
+194 -45
View File
@@ -9,16 +9,33 @@ ESPHOME_LOG_TAG(TAG, "rx8111");
// Indexed by the TSEL value stored in timer_clock_
PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz");
PROGMEM_STRING_TABLE(FoutNames, "32768 Hz", "1024 Hz", "1 Hz", "off");
PROGMEM_STRING_TABLE(EvinPullNames, "none", "pullup_500k", "pullup_1m", "pullup_10m", "pulldown_500k");
PROGMEM_STRING_TABLE(BootFailureNames, "RX8111 battery configuration failed", "RX8111 FOUT configuration failed",
"RX8111 EVIN configuration failed", ESP_LOG_MSG_COMM_FAIL, ESP_LOG_MSG_COMM_FAIL);
static constexpr uint8_t RX8111_REG_SEC = 0x10;
static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17;
static constexpr uint8_t RX8111_REG_HOUR_ALARM = 0x18;
static constexpr uint8_t RX8111_REG_DAY_ALARM = 0x19;
static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D;
static constexpr uint8_t RX8111_REG_FLAG = 0x1E;
static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A;
static constexpr uint8_t RX8111_REG_CONTROL = 0x1F;
static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C;
static constexpr uint8_t RX8111_REG_TIMER_CONTROL = 0x2D;
static constexpr uint8_t RX8111_REG_POWER_SWITCH = 0x32;
static constexpr uint8_t RX8111_REG_TIMESTAMP_CONTROL = 0x34;
static constexpr uint8_t RX8111_TIMESTAMP_EISEL = 0x04;
static constexpr uint8_t RX8111_REG_EVIN = 0x2B;
static constexpr uint8_t RX8111_EXTENSION_ETS = 0x04;
static constexpr uint8_t RX8111_CONTROL_EIE = 0x04;
static constexpr uint8_t RX8111_FLAG_EVF = 0x04;
static constexpr uint8_t RX8111_POWER_CHGEN = 0x80;
static constexpr uint8_t RX8111_POWER_INIEN = 0x40;
static constexpr uint8_t RX8111_POWER_SWSEL = 0x0C;
static constexpr uint8_t RX8111_POWER_VDD_ONLY = 0x08;
static constexpr uint8_t RX8111_FOUT_FSEL = 0xC0;
static constexpr uint8_t RX8111_TIMER_TMPIN = 0x02;
static constexpr uint8_t RX8111_TIMER_MODE = 0x0F; // TBKON, TBKE, TMPIN, TSTP
static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80;
static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08;
static constexpr uint8_t RX8111_CONTROL_AIE = 0x08;
@@ -36,17 +53,121 @@ static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C;
static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0;
static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1;
static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2;
static constexpr uint32_t RX8111_BOOT_BATTERY_RETRY_ID = 3;
static constexpr uint32_t RX8111_BOOT_FOUT_RETRY_ID = 4;
static constexpr uint32_t RX8111_BOOT_EVENT_RETRY_ID = 5;
static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3;
void RX8111Component::setup() {
if (this->battery_configured_)
this->configure_boot_battery_();
if (this->fout_configured_)
this->configure_boot_fout_();
if (this->event_configured_)
this->configure_boot_event_();
this->configure_boot_alarm_();
this->configure_boot_timer_();
this->read_time();
if (this->alarm_enabled_ || this->watching_()) {
if (this->watching_()) {
this->start_event_polling_();
}
}
void RX8111Component::configure_boot_event_() {
const bool success = this->configure_event_();
if (success) {
this->event_boot_pending_ = false;
if (this->watching_())
this->start_event_polling_();
} else {
this->event_boot_pending_ = true;
}
this->boot_config_result_(BOOT_EVENT, success);
}
bool RX8111Component::configure_event_() {
// An explicit disable affects only /INT; leave retained capture settings untouched.
if (!this->event_enabled_)
return this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_EIE, 0);
// ETS gates EVIN detection, not just timestamp publication. Preserve all other
// extension bits, including FOUT, alarm and timer settings, and retained records.
return this->update_register(RX8111_REG_EVIN, 0xFC, this->evin_settings_ & 0xFC) &&
this->update_register(RX8111_REG_TIMESTAMP_CONTROL, RX8111_TIMESTAMP_EISEL, 0) &&
this->update_register(RX8111_REG_EXTENSION, RX8111_EXTENSION_ETS, RX8111_EXTENSION_ETS) &&
this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_EIE, RX8111_CONTROL_EIE);
}
bool RX8111Component::clear_event_flag() {
// FLAG is write-zero-to-clear; ones preserve other events, even if they latch during the write.
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_EVF))) {
this->set_warning_(WARN_EVENT_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->event_flag_seen_ = false;
this->clear_warning_(WARN_EVENT_FLAG_CLEAR);
return true;
}
void RX8111Component::configure_boot_battery_() {
// Own SWSEL as well as INIEN/CHGEN: 10 selects VDD when automatic switching is disabled
// and is legal when enabled (unlike 11). Preserve SMPT and reserved bits.
const uint8_t value = RX8111_POWER_VDD_ONLY | (this->battery_backup_ ? RX8111_POWER_INIEN : 0) |
(this->battery_backup_ && this->battery_charging_ ? RX8111_POWER_CHGEN : 0);
this->boot_config_result_(BOOT_BATTERY,
this->update_register(RX8111_REG_POWER_SWITCH,
RX8111_POWER_INIEN | RX8111_POWER_CHGEN | RX8111_POWER_SWSEL, value));
}
void RX8111Component::configure_boot_fout_() {
// TMPIN routes timer interrupts to FOUT instead of /INT; do not silently change that routing.
uint8_t timer_control;
bool success = this->read_byte(RX8111_REG_TIMER_CONTROL, &timer_control);
if (success && this->fout_frequency_ != 3 && (timer_control & RX8111_TIMER_TMPIN)) {
ESP_LOGW(TAG, "FOUT is routed to the wake-up timer (TMPIN); cannot configure clock output");
success = false;
} else if (success) {
success =
this->update_register(RX8111_REG_EXTENSION, RX8111_FOUT_FSEL, static_cast<uint8_t>(this->fout_frequency_ << 6));
}
this->boot_config_result_(BOOT_FOUT, success);
}
// A timer start clears TMPIN, which may be what failed FOUT; give it a fresh set of attempts.
void RX8111Component::retry_fout_after_timer_start_() {
if (!this->fout_configured_ || (this->warnings_ & WARN_FOUT) == 0)
return;
this->cancel_timeout(RX8111_BOOT_FOUT_RETRY_ID);
this->boot_attempts_[BOOT_FOUT] = 0;
this->configure_boot_fout_();
}
void RX8111Component::boot_config_result_(BootFeature feature, bool success) {
struct BootConfig {
uint32_t retry_id;
void (RX8111Component::*configure)();
uint16_t warning;
};
static constexpr BootConfig CONFIGS[BOOT_FEATURE_COUNT] PROGMEM = {
{RX8111_BOOT_BATTERY_RETRY_ID, &RX8111Component::configure_boot_battery_, WARN_BATTERY},
{RX8111_BOOT_FOUT_RETRY_ID, &RX8111Component::configure_boot_fout_, WARN_FOUT},
{RX8111_BOOT_EVENT_RETRY_ID, &RX8111Component::configure_boot_event_, WARN_EVENT},
{RX8111_BOOT_ALARM_RETRY_ID, &RX8111Component::configure_boot_alarm_, WARN_ALARM},
{RX8111_BOOT_TIMER_RETRY_ID, &RX8111Component::configure_boot_timer_, WARN_TIMER},
};
BootConfig config;
progmem_memcpy(&config, &CONFIGS[feature], sizeof(config));
const auto attempts = ++this->boot_attempts_[feature];
if (success) {
this->clear_warning_(config.warning);
return;
}
this->set_warning_(config.warning, BootFailureNames::get_log_str(feature, BootFailureNames::LAST_INDEX));
if (attempts < RX8111_BOOT_MAX_ATTEMPTS) {
this->set_timeout(config.retry_id, this->boot_retry_delay_ms_,
[this, configure = config.configure]() { (this->*configure)(); });
}
}
void RX8111Component::configure_boot_alarm_() {
bool success = true;
if (this->alarm_enabled_) {
@@ -74,7 +195,7 @@ void RX8111Component::configure_boot_alarm_() {
} else if (this->alarm_explicitly_configured_) {
success = this->set_alarm_interrupt_(false);
}
this->boot_config_result_(true, success);
this->boot_config_result_(BOOT_ALARM, success);
}
void RX8111Component::configure_boot_timer_() {
@@ -92,29 +213,10 @@ void RX8111Component::configure_boot_timer_() {
this->timer_retained_event_pending_ = true;
this->start_event_polling_();
}
if (success && start_timer)
this->retry_fout_after_timer_start_();
}
this->boot_config_result_(false, success);
}
void RX8111Component::boot_config_result_(bool alarm, bool success) {
auto &attempts = alarm ? this->boot_alarm_attempts_ : this->boot_timer_attempts_;
const uint8_t warning = alarm ? WARN_ALARM : WARN_TIMER;
++attempts;
if (success) {
this->clear_warning_(warning);
return;
}
this->set_warning_(warning, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
if (attempts >= RX8111_BOOT_MAX_ATTEMPTS)
return;
this->set_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID, this->boot_retry_delay_ms_,
[this, alarm]() {
if (alarm) {
this->configure_boot_alarm_();
} else {
this->configure_boot_timer_();
}
});
this->boot_config_result_(BOOT_TIMER, success);
}
// A runtime action supersedes pending boot programming of the same feature
@@ -144,12 +246,12 @@ void RX8111Component::update() {
this->check_event_flags_();
}
void RX8111Component::set_warning_(uint8_t bit, const LogString *message) {
void RX8111Component::set_warning_(uint16_t bit, const LogString *message) {
this->warnings_ |= bit;
this->status_set_warning(message);
}
void RX8111Component::clear_warning_(uint8_t bit) {
void RX8111Component::clear_warning_(uint16_t bit) {
if ((this->warnings_ & bit) == 0)
return;
this->warnings_ &= ~bit;
@@ -160,6 +262,23 @@ void RX8111Component::clear_warning_(uint8_t bit) {
void RX8111Component::dump_config() {
ESP_LOGCONFIG(TAG, "RX8111:");
LOG_I2C_DEVICE(this);
if (this->battery_configured_) {
ESP_LOGCONFIG(TAG, " Battery backup: %s, charging: %s", ONOFF(this->battery_backup_),
ONOFF(this->battery_charging_));
}
if (this->fout_configured_) {
ESP_LOGCONFIG(TAG, " FOUT: %s", LOG_STR_ARG(FoutNames::get_log_str(this->fout_frequency_, FoutNames::LAST_INDEX)));
}
if (this->event_configured_) {
ESP_LOGCONFIG(TAG, " EVIN interrupt: %s", ONOFF(this->event_enabled_));
if (this->event_enabled_) {
ESP_LOGCONFIG(
TAG, " EVIN: %s, filter: %u, pull: %s",
(this->evin_settings_ & 0x80) ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"),
(this->evin_settings_ >> 5) & 3,
LOG_STR_ARG(EvinPullNames::get_log_str((this->evin_settings_ >> 2) & 7, EvinPullNames::LAST_INDEX)));
}
}
ESP_LOGCONFIG(TAG,
" Alarm interrupt: %s\n"
" Wake-up timer: %s",
@@ -181,12 +300,20 @@ void RX8111Component::read_time() {
}
bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags)) {
if (this->time_write_invalid_) {
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC write incomplete; write trusted time again"));
return false;
}
uint8_t regs[2]; // Adjacent FLAG and CONTROL registers.
if (!this->read_bytes(RX8111_REG_FLAG, regs, sizeof(regs))) {
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
if (regs[1] & RX8111_CTRL_STOP) {
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC counter stopped; not synchronizing"));
return false;
}
if (regs[0] & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time");
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
return false;
@@ -199,12 +326,12 @@ bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
return false;
}
// decode_bcd_calendar() masks the same bits; the check here catches a corrupted register
static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
static constexpr uint8_t CALENDAR_MASKS[7] PROGMEM = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
bool valid = true;
for (size_t i = 0; i < sizeof(buf); i++) {
if (i == 3)
continue; // Weekday uses one-hot encoding, not BCD.
buf[i] &= CALENDAR_MASKS[i];
buf[i] &= progmem_read_byte(&CALENDAR_MASKS[i]);
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
}
const uint8_t onehot = buf[3];
@@ -236,23 +363,31 @@ void RX8111Component::write_time() {
uint8_t buf[7];
time::encode_bcd_calendar<time::EPSON_BCD_CALENDAR>(now, buf);
const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
// Always try to restart the counter, even after a failed calendar write.
const bool restarted = this->set_stop_bit_(false);
if (!written || !restarted) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->time_write_invalid_ = true;
const bool calendar_written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf));
if (!this->finish_time_write_(calendar_written))
return;
}
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->clear_warning_(WARN_WRITE_TIME);
this->clear_warning_(WARN_CALENDAR);
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
bool RX8111Component::finish_time_write_(bool calendar_written) {
// STOP is the battery-backed invalidity marker, including across host resets.
// Never release a partial or delayed snapshot; only a fresh trusted write can restart it.
if (!calendar_written) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR("RTC write incomplete; write trusted time again"));
return false;
}
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS) || !this->set_stop_bit_(false)) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->time_write_invalid_ = false;
this->clear_warning_(WARN_WRITE_TIME);
this->clear_warning_(WARN_CALENDAR);
return true;
}
bool RX8111Component::set_stop_bit_(bool stop) {
return this->update_register(RX8111_REG_CONTROL, RX8111_CTRL_STOP, stop ? RX8111_CTRL_STOP : 0);
}
@@ -383,6 +518,11 @@ bool RX8111Component::configure_timer_(bool clear_stale_flag) {
if (!this->disable_timer_(extension, control))
return false;
// Explicit starts own the timer mode: count in both power modes, do not pause,
// and route interrupt pulses to /INT rather than FOUT. Leave reserved bits alone.
if (!this->update_register(RX8111_REG_TIMER_CONTROL, RX8111_TIMER_MODE, 0))
return false;
const uint8_t count[] = {static_cast<uint8_t>(this->timer_count_), static_cast<uint8_t>(this->timer_count_ >> 8),
static_cast<uint8_t>(this->timer_count_ >> 16)};
if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) ||
@@ -414,6 +554,7 @@ bool RX8111Component::start_timer() {
this->timer_enabled_ = true;
this->timer_retained_event_pending_ = false;
this->start_event_polling_();
this->retry_fout_after_timer_start_();
return true;
}
@@ -445,6 +586,12 @@ bool RX8111Component::poll_event_flags_() {
if (!this->read_byte(RX8111_REG_FLAG, &flags))
return false;
this->clear_warning_(WARN_EVENT_POLL);
// EVF is not cleared here: users may use it to hold /INT asserted across sleep.
const bool event_active = (flags & RX8111_FLAG_EVF) != 0;
const bool event_rising =
event_active && !this->event_flag_seen_ && this->event_enabled_ && !this->event_boot_pending_;
if (this->event_enabled_ && !this->event_boot_pending_)
this->event_flag_seen_ = event_active;
const bool alarm_active = (flags & RX8111_FLAG_AF) != 0;
const bool timer_active = (flags & RX8111_FLAG_TF) != 0;
// alarm_armed_ is only ever set while the alarm is enabled
@@ -466,6 +613,8 @@ bool RX8111Component::poll_event_flags_() {
// An alarm callback may stop the timer before its callback is delivered.
if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event))
this->timer_callbacks_.call();
if (event_rising && this->event_flag_seen_)
this->event_callbacks_.call();
return true;
}
+65 -18
View File
@@ -29,6 +29,28 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; }
void set_timer_configured(bool configured) { this->timer_configured_ = configured; }
void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; }
void set_battery_backup(bool enabled, bool charging) {
this->battery_configured_ = true;
this->battery_backup_ = enabled;
this->battery_charging_ = charging;
}
void set_fout_frequency(uint8_t frequency) {
this->fout_configured_ = true;
this->fout_frequency_ = frequency;
}
void set_event_config(uint8_t settings) {
this->event_configured_ = true;
this->event_enabled_ = true;
this->evin_settings_ = settings;
}
void set_event_disabled() {
this->event_configured_ = true;
this->event_enabled_ = false;
}
template<typename F> void add_on_event_callback(F &&callback) {
this->event_callbacks_.add(std::forward<F>(callback));
}
bool clear_event_flag();
template<typename F> void add_on_alarm_callback(F &&callback) {
this->alarm_callbacks_.add(std::forward<F>(callback));
}
@@ -51,7 +73,7 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF;
// One bit per fault source, so a recovery in one path cannot hide another path's fault
enum Warning : uint8_t {
enum Warning : uint16_t {
WARN_CALENDAR = 1 << 0,
WARN_EVENT_POLL = 1 << 1,
WARN_ALARM = 1 << 2,
@@ -59,13 +81,28 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
WARN_WRITE_TIME = 1 << 4,
WARN_ALARM_FLAG_CLEAR = 1 << 5,
WARN_TIMER_FLAG_CLEAR = 1 << 6,
WARN_BATTERY = 1 << 7,
WARN_FOUT = 1 << 8,
WARN_EVENT = 1 << 9,
WARN_EVENT_FLAG_CLEAR = 1 << 10,
};
bool set_stop_bit_(bool stop);
bool finish_time_write_(bool calendar_written);
bool read_calendar_(ESPTime &rtc_time);
void set_warning_(uint8_t bit, const LogString *message);
void clear_warning_(uint8_t bit);
bool watching_() const { return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_; }
void set_warning_(uint16_t bit, const LogString *message);
void clear_warning_(uint16_t bit);
void configure_boot_battery_();
void configure_boot_fout_();
void retry_fout_after_timer_start_();
enum BootFeature : uint8_t { BOOT_BATTERY, BOOT_FOUT, BOOT_EVENT, BOOT_ALARM, BOOT_TIMER, BOOT_FEATURE_COUNT };
void boot_config_result_(BootFeature feature, bool success);
bool watching_() const {
return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_ ||
(this->event_enabled_ && !this->event_boot_pending_ && !this->event_callbacks_.empty());
}
void configure_boot_event_();
bool configure_event_();
void check_event_flags_();
bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false);
bool set_alarm_interrupt_(bool enabled);
@@ -75,33 +112,43 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
bool poll_event_flags_();
void configure_boot_alarm_();
void configure_boot_timer_();
void boot_config_result_(bool alarm, bool success);
void cancel_boot_retry_(bool alarm);
LazyCallbackManager<void()> alarm_callbacks_;
LazyCallbackManager<void()> timer_callbacks_;
LazyCallbackManager<void()> event_callbacks_;
uint32_t timer_count_{0};
uint32_t event_poll_interval_{1000};
uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS};
uint8_t warnings_{0};
uint16_t warnings_{0};
uint8_t boot_attempts_[BOOT_FEATURE_COUNT]{};
uint8_t evin_settings_{0};
uint8_t fout_frequency_{3};
uint8_t timer_clock_{2}; // 1 Hz
uint8_t boot_alarm_attempts_{0};
uint8_t boot_timer_attempts_{0};
uint8_t alarm_second_{ALARM_FIELD_UNSET};
uint8_t alarm_minute_{ALARM_FIELD_UNSET};
uint8_t alarm_hour_{ALARM_FIELD_UNSET};
uint8_t alarm_weekday_{ALARM_FIELD_UNSET};
uint8_t alarm_day_{ALARM_FIELD_UNSET};
bool timer_enabled_{false};
bool timer_configured_{false};
bool timer_flag_seen_{false};
bool timer_retained_event_pending_{false};
bool event_polling_started_{false};
bool boot_alarm_event_seen_{false};
bool alarm_enabled_{false};
bool alarm_explicitly_configured_{false};
bool alarm_flag_seen_{false};
bool alarm_armed_{false};
bool time_write_invalid_ : 1 {false};
bool battery_configured_ : 1 {false};
bool battery_backup_ : 1 {false};
bool battery_charging_ : 1 {false};
bool fout_configured_ : 1 {false};
bool event_configured_ : 1 {false};
bool event_enabled_ : 1 {false};
bool event_flag_seen_ : 1 {false};
bool event_boot_pending_ : 1 {false};
bool timer_enabled_ : 1 {false};
bool timer_configured_ : 1 {false};
bool timer_flag_seen_ : 1 {false};
bool timer_retained_event_pending_ : 1 {false};
bool event_polling_started_ : 1 {false};
bool boot_alarm_event_seen_ : 1 {false};
bool alarm_enabled_ : 1 {false};
bool alarm_explicitly_configured_ : 1 {false};
bool alarm_flag_seen_ : 1 {false};
bool alarm_armed_ : 1 {false};
};
} // namespace esphome::rx8111
+48
View File
@@ -10,6 +10,7 @@ from esphome.const import (
CONF_HOUR,
CONF_ID,
CONF_MINUTE,
CONF_ON_EVENT,
CONF_SECOND,
)
from esphome.types import ConfigType
@@ -20,6 +21,14 @@ DEPENDENCIES = ["i2c"]
rx8111_ns = cg.esphome_ns.namespace("rx8111")
RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice)
TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3}
FOUT_FREQUENCIES = {"32768hz": 0, "1024hz": 1, "1hz": 2, "off": 3}
EVIN_PULLS = {
"none": 0,
"pullup_500k": 0x04,
"pullup_1m": 0x08,
"pullup_10m": 0x0C,
"pulldown_500k": 0x10,
}
# set_alarm() ignores a field set to this value
ALARM_FIELD_UNSET = 0xFF
@@ -36,6 +45,13 @@ CONF_TIMER_ENABLED = "timer_enabled"
CONF_ON_TIMER = "on_timer"
CONF_WEEKDAY = "weekday"
CONF_EVENT_POLL_INTERVAL = "event_poll_interval"
CONF_BATTERY_BACKUP = "battery_backup"
CONF_BATTERY_CHARGING = "battery_charging"
CONF_FOUT_FREQUENCY = "fout_frequency"
CONF_EVENT_ENABLED = "event_enabled"
CONF_EVENT_LEVEL = "event_level"
CONF_EVIN_FILTER = "evin_filter"
CONF_EVIN_PULL = "evin_pull"
_ALARM_FIELDS = (
CONF_ALARM_SECOND,
@@ -47,6 +63,7 @@ _ALARM_FIELDS = (
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"),
automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"),
automation.CallbackAutomation(CONF_ON_EVENT, "add_on_event_callback"),
)
@@ -62,6 +79,16 @@ def _validate_alarm_config(config: ConfigType) -> ConfigType:
config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config
):
raise cv.Invalid("timer_enabled or on_timer requires timer_count")
if CONF_BATTERY_CHARGING in config and CONF_BATTERY_BACKUP not in config:
raise cv.Invalid("battery_charging requires an explicit battery_backup setting")
if config.get(CONF_BATTERY_CHARGING) and not config.get(CONF_BATTERY_BACKUP):
raise cv.Invalid("battery_charging: true requires battery_backup: true")
if CONF_ON_EVENT in config and not config.get(CONF_EVENT_ENABLED):
raise cv.Invalid("on_event requires event_enabled: true")
if any(
k in config for k in (CONF_EVENT_LEVEL, CONF_EVIN_FILTER, CONF_EVIN_PULL)
) and not config.get(CONF_EVENT_ENABLED):
raise cv.Invalid("EVIN settings require event_enabled: true")
return config
@@ -86,6 +113,16 @@ CONFIG_SCHEMA = cv.All(
TIMER_CLOCKS, lower=True
),
cv.Optional(CONF_TIMER_ENABLED): cv.boolean,
cv.Optional(CONF_BATTERY_BACKUP): cv.boolean,
cv.Optional(CONF_BATTERY_CHARGING): cv.boolean,
cv.Optional(CONF_FOUT_FREQUENCY): cv.enum(FOUT_FREQUENCIES, lower=True),
cv.Optional(CONF_EVENT_ENABLED): cv.boolean,
cv.Optional(CONF_EVENT_LEVEL): cv.enum(
{"low": 0, "high": 0x80}, lower=True
),
cv.Optional(CONF_EVIN_FILTER): cv.int_range(min=0, max=3),
cv.Optional(CONF_EVIN_PULL): cv.enum(EVIN_PULLS, lower=True),
cv.Optional(CONF_ON_EVENT): automation.validate_automation({}),
cv.Optional(CONF_ON_TIMER): automation.validate_automation({}),
# Below 500 ms a scheduler interval costs more than it saves
cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All(
@@ -107,6 +144,7 @@ for _method in (
"read_time",
"clear_alarm_flag",
"clear_timer_flag",
"clear_event_flag",
"unset_alarm",
"stop_timer",
"start_timer",
@@ -184,6 +222,16 @@ async def to_code(config: ConfigType) -> None:
var = await i2c.new_i2c_component(config)
await time.register_time(var, config)
cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL]))
if (backup := config.get(CONF_BATTERY_BACKUP)) is not None:
cg.add(var.set_battery_backup(backup, config.get(CONF_BATTERY_CHARGING, False)))
cg.add_optional(var.set_fout_frequency, config.get(CONF_FOUT_FREQUENCY))
if config.get(CONF_EVENT_ENABLED):
level = config[CONF_EVENT_LEVEL].enum_value if CONF_EVENT_LEVEL in config else 0
pull = config[CONF_EVIN_PULL].enum_value if CONF_EVIN_PULL in config else 0
filter_bits = config.get(CONF_EVIN_FILTER, 0) << 5
cg.add(var.set_event_config(level | filter_bits | pull))
elif CONF_EVENT_ENABLED in config:
cg.add(var.set_event_disabled())
cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND))
cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE))
cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR))
+5
View File
@@ -124,6 +124,7 @@ from esphome.core import (
)
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
lazy_load_validator,
@@ -1109,6 +1110,10 @@ def sub_sensors(
return SubEntities(new_sensor, config, parent)
# Makes a plain sensor follow another sensor: follow_sensor(entity, source)
follow_sensor = FollowEntity("add_on_state_callback", ((cg.float_, "x"),), "x")
SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
{
cv.Required(CONF_ID): cv.use_id(Sensor),
+53 -16
View File
@@ -1,3 +1,5 @@
import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import climate, sensor
@@ -27,11 +29,22 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE,
CONF_TARGET_TEMPERATURE_HIGH,
CONF_TARGET_TEMPERATURE_LOW,
CONF_UNIT_OF_MEASUREMENT,
)
from esphome.core.entity_helpers import (
UNRESOLVED_PROPERTY,
convert_temperature_keys,
entity_temperature_unit,
resolve_inherited_property,
temperature_in_entity_unit,
)
from esphome.cpp_types import TEMPERATURE_UNITS
from esphome.types import ConfigType
from .. import template_ns
_LOGGER = logging.getLogger(__name__)
CONF_CURRENT_HUMIDITY = "current_humidity"
CONF_TARGET_HUMIDITY = "target_humidity"
CONF_SUPPORTS_ACTION = "supports_action"
@@ -190,6 +203,9 @@ def _validate_initial_state(config: ConfigType) -> ConfigType:
f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled",
path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY],
)
convert_temperature_keys(
initial_state, climate.TARGET_TEMPERATURE_KEYS, entity_temperature_unit(config)
)
return config
@@ -199,9 +215,9 @@ INITIAL_STATE_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_MODE): climate.validate_climate_mode,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int,
cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode,
cv.Exclusive(
@@ -216,7 +232,7 @@ INITIAL_STATE_SCHEMA = cv.All(
)
CONFIG_SCHEMA = cv.All(
climate.climate_schema(TemplateClimate)
climate.climate_schema(TemplateClimate, temperature_unit=True)
.extend(
{
cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor),
@@ -290,6 +306,30 @@ CONFIG_SCHEMA = cv.All(
)
def _sensor_reports_in_the_entity_unit(config: ConfigType) -> ConfigType:
"""A sensor's state is copied as is, so a temperature sensor must report in the climate's unit."""
if (sensor_id := config.get(CONF_SENSOR)) is None:
return config
unit = entity_temperature_unit(config)
sensor_unit = resolve_inherited_property(sensor_id, CONF_UNIT_OF_MEASUREMENT)
if sensor_unit is UNRESOLVED_PROPERTY:
_LOGGER.warning(
"Could not determine the unit of sensor '%s'; it must report in %s",
sensor_id,
unit,
)
return config
if sensor_unit in TEMPERATURE_UNITS and sensor_unit != unit:
raise cv.Invalid(
f"'{CONF_SENSOR}' reports in {sensor_unit} but the climate reports in {unit}",
path=[CONF_SENSOR],
)
return config
FINAL_VALIDATE_SCHEMA = _sensor_reports_in_the_entity_unit
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -367,11 +407,11 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(TemplateClimate),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable_temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable_temperature,
cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int),
cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode),
cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action),
@@ -400,16 +440,13 @@ automation.register_apply_action(
"climate.template.publish",
CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA,
automation.ApplyField(
CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_
CONF_CURRENT_TEMPERATURE,
"current_temperature = {}",
cg.float_,
temperature_in_entity_unit,
),
automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_),
automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_
),
automation.ApplyField(
CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_
),
*climate.TARGET_TEMPERATURE_APPLY_FIELDS,
automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode),
automation.ApplyField(CONF_ACTION, "action = {}", climate.ClimateAction),
@@ -2,6 +2,7 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/climate/climate.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
@@ -22,6 +23,12 @@ class TemplateClimate final : public climate::Climate, public Component {
climate::ClimateTraits traits() override { return this->traits_; }
void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); }
void set_temperature_unit(TemperatureUnit unit) {
this->traits_.set_temperature_unit(unit);
// Visual overrides still apply on top of the converted default range
this->traits_.set_visual_min_temperature(celsius_to(climate::ClimateTraits::DEFAULT_VISUAL_MIN_TEMPERATURE, unit));
this->traits_.set_visual_max_temperature(celsius_to(climate::ClimateTraits::DEFAULT_VISUAL_MAX_TEMPERATURE, unit));
}
#ifdef USE_SENSOR
// The matching feature flag is added from codegen, so the configuration alone decides it.
@@ -14,6 +14,7 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE,
)
from esphome.core import ID
from esphome.core.entity_helpers import entity_temperature_unit_of_id
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
@@ -37,7 +38,7 @@ RESTORE_MODES = TemplateWaterHeaterRestoreMode.options(
)
CONFIG_SCHEMA = (
water_heater.water_heater_schema(TemplateWaterHeater)
water_heater.water_heater_schema(TemplateWaterHeater, temperature_unit=True)
.extend(
{
cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean,
@@ -122,8 +123,8 @@ async def to_code(config: ConfigType) -> None:
cv.Schema(
{
cv.GenerateID(): cv.use_id(TemplateWaterHeater),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature),
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable_temperature,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable_temperature,
cv.Optional(CONF_MODE): cv.templatable(
water_heater.validate_water_heater_mode
),
@@ -142,13 +143,16 @@ async def water_heater_template_publish_to_code(
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
if current_temp := config.get(CONF_CURRENT_TEMPERATURE):
template_ = await cg.templatable(current_temp, args, cg.float_)
cg.add(var.set_current_temperature(template_))
if target_temp := config.get(CONF_TARGET_TEMPERATURE):
template_ = await cg.templatable(target_temp, args, cg.float_)
cg.add(var.set_target_temperature(template_))
unit = entity_temperature_unit_of_id(config[CONF_ID])
for key, setter in (
(CONF_CURRENT_TEMPERATURE, var.set_current_temperature),
(CONF_TARGET_TEMPERATURE, var.set_target_temperature),
):
if (value := config.get(key)) is None:
continue
if isinstance(value, tuple):
value = cv.convert_temperature(*value, unit)
cg.add(setter(await cg.templatable(value, args, cg.float_)))
if mode := config.get(CONF_MODE):
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
@@ -32,6 +32,7 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
}
traits.set_supports_current_temperature(true);
traits.set_temperature_unit(this->temperature_unit_);
if (this->target_temperature_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
}
@@ -29,6 +29,7 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; }
void set_supported_modes(const std::initializer_list<water_heater::WaterHeaterMode> &modes) {
this->supported_modes_ = modes;
}
@@ -54,6 +55,7 @@ class TemplateWaterHeater final : public Component, public water_heater::WaterHe
TemplateLambda<bool> away_f_;
TemplateLambda<bool> is_on_f_;
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
water_heater::WaterHeaterModeMask supported_modes_;
bool optimistic_{true};
};
@@ -22,6 +22,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -238,6 +239,12 @@ def sub_text_sensors(
return SubEntities(new_text_sensor, config, parent)
# Makes a plain text sensor follow another text sensor: follow_text_sensor(entity, source)
follow_text_sensor = FollowEntity(
"add_on_state_callback", ((cg.std_string_ref.operator("const"), "x"),), "x"
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
cg.add_global(text_sensor_ns.using)
+32 -3
View File
@@ -1,3 +1,5 @@
from functools import partial
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -6,15 +8,19 @@ from esphome.const import (
CONF_ID,
CONF_MAX_TEMPERATURE,
CONF_MIN_TEMPERATURE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VISUAL,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
convert_visual_temperatures,
entity_duplicate_validator,
queue_entity_register,
setup_entity,
setup_entity_temperature_unit,
)
from esphome.cpp_generator import MockObjClass
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
@@ -42,19 +48,36 @@ WATER_HEATER_MODES = WaterHeaterMode.options(
)
validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True)
_visual_temperatures_in_entity_unit = partial(
convert_visual_temperatures, delta_keys=(CONF_TARGET_TEMPERATURE_STEP,)
)
_WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
{
cv.Optional(CONF_VISUAL, default={}): cv.Schema(
{
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.float_,
cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature_with_unit,
cv.Optional(CONF_TARGET_TEMPERATURE_STEP): cv.temperature_with_unit,
}
),
}
)
_WATER_HEATER_SCHEMA.add_extra(entity_duplicate_validator("water_heater"))
_WATER_HEATER_SCHEMA.add_extra(_visual_temperatures_in_entity_unit)
def _final_validate(config: list[ConfigType]) -> None:
# Runs once for the whole domain; converts what waited for a platform's unit resolver
full_config = fv.full_config.get()
for entry in config:
_visual_temperatures_in_entity_unit(entry, full_config=full_config)
FINAL_VALIDATE_SCHEMA = _final_validate
def water_heater_schema(
@@ -62,8 +85,13 @@ def water_heater_schema(
*,
icon: str = cv.UNDEFINED,
entity_category: str = cv.UNDEFINED,
temperature_unit: bool = False,
) -> cv.Schema:
"""``temperature_unit`` adds ``unit_of_measurement`` for a platform whose temperatures are all
in the unit the user picks; the class then needs ``set_temperature_unit(TemperatureUnit)``."""
schema = {cv.GenerateID(): cv.declare_id(class_)}
if temperature_unit:
schema[cv.Optional(CONF_UNIT_OF_MEASUREMENT)] = cv.temperature_unit
for key, default, validator in [
(CONF_ICON, icon, cv.icon),
@@ -88,6 +116,7 @@ async def setup_water_heater_core_(var: cg.Pvariable, config: ConfigType) -> Non
if (temp_step := visual.get(CONF_TARGET_TEMPERATURE_STEP)) is not None:
cg.add_define("USE_WATER_HEATER_VISUAL_OVERRIDES")
cg.add(var.set_visual_target_temperature_step_override(temp_step))
setup_entity_temperature_unit(var, config)
async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pvariable:
+60 -30
View File
@@ -218,13 +218,16 @@ WIFI_8266_PHY_MODES = WiFi8266PhyMode.options(
)
WIFI_AP_MODE_FALLBACK = "FALLBACK"
WIFI_AP_MODE_ALWAYS_ON = "ALWAYS_ON"
WIFI_AP_MODE_WISP = "WISP"
def _validate_ap_mode(value: str) -> str:
value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value)
if value == WIFI_AP_MODE_WISP:
# The bridge is part of the ESP32 backend, on both of its frameworks
value = cv.one_of(
WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_ALWAYS_ON, WIFI_AP_MODE_WISP, upper=True
)(value)
if value != WIFI_AP_MODE_FALLBACK:
# The AP next to the station is part of the ESP32 backend, on both of its frameworks
cv.only_on_esp32(value)
return value
@@ -368,18 +371,21 @@ def wifi_network_ap(value):
if value is None:
value = {}
config = WIFI_NETWORK_AP(value)
if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
if not config.get(CONF_PASSWORD):
raise cv.Invalid(
"WISP mode requires a password for the access point",
path=[CONF_PASSWORD],
)
if config[CONF_AP_TIMEOUT].total_milliseconds == 0:
raise cv.Invalid(
"ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0",
path=[CONF_AP_TIMEOUT],
)
elif CONF_DNS_SERVERS in config:
ap_mode = config[CONF_AP_MODE]
if ap_mode == WIFI_AP_MODE_WISP and not config.get(CONF_PASSWORD):
raise cv.Invalid(
"WISP mode requires a password for the access point",
path=[CONF_PASSWORD],
)
if (
ap_mode != WIFI_AP_MODE_FALLBACK
and config[CONF_AP_TIMEOUT].total_milliseconds == 0
):
raise cv.Invalid(
f"ap_timeout is how long ap_mode: {ap_mode} waits before a failed start is tried again; it cannot be 0",
path=[CONF_AP_TIMEOUT],
)
if ap_mode != WIFI_AP_MODE_WISP and CONF_DNS_SERVERS in config:
raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS])
if CONF_MANUAL_IP in config and CORE.is_rp2:
raise cv.Invalid(
@@ -432,6 +438,15 @@ def wifi_has_sta(config: ConfigType) -> bool:
return bool(config.get(CONF_NETWORKS))
def _has_upstream(config: ConfigType, full_config: ConfigType) -> bool:
"""Whether a station network, ethernet or a modem can carry the AP that stays up."""
return (
wifi_has_sta(config)
or CONF_ETHERNET in full_config
or CONF_MODEM in full_config
)
# Components that save station credentials at run time
_PROVISIONERS = ("captive_portal", "improv_ble", "improv_serial")
@@ -459,24 +474,34 @@ def final_validate(config):
"Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)."
)
ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP
ap_mode = config[CONF_AP][CONF_AP_MODE] if has_ap else WIFI_AP_MODE_FALLBACK
ap_is_wisp = ap_mode == WIFI_AP_MODE_WISP
# The AP stays up next to an upstream: the station, or ethernet or modem
if ap_mode != WIFI_AP_MODE_FALLBACK and not _has_upstream(config, full_config):
raise cv.Invalid(
f"ap_mode: {ap_mode} needs a station network (wifi.ssid or wifi.networks), "
"or ethernet or modem as the upstream interface"
)
# The scan after a connect would take the AP down again; with no way for a
# network to appear at run time there is nothing for it to roam to.
if (
ap_mode == WIFI_AP_MODE_ALWAYS_ON
and config[CONF_POST_CONNECT_ROAMING]
and not wifi_stays_ap_only(config, full_config)
):
raise cv.Invalid(
"ap_mode: ALWAYS_ON is incompatible with post_connect_roaming. "
"Please set post_connect_roaming: false in the wifi block."
)
if ap_is_wisp:
# The clients are routed through the station, or through ethernet or modem
if (
not has_sta
and CONF_ETHERNET not in full_config
and CONF_MODEM not in full_config
):
raise cv.Invalid(
"WISP mode needs a station network (wifi.ssid or wifi.networks), "
"or ethernet or modem as the upstream interface"
)
if has_captive_portal:
raise cv.Invalid(
"captive_portal is not supported together with ap_mode: WISP"
)
# Every client packet passes through the WiFi driver, none through the device's own sockets
require_high_performance_wifi()
if has_captive_portal and CONF_DNS_SERVERS in config[CONF_AP]:
# Clients find the portal through the AP's own address as resolver, which dns_servers replaces
raise cv.Invalid(
"dns_servers cannot be combined with captive_portal in WISP mode",
path=[CONF_AP, CONF_DNS_SERVERS],
)
if CONF_DNS_SERVERS not in config[CONF_AP]:
# The access point passes the upstream's resolvers on, which a static address has to name
upstreams = [
@@ -845,10 +870,15 @@ async def to_code(config):
# Skip the setter when the config matches the C++ initializer.
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD:
cg.add(var.set_ap_timeout(ap_timeout))
if conf[CONF_AP_MODE] != WIFI_AP_MODE_FALLBACK:
cg.add_define("USE_WIFI_AP_PERSISTENT")
if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
cg.add_define("USE_WIFI_AP_WISP")
add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True)
add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", True)
if "captive_portal" in CORE.config:
# Starts without a resolver, offering its own address so lookups reach the portal
cg.add_define("USE_WIFI_AP_WISP_PORTAL")
if dns_servers := conf.get(CONF_DNS_SERVERS):
cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS")
# As lwIP stores an IPv4 address: the four bytes read as one little-endian word
+85 -41
View File
@@ -404,6 +404,17 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000;
static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS;
static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000;
#endif
// Every AP is a provisioning surface except a bridge without captive_portal
#if defined(USE_WIFI_AP) && (!defined(USE_WIFI_AP_WISP) || defined(USE_WIFI_AP_WISP_PORTAL))
#define WIFI_AP_HAS_PORTAL
#endif
// On an AP that stays up the portal follows the station: served while it is down, ended when it connects
#if defined(USE_WIFI_AP_PERSISTENT) && defined(WIFI_AP_HAS_PORTAL) && \
(defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE))
#define WIFI_AP_PORTAL_FOLLOWS_STA
/// Keeps a roaming scan or a brief drop from serving the portal again
static constexpr uint32_t WIFI_PORTAL_REOPEN_DELAY_MS = 10000;
#endif
/// Cooldown duration when fallback AP is active and captive portal may be running
/// Longer interval gives users time to configure WiFi without constant connection attempts
@@ -657,18 +668,19 @@ void WiFiComponent::setup() {
this->configured_power_save_ = this->power_save_;
#endif
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)))
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || defined(WIFI_AP_HAS_PORTAL))
// The access point, the portals on it and the DPP listen are provisioning surfaces: once the
// window has closed, end them (mirrors the teardown on a successful connection). The blocks in
// loop() that start them are gated so none of them is started again afterwards. A bridge AP
// carries no portal and stays up.
// loop() that start them are gated so none of them is started again afterwards. An AP that
// stays up only loses its portal.
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
#ifdef USE_WIFI_DPP
this->stop_dpp_listen_();
#endif
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
#ifdef WIFI_AP_HAS_PORTAL
this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_
#ifndef USE_WIFI_AP_PERSISTENT
if (this->ap_setup_) {
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
#ifdef USE_WIFI_AP_EXCLUSIVE
@@ -681,7 +693,8 @@ void WiFiComponent::setup() {
#endif
this->disable_ap_();
}
#endif // USE_WIFI_AP
#endif // !USE_WIFI_AP_PERSISTENT
#endif // WIFI_AP_HAS_PORTAL
});
}
#endif
@@ -783,18 +796,12 @@ void WiFiComponent::start() {
// Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
this->start_initial_connection_();
#endif
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
} else if (this->has_ap()) { // a bridge waits in loop() for the upstream's resolver
#ifdef WIFI_AP_HAS_PORTAL
} else if (this->has_ap()) { // a bridge without a portal waits in loop() for the upstream's resolver
this->setup_ap_config_();
#ifdef USE_CAPTIVE_PORTAL
// Where the radio scans alongside the AP, the portal can list networks.
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
this->wifi_sta_pre_setup_();
this->start_scanning();
}
#endif
this->start_portal_scan_();
this->start_ap_portal_();
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
#endif // WIFI_AP_HAS_PORTAL
}
#ifdef USE_WIFI_DPP
// Without networks there is nothing to try first, so listen for a configurator right away
@@ -934,7 +941,7 @@ void WiFiComponent::loop() {
return;
}
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_PERSISTENT)
// Once the provisioning window has closed, don't bring up the fallback AP (or
// the captive portal on it) - the device must stay unprovisionable until it is
// power-cycled.
@@ -964,7 +971,13 @@ void WiFiComponent::loop() {
this->start_ap_portal_();
}
}
#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
#endif // USE_WIFI_AP && !USE_WIFI_AP_PERSISTENT
#ifdef WIFI_AP_PORTAL_FOLLOWS_STA
if (this->ap_setup_ && !this->is_connected() && now - this->last_connected_ > WIFI_PORTAL_REOPEN_DELAY_MS &&
!this->is_provisioning_closed_() && !this->is_ap_portal_active_()) {
this->start_ap_portal_();
}
#endif
#ifdef USE_IMPROV_BLE
if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() &&
@@ -1002,13 +1015,24 @@ void WiFiComponent::loop() {
}
}
#ifdef USE_WIFI_AP_WISP
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && this->ap_dns_known_() &&
#ifdef USE_WIFI_AP_PERSISTENT
// The AP stays up next to the upstream, so it is started with any upstream; a start that failed is
// tried again after ap_timeout. A bridge without a portal waits for a resolver to offer.
if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED &&
#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_PORTAL)
this->ap_dns_known_() &&
#endif
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
ESP_LOGI(TAG, "Starting AP");
this->ap_changed_ = now;
this->setup_ap_config_();
#ifdef WIFI_AP_PORTAL_FOLLOWS_STA
// With a station the follows-station block serves the portal after its grace period
if (!this->has_sta() && !this->is_provisioning_closed_()) {
this->start_portal_scan_(); // as in start(), so credentials saved through the portal are tried
this->start_ap_portal_();
}
#endif
}
#endif
@@ -1102,9 +1126,9 @@ void WiFiComponent::setup_ap_config_() {
!this->wifi_apply_output_power_(this->output_power_)) {
ESP_LOGV(TAG, "Setting Output Power Option failed");
}
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_PERSISTENT)
// A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
// the STA attempts until the next try, and a bridge would take clients it cannot serve.
// the STA attempts until the next try, and an AP that stays up would take clients it cannot serve.
if (!this->ap_setup_)
this->wifi_mode_({}, false);
#endif
@@ -1153,24 +1177,23 @@ void WiFiComponent::pause_exclusive_ap_() {
}
#endif
#ifdef USE_WIFI_AP_WISP
void WiFiComponent::fail_bridge_() {
this->ap_setup_ = false;
this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each
this->wifi_mode_({}, false);
}
void WiFiComponent::forget_ap_() {
#ifdef USE_WIFI_AP_WISP_PORTAL
this->ap_offers_own_dns_ = false; // the AP goes down, so ending its portal rewrites nothing
this->cancel_timeout(AP_DEAUTH_RETRY_TIMEOUT_ID); // the next AP setup works out its clients' resolver itself
#endif
void WiFiComponent::disable_ap_() {
this->end_ap_portal_();
ESP_LOGD(TAG, "Disabling AP");
this->wifi_mode_({}, false);
// The fallback block may bring the AP back after a later disconnect; its clients dropped with it without
// a disconnect event for each
// The AP may come back later; its clients dropped with it without a disconnect event for each
this->ap_setup_ = false;
this->ap_clients_ = 0;
}
void WiFiComponent::disable_ap_() {
this->forget_ap_();
ESP_LOGD(TAG, "Disabling AP");
this->wifi_mode_({}, false);
}
void WiFiComponent::set_ap(const WiFiAP &ap) {
this->ap_ = ap;
this->has_ap_ = true;
@@ -1503,14 +1526,12 @@ void WiFiComponent::disable() {
this->stop_dpp_listen_();
#endif
this->wifi_disconnect_();
this->wifi_mode_(false, false);
#ifdef USE_WIFI_AP
// The AP went down with the radio; enable() has to configure it, and its portal, again
this->end_ap_portal_();
this->ap_setup_ = false;
this->ap_clients_ = 0;
// The AP goes down with the radio; enable() has to configure it, and its portal, again
this->forget_ap_();
#endif
#ifdef USE_WIFI_AP_WISP
this->wifi_mode_(false, false);
#ifdef USE_WIFI_AP_PERSISTENT
this->ap_changed_ = 0; // at once, not after ap_timeout
#endif
}
@@ -1809,9 +1830,13 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
// Reset to initial phase on successful connection (don't log transition, just reset state)
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
this->num_retried_ = 0;
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients
#ifdef WIFI_AP_HAS_PORTAL
if (this->has_ap()) {
#ifdef USE_WIFI_AP_PERSISTENT
this->end_ap_portal_(); // the AP stays up for its clients
#else
this->disable_ap_();
#endif
}
#endif
#ifdef USE_WIFI_DPP
@@ -2452,8 +2477,23 @@ void WiFiComponent::start_ap_portal_() {
#ifdef USE_WEBSERVER_CAPTIVE
web_server::global_web_server->start_captive();
#endif
#ifdef USE_WIFI_AP_WISP_PORTAL
this->apply_ap_dns_(); // point the clients at the portal; a failed refresh takes the AP down for loop() to retry
#endif
}
#ifdef USE_WIFI_AP
void WiFiComponent::start_portal_scan_() {
#ifdef USE_CAPTIVE_PORTAL
// Where the radio scans alongside the AP, the portal can list networks.
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
this->wifi_sta_pre_setup_();
this->start_scanning();
}
#endif
}
#endif
void WiFiComponent::end_ap_portal_() {
#ifdef USE_CAPTIVE_PORTAL
if (this->is_captive_portal_active_())
@@ -2462,6 +2502,10 @@ void WiFiComponent::end_ap_portal_() {
#ifdef USE_WEBSERVER_CAPTIVE
web_server::global_web_server->end_captive();
#endif
#ifdef USE_WIFI_AP_WISP_PORTAL
if (this->ap_setup_ && this->ap_offers_own_dns_)
this->apply_ap_dns_(); // hand the clients the upstream's resolver again
#endif
}
bool WiFiComponent::is_improv_ble_active_() {
#ifdef USE_IMPROV_BLE
+19 -3
View File
@@ -460,6 +460,7 @@ class WiFiPowerSaveListener {
// Scheduler ids
static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0;
static constexpr uint32_t LEASE_RENEW_TIMEOUT_ID = 1;
static constexpr uint32_t AP_DEAUTH_RETRY_TIMEOUT_ID = 2;
/// This component is responsible for managing the ESP WiFi interface.
class WiFiComponent final : public Component {
@@ -739,6 +740,8 @@ class WiFiComponent final : public Component {
void setup_ap_config_();
/// End the portals and turn the AP off.
void disable_ap_();
/// End the portals and forget the AP's state, before the radio takes the AP down.
void forget_ap_();
#ifdef USE_WIFI_AP_EXCLUSIVE
/// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout.
void pause_exclusive_ap_();
@@ -750,11 +753,16 @@ class WiFiComponent final : public Component {
bool ap_dns_known_();
/// Hand the AP DHCP server its resolvers; false when one could not be set
bool write_ap_dns_();
/// Take the bridge down so loop() runs its setup again after ap_timeout
void fail_bridge_();
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
/// Pass the station's resolvers on to AP clients when they changed
void apply_ap_dns_();
#ifdef USE_WIFI_AP_WISP_PORTAL
/// Disconnect the AP clients so they rejoin with the resolver now offered; a few retries, then the AP restarts
void drop_ap_clients_(uint8_t attempt = 0);
/// DHCP offers the AP's own address while the portal is up for a station that is down, or while the
/// upstream has no resolver, so lookups reach the portal
bool offer_own_dns_();
#endif
#endif
#endif
@@ -883,6 +891,10 @@ class WiFiComponent final : public Component {
bool is_ap_portal_active_();
void start_ap_portal_();
void end_ap_portal_();
#ifdef USE_WIFI_AP
/// Scan alongside the AP so the portal can list networks, when there is no station to try
void start_portal_scan_();
#endif
bool is_improv_ble_active_();
#ifdef USE_WIFI_FAST_CONNECT
@@ -1030,7 +1042,7 @@ class WiFiComponent final : public Component {
float output_power_{NAN};
uint32_t action_started_;
uint32_t last_connected_{0};
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_PERSISTENT)
uint32_t ap_changed_{0}; // When the AP was last started or paused
#endif
uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py
@@ -1149,6 +1161,10 @@ class WiFiComponent final : public Component {
// Fallback AP starts that failed since the last connection; each one waits another ap_timeout
uint8_t ap_start_failures_{0};
#endif
#endif
#ifdef USE_WIFI_AP_WISP_PORTAL
// DHCP offers the AP's own address; apply_ap_dns_() drops the clients whenever that changes
bool ap_offers_own_dns_{false};
#endif
bool passive_scan_{false};
bool has_saved_wifi_settings_{false};
@@ -1263,7 +1263,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
// the AP goes down and its setup runs again, which brings it back up through this event.
if (esp_err_t err = esp_netif_napt_enable(s_ap_netif); err != ESP_OK) {
ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err));
this->fail_bridge_();
this->disable_ap_();
}
#endif
@@ -1471,8 +1471,8 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
#endif
#ifdef USE_WIFI_AP_WISP
// Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would
// offer the AP's own address, so a failure fails the setup and it is tried again.
// Offer resolvers through DHCP: the configured ones, otherwise the station's (with a portal, the AP's own
// address while there is none). A failure fails the setup and it is tried again.
uint8_t dns_offer = 1;
err =
esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer));
@@ -1652,7 +1652,7 @@ static constexpr uint32_t AP_DNS[] = {
};
#endif
static uint32_t ap_dns_to_offer(size_t i) {
static uint32_t upstream_dns(size_t i) {
#ifdef USE_WIFI_AP_WISP_STATIC_DNS
return i < std::size(AP_DNS) ? AP_DNS[i] : 0;
#else
@@ -1660,14 +1660,45 @@ static uint32_t ap_dns_to_offer(size_t i) {
#endif
}
bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; }
static uint32_t ap_dns_to_offer(size_t i, [[maybe_unused]] bool own) {
#ifdef USE_WIFI_AP_WISP_PORTAL
if (own) {
esp_netif_ip_info_t ip{};
if (s_ap_netif != nullptr && esp_netif_get_ip_info(s_ap_netif, &ip) == ESP_OK)
return ip.ip.addr;
ESP_LOGW(TAG, "Failed to read the AP address to offer as resolver");
return 0;
}
// A slot the upstream leaves empty repeats the primary, so the AP's own address is replaced there too
const uint32_t addr = upstream_dns(i);
return addr != 0 ? addr : upstream_dns(0);
#else
return upstream_dns(i);
#endif
}
bool WiFiComponent::ap_dns_known_() { return upstream_dns(0) != 0; }
#ifdef USE_WIFI_AP_WISP_PORTAL
bool WiFiComponent::offer_own_dns_() {
return !this->ap_dns_known_() || (this->has_sta() && this->is_ap_portal_active_());
}
#endif
bool WiFiComponent::write_ap_dns_() {
#ifdef USE_WIFI_AP_WISP_PORTAL
const bool own = this->offer_own_dns_();
this->ap_offers_own_dns_ = own;
#else
const bool own = false;
#endif
bool ok = true;
for (size_t i = 0; i < AP_DNS_COUNT; i++) {
const uint32_t addr = ap_dns_to_offer(i);
if (addr == 0)
const uint32_t addr = ap_dns_to_offer(i, own);
if (addr == 0) {
ok = ok && !own; // the AP's own address could not be read
continue;
}
esp_netif_dns_info_t info{};
info.ip.type = ESP_IPADDR_TYPE_V4;
info.ip.u_addr.ip4.addr = addr;
@@ -1685,28 +1716,65 @@ bool WiFiComponent::write_ap_dns_() {
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
void WiFiComponent::apply_ap_dns_() {
if (!this->ap_setup_)
return; // loop() brings the bridge up once the upstream has a resolver, and its setup writes them
return; // loop() brings the AP up, and its setup writes the resolvers
// The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the
// next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address.
// next lease. A resolver the station lost stays offered (set_dns_info rejects an empty address), or with a
// portal gives way to the AP's own address.
#ifdef USE_WIFI_AP_WISP_PORTAL
const bool own = this->offer_own_dns_();
#else
const bool own = false;
#endif
bool changed = false;
for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) {
const uint32_t addr = ap_dns_to_offer(i);
const uint32_t addr = ap_dns_to_offer(i, own);
esp_netif_dns_info_t current{};
// A missing own address goes through the setup too, which then fails and restarts the AP
changed =
addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), &current) != ESP_OK ||
current.ip.u_addr.ip4.addr != addr);
(addr == 0 && own) ||
(addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), &current) != ESP_OK ||
current.ip.u_addr.ip4.addr != addr));
}
if (!changed)
return;
#ifdef USE_WIFI_AP_WISP_PORTAL
// Clients rejoin whenever the offer switches between the AP's own address and the upstream's resolver
const bool offered_own = this->ap_offers_own_dns_;
#endif
// The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined
#ifdef USE_WIFI_MANUAL_IP
const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip());
#else
const bool ok = this->wifi_ap_ip_config_({});
#endif
if (!ok)
this->fail_bridge_();
if (!ok) {
this->disable_ap_();
return;
}
#ifdef USE_WIFI_AP_WISP_PORTAL
if (offered_own != this->ap_offers_own_dns_)
this->drop_ap_clients_();
#endif
}
#ifdef USE_WIFI_AP_WISP_PORTAL
void WiFiComponent::drop_ap_clients_(uint8_t attempt) {
static constexpr uint8_t MAX_ATTEMPTS = 5;
if (!this->ap_setup_)
return;
esp_err_t err = esp_wifi_deauth_sta(0);
if (err == ESP_OK)
return;
if (attempt + 1 >= MAX_ATTEMPTS) {
ESP_LOGW(TAG, "Failed to drop AP clients so they pick up the new resolver: %s; restarting AP",
esp_err_to_name(err));
this->disable_ap_(); // loop() starts it again, and the clients rejoin
return;
}
ESP_LOGW(TAG, "Failed to drop AP clients so they pick up the new resolver: %s; retrying", esp_err_to_name(err));
this->set_timeout(AP_DEAUTH_RETRY_TIMEOUT_ID, 1000, [this, attempt]() { this->drop_ap_clients_(attempt + 1); });
}
#endif
#endif // USE_WIFI_AP_WISP_STATIC_DNS
#endif // USE_WIFI_AP_WISP
@@ -78,6 +78,8 @@ _CALLBACK_AUTOMATIONS = (
)
# Above safe_mode, so Bluetooth OTA can still recover a device stuck in a boot loop
@coroutine_with_priority(CoroPriority.COMMUNICATION)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
zephyr_add_prj_conf("BT", True)
@@ -93,6 +95,9 @@ async def to_code(config: ConfigType) -> None:
zephyr_add_prj_conf("BT_SMP_SC_ONLY", True)
zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True)
request_ble_l2cap_mtu(65) # BT_SMP Kconfig default and range minimum
# Pairing confirmation is the user's automation, so in safe mode new pairings
# are rejected (ble_server.cpp) and only bonded hosts can connect
await cg.past_safe_mode()
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@@ -181,7 +181,14 @@ void BLEServer::auth_passkey_confirm(bt_conn *conn, unsigned int passkey) {
snprintk(passkey_str, 7, "%06u", passkey);
ESP_LOGI(TAG, "Confirm passkey for %s: %s", addr, passkey_str);
global_ble_server->defer([passkey]() { global_ble_server->passkey_cb_(passkey); });
global_ble_server->defer([passkey]() {
if (global_ble_server->passkey_cb_.size() == 0) {
ESP_LOGW(TAG, "Nothing answers numeric comparison (safe mode skips the automation), rejecting pairing");
global_ble_server->numeric_comparison_reply(false);
return;
}
global_ble_server->passkey_cb_(passkey);
});
}
static void auth_pairing_confirm(bt_conn *conn) {
+13
View File
@@ -1264,6 +1264,18 @@ class CoreFinalValidateStep(ConfigValidationStep):
fv.full_config.reset(token)
class OptionalPinValidationStep(ConfigValidationStep):
"""Remove optional pins that are also used elsewhere"""
# After the ID pass, and before component final validations which may inspect pin use
priority = -15.0
def run(self, result: Config) -> None:
if result.errors:
return
pins.PIN_SCHEMA_REGISTRY.remove_optional_conflicts(result)
class PinUseValidationCheck(ConfigValidationStep):
"""Check for pin reuse"""
@@ -1437,6 +1449,7 @@ def validate_config(
result.add_validation_step(PrefetchRemoteFilesValidationStep())
result.add_validation_step(IDPassValidationStep())
result.add_validation_step(CoreFinalValidateStep())
result.add_validation_step(OptionalPinValidationStep())
result.add_validation_step(PinUseValidationCheck())
result.add_validation_step(RemoveReferenceValidationStep())
+63 -38
View File
@@ -78,6 +78,9 @@ from esphome.const import (
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
UNIT_CELSIUS,
UNIT_FAHRENHEIT,
UNIT_KELVIN,
Framework,
Toolchain,
__version__ as ESPHOME_VERSION,
@@ -86,6 +89,7 @@ from esphome.core import (
CORE,
HexInt,
Lambda,
TemperatureWithUnit,
TimePeriod,
TimePeriodMicroseconds,
TimePeriodMilliseconds,
@@ -94,6 +98,7 @@ from esphome.core import (
TimePeriodSeconds,
Version,
)
from esphome.cpp_types import TEMPERATURE_UNITS
from esphome.enum import StrEnum
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
from esphome.helpers import (
@@ -1520,7 +1525,7 @@ METRIC_SUFFIXES = {
def float_with_unit(quantity, regex_suffix, optional_unit=False):
pattern = re.compile(
f"^([-+]?[0-9]*\\.?[0-9]*)\\s*(\\w*?){regex_suffix}$", re.UNICODE
f"^([-+]?(?:[0-9]+\\.?[0-9]*|\\.[0-9]+))\\s*(\\w*?){regex_suffix}$", re.UNICODE
)
def validator(value):
@@ -1557,53 +1562,73 @@ distance = float_with_unit("distance", "(m)")
framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)")
angle = float_with_unit("angle", "(°|deg)", optional_unit=True)
_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?")
_temperature_k = float_with_unit("temperature", "(°K|° K|K)?")
_temperature_f = float_with_unit("temperature", "(°F|° F|F)?")
_temperature_c_strict = float_with_unit("temperature", "(°C|° C|C)")
_temperature_k = float_with_unit("temperature", "(°K|° K|K)")
_temperature_f = float_with_unit("temperature", "(°F|° F|F)")
decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True)
pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True)
def temperature(value):
err = None
try:
return _temperature_c(value)
except Invalid as orig_err:
err = orig_err
class TemperatureUnitValue(str):
"""A unit from temperature_unit(): the platform took the option, so the entity reports in it."""
try:
kelvin = _temperature_k(value)
return kelvin - 273.15
except Invalid:
pass
try:
fahrenheit = _temperature_f(value)
return (fahrenheit - 32) * (5 / 9)
except Invalid:
pass
raise err
__slots__ = ()
def temperature_delta(value):
err = None
try:
return _temperature_c(value)
except Invalid as orig_err:
err = orig_err
def temperature_unit(value: Any) -> TemperatureUnitValue:
"""Validate the unit an entity reports its temperatures in."""
return TemperatureUnitValue(one_of(*TEMPERATURE_UNITS)(value))
try:
return _temperature_k(value)
except Invalid:
pass
try:
fahrenheit = _temperature_f(value)
return fahrenheit * (5 / 9)
except Invalid:
pass
def temperature_with_unit(value: Any) -> TemperatureWithUnit:
"""Parse a temperature and keep the unit written with it; the unit is None when there is none."""
for validator, unit in (
(_temperature_k, UNIT_KELVIN),
(_temperature_f, UNIT_FAHRENHEIT),
(_temperature_c_strict, UNIT_CELSIUS),
):
try:
return TemperatureWithUnit(validator(value), unit)
except Invalid:
pass
return TemperatureWithUnit(_temperature_c(value), None)
raise err
# unit = celsius * scale + offset
_TEMPERATURE_SCALES = {
UNIT_CELSIUS: (1.0, 0.0),
UNIT_FAHRENHEIT: (9 / 5, 32.0),
UNIT_KELVIN: (1.0, 273.15),
}
templatable_temperature = templatable(temperature_with_unit)
def convert_temperature(
value: float, unit: str | None, to_unit: str, *, delta: bool = False
) -> float:
"""Convert a temperature, or with ``delta`` a temperature difference, between units.
A value written without a unit is taken to be in ``to_unit`` already.
"""
if unit is None or unit == to_unit:
return value
scale, offset = _TEMPERATURE_SCALES[unit]
to_scale, to_offset = _TEMPERATURE_SCALES[to_unit]
if delta:
return value / scale * to_scale
return (value - offset) / scale * to_scale + to_offset
def temperature(value: Any) -> float:
"""Parse a temperature in °C, K or °F into degrees Celsius."""
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS)
def temperature_delta(value: Any) -> float:
"""Parse a temperature difference in °C, K or °F into Celsius degrees."""
return convert_temperature(*temperature_with_unit(value), UNIT_CELSIUS, delta=True)
_color_temperature_mireds = float_with_unit("Color Temperature", r"(mireds|Mireds)")
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff. Show more