Compare commits

...
Author SHA1 Message Date
J. Nick Koston b1dee3f86a Keep shared state filters as forwarders; when is for one-off tests 2026-10-15 14:16:47 -10:00
J. Nick Koston ef13b8b3d2 [core] Document filtering callback triggers with when in AGENTS.md 2026-10-15 14:10:14 -10:00
Kevin Ahrendt 6817960365 [audio] Deprecate AudioSourceTransferBuffer (#20801) 2026-10-15 13:38:03 -10:00
Kevin Ahrendt cfdaf958e9 [audio][i2s_audio] Fix 16-bit mono playback at 44.1 kHz on the original ESP32 (#20806) 2026-10-15 13:32:35 -10:00
1e89abdb13 [http_request] Allow a per-request timeout (#20821)
Co-authored-by: p1ngb4ck <kleinecke.oliver@googlemail.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-15 13:17:46 -10:00
J. Nick Koston c838d4826e [core] Add ESPHOME_strstr_P (#20811) 2026-10-15 23:09:02 +00:00
J. Nick Koston 69215deb58 [core] Make the YAML load listener hook public (#20805) 2026-10-15 12:54:34 -10:00
J. Nick Koston 5b234bcc20 [wifi] Leave the cooldown from the loop after a credential save, not from the caller's context (#20802) 2026-10-15 12:53:51 -10:00
Panoplianandclydebarrow c101b3c101 [mipi_spi] Honour show_test_card when pages or a lambda are set (#20807)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-10-15 22:38:28 +00:00
Anton ViktorovandJ. Nick Koston c0d8a0dfe9 [as734x] [3/3] Add illuminance, photopic irradiance, PAR, PPFD, colour temperature and RGB (#18012)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-15 11:38:15 -10:00
J. Nick Koston 27c6b7caf5 [ci] Add hardware-test agent skill (#20817) 2026-10-15 21:36:36 +00:00
699e3bd037 [usb_uart] Runtime modem pin control for USB and host (#20330)
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>
2026-10-15 11:03:27 -10:00
Anton ViktorovandJ. Nick Koston a58ce2d641 [as734x] [2/3] Add basic counts, calibration and saturation level (#18011)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-15 10:48:18 -10:00
Remco van EssenandClaude Opus 5.5 27d8d49b17 [dfrobot_sen0609] Add number, switch and select settings (#20813)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:41:20 -10:00
Remco van Essen 304627efd3 [ld2410][ld2412] Move CONF_OUT_PIN_LEVEL to the shared constants (#20820) 2026-10-15 10:34:00 -10:00
7ed06e0ada [as734x] [1/3] Add AS7341/AS7343/TCS3448 spectral sensor hub with counts sensors (#17038)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-15 10:19:56 -10:00
dependabot[bot] 7e564c12dd Bump py7zr from 1.1.3 to 1.1.4 (#20816)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-15 09:58:07 -10:00
dependabot[bot] 6f6fdd0f8a Bump actions/create-github-app-token from 3.2.0 to 3.3.0 (#20815)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-15 09:55:47 -10:00
45ea5f4220 [nrf52] nrf52840dongle support (#19009)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-15 09:03:53 -10:00
techdregsandJ. Nick Koston 108ed67d31 [rx8111] Add basic RX8111CE real-time clock support (#20785)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-15 08:30:25 -10:00
Remco van EssenandClaude Opus 5.5 6356458081 [dfrobot_sen0609] Add DFRobot SEN0609/SEN0610 radar (#20809)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:27:57 +00:00
Panoplian bf67b49104 [i2c] Let host unit tests with I2C build on macOS (#20738) 2026-10-15 07:47:52 -10:00
Panoplian 1947c3d355 [improv_ble] Start Improv again after a session has stopped (#20808) 2026-10-15 07:47:14 -10:00
J. Nick Koston cf4e69273c [shelly_dimmer] Keep the STM32 device table in flash (#20643) 2026-10-15 07:28:55 -10:00
J. Nick Koston c433e96734 [homeassistant] Share the Python list parser between the platforms (#20793) 2026-10-15 07:28:27 -10:00
J. Nick Koston 93dc80d895 [esp8266] Refuse two components that drive hardware timer 1 (#20794) 2026-10-15 07:28:07 -10:00
J. Nick Koston 7f342054fe [wifi] Retry a failed fallback AP once per ap_timeout and start the captive portal once (#20784) 2026-10-15 07:27:19 -10:00
Jeroenandjeroen85 d924d2daf7 [core] Extend fast value formatting to four decimal places (#20800)
Co-authored-by: jeroen85 <26403565+jeroen85@users.noreply.github.com>
2026-10-15 07:15:11 -10:00
Clyde Stubbs 49b12daa58 [epaper_spi] Ensure test card gets drawn (#20804) 2026-10-15 10:55:19 +00:00
Clyde Stubbs d3faafc578 [display] Always treat 888 pixel blocks as b,g,r in memory (#20799) 2026-10-15 20:49:50 +11:00
Remco van Essen 7b08e738d3 [fusb302b] Add sensors, contract text sensor and automations (#20796) 2026-10-14 22:13:58 -10:00
J. Nick Koston 62e3c25c83 [lvgl] Fix the menu test after lambdas gained a line reset (#20798) 2026-10-14 21:50:47 -10:00
6a50f5c166 [apc_proteous] Cover component for APC Proteous gate motors (#13403)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-15 17:46:40 +11:00
Clyde Stubbs bb0837d814 [lvgl] Implement menu widget (#18022) 2026-10-15 17:44:23 +11:00
Bonne Egglestonandclydebarrow ecd5980c5d [epaper_spi] Pass bits per pixel and plane count to the base constructor (#20683)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-10-15 17:41:37 +11:00
8d963bde5b [openthread] Add ESP32 OpenThread Border Router support (#18104)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 20:02:33 -10:00
J. Nick Koston ea6d5499b3 [lsm6ds] Add the LSM6DSO and match all chips to their datasheets (#20733) 2026-10-14 19:24:00 -10:00
41e82b0295 [hdmi_cec] Add HDMI-CEC bus component (part 1: bus core) (#9189)
Co-authored-by: Stéphane Lepin <stephane.lepin@gmail.com>
Co-authored-by: Stéphane Lepin <Palakis@users.noreply.github.com>
Co-authored-by: Niko <nikokirste@hotmail.de>
Co-authored-by: Minims <github@minims.fr>
Co-authored-by: Nicholas Pace <segfly@users.noreply.github.com>
Co-authored-by: Nicholas Pace <3300476+segfly@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-15 04:16:09 +00:00
J. Nick Koston 39705a65e3 [bmi270] Keep the range name and scale tables in flash (#20715) 2026-10-14 17:44:46 -10:00
J. Nick Koston a522553e82 [lsm6ds] Keep the range and chip names in flash (#20718) 2026-10-14 17:43:54 -10:00
J. Nick Koston a07a1ab728 [qmi8658] Keep the range name tables in flash (#20714) 2026-10-14 17:43:34 -10:00
J. Nick Koston 9a5d2d9753 [api] Encode action strings straight from flash (#20425) 2026-10-14 17:38:39 -10:00
J. Nick Koston 3188e0d36f [ota] Fall back to a UDP data phase when the link is lossy (#20498) 2026-10-15 16:30:56 +13:00
Jonathan Swoboda f3eaf698d6 [mipi_rgb] Rotate pure RGB panels in software (#20757) 2026-10-14 22:05:01 -04:00
J. Nick Koston f7057e1270 [tests] Reset saved preferences before every C++ unit test (#20771) 2026-10-14 16:01:45 -10:00
J. Nick Koston 6edfbb594d [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) 2026-10-14 22:00:07 -04:00
J. Nick Koston 22defcd704 [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) 2026-10-14 21:59:56 -04:00
Jonathan Swoboda 0f408234d0 [core] Add ksz8851snl to the static analysis manifest for IDF 6 (#20788) 2026-10-14 21:53:17 -04:00
Jonathan Swoboda 92e0e6db6b [wifi] Call esp_supp_dpp_init without a callback on ESP-IDF 6 (#20789) 2026-10-15 01:40:07 +00:00
908c2f6778 [wifi] Add Wi-Fi Easy Connect (DPP) provisioning on the ESP32 (#17934)
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>
2026-10-14 14:29:47 -10:00
Keith BurzinskiandClaude Opus 5.5 af709ac9a5 [openthread] Fail instead of aborting when NVS is unavailable (#20717)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:30:36 -10:00
Panoplian fe57eef3f6 [core] Test that includes inside a package resolve next to the package (#20739) 2026-10-14 13:28:01 -10:00
d0b9f195b0 [wifi] Apply reboot_timeout with a fallback AP unless a station is connected to it (#9958)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 13:23:37 -10:00
eafeef7cb8 [fusb302b] Add FUSB302B driver (#20760)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 13:18:20 -10:00
471774cdbf [ecocomfort2] Fix the clock sync and read boost from the unit state (#20782)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-14 13:14:04 -10:00
Kevin AhrendtandJ. Nick Koston 4df6291feb [api][sendspin] Add the Sendspin client id to the capabilities message (#20775)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 22:56:51 +00:00
d8ca4a9460 [audio_dac] Add ES9038Q2M Katana platform (#15017)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 22:50:41 +00:00
48a0a41cce [wifi] Implement access point WISP mode (#13633)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 12:49:37 -10:00
esphome[bot] 0e2c4cf5c2 Bump aioesphomeapi from 46.11.0 to 46.12.0 (#20781)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-14 12:43:07 -10:00
ea76073d86 [micro_wake_word] Allow selecting the inference task core (#20126)
Co-authored-by: n-IA-hane <n-IA-hane@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 12:42:24 -10:00
J. Nick Koston dcb943ded3 [noise] Initialize the protocol id's reserved field for ESP-IDF 6.1 (#20779) 2026-10-14 11:45:54 -10:00
dependabot[bot] 2c6f59738c Bump filelock from 4.1.0 to 4.1.1 (#20778)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 21:41:28 +00:00
puddlyandJ. Nick Koston 04715fb32b [usb_uart] Expose interface_class and port_number (#20659)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 21:41:12 +00:00
nullable.ethandJ. Nick Koston 3df9360876 [i2s_audio] Add keep_alive option to speaker to prevent pop/click on audio start (#15565)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 11:39:30 -10:00
dependabot[bot] 61f3de0bc0 Bump pylint from 4.1.2 to 4.1.3 (#20773)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 21:30:26 +00:00
dependabot[bot] cd97c9a9cb Bump platformdirs from 4.12.4 to 4.13.0 (#20772)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 21:29:17 +00:00
J. Nick Koston d3668a7186 [mqtt] Match subscription topics in a loop instead of recursing per character (#20767) 2026-10-14 11:13:32 -10:00
J. Nick Koston ac74796263 [friedrich] Clear saved preferences in the test fixture (#20770) 2026-10-14 10:52:13 -10:00
c56bb14037 [mqtt] Print crash info over MQTT (#17935)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 10:39:55 -10:00
J. Nick Koston a4278b8dec [ci] Run benchmarks when a component the Python import benchmark imports changes (#20763) 2026-10-14 10:35:33 -10:00
Panoplian 6d173c7371 [bq25186] Set i2c_id in the component tests (#20742) 2026-10-14 10:35:01 -10:00
esphome[bot] 1779b011c9 Bump aioesphomeapi from 46.10.0 to 46.11.0 (#20766)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-14 20:21:45 +00:00
PanoplianandJ. Nick Koston 3c4cf50cb6 [core] Point compile errors after a lambda at the right line (#20740)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 10:02:51 -10:00
J. Nick Koston e869d1f7ab [ci] Give the BK72xx clang-tidy environment the static allocation flag the real build has (#20764) 2026-10-14 09:56:09 -10:00
e1a996435d [ld2450] Add polygon zones evaluated on the device (#20154)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 19:29:16 +00:00
Jonathan Swoboda c5a624a1f2 [tests] Count OTA key probe retries on a fake clock (#20761) 2026-10-14 15:21:35 -04:00
Remco van Essen 15fb7f775e [tmc2209] Add chopper and PWM tuning actions (#20753) 2026-10-14 08:59:21 -10:00
J. Nick Koston 3fa72e61c6 [mqtt] Build the mqtt triggers with build_trigger_callback (#20433) 2026-10-14 08:55:39 -10:00
Kevin Ahrendt 163b09a473 [microphone] Speed up MicrophoneSource audio conversion (#20755) 2026-10-14 08:47:25 -10:00
J. Nick Koston 7afc2768f8 [esp8266] Check that every component's saved values fit together (#20686) 2026-10-14 08:45:51 -10:00
56f1a2bf46 [midea_portasplit] Add Midea PortaSplit climate component (#18033)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 08:45:21 -10:00
Panoplian fee47fe2f1 [pcf85063] Convert the weekday to and from the chip's 0-6 range (#20736) 2026-10-14 08:43:23 -10:00
Panoplian b994e081c3 [bm8563] Convert the weekday to and from the chip's 0-6 range (#20737) 2026-10-14 08:42:47 -10:00
Jonathan Swoboda a1f740b71b [lvgl] Keep LV_CONF_PATH one argument when the build path has a space (#20758) 2026-10-14 08:36:17 -10:00
Remco van EssenandClaude Opus 5.5 7f144365ba [fusb302b] Add USB PD sink protocol (#20632)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:35:29 -10:00
Marcus VoßandJ. Nick Koston 928459d16f [cse7766] Keep last power reading between measurements at low load (#20631)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 08:33:11 -10:00
Jonathan Swoboda 9163359445 [core] Analyze the ethernet component under ESP-IDF (#20745) 2026-10-14 08:31:39 -10:00
Jonathan Swoboda 67307f0eeb [ci] Select the feature-gated files the platform clang-tidy jobs were missing (#20743) 2026-10-14 08:31:06 -10:00
91e0edc11d [pn532] Fix NDEF parsing greater than 255 bytes (#18188)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 08:15:48 -10:00
Jonathan Swoboda 9fe75e262f [ci] Add a host clang-tidy environment and job (#20747) 2026-10-14 08:09:53 -10:00
Kevin Ahrendt 0132f722a3 [audio] Bump esp-audio-libs to v4.1.0 (#20754) 2026-10-14 13:24:36 -04:00
Jonathan Swoboda 7d7a78308d [ci] Test tinyusb_keyboard in isolation (#20749) 2026-10-14 10:05:02 -04:00
J. Nick Koston 9c568525b0 [core] Add CodSpeed benchmarks for importing entity components (#20648) 2026-10-14 09:08:41 -04:00
J. Nick Koston 10f42e3e7c [shelly_dimmer] Build the STM32 firmware update path in CI (#20677) 2026-10-14 09:03:34 -04:00
J. Nick Koston 25d4b88605 [nfc] Add unit tests for the URI record prefix codes (#20724) 2026-10-14 09:01:59 -04:00
J. Nick Koston d6abf35138 [sx126x][sx127x] Stop the watchdog reset loop when a packet is sent before setup (#20727) 2026-10-14 09:01:10 -04:00
J. Nick Koston 66ea99ce65 [seeed_mr60fda2] Compile without binary sensors or selects (#20726) 2026-10-14 09:00:08 -04:00
J. Nick Koston d9298cb7b9 [seeed_mr60bha2] Compile without sensors or binary sensors (#20729) 2026-10-14 08:59:27 -04:00
J. Nick Koston 34e1cf96d5 [bmi270] Build the component on ESP8266 in CI (#20731) 2026-10-14 08:58:56 -04:00
56cc03c4d4 [haier] Haier Select component added (#16148)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Pavlo Dudnytskyi <Pavlo.Dudnytskyi@addsecure.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 23:27:18 -10:00
35b416c6c0 [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 22:19:06 -10:00
Petar Petrov 7f2c3ba3f5 [display] Let other components draw into a display (#20622) 2026-10-14 06:11:47 +00:00
Keith BurzinskiandClaude Opus 5.5 a97604d574 [esp32_ble] Rely on preferences for NVS init (#20708)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:10:51 -10:00
Petar Petrov 1146b77836 [socket] Add TcpClientLink::connecting() (#20722) 2026-10-13 19:19:17 -10:00
J. Nick Koston 1338902ac5 [heatpumpir] Link only the configured protocol classes (#20707) 2026-10-14 17:06:22 +13:00
60a1d70805 [core] Move deferred include processing to parse_yaml (#16344)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 17:46:54 -10:00
Ryan LudwigandCursor 887d4c3a83 [esp32_can] Add NO_ACK mode (#20706)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 17:36:15 -10:00
J. Nick Koston c1fa73470e [display] Walk the blit fallback in frame buffer order and resolve the bitness once (#20651) 2026-10-13 17:31:13 -10:00
J. Nick Koston e41d16ba7e [haier] Keep the alarm message table in flash (#20641) 2026-10-14 03:23:28 +00:00
J. Nick Koston 5171113c87 [core] Let PROGMEM_STRING_TABLE hold more than 255 bytes (#20646) 2026-10-13 17:17:48 -10:00
techdregsandJ. Nick Koston 5718e4b779 [tmp102] Add text sensor for diagnostics (#20629)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 17:06:56 -10:00
J. Nick Koston 40e12cd0d8 [image] Add CodSpeed benchmarks for image, runtime image and display block draws (#20649) 2026-10-13 17:01:47 -10:00
Brandon HarveyandJ. Nick Koston 07029d0077 [sen6x] Add measurement control and maintenance actions (#18783)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 15:47:31 +13:00
Christopher Reimer bb1119d83f [esp32_can] Support 20 kbit/s on ESP32 revision 2 or later (#20426) 2026-10-13 22:34:03 -04:00
7b591b26d6 [sy6970] Service the I2C watchdog so charge/LED settings persist (#19424)
Co-authored-by: Willem Oldemans <>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 16:19:58 -10:00
f45e6f0bf4 [midea] Add display_control and send the display toggle over UART without a transmitter (#18671)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 16:03:18 -10:00
Keith BurzinskiandClaude Opus 5.5 4b8af08155 [network] Notify listeners when the active interface changes (#20684)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:57:59 -05:00
613973b432 Bump ruff from 0.16.10 to 0.17.0 (#20694)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-13 21:53:44 -04:00
dependabot[bot] 9ee4314bc2 Bump esptool from 5.4.0 to 5.5.0 (#20693)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 15:41:30 -10:00
fredkotzeandJ. Nick Koston 59f55dbe9e [epaper_spi] Add Waveshare 2.13in-G (JD79660) display models (#18141)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 15:40:28 -10:00
dependabot[bot] 8fce95454a Bump github/codeql-action/init from 4.38.2 to 4.38.3 (#20701)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 15:39:49 -10:00
dependabot[bot] 16134ee993 Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 (#20704)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 15:39:38 -10:00
dependabot[bot] bc39d56bb4 Bump CodSpeedHQ/action from 5.4.0 to 5.4.2 (#20705)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 15:39:29 -10:00
dependabot[bot] c5522abc71 Bump github/codeql-action/analyze from 4.38.2 to 4.38.3 (#20699)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 21:39:11 -04:00
dependabot[bot] 7cb5906d2a Bump actions/upload-artifact from 7.0.1 to 7.0.2 (#20702)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 01:38:42 +00:00
dependabot[bot] d452dbce30 Bump actions/download-artifact from 8.0.1 to 8.0.2 (#20703)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 01:38:39 +00:00
dependabot[bot] 81e5b50087 Bump backports-zstd from 1.7.0 to 1.8.0 (#20695)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 15:38:35 -10:00
dependabot[bot] 426a4a2dce Bump filelock from 4.0.12 to 4.1.0 (#20696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 15:38:26 -10:00
dependabot[bot]andesphome[bot] c95c17c4e7 Bump pyupgrade from 3.21.2 to 3.22.0 (#20698)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-13 15:38:17 -10:00
dependabot[bot] 576d3325fe Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 in /.github/actions/restore-python (#20700)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 15:37:47 -10:00
dependabot[bot] c9cf0ad92e Bump platformdirs from 4.12.3 to 4.12.4 (#20697)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 21:37:36 -04:00
Felipe Guedes f505212e64 [epaper_spi] Add Waveshare 2.66" e-Paper Module (B) support (#20546) 2026-10-13 15:03:05 -10:00
J. Nick Koston 52a5e6a007 [rpi_dpi_rgb] Set removal of the deprecated component for 2027.5.0 (#20691) 2026-10-14 13:32:18 +13:00
J. Nick Koston 3b261d2cf0 [st7789v] Set removal of the deprecated component for 2027.5.0 (#20689) 2026-10-14 13:30:52 +13:00
J. Nick Koston 9238fcc727 [st7735] Set removal of the deprecated component for 2027.5.0 (#20688) 2026-10-14 13:30:21 +13:00
J. Nick Koston 96902cc122 [qspi_dbi] Set removal of the deprecated component for 2027.5.0 (#20690) 2026-10-14 13:29:52 +13:00
J. Nick Koston 95711d83d1 [ili9xxx] Set removal of the deprecated component for 2027.5.0 (#20687) 2026-10-14 13:29:29 +13:00
Panoplian 3cdcf0417a [lvgl] Make the list_outside_block tests work offline (#20676) 2026-10-13 13:31:08 -10:00
J. Nick Koston c6e90de262 [ci] Catch out-of-class member log tags and keep midea's in flash (#20674) 2026-10-14 12:21:24 +13:00
J. Nick Koston 92f6ab8367 [ci] Lint log tags in headers and core (#20668) 2026-10-14 12:21:24 +13:00
J. Nick Koston f61955baa2 [core] Declare the remaining log tags with ESPHOME_LOG_TAG (#20666) 2026-10-14 12:21:23 +13:00
J. Nick Koston 36e1d831d3 [core] Add a decimal fast path to parse_number<float> (#20639) 2026-10-14 12:18:35 +13:00
J. Nick Koston 63616bf642 [rtttl] Play a configured song from static storage without copying it (#19569) 2026-10-14 12:06:27 +13:00
d275926f65 [mdns] Advertise the connected interface in the network TXT record (#20673)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 17:55:22 -05:00
Andrej Friesen f265c46c21 [PCA9505] Add PCA9505/PCA9506 40-channel GPIO expander (#20667) 2026-10-13 12:50:43 -10:00
Frédéric MetrichandClaude Opus 5.5 7d735f0677 [emontx] Lock the emonTx on reboot when its firmware supports it (#20621)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:37:09 -10:00
Remco van Essen 5c23a827b6 [tmc2209] Add current and diagnostic sensors (#20638) 2026-10-13 12:35:55 -10:00
Jonathan Swoboda e265c0c0fc [core] Sync the static analysis dependency pins with the code (#20681) 2026-10-13 12:30:42 -10:00
J. Nick Koston a0c7d754cb [syslog] Keep the log level to severity table in flash (#20662) 2026-10-13 12:22:35 -10:00
Jesse Hills a6dbd72c53 Merge branch 'beta' into dev 2026-10-14 11:15:09 +13:00
Jesse Hills 9d530f5c56 Merge pull request #20680 from esphome/bump-2026.10.0b4
2026.10.0b4
2026-10-14 11:14:49 +13:00
Panoplian 6562c49686 [thermostat] Allow a dry-only thermostat without cool_action (#20682) 2026-10-13 12:08:13 -10:00
guillempages a97ce3a201 [image] Fix pillow deprecation warning (#20679) 2026-10-13 11:58:21 -10:00
Jesse Hills 5a78f636b9 Bump version to 2026.10.0b4 2026-10-14 10:43:50 +13:00
tomaszduda23andClaude 53d504137b [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 10:43:49 +13:00
bb0546fc0b [script] Keep the running script.wait item out of the queue while its chain runs (#20570)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 10:43:49 +13:00
emes30andJ. Nick Koston b43630cf8e [rp2_pio] Use pioasm 4.1.0 so PIO programs run on RP2350 (#19260)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 10:43:49 +13:00
Firat HajiyevandFirat17aze c9664d4e42 [wifi] Reject unsupported WPA3 on ESP8266 (#20592)
Co-authored-by: Firat17aze <187820330+Firat17aze@users.noreply.github.com>
2026-10-14 10:43:49 +13:00
Kevin Ahrendt 39ea6a3220 [audio] Bump microFLAC to v0.3.0 (#20591) 2026-10-14 10:43:49 +13:00
J. Nick Koston f4a6d6f18b [core] Create the PlatformIO HTTP cache dir before the prefetch resolve threads run (#20579) 2026-10-14 10:43:49 +13:00
esphome[bot] 6c24d4aa43 Bump aioesphomeapi from 46.9.0 to 46.10.0 (#20563) 2026-10-14 10:43:49 +13:00
J. Nick Koston 0dc1d3b5a1 [toshiba] Move the lookup tables to PROGMEM (#20636) 2026-10-13 11:25:18 -10:00
tomaszduda23andClaude 42341d05f9 [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 11:12:08 -10:00
J. Nick Koston f941fee5ed [homeassistant] Add sensor platform benchmarks (#20637) 2026-10-13 11:10:14 -10:00
Bonne Eggleston 8721976066 [epaper_spi] Abandon an update when the busy pin never clears (#20555) 2026-10-14 07:47:09 +11:00
Bonne Eggleston 05bf7d3440 [epaper_spi] Write pixel blocks straight into mono buffers (#20552) 2026-10-14 07:40:56 +11:00
918b7d2553 [script] Keep the running script.wait item out of the queue while its chain runs (#20570)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 09:31:19 -10:00
rwrozelleandClaude Opus 5.5 417ab4d2e9 [openthread] Add core_options for OpenThread compile-time options (#20595)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:00:45 -04:00
Jonathan Swoboda 0821c84b7a [core] Let a nested repeat shadow the outer iteration variable (#20634) 2026-10-13 14:35:24 -04:00
Clyde Stubbs a827058928 [lvgl] Change deprecated calls to current version (#20620) 2026-10-13 14:27:50 -04:00
J. Nick Koston 3063b76210 [haier_tundra] Build the IR frame on the stack (#20635) 2026-10-13 14:25:36 -04:00
Remco van EssenandClaude Opus 5.5 55261ae7e5 [tmc2209] Add driver diagnostics and stall detection (#20618)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:17:25 -10:00
cociwebandJ. Nick Koston 1d8423edd8 [es8388] Add initial option configuration for DAC output and ADC input mic (#16342)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 08:14:43 -10:00
n-IA-haneandn-IA-hane d27ec8ec49 [voice_assistant] Make TTS playback start timeout configurable (#18707)
Co-authored-by: n-IA-hane <n-IA-hane@users.noreply.github.com>
2026-10-13 08:13:26 -10:00
Ivan KruglovandJ. Nick Koston 5acbd44067 [pcf8574] Add reset option to preserve outputs across MCU reboots (#20589)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 08:12:28 -10:00
40271fb597 [haier_tundra] New Climate IR component (#20469)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 08:10:36 -10:00
95766ed3ad [icm20689] Add ICM-20689 6-axis accelerometer/gyroscope (SPI) (#19234)
Co-authored-by: Ludovic BOUÉ <938089+lboue@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 08:07:32 -10:00
4a80d785a8 [filter_lifetime] Add component with sensor/lambda support for is_on … (#13914)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 07:52:46 -10:00
J. Nick Koston c1c1d2b2ae [core] Add probe_ota_key, a Noise handshake with the device that sends no image (#20601) 2026-10-13 07:28:32 -10:00
Mat931andJonathan Swoboda 29a668af04 [esp32_camera] Fix compile warning (#20628)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-13 14:01:17 +00:00
J. Nick Koston fd541bca2c [core] Document constexpr constants, per-model schemas and isolated build tests in AGENTS.md (#20617) 2026-10-13 08:20:32 -04:00
J. Nick Koston 192ae31e45 [tests] Turn off the Hypothesis deadline for the MAC address tests (#20614) 2026-10-13 08:19:10 -04:00
Frédéric MetrichandClaude Opus 5.5 77c9fa2fa4 [emontx] Read the emonTx firmware version (#20619)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:43:02 -10:00
guillempagesandJ. Nick Koston 25843c5242 [image] Implement RGB111 format (#20114)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 22:16:44 -10:00
7881dfc5d8 [deep_sleep] Reject wakeup_pin configs that fail after validation (#18616)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:29:42 +11:00
e289a94451 [friedrich] New Climate IR component (#9178)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 17:11:21 -10:00
5017165800 [selec_meter] Add EM4M (3-phase) support via model option (#17782)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 02:58:25 +00:00
8acf885241 [bq76972] Add BQ76972 BMS chip monitoring component (#17720)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 16:43:51 -10:00
f829447b2e [tmp102] Add number entities for runtime thermostat thresholds (#20596)
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>
2026-10-12 16:25:49 -10:00
74e96b7da6 [INA3221] Add optional flags to expose all functionality (#16309)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 16:21:00 -10:00
753c10e5ea [ld2410s] Add the distance sensor, firmware version and common parameters (Part 2) (#8486)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 16:06:45 -10:00
J. Nick Koston 98d3d23ce1 [ecocomfort2] Test the component in isolation (#20613) 2026-10-12 15:59:25 -10:00
Rodrigo MartínandJ. Nick Koston d6e5b9b03b [bq25186] Add the status sensors, buttons, PG/GPO switch and watchdog option (part 2) (#15736)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 15:50:12 -10:00
J. Nick Koston 02287fdf7b [sensor] Share filter_out and throttle_with_priority value tables in flash (#20078) 2026-10-12 15:48:27 -10:00
J. Nick Koston 2001f60954 [core] Keep string constants in apply conditions in flash on ESP8266 (#20107) 2026-10-12 15:48:05 -10:00
J. Nick Koston 5a39097b3a [light] Keep one copy of the color mode lookup table in flash (#20394) 2026-10-12 15:46:41 -10:00
J. Nick Koston fb14db267c [const] Move CONF_SERIAL_NUMBER to the shared component constants (#20611) 2026-10-13 01:45:52 +00:00
J. Nick Koston 36900e36f9 [esp32_power_management] Build the flash power-down path in a standalone test variant (#20612) 2026-10-12 15:45:09 -10:00
a035c17e04 [gt911] Fix the interrupt pin initialisation after reset (#15021)
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>
2026-10-12 15:42:24 -10:00
88fdf822cd [runtime_image] Speed up RGB565 JPEG decoding (#19454)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 15:41:30 -10:00
e7a7aa150d [yaml_util] Raise a clear error for a non-mapping defaults: in a package (#19425)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 15:36:57 -10:00
J. Nick Koston 54f228766c [stcc4] Use SUB_SENSOR for the output sensors (#20605) 2026-10-12 21:12:39 -04:00
Bascht74 58188305c4 [uart] Keep the swapped UART0 pins on an ESP8266 reload (#20463) 2026-10-12 15:09:32 -10:00
J. Nick Koston afedddafe2 [systa_bus] Use SUB_SENSOR and explicit sub_sensors calls for the sensors (#20610) 2026-10-12 20:57:01 -04:00
J. Nick Koston c3c235dea7 [pzem6l24] Use SUB_SENSOR and explicit sub_sensors calls for the sensors (#20609) 2026-10-12 20:56:58 -04:00
J. Nick Koston 39a7f4482a [bh1745] Use SUB_SENSOR for the sensors (#20606) 2026-10-12 20:52:16 -04:00
J. Nick Koston f3f3ca5a8a [runtime_image] Add CodSpeed benchmarks for JPEG decoding (#20603) 2026-10-12 14:50:24 -10:00
Jesse HillsandJ. Nick Koston bdd2dcd8c6 [weikai] Mark configurable classes as final (#17292)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 14:39:08 -10:00
J. Nick Koston a3e373b063 [noise] Add generate_encryption_key and use it in the wizard and dashboard_import (#20600) 2026-10-12 20:31:36 -04:00
dc9a7cffbd [ota] Refuse a second OTA while one is already running (#20588)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 00:28:38 +00:00
4cae63bbaa [ecocomfort2] Add the read-only sensors and clock sync (Part 2) (#15447)
Co-authored-by: gledian <gledina.kruja@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 14:27:16 -10:00
8aab968136 [aranet] Add Aranet BLE sensor support (#18263)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 14:11:10 -10:00
J. Nick Koston 80fa6efd1e [core] Document string tables, shared CRC helpers, NaN sentinels and polling start-up in AGENTS.md (#20599) 2026-10-12 13:44:49 -10:00
J. Nick Koston 3ad83ad02b [text_sensor] Share substitute and map filter tables in flash (#20076) 2026-10-12 13:40:38 -10:00
J. Nick Koston b66cde0270 [sensor] Share calibrate_linear and calibrate_polynomial tables in flash (#20074) 2026-10-12 13:39:38 -10:00
J. Nick Koston 331d12af2f [binary_sensor] Share multi click timing tables in flash and skip the default cooldown setter (#20071) 2026-10-12 13:39:10 -10:00
318e4b79c1 [apc1] Add support for the ScioSense APC1 air quality sensor (UART) (#19274)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-10-12 13:38:34 -10:00
J. Nick Koston 99a4ae1f51 [ota] Add old_key so an upload can retry with the previous key (#19398) 2026-10-12 13:35:27 -10:00
J. Nick Koston 72ca565a0e [core] Resume if and while from the end of their branch instead of an embedded continuation action (#20283) 2026-10-12 13:35:03 -10:00
J. Nick Koston cce435a31e [gpio] Keep each interlock group in one shared flash table (#20238) 2026-10-12 13:34:44 -10:00
J. Nick Koston 6c86a8903f [core] Report incomplete YAML discovery and record remote package sources (#20312) 2026-10-12 13:28:17 -10:00
b21354d6d9 [mistral_ir] Add climate component for Mistral MHFE1126A IR AC units (#17812)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 13:22:34 -10:00
Ilya EsinandJ. Nick Koston fa519cea64 [apds9930] Add ambient light and proximity sensor (#13125)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 13:14:31 -10:00
Remco van EssenandJ. Nick Koston 132e9dac8b [tmc2209] Add TMC2209 stepper driver (#20437)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 13:13:11 -10:00
0c43d689d7 [sh1122] Add OLED display driver (#15295)
Co-authored-by: Nicolas Reinecke <nr@das-labor.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 13:11:51 -10:00
73d5bb3c97 [remko_ar715] Add Remko AR-715 IR climate platform (#17173)
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>
2026-10-12 13:09:40 -10:00
81c275777d [bmi270] Add optional BMM150 magnetometer support via aux interface (#18436)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 13:08:41 -10:00
54fdeb7b1b [samsung] Add climate IR samsung support (#8004)
Co-authored-by: Georgi Filipov <georgi.filipov@uplink.bg>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 13:01:22 -10:00
150e32c4be [renogy_inverter_ble] Add Renogy inverter BLE sensor component (#16754)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 12:56:50 -10:00
1e13b7f973 [tinyusb] Add tinyusb_keyboard component (#16079)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 12:56:28 -10:00
Rodrigo MartínandJ. Nick Koston d08d324a83 [bq25186] Add bq25186 1-Cell, 1A Linear Battery Charger component (part 1) (#15700)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 12:54:35 -10:00
88668f448e [max17048] Add MAX17048 Li+ Cell Fuel Gauge IC (#13585)
Co-authored-by: Rodrigo Martín <contact@rodrigomartin.dev>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 12:53:16 -10:00
c5baae35d5 [ezsp_proxy_tap] Add SiLabs EZSP auto-ACK implementation (#20304)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 12:51:48 -10:00
1c315c3bf2 [ns2009] Add Nsiway NS2009 touchscreen controller component (#10597)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 12:49:07 -10:00
f9f41ef16e [MAX31888] Add temperature sensor (#13567)
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>
2026-10-12 12:45:08 -10:00
a68cf669aa [tca8418] Add TCA8418 keypad component (#18521)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 12:39:11 -10:00
3ddbf8b3ef [lp5562] Add LP5562 4-channel I2C LED driver output component (#18453)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 12:12:11 -10:00
Ryan GammonandJ. Nick Koston b88eafcd00 [bq27220] Add BQ27220 battery fuel gauge component (#17702)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 12:05:34 -10:00
d7dd25a1bc [ld2410s] Add HLK-LD2410S presence radar component (#10452)
Co-authored-by: NovakI <novaki@debian.ka11>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:57:16 -10:00
83e5e32e2b [i2s_clockless_led_strip] Drives LED strips using I2S and DMA (#20255)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:52:05 -10:00
584d4878eb [lis3dh] Add LIS3DH accelerometer component (#17393)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:44:33 -10:00
93183c661e [ecocomfort2] Add component for Fantini Cosmi VMC units via BLE (Part 1: hub + fan) (#17092)
Co-authored-by: gledian <gledina.kruja@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:36:27 -10:00
Anton ViktorovandJ. Nick Koston 5a819f4b48 [bh1745] Add BH1745 colour sensor (#6349)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:34:04 -10:00
Anton ViktorovandJ. Nick Koston 2cd7c0eb36 [husb238] Add HUSB238 USB PD controller hub with binary sensors (#16572)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:31:16 -10:00
1c8808339a [power_management] Add the power_management domain with an esp32_power_management platform (#12325)
Co-authored-by: Kevin Ahrendt <kahrendt@gmail.com>
Co-authored-by: Kevin Ahrendt <kevin.ahrendt@openhomefoundation.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:23:55 -10:00
matt123pandpre-commit-ci-lite[bot] 6bf091104f [es7210] Add TDM support for future AEC support (#18954)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-12 11:22:17 -10:00
Martin KlingensmithandJ. Nick Koston 4e3845ba68 [pd_pioneer_ir] Add Pioneer mini-split IR climate component (#17680)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:20:13 -10:00
0b6856c052 [udp] Add IPv6 unicast and multicast (#20590)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 11:17:58 -10:00
Stephen CoxandJ. Nick Koston ffa849753a [ds3231] Add DS3231 real-time clock hub with temperature sensor (#18883)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:00:05 -10:00
0f087f425a [ld2460] Add HLK-LD2460 radar hub with target sensors (#19002)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 10:52:04 -10:00
833c6634ad [lis2dw12] Add ST LIS2DW12 accelerometer component (#14780)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 09:44:51 +13:00
5a3bee638f [siemens_ira211] Add the Siemens IRA211 protocol and climate platform (#15154)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 09:33:33 +13:00
66ba258baa [udp] Wake the main loop when a datagram arrives (#20585)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 08:53:22 -10:00
216e1bee5c [socket] Read the LwIP interface list under the core lock (#20583)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 18:15:44 +00:00
emes30andJ. Nick Koston e0436e2600 [rp2_pio] Use pioasm 4.1.0 so PIO programs run on RP2350 (#19260)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 18:01:11 +00:00
ca69c33530 [whirlpool] Add the HISENSE_ARG model for Hisense and BGH units sold in Argentina (#17352)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 07:57:23 -10:00
J. Nick Koston aa0bd9af72 [core] Add YAML secret reference and registration helpers (#20343) 2026-10-12 07:55:38 -10:00
Jonathan Swoboda 9c35bb7426 [esp32] Check the TLS option the active IDF version writes in tests (#20584) 2026-10-12 07:48:33 -10:00
J. Nick Koston af8a0739f0 [web_server] Self contained AP mode: embedded interface served as a captive portal (#18517) 2026-10-12 07:46:11 -10:00
99b61760c3 [remote_base] Add the Pioneer WYT transmit protocol (#17175)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 07:37:31 -10:00
db9138c550 [bl0939] Add current transformer measurement mode support (#13350)
Co-authored-by: ShuishengPeng <63339353+ackPeng@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-12 07:34:25 -10:00
J. Nick Koston 5bb1f0f779 [statsd] Use the socket UDP API on ESP8266 and RP2040 (#20557) 2026-10-12 07:17:01 -10:00
J. Nick Koston 4c549d6e92 [e131] Use the socket UDP API on ESP8266 and RP2040 (#20559) 2026-10-12 07:16:47 -10:00
J. Nick Koston dc719b300b [udp] Use the socket UDP API on ESP8266 and RP2040 (#20558) 2026-10-12 07:16:29 -10:00
J. Nick Koston f854da4f8d [cli] Add --prompt-ota-key to present a different key for one upload (#19397) 2026-10-12 07:15:51 -10:00
Firat HajiyevandFirat17aze 2dad6e4f62 [wifi] Reject unsupported WPA3 on ESP8266 (#20592)
Co-authored-by: Firat17aze <187820330+Firat17aze@users.noreply.github.com>
2026-10-12 07:15:13 -10:00
J. Nick Koston 72ea9ae458 [remote_base] Generate the byte payload transmit actions with no C++ class (#20568) 2026-10-12 07:14:19 -10:00
Kevin Ahrendt 9e8845885b [audio] Bump microFLAC to v0.3.0 (#20591) 2026-10-12 11:22:59 -04:00
J. Nick Koston 00872d4336 [ethernet] Skip setters that pass the default PHY address, MDC, MDIO, clock and SPI interface (#20070) 2026-10-12 09:56:53 -04:00
J. Nick Koston 780ccfc462 [adc] Skip the output raw, sample count, attenuation and sampling mode setters when they match the defaults (#20068) 2026-10-12 09:56:24 -04:00
J. Nick Koston 9acc07a973 [remote_base] Generate transmit_pronto and transmit_rc6 with no C++ class (#20564) 2026-10-12 07:48:02 -04:00
J. Nick Koston 698988669d [remote_transmitter] Compile pronto, rc6, midea and lambda byte payloads in the test config (#20565) 2026-10-12 07:37:51 -04:00
J. Nick Koston bde67d83f2 [noise] Exclude the handshake write step from the loop blocking guard (#20573) 2026-10-12 07:33:19 -04:00
J. Nick Koston 057a0c5843 [api] Exclude the spare key refill from the loop blocking guard (#20574) 2026-10-12 07:31:15 -04:00
J. Nick Koston c212950df5 [wifi] Exclude the station connect from the loop blocking guard on LibreTiny (#20575) 2026-10-12 07:30:59 -04:00
J. Nick Koston 3c88d39192 [remote_base] List the hob2hood, nexus and onkyori protocol defines in defines.h (#20577) 2026-10-12 07:28:33 -04:00
J. Nick Koston c0d4287c6e [core] Create the PlatformIO HTTP cache dir before the prefetch resolve threads run (#20579) 2026-10-12 07:28:07 -04:00
BugHaverandJ. Nick Koston 3472ac3dcc [heatpumpir] Add Eco and Boost presets and fix fan speeds on Mitsubishi Heavy ZJ and ZMP (#16877)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 23:44:01 -10:00
5a90bceba0 [cse7766] Clamp a power factor above 1 instead of publishing NaN (#15307)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 23:42:17 -10:00
af76f19ab4 [heatpumpir] Add ZMP IR receiver (#17099)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 23:18:22 -10:00
e92d239996 [dsmr] add Switzerland channel 1-1 sensors (EWL Luzern AM550) (#17391)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 22:37:05 -10:00
602bf00435 [daikin_brc] Add sub_unit parameter for multi-unit addressing (#15234)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-11 22:21:26 -10:00
mfishma 1e05106a78 [mipi_spi] Add M5STACK-ATOMS3R-ST7735 as a display panel configuration (#18673) 2026-10-12 08:01:48 +00:00
marshnandJ. Nick Koston 061b27af02 [remote_base] Add a data option and checksum handling to the Drayton protocol (#13060)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 21:54:55 -10:00
Siddharth SinghandJ. Nick Koston d265ed2b9e [remote_base] Fix NEC command_repeats to send repeat frames (#17663)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 21:29:09 -10:00
20cdca6f18 [remote_base] Decode the full MagiQuest frame and check its checksum (#10622)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 07:01:12 +00:00
toupsandJ. Nick Koston c668ca516c [remote_base] updates to support 56 bit panasonic codes (#20260)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 20:48:55 -10:00
Frédéric MetrichandClaude Opus 5.5 7fbdcb0205 [emontx] Lock the emonTx across reboots (#20576)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:42:52 -10:00
Zach Isbach 92a4e283a9 [epaper_spi] Add Good Display GDEY0213F51 4-color e-paper support (#17991) 2026-10-12 17:40:59 +11:00
Frédéric MetrichandClaude Opus 5.5 ba9061e34e [nextion] Pass std::string to custom-protocol automations that defer (#20571)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:30:53 -10:00
0c645ac3a1 [remote_base] Adding Marantz RC5 Extended protocol (#13164)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 06:24:50 +00:00
J. Nick Koston eb8a793f6e [core] Add a name truncation helper and keep the MAC suffix in the nRF52 BLE name (#19768) 2026-10-11 20:22:11 -10:00
36082b23c2 [remote_base] Add the Somfy RTS transmit protocol (#16502)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 20:11:15 -10:00
esphome[bot] 8191cd18d2 [ci] Refresh integration test durations (#20578) 2026-10-12 06:02:07 +00:00
2692e0f189 [remote_base] Add Nexus RF protocol support (#13577)
Co-authored-by: zry98 <dev@zry.io>
Co-authored-by: Citizen <kvirbickas@gmail.com>
Co-authored-by: Citizen07 <34106434+Citizen07@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Signed-off-by: zry98 <dev@zry.io>
2026-10-11 19:36:07 -10:00
Ivan KruglovandJ. Nick Koston 29db7b3f51 [bl0939] Add optional internal temperature sensor (#19415)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 05:33:44 +00:00
Firat Hajiyev 1c1b4d13ad [core] Mask integer-valued credentials without changing code generation (#20430) 2026-10-11 18:58:41 -10:00
J. Nick Koston f8076fe90b [core] Add UnavoidableBlockingScope for blocking that cannot be shortened (#19020) 2026-10-11 18:48:41 -10:00
19eeab0385 [wifi] Add DHCP lease cache (#17767)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-11 18:48:13 -10:00
3f21ddf6ff [lvgl] Add a color picker widget (#17883)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 15:12:22 +11:00
J. Nick Koston 5c1c946e88 [wake_on_lan] Use the socket UDP API on ESP8266 and RP2040 (#20556) 2026-10-11 18:09:11 -10:00
J. Nick Koston 698935c321 [seeed_mr24hpc1] Remove the duplicate startup delay constant left by the beta merge (#20569) 2026-10-11 17:38:41 -10:00
J. Nick Koston 0c6543c4a6 [remote_base] Name transmit as a template in ProtocolCall::perform (#20566) 2026-10-11 17:38:38 -10:00
4688df3b94 [mcp9808] Remove k7hpn as codeowner (#20561)
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>
2026-10-11 17:06:46 -10:00
esphome[bot] 32f416109e Bump aioesphomeapi from 46.9.0 to 46.10.0 (#20563) 2026-10-12 03:05:16 +00:00
J. Nick Koston 08a55c97b7 [remote_base] Generate the simple transmit actions with no C++ class (#20549) 2026-10-11 17:03:59 -10:00
d19bb7f4be [cse7761] Add current_input option for current transformer boards such as the Sonoff POW CT (#7836)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 17:00:16 -10:00
J. Nick Koston 6095d9d610 [improv_serial] Remove the duplicate timeout id left by the beta merge (#20562) 2026-10-11 16:56:56 -10:00
Jesse Hills 95d9b68ac9 Merge branch 'beta' into dev 2026-10-12 15:42:45 +13:00
Jesse Hills 2c4b1f3ed5 Merge pull request #20542 from esphome/bump-2026.10.0b3
2026.10.0b3
2026-10-12 15:42:25 +13:00
J. Nick KostonandClaude Opus 4.6 847be14d1e [socket] Add lwip raw UDP socket implementation (#14677)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-10-11 16:27:41 -10:00
J. Nick Koston 40606a6c08 [core] Do not search the same heap twice in RAMAllocator single-region requests (#19381) 2026-10-11 16:24:36 -10:00
J. Nick Koston 650a02beb0 [remote_transmitter] Compile the transmit actions the test config left out (#20550) 2026-10-11 22:13:15 -04:00
J. Nick Koston c6ed2069a5 [json][web_server] Link strings that outlive the document instead of copying them (#19380) 2026-10-11 15:55:51 -10:00
J. Nick Koston fa4bc28d3e [text] Build the on_value trigger with build_callback_automation (#19613) 2026-10-11 15:54:44 -10:00
J. Nick Koston 95de93bebb [fan] Build the on_state trigger with build_parent_callback_automation (#19612) 2026-10-11 15:54:25 -10:00
J. Nick Koston 028db614ca [select] Build the on_value trigger with build_callback_automation (#19609) 2026-10-11 15:54:09 -10:00
J. Nick Koston c38674bb7b [climate] Build the on_state and on_control triggers with build_callback_automation (#19611) 2026-10-11 15:53:55 -10:00
Clyde StubbsandClaude Opus 5.5 4ba5afaa1b [core] Move CONF_ON_STOP to the shared component constants (#20548)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:37:17 +00:00
J. Nick Koston 0cd239dfd1 [uart] Keep constant switch, button and write payloads in flash (#20082) 2026-10-12 01:16:34 +00:00
J. Nick Koston ed6ab4565d [core] Emit filter, action and condition lists one item at a time (#20116) 2026-10-11 15:12:59 -10:00
J. Nick Koston 5d64b65d72 [esp32_ble_server] Keep constant set_value payloads in shared flash tables (#20314) 2026-10-11 15:12:09 -10:00
J. Nick Koston 993fd8d7a2 [thermostat] Keep presets in shared flash tables (#20080) 2026-10-11 15:04:16 -10:00
J. Nick Koston 9295342c16 [event] Keep event types in a shared flash table (#20217) 2026-10-11 14:58:49 -10:00
J. Nick Koston e16b3a6245 [api] Share one flash string pool between API action and Home Assistant action strings (#20281) 2026-10-11 14:57:20 -10:00
J. Nick Koston da0c59a57c [http_request] Keep action request headers in a shared flash table (#20225) 2026-10-11 14:57:06 -10:00
J. Nick Koston 40425610d5 [remote_base] Keep constant abbwelcome payloads in shared flash tables (#20311) 2026-10-11 14:55:22 -10:00
Bascht74 643f29fae6 [uart] Let a UART say it has no line timing (#20227) 2026-10-11 14:49:19 -10:00
59d31505e9 [mipi_spi] Add LCDwiki ES3C35P model (ST77922 QSPI) (#19011)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 00:48:47 +00:00
J. Nick Koston e50a0d902e [core] Expose the preferences storage helpers to components (#20535) 2026-10-11 20:42:21 -04:00
Bascht74andpre-commit-ci-lite[bot] 22c17fe7e1 [remote_base] Add optional suffix bits to KeeLoq after the HCS301 word (#19891)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-11 14:36:27 -10:00
techdregs 826a7a252a [tmp102] Add comparator alert binary sensor (#20540) 2026-10-11 14:33:06 -10:00
J. Nick Koston ce19978cfd [ci] Tell too-big PR authors that review waits until the PR is under the cap (#20543) 2026-10-11 14:17:07 -10:00
J. Nick Koston 427e0c9064 [const] Move the colour channel config keys to the shared constants (#20544) 2026-10-11 13:50:50 -10:00
J. Nick Koston 0df7b0b366 [core] Move zstd_module from the api wizard to esphome.helpers (#20342) 2026-10-12 12:46:59 +13:00
Jesse Hills bd4b32873a Bump version to 2026.10.0b3 2026-10-12 11:52:33 +13:00
Bascht74 9e580db434 [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) 2026-10-12 11:52:31 +13:00
Jesse Hills 87187c5391 [api] Let a wizard input pick multiple entities (#20537) 2026-10-12 11:52:31 +13:00
tomaszduda23andClaude 60402614cd [nrf52] fix zigbee time component (#20527)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 11:52:25 +13:00
puddly 2727d35655 [usb_uart] Fix invalid stop bits and parity enum mappings (#20408) 2026-10-12 11:51:44 +13:00
J. Nick Koston fb1ef29d54 [uart] Stop ESP8266 software serial RX ISR from blocking for a whole byte (#18519) 2026-10-12 11:51:39 +13:00
Frédéric MetrichandClaude Opus 5.5 7c0f63ea20 [core] Copy trigger arguments in wait_until and script.wait (#20536)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
heikkikiiandJ. Nick Koston 5d75828ceb [seeed_mr24hpc1] Wait for the module to boot before sending UART traffic (#18860)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
bd2e8d184d [gree] Keep display light on by default for YAN/YAA/YAC/YAC1FB9 (#18516)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
J. Nick Koston 3c7ce71d6f [uart_tcp] Prove the accept has run before checking what a new client receives (#20489) 2026-10-12 11:46:44 +13:00
Frédéric MetrichandClaude Opus 5.5 58e03a875f [teleinfo] Fix out-of-bounds read when splitting a frame into groups (#20513)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
Clyde StubbsandClaude Opus 5.5 41a9fca881 [ci] Reset sdkconfig after ESP-IDF component discovery (#20511)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
ec58e864f5 [canbus] Fix high frame rates causing crashes due to unbounded iteration (#14481)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
esphome[bot] 375fbd829e Bump bundled esphome-device-builder to 1.23.1 (#20497) 2026-10-12 11:46:44 +13:00
4bae89bd4a [nrf52] Fix BLE OTA/log device discovery & transport selection on macOS (#17101)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:43 +13:00
Bascht74andJ. Nick Koston 897b2f16a9 [tests] Close leftover server connections in the tcp_uart bridge and TCP client link tests (#20215)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 11:46:43 +13:00
tomaszduda23 93c8796ae8 [nrf52] fix build error on 3.4.0 with pch ON (#20474) 2026-10-12 11:46:43 +13:00
J. Nick Koston 654afb536e [core] Fix the native ESP8266 and host builds on Windows without ccache (#20468) 2026-10-12 11:46:43 +13:00
jabla 97154f9e5f [espidf] Clean the build when the IDF target changes (#20473) 2026-10-12 11:46:43 +13:00
c9e49b8e8f [teleinfo] Add predefined properties for main tags of TIC standard and historical (#9167)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 11:42:45 +13:00
21804a75de [openthread] add mesh_local_eid openthread_info sensor (#13924)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 12:41:14 -10:00
J. Nick Koston 3ae52e21cf [usb_host] Warn instead of failing when a device duplicates a usb_uart entry (#20373) 2026-10-12 11:38:38 +13:00
Zhaoqi Xu f2395f9001 [heatpumpir] Fix fan speeds for mitsubishi_msc protocol (#20460) 2026-10-12 11:38:38 +13:00
Ali Alaeiandtomaszduda23 3d248496ab [zephyr_mcumgr] Enable MCUMGR_GRP_OS for UART transport, not just BLE (#18319)
Co-authored-by: tomaszduda23 <tomaszduda23@gmail.com>
2026-10-12 11:38:38 +13:00
esphome[bot] 34eca948c4 Bump bundled esphome-device-builder to 1.23.0 (#20446) 2026-10-12 11:38:37 +13:00
J. Nick Koston 58815575ef [core] Pass a single condition to actions directly instead of wrapping it in a group (#19223) 2026-10-12 11:38:37 +13:00
J. Nick Koston 28b012389d [ci] Keep bus group partners on the same runner (#20431) 2026-10-12 11:38:37 +13:00
J. Nick Koston 54f86537ad [esp32][libretiny] Skip registering static destructors that never run (#20440) 2026-10-12 11:38:37 +13:00
J. Nick Koston 3284d93a33 [nrf52] Fake a larger flash in testing mode (#20439) 2026-10-12 11:38:37 +13:00
J. Nick Koston 01686a5f67 [improv_serial] Use a numeric scheduler id for the Wi-Fi connect timeout (#20360) 2026-10-12 11:38:37 +13:00
J. Nick Koston 3b0c485701 [mqtt] Use a numeric scheduler id for the deferred state publish (#20359) 2026-10-12 11:38:37 +13:00
J. Nick Koston e61d348f0c [wifi] Use numeric scheduler ids instead of string names (#20357) 2026-10-12 11:38:37 +13:00
J. Nick Koston 22e594bc62 [esp32_ble_client] Track conn_id per attempt and stop logging an error on a failed open (#20417) 2026-10-12 11:38:37 +13:00
J. Nick Koston 061733672f [ci] Refit component batch estimates to a full run (#20401) 2026-10-12 11:38:37 +13:00
J. Nick Koston b1f5da9561 [esp8266] Keep ArduinoJson's float tables in flash (Arduino core 3.1.2-esphome.3) (#20416) 2026-10-12 11:38:37 +13:00
J. Nick Koston db517059a3 [ota] Collapse duplicate CI test variants (#20407) 2026-10-12 11:38:37 +13:00
J. Nick Koston 0b40892775 [mcp23xxx_base] Keep the interrupt handler in IRAM on ESP8266 (#20409) 2026-10-12 11:38:37 +13:00
J. Nick Koston 8e6ed71e2a [ethernet] Collapse duplicate CI test variants (#20406) 2026-10-12 11:38:37 +13:00
J. Nick Koston cf569702c4 [ci] Stop the toolchain tests from installing ESP-IDF (#20413) 2026-10-12 11:38:37 +13:00
Bascht74 9a35c5229b [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) 2026-10-11 22:36:06 +00:00
Jesse Hills 96eaf42336 [api] Let a wizard input pick multiple entities (#20537) 2026-10-12 11:22:29 +13:00
tomaszduda23andClaude 94894d60b7 [nrf52] fix zigbee time component (#20527)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-11 12:15:23 -10:00
puddly 4155b82f84 [usb_uart] Fix invalid stop bits and parity enum mappings (#20408) 2026-10-12 10:58:31 +13:00
J. Nick Koston 61d98534f6 [uart] Stop ESP8266 software serial RX ISR from blocking for a whole byte (#18519) 2026-10-12 10:56:21 +13:00
embeandJ. Nick Koston 8060dadd6c [micro_wake_word] Add opt-in internal RAM placement (#20529)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-11 11:49:49 -10:00
luar123andJ. Nick Koston 7d7db6bf90 [zigbee] add report action on esp32 (#20488)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-11 11:31:49 -10:00
Frédéric MetrichandClaude Opus 5.5 2ef95bbc28 [core] Copy trigger arguments in wait_until and script.wait (#20536)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:22:52 +00:00
luar123 3035a08b0a [zigbee] Improve basic cluster and add manufacturer and app version setting (#20519) 2026-10-11 11:19:43 -10:00
Frédéric MetrichandClaude Opus 5.5 9b34db14d4 [mqtt] Reject deferring actions in on_json_message (#20538)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:15:15 +00:00
heikkikiiandJ. Nick Koston 9998849009 [seeed_mr24hpc1] Wait for the module to boot before sending UART traffic (#18860)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 11:10:50 -10:00
Maciej KucharekandJ. Nick Koston f2db62adf2 [mipi_dsi] Add JC1060P470-V2 (#18695)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 11:08:02 -10:00
Frédéric MetrichandClaude 36fbf5bf22 [emontx] Add pause/resume actions to let another component take over the UART (#18756)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-11 21:07:39 +00:00
Fabio Pugliese OrnellasandJ. Nick Koston 5cdefa910f [cse7761] Add new sensors (#17531)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 11:06:05 -10:00
Frédéric MetrichandClaude Opus 5.5 eb56ce2b76 [haier] Reject deferring actions in on_status_message (#20539)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:05:09 -10:00
2ab06fa937 [pipsolar] Add QET/QLT total energy sensors (#17347)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 10:39:22 -10:00
Clyde StubbsandClaude bea0ef264a [lvgl] Migrate to 9.6.0 (#20257)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 07:36:32 +11:00
153c51e39f [gree] Keep display light on by default for YAN/YAA/YAC/YAC1FB9 (#18516)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 10:24:50 -10:00
f562d7a6a4 [socket] Add join_multicast_group() and set_ipv6_multicast_if() helpers (#17045)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 09:59:44 -10:00
b73f151619 [pmsx003] add support for luftmy ld15 (#15296)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 09:49:06 -10:00
Frédéric MetrichandClaude Opus 5.5 da43b6292d [mk2pvrouter] Store the frame as bytes and read with read_byte() (#20530)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:41:58 -10:00
Frédéric MetrichandClaude Opus 5.5 b10eef537e [teleinfo] Log the tag name for an invalid timestamp (#20532)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:41:28 -10:00
Frédéric MetrichandClaude Opus 5.5 5c5737303f [teleinfo] Store the frame as bytes and read with read_byte() (#20531)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:41:02 -10:00
Frédéric MetrichandClaude Opus 5.5 16a474cce5 [core] Only treat action names as actions in has_non_synchronous_actions() (#20524)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:40:27 -10:00
86192d89f4 [ufm01] Improve stale recovery, add diagnostics and clear flow action (#18899)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 09:29:56 -10:00
J. Nick Koston c130e2808b [display] Build on_page_change with build_callback_automation and compile it out when unused (#20456) 2026-10-11 09:23:05 -10:00
J. Nick Koston 78c7cd725d [remote_base] Transmit constant Haier, Mirage and AEHA codes without a heap vector (#20287) 2026-10-11 09:22:39 -10:00
Frédéric MetrichandClaude Opus 5.5 eff3a6f9a2 [modbus_client] Use the core deferring-actions validators (#20526)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:17:39 -10:00
Frédéric MetrichandClaude Opus 5.5 8449820480 [emontx] Use the core validate_synchronous_automation() helper (#20525)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:17:18 -10:00
9355bdc3c5 [canbus] Bus error logging, Auto-recover bus-off on ESP32 (#10953)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 09:12:28 -10:00
J. Nick Koston eb1aa2bfd6 [web_server] Keep the cover, valve and alarm panel action tables in flash (#20397) 2026-10-11 09:07:06 -10:00
6322cef8e2 [tmp102] Add hardware configuration options (#20063)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-11 09:06:40 -10:00
Frédéric MetrichandClaude Opus 5.5 c8a300164b [core] Add validators that reject deferring actions in an automation (#20523)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:53:37 -10:00
J. Nick Koston 86af0af0e4 [core] Add sum_bytes() and use it for the additive byte checksums components hand-roll (#20507) 2026-10-11 08:48:47 -10:00
J. Nick Koston 8e8261c39e [core] Add this week's review lessons to AGENTS.md (#20522) 2026-10-11 18:14:56 +00:00
1610 changed files with 78318 additions and 7246 deletions
+123
View File
@@ -0,0 +1,123 @@
---
name: hardware-test
description: Test an ESPHome change on real boards over USB. Use this whenever a task asks to test, verify, reproduce, bench, A/B or soak a pull request, a branch or a crash on hardware, or mentions flashing, serial logs, esphome upload, a captive portal or fallback AP test, or results to post on a PR. Covers finding the serial ports, building from the right checkout, flashing each chip family (including the LibreTiny kits that stock tools cannot flash), bounded log capture, proven test flows and how to report results.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---
# Hardware testing
The goal of a hardware test is evidence a reviewer can trust: which build ran on which board, what was
driven, what the logs show, and the same flow on a `dev` build when a quirk could be pre-existing.
`$SKILL` below is this directory (`.agents/skills/hardware-test`).
## Ground rules
1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that
port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py <port> "`,
naming the port so a capture on another board survives; the bracket keeps the pattern from matching
the shell that runs it. Never run `esphome logs` in a
`while true` loop: the loop respawns it and holds the port.
2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with
`!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs.
3. **Build from the tree under test.** `python -m esphome` puts the current directory first on
`sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by
its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the
checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep
the build's `src/` for the change before trusting any number.
4. **PR build and baseline build get different `name:` values.** Builds share
`.esphome/build/<name>`, so the same name means `esphome upload` can flash the wrong binary. Saved
Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh
name every run.
5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD`
checked out in a throwaway worktree), with the baseline commit named in the table. When a delta
looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it.
6. **Prove a quirk is pre-existing before blaming the PR:** run the identical flow on a `dev` build in
the same session and say so in the results.
7. **Pipe safely.** Set `PYTHONUNBUFFERED=1` on any `esphome run` or `esphome logs` whose output is
piped, or lines land minutes late. Bound every long command with `timeout` (coreutils) or
`perl -e 'alarm shift; exec @ARGV' <seconds> <command>` on macOS without it.
## Workflow
1. **Find the boards:** `python $SKILL/scripts/ports.py` (any Python with pyserial, for example the
ESPHome venv). Port names follow the USB hub port and change on every replug, so never reuse a name
from an earlier session. CH340 adapters (D1 mini, BW15 kit, BK7238, CB3S) all look the same; tell
them apart with a 10 s capture using `serial_capture.py --reset` (ESP8266 prints its ROM banner at
74880 baud, LibreTiny boards print `[LT]` and ESPHome lines, BK7238 prints its bootloader version).
`ports.py --wait 2e8a` blocks until a Pico re-enumerates.
2. **Make a scratch directory** outside the checkout, with `secrets.yaml` and the templates you need.
One yaml per board per variant, file name equal to `name:`.
3. **Compile** in the right tree:
`(cd <worktree> && PYTHONUNBUFFERED=1 timeout 1500 python -m esphome compile <abs yaml> > <name>-compile.log 2>&1)`.
Keep the RAM and flash lines at the end. Two ESP-IDF or two LibreTiny builds at the same time race
over tool caches; run them one at a time.
4. **Flash** per chip family (table below). Stop the capture on that port first.
5. **Capture** with `serial_capture.py PORT SECONDS OUT` in the background, then poll the file with
`grep` in an `until ... sleep 2` loop with a cap. USB CDC boards (Pico, nRF52, native USB ESP32)
drop the port on reboot; the script reopens it until its deadline.
6. **Drive** the test: a second board running a client config, `curl`, an aioesphomeapi script that
presses a template button and subscribes to logs, MQTT publishes, or `interval:` and `on_boot:`
automations in the config itself.
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
no IP addresses.
## Flashing by chip family
| Board | Flash | Log |
|---|---|---|
| ESP8266 D1 mini (CH340) | `python -m esphome upload <yaml> --device <port>` | `serial_capture.py` at 115200; `esphome logs` on some CH340s emits garbage after the RTS reset. A failed esptool write can wedge the adapter until the board is replugged. `esptool erase_flash` before a test that needs an unprovisioned device: the preference sector survives a reflash |
| ESP32 devkit (CP2102 or CH340) | `esphome upload --device <port>`, ESP-IDF or Arduino | `serial_capture.py`. Default Wi-Fi light sleep delays API driven actions by seconds; judge timing from device logs. `power_save_mode: none` for OTA overlap tests |
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test |
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port |
| Any reachable device | `esphome upload <yaml> --device <ip or name.local>`; `--file <bin>` for a prebuilt image | `esphome logs --device <ip>` bounded with `timeout` |
ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA port is 8892.
## Scripts in `$SKILL/scripts`
- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`,
`--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and
`--serial-of PORT` (the serial number behind a path).
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped
capture, ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
deadline. OUT is truncated unless `--append` is given, so counts over it cover one run.
- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot
strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not.
- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN.
- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`:
the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the
templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0),
`RESULT FAIL` (exit 3: a crash, a reboot after the AP came up, or the device never joined the network),
`RESULT INVALID`
(exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run)
or exits 1 when a build, flash or port step failed.
## Templates in `$SKILL/templates`
- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and
serves the captive portal. `example-dut.yaml` is the per-run file with the platform block.
- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real
credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a
plain provisioning check.
## When something looks wrong
- A capture stopped early or a line is missing: check the timestamps before trusting it.
- The client never joins the AP: another board may still run an AP with the same SSID, or the device
booted with saved credentials because the name was reused. One AP device at a time on the bench.
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3).
- Multicast from the host never arrives on some access points: test it device to device. Broadcast
is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate.
- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for
soak tests.
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven
flows. Add new findings there.
@@ -0,0 +1,138 @@
# Board facts
Ports are not listed on purpose: they follow the USB hub port and change on every replug. Run
`scripts/ports.py`.
## ESP8266 (D1 mini, NodeMCU, CH340 or CP2102)
- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset`
pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`).
- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset;
a plain pyserial capture at 115200 is reliable.
- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug.
- The preference sector survives a reflash: a provisioned API key or saved Wi-Fi credentials stay
active. `esptool erase_flash` gives a clean device.
- `Panic core_esp8266_main.cpp:191 __yield` with `ctx: sys` means a yield from lwIP context (a web
handler, an lwIP callback). Decode stacks with
`xtensa-lx106-elf-addr2line -e <build>/.pioenvs/<name>/firmware.elf -f -C -i <addr...>` from the
toolchain under `~/.platformio/packages/toolchain-xtensa/bin/` (PlatformIO builds) or the ESPHome
cache directory (native toolchain builds).
- Constant data lives in RAM unless marked `PROGMEM`; with 80 KB of RAM a few hundred bytes are worth a
row in the PR.
- AsyncMqttClient drops inbound topics over 128 characters; topics of 255 or more wrap a counter and
stall the link until the broker drops TCP.
## ESP32 classic devkit
- Takes ESP-IDF and Arduino. Default Wi-Fi light sleep delays API driven actions by up to 6 s; judge
timings from device logs, not from the host. `power_save_mode: none` for OTA overlap tests (an IDF web
upload of 739 KB took 107 s with power save and 45 s without).
- Receives only a few percent of IPv6 multicast on some access points with power save on; a raw lwIP
socket loses the same, so that is the AP or driver, not a PR.
- Decode with `xtensa-esp32-elf-addr2line` from the IDF tools directory against
`<build>/.pioenvs/<name>/firmware.elf` (PlatformIO) or `<build>/build/<name>.elf` (IDF native).
- Clean reboot between runs with the capture stopped: `esptool --after hard_reset chip_id`.
- Trigger a forced crash only after the app is marked valid (45 s after boot is plenty), never on an
image still pending OTA rollback.
- Signed OTA testing: build with `esp32.framework.advanced.signed_ota_verification.verification_keys`
under a different name, sign with `python -m espsecure sign_data --version 2 --keyfile
tests/components/esp32/dummy_signing_key.pem`, upload with `esphome upload --file`. The verifier
refuses unsigned images afterwards, so restore with a signed plain build.
## ESP32-S3, C3, C6, H2 with native USB
- `/dev/cu.usbmodem*`, esptool works, `logger: hardware_uart: USB_SERIAL_JTAG`.
- The USB console can die at the moment the app takes over the console during a reboot (log stops after
`Disabling RNG early entropy source`, then 0 bytes, esptool reports `No serial data received`). Only a
USB replug fixes it. Fallback: `esphome logs --device <name>.local` (API log stream, which drops lines
while Wi-Fi is off).
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry).
## RP2040 / RP2350 (Pico W, Pico 2 W)
- The CDC port re-enumerates after every reboot and after `esphome upload` (1200 baud touch). Re-detect
with `ports.py --wait 2e8a` before capturing; `serial_capture.py` reopens on its own if the port
vanishes mid-capture.
- First flash ever: BOOTSEL, then `esphome upload --device /Volumes/RPI-RP2` (or `RP2350`).
- arduino-pico creates the lwIP netif in `WiFi.begin()`, so any IGMP join during component setup fails;
joins must wait for `network::is_connected()`.
- Parallel Pico builds race on package extraction after a PlatformIO package wipe; one at a time.
- An RTS pulse from pyserial does not reset the board; reboot with `App.safe_reboot()` or the touch.
## BW15 kit (RTL8720CF, LibreTiny rtl87xx)
- The kit's CH340 wires RTS to CEN and DTR to PA00 (boot strap). A plain pyserial open asserts both and
holds the chip in reset, which is why `esphome upload`, plain `ltchiptool` and `esphome logs` all fail
("Timeout while linking", or silence). `scripts/flash_rtl87xx_kit.py` sets DTR high and RTS released
on every open, pulses RTS once so the ROM answers `ping`, and after the command drops DTR and pulses
RTS to boot the app. Run it with the PlatformIO LibreTiny penv Python.
- `flash write -d <port> <uf2>` takes no family argument; `flash read -d <port> -s 0 -l <len>
realtek-ambz2 <out>` does.
- Capture with `serial_capture.py --released`. The ESPHome log is on the flashing UART already.
- The macOS CH340 driver only pushes DTR/RTS to the wire once a session has driven them, so the first
plain open after a replug streams logs and the second is silent; always open with the lines released.
- Driver quirks seen on `dev`: `WiFi.begin` can refuse the real network on the first try after bogus
credentials (it connects after two cooldowns); right after connecting while the AP is still up the
driver can bus-fault in `rtw_is_adapter_up` (`R0 = 0xdeadbeef`) and reboot; the AP restarts on every
STA retry, so stations cannot stay joined; `WiFi.begin` fails synchronously for an absent network and
lands in a cooldown at once; connecting blocks the loop 2.5 to 4.1 s.
- Decode faults with `arm-none-eabi-addr2line -e <build>/.pioenvs/<name>/raw_firmware.elf -f -C -i <PC>`
from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`).
Fault dumps print PC, LR and R0 to R3.
- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`.
## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN)
- Stock ltchiptool closes and reopens the port after its reset, and pyserial raises both lines on open,
holding CEN low through the BootROM window. `scripts/flash_bk72xx.py` opens with both released and
pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset
reaches the downloader; no software reboot does.
- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter.
- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one
capture open for the whole test and never reopen mid-phase.
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
radio enable 160 to 278 ms, disable about 200 ms.
## BW16 kit (RTL8720DN, AmebaD)
- The kit's own USB-C (CH340) is on LOG_UART and flashes fine. Its two-transistor circuit resets while
RTS alone is asserted and straps BURN while DTR alone is; both asserted does nothing. ROM entry is
esptool's classic sequence (`dtr:false,rts:true` 100 ms, `dtr:true,rts:false` 400 ms, both false); the
first pulse after a replug can be lost, retry up to three times.
- Any reopen resets the chip, 460800 is flaky, 115200 is solid: one open per session, in-place baud
changes only. The ROM and the loader NAK continuously while idle, so ACK scans must drain first.
- The AmebaD core has no mDNS responder (`mdns: disabled: true`).
## LN882H
- ltchiptool forces 1 Mbaud for writes; adapters that cannot hold it fail with "Timeout while linking"
or die mid-write. Patch `LN882H_YM_BAUDRATE` in the penv's `ltchiptool/soc/ln882h/util/ln882htool.py`
to 115200 (lost on reprovision). Weak 3.3 V from the adapter: feed the module's 5 V pad instead. Short
BOOT to GND while the tool connects. OTA afterwards.
- `USE_WIFI_AP_EXCLUSIVE` (one radio mode at a time) exists only on LN882x; compile-check changes there.
## nRF52840
- Nordic PCA10059 dongle: the factory Open DFU bootloader enumerates as `1915:521F` on its own port
name; the app as `2FE3:0100` with two CDC ports, ESPHome log on the second. Only Nordic
`nrfutil nrf5sdk-tools` works (`pkg generate --hw-version 52 --sd-req 0x00 --application-version 1
--application zephyr.hex app.zip`, then `dfu usb-serial -pkg app.zip -p <port>`); ESPHome downloads
it on the first build. GPREGRET does not enter the bootloader; a pin reset through P0.19 does, which
`dfu: true` wires up on this board. A pulsing red LED means bootloader; the side RESET button
re-enters it.
- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume,
adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24),
button P0.18.
## ESP Thread Border Router board v1.2 (S3 host + H2 RCP)
- S3 host on USB JTAG serial, H2 RCP on its own USB. Spinel UART1 from the host: `rx_pin: GPIO17`,
`tx_pin: GPIO18`, RCP reset `GPIO7` active low, 460800. The config needs `network: enable_ipv6: true`.
- The shipped H2 firmware fails `CheckRadioCapabilities` with IDF 5.5; build `examples/openthread/ot_rcp`
for esp32h2 with ESPHome's own IDF (`IDF_PATH` at the cached framework, its penv and tools,
`IDF_PYTHON_CHECK_CONSTRAINTS=no`) and flash with esptool at 0x0, 0x8000 and 0x10000.
- A crash-looping S3 pulses the H2 reset every boot, so the H2 cannot be flashed and the S3 will not
enter its bootloader over USB. Hold S3 BOOT, tap S3 RST, flash the H2, release, then replug both.
- `dns-sd -L <name> _meshcop._udp local.` shows the `omr` prefix; it disappears when routing is dead.
Keep one capture per chip; never reset the H2 over USB mid-test unless that is the test (the host
logs `Unexpected RCP reset: RESET_OTHER` and recovers).
@@ -0,0 +1,117 @@
# Proven test flows
Each flow names the pull requests it was used on, so their result tables show what to post.
## Captive portal `/wifisave` hammer (#20802, #20784)
Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board.
Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`,
`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the
device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown
is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on
ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md).
Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash
markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`.
A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters
the adapter restart), no panic, and the real credentials connect the device within about 10 s.
Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in
`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag
(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive
portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection
refused proves the portal's server is gone.
## `reboot_timeout` with a fallback AP (#9958)
Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network,
`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and
ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes:
`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles
are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot
with no station joined, no reboot for minutes while one is, then a reboot within a second of the last
disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way.
## MQTT (#20433, #17935, #20762, #20767, #20768)
A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives
each subscription's QoS, the device address (`New client connected from <ip> as <name>-<mac>`) and exact
subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription.
Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right
after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a
leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish.
`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a
paho script publishes and polls the serial capture file.
## UDP, multicast, sockets (#20556 to #20559, #20585, #20590)
A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device
multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock
listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate
(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi
reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect`
(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action
rejects `%.*s`; use a lambda.
## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037)
All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
`pkill -f "a\|b"` does not alternate; use two pkills.
## DHCP lease cache (#17767)
Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the
`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep
sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use
`upload` then `logs`.
## HDMI-CEC two-board wire (#9189)
Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:`
filter on the reply handler, template buttons for each target plus lambdas calling
`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms,
round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep
lags the press).
## LD2450 polygon zones (#20154)
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
Writing those numbers blocks the ld2450 loop about 50 ms.
## Scheduler and loop wake A/B (#20356)
An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266;
the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout
logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus
`button_command`). Build the same yaml twice with different names, one per tree.
## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233)
Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over
two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with
`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml.
## ESP-NOW on the ESP8266 (#14407)
The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is
up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only
mode the device transmits from the soft-AP MAC.
## Wi-Fi Easy Connect (DPP) enrollee (#17934)
Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
supplicant state machine.
## Thread border router (#18104)
See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR.
## nRF52840 dongle boards (#19009, #20814)
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
@@ -0,0 +1,51 @@
"""ltchiptool for boards whose RTS drives CEN: reset and link on one open port.
Stock ltchiptool closes and reopens the port after its DTR/RTS reset, and
pyserial raises both lines on open, holding CEN low through the BootROM's
listen window. Open with both lines released and pulse the reset right
before the LinkChecks instead.
"""
import sys
import bk7231tools.serial.main as bk_main
from ltchiptool.__main__ import cli
from ltchiptool.soc.bk72xx.flash import BK72XXFlash
from serial import Serial
class ReleasedSerial(Serial):
def __init__(self, port=None, **kwargs):
super().__init__(None, **kwargs)
self.dtr = False
self.rts = False
self.port = port
self.open()
bk_main.Serial = ReleasedSerial
_connect = BK72XXFlash.flash_connect
def flash_hw_reset(self) -> None:
self._reset_pending = True
def flash_connect(self) -> None:
pending = getattr(self, "_reset_pending", False)
if self.bk and self.conn.linked and not pending:
return
self.flash_build_protocol()
if pending:
self._reset_pending = False
self.bk.hw_reset()
self.conn.linked = False # the chip rebooted, so link again below
_connect(self)
BK72XXFlash.flash_hw_reset = flash_hw_reset
BK72XXFlash.flash_connect = flash_connect
sys.argv = ["ltchiptool", *sys.argv[1:]]
cli()
@@ -0,0 +1,87 @@
"""ltchiptool for the BW15 kit whose CH340 drives CEN (RTS) and PA00 (DTR).
A plain open asserts both lines and holds the chip in reset. Here every open keeps DTR high and RTS
released; the first open pulses RTS so the ROM comes up; after the command DTR low plus an RTS pulse
boots the app. The exit status is ltchiptool's; 2 means the flash succeeded but the app was not
booted (boot pulse failed, or no -d PORT to send it to), so the board sits in the ROM until reset.
Usage: python flash_rtl87xx_kit.py flash write -d <port> firmware.uf2
"""
import sys
import time
import serial
_first = [True]
_orig_open = serial.Serial.open
def _open(self):
self.dtr = True # PA00 high: ROM download mode on the next reset
self.rts = False # CEN released
_orig_open(self)
if _first[0]:
_first[0] = False
self.rts = True
time.sleep(0.1)
self.rts = False
time.sleep(0.3) # reset into the ROM
self.reset_input_buffer()
serial.Serial.open = _open
from ltchiptool.__main__ import cli_entrypoint # noqa: E402
def port_argument(argv: list[str]) -> str | None:
for i, a in enumerate(argv):
if a in ("-d", "--device") and i + 1 < len(argv):
return argv[i + 1]
if a.startswith(("-d=", "--device=")):
return a.split("=", 1)[1]
if a.startswith("-d") and len(a) > 2:
return a[2:]
return None
def boot_app(port: str) -> None:
s = serial.Serial(port=None)
s.port = port
s.dtr = False
s.rts = False
_orig_open(s)
s.rts = True
time.sleep(0.1)
s.rts = False
s.close()
def main() -> int:
rc = 1
try:
result = cli_entrypoint.main(args=sys.argv[1:], standalone_mode=False)
rc = int(result) if isinstance(result, int) else 0
finally:
port = port_argument(sys.argv)
if not port:
print(
"[flash_rtl87xx_kit] no -d PORT given, the application was not booted",
file=sys.stderr,
)
if rc == 0:
rc = 2
else:
try:
boot_app(port)
print("[flash_rtl87xx_kit] application booted")
except (serial.SerialException, OSError) as err:
print(f"[flash_rtl87xx_kit] boot pulse failed: {err}", file=sys.stderr)
if rc == 0:
rc = 2 # flashed, but the board is still in the ROM: press reset
return rc
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,123 @@
"""List the boards on USB, or wait for one to enumerate.
ports.py table: port, vid:pid, product, serial number, guess
ports.py --wait 2e8a print the first port with VID 0x2e8a once it appears (default 60 s)
ports.py --wait 1a86:7523 --timeout 30
ports.py --product "USB Serial" first port whose USB product string matches (case-insensitive)
ports.py --wait 2e8a --serial SN the port of one specific board, whatever name it comes back under
ports.py --serial-of PORT print the USB serial number behind a port name
Run with a Python that has pyserial (any ESPHome venv). Port names follow the hub port on macOS, so
re-run this after every replug instead of trusting a name from an earlier session.
"""
import argparse
import sys
import time
from serial.tools import list_ports
GUESS = [
(
(0x1A86, 0x7523),
"CH340: D1 mini, BW15 kit, BK7238/CB3S or LN882H adapter; identify by the boot banner",
),
((0x1A86, 0x55D3), "CH343: ESP32-P4 devkit console"),
((0x1A86, 0x55D4), "CH343: ESP32 devkit"),
((0x1A86, None), "WCH CH34x USB serial bridge"),
((0x10C4, 0xEA60), "CP2102: classic ESP32 devkit (esp32dev)"),
(
(0x303A, 0x1001),
"Espressif USB JTAG serial: S3/C3/C6/H2 native USB (logger hardware_uart USB_SERIAL_JTAG)",
),
(
(0x2E8A, None),
"Raspberry Pi Pico W / Pico 2 W CDC; the port vanishes and comes back on every reboot",
),
((0x1915, 0x521F), "nRF52840 Dongle (PCA10059) in the Open DFU bootloader"),
(
(0x2FE3, 0x0100),
"nRF52840 Dongle running a Zephyr app; two CDC ports, ESPHome log on the second",
),
((0x1915, 0xCAFE), "Makerdiary nRF52840 MDK dongle running a Zephyr app"),
((0x239A, None), "Adafruit style nRF52 board (UF2 bootloader family)"),
]
def guess(vid: int, pid: int) -> str:
for (gv, gp), text in GUESS:
if vid == gv and (gp is None or pid == gp):
return text
return ""
def parse_id(text: str) -> tuple[int, int | None]:
vid, _, pid = text.partition(":")
return int(vid, 16), (int(pid, 16) if pid else None)
def matches(
port, vid: int | None, pid: int | None, product: str | None, serial: str | None
) -> bool:
if vid is not None and (port.vid != vid or (pid is not None and port.pid != pid)):
return False
if product and (port.product or "").lower() != product.lower():
return False
return not serial or (port.serial_number or "") == serial
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument(
"--wait",
metavar="VID[:PID]",
help="block until a matching port appears, print it",
)
ap.add_argument("--product", help="USB product string to match")
ap.add_argument("--serial", help="USB serial number to match")
ap.add_argument(
"--serial-of", metavar="PORT", help="print the USB serial number of PORT"
)
ap.add_argument("--timeout", type=float, default=60)
args = ap.parse_args()
if args.serial_of:
for p in list_ports.comports():
if p.device == args.serial_of and p.serial_number:
print(p.serial_number)
return 0
print(f"no USB serial number for {args.serial_of}", file=sys.stderr)
return 1
vid = pid = None
if args.wait:
vid, pid = parse_id(args.wait)
if args.wait or args.product or args.serial:
deadline = time.time() + args.timeout
while True:
for p in sorted(list_ports.comports(), key=lambda p: p.device):
if p.vid and matches(p, vid, pid, args.product, args.serial):
print(p.device)
return 0
if time.time() >= deadline:
print("no matching port", file=sys.stderr)
return 1
time.sleep(1)
rows = [p for p in list_ports.comports() if p.vid]
if not rows:
print("no USB serial ports")
return 1
for p in sorted(rows, key=lambda p: p.device):
print(
f"{p.device:32} {p.vid:04x}:{p.pid:04x} {p.product or '?':34} "
f"{p.serial_number or '':18} {guess(p.vid, p.pid)}"
)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,157 @@
"""Bounded, timestamped serial capture that always exits and closes the port.
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append]
default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico.
--released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin
(DTR) and reset (RTS), so a plain open holds the chip in reset.
--reset pulse reset after opening (DTR low, RTS high 100 ms, RTS low) to capture a fresh boot.
--baud 115200 by default; 74880 shows the ESP8266 ROM boot messages.
--append keep what OUTFILE already holds; by default it is truncated so counts cover this run only.
Exit status 1 when nothing was received (wrong port name, or a port another capture still holds).
Text ESPHome marks as secret (LOG_SECRET, the Wi-Fi password) is written as <secret>.
Lines are written as [HH:MM:SS]text with ANSI colour stripped and flushed one by one, so a shell
loop can grep the file while the capture runs. If the port is missing or disappears (USB CDC boards
re-enumerate on reboot) the script retries until the deadline, and follows the board by its USB serial
number when it comes back under another device path.
Never run an open-ended serial session: every capture has a deadline, and a flash on the same port
needs the capture stopped first (pkill -f "[s]erial_capture.py" does not match the killing shell).
"""
import argparse
import contextlib
import pathlib
import re
import sys
import time
import serial
from serial.tools import list_ports
ANSI = re.compile(rb"\x1b\[[0-9;]*[A-Za-z]")
# ESPHome's LOG_SECRET span (a Wi-Fi password); a span cut off before its end marker is secret to the end
SECRET = re.compile(rb"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL)
PORT_ERRORS = (serial.SerialException, OSError)
def identify(port: str) -> tuple[str | None, int]:
"""USB serial number of the port and its position among that device's ports (a dongle has two)."""
ports = sorted(list_ports.comports(), key=lambda p: p.device)
serial_number = next((p.serial_number for p in ports if p.device == port), None)
siblings = [
p.device for p in ports if serial_number and p.serial_number == serial_number
]
return serial_number, siblings.index(port) if port in siblings else 0
def current_path(port: str, serial_number: str | None, index: int) -> str:
"""The port's current device path: a USB CDC board can come back under a new name after a reboot."""
if serial_number:
ports = sorted(list_ports.comports(), key=lambda p: p.device)
siblings = [p.device for p in ports if p.serial_number == serial_number]
if index < len(siblings):
return siblings[index]
return port
def open_port(args: argparse.Namespace, path: str) -> serial.Serial:
s = serial.Serial(port=None, baudrate=args.baud, timeout=0.2)
s.port = path
if args.released:
s.dtr = False
s.rts = False
s.open()
if args.reset:
try:
s.dtr = False
s.rts = True
time.sleep(0.1)
s.rts = False
s.reset_input_buffer()
except PORT_ERRORS:
s.close()
raise
return s
def write_line(out, tag: bytes, data: bytes) -> None:
stamp = time.strftime("[%H:%M:%S]").encode()
out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"<secret>", data)) + b"\n")
out.flush()
def note(out, text: str) -> None:
write_line(out, b"[capture] ", text.encode())
def capture(args: argparse.Namespace, out) -> int:
"""Returns the number of bytes received."""
deadline = time.time() + args.seconds
buf = b""
s = None
received = 0
serial_number, index = None, 0
while time.time() < deadline:
if s is None:
path = current_path(args.port, serial_number, index)
try:
s = open_port(args, path)
args.reset = False # only reset on the first open
if serial_number is None:
serial_number, index = identify(path)
if path != args.port:
note(out, f"reopened as {path}")
except PORT_ERRORS as err:
note(out, f"open failed: {err}")
time.sleep(1)
continue
try:
data = s.read(4096)
except PORT_ERRORS as err:
if buf: # keep the cut-off line apart from the next boot's first line
write_line(out, b"[partial]", buf)
buf = b""
note(out, f"port lost: {err}")
with contextlib.suppress(*PORT_ERRORS):
s.close()
s = None
time.sleep(1)
continue
if not data:
continue
received += len(data)
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
write_line(out, b"", line.rstrip(b"\r"))
if buf:
write_line(out, b"[partial]", buf)
if s is not None:
s.close()
return received
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("port")
ap.add_argument("seconds", type=float)
ap.add_argument("outfile")
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--released", action="store_true")
ap.add_argument("--reset", action="store_true")
ap.add_argument("--append", action="store_true")
args = ap.parse_args()
with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out:
received = capture(args, out)
if received == 0:
print(f"[capture] nothing received from {args.port}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# wifisave_hammer.sh: the device under test (DUT) falls back to its AP; a client board joins it and posts
# /wifisave repeatedly, then the real credentials. Compiles, flashes, captures both serial logs and
# prints counts. Configs come from templates/example-dut.yaml and templates/example-client.yaml.
#
# Usage: wifisave_hammer.sh DUT_KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE_S]
# DUT_KIND esp (esphome upload), pico (upload, then re-find the CDC port by USB serial number),
# bw15 (flash_rtl87xx_kit.py) or bk (flash_bk72xx.py)
# *_YAML absolute path; the file name without .yaml must equal the config's `name`
# *_TREE checkout or worktree whose esphome code must build that yaml (cwd for python -m esphome)
# SETTLE_S seconds to keep capturing after the client reports the real save (default 40; BW15 wants 90)
# Env: PY (python with esphome; default DUT_TREE/venv/bin/python, else python3 on PATH; an explicit PY must exist), LTPY
# (LibreTiny penv python with ltchiptool; default ~/.platformio/penv/.libretiny/bin/python)
# Output next to each yaml: NAME-compile.log, NAME-upload.log, NAME-serial.log, NAME-capture.err, and DUT NAME-events.txt
# (addresses redacted so the extract can go on a PR; the serial logs themselves stay local).
# Exit status: 0 RESULT PASS (no crash marker or reboot after the AP came up, the device joined the network,
# and real stimulus: saves answered and a cooldown exit); 3 RESULT FAIL (the counts show
# a failure); 2 RESULT INVALID (a precondition or the stimulus was missed, or a capture did not last the run);
# 1 when a build, flash or port step fails (nothing is reported).
set -u
SKILL=$(cd "$(dirname "$0")/.." && pwd)
KIND=$1; DPORT=$2; DYAML=$3; DTREE=$4; CPORT=$5; CYAML=$6; CTREE=$7; SETTLE=${8:-40}
if [ -n "${PY:-}" ]; then [ -x "$PY" ] || { echo "[hammer] FAILED: PY=$PY is not executable"; exit 1; }
else PY=$DTREE/venv/bin/python; [ -x "$PY" ] || PY=$(command -v python3) || { echo "[hammer] FAILED: no python found, set PY"; exit 1; }; fi
LTPY=${LTPY:-$HOME/.platformio/penv/.libretiny/bin/python}
DDIR=$(dirname "$DYAML"); DNAME=$(basename "$DYAML" .yaml)
CDIR=$(dirname "$CYAML"); CNAME=$(basename "$CYAML" .yaml)
DLOG=$DDIR/$DNAME-serial.log; CLOG=$CDIR/$CNAME-serial.log
INVALID=""
AP_WAIT=120; UPLOAD_WAIT=300; SAVE_WAIT=240
# Crash signatures (extended regex), not bare words: "aborting connection attempt" is a normal Wi-Fi retry line
CRASH_RE='Panic |abort\(\)|Fatal exception|Exception \(|Guru Meditation|HardFault|Bus Fault|Backtrace:|Soft WDT reset|wdt reset|Brownout|rst cause:[24]'
# Boot banners and port losses are counted apart: a Pico reboot shows as a port loss (and maybe a banner too),
# while on a UART board a port loss comes from the adapter or the hub, not the chip
BANNER_RE='Log initialized'; LOSS_RE='\[capture\] port lost'
DUT_CAPTURE=$((AP_WAIT + UPLOAD_WAIT + SAVE_WAIT + SETTLE + 60)); CLIENT_CAPTURE=$((SAVE_WAIT + SETTLE + 60))
bound() { local t=$1; shift; if command -v timeout >/dev/null; then timeout "$t" "$@"; else perl -e 'alarm shift; exec @ARGV' "$t" "$@"; fi; }
count() { LC_ALL=C grep -a -c "$1" "$2"; }
wait_for() { # wait_for FILE PATTERN SECONDS -> prints elapsed, status 1 on timeout
local n=0; until LC_ALL=C grep -a -q "$2" "$1"; do sleep 2; n=$((n+2)); [ $n -ge "$3" ] && { echo "$n"; return 1; }; done; echo "$n"; }
# Only this run's captures: another capture on the host (a soak on a third board) must survive
stop_tails() { pkill -f "[s]erial_capture.py $DPORT " 2>/dev/null; pkill -f "[s]erial_capture.py $CPORT " 2>/dev/null; sleep 1; }
fail() { echo "[hammer] FAILED: $1"; stop_tails; exit 1; }
port_never_opened() { # an open failure and no line that is not a capture note: the port, not the firmware
LC_ALL=C grep -a -q '\[capture\] open failed' "$1" && ! LC_ALL=C grep -a -q -v '^\[[0-9:]*\]\[capture\]' "$1"; }
compile() { # compile YAML TREE
(cd "$2" && PYTHONUNBUFFERED=1 bound 1500 "$PY" -m esphome compile "$1" > "${1%.yaml}-compile.log" 2>&1); local rc=$?
echo "[hammer] compile $(basename "$1"): rc=$rc"; return $rc; }
esp_upload() { # esp_upload YAML PORT TREE
(cd "$3" && bound $UPLOAD_WAIT "$PY" -m esphome upload "$1" --device "$2" > "${1%.yaml}-upload.log" 2>&1)
local ok; ok=$(count 'Successfully uploaded' "${1%.yaml}-upload.log"); echo "[hammer] upload $(basename "$1"): ok=$ok"; [ "$ok" -ge 1 ]; }
stop_tails
compile "$DYAML" "$DTREE" || fail "DUT build failed, see $DDIR/$DNAME-compile.log"
compile "$CYAML" "$CTREE" || fail "client build failed, see $CDIR/$CNAME-compile.log"
TAILOPT=""
case $KIND in
esp) esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" ;;
pico)
SN=$("$PY" "$SKILL/scripts/ports.py" --serial-of "$DPORT") || fail "no USB serial number for $DPORT"
esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed"
# The Pico drops its CDC port on reboot and may already be back: take whatever path this board has now;
# the capture follows the serial number if the path changes again
DPORT=$("$PY" "$SKILL/scripts/ports.py" --wait 2e8a --serial "$SN" --timeout 60) || fail "pico did not re-enumerate"
echo "[hammer] pico port after upload: $DPORT"; sleep 2 ;;
bw15|bk)
UF2=$(ls "$DDIR/.esphome/build/$DNAME/.pioenvs/$DNAME/"*.uf2 2>/dev/null | head -1)
[ -n "$UF2" ] || fail "no uf2 under $DDIR/.esphome/build/$DNAME"
echo "[hammer] uf2: ${UF2##*/}"
W=$SKILL/scripts/flash_rtl87xx_kit.py; [ "$KIND" = bk ] && W=$SKILL/scripts/flash_bk72xx.py
(cd "$DDIR" && bound 400 "$LTPY" "$W" flash write -d "$DPORT" "$UF2" > "$DDIR/$DNAME-upload.log" 2>&1); rc=$?
echo "[hammer] $KIND flash: rc=$rc"; [ $rc -eq 0 ] || fail "DUT flash failed, see $DDIR/$DNAME-upload.log"
TAILOPT="--released" ;;
*) echo "unknown DUT_KIND $KIND"; exit 1 ;;
esac
: > "$DLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$DPORT" $DUT_CAPTURE "$DLOG" $TAILOPT 2> "${DLOG%-serial.log}-capture.err" &)
if ! n=$(wait_for "$DLOG" "Captive portal\|Starting fallback AP" $AP_WAIT); then
[ -s "$DLOG" ] || fail "the DUT capture produced nothing (the capture itself failed), see ${DLOG%-serial.log}-capture.err"
port_never_opened "$DLOG" && fail "could not open $DPORT (held by another process, or the wrong port), see $DLOG"
fail "DUT never raised its AP within ${n}s, see $DLOG"
fi
echo "[hammer] DUT fallback AP after ${n}s"; sleep 2
# reboots are counted from here; the first banner may predate the capture
BANNER_BASE=$(count "$BANNER_RE" "$DLOG"); LOSS_BASE=$(count "$LOSS_RE" "$DLOG")
esp_upload "$CYAML" "$CPORT" "$CTREE" || fail "client upload failed"
: > "$CLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$CPORT" $CLIENT_CAPTURE "$CLOG" 2> "${CLOG%-serial.log}-capture.err" &)
if n=$(wait_for "$CLOG" "real save status\|posting the real" $SAVE_WAIT); then
echo "[hammer] client posted the real credentials after ${n}s; settling ${SETTLE}s"; sleep "$SETTLE"
else
[ -s "$CLOG" ] || fail "the client capture produced nothing (the capture itself failed), see ${CLOG%-serial.log}-capture.err"
port_never_opened "$CLOG" && fail "could not open $CPORT (held by another process, or the wrong port), see $CLOG"
INVALID="client never reached the real save within ${n}s"; echo "[hammer] WARNING: $INVALID"
fi
# A verdict needs complete evidence: both captures must have lasted the run and logged no error of their own
for spec in "$DPORT:${DLOG%-serial.log}-capture.err" "$CPORT:${CLOG%-serial.log}-capture.err"; do
port=${spec%%:*}; err=${spec#*:}
pgrep -f "[s]erial_capture.py $port " >/dev/null || INVALID="${INVALID:+$INVALID; }the capture on $port ended before the run did, see $err"
[ -s "$err" ] && INVALID="${INVALID:+$INVALID; }the capture on $port reported an error, see $err"
done
CRASHES=$(LC_ALL=C grep -a -c -E "$CRASH_RE" "$DLOG")
BANNERS=$(( $(count "$BANNER_RE" "$DLOG") - BANNER_BASE )); LOSSES=$(( $(count "$LOSS_RE" "$DLOG") - LOSS_BASE ))
if [ "$KIND" = pico ]; then
REBOOTS=$BANNERS; [ "$LOSSES" -gt "$REBOOTS" ] && REBOOTS=$LOSSES # one reboot can show as both, so take the larger
else
REBOOTS=$BANNERS; [ "$LOSSES" -gt 0 ] && INVALID="${INVALID:+$INVALID; }the DUT port dropped $LOSSES time(s) mid-run (adapter or hub, not the chip)"
fi
STA=$(LC_ALL=C grep -a 'IP Address:' "$DLOG" | grep -v -c '192\.168\.4\.'); REAL=$(LC_ALL=C grep -a -o 'real save status [-0-9]*' "$CLOG" | tail -1 | awk '{print $4}')
SAVES=$(count 'Requested WiFi Settings Change' "$DLOG"); EXITS=$(count 'cooldown early' "$DLOG"); ANSWERED=$(count 'bogus save status 200' "$CLOG")
echo "[hammer] $DNAME: saves requested $SAVES, cooldown exits $EXITS, crash markers $CRASHES, after the AP came up: boot banners $BANNERS, port losses $LOSSES, reboots $REBOOTS; STA addresses $STA"
FAILED=$(count 'bogus save status -1' "$CLOG")
echo "[hammer] $CNAME: bogus saves answered $ANSWERED, failed $FAILED, real save status ${REAL:-none}"
# Only whole messages, matched where the message starts, so a credential line or an odd SSID never gets in
LC_ALL=C grep -a -E "^\[[0-9:]+\](\[[A-Z]\]\[[a-z_]+:[0-9]+\]: *(Exiting cooldown early|Starting fallback AP|Captive portal|Requested WiFi Settings Change|Retry phase|Connected\$|IP Address: )|\[capture\] port lost|($CRASH_RE))" "$DLOG" | LC_ALL=C tr -cd '\11\12\15\40-\176' | sed -E 's/(IP Address: )[^ ]+/\1<redacted>/' | cut -c1-110 | head -80 > "$DDIR/$DNAME-events.txt"
echo "[hammer] events: $DDIR/$DNAME-events.txt"
stop_tails
[ -z "$INVALID" ] || { echo "[hammer] RESULT INVALID: $INVALID"; exit 2; }
# Provisioning is proven by the device joining the network; the client's answer to the real save can time
# out because the device leaves the AP before replying, so its status is information only
if [ "$CRASHES" -gt 0 ] || [ "$REBOOTS" -gt 0 ] || [ "$STA" -lt 1 ]; then
echo "[hammer] RESULT FAIL: crash markers $CRASHES, reboots $REBOOTS, STA addresses $STA, real save status ${REAL:-none}"; exit 3
fi
# A pass needs the stimulus the flow is about: at least half the configured bogus saves answered and one cooldown exit
CONFIGURED=$(LC_ALL=C grep -a -E '^[[:space:]]*bogus_saves:' "$CYAML" | head -1 | tr -dc '0-9'); CONFIGURED=${CONFIGURED:-15}
if [ "$EXITS" -lt 1 ] || [ $((ANSWERED * 2)) -lt "$CONFIGURED" ]; then
echo "[hammer] RESULT INVALID: insufficient stimulus (bogus saves answered $ANSWERED of $CONFIGURED, cooldown exits $EXITS)"; exit 2
fi
[ "$FAILED" -eq 0 ] || echo "[hammer] WARNING: $FAILED of $CONFIGURED bogus saves got no answer"
echo "[hammer] RESULT PASS"; exit 0
@@ -0,0 +1,77 @@
# Package for the client board: it joins the device under test's AP (priority 10), posts
# `bogus_saves` bogus /wifisave requests `save_interval` apart, then posts the real credentials once.
# Include from a per-run yaml that holds the platform block and the substitutions (example-client.yaml);
# real_ssid and real_psk come from the scratch directory's secrets.yaml and must be URL safe.
# Set bogus_saves to 1 for a plain provisioning check. Log lines the driver script counts:
# "bogus save N", "bogus save status 200", "posting the real credentials", "real save status 200"
# The hammer is a single-mode script, so a reconnect to the AP (the device under test rebooted) does not
# start a second series on top of the first.
substitutions:
ap_password: hwtestpass
bogus_saves: "15"
save_interval: 3s
bogus_ssid: hwtest-no-such-network
bogus_psk: nosuchpassword
esphome:
name: ${name}
logger:
level: DEBUG
http_request:
id: http
verify_ssl: false
timeout: 10s
wifi:
id: wifi_id
networks:
- ssid: ${ap_ssid}
password: ${ap_password}
priority: 10
- ssid: ${real_ssid}
password: ${real_psk}
on_connect:
- lambda: |-
char b[40];
ESP_LOGI("test", "connected, ip %s", id(wifi_id).get_ip_addresses()[0].str_to(b));
- if:
condition:
lambda: |-
char b[40];
return strncmp(id(wifi_id).get_ip_addresses()[0].str_to(b), "192.168.4.", 10) == 0;
then:
- script.execute: hammer
script:
- id: hammer
mode: single
then:
- delay: 3s
- logger.log: "hammering /wifisave with bogus credentials"
- repeat:
count: ${bogus_saves}
then:
- lambda: ESP_LOGI("test", "bogus save %u", (unsigned) iteration + 1);
- http_request.get:
url: "http://192.168.4.1/wifisave?ssid=${bogus_ssid}&psk=${bogus_psk}"
capture_response: true
on_response:
- lambda: ESP_LOGI("test", "bogus save status %d", response->status_code);
on_error:
- lambda: ESP_LOGW("test", "bogus save status -1");
- delay: ${save_interval}
- logger.log: "posting the real credentials"
- http_request.get:
url: "http://192.168.4.1/wifisave?ssid=${real_ssid}&psk=${real_psk}"
capture_response: true
on_response:
- lambda: ESP_LOGI("test", "real save status %d", response->status_code);
on_error:
- lambda: ESP_LOGW("test", "real save status -1");
interval:
- interval: 5s
then:
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
@@ -0,0 +1,31 @@
# Package for a device under test that never finds its network, falls back to its AP and serves the
# captive portal. Include it from a per-run yaml that holds the platform block and the substitutions
# (see example-dut.yaml). Bump `name` on every run: saved credentials are keyed by it, and a reused
# name boots straight onto the real network and never raises the AP.
substitutions:
ap_password: hwtestpass
ap_timeout: 15s
bogus_ssid: hwtest-no-such-network
bogus_psk: nosuchpassword
esphome:
name: ${name}
logger:
level: DEBUG
wifi:
networks:
- ssid: ${bogus_ssid}
password: ${bogus_psk}
ap:
ssid: ${ap_ssid}
password: ${ap_password}
ap_timeout: ${ap_timeout}
captive_portal:
interval:
- interval: 5s
then:
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
@@ -0,0 +1,18 @@
# Client board for the /wifisave hammer. Copy into a scratch directory next to a secrets.yaml;
# ap_ssid must match the device under test, real_ssid and real_psk must be URL safe.
substitutions:
name: hwtest-client-1
ap_ssid: hwtest-ap
real_ssid: !secret wifi_ssid
real_psk: !secret wifi_password
bogus_saves: "15"
esp32:
board: esp32dev
framework:
type: esp-idf
# esp8266: {board: d1_mini}
packages:
hwtest: !include client-wifisave.yaml
@@ -0,0 +1,16 @@
# Device under test for the /wifisave hammer. Copy into a scratch directory, keep the file name equal
# to `name`, pick one platform block, bump `name` every run.
substitutions:
name: hwtest-dut-1
ap_ssid: hwtest-ap
esp8266:
board: d1_mini
# esp32: {board: esp32dev, framework: {type: esp-idf}}
# rtl87xx: {board: bw15}
# bk72xx: {board: generic-bk7238} # plus logger: {hardware_uart: UART1}, BK72xx logs on UART2 by default
# rp2040: {board: rpipico2w}
packages:
hwtest: !include dut-fallback-ap.yaml
+1 -1
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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
+26 -10
View File
@@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
message +=
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
`this PR is on the large side `;
`Hey @${prAuthor}, thanks for the contribution. This PR is on the ` +
`large side `;
if (tooManyLabels && tooManyChanges) {
message +=
@@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
}
message += `, which makes it harder for maintainers to review.\n\n`;
let condition;
if (tooManyLabels && tooManyChanges) {
condition =
`is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` +
`at most ${MAX_LABELS} components/areas`;
} else if (tooManyLabels) {
condition = `touches at most ${MAX_LABELS} components/areas`;
} else {
condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`;
}
message +=
`Smaller, focused PRs tend to be reviewed much faster since they ` +
`fit into the short gaps between other maintainer work; large ones ` +
`often have to wait for a rare long uninterrupted block of time. ` +
`If you can break this up into smaller pieces that can be reviewed ` +
`independently, it will almost certainly land faster overall.\n\n`;
`**This PR will not be reviewed until it ${condition}.** We know ` +
`that is not what you want to hear after this much work, and we want ` +
`to be upfront so you do not wait for a review that is not coming: ` +
`please split the change. The label is removed automatically once ` +
`the PR ${condition}.\n\n`;
message +=
`Before putting more time in, it's also worth popping into ` +
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
`you scope things and flag anything already in flight.\n\n`;
`There is no exception you can request. Rarely, a maintainer agrees in ` +
`\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` +
`genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` +
`but that is rare enough that you should plan on splitting rather than ` +
`wait for it. Smaller, focused PRs are picked up much faster because ` +
`they fit into the short gaps between other maintainer work. For a new ` +
`component, the first PR brings the hub and a single platform (for ` +
`example \`sensor\`); each further platform is its own PR on top of it.\n\n`;
message +=
`For more details (including how to split the work up), see: ` +
`https://developers.esphome.io/contributing/submitting-your-work/` +
@@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess)
}
module.exports = {
generateReviewMessages,
handleReviews,
handleMaintainerAccessComment
};
@@ -0,0 +1,38 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { generateReviewMessages } = require('../reviews');
const MAX_LABELS = 15;
const TOO_BIG_THRESHOLD = 1000;
function tooBigMessage(labelCount, additions) {
const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }];
const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD);
assert.equal(messages.length, 1);
return messages[0];
}
describe('too-big review message', () => {
it('names the line cap when the PR is too long', () => {
const message = tooBigMessage(3, 1500);
assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./);
assert.match(message, /removed automatically once the PR is at most 1000 lines/);
assert.doesNotMatch(message, /components\/areas\./);
});
it('names the area cap when the PR touches too many areas but is short', () => {
const message = tooBigMessage(20, 200);
assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./);
assert.doesNotMatch(message, /until it is at most 1000 lines/);
});
it('names both caps when both are exceeded', () => {
const message = tooBigMessage(20, 1500);
assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./);
});
it('tells new-component authors to bring one platform first', () => {
assert.match(tooBigMessage(3, 1500), /hub and a single platform/);
assert.match(tooBigMessage(3, 1500), /mega-pr/);
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+2 -2
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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
@@ -97,7 +97,7 @@ jobs:
run: git diff
- if: failure()
name: Archive artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: generated-proto-files
path: |
+2 -2
View File
@@ -128,7 +128,7 @@ jobs:
- name: Upload compile-test image artifact
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
# The tar is already compressed, so upload it as-is. archive: false
# skips the redundant zip and makes the file name the artifact name
@@ -210,7 +210,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: compile-test-image.tar.zst
- name: Load image
+36 -22
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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -81,8 +81,8 @@ jobs:
- name: Install apt packages (cached)
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
determine-jobs:
name: Determine which jobs to run
@@ -415,8 +415,8 @@ jobs:
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Set up Python 3.13
id: python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -438,7 +438,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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -477,7 +477,7 @@ jobs:
# Tests that compare rendered output write the image they actually got here, so a
# failure can be looked at without reproducing the whole build locally.
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: integration-test-artifacts-${{ matrix.bucket.name }}
path: test_artifacts/
@@ -488,7 +488,7 @@ jobs:
# script/update_integration_test_durations.py; only full matrix dev
# runs produce usable data.
if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-integration-${{ strategy.job-index }}
path: junit-integration.xml
@@ -528,7 +528,7 @@ jobs:
script/check_import_time.py --check --har importtime.har
- name: Upload waterfall HAR
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: import-time-waterfall
path: importtime.har
@@ -626,7 +626,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0
uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2
with:
run: |
. venv/bin/activate
@@ -680,7 +680,7 @@ jobs:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP8266
options: --environment esp8266-arduino-tidy --grep USE_ESP8266
options: --environment esp8266-arduino-tidy --grep USE_ESP8266 --grep USE_SOCKET_IMPL_LWIP_TCP --grep USE_SHD_FIRMWARE_DATA
pio_cache_key: tidyesp8266
- id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino
@@ -693,12 +693,16 @@ jobs:
ignore_errors: false
- id: clang-tidy
name: Run script/clang-tidy for RP2
options: --environment rp2-tidy --grep USE_RP2
options: --environment rp2-tidy --grep USE_RP2 --grep USE_BLE_SCAN_RESPONSE_MERGER
pio_cache_key: tidyrp2
- id: clang-tidy
name: Run script/clang-tidy for host
options: --environment host-tidy --grep USE_HOST
pio_cache_key: tidyhost
- id: clang-tidy
name: Run script/clang-tidy for LibreTiny
environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X --grep USE_LN882H_BLE --grep USE_SOCKET_IMPL_LWIP_SOCKETS
pio_cache_key: tidylibretiny
steps:
@@ -707,6 +711,16 @@ jobs:
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
- name: Install apt packages (cached)
if: matrix.pio_cache_key == 'tidyhost'
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
# by dsmr and dlms_meter. Packages and version must match seed-apt-cache
# exactly.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Restore Python
id: restore-python
@@ -989,14 +1003,14 @@ jobs:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 S3
# yamllint disable-line rule:line-length
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM --grep USE_OPENTHREAD
tidy_environment: esp32s3-idf-tidy
- id: clang-tidy
name: Run script/clang-tidy for ESP32 P4
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
# so their code paths differ -- lint them under the P4 build too.
# yamllint disable-line rule:line-length
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep CONFIG_IDF_TARGET_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
tidy_environment: esp32p4-idf-tidy
- id: clang-tidy
name: Run script/clang-tidy for ESP32 C6
@@ -1102,8 +1116,8 @@ jobs:
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -1448,7 +1462,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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -1647,7 +1661,7 @@ jobs:
fi
- name: Upload memory analysis JSON
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: memory-analysis-target
path: memory-analysis-target.json
@@ -1710,7 +1724,7 @@ jobs:
--platform "$platform"
- name: Upload memory analysis JSON
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: memory-analysis-pr
path: memory-analysis-pr.json
@@ -1740,13 +1754,13 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Download target analysis JSON
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: memory-analysis-target
path: ./memory-analysis
continue-on-error: true
- name: Download PR analysis JSON
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: memory-analysis-pr
path: ./memory-analysis
@@ -35,7 +35,7 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+3 -3
View File
@@ -162,7 +162,7 @@ jobs:
# version: ${{ needs.init.outputs.tag }}
- name: Upload digests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digests-${{ matrix.platform.arch }}
path: /tmp/digests
@@ -195,7 +195,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: digests-*
path: /tmp/digests
@@ -245,7 +245,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -43,7 +43,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+2 -2
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.10
rev: v0.17.0
hooks:
# Run the linter.
- id: ruff
@@ -36,7 +36,7 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
rev: v3.22.0
hooks:
- id: pyupgrade
args: [--py312-plus]
+72 -1
View File
@@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting
- Function-local constants: `lower_snake_case`
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
- Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
@@ -143,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting
* **C++ Preprocessor Directives:**
* **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums.
* **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope
and inside functions; `static const` is only for a value computed at run time. A function-local
`static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too.
* **Use `#define` only for:**
- Conditional compilation (`#ifdef`, `#ifndef`)
- Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`)
@@ -177,6 +181,8 @@ file does, and it is the authority when they disagree. The most useful starting
Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use
`set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms:
https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb
* **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm
rebuilds the callback and re-queues a scheduler item, and the period drifts.
* **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a
`static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other
components, so number them from 0 and keep all of a component's ids together in one place.
@@ -191,6 +197,36 @@ file does, and it is the authority when they disagree. The most useful starting
// Bad - this is exactly what the base class already does
float get_setup_priority() const override { return setup_priority::DATA; }
```
* **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope
constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the
table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`);
for a table of structs copy the whole entry out and return the copy, never a pointer into flash.
On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`);
pick the helper's polynomial and bit-order arguments instead of writing a CRC loop.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
end of `setup()` when the entities need a state at boot.
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
resend and give-up logic handles it; never advance on an answer that was only logged.
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
listener base (a second vptr per entity).
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
(`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check
stays inside the `#ifdef`.
* **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure
sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`.
* **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a
runtime check of something the build already knows.
* **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they
can be stored in flash rather than RAM.
```cpp
@@ -359,7 +395,7 @@ file does, and it is the authority when they disagree. The most useful starting
* **Triggers -- Callback method (preferred):**
Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema.
Use `build_callback_automation()` for simple triggers. The automation is registered directly as a callback on the parent, so there is no C++ Trigger class and nothing is allocated. No `CONF_TRIGGER_ID` in the schema.
**Python:**
```python
@@ -412,6 +448,24 @@ file does, and it is the authority when they disagree. The most useful starting
forward=[cg.StringRef(parent.option_at(index)), index],
)
```
**Choosing the filter:** a filter that many automations share, such as entering one value of a state enum,
is a small forwarder struct templated on that value. Every automation for that value then shares one
function and passes only its `Automation *`. `when` generates a separate lambda per automation, which cost
16 to 64 bytes more flash when lock, media_player, alarm_control_panel and fusb302b were tried with it.
```cpp
template<LockState State> struct LockStateForwarder {
Automation<> *automation;
void operator()(LockState state) const {
if (state == State)
this->automation->trigger();
}
};
```
Use `when` for a test that is not a fixed value shared across automations, e.g. one that reads the parent:
`when="{parent}->is_fully_open()"` (valve). Only a trigger that must remember something between calls
(the last position, a click count) keeps a Trigger class.
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
@@ -534,8 +588,12 @@ file does, and it is the authority when they disagree. The most useful starting
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
* **Configuration Validation:**
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
that rejects keys and fills defaults afterwards.
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
@@ -569,6 +627,18 @@ file does, and it is the authority when they disagree. The most useful starting
* **Testing:**
* **Python:** Run unit tests with `pytest`.
* **C++:** Use `clang-tidy` for static analysis.
* **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its
`responses` match on content every time they see it; to answer a repeated request differently, use an
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
on. A variant per board for platform-identical code only adds CI time.
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
```
tests/
@@ -615,6 +685,7 @@ file does, and it is the authority when they disagree. The most useful starting
name: My Sensor
```
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
`!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead.
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
```bash
+54 -2
View File
@@ -39,12 +39,17 @@ esphome/components/am43/sensor/* @buxtronix
esphome/components/analog_threshold/* @ianchi
esphome/components/animation/* @syndlex
esphome/components/anova/* @buxtronix
esphome/components/apc1/* @ademuri
esphome/components/apc_proteous/* @clydebarrow
esphome/components/apds9306/* @aodrenah
esphome/components/apds9930/* @iyesin
esphome/components/api/* @esphome/core
esphome/components/aqi/* @freekode @jasstrong @ximex
esphome/components/aranet/* @patrickod
esphome/components/as5600/* @ammmze
esphome/components/as5600/sensor/* @ammmze
esphome/components/as7341/* @mrgnr
esphome/components/as734x/* @latonita @mrgnr
esphome/components/async_tcp/* @esphome/core
esphome/components/at581x/* @X-Ryl669
esphome/components/atc_mithermometer/* @ahpohl
@@ -65,6 +70,7 @@ esphome/components/bedjet/climate/* @jhansche
esphome/components/bedjet/fan/* @jhansche
esphome/components/bedjet/sensor/* @javawizard @jhansche
esphome/components/beken_spi_led_strip/* @Mat931
esphome/components/bh1745/* @latonita
esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core
@@ -100,6 +106,9 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bq25186/* @Rapsssito
esphome/components/bq27220/* @rggammon
esphome/components/bq76972/* @limpkin
esphome/components/bridge/* @kbx81
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
@@ -148,6 +157,7 @@ esphome/components/delonghi/* @grob6000
esphome/components/dew_point/* @CFlix
esphome/components/dfplayer/* @glmnet
esphome/components/dfrobot_sen0395/* @niklasweber
esphome/components/dfrobot_sen0609/* @remcom
esphome/components/dht/* @OttoWinter
esphome/components/display_menu_base/* @numo68
esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
@@ -156,8 +166,10 @@ esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds1603l/* @JakeLC15
esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/ds3231/* @linkedupbits
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ecocomfort2/* @gledian
esphome/components/ee895/* @Stock-M
esphome/components/ektf2232/touchscreen/* @jesserockz
esphome/components/emc2101/* @ellull
@@ -174,6 +186,7 @@ esphome/components/es7243e/* @kbx81
esphome/components/es8156/* @kbx81
esphome/components/es8311/* @kahrendt @kroimon
esphome/components/es8388/* @P4uLT
esphome/components/es9038q2m_katana/* @GioMez
esphome/components/esp32/* @esphome/core
esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito
esphome/components/esp32_ble_client/* @bdraco @jesserockz
@@ -183,6 +196,7 @@ esphome/components/esp32_camera_web_server/* @ayufan
esphome/components/esp32_can/* @Sympatron
esphome/components/esp32_hosted/* @swoboda1337
esphome/components/esp32_hosted/update/* @swoboda1337
esphome/components/esp32_power_management/* @rwrozelle
esphome/components/esp32_rmt/* @jesserockz
esphome/components/esp32_rmt_led_strip/* @jesserockz
esphome/components/esp8266/* @esphome/core
@@ -197,15 +211,19 @@ esphome/components/exponential_moving_average/* @clydebarrow
esphome/components/exposure_notifications/* @OttoWinter
esphome/components/ezo/* @ssieb
esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/ezsp_proxy_tap/* @kbx81 @puddly
esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
esphome/components/file/* @esphome/core
esphome/components/filter_lifetime/* @tuct
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core
esphome/components/friedrich/* @rwrozelle
esphome/components/fs3000/* @kahrendt
esphome/components/ft5x06/* @clydebarrow
esphome/components/ft63x6/* @gpambrozio
esphome/components/fusb302b/* @remcom
esphome/components/gcja5/* @gcormier
esphome/components/gdk101/* @Szewcson
esphome/components/gl_r01_i2c/* @pkejval
@@ -227,9 +245,13 @@ esphome/components/gt911/* @clydebarrow @jesserockz
esphome/components/haier/* @paveldn
esphome/components/haier/binary_sensor/* @paveldn
esphome/components/haier/button/* @paveldn
esphome/components/haier/select/* @paveldn
esphome/components/haier/sensor/* @paveldn
esphome/components/haier/switch/* @paveldn
esphome/components/haier/text_sensor/* @paveldn
esphome/components/haier_tundra/* @fauxpark
esphome/components/haier_tundra/button/* @fauxpark
esphome/components/haier_tundra/switch/* @fauxpark
esphome/components/havells_solar/* @sourabhjaiswal
esphome/components/hbridge/fan/* @WeekendWarrior
esphome/components/hbridge/light/* @DotNetDann
@@ -238,6 +260,7 @@ esphome/components/hc8/* @omartijn
esphome/components/hdc2010/* @optimusprimespace @ssieb
esphome/components/hdc2080/* @G-Pereira @jesserockz
esphome/components/hdc302x/* @joshuasing
esphome/components/hdmi_cec/* @JosVanEijndhoven
esphome/components/he60r/* @clydebarrow
esphome/components/heatpumpir/* @rob-deutsch
esphome/components/hitachi_ac424/* @sourabhjaiswal
@@ -264,6 +287,7 @@ esphome/components/http_request/ota/* @oarcher
esphome/components/http_request/update/* @jesserockz
esphome/components/htu31d/* @betterengineering
esphome/components/hub75/* @stuartparmenter
esphome/components/husb238/* @latonita
esphome/components/hydreon_rgxx/* @functionpointer
esphome/components/hyt271/* @Philippe12
esphome/components/i2c/* @esphome/core
@@ -271,7 +295,9 @@ esphome/components/i2c_device/* @gabest11
esphome/components/i2s_audio/* @jesserockz
esphome/components/i2s_audio/microphone/* @jesserockz
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
esphome/components/i2s_clockless_led_strip/* @j9brown
esphome/components/iaqcore/* @yozik04
esphome/components/icm20689/* @lboue
esphome/components/icnt86/* @danepowell
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
esphome/components/improv_base/* @esphome/core
@@ -300,9 +326,11 @@ esphome/components/kuntze/* @ssieb
esphome/components/lc709203f/* @ilikecake
esphome/components/lcd_menu/* @numo68
esphome/components/ld2410/* @regevbr @sebcaps
esphome/components/ld2410s/* @NovakIrs
esphome/components/ld2412/* @Rihan9
esphome/components/ld2420/* @descipher
esphome/components/ld2450/* @hareeshmu
esphome/components/ld2460/* @jesusvallejo
esphome/components/ld24xx/* @kbx81
esphome/components/ld6002b/* @hepter
esphome/components/ledc/* @OttoWinter
@@ -311,6 +339,10 @@ esphome/components/libretiny_pwm/* @kuba2k2
esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lis2dw12/* @latonita
esphome/components/lis2dw12_i2c/* @latonita
esphome/components/lis2dw12_spi/* @latonita
esphome/components/lis3dh/* @zebble
esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
@@ -318,6 +350,7 @@ esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
esphome/components/lp5562/* @DennisGaida
esphome/components/lps22/* @nagisa
esphome/components/lsm6ds/* @clydebarrow
esphome/components/ltr390/* @latonita @sjtrny
@@ -328,7 +361,9 @@ esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
esphome/components/max17043/* @blacknell
esphome/components/max17048/* @B48D81EFCC
esphome/components/max31865/* @DAVe3283
esphome/components/max31888/* @JiriPrchal
esphome/components/max44009/* @berfenger
esphome/components/max6956/* @looping40
esphome/components/max7219digit/* @rspaargaren
@@ -347,7 +382,6 @@ esphome/components/mcp4461/* @p1ngb4ck
esphome/components/mcp4728/* @berfenger
esphome/components/mcp47a1/* @jesserockz
esphome/components/mcp9600/* @mreditor97
esphome/components/mcp9808/* @k7hpn
esphome/components/md5/* @esphome/core
esphome/components/mdns/* @esphome/core
esphome/components/media_player/* @jesserockz
@@ -358,9 +392,11 @@ esphome/components/microphone/* @jesserockz @kahrendt
esphome/components/mics_4514/* @jesserockz
esphome/components/midea/* @dudanov
esphome/components/midea_ir/* @dudanov
esphome/components/midea_portasplit/* @Fexiven
esphome/components/mipi_dsi/* @clydebarrow
esphome/components/mipi_rgb/* @clydebarrow
esphome/components/mipi_spi/* @clydebarrow
esphome/components/mistral_ir/* @lerhum
esphome/components/mitsubishi/* @RubyBailey
esphome/components/mitsubishi_cn105/* @crnjan
esphome/components/mixer/speaker/* @kahrendt
@@ -399,6 +435,8 @@ esphome/components/noblex/* @AGalfra
esphome/components/noise/* @esphome/core
esphome/components/npi19/* @bakerkj
esphome/components/nrf52/* @tomaszduda23
esphome/components/ns2009/* @tchilov
esphome/components/ns2009/touchscreen/* @tchilov
esphome/components/number/* @esphome/core
esphome/components/one_wire/* @ssieb
esphome/components/online_image/* @clydebarrow @guillempages
@@ -409,10 +447,12 @@ esphome/components/ota/* @esphome/core
esphome/components/output/* @esphome/core
esphome/components/packet_transport/* @clydebarrow
esphome/components/pca6416a/* @Mat931
esphome/components/pca9505/* @ajfriesen @ssieb
esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
esphome/components/pcf85063/* @brogon
esphome/components/pcf8563/* @KoenBreeman
esphome/components/pcm5122/* @remcom
esphome/components/pd_pioneer_ir/* @martinsah
esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984
@@ -431,6 +471,7 @@ esphome/components/pn7160/* @jesserockz @kbx81
esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/pn71xx/* @jesserockz @kbx81
esphome/components/power_management/* @rwrozelle
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
@@ -451,6 +492,8 @@ esphome/components/rc522/* @glmnet
esphome/components/rc522_i2c/* @glmnet
esphome/components/rc522_spi/* @glmnet
esphome/components/rd03d/* @jasstrong
esphome/components/remko_ar715/* @naKruul
esphome/components/renogy_inverter_ble/* @emilioaray-dev
esphome/components/resampler/speaker/* @kahrendt
esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
@@ -468,8 +511,10 @@ esphome/components/rtttl/* @glmnet @ximex
esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt
esphome/components/runtime_stats/* @bdraco
esphome/components/rx8025t/* @remcom
esphome/components/rx8111/* @techdregs
esphome/components/rx8130/* @beormund
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti
esphome/components/samsung/* @jorofi
esphome/components/scd4x/* @martgras @sjtrny
esphome/components/script/* @esphome/core
esphome/components/sdl/* @bdm310 @clydebarrow
@@ -498,11 +543,14 @@ esphome/components/sfa30/* @ghsensdev
esphome/components/sfa40/* @NoQuarrel
esphome/components/sgp40/* @SenexCrenshaw
esphome/components/sgp4x/* @martgras @SenexCrenshaw
esphome/components/sh1122_base/* @pl4nkton
esphome/components/sh1122_spi/* @pl4nkton
esphome/components/sha256/* @esphome/core
esphome/components/shelly_dimmer/* @edge90 @rnauber
esphome/components/sht3xd/* @mrtoy-me
esphome/components/sht4x/* @sjtrny
esphome/components/shutdown/* @esphome/core @jsuanet
esphome/components/siemens_ira211/* @fonix232
esphome/components/sigma_delta_output/* @Cat-Ion
esphome/components/sim800l/* @glmnet
esphome/components/sm10bit_base/* @Cossid
@@ -565,6 +613,7 @@ esphome/components/t6615/* @tylermenezes
esphome/components/tas2780/* @remcom
esphome/components/tas58xx/* @mrtoy-me @remcom
esphome/components/tc74/* @sethgirvan
esphome/components/tca8418/* @zebble
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
@@ -582,12 +631,15 @@ esphome/components/thermopro_ble/* @sittner
esphome/components/thermostat/* @kbx81
esphome/components/time/* @esphome/core
esphome/components/tinyusb/* @kbx81
esphome/components/tinyusb_keyboard/* @frederikbolding
esphome/components/tlc5947/* @rnauber
esphome/components/tlc5971/* @IJIJI
esphome/components/tm1621/* @Philippe12
esphome/components/tm1637/* @glmnet
esphome/components/tm1638/* @skykingjwc
esphome/components/tm1651/* @mrtoy-me
esphome/components/tmc2209/* @remcom
esphome/components/tmc22xx/* @remcom
esphome/components/tmp102/* @timsavage
esphome/components/tmp1075/* @sybrenstuvel
esphome/components/tmp117/* @Azimath
@@ -639,7 +691,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @glmnet
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
+159 -27
View File
@@ -48,8 +48,11 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_ESP32,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
PLATFORM_HOST,
SECRETS_FILES,
ErrorFormat,
Toolchain,
@@ -70,6 +73,7 @@ from esphome.util import (
get_serial_ports,
is_picotool_usb_permission_error,
list_yaml_files,
read_secret_line,
run_external_command,
run_external_process,
safe_print,
@@ -479,12 +483,9 @@ def has_ota() -> bool:
def has_native_ota() -> bool:
"""Check if native API OTA upload is available (``platform: esphome``)."""
if CONF_OTA not in CORE.config:
return False
return any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
for ota_item in CORE.config[CONF_OTA]
)
from esphome.espota2 import esphome_ota_items
return bool(esphome_ota_items(CORE.config))
def has_web_server_ota() -> bool:
@@ -1232,14 +1233,28 @@ def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]:
host = devices[0]
port_type = get_port_type(host)
ota_key = getattr(args, "ota_key", None)
if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL):
_LOGGER.warning(
"The presented OTA key only applies to network uploads; ignored for %s",
host,
)
platform_upload = platform_hooks.get_platform_hook(
CORE.target_platform, "upload_program"
)
if platform_upload is not None and platform_upload(config, args, host):
if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL):
# A platform's own network path (nrf52 mcumgr) has no key handshake
_LOGGER.warning(
"The presented OTA key only applies to the %s OTA platform; "
"ignored for %s",
CONF_ESPHOME,
host,
)
return 0, host
port_type = get_port_type(host)
# MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by
# _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths.
is_partition_table = getattr(args, "partition_table", False)
@@ -1282,6 +1297,8 @@ def upload_program(
network_devices = _resolve_network_devices(devices, config, args)
if chosen_platform == CONF_WEB_SERVER:
if ota_key:
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
if is_partition_table or is_bootloader:
raise EsphomeError(
f"{option_string} is only supported with the esphome OTA platform; "
@@ -1295,6 +1312,17 @@ def upload_program(
return _upload_via_native_api(config, network_devices, args)
def _ota_upload_platforms(config: ConfigType) -> dict[str, None]:
"""The network OTA platforms the config provides, one entry each, in
config order; the web_server final-validate hook merges duplicates."""
return {
ota_platform: None
for ota_item in config.get(CONF_OTA, [])
if (ota_platform := ota_item.get(CONF_PLATFORM))
in (CONF_ESPHOME, CONF_WEB_SERVER)
}
def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
"""Pick the OTA platform to use, optionally honoring ``--ota-platform``.
@@ -1307,16 +1335,7 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
``web_server`` only when that is the only available platform.
"""
# Use a dict (insertion-ordered) instead of a list so error messages and
# membership checks see one entry per platform even if the user has
# multiple ``ota:`` items of the same platform; the web_server OTA
# platform's final-validate hook merges duplicates anyway.
available: dict[str, None] = {}
for ota_item in config.get(CONF_OTA, []):
ota_platform = ota_item.get(CONF_PLATFORM)
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[ota_platform] = None
available = _ota_upload_platforms(config)
if not available:
raise EsphomeError(
f"Cannot upload Over the Air as the {CONF_OTA} configuration is not "
@@ -1337,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
return CONF_WEB_SERVER
def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
"""The validated esphome OTA platform block, if configured."""
from esphome.espota2 import esphome_ota_items
return next(iter(esphome_ota_items(config)), None)
def _upload_via_native_api(
config: ConfigType, network_devices: list[str], args: ArgsProtocol
) -> tuple[int, str | None]:
ota_conf: ConfigType = {}
for ota_item in config.get(CONF_OTA, []):
if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME:
ota_conf = ota_item
break
ota_conf = _esphome_ota_conf(config) or {}
from esphome import espota2
from esphome.components.noise import static_encryption_key
@@ -1354,9 +1376,15 @@ def _upload_via_native_api(
# Fail closed: an encryption block whose key did not resolve must never
# fall back to a plaintext upload
noise_psk = None
old_noise_psk = None
plaintext_fallback = False
allow_plaintext_upload = False
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
if ota_key := getattr(args, "ota_key", None):
# The user presents a key the device still runs instead of the
# configured one (a key change in progress); an explicit key never
# downgrades to plaintext
noise_psk = ota_key
elif (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
noise_psk = encryption_conf.get(CONF_KEY)
allow_plaintext_upload = bool(
encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD)
@@ -1369,6 +1397,8 @@ def _upload_via_native_api(
# Ensure the key is a string, as required by the underlying OTA implementation.
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
noise_psk = str(noise_psk)
if old_key := encryption_conf.get(espota2.CONF_OLD_KEY):
old_noise_psk = str(old_key)
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
noise_psk = str(api_key)
@@ -1418,19 +1448,30 @@ def _upload_via_native_api(
noise_psk,
plaintext_fallback=plaintext_fallback,
allow_plaintext_upload=allow_plaintext_upload,
old_noise_psk=old_noise_psk,
)
def _old_key_notice(config: ConfigType) -> None:
"""After `run`, whose image was just built from this config; a plain
`upload` may send a stale image that still carries the previous key."""
from esphome import espota2
for item in espota2.esphome_ota_items(config):
if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY):
_LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE)
def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]:
from esphome import web_server_ota
from esphome.espota2 import esphome_ota_items
from esphome.web_server_helpers import get_web_server_connection
if any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_item.get(CONF_ENCRYPTION) is not None
for ota_item in config.get(CONF_OTA, [])
ota_item.get(CONF_ENCRYPTION) is not None
for ota_item in esphome_ota_items(config)
):
_LOGGER.warning(
"This config has OTA encryption, but the web_server OTA path sends "
@@ -1738,7 +1779,60 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
ENV_OTA_KEY = "ESPHOME_OTA_KEY"
OTA_KEY_PLATFORM_ERROR = (
f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA "
"platform; to flash by serial instead, name the port with --device"
)
def _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None:
"""Take the key to present to the device for this upload, from the
prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is
not buried in build output."""
prompt = getattr(args, "prompt_ota_key", False)
env_key = getattr(args, "env_ota_key", None)
if not prompt and env_key is None:
return
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST:
_LOGGER.warning("A host program runs locally; the presented OTA key is ignored")
return
# The HTTP path has no key handshake; decide before prompting or
# compiling. A serial target is a serial flash: the key is not even
# asked for, and --ota-platform means nothing to it. Every entry must
# be serial: a missing port is skipped and the next entry may be a host
devices = getattr(args, "device", None) or []
serial_target = bool(devices) and all(
device == "SERIAL"
or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL)
for device in devices
)
chosen = (
_choose_ota_platform(config, getattr(args, "ota_platform", None))
if not serial_target and _ota_upload_platforms(config)
else None
)
if chosen == CONF_WEB_SERVER:
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
from esphome.components.noise import validate_encryption_key
from esphome.config_validation import Invalid
if serial_target:
_LOGGER.warning(
"The presented OTA key only applies to network uploads; ignored for %s",
", ".join(devices),
)
return
key = read_secret_line("OTA encryption key: ") if prompt else env_key
try:
validate_encryption_key(key)
except Invalid as err:
raise EsphomeError(f"Invalid OTA encryption key: {err}") from err
args.ota_key = key
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
_read_ota_key(args, config)
# Get devices, resolving special identifiers like OTA
devices = choose_upload_log_host(
default=args.device,
@@ -1786,6 +1880,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
# Elsewhere the flag is ignored, so serial stays fine there.
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
return 1
_read_ota_key(args, config)
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
@@ -1811,6 +1906,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
exit_code, successful_device = upload_program(config, args, devices)
if exit_code == 0:
_LOGGER.info("Successfully uploaded program.")
_old_key_notice(config)
else:
_LOGGER.warning("Failed to upload to %s", devices)
return exit_code
@@ -2216,6 +2312,15 @@ def _error_format(value: str) -> ErrorFormat:
) from None
ARGUMENT_HELP_PROMPT_OTA_KEY = (
"Ask for the OTA encryption key to present to the device instead of using "
"the one in the configuration, for a device that still runs a previous key. "
"The key is read without echo, or as one line from stdin when that is not "
"a terminal; it is never taken from the command line. A calling program "
f"can hand the key over in the {ENV_OTA_KEY} environment variable instead."
)
def parse_args(argv):
options_parser = argparse.ArgumentParser(add_help=False)
options_parser.add_argument(
@@ -2384,6 +2489,11 @@ def parse_args(argv):
help="Upload as partition table (OTA).",
action="store_true",
)
parser_upload.add_argument(
"--prompt-ota-key",
action="store_true",
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
)
parser_upload.add_argument(
"--bootloader",
help="Upload as bootloader (OTA).",
@@ -2464,6 +2574,11 @@ def parse_args(argv):
"(HTTP Basic auth) when that is the only configured platform."
),
)
parser_run.add_argument(
"--prompt-ota-key",
action="store_true",
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
)
parser_clean = subparsers.add_parser(
"clean-mqtt",
@@ -2689,6 +2804,23 @@ def run_esphome(argv):
args.log_level = "CRITICAL"
setup_log(log_level=args.log_level)
# Taken out of the environment before any command runs, so no build
# tool inherits a key a calling program handed over
args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None)
if args.command in ("upload", "run"):
# One key is one device's; several configs run in child processes
# that would never see it
if (args.env_ota_key is not None or args.prompt_ota_key) and len(
args.configuration
) > 1:
raise EsphomeError(
f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; "
f"{len(args.configuration)} were given"
)
elif args.env_ota_key is not None:
_LOGGER.warning(
"%s is set but the %s command does not use it", ENV_OTA_KEY, args.command
)
_warn_if_source_tree_mismatch()
_warn_if_intel_macos()
+134 -24
View File
@@ -1,4 +1,4 @@
from collections.abc import Callable, Sequence
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import logging
import string
@@ -248,6 +248,23 @@ def flash_string(config: ConfigType, value: str) -> str:
return str(cg.progmem_string(value))
def flash_compare(config: ConfigType, value: str) -> str:
"""Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266."""
if CORE.is_esp8266:
return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}"
return str(cg.safe_exp(value))
def _default_std_string(
members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str]
) -> list[tuple[str, Any, Any]]:
"""Give ``cg.std_string`` members without a renderer the given default."""
return [
(key, t, fn or (renderer if t is cg.std_string else None))
for key, t, fn in members
]
def literal_with_length(config: ConfigType, value: str) -> str:
"""Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length.
@@ -465,6 +482,22 @@ def register_apply_action(
) -> None:
"""Register an action that only forwards config values to its parent, with no C++ class.
See ``apply_action_builder``.
"""
register_action(name, ApplyAction, schema, synchronous=True)(
apply_action_builder(name, schema, *fields, call=call, id_key=id_key)
)
def apply_action_builder(
name: str,
schema: cv.Schema,
*fields: ApplyField | ApplyCall,
call: str | None = None,
id_key: str = CONF_ID,
) -> Callable[..., Awaitable[MockObj]]:
"""The builder ``register_apply_action`` registers, for a caller that wraps it.
Generates one static function with the parent (read from ``id_key``) and constants baked
in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it.
A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names.
@@ -475,10 +508,7 @@ def register_apply_action(
statements_spec = [
(
c.target,
[
(key, t, fn or (flash_string if t is cg.std_string else None))
for key, t, fn in c.members
],
_default_std_string(c.members, flash_string),
)
for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields)
]
@@ -515,7 +545,7 @@ def register_apply_action(
action_id, cg.void, template_arg, lambda_args, statements
)
register_action(name, ApplyAction, schema, synchronous=True)(builder)
return builder
def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool:
@@ -547,11 +577,12 @@ def register_apply_condition(
``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an
``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares
against config values, all of which must be present. Write ``== false`` to negate.
String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member.
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
Generates one static predicate and an ``ApplyCondition`` templated on it.
"""
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
members = call.members
members = _default_std_string(call.members, flash_compare)
_check_key_in_schema(name, schema, id_key)
for conf_key, _, _ in members:
_check_key_in_schema(name, schema, conf_key)
@@ -724,7 +755,7 @@ async def condition_group_to_code(
if len(conditions) == 1:
return conditions[0]
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions
)
@@ -840,11 +871,13 @@ async def if_action_to_code(
condition = await build_condition(config[cond_conf], template_arg, args)
var = cg.new_Pvariable(action_id, if_template_arg, condition)
if CONF_THEN in config:
actions = await build_action_list(config[CONF_THEN], template_arg, args)
cg.add(var.add_then(actions))
for action in await build_action_list(config[CONF_THEN], template_arg, args):
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
if has_else:
actions = await build_action_list(config[CONF_ELSE], template_arg, args)
cg.add(var.add_else(actions))
for action in await build_action_list(config[CONF_ELSE], template_arg, args):
cg.add(var.add_else_action(action))
cg.add(var.finish_else())
return var
@@ -867,11 +900,27 @@ async def while_action_to_code(
) -> MockObj:
condition = await build_condition(config[CONF_CONDITION], template_arg, args)
var = cg.new_Pvariable(action_id, template_arg, condition)
actions = await build_action_list(config[CONF_THEN], template_arg, args)
cg.add(var.add_then(actions))
for action in await build_action_list(config[CONF_THEN], template_arg, args):
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
return var
def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType:
"""Rename an inherited `iteration` so the inner repeat's own counter can shadow it."""
names = {name for _, name in args}
renamed: TemplateArgsType = []
for type_, name in args:
if name == "iteration":
depth = 1
while f"iteration_{depth}" in names:
depth += 1
name = f"iteration_{depth}"
names.add(name)
renamed.append((type_, name))
return renamed
@register_action(
"repeat",
RepeatAction,
@@ -895,9 +944,11 @@ async def repeat_action_to_code(
actions = await build_action_list(
config[CONF_THEN],
cg.TemplateArguments(cg.uint32, *template_arg.args),
[(cg.uint32, "iteration"), *args],
[(cg.uint32, "iteration"), *_shadow_iteration(args)],
)
cg.add(var.add_then(actions))
for action in actions:
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
return var
@@ -1033,21 +1084,79 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool:
Non-synchronous actions (delay, wait_until, script.wait, etc.) store
trigger args for later execution, making non-owning types like StringRef
unsafe.
Accepts an action list, a single action, or an automation. Only the keys of
action entries are looked up as action names; the options of an action are
only searched for nested action lists (``then:``, ``else:``, ...), so an
option that shares a name with an action, such as the ``delay:`` option of
``speaker_source.set_playlist_delay``, is not mistaken for that action.
"""
if isinstance(actions, list):
return any(has_non_synchronous_actions(item) for item in actions)
if isinstance(actions, dict):
for key in actions:
for key, options in actions.items():
if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous:
return True
return any(
has_non_synchronous_actions(v)
for v in actions.values()
if isinstance(v, (list, dict))
)
if _options_have_non_synchronous_actions(options):
return True
return False
def _options_have_non_synchronous_actions(options: Any) -> bool:
"""Search an action's options for nested action lists, without treating option keys as actions."""
if isinstance(options, list):
return has_non_synchronous_actions(options)
if isinstance(options, dict):
return any(_options_have_non_synchronous_actions(v) for v in options.values())
return False
def validate_no_deferring_actions(
context: str, data: str = "the trigger data"
) -> Callable[[ConfigType], ConfigType]:
"""Return a validator that rejects deferring actions in a validated automation.
Use it for triggers whose arguments only live while the callback runs (views into a reused
buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later.
:param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``.
:param data: What is only valid while the automation runs, for the error message.
"""
def validator(value: ConfigType) -> ConfigType:
if has_non_synchronous_actions(value):
raise cv.Invalid(
f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in "
f"{context}: {data} is only valid while the automation runs. Copy what you need "
"into globals first, then defer in a separate script or automation."
)
return value
return validator
def validate_synchronous_automation(
context: str,
data: str = "the trigger data",
extra_schema=None,
extra_validators=None,
single: bool = False,
) -> cv.All:
"""``validate_automation()`` that also rejects deferring actions.
See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are
passed to ``validate_automation()``.
"""
return cv.All(
validate_automation(
extra_schema=extra_schema,
extra_validators=extra_validators,
single=single,
),
validate_no_deferring_actions(context, data),
)
async def _new_automation(
args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj
) -> MockObj:
@@ -1055,7 +1164,8 @@ async def _new_automation(
templ = cg.TemplateArguments(*(arg[0] for arg in args))
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args)
actions = await build_action_list(config[CONF_THEN], templ, args)
cg.add(obj.add_actions(actions))
for action in actions:
cg.add(obj.add_action(action))
return obj
+20 -12
View File
@@ -102,25 +102,21 @@ _KNOWN_FILE_EXTENSIONS = frozenset(
)
# Matches !secret references in YAML text. An optional surrounding
# quote pair around the key is allowed and ignored: YAML treats
# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is
# intentionally a simple regex scan rather than a YAML parse — it may
# match inside comments or multi-line strings, which is the conservative
# direction (include more secrets rather than fewer).
_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""")
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
"""Scan YAML files for ``!secret <key>`` references."""
keys: set[str] = set()
for fpath in yaml_files:
try:
text = fpath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError) as err:
_LOGGER.warning(
"Could not scan %s for !secret references (%s); the bundled "
"secret set may be incomplete",
fpath,
err,
)
continue
for match in _SECRET_RE.finditer(text):
keys.add(match.group(1))
keys |= yaml_util.find_secret_references(text)
return keys
@@ -422,6 +418,18 @@ class ConfigBundleCreator:
must ship every candidate so the remote build can pick any one.
"""
discovered = yaml_util.discover_user_yaml_files(self._config_path)
if discovered.load_errors:
_LOGGER.warning(
"Bundle may be incomplete; could not load all configuration files: %s",
"; ".join(discovered.load_errors),
)
if discovered.unresolved:
_LOGGER.warning(
"Bundle may be incomplete; %d !include path(s) use "
"substitutions and cannot be captured: %s",
len(discovered.unresolved),
", ".join(discovered.unresolved),
)
self._secrets_paths.update(discovered.secrets)
# A !secret inside a file this re-parse does not reach (for example
# a git-fetched package the builder re-fetches) still resolves
+1
View File
@@ -44,6 +44,7 @@ from esphome.cpp_generator import ( # noqa: F401
safe_exp,
set_cpp_standard,
shared_progmem_array,
shared_progmem_string,
statement,
static_const_array,
static_function,
+4 -4
View File
@@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
#endif // USE_RP2
protected:
uint8_t sample_count_{1};
bool output_raw_{false};
uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py
bool output_raw_{false}; // Must match CONF_RAW default in sensor.py
InternalGPIOPin *pin_;
SamplingMode sampling_mode_{SamplingMode::AVG};
SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py
#ifdef USE_ESP32
float sample_autorange_();
@@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
bool autorange_{false};
adc_oneshot_unit_handle_t adc_handle_{nullptr};
adc_cali_handle_t calibration_handle_{nullptr};
adc_atten_t attenuation_{ADC_ATTEN_DB_0};
adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py
adc_channel_t channel_{};
adc_unit_t adc_unit_{};
struct SetupFlags {
+24 -8
View File
@@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"]
CONF_SAMPLES = "samples"
CONF_SAMPLING_MODE = "sampling_mode"
# Keep in sync with the initializers in adc_sensor.h
DEFAULT_ATTENUATION = "0db"
DEFAULT_SAMPLING_MODE = "avg"
DEFAULT_SAMPLES = 1
_attenuation = cv.enum(ATTENUATION_MODES, lower=True)
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
@@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType:
):
raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'")
if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1:
if (
config.get(CONF_ATTENUATION, None) == "auto"
and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1
):
raise cv.Invalid(
"Automatic attenuation cannot be used when multisampling is set"
)
@@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All(
{
cv.Required(CONF_PIN): validate_adc_pin,
cv.Optional(CONF_RAW, default=False): cv.boolean,
cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All(
cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All(
cv.only_on_esp32, _attenuation
),
cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec),
cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255),
cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode,
cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range(
min=1, max=255
),
cv.Optional(
CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE
): _sampling_mode,
}
)
.extend(cv.polling_component_schema("60s")),
@@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
cg.add(var.set_output_raw(config[CONF_RAW]))
cg.add(var.set_sample_count(config[CONF_SAMPLES]))
cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE]))
# Skip the setters when the config matches the C++ initializers.
if config[CONF_RAW]:
cg.add(var.set_output_raw(True))
if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES:
cg.add(var.set_sample_count(samples))
if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE:
cg.add(var.set_sampling_mode(sampling_mode))
if CORE.is_esp32:
# Re-enable ESP-IDF's ADC driver (excluded by default to save compile time)
@@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None:
if attenuation := config.get(CONF_ATTENUATION):
if attenuation == "auto":
cg.add(var.set_autorange(cg.global_ns.true))
else:
elif attenuation != DEFAULT_ATTENUATION:
cg.add(var.set_attenuation(attenuation))
variant = get_esp32_variant()
+3
View File
@@ -0,0 +1,3 @@
"""Support for ScioSense APC1 air quality sensor."""
DOMAIN = "apc1"
+352
View File
@@ -0,0 +1,352 @@
#include "apc1.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <cinttypes>
#include <cmath>
namespace esphome::apc1 {
ESPHOME_LOG_TAG(TAG, "apc1");
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58;
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19;
// Give a freshly powered sensor time to boot before the first retry
static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000;
// Re-send the init commands at most this often while no frames arrive
static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000;
// Active mode streams a frame every second; this long without one means the sensor stopped
static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000;
// Drop a partial frame when the bytes stop mid-way
static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200;
// Frames and commands end with the 16-bit sum of every byte before it
static uint16_t apc1_checksum(const uint8_t *data, size_t len) {
uint16_t sum = 0;
for (size_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
void APC1Component::setup() {
if (this->set_pin_ != nullptr) {
this->set_pin_->setup();
this->set_pin_->digital_write(true);
}
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
}
// Attempt initial communication immediately for warm restarts
this->initialize_device_();
// Schedule delayed initialization to catch sensors during cold power-on bootup
this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); });
}
void APC1Component::initialize_device_() {
this->last_init_attempt_ = App.get_loop_component_start_time();
if (this->active_mode_ && !this->idle_) {
this->set_active_mode();
}
this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000);
}
void APC1Component::dump_config() {
ESP_LOGCONFIG(TAG, "APC1:");
LOG_PIN(" Set Pin: ", this->set_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_);
LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_);
LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_);
LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_);
LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_);
LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_);
LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_);
LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_);
LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_);
LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_);
LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_);
LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_);
LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_);
LOG_SENSOR(" ", "eCO2", this->eco2_sensor_);
LOG_SENSOR(" ", "AQI", this->aqi_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_);
LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_);
LOG_SENSOR(" ", "RS0", this->rs0_sensor_);
LOG_SENSOR(" ", "RS2", this->rs2_sensor_);
LOG_SENSOR(" ", "RS3", this->rs3_sensor_);
LOG_SENSOR(" ", "Error Code", this->error_code_sensor_);
}
void APC1Component::loop() {
const uint32_t now = App.get_loop_component_start_time();
// If in active mode and not idle, retry periodically if no valid data received
if (this->active_mode_ && !this->idle_) {
if (this->last_valid_frame_ == 0) {
if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands...");
this->initialize_device_();
}
} else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS &&
now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_);
this->initialize_device_();
}
}
if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) {
ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer");
this->rx_index_ = 0;
}
while (this->available()) {
this->last_transmission_ = now;
uint8_t byte;
this->read_byte(&byte);
if (this->rx_index_ == 0) {
if (byte == APC1_HEADER_1) {
this->rx_buffer_[this->rx_index_++] = byte;
}
continue;
}
if (this->rx_index_ == 1) {
if (byte == APC1_HEADER_2) {
this->rx_buffer_[this->rx_index_++] = byte;
} else if (byte != APC1_HEADER_1) {
this->rx_index_ = 0;
}
continue;
}
this->rx_buffer_[this->rx_index_++] = byte;
if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) {
continue;
}
// The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a
// corrupt length from wrapping the total and walking past the buffer
const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]);
if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) {
ESP_LOGW(TAG, "Invalid frame length: %u", payload_len);
this->rx_index_ = 0;
continue;
}
const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE;
if (this->rx_index_ == total_len) {
this->parse_frame_(total_len);
this->rx_index_ = 0;
}
}
}
void APC1Component::parse_frame_(uint16_t total_len) {
uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]);
uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2);
if (expected_checksum != calculated_checksum) {
ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum,
expected_checksum);
return;
}
if (total_len == APC1_FRAME_SIZE_MEASUREMENT) {
this->parse_measurement_frame_();
} else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) {
this->parse_device_info_frame_();
} else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) {
ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]);
} else {
ESP_LOGD(TAG, "Received frame of unknown length %u", total_len);
}
}
// One message per error bit, bit 0 first
PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault",
"Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault");
void APC1Component::parse_measurement_frame_() {
const uint32_t now = App.get_loop_component_start_time();
this->last_valid_frame_ = now;
uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE];
if (error_code != this->last_error_code_) {
if (error_code != 0) {
for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) {
if (error_code & (1 << bit)) {
ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX)));
}
}
this->status_set_warning(LOG_STR("Sensor reported hardware error"));
} else {
ESP_LOGI(TAG, "APC1: Hardware error cleared");
this->status_clear_warning();
}
this->last_error_code_ = error_code;
if (this->error_code_sensor_ != nullptr) {
this->error_code_sensor_->publish_state(error_code);
}
}
uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI];
if (aqi == 0) {
if (!this->gas_warming_up_) {
this->gas_warming_up_ = true;
ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)");
}
} else if (this->gas_warming_up_) {
this->gas_warming_up_ = false;
ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi);
}
// Measurement payload data (bytes 4-57): big-endian sensor readings.
// The values below feed the debug summary, so they are always decoded; every
// other reading is only decoded when its sensor is configured.
const auto u16 = [this](uint8_t offset) {
return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]);
};
const auto u32 = [this](uint8_t offset) {
return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2],
this->rx_buffer_[offset + 3]);
};
uint16_t pm_1_0 = u16(10);
uint16_t pm_2_5 = u16(12);
uint16_t pm_10_0 = u16(14);
uint16_t tvoc = u16(28);
uint16_t eco2 = u16(30);
float temp_comp = static_cast<int16_t>(u16(34)) / 10.0f;
float hum_comp = u16(36) / 10.0f;
ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0,
pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp);
if (this->pm_1_0_std_sensor_ != nullptr)
this->pm_1_0_std_sensor_->publish_state(u16(4));
if (this->pm_2_5_std_sensor_ != nullptr)
this->pm_2_5_std_sensor_->publish_state(u16(6));
if (this->pm_10_0_std_sensor_ != nullptr)
this->pm_10_0_std_sensor_->publish_state(u16(8));
if (this->pm_1_0_sensor_ != nullptr)
this->pm_1_0_sensor_->publish_state(pm_1_0);
if (this->pm_2_5_sensor_ != nullptr)
this->pm_2_5_sensor_->publish_state(pm_2_5);
if (this->pm_10_0_sensor_ != nullptr)
this->pm_10_0_sensor_->publish_state(pm_10_0);
if (this->pm_0_3um_sensor_ != nullptr)
this->pm_0_3um_sensor_->publish_state(u16(16));
if (this->pm_0_5um_sensor_ != nullptr)
this->pm_0_5um_sensor_->publish_state(u16(18));
if (this->pm_1_0um_sensor_ != nullptr)
this->pm_1_0um_sensor_->publish_state(u16(20));
if (this->pm_2_5um_sensor_ != nullptr)
this->pm_2_5um_sensor_->publish_state(u16(22));
if (this->pm_5_0um_sensor_ != nullptr)
this->pm_5_0um_sensor_->publish_state(u16(24));
if (this->pm_10_0um_sensor_ != nullptr)
this->pm_10_0um_sensor_->publish_state(u16(26));
if (this->tvoc_sensor_ != nullptr)
this->tvoc_sensor_->publish_state(tvoc);
if (this->eco2_sensor_ != nullptr)
this->eco2_sensor_->publish_state(eco2);
if (this->aqi_sensor_ != nullptr)
this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN);
if (this->temperature_sensor_ != nullptr)
this->temperature_sensor_->publish_state(temp_comp);
if (this->humidity_sensor_ != nullptr)
this->humidity_sensor_->publish_state(hum_comp);
if (this->raw_temperature_sensor_ != nullptr) {
this->raw_temperature_sensor_->publish_state(static_cast<int16_t>(u16(38)) / 10.0f);
}
if (this->raw_humidity_sensor_ != nullptr)
this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f);
if (this->rs0_sensor_ != nullptr) {
this->rs0_sensor_->publish_state(u32(42));
}
if (this->rs2_sensor_ != nullptr) {
this->rs2_sensor_->publish_state(u32(50));
}
if (this->rs3_sensor_ != nullptr) {
this->rs3_sensor_->publish_state(u32(54));
}
}
void APC1Component::parse_device_info_frame_() {
const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL;
uint16_t fw_version =
encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]);
ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)",
reinterpret_cast<const char *>(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE),
encode_uint32(serial[0], serial[1], serial[2], serial[3]),
encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version);
}
void APC1Component::arm_watchdog_() {
this->last_valid_frame_ = App.get_loop_component_start_time();
this->last_init_attempt_ = this->last_valid_frame_;
}
void APC1Component::set_active_mode() {
if (!this->active_mode_) {
this->arm_watchdog_();
}
this->active_mode_ = true;
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE);
}
void APC1Component::set_passive_mode() {
this->active_mode_ = false;
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE);
}
void APC1Component::request_measurement() {
this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000);
}
void APC1Component::set_idle_mode() {
this->idle_ = true;
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE);
}
void APC1Component::set_measurement_mode() {
this->idle_ = false;
this->arm_watchdog_();
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD);
}
void APC1Component::send_command_(APC1Command cmd, uint16_t data) {
std::array<uint8_t, APC1_COMMAND_FRAME_SIZE> buf{
APC1_HEADER_1,
APC1_HEADER_2,
static_cast<uint8_t>(cmd),
static_cast<uint8_t>((data >> 8) & 0xFF),
static_cast<uint8_t>(data & 0xFF),
0,
0,
};
uint16_t checksum = apc1_checksum(buf.data(), 5);
buf[5] = static_cast<uint8_t>((checksum >> 8) & 0xFF);
buf[6] = static_cast<uint8_t>(checksum & 0xFF);
this->write_array(buf);
}
} // namespace esphome::apc1
+99
View File
@@ -0,0 +1,99 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include <array>
namespace esphome::apc1 {
static constexpr uint8_t APC1_HEADER_1 = 0x42;
static constexpr uint8_t APC1_HEADER_2 = 0x4D;
static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes
static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64;
static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23;
static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8;
static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7;
enum class APC1Command : uint8_t {
APC1_COMMAND_MEASUREMENT_MODE = 0xE1,
APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2,
APC1_COMMAND_OPERATION_MODE = 0xE4,
APC1_COMMAND_READ_SENSOR_VERSION = 0xE9,
};
static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000;
static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001;
static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000;
static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001;
class APC1Component final : public uart::UARTDevice, public Component {
SUB_SENSOR(pm_1_0)
SUB_SENSOR(pm_2_5)
SUB_SENSOR(pm_10_0)
SUB_SENSOR(pm_1_0_std)
SUB_SENSOR(pm_2_5_std)
SUB_SENSOR(pm_10_0_std)
SUB_SENSOR(pm_0_3um)
SUB_SENSOR(pm_0_5um)
SUB_SENSOR(pm_1_0um)
SUB_SENSOR(pm_2_5um)
SUB_SENSOR(pm_5_0um)
SUB_SENSOR(pm_10_0um)
SUB_SENSOR(tvoc)
SUB_SENSOR(eco2)
SUB_SENSOR(aqi)
SUB_SENSOR(temperature)
SUB_SENSOR(humidity)
SUB_SENSOR(raw_temperature)
SUB_SENSOR(raw_humidity)
SUB_SENSOR(rs0)
SUB_SENSOR(rs2)
SUB_SENSOR(rs3)
SUB_SENSOR(error_code)
public:
void setup() override;
void dump_config() override;
void loop() override;
void set_active_mode();
void set_passive_mode();
void request_measurement();
void set_idle_mode();
void set_measurement_mode();
void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; }
void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; }
protected:
void send_command_(APC1Command cmd, uint16_t data);
void initialize_device_();
/// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot
void arm_watchdog_();
void parse_frame_(uint16_t total_len);
void parse_measurement_frame_();
void parse_device_info_frame_();
GPIOPin *set_pin_{nullptr};
GPIOPin *reset_pin_{nullptr};
uint32_t last_transmission_{0};
uint32_t last_valid_frame_{0};
uint32_t last_init_attempt_{0};
std::array<uint8_t, APC1_FRAME_SIZE_MEASUREMENT> rx_buffer_{};
uint8_t rx_index_{0};
uint8_t last_error_code_{0xFF};
bool active_mode_{true};
bool idle_{false};
bool gas_warming_up_{false};
};
} // namespace esphome::apc1
+222
View File
@@ -0,0 +1,222 @@
from esphome import automation, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import sensor, uart
from esphome.components.const import CONF_AQI
import esphome.config_validation as cv
from esphome.const import (
CONF_ECO2,
CONF_HUMIDITY,
CONF_ID,
CONF_PM_0_3UM,
CONF_PM_0_5UM,
CONF_PM_1_0,
CONF_PM_1_0_STD,
CONF_PM_1_0UM,
CONF_PM_2_5,
CONF_PM_2_5_STD,
CONF_PM_2_5UM,
CONF_PM_5_0UM,
CONF_PM_10_0,
CONF_PM_10_0_STD,
CONF_PM_10_0UM,
CONF_RESET_PIN,
CONF_TEMPERATURE,
CONF_TVOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
DEVICE_CLASS_PM25,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_CHEMICAL_WEAPON,
ICON_MOLECULE_CO2,
ICON_OMEGA,
ICON_RADIATOR,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_COUNT_DECILITRE,
UNIT_MICROGRAMS_PER_CUBIC_METER,
UNIT_OHM,
UNIT_PARTS_PER_BILLION,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
)
from esphome.types import ConfigType
CODEOWNERS = ["@ademuri"]
DEPENDENCIES = ["uart"]
CONF_SET_PIN = "set_pin"
CONF_RAW_TEMPERATURE = "raw_temperature"
CONF_RAW_HUMIDITY = "raw_humidity"
CONF_RS0 = "rs0"
CONF_RS2 = "rs2"
CONF_RS3 = "rs3"
CONF_ERROR_CODE = "error_code"
apc1_ns = cg.esphome_ns.namespace("apc1")
APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component)
def _pm_mass_schema(device_class: str) -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
device_class=device_class,
state_class=STATE_CLASS_MEASUREMENT,
)
_PM_COUNT_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNT_DECILITRE,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
_TEMPERATURE_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
)
_HUMIDITY_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
)
_RESISTANCE_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_OHM,
icon=ICON_OMEGA,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(APC1Component),
cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1),
cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25),
cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10),
cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1),
cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25),
cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10),
cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_TVOC): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_BILLION,
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ECO2): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
icon=ICON_MOLECULE_CO2,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_AQI): sensor.sensor_schema(
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA,
cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA,
cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA,
cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA,
cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA,
cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA,
cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA,
cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema(
icon="mdi:alert-circle-outline",
accuracy_decimals=0,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
def final_validate(config: ConfigType) -> None:
schema = uart.final_validate_device_schema(
"apc1", baud_rate=9600, require_rx=True, require_tx=True
)
schema(config)
FINAL_VALIDATE_SCHEMA = final_validate
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)
sensors = sensor.sub_sensors(config)
await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor)
await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor)
await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor)
await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor)
await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor)
await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor)
await sensors(CONF_PM_0_3UM, var.set_pm_0_3um_sensor)
await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor)
await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor)
await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor)
await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor)
await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor)
await sensors(CONF_TVOC, var.set_tvoc_sensor)
await sensors(CONF_ECO2, var.set_eco2_sensor)
await sensors(CONF_AQI, var.set_aqi_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor)
await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor)
await sensors(CONF_RS0, var.set_rs0_sensor)
await sensors(CONF_RS2, var.set_rs2_sensor)
await sensors(CONF_RS3, var.set_rs3_sensor)
await sensors(CONF_ERROR_CODE, var.set_error_code_sensor)
if (set_pin_config := config.get(CONF_SET_PIN)) is not None:
set_pin = await cg.gpio_pin_expression(set_pin_config)
cg.add(var.set_set_pin(set_pin))
if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None:
reset_pin = await cg.gpio_pin_expression(reset_pin_config)
cg.add(var.set_reset_pin(reset_pin))
APC1_ACTION_SCHEMA = maybe_simple_id(
{
cv.GenerateID(): cv.use_id(APC1Component),
}
)
for _name, _call in (
("apc1.set_active_mode", "set_active_mode()"),
("apc1.set_passive_mode", "set_passive_mode()"),
("apc1.request_measurement", "request_measurement()"),
("apc1.set_idle_mode", "set_idle_mode()"),
("apc1.set_measurement_mode", "set_measurement_mode()"),
):
automation.register_apply_action(
_name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call)
)
@@ -0,0 +1,3 @@
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["uart"]
DOMAIN = "apc_proteous"
@@ -0,0 +1,242 @@
#include "apc_proteous_cover.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::apc_proteous {
ESPHOME_LOG_TAG(TAG, "apc_proteous.cover");
static constexpr const char *START_CMD = "*1\r"; // start/stop toggle
static constexpr const char *OPEN_CMD = "*6\r";
static constexpr const char *CLOSE_CMD = "*7\r";
static constexpr const char *QUERY_S = "?s\r";
static constexpr const char *QUERY_X = "?x\r";
// Depending on calibration the reported position may stop a little short of the physical limits
// (e.g. 99% at the fully open reed switch), so readings this close to an end count as that end.
static constexpr uint8_t ENDPOINT_MARGIN = 2;
using namespace esphome::cover;
void APCProteousCover::setup() {
auto restore = this->restore_state_();
if (restore.has_value()) {
restore->apply(this);
} else {
this->position = 0.5f;
}
}
void APCProteousCover::loop() {
uint8_t data;
while (this->available() > 0) {
if (!this->read_byte(&data)) {
continue;
}
if (data == '\r') {
if (this->rx_len_ != 0) {
this->rx_buffer_[this->rx_len_] = '\0';
ESP_LOGVV(TAG, "rx: '%s'", this->rx_buffer_);
this->parse_response_();
this->rx_len_ = 0;
}
} else if (data != '\n') {
if (this->rx_len_ >= MAX_RESPONSE_LEN) {
ESP_LOGVV(TAG, "rx overflow, discarding");
this->rx_len_ = 0;
}
this->rx_buffer_[this->rx_len_++] = static_cast<char>(data);
}
}
}
void APCProteousCover::parse_response_() {
// Expected format: "?s=XX" or "?x=XX" where XX is a hex value
if (this->rx_len_ < 4 || this->rx_buffer_[0] != '?' || this->rx_buffer_[2] != '=') {
ESP_LOGV(TAG, "Invalid response: %s", this->rx_buffer_);
return;
}
const char *hex_str = this->rx_buffer_ + 3;
char *end_ptr;
uint8_t value = static_cast<uint8_t>(strtol(hex_str, &end_ptr, 16));
if (end_ptr == hex_str) {
ESP_LOGW(TAG, "Failed to parse hex value: %s", this->rx_buffer_);
return;
}
bool state_changed = false;
char type = this->rx_buffer_[1];
if (type == 's') {
// bit 0 = operating, bit 1 = direction (0 = opening, 1 = closing)
CoverOperation new_operation = COVER_OPERATION_IDLE;
if (value & 0x01) {
new_operation = (value & 0x02) ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING;
}
if (this->current_operation != new_operation) {
this->current_operation = new_operation;
state_changed = true;
}
// Motion in the commanded direction acknowledges the pending command
if ((this->pending_command_ == OPEN_CMD && new_operation == COVER_OPERATION_OPENING) ||
(this->pending_command_ == CLOSE_CMD && new_operation == COVER_OPERATION_CLOSING)) {
this->clear_pending_();
}
// A partial move is over once the acknowledged motion has stopped, wherever it stopped, so an
// old target cannot halt a movement started from the wall button or the remote later on
if (new_operation == COVER_OPERATION_IDLE && this->pending_command_ == nullptr) {
this->target_position_.reset();
}
ESP_LOGV(TAG, "s-status: 0x%02X (operation=%d)", value, new_operation);
} else if (type == 'x') {
// Position percentage, snapped to the ends so is_open/is_closed can latch despite calibration offsets
float new_position;
if (value + ENDPOINT_MARGIN >= 100) {
new_position = COVER_OPEN;
} else if (value <= ENDPOINT_MARGIN) {
new_position = COVER_CLOSED;
} else {
new_position = value / 100.0f;
}
if (this->position != new_position) {
this->position = new_position;
state_changed = true;
// Until the controller has confirmed motion in the commanded direction, current_operation may
// still show the previous one, so a reversing partial move must not be judged against it
if (this->target_position_.has_value() && this->pending_command_ == nullptr &&
((this->current_operation == COVER_OPERATION_OPENING && this->position >= *this->target_position_) ||
(this->current_operation == COVER_OPERATION_CLOSING && this->position <= *this->target_position_))) {
ESP_LOGD(TAG, "Target position %.2f reached", *this->target_position_);
this->stop_cmd_(App.get_loop_component_start_time());
}
}
ESP_LOGV(TAG, "x-status: %d%% (position=%.2f)", value, this->position);
}
if (state_changed) {
this->publish_state();
}
}
void APCProteousCover::clear_pending_() {
this->pending_command_ = nullptr;
this->command_retries_ = 0;
}
void APCProteousCover::retry_pending_command_(uint32_t now) {
if (this->pending_command_ == nullptr || (now - this->pending_command_time_) < COMMAND_ACK_MS) {
return;
}
bool is_open_cmd = this->pending_command_ == OPEN_CMD;
// Nothing to resend once the gate is at the commanded end
if ((is_open_cmd && this->position >= COVER_OPEN) || (!is_open_cmd && this->position <= COVER_CLOSED)) {
this->clear_pending_();
return;
}
const LogString *label = is_open_cmd ? LOG_STR("open") : LOG_STR("close");
if (this->command_retries_ >= MAX_COMMAND_RETRIES) {
ESP_LOGW(TAG, "Gate did not acknowledge %s command after %u retries", LOG_STR_ARG(label), this->command_retries_);
this->clear_pending_();
this->target_position_.reset();
return;
}
this->command_retries_++;
ESP_LOGD(TAG, "Gate did not respond, resending %s command (retry %u)", LOG_STR_ARG(label), this->command_retries_);
this->pending_command_time_ = now;
this->write_command_(this->pending_command_, now);
}
void APCProteousCover::update() {
const uint32_t now = App.get_loop_component_start_time();
this->retry_pending_command_(now);
// Let the command and its echo clear the line before the next query
if ((now - this->last_command_tx_) < COMMAND_QUIET_MS) {
return;
}
// The line is idle, so anything left in the buffer is an unterminated echo
if (this->rx_len_ != 0) {
this->rx_buffer_[this->rx_len_] = '\0';
ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->rx_buffer_);
this->rx_len_ = 0;
}
const char *query = this->query_s_next_ ? QUERY_S : QUERY_X;
ESP_LOGVV(TAG, "tx: %s", query);
this->write_str(query);
this->query_s_next_ = !this->query_s_next_;
}
void APCProteousCover::dump_config() {
LOG_COVER("", "APC Proteous Cover", this);
LOG_UPDATE_INTERVAL(this);
}
CoverTraits APCProteousCover::get_traits() {
auto traits = CoverTraits();
traits.set_supports_position(true);
traits.set_supports_stop(true);
traits.set_supports_toggle(true);
return traits;
}
void APCProteousCover::write_command_(const char *cmd, uint32_t now) {
ESP_LOGVV(TAG, "tx: %s", cmd);
this->last_command_tx_ = now;
this->write_str(cmd);
}
void APCProteousCover::send_command_(const char *cmd) {
// The published state comes only from status reads; this just records the command so a stop
// can cancel it before the controller reports motion, and so it can be resent if dropped.
// The commands are idempotent, so only skip one when the gate is already moving that way.
CoverOperation operation = cmd == OPEN_CMD ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING;
if (this->current_operation == operation) {
return;
}
ESP_LOGD(TAG, "Sending %s command",
operation == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("open") : LOG_STR_LITERAL("close"));
const uint32_t now = App.get_loop_component_start_time();
this->pending_command_ = cmd;
this->pending_command_time_ = now;
this->command_retries_ = 0;
this->write_command_(cmd, now);
}
void APCProteousCover::stop_cmd_(uint32_t now) {
// START_CMD is a start/stop toggle, so it is only sent while the gate is moving, or while a
// movement command is in flight that the controller has not reported yet; otherwise it would start the gate.
if (this->current_operation == COVER_OPERATION_IDLE && this->pending_command_ == nullptr) {
ESP_LOGD(TAG, "Cover already idle, ignoring stop command");
return;
}
ESP_LOGD(TAG, "Sending stop command");
this->write_command_(START_CMD, now);
this->clear_pending_();
this->target_position_.reset();
}
void APCProteousCover::control(const CoverCall &call) {
if (call.get_stop()) {
this->stop_cmd_(App.get_loop_component_start_time());
} else if (call.get_position().has_value()) {
float target = *call.get_position();
ESP_LOGD(TAG, "Target position: %.2f, current: %.2f", target, this->position);
// There is no position command: a partial target is reached by moving towards it and stopping
// once the reported position gets there
this->target_position_.reset();
if (target == COVER_OPEN) {
this->send_command_(OPEN_CMD);
} else if (target == COVER_CLOSED) {
this->send_command_(CLOSE_CMD);
} else if (target != this->position) {
this->target_position_ = target;
this->send_command_(target > this->position ? OPEN_CMD : CLOSE_CMD);
}
} else if (call.get_toggle()) {
this->clear_pending_();
this->target_position_.reset();
this->write_command_(START_CMD, App.get_loop_component_start_time());
}
}
} // namespace esphome::apc_proteous
@@ -0,0 +1,52 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/uart/uart.h"
#include "esphome/components/cover/cover.h"
namespace esphome::apc_proteous {
class APCProteousCover : public cover::Cover, public PollingComponent, public uart::UARTDevice {
public:
void setup() override;
void loop() override;
void update() override;
void dump_config() override;
cover::CoverTraits get_traits() override;
protected:
void control(const cover::CoverCall &call) override;
void parse_response_();
void send_command_(const char *cmd);
void write_command_(const char *cmd, uint32_t now);
void stop_cmd_(uint32_t now);
void retry_pending_command_(uint32_t now);
void clear_pending_();
// The longest valid frame is "?s=XX"; anything longer is line noise
static constexpr size_t MAX_RESPONSE_LEN = 16;
// A movement command is acknowledged once the controller reports motion in the commanded
// direction; resend it this often, up to MAX_COMMAND_RETRIES times, since the serial link
// occasionally drops one. The stop toggle is never resent because a spurious toggle could start the gate.
static constexpr uint32_t COMMAND_ACK_MS = 2000;
static constexpr uint8_t MAX_COMMAND_RETRIES = 3;
// The controller echoes every command, sometimes without a terminator. A query sent too soon
// after a command collides with that echo, so polling pauses for this long after each command.
static constexpr uint32_t COMMAND_QUIET_MS = 250;
// Set only for a partial target; the gate is stopped when the reported position reaches it
optional<float> target_position_{};
// Last movement command sent and not yet acknowledged; lets a stop cancel it and drives the retries
const char *pending_command_{nullptr};
uint32_t pending_command_time_{0};
uint32_t last_command_tx_{0};
char rx_buffer_[MAX_RESPONSE_LEN + 1];
uint8_t rx_len_{0};
uint8_t command_retries_{0};
bool query_s_next_{true};
};
} // namespace esphome::apc_proteous
+33
View File
@@ -0,0 +1,33 @@
import esphome.codegen as cg
from esphome.components import cover, uart
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import DOMAIN
apc_proteous_ns = cg.esphome_ns.namespace("apc_proteous")
APCProteousCover = apc_proteous_ns.class_(
"APCProteousCover", cover.Cover, cg.PollingComponent, uart.UARTDevice
)
CONFIG_SCHEMA = (
cover.cover_schema(APCProteousCover)
.extend(uart.UART_DEVICE_SCHEMA)
.extend(cv.polling_component_schema("500ms"))
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
DOMAIN,
baud_rate=9600,
require_tx=True,
require_rx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config: ConfigType) -> None:
var = await cover.new_cover(config)
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@iyesin"]
DOMAIN = "apds9930"
+140
View File
@@ -0,0 +1,140 @@
#include "apds9930.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::apds9930 {
ESPHOME_LOG_TAG(TAG, "apds9930");
// Register addresses
static constexpr uint8_t APDS9930_ENABLE = 0x00;
static constexpr uint8_t APDS9930_ATIME = 0x01;
static constexpr uint8_t APDS9930_PTIME = 0x02;
static constexpr uint8_t APDS9930_PPULSE = 0x0E;
static constexpr uint8_t APDS9930_CONTROL = 0x0F;
static constexpr uint8_t APDS9930_ID = 0x12;
static constexpr uint8_t APDS9930_STATUS = 0x13;
// Command byte: repeated byte and auto-increment protocols
static constexpr uint8_t APDS9930_CMD = 0x80;
static constexpr uint8_t APDS9930_CMD_AUTO_INCREMENT = 0xA0;
static constexpr uint8_t APDS9930_CHIP_ID = 0x39;
// ENABLE register bits
static constexpr uint8_t APDS9930_PON = 0x01;
static constexpr uint8_t APDS9930_AEN = 0x02;
static constexpr uint8_t APDS9930_PEN = 0x04;
// STATUS register bits
static constexpr uint8_t APDS9930_AVALID = 0x01;
static constexpr uint8_t APDS9930_PVALID = 0x02;
// CONTROL register fields
static constexpr uint8_t CONTROL_PDRIVE_SHIFT = 6;
static constexpr uint8_t CONTROL_PDIODE_SHIFT = 4;
static constexpr uint8_t CONTROL_PGAIN_SHIFT = 2;
static constexpr uint8_t CONTROL_AGAIN_SHIFT = 0;
static constexpr uint8_t CONTROL_PDIODE_CH1 = 0b10; // The only supported proximity diode
static constexpr uint8_t APDS9930_ATIME_VALUE = 0xED; // 19 cycles, about 52 ms
static constexpr uint8_t APDS9930_PTIME_VALUE = 0xFF; // 2.73 ms
static constexpr uint8_t APDS9930_PPULSE_VALUE = 0x08; // 8 pulses
// Lux calculation coefficients
static constexpr float APDS9930_DF = 52.0f;
static constexpr float APDS9930_GA = 0.49f;
static constexpr float APDS9930_ALS_B = 1.862f;
static constexpr float APDS9930_ALS_C = 0.746f;
static constexpr float APDS9930_ALS_D = 1.291f;
static constexpr float APDS9930_ALSIT_MS = 2.73f * (256 - APDS9930_ATIME_VALUE);
static constexpr uint8_t AMBIENT_GAINS[4] PROGMEM = {1, 8, 16, 120};
static constexpr uint8_t PROXIMITY_GAINS[4] PROGMEM = {1, 2, 4, 8};
static constexpr float LED_CURRENT_MAX_MA = 100.0f; // halved for each drive step
// Lux per count at 1x gain and the fixed integration time
static constexpr float LUX_PER_COUNT_1X = APDS9930_GA * APDS9930_DF / APDS9930_ALSIT_MS;
// Each integration cycle adds up to 1024 counts, so this is the ceiling of both light channels
static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE);
bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); }
void APDS9930Component::setup() {
uint8_t id;
if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) {
this->mark_failed(LOG_STR("Communication failed"));
return;
}
if (id != APDS9930_CHIP_ID) {
ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID);
this->mark_failed(LOG_STR("Wrong chip ID"));
return;
}
const uint8_t control =
(this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT |
(this->proximity_gain_ & 0b11) << CONTROL_PGAIN_SHIFT | (this->ambient_gain_ & 0b11) << CONTROL_AGAIN_SHIFT;
uint8_t enable = APDS9930_PON;
if (this->illuminance_sensor_ != nullptr)
enable |= APDS9930_AEN;
if (this->proximity_sensor_ != nullptr)
enable |= APDS9930_PEN;
if (!this->write_reg_(APDS9930_ENABLE, 0x00) || !this->write_reg_(APDS9930_ATIME, APDS9930_ATIME_VALUE) ||
!this->write_reg_(APDS9930_PTIME, APDS9930_PTIME_VALUE) ||
!this->write_reg_(APDS9930_PPULSE, APDS9930_PPULSE_VALUE) || !this->write_reg_(APDS9930_CONTROL, control) ||
!this->write_reg_(APDS9930_ENABLE, enable)) {
this->mark_failed(LOG_STR("Configuration failed"));
}
}
void APDS9930Component::dump_config() {
ESP_LOGCONFIG(TAG,
"APDS9930:\n"
" LED Drive: %.1f mA\n"
" Proximity Gain: %ux\n"
" Ambient Light Gain: %ux",
LED_CURRENT_MAX_MA / (1 << this->led_drive_),
progmem_read_byte(&PROXIMITY_GAINS[this->proximity_gain_]),
progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_]));
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_);
LOG_SENSOR(" ", "Proximity", this->proximity_sensor_);
}
void APDS9930Component::update() {
// STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H
uint8_t raw[7];
if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) {
this->status_set_warning(LOG_STR("Reading data failed"));
return;
}
this->status_clear_warning();
const uint8_t status = raw[0];
if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) {
const uint16_t ch0 = encode_uint16(raw[2], raw[1]);
const uint16_t ch1 = encode_uint16(raw[4], raw[3]);
if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) {
// A saturated channel would give a lux value that is too low, not too high
ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain");
this->illuminance_sensor_->publish_state(NAN);
} else {
const float iac = std::max({ch0 - APDS9930_ALS_B * ch1, APDS9930_ALS_C * ch0 - APDS9930_ALS_D * ch1, 0.0f});
this->illuminance_sensor_->publish_state(iac * LUX_PER_COUNT_1X /
progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_]));
}
}
if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) {
this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5]));
}
}
} // namespace esphome::apds9930
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::apds9930 {
class APDS9930Component final : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(illuminance)
SUB_SENSOR(proximity)
public:
void setup() override;
void dump_config() override;
void update() override;
void set_led_drive(uint8_t level) { this->led_drive_ = level; }
void set_proximity_gain(uint8_t gain) { this->proximity_gain_ = gain; }
void set_ambient_gain(uint8_t gain) { this->ambient_gain_ = gain; }
protected:
bool write_reg_(uint8_t reg, uint8_t value);
uint8_t led_drive_{0};
uint8_t proximity_gain_{0};
uint8_t ambient_gain_{0};
};
} // namespace esphome::apds9930
+70
View File
@@ -0,0 +1,70 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.const import UNIT_COUNTS
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_ILLUMINANCE,
DEVICE_CLASS_ILLUMINANCE,
STATE_CLASS_MEASUREMENT,
UNIT_LUX,
)
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
CONF_AMBIENT_LIGHT_GAIN = "ambient_light_gain"
CONF_LED_DRIVE = "led_drive"
CONF_PROXIMITY = "proximity"
CONF_PROXIMITY_GAIN = "proximity_gain"
DRIVE_LEVELS = {"100ma": 0, "50ma": 1, "25ma": 2, "12.5ma": 3}
PROXIMITY_LEVELS = {"1x": 0, "2x": 1, "4x": 2, "8x": 3}
AMBIENT_LEVELS = {"1x": 0, "8x": 1, "16x": 2, "120x": 3}
apds9930_ns = cg.esphome_ns.namespace("apds9930")
APDS9930Component = apds9930_ns.class_(
"APDS9930Component", cg.PollingComponent, i2c.I2CDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(APDS9930Component),
cv.Optional(CONF_LED_DRIVE, default="100ma"): cv.enum(
DRIVE_LEVELS, lower=True
),
cv.Optional(CONF_PROXIMITY_GAIN, default="8x"): cv.enum(
PROXIMITY_LEVELS, lower=True
),
cv.Optional(CONF_AMBIENT_LIGHT_GAIN, default="1x"): cv.enum(
AMBIENT_LEVELS, lower=True
),
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_LUX,
accuracy_decimals=1,
device_class=DEVICE_CLASS_ILLUMINANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PROXIMITY): sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x39))
)
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)
cg.add(var.set_led_drive(config[CONF_LED_DRIVE]))
cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN]))
cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN]))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor)
await sensors(CONF_PROXIMITY, var.set_proximity_sensor)
+8 -67
View File
@@ -54,7 +54,7 @@ from esphome.const import (
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import Expression, MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import cpp_string_escape, fnv1_hash
from esphome.helpers import fnv1_hash
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigFragmentType, ConfigType
@@ -251,9 +251,6 @@ def _validate_supports_response(value: Any) -> str:
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small
ESP8266_ACTION_STRINGS_MAX_TOTAL = 384
VARIABLE_SCHEMA = cv.Schema(
{
cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True),
@@ -484,29 +481,6 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
return strings
def _action_strings_size(strings: list[str | None]) -> int:
"""Bytes needed to copy every string out of flash, each with its terminator."""
return sum(
len(string.encode("utf-8")) + 1 for string in strings if string is not None
)
def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
if not CORE.is_esp8266:
return config
actions = config.get(CONF_ACTIONS, [])
has_metadata = _has_action_metadata(actions)
for conf in actions:
size = _action_strings_size(_action_strings(conf, has_metadata))
if size > ESP8266_ACTION_STRINGS_MAX_TOTAL:
raise cv.Invalid(
f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, "
f"description and example text; ESP8266 allows at most "
f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action"
)
return config
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
if (
@@ -531,34 +505,16 @@ def _validate_wizard(config: ConfigType) -> ConfigType:
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_esp8266_action_strings,
_validate_outgoing_host_ipv6,
_validate_wizard,
)
def _add_action_strings(
index: int, strings: list[str | None], interned: dict[str, MockObj]
) -> MockObj:
"""Emit the PROGMEM string table for one action.
Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical
strings are shared between actions through `interned`.
"""
entries: list[MockObj] = []
for string in strings:
if string is None:
entries.append(cg.nullptr)
continue
if (var := interned.get(string)) is None:
var = interned[string] = cg.progmem_array(
ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char),
string,
)
entries.append(var)
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
"""Emit the PROGMEM string table for one action."""
return cg.progmem_array(
ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr),
entries,
[cg.nullptr if s is None else cg.shared_progmem_string(s) for s in strings],
)
@@ -604,14 +560,12 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_HOMEASSISTANT_STATES]:
cg.add_define("USE_API_HOMEASSISTANT_STATES")
scratch_size = 0
if actions:
# Metadata is compiled in for every action once any action declares it, because the
# string table layout is fixed by the define rather than per action
has_metadata = _has_action_metadata(actions)
if has_metadata:
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
interned_strings: dict[str, MockObj] = {}
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
for index, conf in enumerate(actions):
@@ -656,9 +610,7 @@ async def to_code(config: ConfigType) -> None:
service_template_args.append(native)
func_args.append((native, name))
strings = _action_strings(conf, has_metadata)
table = _add_action_strings(index, strings, interned_strings)
if CORE.is_esp8266:
scratch_size = max(scratch_size, _action_strings_size(strings))
table = _add_action_strings(index, strings)
# Template args: supports_response mode, then user service arg types
templ = cg.TemplateArguments(supports_response, *service_template_args)
# Key is hashed here because the name is not readable at runtime on ESP8266
@@ -682,12 +634,9 @@ async def to_code(config: ConfigType) -> None:
unregister_id,
var,
)
cg.add(auto.add_actions([unregister_action]))
cg.add(auto.add_action(unregister_action))
# Register all services at once - single allocation, no reallocations
cg.add(var.initialize_user_services(triggers))
if CORE.is_esp8266 and has_user_actions:
# Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action
cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1))
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
await wizard.to_code(wizard_config)
@@ -828,14 +777,6 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
)
def _field_string(value: str) -> Expression:
# ESP8266 can only keep a string in flash as its own PROGMEM array
literal = cg.RawExpression(cpp_string_escape(value))
if CORE.is_esp8266:
return cg.shared_progmem_array("ha_field_str", cg.char, literal)
return literal
async def _new_service_call_action(
server_id: ID,
action_id: ID,
@@ -858,12 +799,12 @@ async def _new_service_call_action(
)
async def field(key: str | None, value: Any, output_type: Any = None) -> Expression:
key_exp = cg.nullptr if key is None else _field_string(key)
key_exp = cg.nullptr if key is None else cg.shared_progmem_string(key)
if cg.is_template(value):
# output_type=None lets lambdas return numbers or char pointers; C++ converts them
lam = await cg.process_lambda(value, args, return_type=output_type)
return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})")
return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr)
return cg.ArrayInitializer(key_exp, cg.shared_progmem_string(value), cg.nullptr)
entries = [await field(None, service, cg.std_string)]
for group in groups:
+34 -15
View File
@@ -403,6 +403,9 @@ message WizardCapabilities {
message SendspinCapabilities {
// Bitmask of the Sendspin features this device supports
uint32 feature_flags = 1;
// The Sendspin client_id: base64url of the client's static public key, 43 characters.
// Empty until the Sendspin client has started once since boot.
string client_id = 2 [(max_data_length) = 43];
}
message DeviceCapabilitiesResponse {
@@ -444,7 +447,7 @@ message DeviceWizardRequest {
// {"version": 1,
// "pages": [{"title": "...", "description": "...",
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
// "inputs": [{"key": 789, "description": "...",
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
// "entity_filters": [{"integration": "...", "domain": ["..."],
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
//
@@ -468,6 +471,14 @@ message DeviceWizardRequest {
// an entity matches a filter when it satisfies every field that is set, and an entity
// that matches any one of the filters is accepted.
//
// An input with "multiple": true lets the user pick several entities. The client sends
// them in one WizardInputSetRequest as a comma separated list with no spaces
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
// longest list in bytes the device accepts. The version stays 1: a client that does not
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
// multiple input accepts.
//
// The device keeps the entity id of an input in RAM only and does not store it across
// restarts. The client therefore stores the choice itself and, after every connection
// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest
@@ -483,8 +494,13 @@ message DeviceWizardResponse {
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no
// reply. The device ignores a request whose key matches no input or whose entity_id
// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM,
// reply. The device ignores a request whose key matches no input. A single input is
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
// spaces, the device trims spaces around each id and stores the list without them. The
// whole request is ignored when any id is empty or has no '.', or when the list without
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
// or list in RAM,
// not across restarts, and when the input belongs to a homeassistant platform entity
// it subscribes to that entity's state. Clients send this for every input right
// after connecting, before SubscribeHomeAssistantStatesRequest. A request that
@@ -496,7 +512,7 @@ message WizardInputSetRequest {
option (ifdef) = "USE_API_WIZARD_INPUTS";
fixed32 key = 1;
string entity_id = 2 [(max_data_length) = 255];
string entity_id = 2 [(max_data_length) = 4095];
}
message ListEntitiesRequest {
@@ -1013,8 +1029,8 @@ message SubscribeHomeassistantServicesRequest {
}
message HomeassistantServiceMap {
string key = 1;
string value = 2;
string key = 1 [(progmem) = true];
string value = 2 [(progmem) = true];
}
message HomeassistantActionRequest {
@@ -1023,7 +1039,7 @@ message HomeassistantActionRequest {
option (no_delay) = true;
option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES";
string service = 1;
string service = 1 [(progmem) = true];
repeated HomeassistantServiceMap data = 2 [(fixed_vector) = true];
repeated HomeassistantServiceMap data_template = 3 [(fixed_vector) = true];
repeated HomeassistantServiceMap variables = 4 [(fixed_vector) = true];
@@ -1144,21 +1160,21 @@ enum SupportsResponseType {
}
message ListEntitiesServicesArgument {
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
string name = 1;
string name = 1 [(progmem) = true];
ServiceArgType type = 2;
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
}
message ListEntitiesServicesResponse {
option (id) = 41;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
string name = 1;
string name = 1 [(progmem) = true];
fixed32 key = 2 [(force) = true];
repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true];
SupportsResponseType supports_response = 4;
string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"];
string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true];
}
message ExecuteServiceArgument {
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
@@ -2557,7 +2573,7 @@ message ListEntitiesEventResponse {
EntityCategory entity_category = 7;
string device_class = 8 [(max_data_length) = 47];
repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector<const char *>"];
repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector<const char *, true>"];
uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"];
}
message EventResponse {
@@ -3030,12 +3046,15 @@ enum SerialProxyIdentityFlag {
// IDs below are empty
}
// The descriptor fields of a USB device as seen by the host stack
// The descriptor fields of a USB device as seen by the host stack, and the port's index
// among those a vendor driver exposes on the interface
message UsbDeviceDescriptor {
uint32 vendor_id = 1;
uint32 product_id = 2;
uint32 bcd_device = 3;
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to
uint32 interface_number = 4; // bInterfaceNumber the port is bound through
uint32 interface_class = 5; // bInterfaceClass of that interface
uint32 port_number = 6;
}
// The identity of the device behind a port. Identifies one physical device among others of
+13
View File
@@ -1,4 +1,7 @@
#include "api_connection.h"
#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER)
#include "esphome/components/logger/logger.h"
#endif
#ifdef USE_API
#include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines
#ifdef USE_API_NOISE
@@ -451,6 +454,15 @@ bool APIConnection::send_disconnect_response_() {
DisconnectResponse resp;
return this->send_message(resp);
}
void APIConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) {
this->flags_.log_subscription = msg.level;
if (msg.dump_config)
App.schedule_dump_config();
#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER)
logger::log_crash_to_client();
#endif
}
void APIConnection::on_disconnect_response() {
// Don't close socket here, let APIServer::loop() do it
// so getpeername() still works for the disconnect trigger
@@ -2119,6 +2131,7 @@ bool APIConnection::send_device_capabilities_response_() {
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id();
#endif
return this->send_message(resp);
}
+1 -24
View File
@@ -15,15 +15,6 @@
#include "subscribe_state.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
#include "esphome/components/esp32/crash_handler.h"
#endif
#ifdef USE_RP2_CRASH_HANDLER
#include "esphome/components/rp2/crash_handler.h"
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
#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"
@@ -298,21 +289,7 @@ class APIConnection final : public APIServerConnectionBase {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
}
void on_subscribe_logs_request(const SubscribeLogsRequest &msg) {
this->flags_.log_subscription = msg.level;
if (msg.dump_config)
App.schedule_dump_config();
#ifdef USE_ESP32_CRASH_HANDLER
esp32::crash_handler_log();
esp32::crash_handler_clear();
#endif
#ifdef USE_RP2_CRASH_HANDLER
rp2::crash_handler_log();
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
esp8266::crash_handler_log();
#endif
}
void on_subscribe_logs_request(const SubscribeLogsRequest &msg);
#ifdef USE_API_HOMEASSISTANT_SERVICES
void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; }
#endif
+1 -1
View File
@@ -123,7 +123,7 @@ extend google.protobuf.FieldOptions {
// from an absent one (e.g. a UTC ParsedTimezone, which is all zeros).
optional bool track_presence = 50020 [default=false];
// progmem: The pointer_to_buffer bytes field points into flash (PROGMEM).
// progmem: The string field, or pointer_to_buffer bytes field, points into flash (PROGMEM).
// The generated encode and dump copy it with progmem_memcpy, as ESP8266
// cannot read flash with ordinary loads.
optional bool progmem = 50021 [default=false];
+14 -8
View File
@@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) {
uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id);
return pos;
}
uint32_t SendspinCapabilities::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, msg.feature_flags);
size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0;
return size;
}
#endif
@@ -1140,8 +1142,8 @@ uint32_t NoiseEncryptionSetKeyResponse::calc_size_msg(const void *self) {
#ifdef USE_API_HOMEASSISTANT_SERVICES
uint8_t *HomeassistantServiceMap::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const HomeassistantServiceMap *>(self);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value);
return pos;
}
uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) {
@@ -1153,7 +1155,7 @@ uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) {
}
uint8_t *HomeassistantActionRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const HomeassistantActionRequest *>(self);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service);
for (auto &it : msg.data) {
pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, it);
}
@@ -1324,13 +1326,13 @@ void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data
uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self,
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const ListEntitiesServicesArgument *>(self);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(msg.type));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example);
#endif
return pos;
}
@@ -1350,14 +1352,14 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) {
uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self,
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const ListEntitiesServicesResponse *>(self);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name);
pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key);
for (auto &it : msg.args) {
pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it);
}
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast<uint32_t>(msg.supports_response));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description);
pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description);
#endif
return pos;
}
@@ -4175,6 +4177,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number);
return pos;
}
uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
@@ -4184,6 +4188,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.product_id);
size += ProtoSize::calc_uint32(1, msg.bcd_device);
size += ProtoSize::calc_uint32(1, msg.interface_number);
size += ProtoSize::calc_uint32(1, msg.interface_class);
size += ProtoSize::calc_uint32(1, msg.port_number);
return size;
}
uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
+12 -9
View File
@@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage {
class SendspinCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
StringRef client_id{nullptr, 0}; // null until set, encode only
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -1388,8 +1389,8 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage {
#ifdef USE_API_HOMEASSISTANT_SERVICES
class HomeassistantServiceMap final : public ProtoMessage {
public:
StringRef key{nullptr, 0}; // null until set, encode only
StringRef value{nullptr, 0}; // null until set, encode only
ProgmemStringRef key{};
ProgmemStringRef value{};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -1409,7 +1410,7 @@ class HomeassistantActionRequest final : public ProtoMessage {
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); }
#endif
StringRef service{nullptr, 0}; // null until set, encode only
ProgmemStringRef service{};
FixedVector<HomeassistantServiceMap> data{};
FixedVector<HomeassistantServiceMap> data_template{};
FixedVector<HomeassistantServiceMap> variables{};
@@ -1576,13 +1577,13 @@ class GetTimeResponse final : public ProtoDecodableMessage {
#ifdef USE_API_USER_DEFINED_ACTIONS
class ListEntitiesServicesArgument final : public ProtoMessage {
public:
StringRef name{nullptr, 0}; // null until set, encode only
ProgmemStringRef name{};
enums::ServiceArgType type{};
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef description{nullptr, 0}; // null until set, encode only
ProgmemStringRef description{};
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef example{nullptr, 0}; // null until set, encode only
ProgmemStringRef example{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -1603,12 +1604,12 @@ class ListEntitiesServicesResponse final : public ProtoMessage {
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); }
#endif
StringRef name{nullptr, 0}; // null until set, encode only
ProgmemStringRef name{};
uint32_t key{0};
FixedVector<ListEntitiesServicesArgument> args{};
enums::SupportsResponseType supports_response{};
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
StringRef description{nullptr, 0}; // null until set, encode only
ProgmemStringRef description{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -3460,7 +3461,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage {
const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); }
#endif
StringRef device_class{nullptr, 0}; // null until set, encode only
const FixedVector<const char *> *event_types{};
const ConstVector<const char *, true> *event_types{};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -4083,6 +4084,8 @@ class UsbDeviceDescriptor final : public ProtoMessage {
uint32_t product_id{0};
uint32_t bcd_device{0};
uint32_t interface_number{0};
uint32_t interface_class{0};
uint32_t port_number{0};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
+21 -10
View File
@@ -12,8 +12,9 @@ namespace esphome::api {
#ifdef USE_ESP8266
// Out-of-line to avoid inlining strlen_P/memcpy_P at every call site
void DumpBuffer::append_p_esp8266(const char *str) {
size_t len = strlen_P(str);
void DumpBuffer::append_p_esp8266(const char *str) { this->append_p_esp8266(str, strlen_P(str)); }
void DumpBuffer::append_p_esp8266(const char *str, size_t len) {
size_t space = CAPACITY - 1 - pos_;
if (len > space)
len = space;
@@ -134,6 +135,13 @@ static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, co
progmem_memcpy(data_buf, data, len);
dump_bytes_field(out, field_name, data_buf, len, indent);
}
// Helper for string fields in flash
static void dump_progmem_string_field(DumpBuffer &out, const char *field_name, const ProgmemStringRef &value,
int indent = 2) {
append_field_prefix(out, field_name, indent);
out.append("'").append_p(value.progmem_ptr(), value.size()).append("'\n");
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
@@ -1119,6 +1127,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("client_id"), this->client_id);
return out.c_str();
}
#endif
@@ -1511,13 +1520,13 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const {
#ifdef USE_API_HOMEASSISTANT_SERVICES
const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantServiceMap"));
dump_field(out, ESPHOME_PSTR("key"), this->key);
dump_field(out, ESPHOME_PSTR("value"), this->value);
dump_progmem_string_field(out, ESPHOME_PSTR("key"), this->key);
dump_progmem_string_field(out, ESPHOME_PSTR("value"), this->value);
return out.c_str();
}
const char *HomeassistantActionRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HomeassistantActionRequest"));
dump_field(out, ESPHOME_PSTR("service"), this->service);
dump_progmem_string_field(out, ESPHOME_PSTR("service"), this->service);
for (const auto &it : this->data) {
out.append(4, ' ').append_p(ESPHOME_PSTR("data")).append(": ");
it.dump_to(out);
@@ -1612,19 +1621,19 @@ const char *GetTimeResponse::dump_to(DumpBuffer &out) const {
#ifdef USE_API_USER_DEFINED_ACTIONS
const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesArgument"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ServiceArgType>(this->type));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("description"), this->description);
dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description);
#endif
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("example"), this->example);
dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example);
#endif
return out.c_str();
}
const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesResponse"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("key"), this->key);
for (const auto &it : this->args) {
out.append(4, ' ').append_p(ESPHOME_PSTR("args")).append(": ");
@@ -1633,7 +1642,7 @@ const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const {
}
dump_field(out, ESPHOME_PSTR("supports_response"), static_cast<enums::SupportsResponseType>(this->supports_response));
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
dump_field(out, ESPHOME_PSTR("description"), this->description);
dump_progmem_string_field(out, ESPHOME_PSTR("description"), this->description);
#endif
return out.c_str();
}
@@ -2942,6 +2951,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class);
dump_field(out, ESPHOME_PSTR("port_number"), this->port_number);
return out.c_str();
}
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
+3 -1
View File
@@ -237,6 +237,8 @@ void APIServer::refill_spare_ephemeral_() {
return;
}
}
// Generating the key pair cannot be split across loop passes
UnavoidableBlockingScope scope;
noise::prepare_spare_ephemeral();
}
#endif
@@ -545,7 +547,7 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call
// arrive before the subscription and are lost - warn instead of failing silently.
ESP_LOGW(TAG, "Home Assistant %s '%.*s' dropped; %s",
call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"),
static_cast<int>(call.service.size()), call.service.empty() ? "" : call.service.c_str(),
static_cast<int>(call.service.size()), call.service.empty() ? "" : call.service.progmem_ptr(),
this->is_connected() ? LOG_STR_LITERAL("client has not subscribed to actions (yet)")
: LOG_STR_LITERAL("no client connected"));
}
+49 -7
View File
@@ -14,26 +14,68 @@ namespace esphome::api {
ESPHOME_LOG_TAG(TAG, "api.wizard");
#ifdef USE_API_WIZARD_INPUTS
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
size_t joined = 0;
const char *end = value + length;
while (true) {
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
const char *id_end = comma != nullptr ? comma : end;
while (value < id_end && *value == ' ')
value++;
while (id_end > value && id_end[-1] == ' ')
id_end--;
size_t id_length = id_end - value;
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
return 0;
if (joined != 0) {
if (out != nullptr)
out[joined] = ',';
joined++;
}
if (out != nullptr)
memcpy(out + joined, value, id_length);
joined += id_length;
if (comma == nullptr)
return joined;
value = comma + 1;
}
}
#endif // USE_API_WIZARD_MULTIPLE_INPUTS
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
}
const char *wizard_set_input(const WizardInputSetRequest &msg) {
if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) {
ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input");
return nullptr;
}
const char *value = msg.entity_id.c_str();
const size_t length = msg.entity_id.size();
for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) {
// The table is in flash, which ESP8266 can only read through progmem_memcpy
WizardInputEntry entry;
progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry));
if (entry.key != msg.key)
continue;
memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size());
entry.entity_id[msg.entity_id.size()] = '\0';
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
if (entry.size != 0) {
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
if (joined == 0 || joined >= entry.size)
break;
wizard_join_entity_ids(value, length, entry.entity_id);
entry.entity_id[joined] = '\0';
return entry.entity_id;
}
#endif
if (!wizard_entity_id_valid(value, length))
break;
memcpy(entry.entity_id, value, length);
entry.entity_id[length] = '\0';
return entry.entity_id;
}
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input");
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
return nullptr;
}
#endif // USE_API_WIZARD_INPUTS
+33 -2
View File
@@ -6,6 +6,9 @@
#include <cstddef>
#include <cstdint>
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
#include <cstring>
#endif
#include "esphome/core/hal.h"
#include "proto.h"
@@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM;
/// Where the entity id of an input is kept, found by the key the client uses for it.
struct WizardInputEntry {
uint32_t key; // FNV-1 hash of the ESPHome id of the input
char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
uint16_t size; // Buffer size of a multiple input, 0 for a single input
#endif
};
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
@@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg);
class WizardInput {
public:
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
/// The Home Assistant entity ID, empty until the wizard sets one.
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
/// comma separated list, which homeassistant.action data can use as it is.
StringRef entity_id() const { return StringRef(this->entity_id_); }
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
/// The number of entity IDs in the list, 0 until the wizard sets one.
size_t entity_id_count() const {
if (!this->has_entity_id())
return 0;
size_t count = 1;
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
count++;
return count;
}
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
/// not null terminated, so print it with "%.*s".
template<typename F> void for_each_entity_id(F &&callback) const {
// The device stores the list without spaces or empty entries
const char *start = this->entity_id_;
while (*start != '\0') {
const char *end = strchr(start, ',');
if (end == nullptr)
end = start + strlen(start);
callback(StringRef(start, end - start));
start = *end == ',' ? end + 1 : end;
}
}
#endif
protected:
const char *entity_id_;
+8 -8
View File
@@ -247,7 +247,7 @@ class CustomAPIDevice {
*/
void call_homeassistant_service(const std::string &service_name) {
HomeassistantActionRequest resp;
resp.service = StringRef(service_name);
resp.service = ProgmemStringRef(StringRef(service_name));
global_api_server->send_homeassistant_action(resp);
}
@@ -267,12 +267,12 @@ class CustomAPIDevice {
*/
void call_homeassistant_service(const std::string &service_name, const std::map<std::string, std::string> &data) {
HomeassistantActionRequest resp;
resp.service = StringRef(service_name);
resp.service = ProgmemStringRef(StringRef(service_name));
resp.data.init(data.size());
for (auto &it : data) {
auto &kv = resp.data.emplace_back();
kv.key = StringRef(it.first);
kv.value = StringRef(it.second); // data map lives until send completes
kv.key = ProgmemStringRef(StringRef(it.first));
kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes
}
global_api_server->send_homeassistant_action(resp);
}
@@ -289,7 +289,7 @@ class CustomAPIDevice {
*/
void fire_homeassistant_event(const std::string &event_name) {
HomeassistantActionRequest resp;
resp.service = StringRef(event_name);
resp.service = ProgmemStringRef(StringRef(event_name));
resp.is_event = true;
global_api_server->send_homeassistant_action(resp);
}
@@ -309,13 +309,13 @@ class CustomAPIDevice {
*/
void fire_homeassistant_event(const std::string &service_name, const std::map<std::string, std::string> &data) {
HomeassistantActionRequest resp;
resp.service = StringRef(service_name);
resp.service = ProgmemStringRef(StringRef(service_name));
resp.is_event = true;
resp.data.init(data.size());
for (auto &it : data) {
auto &kv = resp.data.emplace_back();
kv.key = StringRef(it.first);
kv.value = StringRef(it.second); // data map lives until send completes
kv.key = ProgmemStringRef(StringRef(it.first));
kv.value = ProgmemStringRef(StringRef(it.second)); // data map lives until send completes
}
global_api_server->send_homeassistant_action(resp);
}
+4 -28
View File
@@ -116,42 +116,18 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
const Field *fields = this->fields_;
const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_;
// Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send
// Lambda results must live until the send; the constant strings are encoded from flash where they are
size_t lambda_count = 0;
#ifdef USE_ESP8266
size_t flash_len = 0;
#endif
for (size_t i = 0; i < total; i++) {
for (size_t i = 0; i < total; i++)
lambda_count += fields[i].fn != nullptr;
#ifdef USE_ESP8266
if (fields[i].fn == nullptr)
flash_len += ESPHOME_strlen_P(fields[i].value);
if (fields[i].key != nullptr)
flash_len += ESPHOME_strlen_P(fields[i].key);
#endif
}
FixedVector<std::string> results;
results.init(lambda_count);
#ifdef USE_ESP8266
SmallBufferWithHeapFallback<128, char> flash_copy(flash_len);
char *cursor = flash_copy.get();
#endif
auto string_ref = [&](const char *str) {
#ifdef USE_ESP8266
size_t len = ESPHOME_strlen_P(str);
memcpy_P(cursor, str, len);
StringRef ref(cursor, len);
cursor += len;
return ref;
#else
return StringRef(str);
#endif
};
auto string_ref = [](const char *str) { return ProgmemStringRef(str, ESPHOME_strlen_P(str)); };
auto value_ref = [&](const Field &field) {
if (field.fn == nullptr)
return string_ref(field.value);
results.push_back(field.fn(x...));
return StringRef(results.back());
return ProgmemStringRef(StringRef(results.back()));
};
auto fill = [&](FixedVector<HomeassistantServiceMap> &dest, uint8_t count) {
dest.init(count);
+1 -2
View File
@@ -99,8 +99,7 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie
static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3;
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
UserActionScratch scratch;
auto resp = service->encode_list_service_response(scratch);
auto resp = service->encode_list_service_response();
if (!this->client_->send_message(resp))
return false;
// at_ is this service's index
+34
View File
@@ -418,6 +418,18 @@ class ProtoEncode {
std::memcpy(pos + 2, ref.c_str(), ref.size());
return pos + 2 + ref.size();
}
/// encode_short_string_force for a string that may be in flash (PROGMEM).
[[nodiscard]] static inline uint8_t *encode_short_progmem_string_force(
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const ProgmemStringRef &ref) {
#ifdef ESPHOME_DEBUG_API
assert(ref.size() < 128 && "encode_short_progmem_string_force: string exceeds max_data_length < 128");
#endif
PROTO_ENCODE_CHECK_BOUNDS(pos, 2 + ref.size());
pos[0] = tag;
pos[1] = static_cast<uint8_t>(ref.size());
progmem_memcpy(pos + 2, ref.progmem_ptr(), ref.size());
return pos + 2 + ref.size();
}
/// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field.
[[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32(
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) {
@@ -481,6 +493,17 @@ class ProtoEncode {
progmem_memcpy(pos, data, len);
return pos + len;
}
/// encode_progmem_bytes for a string field that may be in flash.
[[nodiscard]] static inline uint8_t *encode_progmem_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const ProgmemStringRef &ref) {
return encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, field_id,
reinterpret_cast<const uint8_t *>(ref.progmem_ptr()), ref.size());
}
[[nodiscard]] static inline uint8_t *encode_progmem_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const ProgmemStringRef &ref) {
return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id,
reinterpret_cast<const uint8_t *>(ref.progmem_ptr()), ref.size());
}
[[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, uint32_t value) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
@@ -680,9 +703,20 @@ class DumpBuffer {
return *this;
}
/// Append len bytes of a PROGMEM string
DumpBuffer &append_p(const char *str, size_t len) {
#ifdef USE_ESP8266
append_p_esp8266(str, len);
#else
append_impl_(str, len);
#endif
return *this;
}
#ifdef USE_ESP8266
/// Out-of-line ESP8266 PROGMEM append to avoid inlining strlen_P/memcpy_P at every call site
void append_p_esp8266(const char *str);
void append_p_esp8266(const char *str, size_t len);
#endif
const char *c_str() const { return buf_; }
+9 -20
View File
@@ -1,47 +1,36 @@
#include "user_services.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
namespace esphome::api {
StringRef UserServiceStatic::str_(size_t idx, std::span<char> &scratch) const {
ProgmemStringRef UserServiceStatic::str_(size_t idx) const {
const char *s = progmem_read_ptr(&this->strings_[idx]);
if (s == nullptr)
return {};
#ifdef USE_ESP8266
// Codegen sizes the scratch buffer for the largest service; the bound only guards other callers
if (scratch.empty())
return {};
size_t len = strnlen_P(s, scratch.size() - 1);
progmem_memcpy(scratch.data(), s, len);
scratch[len] = '\0';
StringRef ref(scratch.data(), len);
scratch = scratch.subspan(len + 1);
return ref;
#else
return StringRef(s);
#endif
return ProgmemStringRef(s, ESPHOME_strlen_P(s));
}
ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_(
std::span<const enums::ServiceArgType> arg_types, std::span<char> scratch) const {
std::span<const enums::ServiceArgType> arg_types) const {
ListEntitiesServicesResponse msg;
msg.name = this->str_(0, scratch);
msg.name = this->str_(0);
msg.key = this->key_;
msg.supports_response = this->supports_response_;
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
msg.description = this->str_(1, scratch);
msg.description = this->str_(1);
#endif
msg.args.init(arg_types.size());
for (size_t i = 0; i < arg_types.size(); i++) {
size_t base = USER_ACTION_HEADER_STRINGS + i * USER_ACTION_ARG_STRINGS;
auto &arg = msg.args.emplace_back();
arg.type = arg_types[i];
arg.name = this->str_(base, scratch);
arg.name = this->str_(base);
#ifdef USE_API_USER_DEFINED_ACTION_METADATA
arg.description = this->str_(base + 1, scratch);
arg.example = this->str_(base + 2, scratch);
arg.description = this->str_(base + 1);
arg.example = this->str_(base + 2);
#endif
}
return msg;
+10 -21
View File
@@ -20,9 +20,8 @@ class APIServer;
class UserServiceDescriptor {
public:
/// Build the list-entities message. On ESP8266 the strings live in PROGMEM and are copied into
/// `scratch`, so the returned message is only valid while `scratch` is; other platforms ignore it.
virtual ListEntitiesServicesResponse encode_list_service_response(std::span<char> scratch) = 0;
/// Build the list-entities message; its strings may point into flash (PROGMEM).
virtual ListEntitiesServicesResponse encode_list_service_response() = 0;
virtual bool execute_service(const ExecuteServiceRequest &req) = 0;
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
@@ -37,13 +36,6 @@ template<typename T> T get_execute_arg_value(const ExecuteServiceArgument &arg);
template<typename T> enums::ServiceArgType to_service_arg_type();
// Scratch buffer list-entities hands to encode_list_service_response(); only ESP8266 copies into it
#ifdef USE_ESP8266
using UserActionScratch = std::array<char, API_USER_ACTION_STRINGS_SCRATCH_SIZE>;
#else
using UserActionScratch = std::array<char, 0>;
#endif
// Non-template base for YAML-defined services so the list-entities encoder is compiled once.
// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in
// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then
@@ -63,12 +55,9 @@ class UserServiceStatic : public UserServiceDescriptor {
: strings_(strings), key_(key), supports_response_(supports_response) {}
protected:
ListEntitiesServicesResponse encode_list_service_response_(std::span<const enums::ServiceArgType> arg_types,
std::span<char> scratch) const;
/// Reference table entry `idx`; nullptr gives an empty StringRef.
/// On ESP8266 the bytes are copied out of PROGMEM into `scratch` with a terminator, and the span
/// is advanced past the copy.
StringRef str_(size_t idx, std::span<char> &scratch) const;
ListEntitiesServicesResponse encode_list_service_response_(std::span<const enums::ServiceArgType> arg_types) const;
/// Table entry `idx` as it sits in flash; nullptr gives an empty string.
ProgmemStringRef str_(size_t idx) const;
const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr()
uint32_t key_;
@@ -79,9 +68,9 @@ template<typename... Ts> class UserServiceBase : public UserServiceStatic {
public:
using UserServiceStatic::UserServiceStatic;
ListEntitiesServicesResponse encode_list_service_response(std::span<char> scratch) override {
ListEntitiesServicesResponse encode_list_service_response() override {
std::array<enums::ServiceArgType, sizeof...(Ts)> arg_types = {to_service_arg_type<Ts>()...};
return this->encode_list_service_response_(arg_types, scratch);
return this->encode_list_service_response_(arg_types);
}
bool execute_service(const ExecuteServiceRequest &req) override {
@@ -125,9 +114,9 @@ template<typename... Ts> class UserServiceDynamic : public UserServiceDescriptor
this->key_ = fnv1_hash(this->name_.c_str());
}
ListEntitiesServicesResponse encode_list_service_response(std::span<char> /*scratch*/) override {
ListEntitiesServicesResponse encode_list_service_response() override {
ListEntitiesServicesResponse msg;
msg.name = StringRef(this->name_);
msg.name = ProgmemStringRef(StringRef(this->name_));
msg.key = this->key_;
msg.supports_response = enums::SUPPORTS_RESPONSE_NONE; // Dynamic services don't support responses yet
std::array<enums::ServiceArgType, sizeof...(Ts)> arg_types = {to_service_arg_type<Ts>()...};
@@ -135,7 +124,7 @@ template<typename... Ts> class UserServiceDynamic : public UserServiceDescriptor
for (size_t i = 0; i < sizeof...(Ts); i++) {
auto &arg = msg.args.emplace_back();
arg.type = arg_types[i];
arg.name = StringRef(this->arg_names_[i]);
arg.name = ProgmemStringRef(StringRef(this->arg_names_[i]));
}
return msg;
}
+48 -7
View File
@@ -20,6 +20,8 @@ from esphome.const import (
CONF_ENTITY_ID,
CONF_ID,
CONF_INTERNAL,
CONF_MAX_LENGTH,
CONF_MULTIPLE,
CONF_NAME,
CONF_PAGES,
CONF_PLATFORM,
@@ -51,6 +53,10 @@ WizardInput = _API.class_("WizardInput")
WIZARD_ENTITY_ID_BUFFER_SIZE = (
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
)
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
# api.proto max_data_length documents the same value
WIZARD_MULTIPLE_MAX_LENGTH = 4095
# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes,
# Noise) and footer (16 bytes, Noise MAC)
WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16
@@ -106,6 +112,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema(
)
def _validate_multiple(conf: ConfigType) -> ConfigType:
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
raise cv.Invalid(
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
"is only for standalone inputs (id)",
path=[CONF_MULTIPLE],
)
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
raise cv.Invalid(
"max_length only applies to an input with multiple: true",
path=[CONF_MAX_LENGTH],
)
return conf
# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no
# entity_id of its own: Home Assistant sets it
WIZARD_INPUT_SCHEMA = cv.All(
@@ -114,6 +135,10 @@ WIZARD_INPUT_SCHEMA = cv.All(
cv.Optional(CONF_ID): cv.declare_id(WizardInput),
cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase),
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
cv.Optional(CONF_MULTIPLE, default=False): cv.boolean,
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
),
cv.Optional(CONF_TARGET): cv.Schema(
{
cv.Required(CONF_ENTITY): cv.All(
@@ -124,6 +149,7 @@ WIZARD_INPUT_SCHEMA = cv.All(
}
),
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
_validate_multiple,
)
WIZARD_PAGE_SCHEMA = cv.All(
@@ -248,6 +274,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]:
defines.add("USE_API_WIZARD_LINKED_INPUTS")
if any(CONF_ID in conf for conf in inputs):
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
return defines
@@ -371,6 +399,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
if description := conf.get(CONF_DESCRIPTION):
document[CONF_DESCRIPTION] = description
if conf.get(CONF_MULTIPLE):
document["multiple"] = True
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
document["max_length"] = max_length
if filters := _wizard_input_filters(conf, config):
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
return document
@@ -384,7 +416,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
{"version": 1,
"pages": [{"title": "...", "description": "...",
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
"inputs": [{"key": 789, "description": "...",
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
"entity_filters": [{"integration": "...", "domain": ["..."],
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
@@ -396,6 +428,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
the device it belongs to (esphome/core/config.py), and is left out for the main device.
- An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked
entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets.
- multiple means the user may pick several entities. The client sends them in one
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
device trims spaces around each id and stores the list without them. The whole request is
ignored when any id is empty or has no '.', or when the list without spaces does not fit
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
accepts.
- entity_filters are the filters of the input, or the default filters of a linked switch,
number, text, select or button.
"""
@@ -441,14 +481,15 @@ async def to_code(wizard: ConfigType) -> None:
input_id = _wizard_input_id(conf)
# Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id.
buffer = _wizard_buffer_name(input_id)
cg.add_global(
cg.RawStatement(
f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";'
)
)
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";'))
if CONF_ID in conf:
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}"))
key = fnv1_hash(input_id.id)
if conf[CONF_MULTIPLE]:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
else:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
if entries:
cg.extern_progmem_array(
"esphome::api::API_WIZARD_INPUTS",
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@patrickod"]
DOMAIN = "aranet"
+157
View File
@@ -0,0 +1,157 @@
#include "aranet.h"
#include <cinttypes>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::aranet {
ESPHOME_LOG_TAG(TAG, "aranet");
static constexpr uint16_t ARANET_MANUFACTURER_ID = 0x0702;
static constexpr size_t ARANET4_DATA_LENGTH = 22;
static constexpr size_t OTHER_ARANET_DATA_LENGTH = 24;
enum class AranetDeviceType : uint8_t {
ARANET_DEVICE_TYPE_ARANET4 = 0,
ARANET_DEVICE_TYPE_ARANET2 = 1,
ARANET_DEVICE_TYPE_RADIATION = 2,
ARANET_DEVICE_TYPE_RADON = 3,
};
static inline void publish_if_set(sensor::Sensor *sensor, float value) {
if (sensor != nullptr)
sensor->publish_state(value);
}
// Aranet does not publish an official advertisement specification. The layout below is corroborated by these
// independently maintained implementations:
// https://github.com/Anrijs/Aranet4-Python/blob/master/aranet4/client.py
// https://github.com/Anrijs/Aranet4-ESP32/blob/main/src/Aranet4.h
void Aranet::dump_config() {
ESP_LOGCONFIG(TAG, "Aranet");
LOG_SENSOR(" ", "Carbon Dioxide", this->co2_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
LOG_SENSOR(" ", "Pressure", this->pressure_sensor_);
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
LOG_SENSOR(" ", "Measurement Interval", this->measurement_interval_sensor_);
LOG_SENSOR(" ", "Measurement Age", this->measurement_age_sensor_);
LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_);
LOG_SENSOR(" ", "Radon Concentration", this->radon_sensor_);
LOG_SENSOR(" ", "Radiation Dose Rate", this->radiation_rate_sensor_);
LOG_SENSOR(" ", "Radiation Total Dose", this->radiation_total_sensor_);
LOG_SENSOR(" ", "Radiation Dose Duration", this->radiation_duration_sensor_);
}
bool Aranet::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != this->address_)
return false;
const auto manufacturer_uuid = ble_device_base::ESPBTUUID::from_uint16(ARANET_MANUFACTURER_ID);
for (const auto &manufacturer_data : device.get_manufacturer_datas()) {
if (manufacturer_data.uuid != manufacturer_uuid)
continue;
const auto &data = manufacturer_data.data;
if (data.size() != ARANET4_DATA_LENGTH && data.size() != OTHER_ARANET_DATA_LENGTH) {
if ((this->warned_ & WARNED_INVALID_LENGTH) == 0) {
ESP_LOGW(TAG, "Unexpected manufacturer data length: %u", static_cast<unsigned>(data.size()));
this->warned_ |= WARNED_INVALID_LENGTH;
}
return false;
}
// Aranet4 has no device type byte, so its flags and trailer start one byte earlier
const bool is_aranet4 = data.size() == ARANET4_DATA_LENGTH;
if ((data[is_aranet4 ? 0 : 1] & 0x20) == 0) {
if ((this->warned_ & WARNED_INTEGRATION_DISABLED) == 0) {
ESP_LOGW(TAG, "Smart Home integration is disabled on the Aranet device");
this->warned_ |= WARNED_INTEGRATION_DISABLED;
}
return false;
}
if (!is_aranet4 && (data[0] == 0 || data[0] > 3)) {
if ((this->warned_ & WARNED_UNSUPPORTED_TYPE) == 0) {
ESP_LOGW(TAG, "Unsupported Aranet device type: %u", data[0]);
this->warned_ |= WARNED_UNSUPPORTED_TYPE;
}
return false;
}
const auto device_type =
is_aranet4 ? AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4 : static_cast<AranetDeviceType>(data[0]);
// Battery, measurement interval, measurement age and measurement counter
const uint8_t *trailer = &data[is_aranet4 ? 15 : 17];
const uint16_t measurement_interval = encode_uint16(trailer[3], trailer[2]);
const uint16_t measurement_age = encode_uint16(trailer[5], trailer[4]);
const uint8_t measurement_counter = trailer[6];
publish_if_set(this->measurement_age_sensor_, measurement_age);
publish_if_set(this->signal_strength_sensor_, device.get_rssi());
if (measurement_counter == this->last_measurement_counter_)
return true;
this->last_measurement_counter_ = measurement_counter;
// Not meaningful for Aranet Radiation, which stores its dose duration in these bytes
const float temperature = encode_uint16(data[11], data[10]) / 20.0f;
const float pressure = encode_uint16(data[13], data[12]) / 10.0f;
switch (device_type) {
case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4: {
const uint16_t co2 = encode_uint16(data[9], data[8]);
publish_if_set(this->co2_sensor_, co2);
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->pressure_sensor_, pressure);
publish_if_set(this->humidity_sensor_, data[14]);
ESP_LOGD(TAG, "Aranet4 CO2: %u ppm, temperature: %.1f C, humidity: %u%%, pressure: %.1f hPa", co2, temperature,
data[14], pressure);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET2: {
const float humidity = encode_uint16(data[15], data[14]) / 10.0f;
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->humidity_sensor_, humidity);
ESP_LOGD(TAG, "Aranet2 temperature: %.1f C, humidity: %.1f%%", temperature, humidity);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_RADIATION: {
const uint32_t radiation_total = encode_uint32(data[9], data[8], data[7], data[6]);
const uint32_t radiation_duration = encode_uint32(data[13], data[12], data[11], data[10]);
const uint16_t radiation_rate = encode_uint16(data[15], data[14]);
publish_if_set(this->radiation_rate_sensor_, radiation_rate / 1000.0f);
publish_if_set(this->radiation_total_sensor_, radiation_total);
publish_if_set(this->radiation_duration_sensor_, radiation_duration);
ESP_LOGD(TAG, "Aranet Radiation dose rate: %u nSv/h, total dose: %" PRIu32 " nSv", radiation_rate,
radiation_total);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_RADON: {
const uint16_t radon = encode_uint16(data[9], data[8]);
const float humidity = encode_uint16(data[15], data[14]) / 10.0f;
publish_if_set(this->radon_sensor_, radon);
// Aranet Radon One uses zero temperature and humidity to indicate fields not supported by that model.
if (temperature != 0)
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->pressure_sensor_, pressure);
if (humidity != 0)
publish_if_set(this->humidity_sensor_, humidity);
ESP_LOGD(TAG, "Aranet Radon: %u Bq/m3, temperature: %.1f C, humidity: %.1f%%, pressure: %.1f hPa", radon,
temperature, humidity, pressure);
break;
}
default:
break;
}
publish_if_set(this->battery_level_sensor_, trailer[0]);
publish_if_set(this->measurement_interval_sensor_, measurement_interval);
return true;
}
return false;
}
} // namespace esphome::aranet
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::aranet {
class Aranet final : public Component, public ble_device_base::ESPBTDeviceListener {
public:
explicit Aranet(uint64_t address) : address_(address) {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
void dump_config() override;
protected:
static constexpr uint8_t WARNED_INVALID_LENGTH = 1 << 0;
static constexpr uint8_t WARNED_INTEGRATION_DISABLED = 1 << 1;
static constexpr uint8_t WARNED_UNSUPPORTED_TYPE = 1 << 2;
// Small state first so it fills the padding before the first pointer
uint16_t last_measurement_counter_{0x100}; // Out of range of the 8-bit counter until the first measurement
uint8_t warned_{0};
SUB_SENSOR(co2)
SUB_SENSOR(temperature)
SUB_SENSOR(humidity)
SUB_SENSOR(pressure)
SUB_SENSOR(battery_level)
SUB_SENSOR(measurement_interval)
SUB_SENSOR(measurement_age)
SUB_SENSOR(signal_strength)
SUB_SENSOR(radon)
SUB_SENSOR(radiation_rate)
SUB_SENSOR(radiation_total)
SUB_SENSOR(radiation_duration)
protected:
uint64_t address_;
};
} // namespace esphome::aranet
+150
View File
@@ -0,0 +1,150 @@
import esphome.codegen as cg
from esphome.components import ble_device_base, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_CO2,
CONF_HUMIDITY,
CONF_ID,
CONF_MAC_ADDRESS,
CONF_PRESSURE,
CONF_RADON,
CONF_SIGNAL_STRENGTH,
CONF_TEMPERATURE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_RADON,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_RADIOACTIVE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_BECQUEREL_PER_CUBIC_METER,
UNIT_CELSIUS,
UNIT_DECIBEL_MILLIWATT,
UNIT_HECTOPASCAL,
UNIT_MICROSILVERTS_PER_HOUR,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
UNIT_SECOND,
)
from esphome.types import ConfigType
AUTO_LOAD = ["ble_device_base"]
CONF_MEASUREMENT_AGE = "measurement_age"
CONF_MEASUREMENT_INTERVAL = "measurement_interval"
CONF_RADIATION_RATE = "radiation_rate"
CONF_RADIATION_TOTAL = "radiation_total"
CONF_RADIATION_DURATION = "radiation_duration"
UNIT_NANOSIEVERT = "nSv"
aranet_ns = cg.esphome_ns.namespace("aranet")
Aranet = aranet_ns.class_("Aranet", ble_device_base.ESPBTDeviceListener, cg.Component)
DIAGNOSTIC_DURATION_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Aranet),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_CO2): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PRESSURE): sensor.sensor_schema(
unit_of_measurement=UNIT_HECTOPASCAL,
accuracy_decimals=1,
device_class=DEVICE_CLASS_PRESSURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_MEASUREMENT_INTERVAL): DIAGNOSTIC_DURATION_SCHEMA,
cv.Optional(CONF_MEASUREMENT_AGE): DIAGNOSTIC_DURATION_SCHEMA,
cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema(
unit_of_measurement=UNIT_DECIBEL_MILLIWATT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_RADON): sensor.sensor_schema(
unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER,
accuracy_decimals=0,
device_class=DEVICE_CLASS_RADON,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_RADIATION_RATE): sensor.sensor_schema(
unit_of_measurement=UNIT_MICROSILVERTS_PER_HOUR,
accuracy_decimals=3,
icon=ICON_RADIOACTIVE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_RADIATION_TOTAL): sensor.sensor_schema(
unit_of_measurement=UNIT_NANOSIEVERT,
accuracy_decimals=0,
icon=ICON_RADIOACTIVE,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_RADIATION_DURATION): sensor.sensor_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex)
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
sensors = sensor.sub_sensors(config)
await sensors(CONF_CO2, var.set_co2_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_MEASUREMENT_INTERVAL, var.set_measurement_interval_sensor)
await sensors(CONF_MEASUREMENT_AGE, var.set_measurement_age_sensor)
await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength_sensor)
await sensors(CONF_RADON, var.set_radon_sensor)
await sensors(CONF_RADIATION_RATE, var.set_radiation_rate_sensor)
await sensors(CONF_RADIATION_TOTAL, var.set_radiation_total_sensor)
await sensors(CONF_RADIATION_DURATION, var.set_radiation_duration_sensor)
+188
View File
@@ -0,0 +1,188 @@
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_CALIBRATION,
CONF_GAIN,
CONF_GLASS_ATTENUATION_FACTOR,
CONF_ID,
CONF_TYPE,
CONF_UPDATE_INTERVAL,
)
from esphome.types import ConfigType
CODEOWNERS = ["@latonita", "@mrgnr"]
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["sensor"]
MULTI_CONF = True
DOMAIN = "as734x"
CONF_AS734X_ID = "as734x_id"
CONF_ATIME = "atime"
CONF_ASTEP = "astep"
CONF_CHANNEL_CORRECTION = "channel_correction"
CONF_DARK_CURRENT = "dark_current"
CONF_NORMALIZE_BASIC_COUNTS = "normalize_basic_counts"
# Band order is a contract with the readout maps in as734x.cpp: the index selects the counts slot.
BANDS_43 = (
"f1",
"f2",
"fz",
"f3",
"f4",
"fy",
"f5",
"fxl",
"f6",
"f7",
"f8",
"nir",
"clear",
)
BANDS_41 = tuple(b for b in BANDS_43 if b not in ("fz", "fy", "fxl"))
as734x_ns = cg.esphome_ns.namespace("as734x")
AS734XComponent = as734x_ns.class_(
"AS734XComponent", cg.PollingComponent, i2c.I2CDevice
)
Gain = as734x_ns.enum("Gain")
GAINS_41 = {
f"X{name}": getattr(Gain, f"GAIN_{name.replace('.', '_')}X")
for name in ("0.5", "1", "2", "4", "8", "16", "32", "64", "128", "256", "512")
}
GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X}
Normalization = as734x_ns.enum("Normalization", True)
NORMALIZATIONS = {
"NONE": Normalization.NORMALIZATION_NONE,
"ALL": Normalization.NORMALIZATION_ALL,
"BANDS": Normalization.NORMALIZATION_BANDS,
"CLEAR": Normalization.NORMALIZATION_CLEAR,
}
# Per-channel correction for the golden device, measured by the manufacturer.
DEFAULT_CHANNEL_CORRECTION_43 = [
1.055464349,
1.043509797,
1.029576268,
1.0175052,
1.00441899,
0.987356499,
0.957597044,
0.995863485,
1.014628964,
0.996500814,
0.933072749,
1.052236338,
0.999570232,
]
# One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us
CYCLE_STEP_SECONDS = 2.78e-6
def _float_list(length: int) -> cv.All:
"""One float per band, so the list length has to match the chip."""
return cv.All(cv.ensure_list(cv.float_), cv.Length(min=length, max=length))
def _calibration_schema(bands: tuple[str, ...]) -> cv.Schema:
correction = (
DEFAULT_CHANNEL_CORRECTION_43 if bands == BANDS_43 else [1.0] * len(bands)
)
return cv.Schema(
{
cv.Optional(CONF_CHANNEL_CORRECTION, default=correction): _float_list(
len(bands)
),
cv.Optional(CONF_DARK_CURRENT, default=[0.0] * len(bands)): _float_list(
len(bands)
),
cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.positive_float,
}
)
def _validate_exposure(cycles: int):
"""Reject an exposure that is empty or longer than the update interval."""
def validator(config: ConfigType) -> ConfigType:
atime, astep = config[CONF_ATIME], config[CONF_ASTEP]
if atime == 0 and astep == 0:
raise cv.Invalid(f"{CONF_ATIME} and {CONF_ASTEP} must not both be 0")
measurement = (atime + 1) * (astep + 1) * CYCLE_STEP_SECONDS * cycles
if measurement > config[CONF_UPDATE_INTERVAL].total_seconds:
raise cv.Invalid(
f"A measurement takes {measurement:.1f}s with these {CONF_ATIME} and "
f"{CONF_ASTEP} values, longer than the {CONF_UPDATE_INTERVAL}"
)
return config
return validator
def _schema(
gains: dict, address: int, cycles: int, bands: tuple[str, ...]
) -> cv.Schema:
return cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(AS734XComponent),
cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains),
cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255),
cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534),
cv.Optional(CONF_CALIBRATION, default={}): _calibration_schema(bands),
cv.Optional(CONF_NORMALIZE_BASIC_COUNTS, default="NONE"): cv.enum(
NORMALIZATIONS, upper=True
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(address)),
_validate_exposure(cycles),
)
CONFIG_SCHEMA = cv.typed_schema(
{
# two SMUX steps per update
"AS7341": _schema(GAINS_41, 0x39, cycles=2, bands=BANDS_41),
"AS7343": _schema(GAINS_43, 0x39, cycles=3, bands=BANDS_43),
"TCS3448": _schema(GAINS_43, 0x59, cycles=3, bands=BANDS_43),
},
upper=True,
)
def _per_band(
values: list[float], bands: tuple[str, ...], absent: float
) -> list[float]:
"""Spread per-band calibration over the 13 slots the C++ side indexes by band."""
spread = [absent] * len(BANDS_43)
for value, band in zip(values, bands, strict=True):
spread[BANDS_43.index(band)] = value
return spread
async def to_code(config: ConfigType) -> None:
chip = config[CONF_TYPE]
cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343")
var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&as734x::{chip}_CHIP"))
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_gain(config[CONF_GAIN]))
cg.add(var.set_atime(config[CONF_ATIME]))
cg.add(var.set_astep(config[CONF_ASTEP]))
cg.add(var.set_basic_counts_normalization(config[CONF_NORMALIZE_BASIC_COUNTS]))
bands = BANDS_41 if chip == "AS7341" else BANDS_43
calibration = config[CONF_CALIBRATION]
cg.add(
var.set_channel_correction(
_per_band(calibration[CONF_CHANNEL_CORRECTION], bands, 1.0)
)
)
cg.add(var.set_dark_current(_per_band(calibration[CONF_DARK_CURRENT], bands, 0.0)))
cg.add(var.set_glass_attenuation_factor(calibration[CONF_GLASS_ATTENUATION_FACTOR]))
+674
View File
@@ -0,0 +1,674 @@
#include "as734x.h"
#include "color_helpers.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <cstdio>
namespace esphome::as734x {
ESPHOME_LOG_TAG(TAG, "as734x");
// Registers and bits shared by both chips
static constexpr uint8_t REG_ENABLE = 0x80;
static constexpr uint8_t REG_ATIME = 0x81;
static constexpr uint8_t REG_ASTATUS = 0x94;
static constexpr uint8_t ENABLE_PON = 0x01;
static constexpr uint8_t ENABLE_SP_EN = 0x02;
static constexpr uint8_t ENABLE_SMUX_EN = 0x10;
static constexpr uint8_t CFG0_REG_BANK = 0x10;
static constexpr uint8_t BANK_SPLIT = 0x80; // registers below this need REG_BANK set
static constexpr uint8_t STATUS2_AVALID = 0x40;
static constexpr uint8_t ASTATUS_ASAT = 0x80;
static constexpr uint8_t LED_ACT = 0x80; // LED driver on, in the chip's LED register
static constexpr uint8_t CONFIG_LED_SEL = 0x08;
static constexpr uint8_t AS7341_CFG6 = 0xAF;
static constexpr uint8_t AS7341_SMUX_CMD_WRITE = 0x10; // copy the SMUX image from RAM to the chain
static constexpr uint8_t AS7341_SMUX_RAM = 0x00;
static constexpr uint8_t AS7343_CHAIN_CMD = 0xE4;
static constexpr uint8_t AS7343_CHAIN_SMUX = 0xE7;
static constexpr uint8_t AS7343_CHAINS = 3;
// Read one field of a ChipInfo kept in flash
template<typename T> static T pgm(const T &field) {
T value;
progmem_memcpy(&value, &field, sizeof(T));
return value;
}
static constexpr uint8_t NO_SLOT = 0xFF;
static constexpr uint8_t CLEAR_BAND = NUM_BANDS - 1;
static constexpr uint8_t MAX_SMUX_LEN = 20;
static constexpr uint8_t MAX_ADC_SLOTS = 18;
static constexpr uint32_t MIN_COLLECTION_TIMEOUT_MS = 30 * 1000;
static constexpr uint32_t MAX_ADC_COUNT = 65535;
PROGMEM_STRING_TABLE(BandNames, "F1", "F2", "FZ", "F3", "F4", "FY", "F5", "FXL", "F6", "F7", "F8", "NIR", "Clear");
#ifdef USE_AS7341
static const char AS7341_NAME[] PROGMEM = "AS7341";
static constexpr uint8_t AS7341_ADC_SLOTS = 6;
static constexpr uint8_t AS7341_SMUX_LEN = 20;
static constexpr uint8_t AS7341_SMUX_STEPS = 2;
static const RegValue AS7341_INIT[] PROGMEM = {{0x70, 0x00}}; // CONFIG: SPM mode, INT_SEL and LED_SEL cleared
static const uint8_t AS7341_SMUX[] PROGMEM = {
0x30, 0x01, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x50, 0x00, // step 0: F1 to F4, NIR, Clear
0x00, 0x00, 0x20, 0x04, 0x00, 0x30, 0x01, 0x50, 0x00, 0x06, // step 0, continued
0x00, 0x00, 0x00, 0x40, 0x02, 0x00, 0x10, 0x03, 0x50, 0x10, // step 1: F5 to F8, NIR, Clear
0x03, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x50, 0x00, 0x06, // step 1, continued
};
static_assert(sizeof(AS7341_SMUX) == AS7341_SMUX_STEPS * AS7341_SMUX_LEN && AS7341_SMUX_LEN <= MAX_SMUX_LEN);
static constexpr uint8_t N = NO_SLOT;
static const uint8_t AS7341_READOUT[] PROGMEM = {
0, 1, N, 2, 3, N, N, N, N, N, N, N, N, // step 0
N, N, N, N, N, N, 0, N, 1, 2, 3, 5, 4, // step 1: SMUX routes clear to ADC4 and NIR to ADC5
};
static_assert(sizeof(AS7341_READOUT) == AS7341_SMUX_STEPS * NUM_BANDS && AS7341_ADC_SLOTS <= MAX_ADC_SLOTS);
// This chip has no FZ, FY or FXL band, so those slots are marked absent.
// clang-format off
static const uint16_t AS7341_WAVELENGTHS[] PROGMEM = {
415, 445, ABSENT_NM, 480, 515, ABSENT_NM, 555, ABSENT_NM, 590, 630, 680, 910, WIDEBAND_NM,
};
// clang-format on
static_assert(sizeof(AS7341_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t));
// Datasheet values for the golden device: gain correction per gain step, the same for every band.
// The last two entries stay at zero because this chip has no 1024x or 2048x gain.
static const float AS7341_GAIN_CORRECTION[] PROGMEM = {1.0577f, 1.0491f, 1.0479f, 1.0491f, 1.0207f, 1.0158f, 1.0109f,
1.0000f, 1.0003f, 0.9873f, 0.9593f, 0.0f, 0.0f};
static_assert(sizeof(AS7341_GAIN_CORRECTION) == GAIN_COUNT * sizeof(float));
// clang-format off
// The three bands this chip does not have contribute nothing to the weighted sums.
static const ChannelContribution AS7341_CONTRIBUTIONS[] PROGMEM = {
{0.005261294f, 5.936230324f, 0.020518273f}, // F1
{0.242615252f, 3.572528495f, 0.013525490f}, // F2
{0.0f, 0.0f, 0.0f}, // FZ, absent
{0.174482552f, 2.914113437f, 0.011585145f}, // F3
{1.479499075f, 2.452851227f, 0.010604986f}, // F4
{0.0f, 0.0f, 0.0f}, // FY, absent
{1.906818555f, 1.940656433f, 0.008890531f}, // F5
{0.0f, 0.0f, 0.0f}, // FXL, absent
{1.162058142f, 1.533976606f, 0.007441544f}, // F6
{0.462374845f, 1.819838250f, 0.009393590f}, // F7
{-0.017794572f, 1.232942274f, 0.006890812f}, // F8
{-0.260730901f, -1.102368000f, -0.004647911f}, // NIR
{-0.022604075f, -0.033304813f, -0.000129106f}, // Clear
};
static const ChannelTristimulus AS7341_TRISTIMULUS[] PROGMEM = {
{0.39814f, 0.01396f, 1.95010f}, // F1
{1.29540f, 0.16748f, 6.45490f}, // F2
{0.0f, 0.0f, 0.0f}, // FZ, absent
{0.36956f, 0.23538f, 2.78010f}, // F3
{0.10902f, 1.42750f, 0.18501f}, // F4
{0.0f, 0.0f, 0.0f}, // FY, absent
{0.71942f, 1.88670f, 0.15325f}, // F5
{0.0f, 0.0f, 0.0f}, // FXL, absent
{1.78180f, 1.14200f, 0.09539f}, // F6
{1.10110f, 0.46497f, 0.10563f}, // F7
{-0.03991f, -0.02702f, 0.08866f}, // F8
{-0.27597f, -0.24468f, -0.61140f}, // NIR
{-0.02347f, -0.01993f, -0.00938f}, // Clear
};
// clang-format on
static_assert(sizeof(AS7341_CONTRIBUTIONS) == NUM_BANDS * sizeof(ChannelContribution));
static_assert(sizeof(AS7341_TRISTIMULUS) == NUM_BANDS * sizeof(ChannelTristimulus));
const ChipInfo AS7341_CHIP PROGMEM = {
.name = AS7341_NAME,
.init = AS7341_INIT,
.smux = AS7341_SMUX,
.readout = AS7341_READOUT,
.wavelengths = AS7341_WAVELENGTHS,
.gain_correction = AS7341_GAIN_CORRECTION,
.contributions = AS7341_CONTRIBUTIONS,
.tristimulus = AS7341_TRISTIMULUS,
.init_count = 1,
.astep_reg = 0xCA,
.cfg0_reg = 0xA9,
.cfg1_reg = 0xAA,
.status2_reg = 0xA3,
.id_reg = 0x92,
.id_mask = 0xFC,
.id_value = 0x09 << 2,
.led_reg = 0x74,
.led_sel_reg = 0x70, // CONFIG, whose LED_SEL bit connects the pin to the driver
.adc_slots = AS7341_ADC_SLOTS,
.smux_steps = AS7341_SMUX_STEPS,
.integration_cycles = 1,
.smux_len = AS7341_SMUX_LEN,
.auto_smux = false,
.gain_correction_per_band = false,
.extra_clear_slot = NO_SLOT,
};
#endif
#ifdef USE_AS7343
static const char AS7343_NAME[] PROGMEM = "AS7343";
static constexpr uint8_t AS7343_ADC_SLOTS = 18;
static constexpr uint8_t AS7343_SMUX_LEN = 10;
static const char TCS3448_NAME[] PROGMEM = "TCS3448";
// CFG6, FD_CFG0, CFG10, CFG8, CFG20 (auto_smux, 18 channels in 3 cycles), AGC_GAIN_MAX, FD_TIME_1, FD_TIME_2
static const RegValue AS7343_INIT[] PROGMEM = {{0xF5, 0x00}, {0xDF, 0xA1}, {0x65, 0xF2}, {0xC9, 0xC8},
{0xD6, 0x62}, {0xD7, 0x99}, {0xE0, 0x64}, {0xE2, 0x21}};
static const uint8_t AS7343_SMUX[] PROGMEM = {0x00, 0x04, 0x65, 0x02, 0x00, 0x05, 0x00, 0x01, 0x00, 0x30,
0x00, 0x00, 0x60, 0x20, 0x04, 0x50, 0x03, 0x00, 0x01, 0x05,
0x05, 0x00, 0x60, 0x00, 0x30, 0x00, 0x40, 0x10, 0x20, 0x00};
// Clear is the average of the cycle 1 (slot 4) and cycle 2 (slot 10) readings; cycle 3 always reads 0.
static const uint8_t AS7343_READOUT[] PROGMEM = {12, 6, 0, 7, 8, 1, 15, 2, 9, 13, 14, 3, 10};
static_assert(sizeof(AS7343_SMUX) == AS7343_CHAINS * AS7343_SMUX_LEN && AS7343_SMUX_LEN <= MAX_SMUX_LEN);
static_assert(sizeof(AS7343_READOUT) == NUM_BANDS && AS7343_ADC_SLOTS <= MAX_ADC_SLOTS);
// clang-format off
static const uint16_t AS7343_WAVELENGTHS[] PROGMEM = {
405, 425, 450, 475, 515, 555, 550, 600, 640, 690, 745, 855, WIDEBAND_NM,
};
// clang-format on
static_assert(sizeof(AS7343_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t));
// Datasheet values for the golden device: gain correction per gain step, per band.
// clang-format off
static const float AS7343_GAIN_CORRECTION[] PROGMEM = {
1.149f, 1.100f, 1.060f, 1.070f, 1.063f, 1.051f, 1.062f, 1.056f, 1.049f, 1.040f, 1.080f, 1.038f, 1.065f, // 0.5x
1.090f, 1.128f, 1.064f, 1.071f, 1.063f, 1.050f, 1.068f, 1.055f, 1.047f, 1.039f, 1.075f, 1.038f, 1.085f, // 1x
1.083f, 1.086f, 1.062f, 1.070f, 1.062f, 1.049f, 1.057f, 1.053f, 1.045f, 1.038f, 1.063f, 1.037f, 1.069f, // 2x
1.059f, 1.068f, 1.056f, 1.066f, 1.058f, 1.046f, 1.051f, 1.051f, 1.044f, 1.036f, 1.059f, 1.035f, 1.053f, // 4x
1.100f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.076f, 1.088f, // 8x
1.099f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.075f, 1.087f, // 16x
1.088f, 1.096f, 1.085f, 1.097f, 1.087f, 1.078f, 1.079f, 1.080f, 1.071f, 1.067f, 1.087f, 1.064f, 1.076f, // 32x
1.083f, 1.091f, 1.078f, 1.090f, 1.079f, 1.072f, 1.072f, 1.073f, 1.064f, 1.062f, 1.080f, 1.057f, 1.069f, // 64x
1.076f, 1.084f, 1.072f, 1.085f, 1.074f, 1.066f, 1.062f, 1.067f, 1.055f, 1.056f, 1.074f, 1.051f, 1.061f, // 128x
1.067f, 1.074f, 1.063f, 1.075f, 1.064f, 1.059f, 1.055f, 1.058f, 1.049f, 1.051f, 1.064f, 1.044f, 1.053f, // 256x
1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, // 512x
1.033303857f, 1.034763813f, 1.029855013f, 1.036911249f, 0.988150537f, 1.029003501f, 1.021363258f,
0.98762089f, 1.021713376f, 1.040108204f, 0.987417698f, 1.019481421f, 0.987270057f, // 1024x
0.986859143f, 0.980877221f, 0.9469769f, 0.985535324f, 0.926243961f, 0.959814787f, 1.007963896f,
0.942685187f, 1.007630587f, 1.025460005f, 0.928696275f, 1.001296639f, 1.041275263f, // 2048x
};
// clang-format on
static_assert(sizeof(AS7343_GAIN_CORRECTION) == GAIN_COUNT * NUM_BANDS * sizeof(float));
// clang-format off
static const ChannelContribution AS7343_CONTRIBUTIONS[] PROGMEM = {
{-0.028559089f, 0.450293995f, 0.001266925f}, // F1
{-0.042150948f, 3.566379984f, 0.012515925f}, // F2
{0.000661721f, 2.877424672f, 0.010243005f}, // FZ
{-0.108791278f, 2.273837581f, 0.009376157f}, // F3
{-0.167172180f, 1.519298503f, 0.006017556f}, // F4
{5.726660294f, 4.199345859f, 0.019357905f}, // FY
{0.0f, 0.0f, 0.0f}, // F5
{-0.241492014f, 1.185259160f, 0.006516325f}, // FXL
{-0.047665296f, 1.759469826f, 0.009780714f}, // F6
{-0.012516990f, 1.702006611f, 0.009950228f}, // F7
{-0.026424974f, -1.059196137f, -0.005425656f}, // F8
{-0.007565221f, -0.064827881f, -0.000232267f}, // NIR
{0.0f, 0.0f, 0.0f}, // Clear
};
static const ChannelTristimulus AS7343_TRISTIMULUS[] PROGMEM = {
{-0.07879f, -0.03269f, -0.31295f}, // F1
{-0.12235f, -0.01297f, -0.57885f}, // F2
{1.99879f, -0.04011f, 10.00197f}, // FZ
{-0.33364f, -0.06889f, -0.31281f}, // F3
{-0.06795f, -0.17453f, -0.19657f}, // F4
{-0.45419f, 5.69083f, -0.11077f}, // FY
{0.0f, 0.0f, 0.0f}, // F5
{5.27242f, -0.22956f, -0.06132f}, // FXL
{-0.05072f, -0.04762f, -0.03536f}, // F6
{-0.04666f, -0.01295f, -0.06025f}, // F7
{-0.03931f, -0.02797f, -0.12174f}, // F8
{0.0f, 0.0f, 0.0f}, // NIR
{0.0f, 0.0f, 0.0f}, // Clear
};
// clang-format on
static_assert(sizeof(AS7343_CONTRIBUTIONS) == NUM_BANDS * sizeof(ChannelContribution));
static_assert(sizeof(AS7343_TRISTIMULUS) == NUM_BANDS * sizeof(ChannelTristimulus));
static constexpr ChipInfo as7343_chip(const char *name) {
return {
.name = name,
.init = AS7343_INIT,
.smux = AS7343_SMUX,
.readout = AS7343_READOUT,
.wavelengths = AS7343_WAVELENGTHS,
.gain_correction = AS7343_GAIN_CORRECTION,
.contributions = AS7343_CONTRIBUTIONS,
.tristimulus = AS7343_TRISTIMULUS,
.init_count = sizeof(AS7343_INIT) / sizeof(RegValue),
.astep_reg = 0xD4,
.cfg0_reg = 0xBF,
.cfg1_reg = 0xC6,
.status2_reg = 0x90,
.id_reg = 0x5A,
.id_mask = 0xFF,
.id_value = 0x81,
.led_reg = 0xCD,
.led_sel_reg = 0, // the LED register drives the pin on its own
.adc_slots = AS7343_ADC_SLOTS,
.smux_steps = 1,
.integration_cycles = 3,
.smux_len = AS7343_SMUX_LEN,
.auto_smux = true,
.gain_correction_per_band = true,
.extra_clear_slot = 4,
};
}
const ChipInfo AS7343_CHIP PROGMEM = as7343_chip(AS7343_NAME);
const ChipInfo TCS3448_CHIP PROGMEM = as7343_chip(TCS3448_NAME); // AS7343 register map, address 0x59
#endif
// Registers below 0x80 are only reachable with the REG_BANK bit of CFG0 set; CFG0 itself always answers.
bool AS734XComponent::select_bank_(uint8_t reg) {
const bool low = reg < BANK_SPLIT;
if (low == this->bank_low_) {
return true;
}
this->bank_low_ = low;
return this->write_byte(pgm(this->chip_->cfg0_reg), low ? CFG0_REG_BANK : 0);
}
bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) {
uint8_t data = 0;
if (!this->select_bank_(reg) || !this->read_byte(reg, &data)) {
return false;
}
data = value ? (data | mask) : (data & ~mask);
return this->write_byte(reg, data);
}
// The exposure is applied before every measurement, so a gain, ATIME or ASTEP changed at runtime
// takes effect on the next reading rather than only after a restart.
bool AS734XComponent::write_exposure_() {
const ChipInfo *chip = this->chip_;
if (!this->select_bank_(REG_ATIME) || !this->write_byte(REG_ATIME, this->atime_) ||
!this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) ||
!this->write_byte(pgm(chip->cfg1_reg), this->gain_)) {
return false;
}
this->reading_gain_ = this->gain_;
this->reading_atime_ = this->atime_;
this->reading_astep_ = this->astep_;
// One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us.
const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000;
this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles);
this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_);
return true;
}
void AS734XComponent::setup() {
const ChipInfo *chip = this->chip_;
const uint8_t id_reg = pgm(chip->id_reg);
uint8_t id = 0;
// The sensor keeps its bank across an MCU reset, so the first access always writes CFG0
bool ok = this->select_bank_(REG_ENABLE) && this->write_byte(REG_ENABLE, ENABLE_PON);
delayMicroseconds(200); // oscillator start-up
ok = ok && this->select_bank_(id_reg) && this->read_byte(id_reg, &id);
if (ok && (id & pgm(chip->id_mask)) != pgm(chip->id_value)) {
this->mark_failed(LOG_STR("Wrong chip ID"));
return;
}
const RegValue *init = pgm(chip->init);
for (uint8_t i = 0, n = pgm(chip->init_count); ok && i < n; i++) {
RegValue rv;
progmem_memcpy(&rv, &init[i], sizeof(rv));
ok = this->select_bank_(rv.reg) && this->write_byte(rv.reg, rv.value);
}
// Everything from here on, including the measurement registers, is in the high bank.
ok = ok && this->write_exposure_();
if (pgm(chip->auto_smux)) {
const uint8_t *smux = pgm(chip->smux);
const uint8_t len = pgm(chip->smux_len);
for (uint8_t i = 0; ok && i < AS7343_CHAINS * len; i++) {
ok = this->write_byte(AS7343_CHAIN_SMUX, progmem_read_byte(&smux[i])) &&
this->write_byte(AS7343_CHAIN_CMD, 0x46 + (i / len) * 0x10);
}
}
if (!ok) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->disable_loop();
}
void AS734XComponent::dump_config() {
const float gain = this->gain_ == GAIN_0_5X ? 0.5f : static_cast<float>(1 << (this->gain_ - 1));
ESP_LOGCONFIG(TAG,
"AS734x:\n"
" Chip: %s\n"
" Gain: %gx\n"
" ATIME: %u\n"
" ASTEP: %u\n"
" Glass attenuation factor: %.3f",
LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_, this->glass_attenuation_factor_);
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
for (uint8_t band = 0; band < NUM_BANDS; band++) {
char label[24];
const char *name = LOG_STR_ARG(BandNames::get_log_str(band, BandNames::LAST_INDEX));
snprintf(label, sizeof(label), "Counts %s", name);
sensor::log_sensor(TAG, " ", label, this->sensors_[band]);
snprintf(label, sizeof(label), "Basic counts %s", name);
sensor::log_sensor(TAG, " ", label, this->basic_counts_sensors_[band]);
}
LOG_SENSOR(" ", "Saturation level", this->saturation_level_sensor_);
LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_);
LOG_SENSOR(" ", "Irradiance photopic", this->irradiance_photopic_sensor_);
LOG_SENSOR(" ", "Irradiance PAR", this->irradiance_par_sensor_);
LOG_SENSOR(" ", "PPFD", this->ppfd_sensor_);
LOG_SENSOR(" ", "Color temperature", this->color_temperature_sensor_);
#ifdef USE_AS734X_RGB
LOG_TEXT_SENSOR(" ", "RGB hex", this->rgb_hex_sensor_);
#endif
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
}
void AS734XComponent::enable_led(bool enable) {
const uint8_t led_reg = pgm(this->chip_->led_reg);
if (led_reg == 0) {
return;
}
// The LED register only drives the LED while LED_SEL is set, so the pin is handed over before
// switching on and released only after switching off. The other order would leave the LED lit
// with nothing able to turn it off.
const uint8_t led_sel_reg = pgm(this->chip_->led_sel_reg);
bool ok = true;
if (enable && led_sel_reg != 0) {
ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, true);
}
ok = this->update_bits_(led_reg, LED_ACT, enable) && ok;
if (!enable && led_sel_reg != 0) {
ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, false) && ok;
}
// The measurement path assumes the high bank, so leave the chip there for a reading in flight
ok = this->select_bank_(REG_ENABLE) && ok;
if (!ok) {
ESP_LOGW(TAG, "Failed to switch the LED");
}
}
void AS734XComponent::update() {
if (this->state_ != State::STATE_IDLE) {
ESP_LOGW(TAG, "Skipping update, previous measurement still running");
return;
}
if (!this->write_exposure_()) {
this->abort_measurement_(LOG_STR("Failed to apply measurement settings"));
return;
}
this->step_ = 0;
this->peak_raw_count_ = 0;
this->raw_counts_.fill(0);
this->enable_loop();
if (!this->start_step_()) {
this->abort_measurement_(LOG_STR("Failed to start measurement"));
}
}
bool AS734XComponent::start_step_() {
this->start_ms_ = App.get_loop_component_start_time();
if (pgm(this->chip_->auto_smux)) {
this->state_ = State::STATE_READ_DATA;
return this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SP_EN);
}
// Load this step's image into SMUX RAM; SMUX_EN copies it to the chain and clears itself when done.
std::array<uint8_t, MAX_SMUX_LEN> image{};
const uint8_t len = pgm(this->chip_->smux_len);
progmem_memcpy(image.data(), pgm(this->chip_->smux) + this->step_ * len, len);
this->state_ = State::STATE_WAIT_SMUX;
return this->write_byte(AS7341_CFG6, AS7341_SMUX_CMD_WRITE) &&
this->write_bytes(AS7341_SMUX_RAM, image.data(), len) &&
this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SMUX_EN);
}
void AS734XComponent::loop() {
const uint32_t now = App.get_loop_component_start_time();
uint8_t value = 0;
switch (this->state_) {
case State::STATE_WAIT_SMUX:
// A failed read counts as busy, so the timeout below catches a chip that stops answering.
if (this->read_byte(REG_ENABLE, &value) && !(value & ENABLE_SMUX_EN)) {
this->start_ms_ = now;
this->state_ = State::STATE_READ_DATA;
if (!this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SP_EN)) {
this->abort_measurement_(LOG_STR("Failed to start integration"));
}
return;
}
break;
case State::STATE_READ_DATA:
if (now - this->start_ms_ < this->integration_ms_) {
return;
}
if (this->read_byte(pgm(this->chip_->status2_reg), &value) && (value & STATUS2_AVALID)) {
this->read_step_();
return;
}
break;
case State::STATE_IDLE:
this->disable_loop();
return;
}
if (now - this->start_ms_ > this->timeout_ms_) {
this->abort_measurement_(LOG_STR("Measurement timeout"));
}
}
void AS734XComponent::read_step_() {
// Reading ASTATUS latches the data, which is only consistent within one burst starting there.
std::array<uint8_t, 1 + 2 * MAX_ADC_SLOTS> frame{};
if (!this->read_bytes(REG_ASTATUS, frame.data(), 1 + 2 * pgm(this->chip_->adc_slots)) ||
!this->write_byte(REG_ENABLE, ENABLE_PON)) {
this->abort_measurement_(LOG_STR("Failed to read channel data"));
return;
}
if (frame[0] & ASTATUS_ASAT) {
ESP_LOGV(TAG, "Step %u saturated", this->step_);
}
auto slot_value = [&frame](uint8_t slot) { return encode_uint16(frame[2 + 2 * slot], frame[1 + 2 * slot]); };
const uint8_t *readout = pgm(this->chip_->readout) + this->step_ * NUM_BANDS;
const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot);
for (uint8_t band = 0; band < NUM_BANDS; band++) {
const uint8_t slot = progmem_read_byte(&readout[band]);
if (slot == NO_SLOT) {
continue;
}
const uint16_t raw = slot_value(slot);
this->peak_raw_count_ = std::max(this->peak_raw_count_, raw);
uint16_t counts = raw;
if (band == CLEAR_BAND && extra_clear != NO_SLOT) {
const uint16_t extra = slot_value(extra_clear);
this->peak_raw_count_ = std::max(this->peak_raw_count_, extra);
counts = (static_cast<uint32_t>(raw) + extra) / 2;
}
this->raw_counts_[band] = counts;
}
if (++this->step_ < pgm(this->chip_->smux_steps)) {
if (!this->start_step_()) {
this->abort_measurement_(LOG_STR("Failed to configure SMUX"));
}
return;
}
this->publish_measurement_();
this->status_clear_warning();
this->state_ = State::STATE_IDLE;
this->disable_loop();
}
// A basic count is the raw count scaled to one unit of gain and one millisecond of integration
// time, which makes readings taken with different settings comparable. The manufacturer's
// conversion factors are calibrated against that definition.
void AS734XComponent::calculate_basic_counts_() {
this->basic_counts_.fill(0.0f);
this->max_basic_count_ = 0.0f;
this->max_band_basic_count_ = 0.0f;
this->clear_basic_count_ = 0.0f;
// The gain enum counts in powers of two starting at 0.5x, so the multiplier is 2^gain / 2.
const float gain_x = static_cast<float>(1 << this->reading_gain_) / 2.0f;
const float integration_ms = (1.0f + this->reading_atime_) * (1.0f + this->reading_astep_) * 2.78e-3f;
if (gain_x <= 0.0f || integration_ms <= 0.0f) {
return;
}
const float inv_exposure = 1.0f / (gain_x * integration_ms);
const ChipInfo *chip = this->chip_;
const uint16_t *wavelengths = pgm(chip->wavelengths);
const float *gain_correction = pgm(chip->gain_correction);
const bool per_band = pgm(chip->gain_correction_per_band);
const uint8_t correction_base = per_band ? this->reading_gain_ * NUM_BANDS : this->reading_gain_;
for (uint8_t band = 0; band < NUM_BANDS; band++) {
const uint16_t wavelength = pgm(wavelengths[band]);
if (wavelength == ABSENT_NM) {
continue;
}
float basic_count = this->raw_counts_[band] * inv_exposure;
basic_count -= this->dark_current_[band];
basic_count = std::max(basic_count, 0.0f);
basic_count *= pgm(gain_correction[per_band ? correction_base + band : correction_base]);
basic_count *= this->channel_correction_[band];
basic_count *= this->glass_attenuation_factor_;
this->basic_counts_[band] = basic_count;
this->max_basic_count_ = std::max(this->max_basic_count_, basic_count);
if (wavelength == WIDEBAND_NM) {
this->clear_basic_count_ = basic_count;
} else if (wavelength >= VISIBLE_MIN_NM && wavelength <= VISIBLE_MAX_NM) {
this->max_band_basic_count_ = std::max(this->max_band_basic_count_, basic_count);
}
}
}
float AS734XComponent::normalization_divisor_() const {
float divisor = 1.0f;
switch (this->normalization_) {
case Normalization::NORMALIZATION_ALL:
divisor = this->max_basic_count_;
break;
case Normalization::NORMALIZATION_BANDS:
divisor = this->max_band_basic_count_;
break;
case Normalization::NORMALIZATION_CLEAR:
divisor = this->clear_basic_count_;
break;
case Normalization::NORMALIZATION_NONE:
return 1.0f;
}
// In the dark the reference can be zero, and dividing by that would publish infinities.
return divisor > 0.0f ? divisor : 1.0f;
}
void AS734XComponent::publish_basic_counts_() {
// Normalization describes the shape of the spectrum rather than its absolute level. Channels
// outside the chosen reference can exceed 1.0 and are left that way rather than clipped: with
// BANDS the near infrared channel legitimately sits well above the visible bands.
const float scale = 1.0f / this->normalization_divisor_();
for (uint8_t band = 0; band < NUM_BANDS; band++) {
if (this->basic_counts_sensors_[band] != nullptr) {
this->basic_counts_sensors_[band]->publish_state(this->basic_counts_[band] * scale);
}
}
}
// Each band contributes a fixed amount per basic count to every integrated quantity, so the
// totals are a weighted sum over the bands.
// The weighted sums can go negative under light the tables were not fitted for, such as pure blue or
// far red, so the published values are floored at zero.
void AS734XComponent::publish_light_metrics_() {
float irradiance_photopic = 0.0f;
float irradiance_par = 0.0f;
float ppfd = 0.0f;
const ChannelContribution *contributions = pgm(this->chip_->contributions);
for (uint8_t band = 0; band < NUM_BANDS; band++) {
const float basic_count = this->basic_counts_[band];
const ChannelContribution contribution = pgm(contributions[band]);
irradiance_photopic += contribution.irradiance_photopic * basic_count;
irradiance_par += contribution.irradiance_par * basic_count;
ppfd += contribution.ppfd * basic_count;
}
irradiance_photopic = std::max(irradiance_photopic, 0.0f);
irradiance_par = std::max(irradiance_par, 0.0f);
ppfd = std::max(ppfd, 0.0f) * 1e3f;
const float illuminance = irradiance_photopic * LUMENS_PER_WATT;
ESP_LOGV(TAG, "Photopic irradiance %.4f, PAR %.4f, PPFD %.4f, illuminance %.2f lx", irradiance_photopic,
irradiance_par, ppfd, illuminance);
if (this->illuminance_sensor_ != nullptr) {
this->illuminance_sensor_->publish_state(illuminance);
}
if (this->irradiance_photopic_sensor_ != nullptr) {
this->irradiance_photopic_sensor_->publish_state(irradiance_photopic);
}
if (this->irradiance_par_sensor_ != nullptr) {
this->irradiance_par_sensor_->publish_state(irradiance_par);
}
if (this->ppfd_sensor_ != nullptr) {
this->ppfd_sensor_->publish_state(ppfd);
}
}
void AS734XComponent::publish_color_() {
float tri_x = 0.0f;
float tri_y = 0.0f;
float tri_z = 0.0f;
const ChannelTristimulus *table = pgm(this->chip_->tristimulus);
for (uint8_t band = 0; band < NUM_BANDS; band++) {
const float basic_count = this->basic_counts_[band];
const ChannelTristimulus tristimulus = pgm(table[band]);
tri_x += tristimulus.x * basic_count;
tri_y += tristimulus.y * basic_count;
tri_z += tristimulus.z * basic_count;
}
const float color_temperature = tristimulus_to_cct(tri_x, tri_y, tri_z);
ESP_LOGV(TAG, "XYZ %.4f, %.4f, %.4f -> CCT %.0f K", tri_x, tri_y, tri_z, color_temperature);
if (this->color_temperature_sensor_ != nullptr) {
this->color_temperature_sensor_->publish_state(color_temperature);
}
#ifdef USE_AS734X_RGB
if (this->rgb_hex_sensor_ == nullptr) {
return;
}
// Convert to chromaticity first, so the colour describes the hue rather than the brightness.
// Absolute tristimulus values clamp to white under anything but dim light.
char rgb_hex[7] = "000000"; // no usable chromaticity reads as black rather than an empty string
const float sum = tri_x + tri_y + tri_z;
if (sum > MIN_TRISTIMULUS_SUM) {
uint8_t rgb[3];
tristimulus_to_rgb(tri_x / sum, tri_y / sum, tri_z / sum, rgb[0], rgb[1], rgb[2]);
format_hex_to(rgb_hex, rgb, sizeof(rgb));
}
this->rgb_hex_sensor_->publish_state(rgb_hex);
#endif
}
void AS734XComponent::publish_measurement_() {
this->calculate_basic_counts_();
for (uint8_t band = 0; band < NUM_BANDS; band++) {
if (this->sensors_[band] != nullptr) {
this->sensors_[band]->publish_state(this->raw_counts_[band]);
}
}
this->publish_basic_counts_();
this->publish_light_metrics_();
this->publish_color_();
if (this->saturation_level_sensor_ != nullptr) {
const uint32_t max_adc =
std::min<uint32_t>((this->reading_atime_ + 1UL) * (this->reading_astep_ + 1UL), MAX_ADC_COUNT);
const float level = max_adc == 0 ? 0.0f : 100.0f * this->peak_raw_count_ / max_adc;
ESP_LOGV(TAG, "Highest ADC count %u of %u (%.1f%%)", this->peak_raw_count_, static_cast<unsigned>(max_adc), level);
this->saturation_level_sensor_->publish_state(level);
}
}
void AS734XComponent::abort_measurement_(const LogString *reason) {
ESP_LOGW(TAG, "%s", LOG_STR_ARG(reason));
this->write_byte(REG_ENABLE, ENABLE_PON);
this->status_set_warning(reason);
this->state_ = State::STATE_IDLE;
this->disable_loop();
}
} // namespace esphome::as734x
+199
View File
@@ -0,0 +1,199 @@
#pragma once
#include <array>
#include <cstdint>
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#ifdef USE_AS734X_RGB
#include "esphome/components/text_sensor/text_sensor.h"
#endif
namespace esphome::as734x {
enum Gain : uint8_t {
GAIN_0_5X,
GAIN_1X,
GAIN_2X,
GAIN_4X,
GAIN_8X,
GAIN_16X,
GAIN_32X,
GAIN_64X,
GAIN_128X,
GAIN_256X,
GAIN_512X,
GAIN_1024X, // AS7343 only
GAIN_2048X, // AS7343 only
};
static constexpr uint8_t GAIN_COUNT = 13; // entries in Gain, for sizing the correction tables
static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py
// What the published basic counts are divided by. The clear channel is wideband, so it is roughly
// the size of all the bands together, and the near infrared channel outgrows them under daylight.
// Dividing by the largest of every channel therefore leaves the visible bands squeezed into the
// bottom of the range.
enum class Normalization : uint8_t {
NORMALIZATION_NONE, // publish absolute basic counts
NORMALIZATION_ALL, // largest of every channel
NORMALIZATION_BANDS, // largest of the visible bands only
NORMALIZATION_CLEAR, // the wideband clear channel
};
// Nominal centre wavelengths in nanometres bound the visible bands. The clear channel is wideband
// and has no single centre wavelength, so it is marked with zero instead.
static constexpr uint16_t VISIBLE_MIN_NM = 380;
static constexpr uint16_t VISIBLE_MAX_NM = 700;
static constexpr uint16_t WIDEBAND_NM = 0;
static constexpr uint16_t ABSENT_NM = 0xFFFF; // band this chip does not have
// Luminous efficacy at 555 nm, for turning photopic irradiance into lux.
static constexpr float LUMENS_PER_WATT = 683.002f;
// How much one basic count of a band contributes to each integrated quantity. The factors come
// from the manufacturer's golden-device calibration, convolved for a reconstructed spectrum.
struct ChannelContribution {
float irradiance_photopic; // weighted by the CIE 1931 V(lambda) curve
float irradiance_par; // 400-700 nm only
float ppfd; // 400-700 nm only, scaled by 1e3 before publishing
};
// CIE 1931 tristimulus contribution of one basic count of a band.
struct ChannelTristimulus {
float x;
float y;
float z;
};
struct RegValue {
uint8_t reg;
uint8_t value;
};
/// Per-chip constants, stored in flash. The pointed-to tables are in flash too.
struct ChipInfo {
const char *name;
const RegValue *init; // written in order; the bank is selected per register address
const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images
const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step)
const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band
const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band
const ChannelContribution *contributions; // per band
const ChannelTristimulus *tristimulus; // per band
uint8_t init_count;
uint8_t astep_reg;
uint8_t cfg0_reg;
uint8_t cfg1_reg;
uint8_t status2_reg;
uint8_t id_reg;
uint8_t id_mask;
uint8_t id_value;
uint8_t led_reg; // LED driver register (0: this chip has no LED control)
uint8_t led_sel_reg; // register whose LED_SEL bit hands the pin to the driver (0: not needed)
uint8_t adc_slots; // 16-bit data registers read after ASTATUS
uint8_t smux_steps; // measurements per update
uint8_t integration_cycles;
uint8_t smux_len; // bytes per SMUX image
bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup
bool gain_correction_per_band; // the correction table is indexed by gain and band, not gain alone
uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none)
};
#ifdef USE_AS7341
extern const ChipInfo AS7341_CHIP;
#endif
#ifdef USE_AS7343
extern const ChipInfo AS7343_CHIP;
extern const ChipInfo TCS3448_CHIP;
#endif
class AS734XComponent : public PollingComponent, public i2c::I2CDevice {
public:
// Channel correction is multiplicative, so it starts at unity rather than zero in case no
// calibration is configured.
explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) { this->channel_correction_.fill(1.0f); }
void setup() override;
void dump_config() override;
void update() override;
void loop() override;
void set_gain(Gain gain) { this->gain_ = gain; }
void set_atime(uint8_t atime) { this->atime_ = atime; }
void set_astep(uint16_t astep) { this->astep_ = astep; }
/// Turns the sensor's LED driver on or off. Meant for lambdas.
void enable_led(bool enable);
// Calibration applied while turning raw counts into basic counts.
void set_dark_current(const std::array<float, NUM_BANDS> &values) { this->dark_current_ = values; }
void set_channel_correction(const std::array<float, NUM_BANDS> &values) { this->channel_correction_ = values; }
void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; }
/// Chooses what the published basic counts are divided by.
void set_basic_counts_normalization(Normalization normalization) { this->normalization_ = normalization; }
void set_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->sensors_[band] = sensor; }
void set_basic_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->basic_counts_sensors_[band] = sensor; }
SUB_SENSOR(saturation_level)
SUB_SENSOR(illuminance)
SUB_SENSOR(irradiance_photopic)
SUB_SENSOR(irradiance_par)
SUB_SENSOR(ppfd)
SUB_SENSOR(color_temperature)
#ifdef USE_AS734X_RGB
void set_rgb_hex_sensor(text_sensor::TextSensor *sensor) { this->rgb_hex_sensor_ = sensor; }
#endif
protected:
bool select_bank_(uint8_t reg);
bool update_bits_(uint8_t reg, uint8_t mask, bool value);
bool write_exposure_();
bool start_step_();
void read_step_();
void abort_measurement_(const LogString *reason);
void calculate_basic_counts_();
void publish_light_metrics_();
void publish_color_();
void publish_measurement_();
void publish_basic_counts_();
float normalization_divisor_() const;
std::array<sensor::Sensor *, NUM_BANDS> sensors_{};
std::array<sensor::Sensor *, NUM_BANDS> basic_counts_sensors_{};
std::array<float, NUM_BANDS> dark_current_{};
std::array<float, NUM_BANDS> channel_correction_;
std::array<float, NUM_BANDS> basic_counts_{};
std::array<uint16_t, NUM_BANDS> raw_counts_{};
const ChipInfo *chip_; // in flash, read field by field with progmem_memcpy
uint32_t start_ms_{0};
uint32_t integration_ms_{0};
uint32_t timeout_ms_{0};
float glass_attenuation_factor_{1.0f};
float max_basic_count_{0.0f}; // largest of every channel
float max_band_basic_count_{0.0f}; // largest of the visible bands only
float clear_basic_count_{0.0f}; // the wideband clear channel
uint16_t astep_{0};
uint16_t peak_raw_count_{0}; // highest ADC value of this measurement, before the clear channels are averaged
// What the running measurement was started with, so the maths matches the data even if the
// configuration is changed while a measurement is in flight.
uint16_t reading_astep_{0};
Gain reading_gain_{GAIN_1X};
uint8_t reading_atime_{0};
enum class State : uint8_t { STATE_IDLE, STATE_WAIT_SMUX, STATE_READ_DATA } state_{State::STATE_IDLE};
Normalization normalization_{Normalization::NORMALIZATION_NONE};
Gain gain_{GAIN_1X};
uint8_t atime_{0};
uint8_t step_{0};
bool bank_low_{true}; // unknown until setup() writes CFG0
#ifdef USE_AS734X_RGB
text_sensor::TextSensor *rgb_hex_sensor_{nullptr};
#endif
};
} // namespace esphome::as734x
@@ -0,0 +1,63 @@
#include "color_helpers.h"
#include <algorithm>
#include <cmath>
namespace esphome::as734x {
namespace {
// Range McCamy's approximation is meaningful over.
constexpr float CCT_MIN_K = 1000.0f;
constexpr float CCT_MAX_K = 25000.0f;
#ifdef USE_AS734X_RGB
// sRGB transfer function.
float gamma_correct(float channel) {
if (channel <= 0.0031308f) {
return 12.92f * channel;
}
return 1.055f * std::pow(channel, 1.0f / 2.4f) - 0.055f;
}
uint8_t to_byte(float channel) { return static_cast<uint8_t>(std::clamp(channel, 0.0f, 1.0f) * 255.0f); }
#endif
} // namespace
float tristimulus_to_cct(float x, float y, float z) {
const float sum = x + y + z;
// The conversion matrices carry negative coefficients, so a spectrum unlike the one they were
// calibrated against can drive the sum to zero or below. Chromaticity is undefined there, and
// McCamy's formula would still return a plausible looking number from it.
if (sum <= MIN_TRISTIMULUS_SUM) {
return NAN;
}
const float chroma_x = x / sum;
const float chroma_y = y / sum;
// McCamy's approximation: cct = 437 n^3 + 3601 n^2 + 6861 n + 5517. The pole at
// chroma_y = 0.1858 sends the cubic off to either infinity, and it stays meaningless well
// either side of that, so the answer is only reported where the approximation holds.
const float denominator = 0.1858f - chroma_y;
if (std::abs(denominator) < 1e-4f) {
return NAN;
}
const float n = (chroma_x - 0.3320f) / denominator;
const float cct = ((437.0f * n + 3601.0f) * n + 6861.0f) * n + 5517.0f;
return (cct >= CCT_MIN_K && cct <= CCT_MAX_K) ? cct : NAN;
}
#ifdef USE_AS734X_RGB
void tristimulus_to_rgb(float x, float y, float z, uint8_t &r, uint8_t &g, uint8_t &b) {
const float r_linear = 3.2406f * x - 1.5372f * y - 0.4986f * z;
const float g_linear = -0.9689f * x + 1.8758f * y + 0.0415f * z;
const float b_linear = 0.0557f * x - 0.2040f * y + 1.0570f * z;
r = to_byte(gamma_correct(r_linear));
g = to_byte(gamma_correct(g_linear));
b = to_byte(gamma_correct(b_linear));
}
#endif
} // namespace esphome::as734x
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
#include "esphome/core/defines.h"
namespace esphome::as734x {
// Smallest tristimulus sum that still carries a usable hue.
constexpr float MIN_TRISTIMULUS_SUM = 0.001f;
// Correlated colour temperature in kelvin from CIE 1931 tristimulus values, using McCamy's
// approximation. Returns NAN when the input carries no usable hue, or when the result falls
// outside the range the approximation holds over.
float tristimulus_to_cct(float x, float y, float z);
#ifdef USE_AS734X_RGB
// sRGB components for CIE 1931 tristimulus values. Pass chromaticity (x + y + z == 1) rather than
// absolute values, otherwise anything but dim light clamps to white.
void tristimulus_to_rgb(float x, float y, float z, uint8_t &r, uint8_t &g, uint8_t &b);
#endif
} // namespace esphome::as734x
+144
View File
@@ -0,0 +1,144 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import UNIT_COUNTS
import esphome.config_validation as cv
from esphome.const import (
CONF_COLOR_TEMPERATURE,
CONF_COUNTS,
CONF_ILLUMINANCE,
CONF_TYPE,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_IRRADIANCE,
STATE_CLASS_MEASUREMENT,
UNIT_KELVIN,
UNIT_LUX,
UNIT_PERCENT,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent
DEPENDENCIES = [DOMAIN]
CONF_BASIC_COUNTS = "basic_counts"
CONF_IRRADIANCE_PAR = "irradiance_par"
CONF_IRRADIANCE_PHOTOPIC = "irradiance_photopic"
CONF_PPFD = "ppfd"
CONF_SATURATION_LEVEL = "saturation_level"
UNIT_IRRADIANCE = "W/m\u00b2"
UNIT_PPFD = "\u00b5mol/m\u00b2/s"
ICON_COUNTS = "mdi:counter"
ICON_SATURATION = "mdi:weather-sunny-alert"
ICON_ILLUMINANCE = "mdi:weather-sunny"
ICON_IRRADIANCE_PHOTOPIC = "mdi:sun-wireless-outline"
ICON_PAR = "mdi:sprout"
ICON_PPFD = "mdi:sprout-outline"
ICON_COLOR_TEMPERATURE = "mdi:sun-thermometer-outline"
_COUNTS_SENSOR = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_COUNTS,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
# Basic counts are fractional, so they need decimals where raw counts do not.
_BASIC_COUNTS_SENSOR = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_COUNTS,
accuracy_decimals=6,
state_class=STATE_CLASS_MEASUREMENT,
)
_BAND_KEYS = (CONF_COUNTS, CONF_BASIC_COUNTS)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent),
cv.Optional(CONF_COUNTS, default={}): cv.Schema(
{cv.Optional(band): _COUNTS_SENSOR for band in BANDS_43}
),
cv.Optional(CONF_BASIC_COUNTS, default={}): cv.Schema(
{cv.Optional(band): _BASIC_COUNTS_SENSOR for band in BANDS_43}
),
cv.Optional(CONF_SATURATION_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
icon=ICON_SATURATION,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_LUX,
icon=ICON_ILLUMINANCE,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_IRRADIANCE_PHOTOPIC): sensor.sensor_schema(
unit_of_measurement=UNIT_IRRADIANCE,
icon=ICON_IRRADIANCE_PHOTOPIC,
accuracy_decimals=3,
device_class=DEVICE_CLASS_IRRADIANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_IRRADIANCE_PAR): sensor.sensor_schema(
unit_of_measurement=UNIT_IRRADIANCE,
icon=ICON_PAR,
accuracy_decimals=3,
device_class=DEVICE_CLASS_IRRADIANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PPFD): sensor.sensor_schema(
unit_of_measurement=UNIT_PPFD,
icon=ICON_PPFD,
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_KELVIN,
icon=ICON_COLOR_TEMPERATURE,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
def _final_validate(config: ConfigType) -> ConfigType:
full = fv.full_config.get()
hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1])
if hub[CONF_TYPE] != "AS7341":
return config
for key in _BAND_KEYS:
if extra := [band for band in config.get(key, {}) if band not in BANDS_41]:
raise cv.Invalid(
f"The AS7341 has no {', '.join(extra)} band", path=[key, extra[0]]
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_AS734X_ID])
counts = config[CONF_COUNTS]
basic_counts = config[CONF_BASIC_COUNTS]
for i, band in enumerate(BANDS_43):
if (conf := counts.get(band)) is not None:
sens = await sensor.new_sensor(conf)
cg.add(hub.set_counts_sensor(sens, i))
if (conf := basic_counts.get(band)) is not None:
sens = await sensor.new_sensor(conf)
cg.add(hub.set_basic_counts_sensor(sens, i))
sensors = sensor.sub_sensors(config)
await sensors(CONF_SATURATION_LEVEL, hub.set_saturation_level_sensor)
await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor)
await sensors(CONF_IRRADIANCE_PHOTOPIC, hub.set_irradiance_photopic_sensor)
await sensors(CONF_IRRADIANCE_PAR, hub.set_irradiance_par_sensor)
await sensors(CONF_PPFD, hub.set_ppfd_sensor)
await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor)
+28
View File
@@ -0,0 +1,28 @@
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import CONF_AS734X_ID, DOMAIN, AS734XComponent
DEPENDENCIES = [DOMAIN]
CONF_RGB_HEX = "rgb_hex"
ICON_PALETTE = "mdi:palette"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent),
cv.Optional(CONF_RGB_HEX): text_sensor.text_sensor_schema(icon=ICON_PALETTE),
}
)
async def to_code(config: ConfigType) -> None:
if (conf := config.get(CONF_RGB_HEX)) is None:
return
hub = await cg.get_variable(config[CONF_AS734X_ID])
cg.add_define("USE_AS734X_RGB")
sens = await text_sensor.new_text_sensor(conf)
cg.add(hub.set_rgb_hex_sensor(sens))
+2 -2
View File
@@ -338,7 +338,7 @@ async def to_code(config: ConfigType) -> None:
# HTTPS streams verify the server against the root certificate bundle
require_certificate_bundle()
add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1")
add_idf_component(name="esphome/esp-audio-libs", ref="4.1.0")
data = _get_data()
@@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None:
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.2.0")
add_idf_component(name="esphome/micro-flac", ref="0.3.0")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
+1 -1
View File
@@ -188,7 +188,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
// Expose the next chunk of file data. Every decoder buffers internally and consumes only what it
// processed, so the source does not need to accumulate or stitch chunks across fill() calls.
this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS));
const size_t available_before_decode = this->input_buffer_->available();
+2 -3
View File
@@ -129,9 +129,8 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
delay(READ_WRITE_TIMEOUT_MS);
}
// Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource
// (there is no intermediate transfer buffer to compact).
this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
// Expose a chunk of the ring buffer's internal storage.
this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS));
if (this->audio_source_->available() == 0) {
// No samples available to process
@@ -20,16 +20,6 @@ std::unique_ptr<AudioSinkTransferBuffer> AudioSinkTransferBuffer::create(size_t
return sink_buffer;
}
std::unique_ptr<AudioSourceTransferBuffer> AudioSourceTransferBuffer::create(size_t buffer_size) {
std::unique_ptr<AudioSourceTransferBuffer> source_buffer = make_unique<AudioSourceTransferBuffer>();
if (!source_buffer->allocate_buffer_(buffer_size)) {
return nullptr;
}
return source_buffer;
}
size_t AudioTransferBuffer::free() const {
if (this->buffer_size_ == 0) {
return 0;
@@ -132,27 +122,6 @@ void AudioTransferBuffer::deallocate_buffer_() {
this->buffer_length_ = 0;
}
size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_wait, bool pre_shift) {
if (pre_shift) {
// Shift data in buffer to start
if (this->buffer_length_ > 0) {
std::memmove(this->buffer_, this->data_start_, this->buffer_length_);
}
this->data_start_ = this->buffer_;
}
size_t bytes_to_read = AudioTransferBuffer::free();
size_t bytes_read = 0;
if (bytes_to_read > 0) {
if (this->ring_buffer_ != nullptr) {
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
}
this->increase_buffer_length(bytes_read);
}
return bytes_read;
}
size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bool post_shift) {
size_t bytes_written = 0;
if (this->available()) {
@@ -192,9 +161,44 @@ bool AudioSinkTransferBuffer::has_buffered_data() const {
return (this->available() > 0);
}
// AudioSourceTransferBuffer is deprecated. Remove before 2027.5.0.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
std::unique_ptr<AudioSourceTransferBuffer> AudioSourceTransferBuffer::create(size_t buffer_size) {
std::unique_ptr<AudioSourceTransferBuffer> source_buffer = make_unique<AudioSourceTransferBuffer>();
if (!source_buffer->allocate_buffer_(buffer_size)) {
return nullptr;
}
return source_buffer;
}
size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_wait, bool pre_shift) {
if (pre_shift) {
// Shift data in buffer to start
if (this->buffer_length_ > 0) {
std::memmove(this->buffer_, this->data_start_, this->buffer_length_);
}
this->data_start_ = this->buffer_;
}
size_t bytes_to_read = AudioTransferBuffer::free();
size_t bytes_read = 0;
if (bytes_to_read > 0) {
if (this->ring_buffer_ != nullptr) {
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
}
this->increase_buffer_length(bytes_read);
}
return bytes_read;
}
size_t AudioSourceTransferBuffer::free() const { return AudioTransferBuffer::free(); }
bool AudioSourceTransferBuffer::has_buffered_data() const { return AudioTransferBuffer::has_buffered_data(); }
#pragma GCC diagnostic pop
void ConstAudioSourceBuffer::set_data(const uint8_t *data, size_t length) {
this->data_start_ = data;
@@ -268,6 +272,23 @@ void RingBufferAudioSource::clear_buffered_data() {
this->ring_buffer_->reset();
}
size_t RingBufferAudioSource::expose_padded_partial(size_t frame_bytes) {
if (this->current_available_ > 0 || this->queued_length_ > 0) {
return 0;
}
// Moves any trailing bytes still inside a held item into the splice buffer
this->release_item_();
const size_t real_bytes = this->splice_length_;
if (real_bytes == 0 || frame_bytes == 0 || real_bytes % frame_bytes != 0) {
return 0;
}
std::memset(this->splice_buffer_ + real_bytes, 0, this->alignment_bytes_ - real_bytes);
this->splice_length_ = 0;
this->current_data_ = this->splice_buffer_;
this->current_available_ = this->alignment_bytes_;
return real_bytes;
}
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
@@ -276,7 +297,7 @@ bool RingBufferAudioSource::has_buffered_data() const {
return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0);
}
size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) {
size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait) {
if (this->current_available_ > 0) {
// Caller has not finished consuming the current exposure
return 0;
@@ -3,6 +3,7 @@
#ifdef USE_ESP32
#include "esphome/components/ring_buffer/ring_buffer.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h" // for ESPDEPRECATED
#ifdef USE_SPEAKER
#include "esphome/components/speaker/speaker.h"
@@ -152,13 +153,20 @@ class AudioReadableBuffer {
/// @brief Refills the buffer from its source. No-op by default for read-only buffers.
/// @param ticks_to_wait FreeRTOS ticks to block while waiting for data
/// @param pre_shift If true, shifts existing data to the start of the buffer before reading
/// @return Number of bytes read
virtual size_t fill(TickType_t ticks_to_wait, bool pre_shift) { return 0; }
size_t fill(TickType_t ticks_to_wait) { return this->fill(ticks_to_wait, true); }
virtual size_t fill(TickType_t ticks_to_wait) { return 0; }
// Remove before 2027.5.0
ESPDEPRECATED("Use fill(ticks_to_wait) instead. Removed in 2027.5.0.", "2026.11.0")
virtual size_t fill(TickType_t ticks_to_wait, bool pre_shift) { return this->fill(ticks_to_wait); }
};
class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadableBuffer {
// Remove before 2027.5.0
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
class ESPDEPRECATED("Use RingBufferAudioSource instead. Removed in 2027.5.0.", "2026.11.0") AudioSourceTransferBuffer
: public AudioTransferBuffer,
public AudioReadableBuffer {
/*
* @brief A class that implements a transfer buffer for audio sources.
* Supports reading audio data from a ring buffer into the transfer buffer for processing.
@@ -189,10 +197,12 @@ class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadab
size_t free() const override;
void consume(size_t bytes) override { this->decrease_buffer_length(bytes); }
bool has_buffered_data() const override;
size_t fill(TickType_t ticks_to_wait) override { return this->transfer_data_from_source(ticks_to_wait); }
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override {
return this->transfer_data_from_source(ticks_to_wait, pre_shift);
}
};
#pragma GCC diagnostic pop
/// @brief A lightweight read-only audio buffer for const data sources (e.g., flash memory).
/// Does not allocate memory or transfer data from external sources.
@@ -246,14 +256,21 @@ class RingBufferAudioSource : public AudioReadableBuffer {
size_t available() const override { return this->current_available_; }
void consume(size_t bytes) override;
bool has_buffered_data() const override;
/// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
/// An unconsumed exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait) override;
// Keeps the deprecated fill(ticks_to_wait, pre_shift) overload visible. Remove before 2027.5.0.
using AudioReadableBuffer::fill;
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
void clear_buffered_data();
/// @brief Zero-pads held whole frames short of an alignment unit and exposes them, so a stream's tail is not
/// stranded. A held partial frame is left to wait for its remaining bytes.
/// @param frame_bytes Bytes per audio frame
/// @return Real (unpadded) bytes exposed, or 0 if nothing is exposable
size_t expose_padded_partial(size_t frame_bytes);
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
+1 -4
View File
@@ -146,10 +146,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
}
uint8_t checksum = 0;
// Calculate checksum and compare with signal value.
for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++)
checksum += remote_state[i];
const uint8_t checksum = sum_bytes(remote_state, BALLU_STATE_LENGTH - 1);
if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) {
ESP_LOGVV(TAG, "Checksum fail");
+2 -1
View File
@@ -1,10 +1,11 @@
#pragma once
#include <set>
#include "esphome/core/log.h"
namespace esphome::bedjet {
static const char *const TAG = "bedjet";
ESPHOME_LOG_TAG(TAG, "bedjet");
/// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%.
inline static uint8_t bedjet_fan_step_to_speed(const uint8_t fan) {
+75
View File
@@ -0,0 +1,75 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_GAIN,
CONF_GLASS_ATTENUATION_FACTOR,
CONF_ID,
CONF_INTEGRATION_TIME,
)
from esphome.types import ConfigType
CODEOWNERS = ["@latonita"]
DEPENDENCIES = ["i2c"]
DOMAIN = "bh1745"
MULTI_CONF = True
CONF_BH1745_ID = "bh1745_id"
bh1745_ns = cg.esphome_ns.namespace("bh1745")
BH1745Component = bh1745_ns.class_(
"BH1745Component", cg.PollingComponent, i2c.I2CDevice
)
AdcGain = bh1745_ns.enum("AdcGain", is_class=True)
ADC_GAINS = {
"1X": AdcGain.ADC_GAIN_1X,
"2X": AdcGain.ADC_GAIN_2X,
"16X": AdcGain.ADC_GAIN_16X,
}
MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True)
MEASUREMENT_TIMES = {
160: MeasurementTime.MEASUREMENT_TIME_160MS,
320: MeasurementTime.MEASUREMENT_TIME_320MS,
640: MeasurementTime.MEASUREMENT_TIME_640MS,
1280: MeasurementTime.MEASUREMENT_TIME_1280MS,
2560: MeasurementTime.MEASUREMENT_TIME_2560MS,
5120: MeasurementTime.MEASUREMENT_TIME_5120MS,
}
def validate_measurement_time(value: Any) -> Any:
value = cv.positive_time_period_milliseconds(value).total_milliseconds
return cv.enum(MEASUREMENT_TIMES, int=True)(value)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BH1745Component),
cv.Optional(CONF_GAIN, default="1X"): cv.enum(ADC_GAINS, upper=True),
cv.Optional(
CONF_INTEGRATION_TIME, default="160ms"
): validate_measurement_time,
cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range(
min=1.0
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x38))
)
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)
cg.add(var.set_adc_gain(config[CONF_GAIN]))
cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME]))
cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR]))
+182
View File
@@ -0,0 +1,182 @@
#include "bh1745.h"
#include <cinttypes>
#include <cmath>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::bh1745 {
ESPHOME_LOG_TAG(TAG, "bh1745");
static constexpr uint8_t REG_SYSTEM_CONTROL = 0x40;
static constexpr uint8_t REG_MODE_CONTROL1 = 0x41;
static constexpr uint8_t REG_MODE_CONTROL2 = 0x42;
static constexpr uint8_t REG_MODE_CONTROL3 = 0x44;
static constexpr uint8_t REG_RED_DATA_LSB = 0x50;
static constexpr uint8_t REG_INTERRUPT = 0x60;
static constexpr uint8_t REG_TH_LSB = 0x62;
static constexpr uint8_t REG_MANUFACTURER_ID = 0x92;
static constexpr uint8_t MANUFACTURER_ID = 0xE0;
static constexpr uint8_t PART_ID = 0b001011;
static constexpr uint8_t SYSTEM_CONTROL_PART_ID_MASK = 0x3F;
static constexpr uint8_t SYSTEM_CONTROL_SW_RESET = 0x80;
static constexpr uint8_t MODE_CONTROL2_RGBC_EN = 0x10;
static constexpr uint8_t MODE_CONTROL2_VALID = 0x80;
static constexpr uint8_t MODE_CONTROL3_VALUE = 0x02; // the datasheet says always write 0x02
static constexpr uint8_t INTERRUPT_ENABLE = 0x01;
static constexpr uint8_t BH1745_CHANNELS = 4;
static constexpr uint32_t RESET_DELAY_MS = 100;
static constexpr uint32_t BASE_MEAS_TIME_MS = 160;
static uint32_t get_measurement_time_ms(MeasurementTime time) {
return BASE_MEAS_TIME_MS << static_cast<uint8_t>(time);
}
static uint8_t get_adc_gain(AdcGain gain) {
return gain == AdcGain::ADC_GAIN_16X ? 16 : 1 + static_cast<uint8_t>(gain);
}
static float calculate_lux(uint16_t red, uint16_t green, bool low_clear_ratio, float scale) {
if (green == 0)
return 0.0f;
float lx = low_clear_ratio ? 0.202f * red + 0.766f * green : 0.159f * red + 0.646f * green;
return lx * scale;
}
static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low_clear_ratio) {
if (green == 0)
return 0.0f;
float all = static_cast<float>(static_cast<uint32_t>(red) + green + blue);
float r_ratio = red / all;
float b_ratio = blue / all;
float ct;
if (low_clear_ratio) {
float b_eff = std::fmin(b_ratio * 3.13f, 1.0f);
ct = ((1.0f - b_eff) * 12746.0f * std::exp(-2.911f * r_ratio)) + (b_eff * 1637.0f * std::exp(4.865f * b_ratio));
} else {
float b_eff = std::fmin(b_ratio * 10.67f, 1.0f);
ct = ((1.0f - b_eff) * 16234.0f * std::exp(-2.781f * r_ratio)) + (b_eff * 1882.0f * std::exp(4.448f * b_ratio));
}
return clamp_at_most(ct, 10000.0f);
}
void BH1745Component::setup() {
uint8_t manufacturer_id;
if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (manufacturer_id != MANUFACTURER_ID) {
this->mark_failed(LOG_STR("Wrong manufacturer ID"));
return;
}
uint8_t system_control;
if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) ||
(system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) {
this->mark_failed(LOG_STR("Wrong part ID"));
return;
}
if (!this->write_byte(REG_SYSTEM_CONTROL, SYSTEM_CONTROL_SW_RESET)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// Light lost behind glass is scaled back up, as the other light sensors do
this->lux_scale_ = 160.0f * this->glass_attenuation_factor_ /
(get_adc_gain(this->adc_gain_) * get_measurement_time_ms(this->measurement_time_));
this->set_timeout(RESET_DELAY_MS, [this]() {
if (this->interrupt_output_) {
// High threshold 0 and low threshold 0xFFFF (low byte first) make the interrupt condition always
// true, so the INT pin simply follows the enable bit, which drives the breakout's LEDs.
static constexpr uint8_t THRESHOLDS[4] = {0x00, 0x00, 0xFF, 0xFF};
if (!this->write_bytes(REG_TH_LSB, THRESHOLDS, sizeof(THRESHOLDS))) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
// RGBC_EN makes the chip measure continuously; update() reads the latest result.
if (!this->write_byte(REG_MODE_CONTROL1, static_cast<uint8_t>(this->measurement_time_)) ||
!this->write_byte(REG_MODE_CONTROL2, static_cast<uint8_t>(this->adc_gain_) | MODE_CONTROL2_RGBC_EN) ||
!this->write_byte(REG_MODE_CONTROL3, MODE_CONTROL3_VALUE)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->busy_ = false;
});
}
void BH1745Component::dump_config() {
ESP_LOGCONFIG(TAG,
"BH1745:\n"
" Gain: %ux\n"
" Integration time: %" PRIu32 " ms\n"
" Glass attenuation factor: %.2f",
get_adc_gain(this->adc_gain_), get_measurement_time_ms(this->measurement_time_),
this->glass_attenuation_factor_);
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Red Counts", this->red_counts_sensor_);
LOG_SENSOR(" ", "Green Counts", this->green_counts_sensor_);
LOG_SENSOR(" ", "Blue Counts", this->blue_counts_sensor_);
LOG_SENSOR(" ", "Clear Counts", this->clear_counts_sensor_);
LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_);
LOG_SENSOR(" ", "Color Temperature", this->color_temperature_sensor_);
}
void BH1745Component::update() {
if (this->busy_)
return;
// Reading MODE_CONTROL2 clears VALID, so read it only once per poll.
uint8_t mode_control2;
if (!this->read_byte(REG_MODE_CONTROL2, &mode_control2)) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((mode_control2 & MODE_CONTROL2_VALID) == 0)
return;
uint8_t buffer[BH1745_CHANNELS * 2];
if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->status_clear_warning();
// Each channel is sent low byte first.
const uint16_t red = encode_uint16(buffer[1], buffer[0]);
const uint16_t green = encode_uint16(buffer[3], buffer[2]);
const uint16_t blue = encode_uint16(buffer[5], buffer[4]);
const uint16_t clear = encode_uint16(buffer[7], buffer[6]);
ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear);
if (this->red_counts_sensor_ != nullptr)
this->red_counts_sensor_->publish_state(red);
if (this->green_counts_sensor_ != nullptr)
this->green_counts_sensor_->publish_state(green);
if (this->blue_counts_sensor_ != nullptr)
this->blue_counts_sensor_->publish_state(blue);
if (this->clear_counts_sensor_ != nullptr)
this->clear_counts_sensor_->publish_state(clear);
// The datasheet picks its coefficients by whether clear / green < 0.160 (= 4 / 25).
const bool low_clear_ratio = static_cast<uint32_t>(clear) * 25 < static_cast<uint32_t>(green) * 4;
if (this->illuminance_sensor_ != nullptr)
this->illuminance_sensor_->publish_state(calculate_lux(red, green, low_clear_ratio, this->lux_scale_));
if (this->color_temperature_sensor_ != nullptr)
this->color_temperature_sensor_->publish_state(calculate_cct(red, green, blue, low_clear_ratio));
}
void BH1745Component::set_interrupt_state(bool on) {
if (this->is_failed())
return;
if (!this->write_byte(REG_INTERRUPT, on ? INTERRUPT_ENABLE : 0x00)) {
this->status_set_warning();
}
}
} // namespace esphome::bh1745
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::bh1745 {
enum class MeasurementTime : uint8_t {
MEASUREMENT_TIME_160MS = 0b000,
MEASUREMENT_TIME_320MS = 0b001,
MEASUREMENT_TIME_640MS = 0b010,
MEASUREMENT_TIME_1280MS = 0b011,
MEASUREMENT_TIME_2560MS = 0b100,
MEASUREMENT_TIME_5120MS = 0b101,
};
enum class AdcGain : uint8_t {
ADC_GAIN_1X = 0,
ADC_GAIN_2X = 1,
ADC_GAIN_16X = 2,
};
class BH1745Component : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(red_counts)
SUB_SENSOR(green_counts)
SUB_SENSOR(blue_counts)
SUB_SENSOR(clear_counts)
SUB_SENSOR(illuminance)
SUB_SENSOR(color_temperature)
public:
void setup() override;
void dump_config() override;
void update() override;
void set_measurement_time(MeasurementTime measurement_time) { this->measurement_time_ = measurement_time; }
void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; }
void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; }
/// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds.
void enable_interrupt_output() { this->interrupt_output_ = true; }
void set_interrupt_state(bool on);
protected:
float glass_attenuation_factor_{1.0f};
float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup()
MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS};
AdcGain adc_gain_{AdcGain::ADC_GAIN_1X};
bool busy_{true}; // cleared once the chip is configured after the reset
bool interrupt_output_{false};
};
} // namespace esphome::bh1745
@@ -0,0 +1,28 @@
import esphome.codegen as cg
from esphome.components import output
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns
DEPENDENCIES = ["bh1745"]
BH1745InterruptPinOutput = bh1745_ns.class_(
"BH1745InterruptPinOutput", output.BinaryOutput
)
CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput),
cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component),
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await output.register_output(var, config)
hub = await cg.get_variable(config[CONF_BH1745_ID])
await cg.register_parented(var, hub)
cg.add(hub.enable_interrupt_output())
@@ -0,0 +1,16 @@
#pragma once
#include "esphome/components/output/binary_output.h"
#include "esphome/core/helpers.h"
#include "../bh1745.h"
namespace esphome::bh1745 {
/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one.
class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented<BH1745Component> {
protected:
void write_state(bool state) override { this->parent_->set_interrupt_state(state); }
};
} // namespace esphome::bh1745
+66
View File
@@ -0,0 +1,66 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import (
CONF_BLUE_CHANNEL,
CONF_CLEAR_CHANNEL,
CONF_GREEN_CHANNEL,
CONF_RED_CHANNEL,
UNIT_COUNTS,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_COLOR_TEMPERATURE,
CONF_ILLUMINANCE,
DEVICE_CLASS_ILLUMINANCE,
ICON_LIGHTBULB,
ICON_THERMOMETER,
STATE_CLASS_MEASUREMENT,
UNIT_KELVIN,
UNIT_LUX,
)
from esphome.types import ConfigType
from . import CONF_BH1745_ID, BH1745Component
DEPENDENCIES = ["bh1745"]
color_channel_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_LIGHTBULB,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component),
cv.Optional(CONF_RED_CHANNEL): color_channel_schema,
cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema,
cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema,
cv.Optional(CONF_CLEAR_CHANNEL): color_channel_schema,
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_LUX,
icon=ICON_LIGHTBULB,
accuracy_decimals=1,
device_class=DEVICE_CLASS_ILLUMINANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_KELVIN,
icon=ICON_THERMOMETER,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_BH1745_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_RED_CHANNEL, hub.set_red_counts_sensor)
await sensors(CONF_GREEN_CHANNEL, hub.set_green_counts_sensor)
await sensors(CONF_BLUE_CHANNEL, hub.set_blue_counts_sensor)
await sensors(CONF_CLEAR_CHANNEL, hub.set_clear_counts_sensor)
await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor)
await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor)
+17 -9
View File
@@ -74,6 +74,10 @@ from esphome.types import ConfigType
from esphome.util import Registry
CODEOWNERS = ["@esphome/core"]
DEFAULT_INVALID_COOLDOWN_MS = (
1000 # Keep in sync with invalid_cooldown_ in automation.h
)
DEVICE_CLASSES = [
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_BATTERY_CHARGING,
@@ -131,10 +135,9 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa
DoubleClickTrigger = binary_sensor_ns.class_(
"DoubleClickTrigger", automation.Trigger.template()
)
MultiClickTriggerBase = binary_sensor_ns.class_(
"MultiClickTriggerBase", automation.Trigger.template(), cg.Component
MultiClickTrigger = binary_sensor_ns.class_(
"MultiClickTrigger", automation.Trigger.template(), cg.Component
)
MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase)
MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent")
@@ -492,7 +495,8 @@ _BINARY_SENSOR_SCHEMA = (
cv.Length(min=1, max=255),
),
cv.Optional(
CONF_INVALID_COOLDOWN, default="1s"
CONF_INVALID_COOLDOWN,
default=f"{DEFAULT_INVALID_COOLDOWN_MS}ms",
): cv.positive_time_period_milliseconds,
}
),
@@ -586,11 +590,14 @@ async def _build_binary_sensor_automations(var, config):
)
for tim in conf[CONF_TIMING]
]
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings
table = cg.shared_progmem_array(
"multi_click_timing", MultiClickTriggerEvent, cg.ArrayInitializer(*timings)
)
if CONF_INVALID_COOLDOWN in conf:
cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN]))
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, table, len(timings))
if (
cooldown := conf[CONF_INVALID_COOLDOWN]
).total_milliseconds != DEFAULT_INVALID_COOLDOWN_MS:
cg.add(trigger.set_invalid_cooldown(cooldown))
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
@@ -607,7 +614,8 @@ async def setup_binary_sensor_core_(var, config):
if filters_config := config.get(CONF_FILTERS):
cg.add_define("USE_BINARY_SENSOR_FILTER")
filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config)
cg.add(var.add_filters(filters))
for filter_ in filters:
cg.add(var.add_filter(filter_))
CORE.add_job(_build_binary_sensor_automations, var, config)
+13 -14
View File
@@ -18,7 +18,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1;
constexpr uint32_t MULTICLICK_IS_VALID_ID = 2;
constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3;
void MultiClickTriggerBase::on_state_(bool state) {
void MultiClickTrigger::on_state_(bool state) {
// Handle duplicate events
if (state == this->last_state_) {
return;
@@ -30,7 +30,7 @@ void MultiClickTriggerBase::on_state_(bool state) {
return;
}
if (!this->at_index_.has_value()) {
if (this->at_index_ == 0) {
// Start matching
MultiClickTriggerEvent evt = this->timing_[0];
if (evt.state == state) {
@@ -55,8 +55,7 @@ void MultiClickTriggerBase::on_state_(bool state) {
return;
}
// at_index_ has a value here (the !has_value() branch above returns).
size_t at_index = *this->at_index_;
uint8_t at_index = this->at_index_;
if (at_index == this->timing_count_) {
this->trigger_();
return;
@@ -65,15 +64,15 @@ void MultiClickTriggerBase::on_state_(bool state) {
MultiClickTriggerEvent evt = this->timing_[at_index];
if (evt.max_length != 4294967294UL) {
ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT
ESP_LOGV(TAG, "A i=%u min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length);
this->schedule_is_valid_(evt.min_length);
this->schedule_is_not_valid_(evt.max_length);
} else if (at_index + 1 != this->timing_count_) {
ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT
ESP_LOGV(TAG, "B i=%u min=%" PRIu32, at_index, evt.min_length);
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
this->schedule_is_valid_(evt.min_length);
} else {
ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT
ESP_LOGV(TAG, "C i=%u min=%" PRIu32, at_index, evt.min_length);
this->is_valid_ = false;
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); });
@@ -81,19 +80,19 @@ void MultiClickTriggerBase::on_state_(bool state) {
this->at_index_ = at_index + 1;
}
void MultiClickTriggerBase::schedule_cooldown_() {
void MultiClickTrigger::schedule_cooldown_() {
ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_);
this->is_in_cooldown_ = true;
this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() {
ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again.");
this->is_in_cooldown_ = false;
});
this->at_index_.reset();
this->at_index_ = 0;
this->cancel_timeout(MULTICLICK_TRIGGER_ID);
this->cancel_timeout(MULTICLICK_IS_VALID_ID);
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
}
void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) {
void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) {
if (min_length == 0) {
this->is_valid_ = true;
return;
@@ -105,7 +104,7 @@ void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) {
this->is_valid_ = true;
});
}
void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) {
void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) {
this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() {
ESP_LOGV(TAG, "Multi Click: You waited too long to %s.",
this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS"));
@@ -113,14 +112,14 @@ void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) {
this->schedule_cooldown_();
});
}
void MultiClickTriggerBase::cancel() {
void MultiClickTrigger::cancel() {
ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled.");
this->is_valid_ = false;
this->schedule_cooldown_();
}
void MultiClickTriggerBase::trigger_() {
void MultiClickTrigger::trigger_() {
ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!");
this->at_index_.reset();
this->at_index_ = 0;
this->cancel_timeout(MULTICLICK_TRIGGER_ID);
this->cancel_timeout(MULTICLICK_IS_VALID_ID);
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
+16 -26
View File
@@ -1,7 +1,7 @@
#pragma once
#include <array>
#include <cinttypes>
#include <type_traits>
#include <utility>
#include "esphome/core/component.h"
@@ -13,10 +13,14 @@
namespace esphome::binary_sensor {
struct MultiClickTriggerEvent {
bool state;
uint32_t state; // Not bool: all-word fields allow aligned loads from PROGMEM on ESP8266
uint32_t min_length;
uint32_t max_length;
};
// Read straight from PROGMEM; a byte-sized field would fault on ESP8266.
static_assert(std::is_same_v<decltype(MultiClickTriggerEvent::state), uint32_t> &&
sizeof(MultiClickTriggerEvent) == 3 * sizeof(uint32_t),
"MultiClickTriggerEvent fields must all be uint32_t");
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length);
@@ -70,10 +74,11 @@ class DoubleClickTrigger final : public Trigger<> {
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
};
/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header).
class MultiClickTriggerBase : public Trigger<>, public Component {
class MultiClickTrigger final : public Trigger<>, public Component {
public:
explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {}
/// `timing` is a codegen PROGMEM table shared by triggers with the same timing.
MultiClickTrigger(BinarySensor *parent, const MultiClickTriggerEvent *timing, uint8_t timing_count)
: parent_(parent), timing_(timing), timing_count_(timing_count) {}
void setup() override {
this->last_state_ = this->parent_->get_state_default(false);
@@ -85,8 +90,8 @@ class MultiClickTriggerBase : public Trigger<>, public Component {
void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; }
void cancel();
MultiClickTriggerBase(const MultiClickTriggerBase &) = delete;
MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete;
MultiClickTrigger(const MultiClickTrigger &) = delete;
MultiClickTrigger &operator=(const MultiClickTrigger &) = delete;
protected:
void on_state_(bool state);
@@ -96,28 +101,13 @@ class MultiClickTriggerBase : public Trigger<>, public Component {
void trigger_();
BinarySensor *parent_;
const MultiClickTriggerEvent *timing_{nullptr};
uint32_t invalid_cooldown_{1000};
optional<size_t> at_index_{};
uint8_t timing_count_{0};
const MultiClickTriggerEvent *timing_;
uint32_t invalid_cooldown_{1000}; // Must match DEFAULT_INVALID_COOLDOWN_MS in __init__.py
uint8_t timing_count_;
uint8_t at_index_{0}; // 0: not matching, otherwise the index of the next timing event
bool last_state_{false};
bool is_in_cooldown_{false};
bool is_valid_{false};
};
/// Template wrapper that provides inline std::array storage for timing events.
/// N is set by code generation to match the exact number of timing events configured in YAML.
template<size_t N> class MultiClickTrigger final : public MultiClickTriggerBase {
public:
MultiClickTrigger(BinarySensor *parent, std::initializer_list<MultiClickTriggerEvent> timing)
: MultiClickTriggerBase(parent) {
init_array_from(this->timing_storage_, timing);
this->timing_ = this->timing_storage_.data();
this->timing_count_ = N;
}
protected:
std::array<MultiClickTriggerEvent, N> timing_storage_{};
};
} // namespace esphome::binary_sensor
+14 -6
View File
@@ -51,12 +51,8 @@ void BL0939::loop() {
}
bool BL0939::validate_checksum(const DataPacket *data) {
uint8_t checksum = BL0939_READ_COMMAND;
// Whole package but checksum
for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) {
checksum += data->raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum = sum_bytes(data->raw, sizeof(data->raw) - 1, BL0939_READ_COMMAND) ^ 0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
@@ -120,13 +116,24 @@ void BL0939::received_package_(const DataPacket *data) const {
energy_sensor_sum_->publish_state(total_energy_consumption);
}
if (this->internal_temperature_sensor_ != nullptr) {
const uint16_t raw_temperature = ((uint16_t(data->tps1.h) << 8) | data->tps1.l) & 0x3FF;
// Datasheet temperature conversion: Tx = (170 / 448) * (TB / 2 - 32) - 45.
const float temperature = (170.0f / 448.0f) * (raw_temperature / 2.0f - 32.0f) - 45.0f;
this->internal_temperature_sensor_->publish_state(temperature);
}
ESP_LOGV(TAG,
"BL0939: U %fV, I1 %fA, I2 %fA, P1 %fW, P2 %fW, CntA %" PRId32 ", CntB %" PRId32 ", ∫P1 %fkWh, ∫P2 %fkWh",
v_rms, ia_rms, ib_rms, a_watt, b_watt, cfa_cnt, cfb_cnt, a_energy_consumption, b_energy_consumption);
}
void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity)
ESP_LOGCONFIG(TAG, "BL0939:");
ESP_LOGCONFIG(TAG,
"BL0939:\n"
" Mode: %s",
this->mode_ == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER ? LOG_STR_LITERAL("current_transformer")
: LOG_STR_LITERAL("direct"));
LOG_SENSOR("", "Voltage", this->voltage_sensor_);
LOG_SENSOR("", "Current 1", this->current_sensor_1_);
LOG_SENSOR("", "Current 2", this->current_sensor_2_);
@@ -135,6 +142,7 @@ void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexit
LOG_SENSOR("", "Energy 1", this->energy_sensor_1_);
LOG_SENSOR("", "Energy 2", this->energy_sensor_2_);
LOG_SENSOR("", "Energy sum", this->energy_sensor_sum_);
LOG_SENSOR("", "Internal Temperature", this->internal_temperature_sensor_);
}
uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; }
+35 -4
View File
@@ -8,13 +8,32 @@ namespace esphome::bl0939 {
// https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf
// (unfortunately chinese, but the formulas can be easily understood)
// The datasheet has one set of formulas for direct mode (current shunt) and one for current transformer mode.
// Direct mode.
// Sonoff Dual R3 V2 has the exact same resistor values for the current shunts (RL=1miliOhm)
// and for the voltage divider (R1=0.51kOhm, R2=5*390kOhm)
// as in the manufacturer's reference circuit, so the same formulas were used here (Vref=1.218V)
static const float BL0939_IREF = 324004 * 1 / 1.218;
static const float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51));
static const float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51));
static const float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_IREF = 324004 * 1 / 1.218;
static constexpr float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51));
// Current transformer mode.
// Values from the Seeed Studio XIAO 2-Channel Wi-Fi AC Energy Meter V1.0 schematic (Vref=1.218V).
// Current: 100A:50mA CT (2000:1) into a 0.6Ohm burden (2 * 0.3Ohm), so 0.3mV per primary amp.
// That is the same as a 0.3mOhm shunt in the direct mode formula.
// Voltage: ZMPT107-1 2mA:2mA voltage transformer with R2=5*22kOhm on the primary and R1=24.9Ohm on
// the secondary. The transformer isolates R1 from R2, so the divider is R1/R2 instead of R1/(R1+R2).
static constexpr float BL0939_IREF_CT = 324004 * 0.3 / 1.218;
static constexpr float BL0939_UREF_CT = 79931 * 24.9 * 1000 / (1.218 * 5 * 22000);
static constexpr float BL0939_PREF_CT = 4046 * 0.3 * 24.9 * 1000 / (1.218 * 1.218 * 5 * 22000);
static constexpr float BL0939_EREF_CT = 3.6e6 * 4046 * 0.3 * 24.9 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * 5 * 22000);
enum class BL0939Mode : uint8_t {
BL0939_MODE_DIRECT = 0,
BL0939_MODE_CURRENT_TRANSFORMER = 1,
};
struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align)
uint8_t l;
@@ -66,6 +85,16 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
void set_energy_sensor_1(sensor::Sensor *energy_sensor_1) { energy_sensor_1_ = energy_sensor_1; }
void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; }
void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; }
void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; }
// Inline so a constant mode from codegen folds down to plain stores.
void set_mode(BL0939Mode mode) {
this->mode_ = mode;
const bool current_transformer = mode == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER;
this->power_reference_ = current_transformer ? BL0939_PREF_CT : BL0939_PREF;
this->voltage_reference_ = current_transformer ? BL0939_UREF_CT : BL0939_UREF;
this->current_reference_ = current_transformer ? BL0939_IREF_CT : BL0939_IREF;
this->energy_reference_ = current_transformer ? BL0939_EREF_CT : BL0939_EREF;
}
void loop() override;
@@ -84,6 +113,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
sensor::Sensor *energy_sensor_1_{nullptr};
sensor::Sensor *energy_sensor_2_{nullptr};
sensor::Sensor *energy_sensor_sum_{nullptr};
sensor::Sensor *internal_temperature_sensor_{nullptr};
// Divide by this to turn into Watt
float power_reference_ = BL0939_PREF;
@@ -93,6 +123,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
float current_reference_ = BL0939_IREF;
// Divide by this to turn into kWh
float energy_reference_ = BL0939_EREF;
BL0939Mode mode_{BL0939Mode::BL0939_MODE_DIRECT};
static uint32_t to_uint32_t(ube24_t input);
+27
View File
@@ -3,14 +3,19 @@ from esphome.components import sensor, uart
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INTERNAL_TEMPERATURE,
CONF_MODE,
CONF_VOLTAGE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_KILOWATT_HOURS,
UNIT_VOLT,
UNIT_WATT,
@@ -29,11 +34,22 @@ CONF_ENERGY_TOTAL = "energy_total"
bl0939_ns = cg.esphome_ns.namespace("bl0939")
BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice)
BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True)
MODE_OPTIONS = {
"direct": BL0939Mode.BL0939_MODE_DIRECT,
"current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER,
}
# Must match the mode_ member initializer in bl0939.h; set_mode() is skipped
# when the config already equals it.
DEFAULT_MODE = "direct"
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BL0939),
cv.Optional(CONF_MODE, default=DEFAULT_MODE): cv.enum(
MODE_OPTIONS, lower=True
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
@@ -82,6 +98,13 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_INTERNAL_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
@@ -94,6 +117,9 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if (mode := config[CONF_MODE]) != DEFAULT_MODE:
cg.add(var.set_mode(mode))
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT_1, var.set_current_sensor_1)
@@ -103,3 +129,4 @@ async def to_code(config: ConfigType) -> None:
await sensors(CONF_ENERGY_1, var.set_energy_sensor_1)
await sensors(CONF_ENERGY_2, var.set_energy_sensor_2)
await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum)
await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor)
+2 -6
View File
@@ -44,13 +44,9 @@ void BL0940::loop() {
}
bool BL0940::validate_checksum_(DataPacket *data) {
uint8_t checksum = this->read_command_;
// Whole package but checksum
uint8_t *raw = (uint8_t *) data;
for (uint32_t i = 0; i < sizeof(*data) - 1; i++) {
checksum += raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum =
sum_bytes(reinterpret_cast<const uint8_t *>(data), sizeof(*data) - 1, this->read_command_) ^ 0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
+3 -6
View File
@@ -70,13 +70,10 @@ void BL0942::loop() {
}
bool BL0942::validate_checksum_(DataPacket *data) {
uint8_t checksum = BL0942_READ_COMMAND | this->address_;
// Whole package but checksum
uint8_t *raw = (uint8_t *) data;
for (uint32_t i = 0; i < sizeof(*data) - 1; i++) {
checksum += raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum =
sum_bytes(reinterpret_cast<const uint8_t *>(data), sizeof(*data) - 1, BL0942_READ_COMMAND | this->address_) ^
0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "BL0942 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
+1 -1
View File
@@ -4,7 +4,7 @@
namespace esphome::ble_client {
const char *const Automation::TAG = "ble_client.automation";
const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266
} // namespace esphome::ble_client
+2 -1
View File
@@ -3,6 +3,7 @@ from esphome.components.logger import request_log_listener
from esphome.components.uart import (
UARTComponent,
debug_to_code,
mark_unclocked,
maybe_empty_debug,
uart_ns,
)
@@ -42,7 +43,7 @@ def validate_rx_buffer(config: ConfigType) -> ConfigType:
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLENUS),
cv.GenerateID(): cv.All(cv.declare_id(BLENUS), mark_unclocked),
cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of(
*[CONF_LOGS, CONF_UART], lower=True
),
@@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() {
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}};
esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;

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