Compare commits

...
Author SHA1 Message Date
J. Nick Koston 62c314bf57 Trim comments and docstrings 2026-08-22 23:57:36 -05:00
J. Nick Koston 3100e9be21 Recover only on decode errors; an OSError may hide an intact file 2026-08-22 23:02:04 -05:00
J. Nick Koston d5d7bf15f1 Catch only EsphomeError; read_file wraps the decode error too 2026-08-22 22:40:43 -05:00
J. Nick Koston eb7b522461 [core] Replace a damaged existing file in write_file_if_changed 2026-08-22 22:36:43 -05:00
J. Nick KostonandGitHub f0651e5c9b [radio_frequency] Inline the trivial make_call helper (#18645) 2026-08-22 21:07:34 -05:00
J. Nick KostonandGitHub f24b731f95 [infrared] Inline the trivial make_call helper (#18644) 2026-08-22 21:07:14 -05:00
J. Nick KostonandGitHub 01ad424d12 [datetime] Inline the trivial make_call helpers (#18643) 2026-08-22 21:07:03 -05:00
J. Nick KostonandGitHub 435d522683 [text] Inline the trivial Text publish_state forwarding overloads (#18642) 2026-08-22 21:06:47 -05:00
J. Nick KostonandGitHub a63c3bc0c7 [valve] Inline the trivial Valve and ValveCall accessors (#18641) 2026-08-22 21:06:31 -05:00
J. Nick KostonandGitHub b83ce91528 [display] Inline the trivial DisplayPage setters and page navigation helpers (#18639) 2026-08-22 21:06:03 -05:00
J. Nick KostonandGitHub a30238aab6 [wireguard] Inline the trivial Wireguard setters (#18638) 2026-08-22 21:05:51 -05:00
J. Nick KostonandGitHub 47156c9a5b [mqtt] Inline the trivial MQTT client, component and sensor accessors (#18637) 2026-08-22 21:05:39 -05:00
J. Nick KostonandGitHub 78240c9a46 [sprinkler] Inline the trivial Sprinkler accessors (#18636) 2026-08-22 21:04:57 -05:00
J. Nick KostonandGitHub 5a9f06e584 [thermostat] Inline the trivial ThermostatClimate setters and getters (#18635) 2026-08-22 21:04:41 -05:00
J. Nick KostonandGitHub ecb007da70 [deep_sleep] Inline the trivial DeepSleepComponent setters (#18634) 2026-08-22 21:04:23 -05:00
J. Nick KostonandGitHub ad1a4fca36 [version] Inline the trivial VersionTextSensor setters (#18633) 2026-08-22 21:04:08 -05:00
J. Nick KostonandGitHub 832a738588 [switch] Inline the trivial inverted accessors (#18632) 2026-08-22 21:03:51 -05:00
J. Nick KostonandGitHub fedb3ac5c1 [fan] Inline the trivial Fan call helpers (#18631) 2026-08-22 21:03:38 -05:00
J. Nick KostonandGitHub cb4e55e444 [cover] Inline the trivial Cover and CoverCall accessors (#18630) 2026-08-22 21:03:15 -05:00
J. Nick KostonandGitHub 3ef5a8e6a4 [water_heater] Inline the trivial visual override setters (#18629) 2026-08-22 21:03:07 -05:00
J. Nick KostonandGitHub 0e915e9b8b [core] Inline the ESPTime::strftime std::string overload (#18628) 2026-08-22 21:02:58 -05:00
J. Nick KostonandGitHub dba3b287dd [api] Inline the trivial APIServer accessors (#18627) 2026-08-22 21:02:14 -05:00
J. Nick KostonandGitHub ce019f508d [safe_mode] Inline the trivial set_safe_mode setters (#18626) 2026-08-22 21:02:04 -05:00
J. Nick KostonandGitHub 5c2286cc4a [climate] Inline the trivial visual override setters (#18624) 2026-08-22 21:01:55 -05:00
J. Nick KostonandGitHub efc0a94112 [text_sensor] Inline the trivial TextSensor forwarding overloads (#18623) 2026-08-22 21:01:42 -05:00
J. Nick KostonandGitHub c60062c418 [sensor] Inline the trivial ExponentialMovingAverageFilter setters (#18622) 2026-08-22 21:01:28 -05:00
J. Nick KostonandGitHub 763a1d9371 [select] Inline the trivial Select accessors (#18621) 2026-08-22 21:01:16 -05:00
J. Nick KostonandGitHub 4db1666024 [light] Inline the trivial LightState accessors (#18620) 2026-08-22 21:00:59 -05:00
J. Nick KostonandGitHub 0dc69aab1e [logger] Inline the trivial Logger accessors (#18619) 2026-08-22 21:00:49 -05:00
J. Nick KostonandGitHub a282cb095e [ethernet] Inline the trivial EthernetComponent setters (#18618) 2026-08-22 21:00:39 -05:00
J. Nick KostonandGitHub 259e7182a3 [esp32] Exclude esp_gdbstub from the build by default (#18604) 2026-08-22 20:59:55 -05:00
J. Nick KostonandGitHub 74bdf275d2 [core] Dump the main.cpp config comment with sorted keys (#18653) 2026-08-22 20:58:28 -05:00
J. Nick KostonandGitHub 14499223fd [esp8266] Don't report stale crash state after hardware WDT resets (#18597) 2026-08-22 20:58:13 -05:00
J. Nick KostonandGitHub 6aab523dd9 [esp32_ble] Log connection parameter update results (#18607) 2026-08-22 20:57:58 -05:00
J. Nick KostonandGitHub 1f31e51446 [esphome] Inline the trivial OTA port accessors (#18625) 2026-08-22 20:57:37 -05:00
J. Nick KostonandGitHub c062d0c717 [ota] Log prepare, upload, and total OTA timing in espota2 (#18582) 2026-08-22 20:57:13 -05:00
J. Nick KostonandGitHub dcabaedff1 [bk72xx_ble] Block BK7238 until the LibreTiny bonding partition fix lands (#18649) 2026-08-23 11:59:24 +12:00
d119ad6c60 [usb_uart] Extract non-final USBUartChannelBase from USBUartChannel (#17472)
Co-authored-by: p1ngb4ck <p@local>
2026-08-22 10:47:46 -05:00
esphome[bot]andGitHub 74fc2e367a Bump bundled esphome-device-builder to 1.12.4 (#18651) 2026-08-22 15:42:53 +00:00
ea10f94376 [core] Add type annotations to component Python (10/11) (#18347)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-22 10:31:47 -05:00
Brandon HarveyGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>J. Nick KostonBluetooth Devices Bot
ef1d77885d [captive_portal] Show each network once in the scan list (#17847)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Bluetooth Devices Bot <bluetooth@koston.org>
2026-08-22 05:04:08 +00:00
Keith BurzinskiandGitHub dccf55eadc [remote_transmitter] Use hardware PWM on rtl87xx to fix watchdog crash (#18579) 2026-08-21 23:53:16 -05:00
65704e881f [mitsubishi_cn105] Add Fahrenheit support (#15488)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-21 23:52:18 -05:00
Brandon HarveyandGitHub a30e82459f [deep_sleep] Reject wakeup_pin_mode at both levels on BK72xx (#18615) 2026-08-21 23:51:19 -05:00
Brandon HarveyandGitHub 5a300e92f1 [wifi] Inline the trivial WiFiScanResult accessors (#18613) 2026-08-22 04:14:56 +00:00
8e9fb0f93c [remote_transmitter] Fix repeat gap timing on LibreTiny Beken (#18585)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-21 23:10:47 -05:00
esphome[bot]andGitHub 11ea819bc7 Bump aioesphomeapi from 45.12.0 to 45.13.1 (#18600) 2026-08-21 14:36:47 -05:00
esphome[bot]andGitHub abc9098bd8 Bump bundled esphome-device-builder to 1.12.3 (#18601) 2026-08-21 14:05:00 -05:00
Kevin AhrendtandGitHub 00cffa09a2 [sendspin] Convert tests to package-style includes (#18588) 2026-08-21 13:05:07 -05:00
Jesse HillsandGitHub aa944456e0 [core] Add type annotations to component Python (6/11) (#18343) 2026-08-21 11:11:52 -05:00
Jonathan SwobodaandGitHub 409d74a48d [esp32_hosted] Fire on_update_available trigger when update is detected (#18591) 2026-08-21 11:53:44 -04:00
Frédéric MetrichGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>Claude Sonnet 4.6
7957808f00 [emontx] Fix sensor state_class defaults not being applied correctly (#17610)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-21 09:57:58 -05:00
J. Nick KostonandGitHub 52bfc0efb1 [espnow] Fix dump_config crash when enable_on_boot is false (#18572) 2026-08-20 18:06:23 -05:00
46f90d0c54 [core] Add portable strcasestr implementation named str_contains_ignore_case (#18497)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-20 17:28:17 -05:00
guillempagesGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>J. Nick KostonJ. Nick Koston
5177972c04 [runtime_image] keep decoder allocated (#18488)
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-08-20 17:27:44 -05:00
esphome[bot]andGitHub ece90ee97b Bump bundled esphome-device-builder to 1.12.2 (#18573) 2026-08-20 16:59:30 -05:00
dependabot[bot]andGitHub 4cfa4893ef Bump docker/setup-buildx-action from 4.2.0 to 4.3.0 in the docker-actions group (#18564)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-20 15:24:28 -05:00
dependabot[bot]andGitHub edd4a86d14 Bump prek from 0.4.13 to 0.4.14 (#18563)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-20 15:02:15 -05:00
Jesse HillsandGitHub 44dcd82d78 [mipi_spi] Toggle D/C only while holding the SPI bus (#18529) 2026-08-21 07:58:11 +12:00
Jesse HillsandGitHub 12da2140cf [core] Add type annotations to component Python (11/11) (#18348) 2026-08-21 07:57:14 +12:00
Jesse HillsandGitHub c006e9804a [core] Add type annotations to component Python (9/11) (#18346) 2026-08-21 07:56:48 +12:00
6343c11873 [core] Add type annotations to component Python (5/11) (#18342)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-21 07:56:02 +12:00
David van 't WoutandGitHub 2ab09e1a77 [core] Add add_cmake_arg (#18498) 2026-08-20 12:30:33 -05:00
J. Nick KostonandGitHub a3ea77c2f1 [core] Keep templated !include filenames as strings so Windows path normalization cannot corrupt them (#18549) 2026-08-20 11:32:15 -05:00
J. Nick KostonandGitHub 7fe4399b94 [esp32] Grow the default IDF component exclusion list (#18536) 2026-08-20 16:11:53 +00:00
Jesse HillsandGitHub 545f762568 [core] Add type annotations to component Python (8/11) (#18345) 2026-08-20 12:08:26 -04:00
Jesse HillsandGitHub e83439eaae [core] Add type annotations to component Python (7/11) (#18344) 2026-08-20 12:07:10 -04:00
Jesse HillsandGitHub b6a9761dae [core] Add type annotations to component Python (4/11) (#18341) 2026-08-20 10:59:51 -04:00
Jesse HillsandGitHub fbe4b39a16 [core] Add type annotations to component Python (3/11) (#18340) 2026-08-20 10:58:58 -04:00
ecca240eef [core] Add type annotations to component Python (2/11) (#18339)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-20 14:26:16 +00:00
J. Nick KostonandGitHub 347a6155f8 [uptime] Skip the timestamp sensor source when no time component is configured (#18535) 2026-08-20 10:07:17 -04:00
J. Nick KostonandGitHub a8e721abeb [esp32] Apply IDF component exclusions to native toolchain builds (#18531) 2026-08-20 10:03:49 -04:00
J. Nick KostonandGitHub 2d62ea78d2 [ota] Skip partition-access OTA sources when the feature is disabled (#18532) 2026-08-20 10:00:11 -04:00
J. Nick KostonandGitHub 8ef0f38f4e [ethernet] Skip the custom W5500 SPI driver for other ethernet types (#18533) 2026-08-20 09:59:30 -04:00
Jesse HillsandGitHub 3c47ab42d6 [core] Add type annotations to component Python (1/11) (#18338) 2026-08-20 09:55:15 -04:00
Alar AunandGitHub aafeca5859 [modbus_controller] Brace single-statement log bodies to fix -Wempty-body (#18543) 2026-08-20 09:54:28 -04:00
J. Nick KostonandGitHub 132f494195 [nrf52] Rebuild the Python env when its interpreter symlink dangles (#18540) 2026-08-20 07:59:57 -04:00
esphome[bot]andGitHub 5e9de7c94b Bump bundled esphome-device-builder to 1.12.1 (#18541) 2026-08-20 00:00:44 -05:00
Markus HällandGitHub b7d0b676fc [wifi] Take the lwIP core lock around sntp_servermode_dhcp() (#18511) 2026-08-19 23:31:27 -05:00
Jesse Hills 55b45fc6fb Merge branch 'release' into dev 2026-08-20 14:32:46 +12:00
Jesse HillsandGitHub 6f8dbb6fbc Merge pull request #18527 from esphome/bump-2026.8.0
2026.8.0
2026-08-20 14:32:15 +12:00
J. Nick KostonandJesse Hills ca97c86d65 [ci] Install requirements_dev.txt when the venv cache misses (#18502) 2026-08-20 13:12:33 +12:00
Jesse Hills 828eac90f3 Bump version to 2026.8.0 2026-08-20 11:51:59 +12:00
J. Nick KostonandJesse Hills d9359a70c1 [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) 2026-08-20 11:51:57 +12:00
Jesse Hills 29404a782c Merge branch 'beta' into dev 2026-08-20 10:32:46 +12:00
Jesse HillsandGitHub e75a7a61fa Merge pull request #18523 from esphome/bump-2026.8.0b6
2026.8.0b6
2026-08-20 10:32:26 +12:00
J. Nick KostonandGitHub 185f12266a [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) 2026-08-19 17:29:10 -05:00
J. Nick KostonandJesse Hills c455991962 [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) 2026-08-20 09:38:44 +12:00
Jesse Hills f735dcadc0 Bump version to 2026.8.0b6 2026-08-20 09:33:05 +12:00
J. Nick KostonandJesse Hills 78a65eabdc [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) 2026-08-20 09:33:03 +12:00
b3fda9973e [image] Restore defaults:/files: support for platform entries (#18032)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills 4a85c98285 Bump bundled esphome-device-builder to 1.12.0 (#18514) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills 2c92a2498e Bump bundled esphome-device-builder to 1.11.5 (#18507) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills 74e22b5ad7 Bump bundled esphome-device-builder to 1.11.4 (#18506) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills e9e77d02a0 Bump bundled esphome-device-builder to 1.11.3 (#18505) 2026-08-20 09:33:03 +12:00
Jonathan SwobodaandJesse Hills 7418fcce8d [ci] Stop persisting the integration test ccache (#18504) 2026-08-20 09:33:03 +12:00
Jonathan SwobodaandJesse Hills b768e2a1ce [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 6084314cc9 [vscode] Report the origin of an unexpected exception during validation (#18494) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 2df953f3d7 [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) 2026-08-20 09:33:03 +12:00
10e592fa3a [modbus] CRC scan all unknown function codes (#18483)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills a99a8f364e [api] Bump noise-c to 0.1.21 (#18484) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 200a1644a5 [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 9daae377fc [api] Bump noise-c to 0.1.20 (#18482) 2026-08-20 09:33:02 +12:00
J. Nick KostonandGitHub 0b1065feee [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) 2026-08-20 09:30:34 +12:00
Clyde StubbsandGitHub 47da743d11 [ai] Add instructions for concise comments (#18522) 2026-08-19 16:09:12 -05:00
470226ca03 [image] Restore defaults:/files: support for platform entries (#18032)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-19 12:30:12 -05:00
esphome[bot]andGitHub 7b7107556f Bump bundled esphome-device-builder to 1.12.0 (#18514) 2026-08-19 12:21:58 -05:00
J. Nick KostonandGitHub f90b776071 [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) 2026-08-19 16:39:01 +00:00
esphome[bot]andGitHub e26237e57d Bump bundled esphome-device-builder to 1.11.5 (#18507) 2026-08-19 00:42:05 -05:00
esphome[bot]andGitHub d2c3f749ab Bump bundled esphome-device-builder to 1.11.4 (#18506) 2026-08-18 23:24:23 -05:00
esphome[bot]andGitHub b7cc271219 Bump bundled esphome-device-builder to 1.11.3 (#18505) 2026-08-18 21:57:51 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
17eed7055b Bump resvg-py from 0.3.4 to 0.4.0 (#18460)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 22:22:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b7121940c8 Update wheel requirement from <0.48,>=0.43 to >=0.43,<0.49 (#18459)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 22:08:23 -04:00
Jonathan SwobodaandGitHub 07fa16e2e7 [ci] Stop persisting the integration test ccache (#18504) 2026-08-18 21:38:18 -04:00
Jonathan SwobodaandGitHub 8aa7db15e5 [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) 2026-08-18 21:25:09 -04:00
J. Nick KostonandGitHub 8b637b339b [vscode] Report the origin of an unexpected exception during validation (#18494) 2026-08-18 19:37:10 -05:00
J. Nick KostonandGitHub 4f866c563b [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) 2026-08-18 19:36:57 -05:00
J. Nick KostonandGitHub 7a999f9a48 [ci] Install requirements_dev.txt when the venv cache misses (#18502) 2026-08-18 19:06:55 -05:00
esphome[bot]andGitHub 0a88c81d95 Bump aioesphomeapi from 45.11.0 to 45.12.0 (#18501) 2026-08-18 18:38:23 -05:00
J. Nick KostonandGitHub 804e8fb856 [socket] Remove constant duplicated by the beta merge (#18496) 2026-08-18 15:44:38 -05:00
Jesse Hills 43939a9199 Merge branch 'beta' into dev 2026-08-19 08:01:33 +12:00
Jesse HillsandGitHub f414a07bcd Merge pull request #18481 from esphome/bump-2026.8.0b5
2026.8.0b5
2026-08-19 08:01:14 +12:00
esphome[bot]andGitHub 5c9d050ebe Bump aioesphomeapi from 45.10.3 to 45.11.0 (#18493) 2026-08-18 17:02:54 +00:00
285a508e09 [modbus] CRC scan all unknown function codes (#18483)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 11:09:12 -05:00
J. Nick KostonandGitHub 92f55f721f [api] Bump noise-c to 0.1.21 (#18484) 2026-08-18 14:32:37 +00:00
Jonathan SwobodaandGitHub 476d540065 [ci] Fall back to files API when PR diff exceeds GitHub line limit (#18486) 2026-08-18 10:22:28 -04:00
J. Nick KostonandGitHub ae730d6357 [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) 2026-08-18 09:03:33 -05:00
J. Nick KostonandGitHub 9823205ef3 [api] Bump noise-c to 0.1.20 (#18482) 2026-08-18 09:03:14 -05:00
Jesse Hills d1391c2b10 Bump version to 2026.8.0b5 2026-08-18 16:04:49 +12:00
J. Nick KostonandJesse Hills 8b888f31e0 [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) 2026-08-18 16:04:47 +12:00
Jesse Hills 6a247dfe91 [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) 2026-08-18 16:01:54 +12:00
J. Nick KostonandJesse Hills 482869fbbe [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 4dea147386 Bump bundled esphome-device-builder to 1.11.2 (#18477) 2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 1fd6337254 [api] Bump noise-c to 0.1.19 (#18473) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 4ce6d59484 Bump bundled esphome-device-builder to 1.11.1 (#18475) 2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 014cc19902 [api] Bump noise-c to 0.1.18 (#18451) 2026-08-18 14:27:54 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
b9041566ea Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 3a403c40d5 [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 096e71bd67 Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) 2026-08-18 14:27:54 +12:00
J. Nick KostonandGitHub fffa902a1a [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) 2026-08-18 13:32:03 +12:00
Jesse HillsandGitHub 463e3833da [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) 2026-08-18 13:19:48 +12:00
J. Nick KostonandGitHub 4416aacebb [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) 2026-08-18 13:09:29 +12:00
esphome[bot]andGitHub 15a626bcf3 Bump bundled esphome-device-builder to 1.11.2 (#18477) 2026-08-17 19:16:38 -05:00
J. Nick KostonandGitHub a347a2e879 [api] Bump noise-c to 0.1.19 (#18473) 2026-08-17 18:58:11 -05:00
dependabot[bot]andGitHub 7be4566b41 Bump platformdirs from 4.11.2 to 4.11.3 (#18468)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 18:33:13 -05:00
esphome[bot]andGitHub 96e26c6a5f Bump bundled esphome-device-builder to 1.11.1 (#18475) 2026-08-17 17:54:23 -05:00
J. Nick KostonandGitHub 7362c01c67 [core] Replace base64 lookup tables with arithmetic mapping (#18454) 2026-08-17 16:14:06 -05:00
Jesse HillsandGitHub e45b4e4938 [core] Make FINAL_VALIDATE_SCHEMA functions return None (#18457) 2026-08-18 08:26:24 +12:00
J. Nick KostonandGitHub e32329cc11 [core] Sync pre-commit ruff hook with requirements (0.16.3) (#18469) 2026-08-17 15:00:37 -05:00
dependabot[bot]andGitHub 199e368fe2 Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#18463)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:57:50 -05:00
dependabot[bot]andGitHub 55726120db Bump esphome/workflows/.github/workflows/lock.yml from 2026.7.0 to 2026.8.1 (#18465)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:47:28 -05:00
dependabot[bot]andGitHub 346ba7e831 Bump github/codeql-action/analyze from 4.37.6 to 4.37.7 (#18467)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:47:18 -05:00
dependabot[bot]andGitHub 9518006724 Bump ruff from 0.16.2 to 0.16.3 (#18458)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:46:04 -05:00
dependabot[bot]andGitHub 3d5f6f692f Bump filelock from 3.32.2 to 3.32.3 (#18461)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:45:41 -05:00
dependabot[bot]andGitHub 4712c15c75 Bump github/codeql-action/init from 4.37.6 to 4.37.7 (#18466)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:42:24 -05:00
dependabot[bot]andGitHub 9bc72529a6 Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 in /.github/actions/restore-python (#18462)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:42:08 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6d20943a9c Bump esphome/workflows/.github/workflows/stale.yml from 61fd37a044cad4e9aa4303027b2a61b6a34da855 to a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 (#18464)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 15:38:06 -04:00
J. Nick KostonandGitHub a3af82867b [api] Bump noise-c to 0.1.18 (#18451) 2026-08-17 14:00:39 -05:00
J. Nick KostonandGitHub 27483a4101 [core] Retry gh CLI calls on transient network errors in CI scripts (#18292) 2026-08-17 13:15:31 -05:00
J. Nick KostonandGitHub 6d20ebc66b [socket] Shorten lwip TAG string (#18450) 2026-08-17 17:18:43 +00:00
J. Nick KostonandGitHub 031a038b49 [deep_sleep] Shorten bk72xx TAG string (#18445) 2026-08-17 11:56:37 -05:00
J. Nick KostonandGitHub f6c7434b2a [i2c] Shorten platform backend TAG strings (#18440) 2026-08-17 11:54:55 -05:00
J. Nick KostonandGitHub 3d9fecb562 [remote_receiver] Shorten esp32 TAG string (#18447) 2026-08-17 11:54:52 -05:00
J. Nick KostonandGitHub ebe93e2c68 [bluetooth_connection] Shorten platform TAG strings (#18449) 2026-08-17 11:54:35 -05:00
J. Nick KostonandGitHub 1f4fcead38 [nextion] Shorten upload TAG strings (#18448) 2026-08-17 11:54:29 -05:00
J. Nick KostonandGitHub 1f000ba668 [mqtt] Shorten esp32 backend TAG string (#18446) 2026-08-17 11:54:17 -05:00
J. Nick KostonandGitHub e0d28d7f5c [http_request] Shorten platform backend TAG strings (#18444) 2026-08-17 11:54:03 -05:00
J. Nick KostonandGitHub 47a58dd799 [internal_temperature] Shorten platform TAG strings (#18443) 2026-08-17 11:53:56 -05:00
J. Nick KostonandGitHub 37bea1c153 [spi] Shorten platform backend TAG strings (#18442) 2026-08-17 11:53:48 -05:00
J. Nick KostonandGitHub d1a7b8df8b [adc] Shorten platform TAG strings (#18441) 2026-08-17 11:53:42 -05:00
J. Nick KostonandGitHub c01f24553c [uart] Shorten platform backend TAG strings (#18439) 2026-08-17 11:53:29 -05:00
J. Nick KostonandGitHub 07e8b303b9 [ota] Shorten platform backend TAG strings (#18438) 2026-08-17 11:52:56 -05:00
esphome[bot]GitHubesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
ebb0923362 Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-08-16 22:34:00 -05:00
J. Nick KostonandGitHub e1c279718f [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) 2026-08-16 21:57:12 -05:00
J. Nick KostonandGitHub cf764740cf [api] Create the camera image reader lazily (#18421) 2026-08-16 21:56:58 -05:00
J. Nick KostonandGitHub 58d549ed4c [api] Move NoiseProtocolId off the connection object (#18420) 2026-08-16 21:56:37 -05:00
Jesse Hills 6b22d8068d Merge branch 'beta' into dev 2026-08-17 11:36:10 +12:00
Jesse HillsandGitHub 443d8f1f28 Merge pull request #18424 from esphome/bump-2026.8.0b4
2026.8.0b4
2026-08-17 11:33:27 +12:00
Jesse Hills 1ec21a2245 Bump version to 2026.8.0b4 2026-08-17 10:22:41 +12:00
J. Nick KostonandJesse Hills bb7d4c3630 [esp32] Split crash handler addr2line hint per core (#18418) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills f42fe9af29 [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills 0bc2d71370 [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) 2026-08-17 10:22:39 +12:00
luar123andJesse Hills 594c12b3d9 [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) 2026-08-17 10:22:39 +12:00
Jonathan SwobodaandJesse Hills bca72e9b6d [sensor] Pass NaN through the delta filter again (#18400) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills ce09504c92 [platformio] Skip ccache when the binary on PATH fails to run (#18407) 2026-08-17 10:22:39 +12:00
esphome[bot]andJesse Hills dda4566b9e Bump bundled esphome-device-builder to 1.11.0 (#18403) 2026-08-17 10:22:39 +12:00
Samuel SiebandJesse Hills 46a5665a66 [rotary_encoder] account for min and max value when resetting (#18197) 2026-08-17 10:22:39 +12:00
esphome[bot]andJesse Hills 9161f74bb1 Bump bundled esphome-device-builder to 1.10.0 (#18389) 2026-08-17 10:22:39 +12:00
J. Nick KostonandGitHub 3f01f9895f [esp32_hosted] Require ESP-IDF 5.3 or newer (#18417) 2026-08-16 12:44:49 -07:00
J. Nick KostonandGitHub 801a1817b5 [esp32] Split crash handler addr2line hint per core (#18418) 2026-08-16 12:44:00 -07:00
J. Nick KostonandGitHub 32c76ae828 [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) 2026-08-16 12:43:27 -07:00
J. Nick KostonandGitHub c664f5fc95 [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) 2026-08-16 11:06:22 -07:00
luar123andGitHub 2bc4681fd6 [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) 2026-08-16 13:33:25 -04:00
Jonathan SwobodaandGitHub 646501b0ef [sensor] Pass NaN through the delta filter again (#18400) 2026-08-16 12:36:51 -04:00
J. Nick KostonandGitHub de3e657d8b [platformio] Skip ccache when the binary on PATH fails to run (#18407) 2026-08-16 08:42:47 -07:00
esphome[bot]andGitHub 1add726892 Bump bundled esphome-device-builder to 1.11.0 (#18403) 2026-08-15 13:36:52 -07:00
Samuel SiebandGitHub 5a000cf5e4 [rotary_encoder] account for min and max value when resetting (#18197) 2026-08-15 11:16:04 -07:00
Joppy FurrandGitHub 6ed676fe32 [lvgl] Restore long_press_repeat_time functionality (#18393) 2026-08-14 23:14:53 -07:00
esphome[bot]andGitHub 7cceddb8a3 Bump bundled esphome-device-builder to 1.10.0 (#18389) 2026-08-15 01:48:27 +00:00
J. Nick KostonandGitHub 039b897e7b [ethernet] Defer clk_mode removal to 2026.11.0 (#18380) 2026-08-14 18:45:36 -07:00
J. Nick KostonandGitHub b178f74e5d [core] Save the validated config cache on the first upload or logs run (#18367) 2026-08-14 18:45:23 -07:00
Clyde StubbsandGitHub e5224e22ae [ci] Compare merge-branch base ref against the default branch (#18385) 2026-08-15 06:18:22 +10:00
Clyde StubbsandGitHub be66e8b99c [ci] Disable CodSpeed benchmarks job outside esphome/esphome (#18372) 2026-08-15 04:33:47 +10:00
Jesse Hills 4b91c8aff4 Merge branch 'beta' into dev 2026-08-14 23:15:57 +12:00
Jesse HillsandGitHub 9d7997f55a Merge pull request #18368 from esphome/bump-2026.8.0b3
2026.8.0b3
2026-08-14 22:49:40 +12:00
Jesse Hills 4db47de556 Bump version to 2026.8.0b3 2026-08-14 17:36:56 +12:00
Karl BeeckenandJesse Hills c8de632764 [core] fix PYTHONPATH leak (#18360) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills b794b7b1d1 [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills add18d4e35 [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 02c1810c3a [core] Load component aliases from a generated registry (#18335) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 4f3153375a [ota] Retry uploads that fail from network errors (#18332) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 1c3a67b5e8 [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 236ff33a09 [esp32_ble] Silence spurious warnings for local key GAP events (#18359) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 7c07fb48c5 [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills d72bab79d7 [web_server_base] Stop deleting the web server on captive portal teardown (#18324) 2026-08-14 17:36:54 +12:00
Karl BeeckenandGitHub 617e2ec1e0 [core] fix PYTHONPATH leak (#18360) 2026-08-14 17:23:34 +12:00
J. Nick KostonandGitHub 1afac0312d [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) 2026-08-14 17:22:22 +12:00
J. Nick KostonandGitHub b05465145f [core] Add preference key stability integration test (#18364) 2026-08-13 21:14:30 -05:00
dependabot[bot]andGitHub 990fc402fd Bump bleak from 2.1.1 to 3.0.2 (#16246)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 20:24:02 -05:00
J. Nick KostonandGitHub dd51624fbb [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) 2026-08-13 20:06:48 -05:00
dependabot[bot]andGitHub 45a056e337 Bump platformdirs from 4.11.1 to 4.11.2 (#18363)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 18:54:51 -05:00
dependabot[bot]andGitHub 37782f7206 Bump prek from 0.4.12 to 0.4.13 (#18362)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 18:54:37 -05:00
J. Nick KostonandGitHub 945c2458b3 [core] Load component aliases from a generated registry (#18335) 2026-08-13 13:31:19 -05:00
J. Nick KostonandGitHub 7420d23867 [ota] Retry uploads that fail from network errors (#18332) 2026-08-13 13:31:06 -05:00
J. Nick KostonandGitHub 191686c5b3 [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) 2026-08-13 13:30:53 -05:00
J. Nick KostonandGitHub 137351fa8d [esp32_ble] Silence spurious warnings for local key GAP events (#18359) 2026-08-13 13:27:01 -05:00
J. Nick KostonandGitHub db5173697a [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) 2026-08-13 13:22:48 -05:00
Oliver KleineckeGitHubOliver Kleineckepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
c7940382a9 [const] move CONF_LABEL to components/const (#18354)
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-13 14:18:22 -04:00
esphome[bot]andGitHub 87045ab9c0 Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) 2026-08-13 11:14:14 -05:00
f1c4086778 [const] Move CONF_SLOT to components/const (#18350)
Co-authored-by: Oliver Kleinecke <p1ngb4ck@users.noreply.github.com>
2026-08-13 23:36:11 +12:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jonathan Swoboda
f337d0acff Bump pylint from 4.0.6 to 4.0.7 (#18320)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-13 02:13:29 +00:00
J. Nick KostonandGitHub 9cc05b30d4 [web_server_base] Stop deleting the web server on captive portal teardown (#18324) 2026-08-12 21:09:01 -05:00
Jesse Hills e0b68c4d6d Merge branch 'beta' into dev 2026-08-13 14:06:46 +12:00
Jesse HillsandGitHub 9c3407cfdd Merge pull request #18334 from esphome/bump-2026.8.0b2
2026.8.0b2
2026-08-13 14:06:28 +12:00
Jesse Hills d3e27054f6 Bump version to 2026.8.0b2 2026-08-13 13:30:47 +12:00
J. Nick KostonandJesse Hills bd58b5c8b3 [core] Retry framework downloads on transient network errors (#18330) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills c1a326f32e [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills a14ea0e8fa [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) 2026-08-13 13:30:46 +12:00
esphome[bot]andJesse Hills 48d6368ff9 Bump bundled esphome-device-builder to 1.9.6 (#18328) 2026-08-13 13:30:46 +12:00
Kevin AhrendtandJesse Hills 83cff59fdd [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) 2026-08-13 13:30:46 +12:00
esphome[bot]andJesse Hills 89489b1f0d Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills 7569a7b5ce [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) 2026-08-13 13:30:45 +12:00
J. Nick KostonandGitHub 8e624b4117 [core] Retry framework downloads on transient network errors (#18330) 2026-08-13 12:14:50 +12:00
J. Nick KostonandGitHub 787a909aa4 [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) 2026-08-13 12:11:32 +12:00
J. Nick KostonandGitHub 905485b673 [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) 2026-08-13 11:49:34 +12:00
esphome[bot]andGitHub 99677390e0 Bump bundled esphome-device-builder to 1.9.6 (#18328) 2026-08-12 16:27:14 -05:00
Kevin AhrendtandGitHub f2121130f9 [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) 2026-08-12 12:49:54 -04:00
esphome[bot]andGitHub 1a01c34ec4 Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) 2026-08-12 16:33:45 +00:00
J. Nick KostonandGitHub 3c46cc9c35 [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) 2026-08-12 10:59:24 -04:00
Jesse Hills e192ec8fee Merge branch 'beta' into dev 2026-08-13 00:29:08 +12:00
Jesse HillsandGitHub ec6b4263a2 Merge pull request #18314 from esphome/bump-2026.8.0b1
2026.8.0b1
2026-08-13 00:28:40 +12:00
Jesse Hills 8a1aa5753d Bump version to 2026.9.0-dev 2026-08-12 22:53:42 +12:00
Jesse Hills 3e4661fe1e Bump version to 2026.8.0b1 2026-08-12 22:53:42 +12:00
J. Nick KostonandGitHub 22153be4cd [core] Add esphome logs over web_server HTTP SSE (#17110) 2026-08-12 22:00:07 +12:00
3f490fe1ed [internal_temperature] Read the RP2 on-die sensor directly instead of via the Arduino API (#18262)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-12 21:42:05 +12:00
J. Nick KostonandGitHub 58a42fe5c2 [core] Batch remote file downloads during config validation (#18069) 2026-08-12 18:44:35 +12:00
Josef ZweckGitHubJ. Nick Kostonpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
25c0c2c97b [hoermann_hcp] Add garage light control (#18190)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-12 06:10:23 +00:00
J. Nick KostonandGitHub 622942482c [rp2] Size the lwIP segment pool and heap for concurrent senders (#18257) 2026-08-12 17:30:17 +12:00
Keith BurzinskiandGitHub cffd775450 [ethernet][network][wifi] Arbitrate the default route from the network priority list (#17797) 2026-08-12 16:21:37 +12:00
J. Nick KostonandGitHub 8d87ba34d9 [api] Move the generic buffer-full log to very verbose (#18300) 2026-08-12 04:04:07 +00:00
J. Nick KostonandGitHub 08c6585915 [esp32_ble_tracker] Don't log an error when a scan stop is already in flight (#18306) 2026-08-12 16:03:28 +12:00
J. Nick KostonandGitHub 3ef74d17af [bluetooth_proxy] Give partial advertisement batches 200ms to fill on Wi-Fi (#18303) 2026-08-12 16:02:51 +12:00
J. Nick KostonandGitHub 5d04c1dc18 [esp32_ble_tracker] Demote scan-state echoes to verbose (#18302) 2026-08-12 16:01:45 +12:00
J. Nick KostonandGitHub a3d599ac69 [bluetooth_connection] Demote rp2 backend connection logs to verbose (#18301) 2026-08-12 16:00:37 +12:00
J. Nick KostonandGitHub a4e05cd1c8 [bluetooth_proxy] Move per-advertisement logging to very verbose (#18299) 2026-08-12 15:59:41 +12:00
J. Nick KostonandGitHub 1758653330 [voice_assistant] Do not consume the audio chunk when the send is refused (#18295) 2026-08-12 15:59:26 +12:00
J. Nick KostonandGitHub a2feff8f68 [api] Mark send_message nodiscard so refused frames are never silent (#18293) 2026-08-11 22:23:08 -05:00
J. Nick KostonandGitHub 37eae9b466 [api] Gate the bluetooth connection messages on their own define (#18281) 2026-08-11 21:31:13 -05:00
Jesse HillsandGitHub 9605b34c69 [adc] Deprecate pin: TEMPERATURE in favour of internal_temperature (#18304) 2026-08-12 14:20:41 +12:00
Jesse HillsandGitHub bab62b345b [adc] Fix internal temperature channel on RP2350A under arduino-pico (#18307) 2026-08-12 14:19:59 +12:00
Jesse HillsandGitHub 3349046c5d [rp2] Record the RP2350 die on generated board entries (#18305) 2026-08-12 01:56:05 +00:00
J. Nick KostonandGitHub 3f5b8139f3 [bluetooth_proxy] Latch the connection replies and tighten the send paths (#18278) 2026-08-12 13:52:25 +12:00
be5e28ea9e [core] Warn when running a different source tree than the one you are in (#18288)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 21:06:57 -04:00
J. Nick KostonandGitHub 9f28638ee7 [bluetooth_proxy] Reset every owed reply in one place (#18276) 2026-08-11 19:15:21 -05:00
J. Nick KostonandGitHub 201f843e95 [bluetooth_proxy] Latch the unpair reply (#18274) 2026-08-11 18:57:51 -05:00
J. Nick KostonandGitHub 37a59a07bc [bluetooth_proxy] Retry dropped GATT acks and stop the retry log spam (#18259) 2026-08-11 18:40:47 -05:00
9556c2bc4c [mitsubishi_cn105] Add vertical vane control action (#16737)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 18:39:05 -05:00
3540012529 [tests] Build tests from the tree they run in, not the venv's editable install (#18248)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-11 19:09:51 -04:00
e0b112c584 [sendspin] Clear metadata and controller state on disconnect (#18289)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 21:03:27 +00:00
Kevin AhrendtandGitHub 7f0d6a8696 [sendspin] Add image platform for artwork (#17937) 2026-08-11 15:49:08 -05:00
Kevin AhrendtandGitHub 4a7de87bff [audio] Bump microDecoder to v0.4.0 (#18291) 2026-08-11 15:33:37 -05:00
esphome[bot]andGitHub b9d1d2f06b Bump aioesphomeapi from 45.9.0 to 45.10.0 (#18294) 2026-08-11 20:02:53 +00:00
dependabot[bot]andGitHub 74c30c62ef Bump setuptools from 83.0.0 to 84.0.0 (#18290)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 14:36:59 -05:00
J. Nick KostonandGitHub 94fbfa05de [core] Show the out-of-flash tip instead of crashing the build (#18280) 2026-08-11 14:35:06 -05:00
55bd63732d [mitsubishi_cn105] Add vertical vane state trigger (#16727)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 13:56:12 -05:00
J. Nick KostonandGitHub 3ae651af7b [core] Stop dropping complete lines behind an unfinished one (#18279) 2026-08-11 12:38:55 -05:00
esphome[bot]andGitHub 55e8bc3b14 Bump aioesphomeapi from 45.8.0 to 45.9.0 (#18283) 2026-08-11 17:23:36 +00:00
J. Nick KostonandGitHub 9938a2487d [core] Show the last line when output stops without a newline (#18265) 2026-08-11 10:55:42 -05:00
J. Nick KostonandGitHub aee41d64c2 [rp2040_ble][bluetooth_connection] 3 connection slots on rp2 with esp32 parity (#18247) 2026-08-11 08:43:08 -05:00
J. Nick KostonandGitHub f3a5a9fbd5 [core] Retry transient git network failures with backoff (#18242) 2026-08-11 08:41:55 -05:00
8ee3c8d41d [modbus] Properly support client-mode broadcast sends (#17467)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 08:31:15 -05:00
J. Nick KostonandGitHub eefd2a00c7 [espidf] Flush the runner's output so dashboard builds stream (#18264) 2026-08-11 08:11:42 -05:00
J. Nick KostonandGitHub e8852c5950 [core] Flush stdout in safe_print so logs stream live (#18261) 2026-08-11 08:11:23 -05:00
069f40f653 [hoermann_hcp] Add connectivity binary sensor (#18189)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 08:09:33 -05:00
Jesse HillsandGitHub 04dd6b3a55 [core] Use MAC address size constants instead of literals (#18254) 2026-08-11 08:27:32 -04:00
Jesse HillsandGitHub 2ff55e3058 [rp2] Use SDK clock query directly in arch_get_cpu_freq_hz (#18269) 2026-08-11 08:23:08 -04:00
Jesse HillsandGitHub e5cdda9ee5 [adc] Fix RP2350B internal temperature reading wrong ADC channel (#18270) 2026-08-11 08:22:52 -04:00
Jesse HillsandGitHub ecec3a19c7 [debug] Remove Arduino core dependency from RP2 platform code (#18267) 2026-08-11 08:22:20 -04:00
J. Nick KostonandGitHub 9823ad6abc [tests] Fix flaky modbus server/controller integration tests (#18258) 2026-08-10 23:56:32 -05:00
946 changed files with 22404 additions and 5312 deletions
+3 -3
View File
@@ -32,7 +32,7 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -49,7 +49,7 @@ runs:
python -m venv venv
source venv/bin/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
@@ -58,5 +58,5 @@ runs:
python -m venv venv
source ./venv/Scripts/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
+2 -1
View File
@@ -70,6 +70,7 @@ async function isStackedPr(github, context) {
async function detectMergeBranch(github, context) {
const labels = new Set();
const baseRef = context.payload.pull_request.base.ref;
const defaultBranch = context.payload.repository.default_branch;
if (baseRef === 'release') {
labels.add('merging-to-release');
@@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) {
} else if (await isStackedPr(github, context)) {
// GitHub manages the merge order for a stack, so these are not blocked.
labels.add('stacked-pr');
} else if (baseRef !== 'dev') {
} else if (baseRef !== defaultBranch) {
// A chain built by hand: it must not merge until its base branch does.
labels.add('chained-pr');
}
@@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
// Builds a fresh context for detectMergeBranch tests instead of mutating the
// shared CONTEXT fixture above (which other describe blocks rely on).
function makeMergeContext(baseRef, { stack } = {}) {
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
const pull_request = { number: 1, base: { ref: baseRef } };
if (stack !== undefined) {
pull_request.stack = stack;
}
return {
repo: { owner: 'esphome', repo: 'esphome' },
payload: { pull_request }
payload: { pull_request, repository: { default_branch: defaultBranch } }
};
}
@@ -136,6 +136,21 @@ describe('detectMergeBranch', () => {
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
assert.equal(state.calls, 1);
});
it('base ref matches default branch adds no labels', async () => {
const { github } = makeStackGithub({ stack: null });
const context = makeMergeContext('other', { defaultBranch: 'other' });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), []);
});
it('base ref dev when the default branch is main adds chained-pr', async () => {
const { github } = makeStackGithub({ stack: null });
const context = makeMergeContext('dev', { defaultBranch: 'main' });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
});
});
// ---------------------------------------------------------------------------
+26 -4
View File
@@ -29,7 +29,7 @@ jobs:
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
@@ -41,10 +41,32 @@ jobs:
version: "0.11.15"
- name: Install apt dependencies
# PR-only workflow, so nothing on dev could seed a shared apt cache
# entry; the cached apt action would save one copy per PR. Plain apt
# with every call bounded: the apt.conf.d timeouts make a dead
# mirror fail over in seconds, and timeout runs under sudo so it can
# kill apt-get itself. Install without update first: image lists are
# fresh, and the index refresh is what a congested mirror makes slow.
timeout-minutes: 15
run: |
sudo apt update
sudo apt-cache show protobuf-compiler
sudo apt install -y protobuf-compiler
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
# Common path: the image's package lists are fresh enough.
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
apt-get install -y protobuf-compiler; then
protoc --version
exit 0
fi
# Rescue path: refresh the lists once with a generous bound; the
# apt config already fails a stalled mirror over quickly.
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
dpkg --configure -a || true
sudo timeout -k 15 300 apt-get update
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
apt-get install -y protobuf-compiler
protoc --version
- name: Install python dependencies
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Determine tag and whether to push
id: tag
@@ -153,7 +153,7 @@ jobs:
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to the GitHub container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+106 -42
View File
@@ -49,7 +49,7 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -68,6 +68,22 @@ jobs:
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
seed-apt-cache:
name: Seed apt package cache
runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version; keep both identical in every
# step that restores it. In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push'
timeout-minutes: 10
steps:
- name: Install apt packages (cached)
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
determine-jobs:
name: Determine which jobs to run
runs-on: ubuntu-24.04
@@ -179,6 +195,7 @@ jobs:
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_alias_registry.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
@@ -322,7 +339,8 @@ jobs:
integration-tests:
name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest
# Must match seed-apt-cache's image: the apt cache key has no OS in it.
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
@@ -334,24 +352,16 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install ccache
# Speeds up the host compiles: tests in a bucket compile overlapping
# component sets, so later tests reuse earlier tests' objects.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ccache
- name: Restore ccache (restore-only)
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py). The
# bucket-name prefix prefers a same-bucket seed; the bare prefix falls
# back to any seed when the bucket layout differs from dev.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly;
# libsdl2-dev is unused here and carried only for cache-key parity.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
restore-keys: |
integration-ccache-${{ matrix.bucket.name }}-
integration-ccache-
packages: libsdl2-dev ccache
version: 1.1
- name: Set up Python 3.13
id: python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -366,7 +376,7 @@ jobs:
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -400,14 +410,6 @@ jobs:
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
- name: Save ccache
# Pull request saves land in per-PR scopes nothing else can reuse;
# dev pushes seed the shared copy instead.
if: github.event_name != 'pull_request'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
import-time:
name: Check import esphome.__main__ time
@@ -440,12 +442,17 @@ jobs:
benchmarks:
name: Run CodSpeed benchmarks
runs-on: ubuntu-24.04
timeout-minutes: 30
needs:
- common
- determine-jobs
if: >-
(github.event_name == 'push' && github.ref_name == 'dev') ||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
github.repository == 'esphome/esphome' && (
(github.event_name == 'push' && github.ref_name == 'dev') ||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
)
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -459,12 +466,58 @@ jobs:
- name: Build benchmarks
id: build
run: |
# pipefail: without it a failed build is masked by the grep/cut
# pipeline below, leaving BINARY empty and silently dropping every
# C++ benchmark from the run while the job still reports success.
set -o pipefail
. venv/bin/activate
export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
# --build-only prints BUILD_BINARY=<path> to stdout
BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-)
BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
export BENCHMARK_LIB_CONFIG
# --build-only prints BUILD_BINARY=<path> to stdout; the grep is
# non-fatal so a missing marker reaches the check below instead of
# tripping errexit at this assignment
BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-)
if [ -z "$BINARY" ]; then
echo "::error::Benchmark build did not report a binary path"
exit 1
fi
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Bound apt fetches and pre-install libc6-dbg
# The CodSpeed runner installs valgrind + libc6-dbg via its own
# unbounded apt-get update; per-invocation apt options cannot reach
# it. The apt.conf.d timeouts below bound every later apt call in
# this job, the runner's included. Pre-installing libc6-dbg lets the
# runner skip apt once its valgrind cache is restored (it checks
# ``dpkg -s libc6-dbg``, so the cache action's unregistered restores
# would not count). Install without update first: image lists are
# fresh, and the index refresh is what a congested mirror makes
# slow. Best effort; the job timeout is the last backstop.
timeout-minutes: 15
continue-on-error: true
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
if dpkg -s libc6-dbg >/dev/null 2>&1; then
echo "libc6-dbg already installed"
exit 0
fi
# Common path: the image's package lists are fresh enough.
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
apt-get install -y libc6-dbg; then
exit 0
fi
# Rescue path: refresh the lists once with a generous bound; the
# apt config already fails a stalled mirror over quickly.
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
dpkg --configure -a || true
sudo timeout -k 15 300 apt-get update
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
with:
@@ -549,24 +602,29 @@ jobs:
fetch-depth: 2
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
# Key on the exact Python version as well: LibreTiny creates a venv under
# ~/.platformio/penv whose interpreter is a symlink into the runner's
# hosted toolcache, so a cache saved on an older runner image breaks once
# a new image ships a newer patch release and drops the old interpreter.
- name: Cache platformio
if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
- name: Cache ESP-IDF install
if: matrix.cache_idf
@@ -883,12 +941,17 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Install apt packages
# Not cached: this job is pull-request-only, so a cache save could
# never be shared and would only consume quota.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. Packages and version must match seed-apt-cache
# exactly. The action has no --no-install-recommends; same package
# set this job used before #17463.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -1090,7 +1153,7 @@ jobs:
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -1423,6 +1486,7 @@ jobs:
# this check.
needs:
- common
- seed-apt-cache
- determine-jobs
- ci-custom
- pylint
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
+2 -2
View File
@@ -123,7 +123,7 @@ jobs:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to docker hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -202,7 +202,7 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``prek`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.0
rev: v0.16.3
hooks:
# Run the linter.
- id: ruff
+10
View File
@@ -763,3 +763,13 @@ The project uses English for non-code content. When drafting documentation, code
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
audience, including non-native English speakers.
## 10. Code Comments
Code comments on individual lines should be used only where necessary to flag issues that may not be obvious
on a simple reading of the code. Keep them short (e.g. 1 or 2 lines).
Function and method comment blocks may include more detail as required to make
calling contracts clear and document parameter usage, but should still be kept concise.
Avoid redundancy and repetition; comments should never simply restate what the code already says.
+1
View File
@@ -466,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/image/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.8.0-dev
PROJECT_NUMBER = 2026.9.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5
RUN uv pip install --no-cache-dir esphome-device-builder==1.12.4
RUN \
platformio settings set enable_telemetry No \
+212 -75
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import re
import sys
import time
from typing import Protocol
from typing import TYPE_CHECKING, Protocol
# Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting
@@ -21,7 +21,6 @@ from esphome.const import (
ARGUMENT_HELP_DEVICE,
BUNDLE_EXTENSION,
CONF_API,
CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DEASSERT_RTS_DTR,
@@ -29,6 +28,7 @@ from esphome.const import (
CONF_DISCOVER_IP,
CONF_ESPHOME,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
CONF_LOGGER,
CONF_MDNS,
@@ -42,7 +42,7 @@ from esphome.const import (
CONF_PORT,
CONF_SUBSTITUTIONS,
CONF_TOPIC,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
@@ -71,6 +71,9 @@ from esphome.util import (
safe_print,
)
if TYPE_CHECKING:
import threading
# Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this
# module's top level. Every `esphome` invocation — including fast paths
# like `esphome version` — pays the cost of what's imported here before
@@ -273,8 +276,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
if purpose == Purpose.LOGGING and not has_api():
return (
"Cannot view logs over the network: no 'api:' component is "
"configured. Network log streaming requires the native API; add "
"an 'api:' component, enable MQTT logging, or view logs over USB."
"configured. Add an 'api:' component, enable MQTT logging, add a "
"'web_server:' component, or view logs over USB."
)
if purpose == Purpose.UPLOADING and not has_ota():
return (
@@ -314,9 +317,12 @@ def choose_upload_log_host(
]
resolved.append(choose_prompt(options, purpose=purpose))
elif device == "OTA":
# Logs can stream over a network transport via the native API
# or the web_server HTTP SSE feed.
network_logging = has_api() or has_web_server_logging()
# ensure IP adresses are used first
if is_ip_address(CORE.address) and (
(purpose == Purpose.LOGGING and has_api())
(purpose == Purpose.LOGGING and network_logging)
or (purpose == Purpose.UPLOADING and has_ota())
):
resolved.extend(_resolve_with_cache(CORE.address, purpose))
@@ -328,7 +334,11 @@ def choose_upload_log_host(
if has_mqtt_logging():
resolved.append("MQTT")
if has_api() and has_non_ip_address() and has_resolvable_address():
if (
network_logging
and has_non_ip_address()
and has_resolvable_address()
):
resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
@@ -390,7 +400,7 @@ def choose_upload_log_host(
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api():
if has_api() or has_web_server_logging():
add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
@@ -483,6 +493,21 @@ def has_web_server_ota() -> bool:
)
def has_web_server_logging() -> bool:
"""Check if logs can be streamed over the web_server HTTP SSE endpoint.
The ``web_server`` component exposes a ``/events`` Server-Sent Events
stream that carries ``event: log`` frames. This requires version 2+ (the
v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
"""
web_conf = CORE.config.get(CONF_WEB_SERVER)
if web_conf is None:
return False
if web_conf.get(CONF_VERSION, 2) == 1:
return False
return web_conf.get(CONF_LOG, True)
def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported."""
if CONF_MQTT not in CORE.config:
@@ -545,11 +570,48 @@ def has_name_add_mac_suffix() -> bool:
def mqtt_get_ip(
config: ConfigType, username: str, password: str, client_id: str
config: ConfigType,
username: str,
password: str,
client_id: str,
stop_event: "threading.Event | None" = None,
) -> list[str]:
from esphome import mqtt
return mqtt.get_esphome_device_ip(config, username, password, client_id)
return mqtt.get_esphome_device_ip(
config, username, password, client_id, stop_event=stop_event
)
def _add_network_device(device: str, network_devices: list[str]) -> None:
"""Append a device to the list, expanding it through ``CORE.address_cache``.
If the hostname is already in the address cache (e.g. populated by mDNS
discovery), substitute the cached IPs so aioesphomeapi doesn't open its
own Zeroconf to re-resolve it. Duplicates are dropped.
"""
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
network_devices.extend(addr for addr in cached if addr not in network_devices)
elif device not in network_devices:
network_devices.append(device)
def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]:
"""Split the device list into direct addresses and an MQTT-lookup flag.
Direct addresses are expanded through ``CORE.address_cache`` and deduped
the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings
are not resolved, only reported via the returned bool so the caller can
defer the broker lookup.
"""
network_devices: list[str] = []
has_mqtt_lookup = False
for device in devices:
if get_port_type(device) in _MQTT_PORT_TYPES:
has_mqtt_lookup = True
else:
_add_network_device(device, network_devices)
return network_devices, has_mqtt_lookup
def _resolve_network_devices(
@@ -582,40 +644,44 @@ def _resolve_network_devices(
if port_type in _MQTT_PORT_TYPES:
# Only resolve MQTT once, even if multiple MQTT entries
if not mqtt_resolved:
try:
mqtt_ips = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
# pylint can't infer mqtt_get_ip's return through its
# lazy ``from esphome import mqtt`` import, so it flags
# the genexpr below.
network_devices.extend(
addr
for addr in mqtt_ips # pylint: disable=not-an-iterable
if addr not in network_devices
)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
mqtt_ips = _mqtt_get_ip_or_warn(
config, args.username, args.password, args.client_id
)
network_devices.extend(
addr for addr in mqtt_ips if addr not in network_devices
)
mqtt_resolved = True
continue
# If the hostname is already in the address cache (e.g. populated by
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
# open its own Zeroconf to re-resolve it.
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
network_devices.extend(
addr for addr in cached if addr not in network_devices
)
elif device not in network_devices:
# Regular network address or IP - add if not already present
network_devices.append(device)
_add_network_device(device, network_devices)
return network_devices
def _mqtt_get_ip_or_warn(
config: ConfigType,
username: str,
password: str,
client_id: str,
stop_event: "threading.Event | None" = None,
) -> list[str]:
"""Look up the device IP via MQTT, returning [] with a warning on failure.
This owns the failure policy for MQTT IP discovery on paths that have
other addresses to fall back on: a broker problem must not abort the
operation. Also used as the deferred resolver handed to ``run_logs``,
where it runs in a worker thread.
"""
try:
return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
return []
def run_miniterm(config: ConfigType, port: str, args) -> int:
from datetime import datetime
@@ -696,9 +762,11 @@ def _wrap_to_code(name, comp, yaml_util):
async def wrapped(conf):
cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None:
conf_str = yaml_util.dump(conf)
# sort_keys: voluptuous fills defaults in set order, so an
# unsorted dump would churn main.cpp and relink every run
conf_str = yaml_util.dump(conf, sort_keys=True)
conf_str = conf_str.replace("//", "")
# remove tailing \ to avoid multi-line comment warning
# remove trailing \ to avoid multi-line comment warning
conf_str = conf_str.replace("\\\n", "\n")
cg.add(cg.LineComment(indent(conf_str)))
await coro(conf)
@@ -1291,25 +1359,23 @@ def _upload_via_native_api(
def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]:
web_conf = config.get(CONF_WEB_SERVER)
if not web_conf:
raise EsphomeError(
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
f"is not configured."
)
remote_port = int(web_conf[CONF_PORT])
auth = web_conf.get(CONF_AUTH) or {}
username = auth.get(CONF_USERNAME)
password = auth.get(CONF_PASSWORD)
from esphome import web_server_ota
from esphome.web_server_helpers import get_web_server_connection
remote_port, username, password = get_web_server_connection(config)
return web_server_ota.run_ota(
network_devices, remote_port, username, password, binary
)
def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
from esphome import web_server_logs
from esphome.web_server_helpers import get_web_server_connection
port, username, password = get_web_server_connection(config)
return web_server_logs.run_logs(network_devices, port, username, password)
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
@@ -1418,17 +1484,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
return run_miniterm(config, port, args)
# Check if we should use API for logging
# Resolve MQTT magic strings to actual IP addresses
if has_api() and (
network_devices := _resolve_network_devices(devices, config, args)
):
from esphome.api_client import run_logs
if has_api():
network_devices, has_mqtt_lookup = _split_network_devices(devices)
mqtt_resolver = None
if has_mqtt_lookup:
if network_devices:
# Addresses are already known, so don't block startup on the
# MQTT broker lookup; hand it to run_logs as a deferred
# resolver that runs in the background and feeds discovered
# addresses into the running log client, keeping MQTT as a
# fallback for when the known addresses are stale (e.g. DHCP
# reassigned the IP).
mqtt_resolver = functools.partial(
_mqtt_get_ip_or_warn,
config,
args.username,
args.password,
args.client_id,
)
else:
# The MQTT lookup is the only way to find the device; resolve
# it up front since the client needs an address to start with.
network_devices = _resolve_network_devices(devices, config, args)
if network_devices:
from esphome.api_client import run_logs
return run_logs(
config,
network_devices,
subscribe_states=_should_subscribe_states(args),
)
return run_logs(
config,
network_devices,
subscribe_states=_should_subscribe_states(args),
mqtt_resolver=mqtt_resolver,
)
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
from esphome import mqtt
@@ -1437,6 +1523,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
config, args.topic, args.username, args.password, args.client_id
)
# Fall back to the web_server HTTP SSE log stream for devices that have
# web_server: but no api: (the logging counterpart to web_server OTA).
if has_web_server_logging() and (
network_devices := _resolve_network_devices(devices, config, args)
):
return _show_logs_via_web_server(config, network_devices)
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
@@ -2509,6 +2602,49 @@ def parse_args(argv):
return parser.parse_args(arguments)
def _warn_if_source_tree_mismatch() -> None:
"""Warn when the checkout the user is standing in is not the one being run.
An editable install records one absolute path, so a venv shared between git
worktrees (or reused after a checkout is copied or renamed) keeps importing
the tree it was installed from. Every command then silently runs, and
compiles, sources the user is not looking at. Only fires inside a checkout,
so ordinary installs never see it.
"""
try:
cwd = Path.cwd()
except OSError:
return # working directory is gone; a diagnostic must not break startup
for candidate in (cwd, *cwd.parents):
if (candidate / "esphome" / "__main__.py").is_file():
standing_in = candidate.resolve()
break
else:
return # not inside a checkout; nothing to compare against
running = Path(__file__).resolve().parent.parent
# Both sides are resolved, so on a case-sensitive filesystem this matches
# plain equality. samefile() compares device and inode, which additionally
# covers a case-insensitive filesystem (macOS) reaching one directory by
# differently cased paths. Falls back to equality if either path is gone.
try:
same = standing_in.samefile(running)
except OSError:
same = standing_in == running
if same:
return
_LOGGER.warning(
"Running ESPHome from a different checkout than the one you are in:\n"
" running from: %s\n"
" you are in: %s\n"
"The installed esphome resolves to the first, so its sources are used.\n"
"Run 'python -m esphome' from the second to use that one instead.",
running,
standing_in,
)
def run_esphome(argv):
from esphome.address_cache import AddressCache
@@ -2527,6 +2663,7 @@ def run_esphome(argv):
args.log_level = "CRITICAL"
setup_log(log_level=args.log_level)
_warn_if_source_tree_mismatch()
if args.command in PRE_CONFIG_ACTIONS:
try:
@@ -2597,7 +2734,8 @@ def run_esphome(argv):
conf_path.name,
)
if config is None:
cache_missed = config is None
if cache_missed:
from esphome.config import read_config
config = read_config(
@@ -2606,26 +2744,25 @@ def run_esphome(argv):
# Snapshot only needed by `esphome config --no-defaults`.
snapshot_user_config=getattr(args, "no_defaults", False),
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None:
return 2
if config is None:
return 2
CORE.config = config
# Fallback for platforms whose validators didn't set the toolchain
# (only the esp32 component reads esp32.framework.toolchain). All
# other platforms only support PlatformIO today.
# other platforms only support PlatformIO today. Must run before the
# cache refresh below so its sidecar records the same toolchain a
# compile would.
if CORE.toolchain is None:
CORE.toolchain = Toolchain.PLATFORMIO
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config.
if cache_eligible and cache_missed:
from esphome.compiled_config import save_compiled_config_and_sidecar
save_compiled_config_and_sidecar(config)
if args.command not in POST_CONFIG_ACTIONS:
safe_print(f"Unknown command {args.command}")
return 1
+84 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from contextlib import suppress
import logging
import threading
from typing import TYPE_CHECKING, Any
import warnings
@@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor
from esphome.util import safe_print
if TYPE_CHECKING:
from collections.abc import Callable
from aioesphomeapi.api_pb2 import (
SubscribeLogsResponse, # pylint: disable=no-name-in-module
)
@@ -32,8 +35,18 @@ async def async_run_logs(
config: dict[str, Any],
addresses: list[str],
subscribe_states: bool = True,
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
) -> None:
"""Run the logs command in the event loop."""
"""Run the logs command in the event loop.
If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt
has no asyncio support on Windows) concurrently with the connection
attempts to ``addresses``, and any addresses it discovers are fed into
the running client. It owns its own failure handling (returning [] when
discovery fails) and must honor the ``threading.Event`` it is passed so
teardown is not delayed by the lookup's wait window; the initial broker
connect itself is only bounded by the socket timeout.
"""
from datetime import datetime
conf = config["api"]
@@ -60,6 +73,41 @@ async def async_run_logs(
# Decoder resolution policy lives in LogLineProcessor.
processor = LogLineProcessor(config, CORE.target_platform)
mqtt_task: asyncio.Task[None] | None = None
mqtt_stop_event = threading.Event()
def _cancel_mqtt_discovery() -> None:
"""Stop the broker lookup once a connection has been established.
Its answer is only useful while still disconnected: after that it
either duplicates the connected address or arrives too late to
matter, so don't keep an idle broker session open for it.
"""
mqtt_stop_event.set()
if mqtt_task is not None and not mqtt_task.done():
mqtt_task.cancel()
async def _resolve_mqtt_addresses() -> None:
"""Discover the device address via the MQTT broker in the background."""
try:
mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event)
if not mqtt_ips:
_LOGGER.debug(
"MQTT discovery %s",
"aborted" if mqtt_stop_event.is_set() else "found no addresses",
)
return
if cli.add_addresses(mqtt_ips):
_LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips))
else:
_LOGGER.debug(
"MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips)
)
except Exception: # pylint: disable=broad-except
# A background task failure would otherwise stay invisible for
# the whole session and only re-raise at teardown
_LOGGER.exception("MQTT address discovery failed")
def on_log(msg: SubscribeLogsResponse) -> None:
"""Handle a new log message."""
time_ = datetime.now().astimezone()
@@ -98,20 +146,53 @@ async def async_run_logs(
# A top-level ``deep_sleep:`` block means the device is only awake
# briefly; cap the reconnect backoff so a wake window is not missed.
deep_sleep="deep_sleep" in config,
on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None,
)
try:
# Don't start (or keep) the broker lookup if a connection already
# succeeded; the stop event doubles as the not-needed-anymore latch
# and get_esphome_device_ip returns immediately when it is set.
if mqtt_resolver is not None and not mqtt_stop_event.is_set():
mqtt_task = asyncio.create_task(_resolve_mqtt_addresses())
await asyncio.Event().wait()
finally:
await stop()
try:
if mqtt_task is not None:
# Unblock the worker thread first so it can't hold up
# loop.shutdown_default_executor() for the full lookup timeout.
mqtt_stop_event.set()
# Give the worker a moment to exit through its own error
# handling; cancelling first would race out a late failure.
done, _ = await asyncio.wait([mqtt_task], timeout=1.0)
if not done:
mqtt_task.cancel()
# return_exceptions keeps a CancelledError from the cancel()
# above from re-raising here and jumping over the stop() below.
# The task handles Exception itself, so only a BaseException
# escape (e.g. SystemExit from the worker) can land here.
(result,) = await asyncio.gather(mqtt_task, return_exceptions=True)
if isinstance(result, BaseException) and not isinstance(
result, asyncio.CancelledError
):
_LOGGER.error("MQTT address discovery failed", exc_info=result)
finally:
# Must run even if a second cancellation lands mid-cleanup above
await stop()
def run_logs(
config: dict[str, Any],
addresses: list[str],
subscribe_states: bool = True,
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
) -> None:
"""Run the logs command."""
with suppress(KeyboardInterrupt):
asyncio.run(
async_run_logs(config, addresses, subscribe_states=subscribe_states)
async_run_logs(
config,
addresses,
subscribe_states=subscribe_states,
mqtt_resolver=mqtt_resolver,
)
)
+45 -4
View File
@@ -3,7 +3,12 @@
import json
from pathlib import Path
from esphome.components.esp32 import get_esp32_variant, idf_version
from esphome.components.esp32 import (
get_esp32_variant,
get_excluded_builtin_components,
get_managed_component_require_names,
idf_version,
)
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.framework_helpers import (
@@ -67,6 +72,13 @@ def has_discovered_components() -> bool:
return get_available_components() is not None
def _cmake_quote(value: str) -> str:
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
whitespace, quotes, and '$', so only backslashes need escaping."""
escaped = value.replace("\\", "\\\\")
return f'"{escaped}"'
def get_project_cmakelists(minimal: bool = False) -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
@@ -109,6 +121,15 @@ def get_project_cmakelists(minimal: bool = False) -> str:
else ""
)
# CMake variables registered via cg.add_cmake_arg(). Emitted before
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
# set when project.cmake seeds the component list, and on minimal
# (discovery) writes too so excluded components never register.
cmake_args = "\n".join(
f"set({name} {_cmake_quote(value)})"
for name, value in sorted(CORE.cmake_args.items())
)
# Per-project list exposed as a CMake variable so converted PIO libs
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
# project-specific names into their cached CMakeLists.
@@ -119,8 +140,6 @@ def get_project_cmakelists(minimal: bool = False) -> str:
# runs as a separate CMake script invocation that doesn't load the
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
from esphome.components.esp32 import get_managed_component_require_names
managed_components_property = "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
for name in get_managed_component_require_names()
@@ -131,12 +150,22 @@ def get_project_cmakelists(minimal: bool = False) -> str:
# component's REQUIRES including real IDF components). Referenced by
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
# on minimal writes because project_description.json may be stale.
# Excluded components are dropped here as well: a stale
# project_description.json from a build without exclusions may still
# list them, and requiring an excluded component pulls it back into
# the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS).
# Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the
# two can never disagree within one generated file.
builtin_components_property = (
""
if minimal
else "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
for name in sorted(get_available_components() or [])
for name in sorted(
set(get_available_components() or []).difference(
CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";")
)
)
)
)
@@ -163,6 +192,8 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)
set(IDF_TARGET {idf_target})
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
{cmake_args}
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{cpp_standard_options}
@@ -264,3 +295,13 @@ def write_project(minimal: bool = False) -> None:
CORE.relative_src_path("CMakeLists.txt"),
get_component_cmakelists(),
)
# Snapshot the exclusion set so has_outdated_files() can trigger a
# discovery reconfigure when it changes. Excluded components never
# register in project_description.json, so re-including one (e.g. a
# config gains mqtt) requires a fresh discovery pass before the
# ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it.
write_file_if_changed(
CORE.relative_build_path("exclude_components.esphomeinternal"),
";".join(get_excluded_builtin_components()),
)
+11
View File
@@ -63,6 +63,17 @@ def get_ini_content():
# Add extra script for C++ flags
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
# Add CMake args. A user-supplied value (str or list) is deliberately
# replaced; this option was always overwritten at FINAL priority.
if CORE.cmake_args:
CORE.add_platformio_option(
"board_build.cmake_extra_args",
" ".join(
f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items())
),
replace=True,
)
content = "[platformio]\n"
content += f"description = ESPHome {__version__}\n"
+1
View File
@@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401
add,
add_build_flag,
add_build_unflag,
add_cmake_arg,
add_cxx_build_flag,
add_define,
add_global,
+69 -8
View File
@@ -18,9 +18,9 @@ from pathlib import Path
from typing import Any
from esphome.const import __version__ as ESPHOME_VERSION
from esphome.core import CORE, Lambda
from esphome.core import CORE, EsphomeError, Lambda
from esphome.helpers import write_file
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -65,7 +65,71 @@ def save_compiled_config(config: ConfigType) -> None:
# non-basic dict key), so every upload/logs pays the slow path.
_LOGGER.warning("Cannot cache the validated config: %s", err)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
# Likely persistent (permissions, full disk): every upload/logs
# pays the slow path until it clears, so surface it.
_LOGGER.warning("Skipping compiled config cache write: %s", err)
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
The cache is only written when a complete sidecar is on disk:
load_compiled_config can't use it otherwise, and it holds resolved
secrets.
"""
if _refresh_sidecar():
save_compiled_config(config)
def _refresh_sidecar() -> bool:
"""Ensure a complete sidecar is on disk; True when one is.
Writes one (without claiming a build) when missing or wizard-only.
Failures are non-fatal; the next upload/logs pays the slow path again.
"""
try:
path = storage_path()
try:
old = StorageJSON.load_strict(path)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
# Present but unreadable: it may hold a real build's metadata,
# and a fresh rewrite would also stop the next compile from
# cleaning a possibly incoherent build tree.
_LOGGER.warning(
"Not caching: storage sidecar %s is unreadable (%s)", path, err
)
return False
if old is not None and old.can_apply_to_core():
# Compile-written; nothing to refresh.
return True
if CORE.build_path is not None and CORE.build_path.exists():
# An unvalidated build tree: its absent or mismatched sidecar
# is what makes the next compile wipe it, so don't vouch for
# a build this run never saw.
_LOGGER.warning(
"Not caching: build tree %s has no matching sidecar; "
"'esphome compile' will settle it",
CORE.build_path,
)
return False
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
if not new.can_apply_to_core():
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
return False
new.save(path)
return True
except (OSError, EsphomeError) as err:
# write_file wraps OSError into EsphomeError. Persistent
# (unwritable storage dir), so surface that every upload/logs
# pays the slow path.
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
# A structural bug; keep the traceback so it isn't mistaken
# for the I/O failure above.
_LOGGER.warning(
"Unexpected error refreshing the storage sidecar", exc_info=True
)
return False
def load_compiled_config(conf_path: Path) -> ConfigType | None:
@@ -98,11 +162,8 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
return None
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
# sidecars leave both of these unset and can't drive upload/logs.
if not storage.core_platform and not storage.target_platform:
if storage is None or not storage.can_apply_to_core():
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
return None
storage.apply_to_core()
return config
+10
View File
@@ -0,0 +1,10 @@
"""Component alias registry.
Generated by script/build_alias_registry.py - do not edit manually.
See the component-alias section of esphome/loader.py.
"""
# alias -> (canonical component, removal version or None)
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
"rp2040": ("rp2", "2027.7.0"),
}
+6
View File
@@ -49,6 +49,12 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
if CORE.is_esp32:
from esphome.components.esp32 import include_builtin_idf_component
# Re-enable the gptimer driver (excluded by default to save compile time)
include_builtin_idf_component("esp_driver_gptimer")
if CORE.is_esp8266:
# ac_dimmer uses setTimer1Callback which requires the waveform generator
from esphome.components.esp8266.const import require_waveform
+5 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
from esphome import pins
import esphome.codegen as cg
from esphome.components.esp32 import (
@@ -16,6 +18,7 @@ from esphome.components.esp32 import (
import esphome.config_validation as cv
from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
@@ -225,12 +228,13 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
}
def validate_adc_pin(value):
def validate_adc_pin(value: Any) -> ConfigType | str:
if str(value).upper() == "VCC":
if CORE.is_rp2:
return pins.internal_gpio_input_pin_schema(29)
return cv.only_on([PLATFORM_ESP8266])("VCC")
# Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0
if str(value).upper() == "TEMPERATURE":
return cv.only_on_rp2("TEMPERATURE")
+1 -1
View File
@@ -3,7 +3,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.common";
static const char *const TAG = "adc";
const LogString *sampling_mode_to_str(SamplingMode mode) {
switch (mode) {
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.esp32";
static const char *const TAG = "adc";
adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr};
@@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC)
namespace esphome::adc {
static const char *const TAG = "adc.esp8266";
static const char *const TAG = "adc";
void ADCSensor::setup() {
#ifndef USE_ADC_SENSOR_VCC
@@ -5,7 +5,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.libretiny";
static const char *const TAG = "adc";
void ADCSensor::setup() {
#ifndef USE_ADC_SENSOR_VCC
+21 -2
View File
@@ -17,7 +17,26 @@
namespace esphome::adc {
static const char *const TAG = "adc.rp2";
static const char *const TAG = "adc";
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
// than four.
//
// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
// derives from NUM_ADC_CHANNELS, which <pico.h> settles from a board header, and
// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
// is only declared later, by the variant's pins_arduino.h, so the SDK constant
// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
// is compiled, on both arduino-pico and pico-sdk builds.
#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
#endif
#if defined(PICO_RP2350) && !PICO_RP2350A
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
#else
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
#endif
void ADCSensor::setup() {
static bool initialized = false;
@@ -52,7 +71,7 @@ float ADCSensor::sample() {
if (this->is_temperature_) {
adc_set_temp_sensor_enabled(true);
delay(1);
adc_select_input(4);
adc_select_input(TEMPERATURE_ADC_INPUT);
for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
raw = adc_read();
+1 -1
View File
@@ -7,7 +7,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.zephyr";
static const char *const TAG = "adc";
void ADCSensor::setup() {
if (!adc_is_ready_dt(this->channel_)) {
+11 -3
View File
@@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True)
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
def validate_config(config):
def validate_config(config: ConfigType) -> ConfigType:
if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto":
raise cv.Invalid("Automatic attenuation cannot be used when raw output is set")
@@ -67,6 +67,13 @@ def validate_config(config):
# Alter value here so `config` command prints the recommended change
config[CONF_ATTENUATION] = _attenuation("12db")
# Remove before 2027.2.0
if config[CONF_PIN] == "TEMPERATURE":
_LOGGER.warning(
"[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` "
"sensor platform instead. Will be removed in 2027.2.0"
)
return config
@@ -113,7 +120,7 @@ CONFIG_SCHEMA = cv.All(
CONF_ADC_CHANNEL_ID = "adc_channel_id"
def _overlay_io_channels():
def _overlay_io_channels() -> str:
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
return f"""
@@ -125,7 +132,7 @@ def _overlay_io_channels():
"""
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
@@ -133,6 +140,7 @@ async def to_code(config):
if config[CONF_PIN] == "VCC":
cg.add_define("USE_ADC_SENSOR_VCC")
elif config[CONF_PIN] == "TEMPERATURE":
# Remove before 2027.2.0
cg.add(var.set_is_temperature())
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
+16 -3
View File
@@ -17,6 +17,9 @@ from esphome.const import (
UNIT_OHM,
UNIT_PARTS_PER_BILLION,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
CONF_RESISTANCE = "resistance"
@@ -62,7 +65,7 @@ CONFIG_SCHEMA = (
FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz")
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
@@ -94,7 +97,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
AGS10_NEW_I2C_ADDRESS_SCHEMA,
synchronous=True,
)
async def ags10newi2caddress_to_code(config, action_id, template_arg, args):
async def ags10newi2caddress_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
@@ -126,7 +134,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
AGS10_SET_ZERO_POINT_SCHEMA,
synchronous=True,
)
async def ags10setzeropoint_to_code(config, action_id, template_arg, args):
async def ags10setzeropoint_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
mode = await cg.templatable(
+10 -2
View File
@@ -4,6 +4,9 @@ from esphome.components import i2c
from esphome.components.audio_dac import AudioDac
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MODE
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["i2c"]
@@ -39,7 +42,12 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value(
SET_AUTO_MUTE_ACTION_SCHEMA,
synchronous=True,
)
async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
async def aic3204_set_volume_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
@@ -49,7 +57,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
return var
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
+5
View File
@@ -13,8 +13,13 @@
import esphome.components.image as espImage
import esphome.config_validation as cv
from . import image as animation_image
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
# The deprecated top-level `animation:` shim gets the same batched
# downloads as the `image:` platform form.
PREFETCH_FILES = animation_image.PREFETCH_FILES
AUTO_LOAD = ["image", "file"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
+13 -1
View File
@@ -1,13 +1,20 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
from esphome.components.file import image as file_image
from esphome.components.file.image import image_schema, write_image
from esphome.components.image import Image_, validate_settings
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_REPEAT
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
CODEOWNERS = ["@syndlex"]
# The animation platform shares the file platform's remote file handling,
# including its batch-download hook.
PREFETCH_FILES = file_image.PREFETCH_FILES
AUTO_LOAD = ["file"]
DEPENDENCIES = ["display"]
@@ -74,7 +81,12 @@ SET_FRAME_SCHEMA = cv.Schema(
@automation.register_action(
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
)
async def animation_action_to_code(config, action_id, template_arg, args):
async def animation_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
+2 -1
View File
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
@@ -57,7 +58,7 @@ CONFIG_SCHEMA = (
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
+2 -1
View File
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING
from esphome.types import ConfigType
from . import APDS9960, CONF_APDS9960_ID
@@ -19,7 +20,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_APDS9960_ID])
var = await binary_sensor.new_binary_sensor(config)
func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor")
+2 -1
View File
@@ -7,6 +7,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
)
from esphome.types import ConfigType
from . import APDS9960, CONF_APDS9960_ID
@@ -27,7 +28,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_APDS9960_ID])
var = await sensor.new_sensor(config)
func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor")
+27 -11
View File
@@ -1,5 +1,6 @@
import base64
import logging
from typing import Any
from esphome import automation
from esphome.automation import Condition
@@ -129,7 +130,7 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType:
return config
def validate_encryption_key(value):
def validate_encryption_key(value: Any) -> str:
value = cv.string_strict(value)
try:
decoded = base64.b64decode(value, validate=True)
@@ -217,7 +218,7 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType:
return config
def _validate_supports_response(value):
def _validate_supports_response(value: Any) -> str:
"""Validate supports_response after auto-detection has set the value."""
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
@@ -256,7 +257,7 @@ ENCRYPTION_SCHEMA = cv.Schema(
)
def _encryption_schema(config):
def _encryption_schema(config: ConfigType | None) -> ConfigType:
if config is None:
config = {}
return ENCRYPTION_SCHEMA(config)
@@ -393,7 +394,7 @@ async def to_code(config: ConfigType) -> None:
if actions := config.get(CONF_ACTIONS, []):
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.Pvariable] = []
triggers: list[cg.MockObj] = []
for conf in actions:
func_args: list[tuple[MockObj, str]] = []
service_template_args: list[MockObj] = [] # User service argument types
@@ -497,7 +498,7 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
cg.add_library("esphome/noise-c", "0.1.11")
cg.add_library("esphome/noise-c", "0.1.21")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
@@ -581,7 +582,7 @@ async def homeassistant_service_to_code(
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
):
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, False)
@@ -647,7 +648,7 @@ async def homeassistant_service_to_code(
return var
def validate_homeassistant_event(value):
def validate_homeassistant_event(value: Any) -> str:
value = cv.string(value)
if not value.startswith("esphome."):
raise cv.Invalid(
@@ -676,7 +677,12 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
synchronous=True,
)
async def homeassistant_event_to_code(config, action_id, template_arg, args):
async def homeassistant_event_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
@@ -724,7 +730,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
synchronous=True,
)
async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
async def homeassistant_tag_scanned_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
@@ -740,7 +751,7 @@ CONF_SUCCESS = "success"
CONF_ERROR_MESSAGE = "error_message"
def _validate_api_respond_data(config):
def _validate_api_respond_data(config: ConfigType) -> ConfigType:
"""Set flag during validation so AUTO_LOAD can include json component."""
if CONF_DATA in config:
CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True
@@ -824,7 +835,12 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema(
@automation.register_condition(
"api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA
)
async def api_connected_to_code(config, condition_id, template_arg, args):
async def api_connected_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(condition_id, template_arg)
templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_)
cg.add(var.set_state_subscription_only(templ))
+22 -22
View File
@@ -1760,7 +1760,7 @@ enum BluetoothDeviceRequestType {
message BluetoothDeviceRequest {
option (id) = 68;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
BluetoothDeviceRequestType request_type = 2;
@@ -1771,7 +1771,7 @@ message BluetoothDeviceRequest {
message BluetoothDeviceConnectionResponse {
option (id) = 69;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool connected = 2;
@@ -1782,7 +1782,7 @@ message BluetoothDeviceConnectionResponse {
message BluetoothGATTGetServicesRequest {
option (id) = 70;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
}
@@ -1826,7 +1826,7 @@ message BluetoothGATTService {
message BluetoothGATTGetServicesResponse {
option (id) = 71;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
repeated BluetoothGATTService services = 2;
@@ -1835,7 +1835,7 @@ message BluetoothGATTGetServicesResponse {
message BluetoothGATTGetServicesDoneResponse {
option (id) = 72;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
}
@@ -1843,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse {
message BluetoothGATTReadRequest {
option (id) = 73;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1852,7 +1852,7 @@ message BluetoothGATTReadRequest {
message BluetoothGATTReadResponse {
option (id) = 74;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1864,7 +1864,7 @@ message BluetoothGATTReadResponse {
message BluetoothGATTWriteRequest {
option (id) = 75;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1876,7 +1876,7 @@ message BluetoothGATTWriteRequest {
message BluetoothGATTReadDescriptorRequest {
option (id) = 76;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1885,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest {
message BluetoothGATTWriteDescriptorRequest {
option (id) = 77;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1896,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest {
message BluetoothGATTNotifyRequest {
option (id) = 78;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1906,7 +1906,7 @@ message BluetoothGATTNotifyRequest {
message BluetoothGATTNotifyDataResponse {
option (id) = 79;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1917,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse {
message SubscribeBluetoothConnectionsFreeRequest {
option (id) = 80;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
}
message BluetoothConnectionsFreeResponse {
option (id) = 81;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint32 free = 1;
uint32 limit = 2;
@@ -1936,7 +1936,7 @@ message BluetoothConnectionsFreeResponse {
message BluetoothGATTErrorResponse {
option (id) = 82;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1946,7 +1946,7 @@ message BluetoothGATTErrorResponse {
message BluetoothGATTWriteResponse {
option (id) = 83;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1955,7 +1955,7 @@ message BluetoothGATTWriteResponse {
message BluetoothGATTNotifyResponse {
option (id) = 84;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1964,7 +1964,7 @@ message BluetoothGATTNotifyResponse {
message BluetoothDevicePairingResponse {
option (id) = 85;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool paired = 2;
@@ -1974,7 +1974,7 @@ message BluetoothDevicePairingResponse {
message BluetoothDeviceUnpairingResponse {
option (id) = 86;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool success = 2;
@@ -1990,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest {
message BluetoothDeviceClearCacheResponse {
option (id) = 88;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool success = 2;
@@ -2807,7 +2807,7 @@ message SerialProxyRequestResponse {
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 min_interval = 2; // units of 1.25ms
@@ -2819,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest {
message BluetoothSetConnectionParamsResponse {
option (id) = 146;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
int32 error = 2;
+81 -29
View File
@@ -89,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam
static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
static const char *const TAG = "api.connection";
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
void log_dropped_message(const char *tag, int line, const LogString *what) {
esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"),
LOG_STR_ARG(what));
}
#endif
#ifdef USE_CAMERA
static const int CAMERA_STOP_STREAM = 5000;
#endif
@@ -153,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
#else
#error "No frame helper defined"
#endif
#ifdef USE_CAMERA
if (camera::Camera::instance() != nullptr) {
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
}
#endif
}
void APIConnection::start() {
@@ -441,7 +443,7 @@ void APIConnection::on_disconnect_response() {
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) {
msg.key = entity->get_entity_key();
msg.key = entity->get_object_id_hash();
#ifdef USE_DEVICES
msg.device_id = entity->get_device_id();
#endif
@@ -452,7 +454,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) {
// Set common fields that are shared by all entity types
msg.key = entity->get_entity_key();
msg.key = entity->get_object_id_hash();
if (entity->has_own_name()) {
msg.name = entity->get_name();
@@ -1133,6 +1135,7 @@ void APIConnection::try_send_camera_image_() {
if (!this->image_reader_)
return;
const auto *cam = camera::Camera::instance();
// Send as many chunks as possible without blocking
while (this->image_reader_->available()) {
if (!this->helper_->can_write_without_blocking())
@@ -1142,11 +1145,11 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg;
msg.key = camera::Camera::instance()->get_entity_key();
msg.key = cam->get_object_id_hash();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
msg.device_id = camera::Camera::instance()->get_device_id();
msg.device_id = cam->get_device_id();
#endif
if (!this->send_message(msg)) {
@@ -1162,15 +1165,19 @@ void APIConnection::try_send_camera_image_() {
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
if (!this->flags_.state_subscription)
return;
if (!this->image_reader_)
if (this->image_reader_ && this->image_reader_->available())
return;
if (this->image_reader_->available())
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
return;
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
this->image_reader_->set_image(std::move(image));
// Try to send immediately to reduce latency
this->try_send_camera_image_();
if (!this->image_reader_) {
// Created on the first image this connection will send, so connections
// that never receive one never pay for a reader. Only a registered
// camera's listener can reach this, so instance() is non-null here.
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
}
this->image_reader_->set_image(std::move(image));
// Try to send immediately to reduce latency
this->try_send_camera_image_();
}
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *camera = static_cast<camera::Camera *>(entity);
@@ -1236,6 +1243,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request(
void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
}
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
}
@@ -1269,13 +1277,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() {
}
}
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
}
#endif
void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode(
msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE);
}
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
}
#endif
#ifdef USE_VOICE_ASSISTANT
@@ -1533,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
if (!this->send_message(msg)) {
// V: fires per decoded frame with no subscription gate, so a warning
// would flood the congested link it reports on.
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
}
}
#endif
#ifdef USE_SERIAL_PROXY
@@ -1575,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
SerialProxyGetModemPinsResponse resp{};
resp.instance = msg.instance;
resp.line_states = proxies[msg.instance]->get_modem_pins();
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
}
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
@@ -1607,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
break;
}
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
break;
}
default:
@@ -1616,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
}
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
if (!this->send_message(msg)) {
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
}
}
#endif
#ifdef USE_INFRARED
@@ -1747,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Acknowledge the hello so the client can read the server name, then request
// disconnect with the reason. Authentication is intentionally not completed.
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Hello response");
}
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
return this->send_message(req);
@@ -1772,9 +1798,8 @@ bool APIConnection::send_device_info_response_() {
#ifdef USE_AREAS
resp.suggested_area = StringRef(App.get_area());
#endif
// Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
char mac_address[18];
uint8_t mac[6];
char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
uint8_t mac[MAC_ADDRESS_SIZE];
get_mac_address_raw(mac);
format_mac_addr_upper(mac, mac_address);
resp.mac_address = StringRef(mac_address);
@@ -2037,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
resp.call_id = call_id;
resp.success = success;
resp.error_message = error_message;
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Action response");
}
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message,
@@ -2048,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
resp.error_message = error_message;
resp.response_data = response_data;
resp.response_data_len = response_data_len;
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Action response");
}
}
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
#endif
#ifdef USE_API_HOMEASSISTANT_SERVICES
bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
return false;
if (!this->send_message(call)) {
API_LOG_MSG_DROPPED(TAG, "Action request");
}
return true;
}
#endif // USE_API_HOMEASSISTANT_SERVICES
#ifdef USE_HOMEASSISTANT_TIME
void APIConnection::send_time_request() {
GetTimeRequest req;
if (!this->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Time request");
}
}
#endif // USE_HOMEASSISTANT_TIME
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) {
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
@@ -2128,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
if (this->helper_->can_write_without_blocking())
return true;
if (log_out_of_space) {
ESP_LOGV(TAG, "Cannot send message because of TCP buffer space");
// VV: refusals are either reported by the sending call site (naming what
// was lost) or retried without loss (the deferred batch), so this generic
// line only duplicates them.
ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space");
}
return false;
}
+20 -13
View File
@@ -25,6 +25,7 @@
#include "esphome/components/esp8266/crash_handler.h"
#endif
#include "esphome/core/entity_base.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include <functional>
@@ -40,6 +41,16 @@ namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
// One shared flash string for every refused-frame warning: send_message()
// fails as soon as the TCP buffer is full, and each caller only pays for its
// short name. The guard drops the helper and its arguments below WARN.
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
void log_dropped_message(const char *tag, int line, const LogString *what);
#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what))
#else
#define API_LOG_MSG_DROPPED(tag, what)
#endif
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
@@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase {
// Returns whether this client has subscribed to Home Assistant actions; the message
// is only handed to the send path when subscribed. A true return does not guarantee
// delivery - it lets the caller warn when no connected client has the subscription.
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
return false;
this->send_message(call);
return true;
}
bool send_homeassistant_action(const HomeassistantActionRequest &call);
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -183,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
void on_unsubscribe_bluetooth_le_advertisements_request();
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
@@ -191,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase {
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
void on_subscribe_bluetooth_connections_free_request();
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
#endif
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
#endif
#ifdef USE_HOMEASSISTANT_TIME
void send_time_request() {
GetTimeRequest req;
this->send_message(req);
}
void send_time_request();
#endif
#ifdef USE_VOICE_ASSISTANT
@@ -335,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for type-erased size calculation
using CalculateSizeFn = uint32_t (*)(const void *);
template<typename T> bool send_message(const T &msg) {
/// Returns false as soon as the TCP buffer is full. Marked nodiscard so we
/// have no silent failures: every caller must handle (or log) a refusal.
template<typename T> [[nodiscard]] bool send_message(const T &msg) {
if constexpr (T::ESTIMATED_SIZE == 0) {
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
} else {
@@ -390,7 +397,7 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool send_subscribe_bluetooth_connections_free_response_();
#endif
#ifdef USE_VOICE_ASSISTANT
+2 -2
View File
@@ -149,7 +149,7 @@ class APIFrameHelper {
// holding data too long waiting for Nagle's timer causes buffer exhaustion
// and dropped messages.
//
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
//
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
@@ -312,7 +312,7 @@ class APIFrameHelper {
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
#ifdef USE_ESP8266
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
#else
@@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
*/
APIError APINoiseFrameHelper::init_handshake_() {
int err;
memset(&nid_, 0, sizeof(nid_));
// const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
// err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
nid_.pattern_id = NOISE_PATTERN_NN;
nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
nid_.dh_id = NOISE_DH_CURVE25519;
nid_.prefix_id = NOISE_PREFIX_STANDARD;
nid_.hybrid_id = NOISE_DH_NONE;
nid_.hash_id = NOISE_HASH_SHA256;
nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
// noise_handshakestate_new_by_id copies it, so a member would waste
// 104 bytes per connection, and a static const would sit in RAM on
// ESP8266 (.rodata is DRAM there).
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
@@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Buffer for noise handshake prologue (released after handshake)
APIBuffer prologue_;
// NoiseProtocolId (size depends on implementation)
NoiseProtocolId nid_;
// Group small types together
// Fixed-size header buffer for noise protocol:
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
+5 -1
View File
@@ -2482,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const {
}
return size;
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
@@ -2858,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const {
size += ProtoSize::calc_int32(1, this->error);
return size;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->state));
@@ -4221,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
return size;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
+8 -2
View File
@@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t {
MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
enum BluetoothDeviceRequestType : uint32_t {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
@@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
};
#endif
#ifdef USE_BLUETOOTH_PROXY
enum BluetoothScannerState : uint32_t {
BLUETOOTH_SCANNER_STATE_IDLE = 0,
BLUETOOTH_SCANNER_STATE_STARTING = 1,
@@ -1999,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothDeviceRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 68;
@@ -2384,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_BLUETOOTH_PROXY
class BluetoothScannerStateResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 126;
@@ -3358,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 145;
+1 -1
View File
@@ -3,7 +3,7 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLUETOOTH_PROXY
#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
#ifndef USE_API_VARINT64
#define USE_API_VARINT64
#endif
+8 -2
View File
@@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string<enums::MediaPlayerFormatPurpose>(enu
}
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
template<>
const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::BluetoothDeviceRequestType value) {
switch (value) {
@@ -606,6 +606,8 @@ const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::Bluet
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
#ifdef USE_BLUETOOTH_PROXY
template<> const char *proto_enum_to_string<enums::BluetoothScannerState>(enums::BluetoothScannerState value) {
switch (value) {
case enums::BLUETOOTH_SCANNER_STATE_IDLE:
@@ -2002,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const
}
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest"));
dump_field(out, ESPHOME_PSTR("address"), this->address);
@@ -2173,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("error"), this->error);
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse"));
dump_field(out, ESPHOME_PSTR("state"), static_cast<enums::BluetoothScannerState>(this->state));
@@ -2764,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest"));
dump_field(out, ESPHOME_PSTR("address"), this->address);
+9 -9
View File
@@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothDeviceRequest::MESSAGE_TYPE: {
BluetoothDeviceRequest msg;
msg.decode(msg_data, msg_size);
@@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: {
BluetoothGATTGetServicesRequest msg;
msg.decode(msg_data, msg_size);
@@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTReadRequest::MESSAGE_TYPE: {
BluetoothGATTReadRequest msg;
msg.decode(msg_data, msg_size);
@@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTWriteRequest::MESSAGE_TYPE: {
BluetoothGATTWriteRequest msg;
msg.decode(msg_data, msg_size);
@@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: {
BluetoothGATTReadDescriptorRequest msg;
msg.decode(msg_data, msg_size);
@@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: {
BluetoothGATTWriteDescriptorRequest msg;
msg.decode(msg_data, msg_size);
@@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTNotifyRequest::MESSAGE_TYPE: {
BluetoothGATTNotifyRequest msg;
msg.decode(msg_data, msg_size);
@@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"));
@@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: {
BluetoothSetConnectionParamsRequest msg;
msg.decode(msg_data, msg_size);
+9 -9
View File
@@ -115,32 +115,32 @@ class APIServerConnectionBase {
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_device_request(const BluetoothDeviceRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_subscribe_bluetooth_connections_free_request(){};
#endif
@@ -235,7 +235,7 @@ class APIServerConnectionBase {
void on_serial_proxy_request(const SerialProxyRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
};
+11 -14
View File
@@ -123,7 +123,9 @@ void APIServer::setup() {
// Best-effort: if the send buffer is full the reason is dropped, but the
// client still learns the window is closed when it reconnects (rejected at
// hello) or via the socket close.
c->send_message(req);
if (!c->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
}
}
});
}
@@ -394,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) {
void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
// We could add code to manage a second subscription type, but, since this message type is
// very infrequent and small, we simply send it to all clients
for (auto &c : this->active_clients())
c->send_message(msg);
for (auto &c : this->active_clients()) {
if (!c->send_message(msg)) {
API_LOG_MSG_DROPPED(TAG, "Home ID notification");
}
}
}
#endif
@@ -418,12 +423,6 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel)
#endif
float APIServer::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
void APIServer::set_port(uint16_t port) { this->port_ = port; }
void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; }
#ifdef USE_API_HOMEASSISTANT_SERVICES
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
bool has_subscriber = false;
@@ -548,10 +547,6 @@ const std::vector<APIServer::HomeAssistantStateSubscription> &APIServer::get_sta
}
#endif
uint16_t APIServer::get_port() const { return this->port_; }
void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
#ifdef USE_API_NOISE
bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg,
const LogString *fail_log_msg, bool make_active) {
@@ -576,7 +571,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
for (auto &c : this->active_clients()) {
DisconnectRequest req;
c->send_message(req);
if (!c->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
}
}
});
}
+5 -5
View File
@@ -51,8 +51,8 @@ class APIServer final : public Component,
public:
APIServer();
void setup() override;
uint16_t get_port() const;
float get_setup_priority() const override;
uint16_t get_port() const { return this->port_; }
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void loop() override;
void dump_config() override;
void on_shutdown() override;
@@ -63,9 +63,9 @@ class APIServer final : public Component,
#ifdef USE_CAMERA
void on_camera_image(const std::shared_ptr<camera::CameraImage> &image) override;
#endif
void set_port(uint16_t port);
void set_reboot_timeout(uint32_t reboot_timeout);
void set_batch_delay(uint16_t batch_delay);
void set_port(uint16_t port) { this->port_ = port; }
void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
void set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; }
uint16_t get_batch_delay() const { return batch_delay_; }
void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; }
+3 -1
View File
@@ -14,6 +14,8 @@ from esphome.const import (
CONF_TUNE_ANTENNA,
CONF_WATCHDOG_THRESHOLD,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
MULTI_CONF = True
@@ -42,7 +44,7 @@ AS3935_SCHEMA = cv.Schema(
)
async def setup_as3935(var, config):
async def setup_as3935(var: MockObj, config: ConfigType) -> None:
await cg.register_component(var, config)
irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN])
+2 -1
View File
@@ -1,6 +1,7 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import AS3935, CONF_AS3935_ID
@@ -13,7 +14,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_AS3935_ID])
var = await binary_sensor.new_binary_sensor(config)
cg.add(hub.set_thunder_alert_binary_sensor(var))
+2 -1
View File
@@ -9,6 +9,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_KILOMETER,
)
from esphome.types import ConfigType
from . import AS3935, CONF_AS3935_ID
@@ -31,7 +32,7 @@ CONFIG_SCHEMA = cv.Schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_AS3935_ID])
if distance_config := config.get(CONF_DISTANCE):
+14 -10
View File
@@ -1,3 +1,6 @@
from collections.abc import Callable
from typing import Any
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
@@ -11,6 +14,7 @@ from esphome.const import (
CONF_RANGE,
CONF_WATCHDOG,
)
from esphome.types import ConfigType
CODEOWNERS = ["@ammmze"]
DEPENDENCIES = ["i2c"]
@@ -72,13 +76,13 @@ POSITION_TO_ANGLE = 360 / RESOLUTION
MIN_RANGE = round(18 * ANGLE_TO_POSITION)
def angle(min=-360, max=360):
def angle(min: float = -360, max: float = 360) -> Callable[[Any], Any]:
return cv.All(
cv.float_with_unit("angle", "(°|deg)"), cv.float_range(min=min, max=max)
)
def angle_to_position(value, min=-360, max=360):
def angle_to_position(value: Any, min: float = -360, max: float = 360) -> int:
try:
value = angle(min=min, max=max)(value)
return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION
@@ -86,17 +90,17 @@ def angle_to_position(value, min=-360, max=360):
raise cv.Invalid(f"When using angle, {e.error_message}") from e
def percent_to_position(value):
def percent_to_position(value: Any) -> int:
value = cv.possibly_negative_percentage(value)
return (RESOLUTION + round(value * RESOLUTION)) % RESOLUTION
def position(min=-MAX_POSITION, max=MAX_POSITION):
def position(min: int = -MAX_POSITION, max: int = MAX_POSITION) -> Callable[[Any], Any]:
"""Validate that the config option is a position.
Accepts integers, degrees, or percentage (of 360 degrees).
"""
def validator(value):
def validator(value: Any) -> int:
if isinstance(value, str) and value.endswith("%"):
value = percent_to_position(value)
@@ -112,7 +116,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION):
return validator
def position_range():
def position_range() -> Callable[[Any], Any]:
"""Validate that value given is a valid range for the device.
A valid range is one of the following:
- a value of 0 (meaning full range)
@@ -129,7 +133,7 @@ def position_range():
zero_validator,
)
def validator(value):
def validator(value: Any) -> Any:
is_negative_str = isinstance(value, str) and value.startswith("-")
is_negative_num = isinstance(value, (float, int)) and value < 0
if is_negative_str or is_negative_num:
@@ -139,13 +143,13 @@ def position_range():
return validator
def has_valid_range_config():
def has_valid_range_config() -> Callable[[ConfigType], ConfigType]:
"""Validate that that the config start + end position results in a valid
positional range, which must be >= 18degrees
"""
range_validator = position_range()
def validator(config):
def validator(config: ConfigType) -> ConfigType:
# if we don't have an end position, then there is nothing to do
if CONF_END_POSITION not in config:
return config
@@ -203,7 +207,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
+2 -1
View File
@@ -11,6 +11,7 @@ from esphome.const import (
ICON_ROTATE_RIGHT,
STATE_CLASS_MEASUREMENT,
)
from esphome.types import ConfigType
from .. import AS5600Component, as5600_ns
@@ -77,7 +78,7 @@ CONFIG_SCHEMA = (
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_parented(var, config[CONF_AS5600_ID])
await cg.register_component(var, config)
+16 -3
View File
@@ -4,6 +4,9 @@ import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_FREQUENCY, CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
CODEOWNERS = ["@X-Ryl669"]
DEPENDENCIES = ["i2c"]
@@ -70,7 +73,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
@@ -91,7 +94,12 @@ AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Actio
),
synchronous=True,
)
async def at581x_reset_to_code(config, action_id, template_arg, args):
async def at581x_reset_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
@@ -163,7 +171,12 @@ RADAR_SETTINGS_SCHEMA = cv.Schema(
RADAR_SETTINGS_SCHEMA,
synchronous=True,
)
async def at581x_settings_to_code(config, action_id, template_arg, args):
async def at581x_settings_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
+2 -1
View File
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import DEVICE_CLASS_SWITCH, ICON_WIFI
from esphome.types import ConfigType
from .. import CONF_AT581X_ID, AT581XComponent, at581x_ns
@@ -22,7 +23,7 @@ CONFIG_SCHEMA = switch.switch_schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
at581x_component = await cg.get_variable(config[CONF_AT581X_ID])
s = await switch.new_switch(config)
await cg.register_parented(s, config[CONF_AT581X_ID])
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE
from esphome.types import ConfigType
from .. import atm90e32_ns
from ..sensor import ATM90E32Component
@@ -67,7 +68,7 @@ CONFIG_SCHEMA = {
}
async def to_code(config):
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ID])
if run_gain := config.get(CONF_RUN_GAIN_CALIBRATION):
@@ -15,6 +15,7 @@ from esphome.const import (
UNIT_AMPERE,
UNIT_VOLT,
)
from esphome.types import ConfigType
from .. import atm90e32_ns
from ..sensor import ATM90E32Component
@@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.Schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ID])
if voltage_cfg := config.get(CONF_REFERENCE_VOLTAGE):
+2 -1
View File
@@ -41,6 +41,7 @@ from esphome.const import (
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
from . import atm90e32_ns
@@ -191,7 +192,7 @@ CONFIG_SCHEMA = (
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_instance_id(str(config[CONF_ID])))
await cg.register_component(var, config)
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C
from esphome.types import ConfigType
from ..sensor import ATM90E32Component
@@ -34,7 +35,7 @@ CONFIG_SCHEMA = cv.Schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ID])
if phase_cfg := config.get(CONF_PHASE_STATUS):
+13 -8
View File
@@ -1,4 +1,6 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
import esphome.codegen as cg
from esphome.components.esp32 import (
@@ -15,6 +17,7 @@ from esphome.const import (
)
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
AUTO_LOAD = ["ring_buffer"]
CODEOWNERS = ["@kahrendt"]
@@ -125,10 +128,10 @@ CONF_THREADSAFE = "threadsafe"
_MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True)
def _maybe_empty_codec(schema):
def _maybe_empty_codec(schema: cv.Schema) -> Callable[[Any], Any]:
"""Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict."""
def validator(value):
def validator(value: Any) -> Any:
if value is None:
value = {}
return schema(value)
@@ -200,14 +203,14 @@ def set_stream_limits(
max_channels: int = cv.UNDEFINED,
min_sample_rate: int = cv.UNDEFINED,
max_sample_rate: int = cv.UNDEFINED,
):
) -> Callable[[ConfigType], None]:
"""Sets the limits for the audio stream that audio component can handle
When the component sinks audio (e.g., a speaker), these indicate the limits to the audio it can receive.
When the component sources audio (e.g., a microphone), these indicate the limits to the audio it can send.
"""
def set_limits_in_config(config):
def set_limits_in_config(config: ConfigType) -> None:
if min_bits_per_sample is not cv.UNDEFINED:
config[CONF_MIN_BITS_PER_SAMPLE] = min_bits_per_sample
if max_bits_per_sample is not cv.UNDEFINED:
@@ -233,7 +236,7 @@ def final_validate_audio_schema(
sample_rate: int = cv.UNDEFINED,
enabled_channels: list[int] = cv.UNDEFINED,
audio_device_issue: bool = False,
):
) -> cv.Schema:
"""Validates audio compatibility when passed between different components.
The component derived from ``AUDIO_COMPONENT_SCHEMA`` should call ``set_stream_limits`` in a validator to specify its compatible settings
@@ -251,7 +254,7 @@ def final_validate_audio_schema(
audio_device_issue (bool, optional): Format the error message to indicate the problem is in the configuration for the ``audio_device`` component. Defaults to False.
"""
def validate_audio_compatiblity(audio_config):
def validate_audio_compatiblity(audio_config: ConfigType) -> ConfigType:
audio_schema = {}
if bits_per_sample is not cv.UNDEFINED:
@@ -329,7 +332,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N
add_idf_sdkconfig_option(internal_key, True)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
@@ -371,7 +374,7 @@ async def to_code(config):
data.wav_support = True
if data.micro_decoder_support:
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
add_idf_component(name="esphome/micro-decoder", ref="0.4.0")
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
if not data.flac_support:
@@ -380,6 +383,8 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
if not data.opus_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
# Vorbis is unsupported in ESPHome, so always disable it
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False)
if not data.wav_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
+1 -1
View File
@@ -86,7 +86,7 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url)
// Match "audio/ogg" with a codecs parameter containing "opus"
// Valid forms: audio/ogg;codecs=opus, audio/ogg; codecs="opus", etc.
// Plain "audio/ogg" without opus is not matched (almost always Ogg Vorbis)
if (strncasecmp(content_type, "audio/ogg", 9) == 0 && strcasestr(content_type + 9, "opus") != nullptr) {
if (strncasecmp(content_type, "audio/ogg", 9) == 0 && str_contains_ignore_case(content_type + 9, "opus")) {
return AudioFileType::OPUS;
}
#endif
+10 -3
View File
@@ -2,7 +2,9 @@ from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MIC_GAIN
from esphome.core import CoroPriority, coroutine_with_priority
from esphome.core import ID, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
CODEOWNERS = ["@kbx81"]
IS_PLATFORM_COMPONENT = True
@@ -28,7 +30,12 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value(
SET_MIC_GAIN_ACTION_SCHEMA,
synchronous=True,
)
async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args):
async def audio_adc_set_mic_gain_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
@@ -39,6 +46,6 @@ async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args):
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_AUDIO_ADC")
cg.add_global(audio_adc_ns.using)
+16 -4
View File
@@ -3,7 +3,9 @@ from esphome.automation import maybe_simple_id
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_VOLUME
from esphome.core import CoroPriority, coroutine_with_priority
from esphome.core import ID, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
CODEOWNERS = ["@kbx81"]
IS_PLATFORM_COMPONENT = True
@@ -37,7 +39,12 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value(
@automation.register_action(
"audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True
)
async def audio_dac_mute_action_to_code(config, action_id, template_arg, args):
async def audio_dac_mute_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
@@ -48,7 +55,12 @@ async def audio_dac_mute_action_to_code(config, action_id, template_arg, args):
SET_VOLUME_ACTION_SCHEMA,
synchronous=True,
)
async def audio_dac_set_volume_to_code(config, action_id, template_arg, args):
async def audio_dac_set_volume_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
@@ -59,6 +71,6 @@ async def audio_dac_set_volume_to_code(config, action_id, template_arg, args):
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_AUDIO_DAC")
cg.add_global(audio_dac_ns.using)
+4 -2
View File
@@ -2,6 +2,8 @@ import esphome.codegen as cg
from esphome.components import ble_client, time
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_TIME_ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
CODEOWNERS = ["@jhansche"]
DEPENDENCIES = ["ble_client"]
@@ -32,12 +34,12 @@ BEDJET_CLIENT_SCHEMA = cv.Schema(
)
async def register_bedjet_child(var, config):
async def register_bedjet_child(var: MockObj, config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_BEDJET_ID])
cg.add(parent.register_child(var))
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import climate
import esphome.config_validation as cv
from esphome.const import CONF_HEAT_MODE, CONF_TEMPERATURE_SOURCE
from esphome.types import ConfigType
from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child
@@ -37,7 +38,7 @@ CONFIG_SCHEMA = (
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await climate.new_climate(config)
await cg.register_component(var, config)
await register_bedjet_child(var, config)
+2 -1
View File
@@ -1,6 +1,7 @@
import esphome.codegen as cg
from esphome.components import fan
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child
@@ -16,7 +17,7 @@ CONFIG_SCHEMA = (
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await fan.new_fan(config)
await cg.register_component(var, config)
await register_bedjet_child(var, config)
+2 -1
View File
@@ -9,6 +9,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
)
from esphome.types import ConfigType
from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child
@@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.Schema(
).extend(BEDJET_CLIENT_SCHEMA)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await register_bedjet_child(var, config)
@@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
}
light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const {
int32_t r = 0, g = 0, b = 0;
switch (this->rgb_order_) {
case ORDER_RGB:
r = 0;
g = 1;
b = 2;
break;
case ORDER_RBG:
r = 0;
g = 2;
b = 1;
break;
case ORDER_GRB:
r = 1;
g = 0;
b = 2;
break;
case ORDER_GBR:
r = 2;
g = 0;
b = 1;
break;
case ORDER_BGR:
r = 2;
g = 1;
b = 0;
break;
case ORDER_BRG:
r = 1;
g = 2;
b = 0;
break;
}
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
uint8_t white = this->is_wrgb_ ? 0 : 3;
return {this->buf_ + (index * multiplier) + r + this->is_wrgb_,
this->buf_ + (index * multiplier) + g + this->is_wrgb_,
this->buf_ + (index * multiplier) + b + this->is_wrgb_,
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
&this->effect_data_[index],
&this->correction_};
}
@@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() {
"Beken SPI LED Strip:\n"
" Pin: %u",
this->pin_);
const char *rgb_order;
switch (this->rgb_order_) {
case ORDER_RGB:
rgb_order = "RGB";
break;
case ORDER_RBG:
rgb_order = "RBG";
break;
case ORDER_GRB:
rgb_order = "GRB";
break;
case ORDER_GBR:
rgb_order = "GBR";
break;
case ORDER_BGR:
rgb_order = "BGR";
break;
case ORDER_BRG:
rgb_order = "BRG";
break;
default:
rgb_order = "UNKNOWN";
break;
}
char channel_colors[5];
ESP_LOGCONFIG(TAG,
" RGB Order: %s\n"
" Channel colors: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -3,6 +3,7 @@
#ifdef USE_BK72XX
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/components/light/light_output.h"
#include "esphome/core/color.h"
#include "esphome/core/component.h"
@@ -10,15 +11,6 @@
namespace esphome::beken_spi_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
ORDER_RBG,
ORDER_GRB,
ORDER_GBR,
ORDER_BGR,
ORDER_BRG,
};
class BekenSPILEDStripLightOutput final : public light::AddressableLight {
public:
void setup() override;
@@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
if (this->is_rgbw_ || this->is_wrgb_) {
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
@@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency);
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
void clear_effect_data() override {
for (int i = 0; i < this->size(); i++)
this->effect_data_[i] = 0;
@@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); }
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
uint8_t *buf_{nullptr};
uint8_t *effect_data_{nullptr};
@@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
uint8_t pin_;
uint16_t num_leds_;
bool is_rgbw_;
bool is_wrgb_;
uint32_t spi_frequency_{6666666};
uint8_t bit0_{0xE0};
uint8_t bit1_{0xFC};
RGBOrder rgb_order_;
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
uint32_t last_refresh_{0};
optional<uint32_t> max_refresh_rate_{};
+17 -24
View File
@@ -3,6 +3,7 @@ from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import libretiny, light
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
@@ -13,6 +14,7 @@ from esphome.const import (
CONF_PIN,
CONF_RGB_ORDER,
)
from esphome.types import ConfigType
CODEOWNERS = ["@Mat931"]
DEPENDENCIES = ["libretiny"]
@@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_(
"BekenSPILEDStripLightOutput", light.AddressableLight
)
RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder")
RGB_ORDERS = {
"RGB": RGBOrder.ORDER_RGB,
"RBG": RGBOrder.ORDER_RBG,
"GRB": RGBOrder.ORDER_GRB,
"GBR": RGBOrder.ORDER_GBR,
"BGR": RGBOrder.ORDER_BGR,
"BRG": RGBOrder.ORDER_BRG,
}
@dataclass
class LEDStripTimings:
@@ -57,8 +48,6 @@ CHIPSETS = {
}
CONF_IS_WRGB = "is_wrgb"
SUPPORTED_PINS = {
libretiny.const.FAMILY_BK7231N: [16],
libretiny.const.FAMILY_BK7231T: [16],
@@ -79,10 +68,9 @@ def _validate_pin(value):
return value
def _validate_num_leds(value):
max_num_leds = 165 # 170
if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]:
max_num_leds = 123 # 127
def _validate_num_leds(value: ConfigType) -> ConfigType:
# A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer.
max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170
if value[CONF_NUM_LEDS] > max_num_leds:
raise cv.Invalid(
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
@@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All(
pins.internal_gpio_output_pin_number, _validate_pin
),
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
cv.Optional(CONF_IS_RGBW): cv.boolean,
cv.Optional(CONF_IS_WRGB): cv.boolean,
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
cv.Optional(CONF_IS_WRGB, default=False): cv.boolean,
}
),
light.migrate_channel_colors(
removed_in="2027.3.0", component="beken_spi_led_strip"
),
_validate_num_leds,
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
await light.register_light(var, config)
await cg.register_component(var, config)
@@ -130,6 +123,6 @@ async def to_code(config):
)
)
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
cg.add(
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
)
+50 -19
View File
@@ -4,12 +4,15 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
not by a chip list: the C++ guards on `__has_include("ble_api.h")` the Beken
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
with a clear #error.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE
compiled in, the Beken SDK erases the bootloader flash sector at boot because
LibreTiny's partition table has no BLE bonding entry (esphome#18646,
libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in
to_code. Unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
@@ -21,9 +24,16 @@ import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.core import EsphomeError
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
@@ -50,7 +60,39 @@ CONFIG_SCHEMA = cv.Schema(
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
def _unsupported_family_message(family: str) -> str | None:
if family in (FAMILY_BK7231T, FAMILY_BK7251):
return (
f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 "
"stack; a BLE 5.x SoC such as BK7231N or BK7238 is required"
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
if family == FAMILY_BK7238:
return (
"bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK "
"erases the bootloader flash sector at boot and the device can no longer "
"start (see https://github.com/esphome/esphome/issues/18646); support "
"returns once the LibreTiny partition table fix "
"(libretiny-eu/libretiny#408) is released"
)
return None
def _final_validate(config: ConfigType) -> None:
# Warn only: a hard error here would break the validate-only CI fixtures,
# which run on a BLE 4.2 board. The hard error is raised at codegen.
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
_LOGGER.warning("%s (this configuration cannot compile)", msg)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
raise EsphomeError(msg)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -82,18 +124,7 @@ async def to_code(config: ConfigType) -> None:
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
# which path is available so it doesn't reference a missing symbol.
family = libretiny.get_libretiny_family()
if family == FAMILY_BK7231N:
if libretiny.get_libretiny_family() == FAMILY_BK7231N:
cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR")
elif family == FAMILY_BK7238:
# ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at
# WiFi STA startup when BLE init runs. This component re-enables BLE, so
# warn loudly: BK7238 is accepted but not hardware-verified and may be
# WiFi-unstable with BLE on.
_LOGGER.warning(
"bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup "
"hang on this family and is not yet hardware-verified. Expect possible "
"instability."
)
cg.add_define("USE_BK72XX_BLE")
+2 -2
View File
@@ -10,7 +10,7 @@
#ifdef USE_BK72XX_BLE
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h")
extern "C" {
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
@@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
} // namespace esphome::bk72xx_ble
#endif // !CLANG_TIDY && ble_api.h
#endif // !CLANG_TIDY && ble_api.h && app_ble.h
#endif // USE_BK72XX_BLE
+13 -9
View File
@@ -34,22 +34,26 @@
// ---------------------------------------------------------------------------
// SDK-capability gate (not a chip allowlist).
// This component drives the Beken BLE *5.x* controller via its public API,
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
// itself so any BLE-5.x Beken chip — present or future — is supported without a
// hard-coded list, and a non-5.x build fails here with a clear message instead
// of a cryptic "ble_api.h: No such file or directory".
// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be
// the probe: it ships for every SoC (driver/include) and merely switches on
// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the
// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports
// any BLE-5.x chip — present or future — without a hard-coded list, and a
// non-5.x build fails here with a clear message instead of a cryptic
// "app_ble.h: No such file or directory".
// ---------------------------------------------------------------------------
#if defined(CLANG_TIDY)
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
// accurate to analyze the SDK calls against — skip the file under analysis.
#define BK72XX_BLE_NO_SDK
#elif !__has_include("ble_api.h")
#elif !__has_include("ble_api.h") || !__has_include("app_ble.h")
// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2
// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by
// one and bury this message.
#define BK72XX_BLE_NO_SDK
#error \
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
"bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
#endif
#ifndef BK72XX_BLE_NO_SDK
@@ -364,7 +364,8 @@ bool BK72xxBLETracker::request_scan_mode(bool active) {
if (this->scan_active_ == active)
return true;
this->scan_active_ = active;
ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive");
// V: the proxy's "Setting scanner mode" line already narrates this at D.
ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
// The controller reconciler restarts a running scan itself; the scan stays
// logically running. An idle scanner picks the mode up on its next start.
if (this->scan_running_)
+10 -2
View File
@@ -32,6 +32,9 @@ from esphome.const import (
UNIT_VOLT,
UNIT_WATT,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
# Import ICONS not included in esphome's const.py, from the local components const.py
from .const import ICON_ENERGY, ICON_FREQUENCY, ICON_VOLTAGE
@@ -145,13 +148,18 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
),
synchronous=True,
)
async def reset_energy_to_code(config, action_id, template_arg, args):
async def reset_energy_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
+2 -1
View File
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART
from esphome.types import ConfigType
from .. import CONF_BL0940_ID, bl0940_ns
from ..sensor import BL0940
@@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await button.new_button(config)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_BL0940_ID])
+3 -2
View File
@@ -10,6 +10,7 @@ from esphome.const import (
ENTITY_CATEGORY_CONFIG,
UNIT_PERCENT,
)
from esphome.types import ConfigType
from .. import CONF_BL0940_ID, bl0940_ns
from ..sensor import BL0940
@@ -27,7 +28,7 @@ CalibrationNumber = bl0940_ns.class_(
)
def validate_min_max(config):
def validate_min_max(config: ConfigType) -> ConfigType:
if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]:
raise cv.Invalid("max_value must be greater than min_value")
return config
@@ -69,7 +70,7 @@ CONFIG_SCHEMA = cv.Schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
# Get the BL0940 component instance
bl0940 = await cg.get_variable(config[CONF_BL0940_ID])
+11 -8
View File
@@ -23,6 +23,7 @@ from esphome.const import (
UNIT_VOLT,
UNIT_WATT,
)
from esphome.types import ConfigType
from . import bl0940_ns
@@ -69,27 +70,29 @@ DEFAULT_BL0940_LEGACY_EREF = 3.6e6 / 297
# methods to calculate voltage and current reference values
def calculate_voltage_reference(vref, r_one, r_two):
def calculate_voltage_reference(vref: float, r_one: float, r_two: float) -> float:
# formula: 79931 / Vref * (R1 * 1000) / (R1 + R2)
return 79931 / vref * (r_one * 1000) / (r_one + r_two)
def calculate_current_reference(vref, r_shunt):
def calculate_current_reference(vref: float, r_shunt: float) -> float:
# formula: 324004 * RL / Vref
return 324004 * r_shunt / vref
def calculate_power_reference(voltage_reference, current_reference):
def calculate_power_reference(
voltage_reference: float, current_reference: float
) -> float:
# calculate power reference based on voltage and current reference
return voltage_reference * current_reference * 4046 / 324004 / 79931
def calculate_energy_reference(power_reference):
def calculate_energy_reference(power_reference: float) -> float:
# formula: power_reference * 3600000 / (1638.4 * 256)
return power_reference * 3600000 / (1638.4 * 256)
def validate_legacy_mode(config):
def validate_legacy_mode(config: ConfigType) -> ConfigType:
# Only allow schematic calibration options if legacy_mode is False
if config.get(CONF_LEGACY_MODE, True):
forbidden = [
@@ -106,7 +109,7 @@ def validate_legacy_mode(config):
return config
def set_command_defaults(config):
def set_command_defaults(config: ConfigType) -> ConfigType:
# Set defaults for read_command and write_command based on legacy_mode
legacy = config.get(CONF_LEGACY_MODE, True)
if legacy:
@@ -118,7 +121,7 @@ def set_command_defaults(config):
return config
def set_reference_values(config):
def set_reference_values(config: ConfigType) -> ConfigType:
# Set default reference values based on legacy_mode
if config.get(CONF_LEGACY_MODE, True):
config.setdefault(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_LEGACY_UREF)
@@ -223,7 +226,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
+14 -6
View File
@@ -37,7 +37,7 @@ from esphome.const import (
CONF_INTERVAL,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, ID, KEY_CORE
from esphome.core import CORE, ID, KEY_CORE, TimePeriod
from esphome.types import ConfigType
CODEOWNERS = ["@Bl00d-B0b"]
@@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
return config
# The historical scan window default shared by the trackers that do not pin
# their own; also the fallback for esp32's conditional default.
DEFAULT_SCAN_WINDOW = "30ms"
def scan_parameters_schema(
interval_default: str,
*,
window_default: str = "30ms",
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
bk72xx/rp2 100/30 ms the reference scan rates of the respective stacks;
LN882H's SDK recommends 100/50 ms). The `active` option (default on) is
unconditional: active scanning is part of the tracker contract every
current proxy client assumes it, so a passive-only tracker must not share
this schema.
LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg
callable evaluated per validation when the user omits the key (esp32 uses
this to record that the window was defaulted, so a later validation step
can adjust it once sibling keys are resolved). The `active` option
(default on) is unconditional: active scanning is part of the tracker
contract every current proxy client assumes it, so a passive-only
tracker must not share this schema.
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
@@ -9,6 +9,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components import rp2040_ble
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
@@ -36,9 +37,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"]
bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1;
# raising this needs an upstream change (the layer itself supports N).
RP2_MAX_CONNECTIONS = 1
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and
# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's
# btstack_memory.cpp replaces those pools via linker --wrap (requested by
# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself
# belongs to the platform stack that owns the pools.
RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS
# Slot limits for the hub platforms running the connection-capable proxy;
# the backend registry itself is _PLATFORM_BACKENDS below.
@@ -53,6 +57,19 @@ BluedroidGattClient = bluetooth_connection_ns.class_(
CONF_BACKEND_ID = "backend_id"
DOMAIN = "bluetooth_connection"
@dataclass
class _ConnectionData:
rp2_backend_count: int = 0
def _get_data() -> _ConnectionData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = _ConnectionData()
return CORE.data[DOMAIN]
def _esp32_schema_fragment() -> cv.Schema:
from esphome.components import esp32_ble_tracker
@@ -61,8 +78,6 @@ def _esp32_schema_fragment() -> cv.Schema:
def _rp2_schema_fragment() -> cv.Schema:
from esphome.components import rp2040_ble
return cv.Schema(
{cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)}
)
@@ -77,15 +92,29 @@ async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None:
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
from esphome.components import rp2040_ble
from esphome.components import ota
# The backend drops its link when an OTA starts (esp32 tracker parity).
ota.request_ota_state_listeners()
# More than one backend outgrows the prebuilt BTstack pools: swap them for
# the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's
# btstack_memory.cpp. Keyed to backend registrations (the same event that
# grows the count that sizes the pools), so single-backend builds emit no
# flags and stay byte-identical to previous releases.
data = _get_data()
data.rp2_backend_count += 1
if data.rp2_backend_count == 2:
rp2040_ble.add_btstack_pool_overrides()
await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID])
@dataclass(frozen=True)
class _PlatformBackend:
"""One platform's backend: codegen class, extra schema keys (lazy so the
platform stack is only imported when targeted), and stack registration."""
"""One platform's backend: codegen class, extra schema keys, and stack
registration. The esp32 fragments import their stack lazily because those
imports register esp32-only automations as a side effect; rp2040_ble is
side-effect-free, so it is imported at module scope (the cap constant
needs it there anyway)."""
backend_class: cg.MockObjClass
schema_fragment: Callable[[], cv.Schema]
@@ -5,7 +5,7 @@
#include <esp_gattc_api.h>
#endif
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/log.h"
@@ -44,14 +44,13 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
#ifdef USE_ESP32
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
namespace esphome::bluetooth_connection {
// Address-scoped Bluedroid maintenance shared by every esp32 proxy build,
// including advertisement-only ones where no GATT backend (and none of the
// gated surface above) is compiled - so this block sits outside that gate.
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
// the advertisement-only arm no longer dispatches these requests at all.
conn_err_t unpair_device(uint64_t address) {
esp_bd_addr_t bda;
@@ -66,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) {
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32
#endif // USE_ESP32 && USE_BLE_GATT_CLIENT
@@ -16,17 +16,14 @@
#include <esp_err.h>
#endif
// The connection-aware API request handlers are compiled: a GATT backend is
// wired by codegen (one slot per connection). This is the single spelling of
// that predicate - the hub wrapper and the API request handlers gate on it.
// The wrapper serves the proxy's API surface, so it compiles only when a
// backend AND the proxy are present; advertisement-only and backend-only
// builds get the clean-error handlers instead. Address-scoped maintenance
// (unpair, cache clear) still works there through the per-platform free
// functions below.
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
#define BLUETOOTH_CONNECTION_HAS_GATT
#endif
// USE_BLUETOOTH_PROXY_CONNECTIONS is the single spelling of "this build has
// proxy connection slots": codegen emits it per configured slot, and each
// slot brings a GATT backend, so it also implies USE_BLE_GATT_CLIENT (not
// the converse: a backend can exist without proxy slots). The hub
// wrapper, the proxy's connection surface and the API's connection messages
// all gate on it. The address-scoped maintenance functions below are only
// reached from that gated surface; the #else stubs just keep this header
// parsing on arms without a backend.
namespace esphome::api {
class BluetoothGATTGetServicesResponse;
@@ -68,12 +65,12 @@ 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))
#if (defined(USE_ESP32) || 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
#ifdef USE_ESP32
#if defined(USE_ESP32) && 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; }
@@ -92,6 +89,10 @@ static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVIC
// delivered near the client's 30 s timeout could land on a fresh request's
// empty accumulator and cache as an empty database.
static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30;
// Owed-ack retries stop after ~25 s of subscribed drain time from the first
// refusal, keeping most of the client's 30 s GATT window for congestion to
// clear while still bounding how stale a delivered reply can be.
static constexpr uint16_t PENDING_ACK_RETRY_LIMIT = 250;
// ---- Service-streaming size budget, shared by every platform's streamer ----
@@ -151,7 +152,7 @@ inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uu
}
}
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
/// Result of close_service_batch: keep filling the batch or send it now.
/// An oversized service is packed alone; a failed (backpressured) send is
/// retried from the batch start, so no service is silently skipped.
@@ -163,6 +164,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND };
/// cannot drift.
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t &current_size, int16_t &send_service,
uint8_t connection_index, const char *address_str);
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
} // namespace esphome::bluetooth_connection
@@ -4,7 +4,7 @@
// 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
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "bluetooth_connection.h"
#include "bluetooth_connection_hub.h"
@@ -20,7 +20,7 @@
namespace esphome::bluetooth_connection {
static const char *const TAG = "bluetooth_connection.bluedroid";
static const char *const TAG = "bluetooth_connection";
using ble_device_base::FAST_CONN_TIMEOUT;
using ble_device_base::FAST_MAX_CONN_INTERVAL;
@@ -391,12 +391,14 @@ void BluedroidGattClient::deliver_pending_search_() {
this->listener_->on_service_discovery_done(this->search_status_);
}
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// The wrapper's compile-time streamer detection must keep finding this
// method; a signature drift would silently fall back to the table streamer,
// which proxy builds compile without a materializer.
static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); });
// Bound by the SERVICE STREAMING HAZARD note at the top of
// bluetooth_connection_hub.cpp: never skip a batch, never send done early.
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
if (this->services_released_) {
// Released under the stream: park without services-done so a partial
@@ -527,11 +529,13 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
// On a failed send, rewind the cursor so the batch is retried instead of
// silently skipped.
if (!api_conn->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_);
conn.note_batch_stalled_();
conn.send_service_ = batch_start;
return;
}
conn.batch_stalled_ = false;
}
#endif // USE_BLUETOOTH_PROXY
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
// ---- events ----
@@ -20,7 +20,7 @@
namespace esphome::bluetooth_connection {
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothConnection;
#endif
@@ -79,7 +79,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
ble_device_base::GattServiceTable get_service_table() { return {}; }
void release_services();
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
/// In-place service streamer (the proxy wrapper detects and prefers it):
/// builds one api response batch directly from Bluedroid's cached database,
/// so the streaming peak is the response itself - the old esp32 model.
@@ -1,7 +1,27 @@
// The proxy's per-slot connection wrapper, shared by every platform.
//
// SERVICE STREAMING HAZARD - read before touching the streaming code here or
// in the platform streamers (bluetooth_connection_bluedroid.cpp).
//
// A V3 client caches the service list it receives as the device's complete,
// permanent database. Nothing on the wire marks a list as partial, so a
// stream that is truncated, has a skipped batch, or is terminated early
// would be cached whole and poison every later session with the device.
//
// The rule: it is always better to send nothing and let the client time out
// than to let services-done follow an incomplete stream. Concretely:
// - a refused batch rewinds the cursor and is retried, never skipped;
// - services-done is sent only after every batch was accepted;
// - every interruption (subscriber lost or swapped, backend abort,
// bounds-check failure) parks or aborts WITHOUT services-done and drops
// any owed done;
// - a new GetServices supersedes an owed done, so a stale done can never
// land on a fresh request's empty accumulator and cache it as empty.
// The client only caches a list terminated by services-done within the same
// request; timeouts, disconnects and errors raise instead of caching.
#include "bluetooth_connection_hub.h"
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -16,6 +36,9 @@ static const char *const TAG = "bluetooth_connection";
void BluetoothConnection::set_address(uint64_t address) {
// Keep the proxy's pre-allocated connections-free message in step
this->proxy_->update_address_slot_(this->address_, address);
// Slot changing hands: anything owed belonged to the old address. The
// choke point for every reassignment, not just reset_connection_()'s path.
this->clear_owed_flags_();
this->address_ = address;
if (address == 0) {
this->address_str_[0] = '\0';
@@ -73,6 +96,8 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) {
this->state_ = ClientState::IDLE;
this->services_discovered_ = false;
this->paired_ = false;
// Link gone: the slot may hold a different device before the drain runs.
this->clear_owed_flags_();
this->backend_->release_services();
this->proxy_->reset_connection_slot_(this, reason);
}
@@ -103,11 +128,15 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
// The API client has the services cached; never discover them. No
// discovery phase needs the fast interval, so settle straight into the
// shared steady-state parameters. On esp32 the backend already set the
// same values as prefer-params before opening, so this request is
// usually redundant there - kept because rp2 has no prefer-params and
// the explicit update is its only path to the steady-state interval.
// shared steady-state parameters. Both backends already open cached
// connections with these values (esp32 prefer-params, rp2 initiating
// params), so this request is normally redundant - kept as a backstop
// in case the initial parameters were negotiated away.
this->state_ = ClientState::ESTABLISHED;
// The one D-level line for a cached connect; the uncached path narrates
// through "Discovery finished" instead.
ESP_LOGD(TAG, "[%d] [%s] Connected with cached services, sending connected (mtu=%u)", this->connection_index_,
this->address_str_, mtu);
int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL,
ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0,
ble_device_base::MEDIUM_CONN_TIMEOUT);
@@ -116,7 +145,7 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_,
param_err);
}
this->proxy_->send_device_connection(this->address_, true, mtu);
this->send_connected_reply_();
this->proxy_->send_connections_free();
return;
}
@@ -154,22 +183,136 @@ void BluetoothConnection::on_service_discovery_done(int error) {
this->mtu_);
this->state_ = ClientState::ESTABLISHED;
this->services_discovered_ = true;
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
this->send_connected_reply_();
this->proxy_->send_connections_free();
}
void BluetoothConnection::flush_owed_replies_() {
// Connected first: the client should never see services-done or an ack for
// a link it has not been told is up. Structural, not size-dependent: a
// still-owed connected reply defers the smaller sends to the next tick.
if (this->connected_reply_owed_) {
this->send_connected_reply_();
if (this->connected_reply_owed_) {
// The retry limits are wall-clock windows: age the deferred budgets so
// a reply cannot outlive the window it was sized for.
if (this->send_service_ == SERVICES_DONE_PENDING) {
this->age_services_done_();
}
if (this->has_pending_ack_()) {
this->age_pending_ack_();
}
return;
}
}
if (this->send_service_ == SERVICES_DONE_PENDING) {
this->send_services_done_();
}
if (this->has_pending_ack_()) {
this->flush_pending_ack_();
}
}
void BluetoothConnection::send_connected_reply_() {
if (this->proxy_->send_device_connection(this->address_, true, this->mtu_)) {
this->connected_reply_owed_ = false;
return;
}
// Warn on the leading edge only, as elsewhere: the drop must be visible but
// must not add traffic to the connection that just refused a frame.
if (!this->connected_reply_owed_) {
ESP_LOGW(TAG, "[%d] [%s] Connected reply deferred, TCP buffer full", this->connection_index_, this->address_str_);
this->connected_reply_owed_ = true;
}
}
void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) {
ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_,
operation, handle, status);
}
void BluetoothConnection::note_batch_stalled_() {
if (this->batch_stalled_)
return;
this->batch_stalled_ = true;
ESP_LOGW(TAG, "[%d] [%s] Service batch deferred, TCP buffer full; retrying", this->connection_index_,
this->address_str_);
}
/// Both payload-free acks are just (address, handle); only the type differs.
template<typename Response>
static bool send_handle_reply(api::APIConnection *api_connection, uint64_t address, uint16_t handle) {
Response resp;
resp.address = address;
resp.handle = handle;
return api_connection->send_message(resp);
}
/// Sole construction site, so a re-offer cannot drift from the original.
bool BluetoothConnection::try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) {
if (kind == PendingAck::PENDING_ACK_ERROR) {
// Proxy owns the error reply and reports a refusal the same way.
return this->proxy_->send_gatt_error(this->address_, handle, error);
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return true; // Nobody subscribed: nothing is owed
switch (kind) {
case PendingAck::PENDING_ACK_WRITE:
return send_handle_reply<api::BluetoothGATTWriteResponse>(api_connection, this->address_, handle);
case PendingAck::PENDING_ACK_NOTIFY:
return send_handle_reply<api::BluetoothGATTNotifyResponse>(api_connection, this->address_, handle);
case PendingAck::PENDING_ACK_NONE:
case PendingAck::PENDING_ACK_ERROR: // returned above
return true;
}
// No default label above, so a new enumerator is a -Wswitch warning rather
// than a silent notify reply. This return only satisfies -Wreturn-type.
return true;
}
void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) {
if (this->try_send_ack_(kind, handle, error))
return;
// Report a newly owed reply and a displaced one; displacing is the case
// that loses a reply. Re-refusing the same one stays quiet.
if (!this->has_pending_ack_()) {
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_,
this->address_str_, handle);
} else if (this->pending_ack_handle_ != handle || this->pending_ack_ != kind) {
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X dropped for handle 0x%04X", this->connection_index_,
this->address_str_, this->pending_ack_handle_, handle);
}
this->latch_pending_ack_(kind, handle, error);
}
void BluetoothConnection::flush_pending_ack_() {
if (!this->has_pending_ack_())
return;
if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) {
this->clear_pending_ack_();
return;
}
this->age_pending_ack_();
}
void BluetoothConnection::age_pending_ack_() {
if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) {
// Undeliverable: past here the client has given up and may have re-asked,
// and a late reply would answer the new request instead of this one.
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X undeliverable, abandoning", this->connection_index_,
this->address_str_, this->pending_ack_handle_);
this->clear_pending_ack_();
}
}
void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
// Late completion for a freed slot; nothing to report.
if (this->address_ == 0)
return;
if (error != 0) {
this->log_gatt_operation_error_("reading char/descriptor", handle, error);
this->proxy_->send_gatt_error(this->address_, handle, error);
this->send_gatt_error_(handle, error);
return;
}
auto *api_connection = this->proxy_->get_api_connection();
@@ -180,6 +323,8 @@ void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, u
resp.handle = handle;
resp.set_data(data, len);
if (!api_connection->send_message(resp)) {
// Not latched: would mean holding the payload through the congestion
// that refused it. The client's read timeout arbitrates.
ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_);
}
}
@@ -189,18 +334,10 @@ void BluetoothConnection::on_write_result(uint16_t handle, int error) {
return;
if (error != 0) {
this->log_gatt_operation_error_("writing char/descriptor", handle, error);
this->proxy_->send_gatt_error(this->address_, handle, error);
this->send_gatt_error_(handle, error);
return;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return;
api::BluetoothGATTWriteResponse resp;
resp.address = this->address_;
resp.handle = handle;
if (!api_connection->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_);
}
this->send_ack_(PendingAck::PENDING_ACK_WRITE, handle);
}
void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) {
@@ -209,18 +346,10 @@ void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int err
if (error != 0) {
this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle,
error);
this->proxy_->send_gatt_error(this->address_, handle, error);
this->send_gatt_error_(handle, error);
return;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return;
api::BluetoothGATTNotifyResponse resp;
resp.address = this->address_;
resp.handle = handle;
if (!api_connection->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_);
}
this->send_ack_(PendingAck::PENDING_ACK_NOTIFY, handle);
}
void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
@@ -235,6 +364,8 @@ void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, u
resp.handle = handle;
resp.set_data(data, len);
if (!api_connection->send_message(resp)) {
// Not latched, same reason as the read reply. Notify data is lossy: the
// peripheral will not resend it.
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_);
}
}
@@ -251,6 +382,7 @@ conn_err_t BluetoothConnection::check_connected_op_(const char *action, const ch
}
conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE);
if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
@@ -259,6 +391,7 @@ conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length,
bool response) {
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE);
if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
@@ -266,6 +399,7 @@ conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint
}
conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE);
if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
@@ -276,6 +410,7 @@ conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP).
conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length,
bool /*response*/) {
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE);
if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
@@ -283,6 +418,7 @@ conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t
}
conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) {
this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NOTIFY);
if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_,
@@ -310,7 +446,13 @@ void BluetoothConnection::send_services_done_() {
ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_);
this->services_done_retries_ = 0;
this->send_service_ = SERVICES_DONE_PENDING;
} else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) {
} else {
this->age_services_done_();
}
}
void BluetoothConnection::age_services_done_() {
if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) {
// Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates.
ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_);
this->send_service_ = DONE_SENDING_SERVICES;
@@ -413,11 +555,13 @@ void BluetoothConnection::send_service_for_discovery_() {
// (bounded: a subscriber that stays gone ends streaming via the api-lost
// rewind above).
if (!api_conn->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_);
this->note_batch_stalled_();
this->send_service_ = batch_start;
return;
}
this->batch_stalled_ = false;
}
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
@@ -10,7 +10,7 @@
// The wrapper exists to serve the proxy's API surface; direct consumers
// drive the backend themselves, so backend-only builds compile this header
// empty.
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "bluetooth_connection_gatt_backend.h"
@@ -25,6 +25,16 @@ namespace esphome::bluetooth_connection {
using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
/// A refused GATT reply owed to the current subscriber. Payload-free only:
/// these rebuild from address + handle + error, so a retry costs no buffered
/// data. Read and notify-data carry payloads and are deliberately absent.
enum class PendingAck : uint8_t {
PENDING_ACK_NONE = 0,
PENDING_ACK_WRITE,
PENDING_ACK_NOTIFY,
PENDING_ACK_ERROR,
};
class BluetoothConnection final : public ble_device_base::GattClientListener {
public:
/// Wire the platform backend. Called from codegen before setup.
@@ -77,8 +87,9 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
bool connected() const { return this->state_ == ClientState::ESTABLISHED; }
void set_connection_type(ConnectionType ct) {
this->connection_type_ = ct;
// The bluedroid backend branches on the type itself (prefer-params and
// the with-cache report at OPEN_EVT); the others ignore it.
// Both backends branch on the type before connecting (bluedroid picks
// prefer-params and the with-cache report at OPEN_EVT; rp2 picks the
// initiating parameters), so this must be set before the connect starts.
this->backend_->set_connection_type(ct);
}
// Latched at discovery completion rather than read from the backend table:
@@ -115,6 +126,58 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
this->pending_error_ = err;
}
}
/// Latch a refused reply for the proxy drain. One slot per connection,
/// newest wins: a GATT client works one request at a time, and a discarded
/// reply falls back to the timeout it would have hit anyway.
void latch_pending_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0) {
this->pending_ack_retries_ = 0;
this->pending_ack_ = kind;
this->pending_ack_handle_ = handle;
this->pending_ack_error_ = error;
}
void clear_pending_ack_() { this->pending_ack_ = PendingAck::PENDING_ACK_NONE; }
/// Drop an owed reply this re-ask makes stale. Clients match futures on
/// response type as well as handle, so an owed error (which resolves any op
/// on the handle) is cleared by any re-ask, other kinds only by their own.
void supersede_pending_ack_(uint16_t handle, PendingAck kind) {
if (this->has_pending_ack_() && this->pending_ack_handle_ == handle &&
(this->pending_ack_ == PendingAck::PENDING_ACK_ERROR || this->pending_ack_ == kind)) {
this->clear_pending_ack_();
}
}
bool has_pending_ack_() const { return this->pending_ack_ != PendingAck::PENDING_ACK_NONE; }
/// Warn on the stall's leading edge only. The batch is never lost (the
/// caller rewinds the cursor), and a warning per attempt would add traffic
/// to the connection already refusing frames. Both streamers route here.
void note_batch_stalled_();
/// Send the connected=true reply, latching it if the API refuses. Rebuilt
/// from address_ and mtu_, so the latch is one bit; a dropped confirmation
/// leaves the client timing out while this slot holds a live link. No retry
/// bound: the slot's lifetime is the bound (teardown clears the flag).
void send_connected_reply_();
/// Re-offer everything this slot owes. One entry point so the proxy drain
/// does not have to know which latches exist.
void flush_owed_replies_();
/// Drop everything this slot owes, in one write to the shared tail byte.
void clear_owed_flags_() {
this->pending_ack_ = PendingAck::PENDING_ACK_NONE;
this->batch_stalled_ = false;
this->connected_reply_owed_ = false;
}
/// Sole construction site for these replies, shared by send and retry.
bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error);
/// First attempt: send, and latch it for the drain if the API refuses.
void send_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0);
/// Report a rejected request. Latched like a completion reply, so a
/// refused frame does not strand the client for its whole timeout.
void send_gatt_error_(uint16_t handle, conn_err_t error) {
this->send_ack_(PendingAck::PENDING_ACK_ERROR, handle, error);
}
/// Re-offer the owed reply; clears on success, stays owed on a refusal.
void flush_pending_ack_();
/// Advance the retry budget and abandon at the limit, without sending.
void age_pending_ack_();
// A backend providing its own streamer (see the contract doc) builds the
// response in place from its stack cache; the rest use the table streamer.
// Template so the discarded branch is not odr-checked against backends
@@ -130,6 +193,7 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
/// interrupted stream must never be declared complete (the client's
/// timeout arbitrates), and an owed done is dropped with it.
void park_service_stream_() {
this->batch_stalled_ = false;
if (this->send_service_ >= 0) {
this->backend_->release_services();
this->send_service_ = DONE_SENDING_SERVICES;
@@ -143,6 +207,8 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
/// retries). Callers release the table first; the message needs only the
/// address.
void send_services_done_();
/// Advance the retry budget and abandon at the limit, without sending.
void age_services_done_();
void reset_connection_(conn_err_t reason);
conn_err_t check_connected_op_(const char *action, const char *type) const;
void log_gatt_operation_error_(const char *operation, uint16_t handle, int status);
@@ -152,23 +218,33 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
bluetooth_proxy::BluetoothProxy *proxy_{nullptr};
ble_device_base::BLEGattConnection *backend_{nullptr};
// Group 2: 2-byte types
// Group 2: 2-byte types. Exactly 4 bytes, so address_ below stays
// 8-aligned with no padding (the vptr makes Group 1 12 bytes, not 8).
int16_t send_service_{INIT_SENDING_SERVICES};
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
// Group 3: 8-byte and 4-byte types
uint64_t address_{0};
conn_err_t pending_error_{0};
// Full width: the GATT error domain is open-ended (ble_gatt_client.h) and
// forwarded untranslated, so narrowing would corrupt platform codes.
conn_err_t pending_ack_error_{0};
// Group 4: Arrays
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
// Parked here rather than in Group 2: address_str_ ends 2-aligned, so this
// uses tail slack instead of pushing address_ out by 6 bytes of padding.
uint16_t pending_ack_handle_{0};
// Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48.
// Group 5: bit-packed tail. The first two bytes were already full, so the
// first added bit forced a third and took the 8-aligned object 48 -> 56;
// the handle, error and retry counter ride in that padding. Four bitfield
// bits left; another byte-sized member costs 8 per slot.
static_assert(static_cast<uint8_t>(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow");
static_assert(static_cast<uint8_t>(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2),
"connection_type_ bitfield too narrow");
// Ordered so neither byte's fields straddle a storage unit: 3+5 and
// 4+2+1+1 fill the two tail bytes exactly.
// 4+2+1+1 fill the first two tail bytes exactly.
ClientState state_ : 3 {ClientState::IDLE};
static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow");
uint8_t services_done_retries_ : 5 {0};
@@ -176,8 +252,23 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
ConnectionType connection_type_ : 2 {ConnectionType::V1};
bool paired_ : 1 {false};
bool services_discovered_ : 1 {false};
static_assert(static_cast<uint8_t>(PendingAck::PENDING_ACK_ERROR) < (1 << 2), "pending_ack_ bitfield too narrow");
PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE};
/// Set while a refused batch is retrying, so only the first one warns.
bool batch_stalled_ : 1 {false};
/// An owed connected=true reply; the proxy's paced drain re-offers it.
bool connected_reply_owed_ : 1 {false};
// Plain byte after the bitfields: takes the padding byte instead of
// straddling pending_ack_'s storage unit and growing the object.
static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow");
uint8_t pending_ack_retries_{0};
};
// Pins the grouping above: pending_ack_handle_ in Group 2 instead would pad
// address_ out and reach 64. 32-bit only; the host unit tests build 64-bit.
static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56,
"BluetoothConnection layout regressed on a 32-bit target");
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
@@ -15,7 +15,7 @@
namespace esphome::bluetooth_connection {
static const char *const TAG = "bluetooth_connection.rp2";
static const char *const TAG = "bluetooth_connection";
using ble_device_base::ESPBTUUID;
using ble_device_base::GATT_ERR_NOT_CONNECTED;
@@ -26,6 +26,13 @@ using ble_device_base::GATT_ERR_NO_MEMORY;
// and keeps the scan inhibited, so the engine cancels after 20 s. The
// disconnect timeout mirrors the esp32 CLOSE_EVT safety net.
static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000;
// Budget after a cancel is in flight: its completion normally lands within
// tens of ms, and while the engine waits it pins the stack-wide connect slot,
// so a lost completion must cost seconds, not another full connect budget.
static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000;
// Pending engines re-attempt gap_connect on this cadence instead of every
// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock.
static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50;
// Can-send windows normally open within a connection interval (tens of ms).
static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500;
@@ -54,6 +61,7 @@ RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {};
uint8_t RP2GattClient::instance_count = 0;
btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {};
btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {};
RP2GattClient *RP2GattClient::connect_owner = nullptr;
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) {
@@ -84,6 +92,7 @@ void RP2GattClient::setup() {
// One locked section: the slot store lands before the count bump, and a
// live HCI handler (N > 1 builds) cannot read a half-written registry.
BluetoothLock lock;
this->engine_index_ = instance_count;
instances[instance_count] = this;
instance_count++;
// One HCI event handler for all engine instances (BTstack supports
@@ -96,9 +105,24 @@ void RP2GattClient::setup() {
}
}
#ifdef USE_OTA_STATE_LISTENER
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
this->disable_loop();
}
#ifdef USE_OTA_STATE_LISTENER
void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
// esp32 parity (its tracker disconnects every client at OTA start): free
// the shared radio for the transfer. No restore needed; the client
// reconnects, and on success the device reboots anyway.
if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) {
this->gatt_disconnect();
}
}
#endif
float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); }
@@ -124,34 +148,56 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *
if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) {
break;
}
bd_addr_t peer;
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
uint8_t status = gap_subevent_le_connection_complete_get_status(packet);
hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet);
// Route to the engine that is waiting for this peer.
for (uint8_t i = 0; i < instance_count; i++) {
RP2GattClient *inst = instances[i];
if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) {
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
break;
bd_addr_t peer;
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
// Route by ownership, not address: gap_connect refuses a new
// create-connection until the previous completion is processed, so the
// event belongs to the owner by construction. Cancel completions carry
// a zeroed peer address on this controller, so an address match would
// drop them and pin the owner until its backstop.
RP2GattClient *inst = connect_owner;
static constexpr bd_addr_t ZERO_ADDR = {};
if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 &&
memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) {
// Addressed completion for a peer the owner is not connecting to: a
// success delayed past a cancel and an ownership handoff (the cancel
// idles the stack's request immediately) must not stamp the old
// procedure's link onto the new owner. Zero-address (cancel)
// completions need no such guard: BTstack only emits them while its
// request state is idle, and a new owner re-arms that state when it
// claims the token, so a stale cancel completion is swallowed by the
// stack, never re-attributed. A successful stale link still needs
// disposal (same hazard as the unowned branch below).
if (status == 0) {
gap_disconnect(con_handle);
}
break;
}
connect_owner = nullptr;
if (inst == nullptr) {
if (status == 0) {
// Nobody owns this late link (the owner escalated first): tear it
// down here or the hci_connection_t leaks and the peer answers
// DISALLOWED until reboot.
gap_disconnect(con_handle);
}
break;
}
if (status == 0) {
// Stamp the handle here in the BTstack context: a disconnection
// racing the queued CONNECTED event arrives in this same context
// and must route by handle (it carries no address).
inst->con_handle_ = con_handle;
}
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
break;
}
case HCI_EVENT_DISCONNECTION_COMPLETE: {
hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet);
RP2GattClient *inst = instance_for_con_handle(con_handle);
if (inst == nullptr && instance_count == 1) {
// The main loop may not have recorded the handle yet (the CONNECTED
// event is still queued); with a single engine the connecting
// instance is unambiguous, so route there to close the
// accept-then-drop window. With multiple engines the event has no
// address to match on, so it must be dropped instead of guessed.
RP2GattClient *candidate = instances[0];
if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) {
inst = candidate;
}
}
// Routable even against a still-queued CONNECTED event: the handle is
// stamped in this context at connection-complete time.
RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet));
if (inst != nullptr) {
inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0);
}
@@ -393,44 +439,73 @@ void RP2GattClient::loop() {
if (dropped > 0) {
// Control events must not be lost; the connection state is no longer
// trustworthy — recover with a forced teardown.
ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped);
ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped);
this->gatt_disconnect();
}
uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count();
if (notify_dropped > 0) {
ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped);
ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped);
}
if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
if (this->state_ == EngineState::CONNECT_PENDING) {
uint32_t now = millis();
if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Connect timeout");
if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) {
if (!this->connect_cancel_attempted_) {
this->connect_cancel_attempted_ = true;
BluetoothLock lock;
// Never reached the radio; nothing stack-side to cancel.
ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_);
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
} else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) {
this->connect_retry_ms_ = now;
if (int err = this->try_gap_connect_(); err != 0) {
this->fail_connection_(static_cast<uint8_t>(err));
}
}
} else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
uint32_t now = millis();
bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID &&
this->connect_cancel_attempted_;
uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS;
if (now - this->connect_started_ > budget) {
ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_);
bool link_up = this->state_ != EngineState::CONNECTING;
bool cancel_sent = false;
if (!link_up) {
BluetoothLock lock;
// Handle check under the lock: a success completion can stamp it in
// the BTstack context right up to this point, and escalating past a
// live link would orphan it (the queued CONNECTED event is dropped
// by the state guard once fail_connection_ runs).
link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID;
if (!link_up && connect_owner == this) {
// gap_connect_cancel is stack-global; only the engine whose
// create-connection is in flight may issue it. First timeout:
// cancel and give the completion a grace period. Second: the
// completion was lost, re-issue the cancel in case the procedure
// still runs (a no-op on an idle stack), then escalate.
gap_connect_cancel();
// The cancel produces a connection-complete event with a failure
// status, which drives the normal failure path; restart the timer
// so a lost event escalates below instead of wedging here.
this->connect_started_ = now;
} else {
// The cancel's completion never arrived: reclaim the slot and the
// scan rather than cancelling forever.
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
cancel_sent = !this->connect_cancel_attempted_;
}
} else {
// The link is up (MTU exchange stalled): tear it down properly so the
// controller frees its side; the DISCONNECTING safety net below
// reclaims state if the disconnection event is lost. Dropping engine
// state without gap_disconnect would leak the live link and the
// single GATT slot for the rest of the boot.
this->connect_cancel_attempted_ = true;
}
if (link_up) {
// The link is up (stamped mid-timeout or MTU exchange stalled): tear
// it down properly so the controller frees its side; the
// DISCONNECTING safety net below reclaims state if the disconnection
// event is lost. Dropping engine state without gap_disconnect would
// leak the live link and this engine's GATT slot for the rest of the
// boot.
this->gatt_disconnect();
} else if (cancel_sent) {
// The cancel produces a connection-complete event with a failure
// status, which drives the normal failure path; restart the timer so
// a lost event escalates on the short cancel budget.
this->connect_started_ = now;
} else {
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
}
}
} else if (this->state_ == EngineState::DISCONNECTING) {
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Disconnect timeout, forcing idle");
ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_);
this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT);
}
} else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP &&
@@ -446,7 +521,7 @@ void RP2GattClient::loop() {
}
}
if (timed_out) {
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_);
this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
}
} else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() &&
@@ -467,7 +542,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
case RP2GattEvent::MTU_EXCHANGED:
if (this->state_ == EngineState::MTU_EXCHANGE) {
this->mtu_ = event.value;
ESP_LOGD(TAG, "MTU %u", this->mtu_);
ESP_LOGV(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_);
this->state_ = EngineState::READY;
// Scanning resumes and runs alongside the established connection.
this->release_scan_inhibit_();
@@ -515,7 +590,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
return;
}
if (status != 0) {
ESP_LOGW(TAG, "Connect failed, status=0x%02x", status);
ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status);
this->fail_connection_(status);
return;
}
@@ -539,7 +614,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
}
this->con_handle_ = con_handle;
this->state_ = EngineState::MTU_EXCHANGE;
ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle);
ESP_LOGV(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle);
BluetoothLock lock;
// One wildcard listener covers notifications/indications for every
// characteristic on this connection; the CCCD writes come from the API
@@ -564,6 +639,24 @@ void RP2GattClient::release_scan_inhibit_() {
}
void RP2GattClient::fail_connection_(uint8_t reason) {
{
// Timeout escalation can fire with the completion event lost; release the
// stack-wide connect slot so pending engines can proceed. Until the old
// completion is processed, gap_connect answers any peer with DISALLOWED
// (the request-level guard in hci.c); a cancel idles that request
// immediately, and a late addressed completion from the old procedure is
// then dropped by the owner-peer cross-check in the handler.
BluetoothLock lock;
if (connect_owner == this) {
connect_owner = nullptr;
}
if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) {
// A success completion stamped the handle between the escalation
// decision and this lock: tear the link down before cleanup wipes the
// handle, or it leaks its pool block for the rest of the boot.
gap_disconnect(this->con_handle_);
}
}
this->cleanup_link_state_();
this->release_scan_inhibit_();
this->state_ = EngineState::IDLE;
@@ -577,14 +670,19 @@ void RP2GattClient::cleanup_link_state_() {
while ((stale = this->notify_queue_.pop()) != nullptr) {
this->notify_pool_.release(stale);
}
// The wildcard listener is registered on the normal connect path right
// after con_handle_ is recorded; the cancel branch tears down before
// registering, where stop_listening on an unregistered entry is a no-op.
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
// con_handle_ may be stamped in the BTstack context before the main loop
// registers the listener, so a valid handle does not imply a registration;
// stop_listening on an unregistered entry is a benign no-op. One lock
// scope around check and reset so an IRQ stamp cannot land in between
// (unreachable today — ownership is released before cleanup — but the
// invariant lives three functions away).
{
BluetoothLock lock;
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
}
this->con_handle_ = HCI_CON_HANDLE_INVALID;
}
this->con_handle_ = HCI_CON_HANDLE_INVALID;
this->notify_subscription_count_ = 0;
this->cancel_requested_ = false;
this->op_type_ = OpType::NONE;
@@ -596,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) {
if (this->state_ == EngineState::IDLE) {
return;
}
ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason);
ESP_LOGV(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason);
this->fail_connection_(reason);
}
@@ -654,7 +752,7 @@ int RP2GattClient::discover_services() {
RAMAllocator<ServiceArena> allocator(RAMAllocator<ServiceArena>::ALLOC_INTERNAL);
this->arena_ = allocator.allocate(1);
if (this->arena_ == nullptr) {
ESP_LOGE(TAG, "Service table allocation failed");
ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_);
return ble_device_base::GATT_ERR_NO_MEMORY;
}
new (this->arena_) ServiceArena();
@@ -760,8 +858,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) {
void RP2GattClient::finish_discovery_(int error) {
this->discovery_phase_ = DiscoveryPhase::NONE;
ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_,
this->char_count_, this->desc_count_);
ESP_LOGV(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_,
error, this->service_count_, this->char_count_, this->desc_count_);
if (error == 0 && this->truncated_) {
// A partial table must not stream: V3 clients cache the database
// permanently, so an incomplete one would be wrong forever.
@@ -839,22 +937,68 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) {
this->parent_->inhibit_scan();
this->connect_cancel_attempted_ = false;
this->cancel_requested_ = false;
// Bounds the queued wait; restarted when gap_connect is accepted so the
// radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the
// sum via a disconnect request).
this->connect_started_ = millis();
if (int err = this->try_gap_connect_(); err != 0) {
this->release_scan_inhibit_();
return err;
}
this->enable_loop();
return 0;
}
// One outgoing LE create-connection exists stack-wide: issue it if no other
// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry.
// Returns nonzero only for hard failures (state untouched; caller cleans up).
int RP2GattClient::try_gap_connect_() {
// Unlocked peek: single core, aligned pointer; a stale value costs one loop
// pass and the locked re-check below is authoritative. Keeps the per-loop
// pending retry from taking BluetoothLock just to find the radio busy.
if (connect_owner != nullptr) {
this->state_ = EngineState::CONNECT_PENDING;
return 0;
}
uint8_t status;
{
BluetoothLock lock;
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL,
0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
if (connect_owner != nullptr) {
status = ERROR_CODE_COMMAND_DISALLOWED;
} else {
// esp32 parity: cached connections come up at MEDIUM already (nothing
// consumes the fast interval without a discovery phase), so there is no
// post-connect update procedure to race or silently lose; sustained
// FAST intervals also starve WiFi on the shared CYW43 radio.
// Without-cache runs FAST for discovery and steps down in
// finish_discovery_.
bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE;
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW,
cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL,
cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0,
cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
if (status == 0) {
connect_owner = this;
// Still under the lock: a synthesized failure completion can fire in
// the BTstack context the instant it releases, and completion routing
// requires CONNECTING — set after the fact, the event is discarded
// and the engine burns its whole budget waiting for it.
this->state_ = EngineState::CONNECTING;
this->connect_started_ = millis();
}
}
}
if (status != 0) {
ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status);
this->release_scan_inhibit_();
return status;
if (status == 0) {
return 0;
}
this->state_ = EngineState::CONNECTING;
this->connect_started_ = millis();
this->enable_loop();
return 0;
if (status == ERROR_CODE_COMMAND_DISALLOWED) {
// Radio busy with another engine's connect; resolved from loop().
this->state_ = EngineState::CONNECT_PENDING;
return 0;
}
ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status);
return status;
}
int RP2GattClient::gatt_disconnect() {
@@ -863,6 +1007,10 @@ int RP2GattClient::gatt_disconnect() {
return GATT_ERR_NOT_CONNECTED;
case EngineState::DISCONNECTING:
return 0; // already on its way down
case EngineState::CONNECT_PENDING:
// Nothing issued stack-side; the invalid handle takes the refused
// path below without touching the stack.
break;
case EngineState::CONNECTING: {
if (this->con_handle_ == HCI_CON_HANDLE_INVALID) {
// The cancel can lose the race against a successful connection
@@ -871,9 +1019,18 @@ int RP2GattClient::gatt_disconnect() {
// attempt, so a lost completion escalates on the next timeout tick.
this->cancel_requested_ = true;
this->connect_cancel_attempted_ = true;
// Grace period for the cancel completion: the client's disconnect
// often lands right at the engine's own deadline, and without the
// restart the loop timeout fires first and reports before the
// completion can finish the teardown cleanly.
this->connect_started_ = millis();
BluetoothLock lock;
gap_connect_cancel();
// Completion arrives as a failed connection-complete event.
// Owner: the cancel completes as a failed connection-complete. Not
// the owner (completion already resolved in the BTstack context): the
// queued event drives the same teardown, nothing to cancel.
if (connect_owner == this) {
gap_connect_cancel();
}
return 0;
}
break;
@@ -881,20 +1038,23 @@ int RP2GattClient::gatt_disconnect() {
default:
break;
}
uint8_t status;
{
BluetoothLock lock;
status = gap_disconnect(this->con_handle_);
}
if (status != 0) {
// Refused (handle already gone): complete via the event queue so the
// listener cannot re-enter disconnect() mid-call. BluetoothLock stops
// the IRQ producer, so this main-loop push is SPSC-safe.
ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status);
uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER;
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
{
BluetoothLock lock;
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
status = gap_disconnect(this->con_handle_);
}
if (status != 0) {
ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status);
}
}
if (status != 0) {
// Refused (handle already gone) or never issued (CONNECT_PENDING):
// complete via the event queue so the listener cannot re-enter
// disconnect mid-call. BluetoothLock stops the IRQ producer, so this
// main-loop push is SPSC-safe.
BluetoothLock lock;
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
}
this->state_ = EngineState::DISCONNECTING;
this->disconnecting_started_ = millis();
@@ -19,6 +19,10 @@
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h"
#ifdef USE_OTA_STATE_LISTENER
#include "esphome/components/ota/ota_backend.h"
#endif
#include <btstack.h>
#include <array>
@@ -71,7 +75,13 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8;
// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot.
static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4;
class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040BLE> {
class RP2GattClient final : public Component,
public Parented<rp2040_ble::RP2040BLE>
#ifdef USE_OTA_STATE_LISTENER
,
public ota::OTAGlobalStateListener
#endif
{
public:
void setup() override;
void loop() override;
@@ -95,18 +105,26 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
ble_device_base::GattServiceTable get_service_table();
// No connection-type branching on this backend.
void set_connection_type(ble_device_base::ConnectionType ct) {}
// Cached connections initiate at MEDIUM parameters (esp32 parity); FAST is
// reserved for the discovery phase of uncached connects.
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
void release_services();
#ifdef USE_OTA_STATE_LISTENER
// Drop the connection while an OTA runs (esp32 parity): an active link
// competes with the transfer for the shared radio.
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
protected:
// Link/engine state. Discovery and GATT ops have their own cursors below —
// the link stays READY while they run.
enum class EngineState : uint8_t {
IDLE,
CONNECTING, // gap_connect issued, waiting for connection complete
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
READY, // on_connection_state(true) delivered
CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection
CONNECTING, // gap_connect issued, waiting for connection complete
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
READY, // on_connection_state(true) delivered
DISCONNECTING,
};
@@ -143,6 +161,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
int issue_descriptor_query_(uint16_t char_index);
void finish_discovery_(int error);
void fail_connection_(uint8_t reason);
int try_gap_connect_();
void cleanup_link_state_();
bool notify_subscribed_(uint16_t handle) const;
static void can_write_no_rsp_trampoline(void *context);
@@ -171,8 +190,12 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
// Group 3: 4-byte types
uint32_t connect_started_{0};
uint32_t connect_retry_ms_{0}; // last CONNECT_PENDING gap_connect attempt
uint32_t disconnecting_started_{0};
uint32_t write_no_rsp_started_{0};
// Unscoped C enum, so int-sized: lives with the 4-byte members to keep the
// padding at the tail.
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
// Group 4: 2-byte types (table counters written from the handler during
// discovery, read from the main loop after the phase's QUERY_COMPLETE)
@@ -191,8 +214,9 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
// listener's deliveries on this list (esp32 parity for enable=false).
std::array<uint16_t, RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS> notify_subscriptions_{};
uint8_t notify_subscription_count_{0};
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE};
EngineState state_{EngineState::IDLE};
DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE};
OpType op_type_{OpType::NONE};
@@ -214,6 +238,12 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
static btstack_packet_callback_registration_t hci_event_registration;
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static btstack_packet_callback_registration_t sm_event_registration;
// The engine whose gap_connect is in flight: BTstack allows one outgoing LE
// create-connection stack-wide, and gap_connect_cancel is global, so only
// the owner may cancel. Written under BluetoothLock from the main loop,
// cleared in the BTstack context when the procedure resolves.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static RP2GattClient *connect_owner;
};
} // namespace esphome::bluetooth_connection
+15 -7
View File
@@ -151,20 +151,23 @@ def _validate_no_active(config: ConfigType) -> ConfigType:
@functools.cache
def _rp2_config_schema() -> cv.All:
"""Full proxy on the rp2 BLE hub: active connections through the BTstack
GATT client backend in bluetooth_connection. The slot limit comes from the
prebuilt BTstack library (one connection today); the code is built for N."""
GATT client backend in bluetooth_connection. Multi-slot builds replace the
prebuilt library's one-client BTstack pools via linker --wrap, owned by
rp2040_ble and requested when a second backend registers."""
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)
return {
**config,
CONF_CONNECTIONS: [
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
],
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
}
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
@@ -182,8 +185,8 @@ def _rp2_config_schema() -> cv.All:
min=1,
max=max_conn,
msg=f"rp2 supports at most {max_conn} connection slot(s); "
"the framework's BTstack library is built with "
f"MAX_NR_GATT_CLIENTS {max_conn}",
"the BTstack pool overrides in rp2040_ble are sized "
f"for {max_conn}",
),
),
}
@@ -205,6 +208,11 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
# this define whenever a proxy is present (zero on advertisement-only
# hubs); sized here so it can never diverge from the loop below.
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections))
if connections:
# Gates the connection and GATT half of the API surface. A proxy
# without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never
# 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)
connection = cg.new_Pvariable(connection_conf[CONF_ID])
@@ -70,9 +70,10 @@ void BluetoothProxy::send_polled_scanner_state_() {
#endif // USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::setup() {
// BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy.
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
#endif
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->hub_->scan_active();
@@ -103,7 +104,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
this->response_.advertisements_len++;
ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi);
ESP_LOGVV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi);
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
@@ -111,7 +112,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
}
}
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) {
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
connection->address_str(), ble_device_base::client_state_to_string(state));
@@ -120,7 +121,20 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec
void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) {
ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message);
}
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) {
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address);
}
void BluetoothProxy::log_reply_deferred_(const char *what, uint64_t address) {
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " deferred, TCP buffer full", what, address);
}
void BluetoothProxy::log_reply_displaced_(const char *what, uint64_t owed, uint64_t address) {
ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, what, owed, address);
}
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
@@ -129,11 +143,21 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ
void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action,
const char *type) {
this->log_not_connected_gatt_(action, type);
this->send_gatt_error(address, handle, GATT_NOT_CONNECTED);
if (!this->send_gatt_error(address, handle, GATT_NOT_CONNECTED)) {
// No connection, so nothing to latch against; the client's timeout arbitrates.
this->log_reply_dropped_("Not-connected", address);
}
}
#endif
void BluetoothProxy::log_advertisement_flush_() {
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
void BluetoothProxy::log_advertisement_flush_(bool sent) {
if (sent) {
// VV: one line per flush drowns a verbose log in any busy environment.
ESP_LOGVV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
} else {
// The rare congestion signal stays at V.
ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len);
}
}
void BluetoothProxy::dump_config() {
@@ -144,7 +168,7 @@ void BluetoothProxy::dump_config() {
this->get_bluetooth_mac_address_pretty(mac_str);
const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)";
const char *scan_mode = this->configured_scan_active_ ? "active" : "passive";
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Active: %s\n"
@@ -162,12 +186,9 @@ void BluetoothProxy::dump_config() {
#endif
}
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused.
void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) {
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
void BluetoothProxy::register_connection(BluetoothConnection *connection) {
if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) {
// Cannot happen with codegen-sized registration; a silent drop would
// surface later as a null proxy_ dereference, so refuse loudly.
@@ -178,7 +199,6 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c
connection->connection_index_ = this->connection_count_;
this->connections_[this->connection_count_++] = connection;
connection->proxy_ = this;
#endif
}
void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); }
@@ -197,7 +217,7 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v
void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) {
// Match before free entry so one address never occupies two pool slots.
PendingDisconnect *free_entry = nullptr;
PendingReply *free_entry = nullptr;
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto &owed = this->pending_disconnections_[i];
if (owed.matches(address)) {
@@ -209,12 +229,12 @@ void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t e
}
}
if (free_entry != nullptr) {
this->log_reply_deferred_("Disconnect", address);
free_entry->set(address, error);
return;
}
// Every entry is owed: evict the first so the newest loss is not silent too.
ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full",
(unsigned long long) this->pending_disconnections_[0].address());
this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), address);
this->pending_disconnections_[0].set(address, error);
}
@@ -224,19 +244,39 @@ void BluetoothProxy::clear_pending_disconnection_(uint64_t address) {
for (uint8_t i = 0; i < this->connection_count_; i++) {
if (this->pending_disconnections_[i].matches(address)) {
this->pending_disconnections_[i].clear();
return; // latch_pending_disconnection_ keeps at most one entry per address
}
}
}
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
if (!this->send_device_connection(connection->get_address(), false, 0, reason)) {
// The client has no other way to learn of an unsolicited disconnect;
// latch and let loop()'s paced drain deliver it. V by design: a louder
// level would ride the same congested link this reports on.
ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(),
connection->address_str());
this->latch_pending_disconnection_(connection->get_address(), reason);
void BluetoothProxy::answer_device_disconnected_(uint64_t address) {
if (this->send_device_connection(address, false)) {
// A landed answer satisfies any owed notification for the address; a
// drained duplicate would follow it otherwise.
this->clear_pending_disconnection_(address);
return;
}
// Not latched: the client's own request timeout arbitrates, and pooling
// these would let a request retry loop displace an unsolicited disconnect.
this->log_reply_dropped_("Disconnect", address);
}
void BluetoothProxy::send_device_disconnected_(uint64_t address, conn_err_t error) {
if (this->send_device_connection(address, false, 0, error)) {
// A later disconnect landing for an address that still has one owed would
// otherwise have the drain repeat it.
this->clear_pending_disconnection_(address);
return;
}
// A dropped disconnect leaves the client believing the link is live, so
// every GATT operation on it times out until something else corrects it.
// latch_pending_disconnection_() reports the leading edge.
this->latch_pending_disconnection_(address, error);
}
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
// The client has no other way to learn of an unsolicited disconnect.
this->send_device_disconnected_(connection->get_address(), reason);
connection->set_address(0);
connection->send_service_ = INIT_SENDING_SERVICES;
this->send_connections_free();
@@ -282,18 +322,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
auto *connection = this->get_connection_(msg.address, true);
if (connection == nullptr) {
ESP_LOGW(TAG, "No free connections available");
this->send_device_connection(msg.address, false);
this->answer_device_disconnected_(msg.address);
return;
}
if (!msg.has_address_type) {
ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(),
connection->address_str());
this->send_device_connection(msg.address, false);
this->answer_device_disconnected_(msg.address);
return;
}
if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) {
this->log_connection_request_ignored_(connection, connection->state());
this->send_device_connection(msg.address, true);
connection->send_connected_reply_();
this->send_connections_free();
return;
} else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) {
@@ -320,7 +360,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: {
auto *connection = this->get_connection_(msg.address, false);
if (connection == nullptr) {
this->send_device_connection(msg.address, false);
this->answer_device_disconnected_(msg.address);
this->send_connections_free();
return;
}
@@ -328,7 +368,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
connection->disconnect();
} else {
connection->set_address(0);
this->send_device_connection(msg.address, false);
this->answer_device_disconnected_(msg.address);
this->send_connections_free();
}
break;
@@ -372,7 +412,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
ESP_LOGE(TAG, "V1 connections removed");
this->send_device_connection(msg.address, false);
this->answer_device_disconnected_(msg.address);
break;
}
}
@@ -387,7 +427,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms
auto err = connection->read_characteristic(msg.handle);
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
connection->send_gatt_error_(msg.handle, err);
}
}
@@ -400,7 +440,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &
auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response);
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
connection->send_gatt_error_(msg.handle, err);
}
}
@@ -413,7 +453,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead
auto err = connection->read_descriptor(msg.handle);
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
connection->send_gatt_error_(msg.handle, err);
}
}
@@ -426,7 +466,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri
auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true);
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
connection->send_gatt_error_(msg.handle, err);
}
}
@@ -477,14 +517,15 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
auto err = connection->notify_characteristic(msg.handle, msg.enable);
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
connection->send_gatt_error_(msg.handle, err);
}
}
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
if (this->api_connection_ == nullptr)
return;
// Send results unchecked (esp32 parity): a drop resolves via the client timeout.
// Not latched (esp32 parity): the request is idempotent, so a drop resolves
// via the client timeout and a retry gives the same answer. Still reported.
auto *connection = this->get_connection_(msg.address, false);
api::BluetoothSetConnectionParamsResponse resp;
@@ -495,7 +536,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
connection ? static_cast<int>(connection->get_connection_index()) : -1,
connection ? connection->address_str() : "unknown");
resp.error = GATT_NOT_CONNECTED;
this->api_connection_->send_message(resp);
if (!this->api_connection_->send_message(resp)) {
this->log_reply_dropped_("Connection-params", msg.address);
}
return;
}
@@ -506,10 +549,12 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
static_cast<uint16_t>(std::min(msg.max_interval, max_val)),
static_cast<uint16_t>(std::min(msg.latency, max_val)),
static_cast<uint16_t>(std::min(msg.timeout, max_val)));
this->api_connection_->send_message(resp);
if (!this->api_connection_->send_message(resp)) {
this->log_reply_dropped_("Connection-params", msg.address);
}
}
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
#ifdef USE_ESP32
@@ -552,7 +597,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
#endif // USE_ESP32
void BluetoothProxy::loop() {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// Stream pending service-discovery batches every iteration; the streamer
// handles a vanished API connection itself.
for (uint8_t i = 0; i < this->connection_count_; i++) {
@@ -566,17 +611,19 @@ void BluetoothProxy::loop() {
return;
this->last_advertisement_flush_time_ = now;
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
if (this->connections_free_pending_ && this->api_connection_ != nullptr) {
// Resend a dropped slot-state update, paced by the 100 ms gate so the
// retry does not hammer the congestion it exists to survive; the
// advertisement-only arm answers DISCONNECT requests with this message
// too, so the drain compiles on every proxy build.
// retry does not hammer the congestion it exists to survive. Every build
// sends this at subscribe time (api_connection.cpp), so the drain
// compiles on every proxy build.
this->connections_free_pending_ = false;
this->send_connections_free(this->api_connection_);
}
#endif
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// The API subscriber is gone: tear down any connections it left behind
// (disconnect() on an already-disconnecting slot is a no-op).
for (uint8_t i = 0; i < this->connection_count_; i++) {
@@ -589,18 +636,28 @@ void BluetoothProxy::loop() {
return;
}
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// Paced retries of owed per-slot notifications; subscriber swaps clear
// stale latches before this runs.
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->send_service_ == SERVICES_DONE_PENDING) {
connection->send_services_done_();
}
this->connections_[i]->flush_owed_replies_();
}
// Address-keyed, not slot-keyed, so it gets its own loop; bounded by
// connection_count_ like the latch and clear helpers. Not pre-cleared:
// the sender clears on success and re-latches on refusal, keeping the
// latch's leading-edge warn honest (same shape as the unpair drain).
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto &owed = this->pending_disconnections_[i];
if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) {
owed.clear();
}
if (owed.empty())
continue;
this->send_device_disconnected_(owed.address(), owed.error());
}
// An owed unpair reply. Not pre-cleared: the sender clears on success and
// re-latches on refusal, keeping its leading-edge warn guard honest.
if (!this->pending_unpairing_.empty()) {
conn_err_t error = this->pending_unpairing_.error();
this->send_device_unpairing(this->pending_unpairing_.address(), error == CONN_OK, error);
}
#endif
@@ -617,83 +674,57 @@ void BluetoothProxy::loop() {
}
#endif
this->flush_pending_advertisements_();
}
#ifndef BLUETOOTH_CONNECTION_HAS_GATT
// Advertisement-only proxy. GATT client connections are excluded at compile
// time (no connection backend on this platform, or active: false), so every
// connection-oriented request is answered with a clean error instead of
// silence, and Home Assistant treats the proxy as passive.
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
switch (msg.request_type) {
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE:
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE:
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT:
ESP_LOGW(TAG, "Active connections are not supported on this platform");
this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT:
// Not an error: the device is already disconnected, which is the requested state.
this->send_device_connection(msg.address, false);
this->send_connections_free();
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: {
// Address-scoped maintenance needs no connection slot: real on esp32
// (Bluedroid bond table), the stub elsewhere keeps the old error reply.
conn_err_t ret = bluetooth_connection::unpair_device(msg.address);
this->send_device_unpairing(msg.address, ret == CONN_OK, ret);
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: {
conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address);
this->send_device_clear_cache(msg.address, ret == CONN_OK, ret);
break;
#ifdef USE_WIFI
// Wi-Fi (or a coexistence build that can fall back to it): every other
// non-empty 100 ms tick (~200 ms) gives partial batches time to fill
// toward BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE, so the air gets fewer,
// fuller frames. Full batches still ship immediately from the queueing
// path, and the owed-reply drains above keep the 100 ms cadence.
if (this->response_.advertisements_len != 0) {
if (this->adv_flush_toggle_) {
this->flush_pending_advertisements_();
}
this->adv_flush_toggle_ = !this->adv_flush_toggle_;
} else {
// Nothing pending (idle, or a full batch just shipped inline): arm so
// the next batch ships on the next tick.
this->adv_flush_toggle_ = true;
}
#else
// No Wi-Fi in the build (ethernet): no airtime worth trading latency for,
// so partial batches flush every tick.
this->flush_pending_advertisements_();
#endif
}
void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic");
void BluetoothProxy::reset_owed_replies_() {
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
this->connections_free_pending_ = false;
#endif
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// Owed on unsubscribe; on subscribe the trailing send_scanner_state_()
// re-drives it from the hub, so clearing it there is free.
this->scanner_state_pending_ = false;
#else
// Force a poll-arm mismatch: a frame refused at subscribe time could
// otherwise match the stale detector and never be retried. Inert on
// unsubscribe: loop() returns at the no-subscriber gate before the
// detector runs, and a re-subscribe re-arms this anyway.
this->last_scan_running_ = !this->hub_->scan_running();
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
this->pending_unpairing_.clear();
this->pending_disconnections_.fill({});
for (uint8_t i = 0; i < this->connection_count_; i++) {
// Neither a partial stream's tail nor an owed done belongs to the next
// session; silence (the client's timeout) arbitrates.
auto *connection = this->connections_[i];
connection->park_service_stream_();
connection->clear_owed_flags_();
}
#endif
}
void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic");
}
void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor");
}
void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor");
}
void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
}
void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic");
}
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
if (this->api_connection_ == nullptr)
return;
// Send results unchecked (esp32 parity): a drop resolves via the client timeout.
api::BluetoothSetConnectionParamsResponse resp;
resp.address = msg.address;
resp.error = GATT_NOT_CONNECTED;
this->api_connection_->send_message(resp);
}
#endif // !BLUETOOTH_CONNECTION_HAS_GATT
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
if (api_connection != this->api_connection_) {
if (this->api_connection_ != nullptr) {
@@ -710,15 +741,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
}
// Stale retry latches belong to the previous subscriber's session; a
// re-subscribe by the current one keeps what it is still owed.
this->connections_free_pending_ = false;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
for (uint8_t i = 0; i < this->connection_count_; i++) {
// Neither a partial stream's tail nor an owed done belongs to the new
// session; silence (the client's timeout) arbitrates.
this->connections_[i]->park_service_stream_();
}
this->pending_disconnections_.fill({});
#endif
this->reset_owed_replies_();
}
this->api_connection_ = api_connection;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
@@ -735,12 +758,10 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
return;
}
this->api_connection_ = nullptr;
this->connections_free_pending_ = false;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
this->scanner_state_pending_ = false;
#endif
this->reset_owed_replies_();
}
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void BluetoothProxy::send_connections_free() {
if (this->api_connection_ != nullptr) {
this->send_connections_free(this->api_connection_);
@@ -776,14 +797,14 @@ bool BluetoothProxy::send_gatt_services_done(uint64_t address) {
return this->api_connection_->send_message(call);
}
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
return true; // Nobody subscribed: nothing is owed, only a refused frame reports false
api::BluetoothGATTErrorResponse call;
call.address = address;
call.handle = handle;
call.error = error;
this->api_connection_->send_message(call);
return this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) {
@@ -794,22 +815,46 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err
call.paired = paired;
call.error = error;
this->api_connection_->send_message(call);
if (!this->api_connection_->send_message(call)) {
// Not latched: a retried PAIR is answered from is_paired(), so the client
// recovers on its own. Still worth saying it happened.
this->log_reply_dropped_("Pairing", address);
}
}
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
// An owed success is the authoritative answer: a later attempt for the
// same address fails only because the first already removed the bond.
if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) &&
this->pending_unpairing_.error() == CONN_OK) {
success = true;
error = CONN_OK;
}
api::BluetoothDeviceUnpairingResponse call;
call.address = address;
call.success = success;
call.error = error;
this->api_connection_->send_message(call);
if (this->api_connection_->send_message(call)) {
// A later unpair landing for an address that still has one owed would
// otherwise have the drain repeat it.
if (this->pending_unpairing_.matches(address)) {
this->pending_unpairing_.clear();
}
return;
}
if (this->pending_unpairing_.empty()) {
this->log_reply_deferred_("Unpair", address);
} else if (!this->pending_unpairing_.matches(address)) {
this->log_reply_displaced_("Unpair", this->pending_unpairing_.address(), address);
}
this->pending_unpairing_.set(address, error);
}
// Shared by both platform paths: the neutral bluetooth_device_request() uses it to
// answer a clear-cache request with a clean error, so it must not be esp32-guarded.
// GATT arm only: the advertisement-only arm no longer dispatches CLEAR_CACHE,
// so its response encoder would be dead weight there.
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
@@ -818,8 +863,12 @@ void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, con
call.success = success;
call.error = error;
this->api_connection_->send_message(call);
if (!this->api_connection_->send_message(call)) {
// Not latched: clear-cache is idempotent, so a retry gives the same answer.
this->log_reply_dropped_("Clear-cache", address);
}
}
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -29,7 +29,7 @@ using bluetooth_connection::DONE_SENDING_SERVICES;
using bluetooth_connection::INIT_SENDING_SERVICES;
using bluetooth_connection::SERVICES_DONE_PENDING;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
using ClientState = ble_device_base::ClientState;
#endif
@@ -60,12 +60,10 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
/// One owed freed-slot connected=false notification in a single word: the
/// 48-bit address in the low bits, the sign-extending 16-bit reason on top.
/// Every reason that reaches the pool (esp_gatt_status_t,
/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t.
class PendingDisconnect {
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
/// One owed address-keyed reply in a single word: 48-bit address low, 16-bit
/// error on top. Every error that reaches it fits int16_t.
class PendingReply {
public:
constexpr void set(uint64_t address, conn_err_t error) {
// Mask: the address originates from the client, and a stray high bit
@@ -73,7 +71,9 @@ class PendingDisconnect {
this->word_ = (address & ADDRESS_MASK) | (static_cast<uint64_t>(static_cast<uint16_t>(error)) << 48);
}
constexpr void clear() { this->word_ = 0; }
// Whole-word test: set() is only ever given a live (nonzero) address.
// Whole-word test: only (address 0, error 0) reads back as nothing owed.
// A zero-address failure still latches, which is correct - that reply is
// owed too. Neither backend can unpair address 0 successfully.
constexpr bool empty() const { return this->word_ == 0; }
// Masked like set(), so a stray high bit cannot defeat the pool lookups.
constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); }
@@ -86,19 +86,19 @@ class PendingDisconnect {
};
// Pin the packing at compile time: mask and sign round-trip for every
// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit).
constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) {
PendingDisconnect p;
constexpr bool pending_reply_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) {
PendingReply p;
p.set(address, error);
return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address);
}
static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1));
static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F));
static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110));
static_assert(PendingDisconnect{}.empty());
static_assert(pending_reply_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1));
static_assert(pending_reply_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F));
static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110));
static_assert(PendingReply{}.empty());
#endif
class BluetoothProxy final : public Component {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// Allow the connection to update connections_free_response_
friend bluetooth_connection::BluetoothConnection;
#endif
@@ -109,9 +109,9 @@ class BluetoothProxy final : public Component {
void setup() override;
void loop() override;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void register_connection(BluetoothConnection *connection);
#endif // BLUETOOTH_CONNECTION_HAS_GATT
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
#ifndef USE_ESP32
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
// snapshots scan_active()/scan_running() and installs the raw callback, and
@@ -120,6 +120,7 @@ class BluetoothProxy final : public Component {
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
#endif // !USE_ESP32
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg);
@@ -128,6 +129,7 @@ class BluetoothProxy final : public Component {
void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg);
void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg);
void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg);
#endif
void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags);
void unsubscribe_api_connection(api::APIConnection *api_connection);
@@ -137,18 +139,23 @@ class BluetoothProxy final : public Component {
return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12);
}
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
/// False only when a subscriber refused the frame; true = delivered or
/// nobody subscribed. Request-answer callers ignore the result (client
/// timeouts cover those); only reset_connection_slot_ latches for retry.
/// nobody subscribed. Refusals latch in send_device_disconnected_() and
/// send_connected_reply_(); other callers report via log_reply_dropped_().
bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
void send_connections_free();
void send_connections_free(api::APIConnection *api_connection);
/// Same convention as send_device_connection: false only on a refused frame.
bool send_gatt_services_done(uint64_t address);
void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
/// False only when the API refused the frame, so the reply is still owed.
bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK);
void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK);
/// No default error: the drain rebuilds success as (error == CONN_OK), so a
/// caller that omitted it would have a reported failure resent as a success.
void send_device_unpairing(uint64_t address, bool success, conn_err_t error);
void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK);
#endif
void bluetooth_scanner_set_mode(bool active);
@@ -227,30 +234,27 @@ class BluetoothProxy final : public Component {
void flush_pending_advertisements_() {
if (this->response_.advertisements_len == 0)
return;
this->api_connection_->send_message(this->response_);
// Perishable and the highest-frequency send here: a drop only reports at
// V, anything louder would be the flood the batch pacing exists to avoid.
[[maybe_unused]] bool sent = this->api_connection_->send_message(this->response_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
this->log_advertisement_flush_();
this->log_advertisement_flush_(sent);
#endif
this->response_.advertisements_len = 0;
}
void log_advertisement_flush_();
void log_advertisement_flush_(bool sent);
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state);
void log_connection_info_(BluetoothConnection *connection, const char *message);
#endif
void log_not_connected_gatt_(const char *action, const char *type);
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
/// Keep the pre-allocated connections-free message in step when a
/// connection slot changes address (0 = free). Called from the connection
/// classes' set_address().
// maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the
// body is removed, and the free < MAX compare would trip -Wtype-limits.
void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) {
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
void update_address_slot_(uint64_t old_address, uint64_t new_address) {
auto &resp = this->connections_free_response_;
if (new_address == 0 && old_address != 0) {
if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
@@ -267,7 +271,6 @@ class BluetoothProxy final : public Component {
}
this->replace_allocated_slot_(0, new_address);
}
#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
}
void replace_allocated_slot_(uint64_t find_value, uint64_t set_value);
void log_slot_accounting_mismatch_();
@@ -279,21 +282,48 @@ class BluetoothProxy final : public Component {
void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason);
/// Drop any owed freed-slot notification for this address (client reconnected).
void clear_pending_disconnection_(uint64_t address);
/// Send connected=false and pool it for the paced drain if refused. A
/// dropped disconnect desynchronises the proxy: the client keeps a link it
/// believes is live and every operation on it times out. Unsolicited and
/// drained notifications only; request answers use the variant below.
void send_device_disconnected_(uint64_t address, conn_err_t error = CONN_OK);
/// Answer a request with connected=false. Never pools: a refusal falls back
/// to the client's request timeout, keeping the pool for the unsolicited
/// notifications the client cannot recover on its own.
void answer_device_disconnected_(uint64_t address);
/// Pool a refused freed-slot notification for the paced drain.
void latch_pending_disconnection_(uint64_t address, conn_err_t error);
#endif
/// Drop everything the ending session was owed. One list, so a new latch is
/// one edit rather than two call sites where an omission looks deliberate.
/// Drops state only, never sends: api_connection_ is the departing
/// subscriber on subscribe and nullptr on unsubscribe.
void reset_owed_replies_();
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
/// Report a reply we deliberately do not latch, so no drop is silent.
void log_reply_dropped_(const char *what, uint64_t address);
/// A latched reply's leading edge; the drain's re-refusals stay quiet.
void log_reply_deferred_(const char *what, uint64_t address);
/// A latched reply lost to a newer one for a different address.
void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address);
#endif
// Memory optimized layout for 32-bit systems
// Group 1: Pointers (4 bytes each, naturally aligned)
api::APIConnection *api_connection_{nullptr};
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// Group 2: Fixed-size array of connection pointers
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
// Address-keyed pool of owed freed-slot notifications; loop() resends.
// Proxy-only state, kept off BluetoothConnection; entries are not tied to
// slot indices.
std::array<PendingDisconnect, BLUETOOTH_PROXY_MAX_CONNECTIONS> pending_disconnections_{};
std::array<PendingReply, BLUETOOTH_PROXY_MAX_CONNECTIONS> pending_disconnections_{};
// Owed unpair reply. The bond is already gone when the send is refused, so
// a retry is told the unpair failed when it succeeded. One slot: a second
// refused unpair displaces the first, as happened to both before this.
PendingReply pending_unpairing_{};
#endif
ble_device_base::BLEHub *hub_{nullptr};
// Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below
@@ -303,17 +333,27 @@ class BluetoothProxy final : public Component {
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// Pre-allocated response message - always ready to send
api::BluetoothConnectionsFreeResponse connections_free_response_;
#endif
// Group 4: 1-byte types grouped together
bool active_;
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
// A dropped send (full TCP buffer) would leave the API client with a stale
// slot state forever; the cached response is current by construction, so
// retrying it from loop() is an idempotent resync.
bool connections_free_pending_{false};
uint8_t connection_count_{0};
#endif
bool configured_scan_active_{false}; // Configured scan mode from YAML
#ifdef USE_WIFI
/// Wi-Fi only: flush on every other non-empty tick (~200 ms) so partial
/// batches fill; an idle tick re-arms, so the first batch after a gap
/// still ships on the next tick. See loop().
bool adv_flush_toggle_{false};
#endif
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// A dropped push (full TX buffer) is re-queried from the hub and resent
// from loop(); the hub's current state is idempotent by construction.
+22 -4
View File
@@ -3,6 +3,9 @@ import esphome.codegen as cg
from esphome.components import i2c, time
import esphome.config_validation as cv
from esphome.const import CONF_DURATION, CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
@@ -35,7 +38,12 @@ CONFIG_SCHEMA = (
),
synchronous=True,
)
async def bm8563_write_time_to_code(config, action_id, template_arg, args):
async def bm8563_write_time_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -52,7 +60,12 @@ async def bm8563_write_time_to_code(config, action_id, template_arg, args):
),
synchronous=True,
)
async def bm8563_start_timer_to_code(config, action_id, template_arg, args):
async def bm8563_start_timer_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32)
@@ -70,13 +83,18 @@ async def bm8563_start_timer_to_code(config, action_id, template_arg, args):
),
synchronous=True,
)
async def bm8563_read_time_to_code(config, action_id, template_arg, args):
async def bm8563_read_time_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
+2 -1
View File
@@ -3,6 +3,7 @@ from esphome.components import esp32, i2c
from esphome.components.const import CONF_STATE_SAVE_INTERVAL
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework
from esphome.types import ConfigType
CODEOWNERS = ["@trvrnrth"]
DEPENDENCIES = ["i2c"]
@@ -76,7 +77,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
+4 -2
View File
@@ -29,6 +29,8 @@ from esphome.const import (
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import CONF_BME680_BSEC_ID, SAMPLE_RATE_OPTIONS, BME680BSECComponent
@@ -110,7 +112,7 @@ CONFIG_SCHEMA = cv.Schema(
)
async def setup_conf(config, key, hub):
async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None:
if sensor_config := config.get(key):
sens = await sensor.new_sensor(sensor_config)
cg.add(getattr(hub, f"set_{key}_sensor")(sens))
@@ -120,7 +122,7 @@ async def setup_conf(config, key, hub):
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_BME680_BSEC_ID])
for key in TYPES:
await setup_conf(config, key, hub)

Some files were not shown because too many files have changed in this diff Show More