Compare commits

..
Author SHA1 Message Date
J. Nick Koston 4770f4a82c Locate a while warning inside a list condition and restore App state in its test
A list condition is wrapped in an and: with no place in the file, so the
warning now names and locates the first condition in it, and leaves the
location out rather than pointing at the main file. The shutdown test
restores App's state bits, so later tests still yield.
2026-11-09 00:56:50 -08:00
J. Nick Koston 932f468ddb Keep shutdown as an app state bit and give the while warning its file and line
Shutdown is bit 7 of App.app_state_, set by reboot() and
run_safe_shutdown_hooks(), so every shutdown path stops while yields at
no RAM cost instead of a flag set by ShutdownTrigger. The warning gives
the file, line and column of the loop's condition. The wait check and
the registry key lookup are shared.
2026-11-09 00:43:24 -08:00
J. Nick Koston 60e7d78092 Run a while to its end during shutdown and name the loop in the warning
Nothing runs a timeout once shutdown started, so a while in on_shutdown
that yielded never finished and the actions after it were dropped; it
now runs to its end, as on dev. The warning names the condition and
the component of the loop it means.
2026-11-09 00:38:08 -08:00
J. Nick Koston 90ee1626c1 Restore the while tests for runs without arguments
The trim of the one argument hand-off test also removed the tests for a
wait_until timeout releasing another run inside a pass and for two ends
in one pass, which cover runs without arguments.
2026-11-08 19:26:56 -08:00
J. Nick Koston 61f8e675cd Count a lambda action in a while body only by the ids it names
A lambda action no longer turns the warning off on its own; like other
actions it counts when it names an id the condition reads, such as a
counter both use.
2026-11-08 19:22:10 -08:00
J. Nick Koston e920203064 Walk a while body once for the warning and trim comments and tests
The warning walks the body's actions once for both the wait and the
lambda check, the id filter is shared, and the message is shorter. The
C++ test for one argument is covered by the mixed argument test, and
warning cases that repeated another shape are gone. The climate test
loop uses a lambda body, so it does not warn.
2026-11-08 19:04:07 -08:00
J. Nick Koston 6938ef121a Skip condition trees and unassigned ids in the while warning
The action walker no longer descends into an if's any: or all: or a
choice's if:, which hold conditions. Ids generated later are still None
during validation, so they no longer all match each other.
2026-11-08 18:45:20 -08:00
J. Nick Koston a2b5b61acf Say in the while warning that a body changing the condition is fine
The check cannot see a change made through another script or a later
state update, so the message no longer claims the loop holds up the
device in every case.
2026-11-08 18:03:35 -08:00
J. Nick Koston dc8eb68026 Skip conditions when walking a body's actions
A condition holds no actions, but its and: or or: holds a list, which
was walked as an action list, so a lambda condition there turned the
while warning off.
2026-11-08 17:52:03 -08:00
J. Nick Koston 6544007c7d Count only actions of the body for the while warning
The warning walks the body's action lists: only a lambda action, or an
action on an id the condition reads, turns it off. A logger.log reading
the condition's entity or a lambda condition inside the body only reads.
has_non_synchronous_actions uses the same action walker.
2026-11-08 17:40:25 -08:00
J. Nick Koston 1674121bfe Keep the while body marker per action and key runs on all arguments
Automations can run on other tasks (microphone on_data), so one marker
shared by every while could be overwritten by another task's loop; each
WhileAction now keeps its own. A run is told apart by the storage of
all its arguments, as a wait keeps an entity by reference and copies
the rest. Only a lambda action in the body turns the warning off;
templated values such as logger.log args only read.
2026-11-08 17:28:09 -08:00
J. Nick Koston cb229c509c Warn for a lambda condition the body cannot change
A lambda in the condition no longer turns the warning off on its own.
The warning is skipped only when the body has a lambda, which may change
anything, or acts on an id the condition or its lambda reads.
2026-11-08 17:08:30 -08:00
J. Nick Koston 0a7b302663 Share the while yield callback and drop leftovers from number arguments
Both while actions yield through one non-template ActionTimer helper that
resumes them through play(), skipping the cancel scan a yielding run never
needs. ApplyWhileAction keeps only play() per step function. The tests
drop the cases written for yielding with number arguments.
2026-11-08 17:05:14 -08:00
J. Nick Koston 873e0d8fe1 Yield only from a while without trigger arguments again
Keeping a WhileAction for number arguments turned an inline repeat
around a while back into a recursing RepeatAction, missed number types
the Python check did not know, and allocated a closure per yield with
two or more arguments. A while with any arguments loops in place again,
and an all inline body stays one C++ loop.

The public Scheduler::set_timeout(self) no longer records the running
script as its source, ApplyWhileActionBase checks the num_running_ type
again, and base_automation.h includes <memory> for std::addressof.
2026-11-08 16:58:40 -08:00
J. Nick Koston 9162cbc25a Continue a second while body end in one pass, and fix the clang-tidy name
When one pass of a while without arguments both releases another run's
wait and ends itself, the second end now loops on its own instead of
being lost. args_key is a static method, so it has no trailing
underscore.
2026-11-08 11:09:38 -08:00
J. Nick Koston a0f952f0c3 Pin the no-argument while runs and match number arguments without qualifiers
A wait_until timeout of one run can end inside another run's pass; for a
while without arguments the frame that takes that end loops once for
it, so every run still continues once. A test with a real wait_until
timeout covers it. The Python check for number arguments now ignores
const and reference, like WHILE_CAN_YIELD.
2026-11-08 11:02:39 -08:00
pre-commit-ci-lite[bot] 85b5a2321a apply automatic formatting fixes 2026-11-08 18:54:20 +00:00
J. Nick Koston cae0d26d82 Tie the while body marker to the run and yield with number arguments
A wait of another run that ends synchronously inside this run's body is
now told apart by the address of its arguments, instead of being taken
for the end of this body. A while whose arguments are all numbers, such
as a repeat iteration or a script parameter, keeps its WhileAction and
yields too, copying them.

The step function returns false when its last action stopped the run,
an empty body evaluates its condition once again, the warning skips a
body that acts on the condition's entity, and the timer helper is
private to the actions that use it. Scheduler::set_timeout(self) reuses
set_action_timeout_. The object loop tests now use a body with its own
action object.
2026-11-08 10:52:51 -08:00
J. Nick Koston 7a83dd3daa Share one config walker for the lambda and deferring action checks 2026-11-08 12:28:11 -06:00
pre-commit-ci-lite[bot] 79157daf80 apply automatic formatting fixes 2026-11-08 18:21:56 +00:00
J. Nick Koston adba5f12f5 Drop the USE_WHILE_ACTION gate
Builds with and without it are the same size: an unused template and
inline variable produce no code.
2026-11-08 12:19:49 -06:00
J. Nick Koston 742de9c1fc Share the while loop code and simplify the step function
ApplyWhileAction keeps only its play_complex in the template; the loop
lives in a shared base. Both while actions yield through one helper,
the only new scheduler friend. The step function returns false on a
stop between body actions instead of wrapping the body in a lambda,
and the body-ended flag is one global pointer.
2026-11-08 12:14:31 -06:00
J. Nick Koston 1fa3f4ebac Yield only from a while without trigger arguments
Copying the trigger arguments into the yield timeout broke the build for
a while in climate on_state (an abstract Climate &), changed what an
on_control body edits, and could keep views that are only valid while
the trigger runs. A while with arguments now loops in place and never
yields.

A while without arguments whose body is all inline becomes a 12 byte
ApplyWhileAction that runs one generated step function, instead of a
WhileAction with its own condition and body objects. The flag that marks
a body ending synchronously is one global stack frame pointer instead of
a member, and WhileAction is only compiled in when a config uses it.

Adds tests for every while shape.
2026-11-08 12:07:27 -06:00
J. Nick Koston 64fcde1178 Share the action timeout helper and simplify the while loop
DelayAction and WhileAction arm their timeouts through one Scheduler
helper. The while loop checks its condition in one place and keeps the
body-ended flag on the stack. The warning describes the busy loop.
2026-11-08 11:51:38 -06:00
J. Nick Koston 03deae79d0 [core] Let the main loop run when a while body never waits
A while whose body never waits started its body again from inside the
previous run, so each pass used more stack until the device crashed. On
2026.11 an all-inline body became one C++ loop that never returned.

WhileAction now loops instead of recursing and gives the main loop a turn
after 20 ms. Every while keeps its WhileAction, a false start no longer
stops the body of another run, and validation warns when the body never
waits and the condition has no lambda.
2026-11-08 11:46:46 -06:00
1057 changed files with 7382 additions and 33635 deletions

No files matched your search

+4 -2
View File
@@ -210,8 +210,10 @@ run, so prefer `register_apply_action` wherever the action allows it. Because of
return fewer objects than the config has entries. A builder that wraps `apply_action_builder` may do other work
first, but must return its result unchanged and not use it. A builder that must generate its own statements, and
is registered with `automation.ApplyAction`, can call `apply_statements_action` and still merge; `script.execute`
is an example. `if`, `choose`, `while` and `repeat` whose bodies are all inline become C++ control flow in the
same function, and `register_apply_condition` and `lambda:` conditions become plain expressions there.
is an example. `if`, `choose`, `repeat` and a `while` with trigger arguments whose bodies are all inline become
C++ control flow in the same function, and `register_apply_condition` and `lambda:` conditions become plain
expressions there. A `while` without trigger arguments becomes an `ApplyWhileAction` instead, which can yield to the
main loop; with arguments a loop never yields.
## Conditions
-10
View File
@@ -40,14 +40,6 @@ Not shared code: the component's own platform files (`esphome/components/<c>/sen
`text_sensor/__init__.py`, ...), its new `USE_*` lines in `esphome/core/defines.h`,
and its `CODEOWNERS` entry.
## YAML language freeze
Until configurations move to yaml.rocks (esphome/backlog#201), flag any PR that adds to the YAML language: a new tag
in `esphome/yaml_util.py`, new behaviour in the `packages` or `substitutions` components (a new entry in the Jinja
`SAFE_GLOBALS` in `substitutions/jinja.py`, or a custom Jinja filter, counts), or new merge, override or remove
semantics (`esphome/config_helpers.py`, `!extend`, `!remove`). Bug fixes to the existing features are fine. This
covers how YAML files are read, included and combined, not a component's own configuration options.
## Memory and embedded constraints (highest value)
Heap allocation after `setup()` is treated as a reliability bug, not a
@@ -112,8 +104,6 @@ helper; `AGENTS.md` section 4 and the `component-codegen` skill have the detail.
| --- | --- |
| `if (this->x_sensor_ != nullptr) this->x_sensor_->publish_state(v);` | `PUBLISH_STATE(this->x_sensor_, v);` (`esphome/core/entity_base.h`) |
| set warning / `return` / clear warning around a bus result | `if (!this->status_comm_result(ok)) return;` |
| set warning / `return` around an earlier step of a multi-step poll | `if (!this->status_comm_check(ok)) return;` |
| warning set, but no `status_comm_result` or `status_clear_warning` on the success path | the last step uses `status_comm_result(ok)`, or the warning never clears |
| hand-written `set_x_sensor()` plus `x_sensor_` member | `SUB_SENSOR(x)`, `SUB_BINARY_SENSOR`, `SUB_TEXT_SENSOR`, `SUB_SWITCH`, ... |
| `if CONF_X in config: s = await sensor.new_sensor(config[CONF_X])` plus setter | `sensors = sensor.sub_sensors(config)` then `await sensors(CONF_X, hub.set_x_sensor)` |
| hand-written `{"a": MyEnum.MY_ENUM_A, ...}` dict | `MyEnum.options("a", "b", prefix="MY_ENUM_")` |
@@ -236,8 +236,6 @@ Never a template per bit or a virtual listener base (a second vptr per entity).
instead, so pass `buf` once and mark the component failed in `setup()` if it is nullptr. `align=4` emits
`alignas(4)`, so a word-aligned byte buffer stays `uint8`. `size` may be an expression such as `MyClass.static_("buffer_size")(n)`, which renders
`ns::MyClass::buffer_size(n)`.
- `cg.static_storage(config[CONF_ID], T, count)` is raw aligned storage for `count` objects as a `std::span<T>`,
for `StaticFixedVector::init()` when codegen knows the count.
- `display.add_writer(var, config)` sets the lambda.
- `MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict.
- `button.restart_button_schema(cls)` (and `factory_reset_`, `query_`) are button presets.
-3
View File
@@ -88,9 +88,6 @@ Components that define buses directly are flagged as "NEEDS MIGRATION" and canno
CI build time. `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a
standalone entity with its own id instead.
In a grouped test, set `i2c_id: i2c_bus`, `one_wire_id` and `motion_id` even with one candidate: a merged
batch can add a second one ("Too many candidates"). `script/ci-custom.py` checks this.
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.
-2
View File
@@ -107,8 +107,6 @@ APIs.
This eliminates `_M_realloc_insert` and `_M_default_append` template instantiations (200-500 bytes per
instance), needs no upper bound, and is compatible with protobuf code generation when using
`[(fixed_vector) = true]`.
When codegen knows the count, use `StaticFixedVector<T>` and pass `cg.static_storage(config[CONF_ID], T, count)`
to its `init()`, so the elements live in BSS instead of the heap.
4. **Small datasets (1-16 elements):** use `std::vector` or `std::array` with simple structs instead of
`std::map`/`std::set`/`std::unordered_map`.
+2 -9
View File
@@ -56,9 +56,6 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
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.
A change in validation or platform independent code needs no board: a `host:` build serves the API on
port 6053 on every interface (reach it at 127.0.0.1, and add `api: encryption:` on a shared network); see
recipes/homeassistant.md for one against a throwaway Home Assistant.
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:
@@ -128,13 +125,12 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
(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.
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `heap_alloc/` (operator new caller
recorder, dump saver and decoder, cover cycle driver), `online_image/`
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `online_image/`
(server with prefixed misbehaviour cases, PNG, JPEG, BMP and QOI generators, API drivers, first pass timer), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header,
capture decoder, RTL8720C ROM dump versus report matcher), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer, frame stream harness with presence hold and link loss drivers, BT MAC driver), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards, AT commands for the UART passthrough), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (`ha_discovery/`, `disable/`, `retained_burst/` and `reconnect/` runners; broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner), `gpio_hold/` (ISR pin hold self test with ESP32 and S3 configs), `ble_event_inject/` (GAP and GATTC event injector header and proxy package), `light_sleep_wake/` (LEDC wave and edge timer for light sleep wakeup on a C3, external wave generator and per-call edge detail probe for peripheral power-down on the P4), `manual_ip/` (station and AP manual IP configs, DNS probe and runners), `web_handlers/` (late handler, web_server, captive_portal and prometheus variants, web OTA runner), `bk72xx_flash/` (flash readback with retries, per partition diff of two dumps), `ble_server_security/` (secure GATT server and pairing client configs, driver, summary and runner), `prometheus/` (datetime metrics configs and driver), `zephyr_ble_server/` (nRF52 server, looping ESP32 client and UF2 flash and count runner), `ble_adv_triggers/` (advertiser with six raw frames, ESP32 and BK72xx receivers with eleven triggers, runner and checker), `sun/` (longitude sweep package, runner and checker), `sensor_filters/` (every filter shape with a fixed input sequence, per-board packages and runner), `led_driver_sniff/` (SPI slave capture header with selftest, config generator, window comparer and runner), `hub_input_sim/` (keypad, wiegand and rdm6300 simulation on a D1 mini, API log runner), `value_range/` (on_value_range sequence with a restart, runner), `ezo_fake_bus/` (fake EZO I2C bus and step config, runner), `twai_self/` (TWAI self-reception header, on_frame config and counter runner), `ota_automations/` (OTA automation package for both OTA platforms, four case runner, repeated web upload runner and per case summary), `output_button/` (pad edge timer header, button package and runner), `homeassistant_button/` (raw plaintext API client for action calls and wizard inputs, package and runner), `uart_button/` (UART0 loopback package, API driver with TX and RX counts, runner), `interval/` (plain and delayed interval package, timing summary and runner), `smp_udp_ota/` (SMP reset fault injector, OTA runner, per mode result check, MCUboot swap limit, running image check and slot blanking UF2, Thread device config), `ld2420_sim/` (fake LD2420 answering the hub's commands on a UART0 loopback, API state recorder, two-build comparer and runner), `nrf52_tcp_probe/` (TCP and UDP socket probe config, build and log check, API check over Thread), `rs485_frame/` (simulated DLE/STX/ETX bus device, frame and Line truncated
capture decoder, RTL8720C ROM dump versus report matcher), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer, frame stream harness with presence hold and link loss drivers, BT MAC driver), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (`ha_discovery/`, `disable/`, `retained_burst/` and `reconnect/` runners; broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner), `gpio_hold/` (ISR pin hold self test with ESP32 and S3 configs), `ble_event_inject/` (GAP and GATTC event injector header and proxy package), `light_sleep_wake/` (LEDC wave and edge timer for light sleep wakeup on a C3, external wave generator and per-call edge detail probe for peripheral power-down on the P4), `manual_ip/` (station and AP manual IP configs, DNS probe and runners), `web_handlers/` (late handler, web_server, captive_portal and prometheus variants, web OTA runner), `bk72xx_flash/` (flash readback with retries, per partition diff of two dumps), `ble_server_security/` (secure GATT server and pairing client configs, driver, summary and runner), `prometheus/` (datetime metrics configs and driver), `zephyr_ble_server/` (nRF52 server, looping ESP32 client and UF2 flash and count runner), `ble_adv_triggers/` (advertiser with six raw frames, ESP32 and BK72xx receivers with eleven triggers, runner and checker), `sun/` (longitude sweep package, runner and checker), `sensor_filters/` (every filter shape with a fixed input sequence, per-board packages and runner), `led_driver_sniff/` (SPI slave capture header with selftest, config generator, window comparer and runner), `hub_input_sim/` (keypad, wiegand and rdm6300 simulation on a D1 mini, API log runner), `value_range/` (on_value_range sequence with a restart, runner), `ezo_fake_bus/` (fake EZO I2C bus and step config, runner), `twai_self/` (TWAI self-reception header, on_frame config and counter runner), `ota_automations/` (OTA automation package for both OTA platforms, four case runner, repeated web upload runner and per case summary), `output_button/` (pad edge timer header, button package and runner), `homeassistant_button/` (raw plaintext API client for action calls and wizard inputs, package and runner), `uart_button/` (UART0 loopback package, API driver with TX and RX counts, runner), `interval/` (plain and delayed interval package, timing summary and runner), `smp_udp_ota/` (SMP reset fault injector, OTA runner, per mode result check, MCUboot swap limit, running image check and slot blanking UF2, Thread device config), `ld2420_sim/` (fake LD2420 answering the hub's commands on a UART0 loopback, API state recorder, two-build comparer and runner), `nrf52_tcp_probe/` (TCP and UDP socket probe config, build and log check, API check over Thread), `rs485_frame/` (simulated DLE/STX/ETX bus device, frame and gate cases, runner), `hx711/` (fake HXLine truncated
## Templates in `$SKILL/templates`
@@ -173,9 +169,6 @@ comment quotes the exact commits. Fetch `origin dev` before computing a merge ba
## When something looks wrong
- A harness header fails to compile on Arduino or LibreTiny: those frameworks define `millis()` and `micros()` as
macros, so call `(esphome::millis)()`, which builds everywhere. Realtek SDK headers also define `OFF`, `ON`
and `SUCCESS`.
- A capture stopped early or a line is missing: check the timestamps before trusting it.
- A script that pipes into `tee` never ends although its work is done: a background command written as
`(cd dir && server & echo $!)` backgrounds the whole `cd && server` list, so a shell stays behind holding the
@@ -24,11 +24,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
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.
- `Arduino.h` already declares `_heap_start`; a lambda can use `&_heap_start` directly, and a second `extern` fails.
- `Hardware Watchdog` with `wDev_ProcessFiq` in the stack means interrupts were held off too long for the Wi-Fi
interrupt handler; `Software Watchdog` with `pm_send_nullfunc` means the loop was busy too long. Code that takes
interrupt locks needs a load run to show either (`scripts/esp8266_lock_load/`, see recipes/one_wire.md).
- `GPI` bits 16 to 31 are strapping bits (mask with 0xFFFF). GPIO16 has no interrupt; read `GP16O` for an output.
## ESP32 classic devkit
@@ -45,8 +40,7 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
- 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. The full accept and reject case set
(several keys, CRC-only edits) is in `recipes/ota.md`.
refuses unsigned images afterwards, so restore with a signed plain build.
- An ESP32 whose bootloader has no app rollback is protected only by `safe_mode` after a bad OTA. Before sending a
change to such a device that nobody can reach by hand, boot the same code on a devkit first and keep a fallback
image of the merge base built.
@@ -58,7 +52,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
`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 USB console drops a line now and then even when it stays up; do not count events by log lines alone.
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry). The
Waveshare ESP32-P4-WIFI6 rev1.3 board is such pre-rev3 silicon.
- S3, C3 and C6 all enumerate as `303a:1001`; confirm the chip with `esptool --port <port> chip_id` before flashing.
@@ -154,8 +147,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
assuming either.
- `flash_bk72xx.py` misses the link now and then (`Timed out attempting to link with chip`); retry up to 3
times before suspecting the board.
- Both flash wrappers pass ltchiptool `-L` with the newest installed LibreTiny platform (`scripts/lt_platform.py`):
its default `~/.platformio/platforms/libretiny` can be an older copy that rejects BK7238 UF2s as unsupported.
- Crashes: `__builtin_trap()` and a call into unmapped memory both end as an undefined-instruction trap
(`bk_misc_get_start_type()` 6, `lt_get_reboot_reason()` Crash); reads, unaligned loads and stores to
unmapped addresses never fault on the ARM968. Without Wi-Fi the SDK trap handler spins with interrupts off
@@ -235,9 +226,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
- A 4-way handshake that never finishes after association is reported as `RTW_WRONG_PASSWORD` on AmebaZ
even with the right password; AmebaZ2 reports a real wrong password as a 4-way handshake timeout.
- The driver also posts a SCAN_DONE (number 0) inside every join, on AmebaZ2 too.
- RTL8710B GPIO interrupts fire on one edge only, and LibreTiny maps CHANGE to rising. The Arduino `digitalRead` called
from an ISR switches the pin back to GPIO and frees its interrupt (esphome#22400, #22403).
- The FreeRTOS tick is 1/1024 s, so `millis()` on the tick ran 2.4 % fast (esphome#22404).
## BW16 kit (RTL8720DN, AmebaD)
@@ -14,10 +14,10 @@ One file per component, named after it:
- [`ld2420.md`](ld2420.md): hub setup and sensor states with a simulated module on a UART0 loopback (energy and simple mode)
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar, presence hold and link loss on a frame stream, BT MAC readback
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx and RTL8720C crash report on the next boot with a ROM dump matcher, BK7238 BLE bond store safety with a full flash backup and per partition diff, BK7238 OTA versus the board layout and bootloader, pulse counting, RTL8710B edge interrupts and millis timing
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing, SIM7080G behaviour with no SIM and with no data plan through a raw UART passthrough
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx and RTL8720C crash report on the next boot with a ROM dump matcher, BK7238 BLE bond store safety with a full flash backup and per partition diff, BK7238 OTA versus the board layout and bootloader
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
- [`nrf52.md`](nrf52.md): dongle board support, zephyr_ble_server connection slot under reordered callbacks, SMP over UDP OTA over Thread with reset faults, MCUboot swap limit and stuck slot recovery, TCP stack probe, sdk-nrf 3.4.1 build and USB notes
- [`nrf52.md`](nrf52.md): dongle board support, zephyr_ble_server connection slot under reordered callbacks, SMP over UDP OTA over Thread with reset faults, MCUboot swap limit and stuck slot recovery, TCP stack probe
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
- [`ota.md`](ota.md): upload without a configuration and the refusal checks, overlap and lock, timeouts, deflate, upload timing A/B per build and board with deflate and raw, on-device inflate benchmark, OTA automations on two platforms
@@ -25,14 +25,12 @@ One file per component, named after it:
- [`scheduler.md`](scheduler.md): loop wake A/B, interval startup delay and control from lambdas
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save, soft AP clients with the BLE tracker scanning, ESP8266 power save after captive portal provisioning
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
- [`one_wire.md`](one_wire.md): ESP8266 interrupt locks under Wi-Fi load with a DS18B20 search and a busy-wait control
- [`cc1101.md`](cc1101.md): sleep and wake checks read directly from the chip (CHIP_RDYn, PATABLE loss, registers per setter)
- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls, wizard-linked entities on every platform, homeassistant switch on a script with a host build
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls
- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin
- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply
- [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player
@@ -63,7 +61,7 @@ One file per component, named after it:
- [`rs485_frame.md`](rs485_frame.md): DLE/STX/ETX bus on an ESP32-C3 with a cross-wired simulated bus device (frame cases, gate timing)
- [`hx711.md`](hx711.md): fake HX711 driven from the other core, every published value matched to the served conversion
- [`automation_trace.md`](automation_trace.md): automation codegen A/B, a marker trace of chains, conditions, loops and script modes over the API
- [`bluetooth_proxy.md`](bluetooth_proxy.md): held GATT link after a half-open API session, frozen Home Assistant stand-in, out-of-order GATT discovery responses injected on the proxy
- [`bluetooth_proxy.md`](bluetooth_proxy.md): held GATT link after a half-open API session, frozen Home Assistant stand-in
- [`crash_report.md`](crash_report.md): crash buttons, fetching the previous boot's crash report over the API, per-chip abort facts
- [`https_idf.md`](https_idf.md): web_server_idf HTTPS test component, listener switch, stalled TLS clients, bad key
- [`mdns.md`](mdns.md): legacy unicast mDNS dump, hammer and latency straight to the device
@@ -72,14 +70,3 @@ One file per component, named after it:
- [`microphone_http_stream.md`](microphone_http_stream.md): raw PCM over HTTP with a counter fake mic or a USB mic, multi-reader client
- [`i2s_audio.md`](i2s_audio.md): I2S microphone fed by an I2S TX channel on the same S3 through three jumpers
- [`voice_assistant.md`](voice_assistant.md): late TTS start on the Voice PE with a slow announcement URL
- [`mcp251xfd.md`](mcp251xfd.md): MCP2518FD CAN FD over SPI in loopback, forced modes, chip reset and bus-off recovery, TX objects read back from chip RAM, idle SPI cost from a PCNT on CS
- [`crc32.md`](crc32.md): crc32 vectors on every platform path and the signed OTA signature block CRC on an ESP32-C3
- [`i2c_isr_fault.md`](i2c_isr_fault.md): software INA219 on the master's own pads with seeded bus faults, interrupt storm and stuck bus
- [`bmp390.md`](bmp390.md): BMP390/BMP388 register readback, INT data ready rate, start write faults by software NACK or held SDA, and the IDF I2C hang after a held SDA
- [`ipv6.md`](ipv6.md): IPv6 link-local and SLAAC boot and reconnect timing on ESP32 Wi-Fi, with an injected esp-netif delay that forces the race
- [`mcp23017.md`](mcp23017.md): MCP23017 IOCON aliasing and INT line behavior (open drain, push-pull, polling) on an ESP8266 with PA to PB jumpers
- [`pwm_capture.md`](pwm_capture.md): every LEDC PWM period captured on the same ESP32-C6 through RMT receive and PCNT, with and without power save
- [`spi.md`](spi.md): SPI bus capture on the same board with no chips (ESP32 SPI3 slave, edge interrupt snapshots on C3, ESP8266 and Pico 2 W), checked and diffed between builds
- [`heap_alloc.md`](heap_alloc.md): operator new counting, and a ring of allocation callers decoded with addr2line to tell the first run after boot from repeats
- [`wled.md`](wled.md): WLED realtime sender on a board's own access point
- [`combination.md`](combination.md): combination sensor NaN behaviour from the last source
@@ -56,9 +56,3 @@ include of random base64 lines with `include_secrets: true` gives four chunks (1
0bd3738430: `include_secrets: true` fails the build when a package in another directory uses `!secret` and has no
`secrets.yaml` beside it; a copy next to the package works around it.
Results: https://github.com/esphome/esphome/pull/16445#issuecomment-6335435397
## Allocations of a connecting client (ratgdo/esphome-ratgdo#705)
An API client that connects allocates about 15 times on ESP32: the connection object, then the list entities and
initial state batches growing the `DeferredBatch` vector. A log subscription adds more per line. Connect before a
heap measurement and subscribe to logs only afterwards (`heap_alloc.md`).
@@ -42,7 +42,6 @@ comparison accepted automatically) and a classic ESP32 devkit runs `esp32central
phase B reads the MITM characteristic with no numeric comparison and no `Deleting unpaired device` line.
- On ESP-IDF 5.5.6 every other reconnect pairs again (`Deleting unpaired device` on the disconnect after a bonded
reconnect), the regression #22081 describes.
#22559 fixes it: the bond survived 15 reconnects and a reset of both boards with no new pairing.
## Bluedroid hex parsing bench (#22081)
@@ -26,32 +26,3 @@ swap fails when a round never connects. An owner that never got the link also fa
- Before #22077, case H holds the link for good: 0 adverts, and the address stays in the slot report. With #22077 the
link is released in the same millisecond as the `replaces` warning. In case 4 the first request logs `Connection
request ignored, state: DISCONNECTING` (connected=false at 0.2 s) and the retry connects at 0.5 to 0.7 s.
## Out-of-order GATT discovery responses (#22518)
Some peripherals answer a characteristic discovery (Read By Type) with records out of handle order. ESP-IDF 5.5.6
rejects that (`gatt_process_read_by_type_rsp invalid handle ...`) and ends the whole discovery; 5.5.5, Android and
BlueZ accept it. `scripts/att_reorder/` reproduces it with no such device:
- `server.yaml` (ESP32-S3) serves `FFF0` with six characteristics, then `1826`.
- `proxy.yaml` (classic ESP32, `idf` substitution; keep per-build files such as `example-proxy.yaml` in the same
directory) links `components/attswap`, which wraps Bluedroid's
`l2c_rcv_acl_data` (`-Wl,--wrap`); `attswap.hpp` swaps the first two records of the response whose first record
declares `FFF1`, and logs a swap count.
- `gattget.py PROXY PEER_MAC ROUNDS` reads the services through the proxy like Home Assistant (no cache);
`run.sh PROXY_YAML [ROUNDS]` flashes both boards and prints the services per round and the discovery errors.
On 5.5.6 Home Assistant gets `fff0` with no characteristics and no `1826`, while the proxy still logs
`Service discovery complete` (the proxy ignores the error status); on 5.5.5 every service arrives. To find the order a
real device uses, decode an Android HCI snoop: a Read By Type response lists its records in the order sent.
- Handles, reads and notifies after a reordered response: flash `server-notify.yaml` (`SERVER_YAML` on `run.sh`) and run
`gattcheck.py PROXY PEER_MAC`. It prints every service, characteristic and descriptor handle, reads FFF1, FFF2 and
FFF3 by the received handles (each holds its own value), and enables notify on FFF1, FFF2 and 2ACD. Compare the table
with a run of a proxy built without `attswap`. On ESP-IDF 5.5.5, which accepts the unsorted response, FFF1's
descriptor range runs over FFF2's declaration, value and CCCD, so FFF2 has no CCCD (#22576).
- `bluetooth_gatt_start_notify` only registers the notify on the proxy; the client writes the CCCD itself, as Home
Assistant does. Without that write no notification arrives.
- Give the proxy an `api` encryption key (`API_KEY` for the scripts). A Home Assistant on the same network connected to
a plaintext test proxy and took over its Bluetooth subscription (`Subscription from Home Assistant ... replaces
aioesphomeapi`), dropping the test client's connection mid-run.
@@ -1,35 +0,0 @@
# BMP390 / BMP388 (bmp3xx_i2c): register readback, data ready rate, start write faults (#21632, #21633)
Bench: ESP32-S3 devkit (native USB console, ESP-IDF), BMP390 breakout on 3V3, SDA GPIO1, SCL GPIO2, CS and SDO open
(address 0x77), INT to GPIO42. Some breakouts answer chip ID 0x50 (the BMP388 value); the register map is the same.
`scripts/bmp390/`:
- `gen.py NAME [--rate 50Hz] [--iir 128x] [--interval 1s] [--fault --kinds ... --probe-delay MS] [--reset-at 40,80]
[--open-line] [--bus-reset]` writes `NAME.yaml` (no Wi-Fi).
- `bmp390probe.hpp` wraps the sensor's I2C bus (`set_i2c_bus()` from an `on_boot` priority 800 lambda), dumps
CHIP_ID, ERR, INT_CTRL, PWR_CTRL, OSR, ODR and CONFIG before and after setup and every 10 s (`REGS` lines), turns on
data ready on INT after setup (`INT_CTRL = 0x42`, push-pull, active high: the driver never writes INT_CTRL and its
soft reset in setup clears it), counts rising edges (`INT edges=N in M ms`) and logs every pressure value with four
decimals (`READ <ms> <hPa>`).
- `run.sh NAME SECONDS [TAG]` (env `TREE`, `PORT`, `SC`, `PY`) builds, flashes, captures from a fresh boot and runs
`stats.py CAPTURE [SKIP_S]`: read count, cadence, INT rate, detrended noise (21 sample window), repeated values.
Flows:
- Register A/B: the same config on both builds; compare `REGS` after setup and between updates. Forced mode leaves
PWR_CTRL at 0x03 (sleep, both sensors enabled), normal mode reads 0x33. ODR codes: 0x00 200 Hz, 0x02 50 Hz, 0x07
1.5 Hz, 0x0B 0.1 Hz (period 5 ms << code).
- Real output rate: INT edges per 10 s window. Forced mode gives one edge per update.
- Settling: the bench cannot make a pressure step, but the soft reset in setup restarts the IIR filter with an
offset, so the first readings after boot show its step response (50 Hz with 128x settled in about 8 s).
- Reset behind the driver: `--reset-at` writes CMD 0x7E = 0xB6 at those seconds; a driver that handles it logs that
the sensor was reset and restores the registers.
- Start write faults (`--fault --kinds`, one letter per event at 30 s and then every 20 s): `N` the next start write
NACKs in software, `M` every start write NACKs for 5 s, `T` SDA held low through the GPIO matrix for the next start
write, `W` the same for every transfer for 5 s, `L` a continuous 5 s hold, `S` a 300 ms hold, `H` and `D` held
transfers of the harness only. `PROBE` lines check the bus `--probe-delay` ms later.
Pitfall: on the S3 with ESP-IDF 5.5.6, any transfer that fails while SDA is routed to a GPIO held low (or routed away
with the line floating high) leaves the `i2c_master` driver hung on every build, with or without the sensor: every
later transfer logs `I2C software timeout` while the lines read idle high and the saved pad registers are back, and
`i2c_master_bus_reset()` does not help. Use the software NACK kinds (`N`, `M`) to test what a component does after a
failure and the recovery; use the wire kinds only to show the first failure.
@@ -1,17 +0,0 @@
# cc1101
## Sleep and wake checks read from the chip (#22519)
`scripts/cc1101_sleep/`: `cc1101-sleep.yaml` runs an ESP32-S3 (ESP-IDF) with an E07-M1101D module on SPI and runs
tests T0 to T6 4 s after boot; `cctest.hpp` reads every config register and the PATABLE directly over SPI. The log
ends with `DONE fails=N`, and T6 prints whether each setter woke a sleeping chip.
- Proof that the chip sleeps: with CSn low, SO (MISO) stays high until the crystal runs, so it is high after
`power_down()` and low at once when awake. PATABLE entries 1 to 7 are also lost in SLEEP, so write marks to them
before `power_down()` and check that they are gone.
- Selecting the chip to check it wakes it, so after the check the chip is awake while the component still thinks it
sleeps. Call `wake_up()` and `begin_rx()` before the next case.
- Leave FSCAL3 to FSCAL1 (0x23 to 0x25), FSTEST (0x29) and AGCTEST (0x2B) out of register comparisons: calibration
and the chip itself change them.
- Results: on 7e6b90d08b, `set_frequency`, `set_channel` and `set_modulation_type` woke a sleeping chip and left it
in RX. 9e65d0c327 fixed that; every other check passed on both, and 200 sleep and wake cycles took about 1 s.
@@ -1,7 +0,0 @@
# combination
## NaN from the last source (#22510)
A combination sensor defers each update and uses the value of the source that published last. A NaN from that
source drops the whole update, so when testing NaN handling keep the last-publishing source finite, and compare
rows one update late. Heap churn per update: see `heap_alloc.md`.
@@ -1,13 +0,0 @@
# crc32 helper and the signed OTA block check (esphome#22398, 2026-11-07)
`scripts/crc32/crctest.hpp` checks `esphome::crc32()` against Python `zlib.crc32` values: `"123456789"` = `0xCBF43926`,
empty input returns the seed, chained calls (4 + 5, 300 one-byte calls, 97-byte chunks from an odd offset), bytes
0..255, 32 × 0xFF, 4093 bytes from offsets 0 to 3, and 8 KiB timed with `(esphome::micros)()` (best of five).
`common-crc.yaml` runs it at boot and every 20 s and also makes an unqualified `crc32(...)` call from a lambda, which
catches a clash with anything the generated `main.cpp` pulls in. Paths and timings seen (8 KiB): ESP32 ROM 512 µs,
ESP8266 PROGMEM nybble table 3072 µs, RP2350 932 µs, BW15 (FlashDB) 656 µs, BW12 2000 µs, BK7238 1026 µs.
The signed OTA signature block check (`block_is_valid()`), the PR's only converted caller, ran the accept and reject
cases in `ota.md` (signed OTA with several trusted keys).
Results: https://github.com/esphome/esphome/pull/22398#issuecomment-6716330334 and #issuecomment-6716545831.
@@ -13,6 +13,8 @@ replies.
- `run.sh LABEL` builds, flashes, captures 90 s and writes `out-ezo<LABEL>.events`. Compare dev and the PR: the
replies, callback texts, read values and the wait before each read (912 ms for reads and calibration, 304 ms for
the rest) must match; differences in queue limits are the PR's intended change.
- Call plain `millis()` in a fake: Arduino builds define a `millis()` macro (`esphome::millis()`), so
`esphome::millis()` breaks there, and on ESP-IDF the plain name resolves through `using namespace esphome`.
- Found with it: dev read past its 32-byte buffer on an unterminated reply (a custom callback got 56 bytes), and
two queued address changes both went to the first new address.
- Results: https://github.com/esphome/esphome/pull/21840#issuecomment-6493565259
@@ -1,41 +0,0 @@
# Heap allocation counts and callers (#22509, #22510, ratgdo/esphome-ratgdo#705)
## Counting
Replace the global `operator new` in an `esphome: includes:` header on ESP32 (Arduino or ESP-IDF) and log the
delta from an `interval`. Compare a PR build with its base on the same flow; the rate per second shows churn.
- The ESP8266 core defines `operator new` non-weak. `-Wl,--wrap=_Znwj` worked in a sensor-only config but
boot-looped a neopixelbus config (alignment fault in the wrapper), so report free heap only there.
- `vec = {}` keeps a std::vector's capacity (it is an initializer_list assignment); only
`std::vector<T>().swap(vec)` or `clear()` plus `shrink_to_fit()` frees it. Seen as heap that did not come back
after a WLED effect stopped.
## Callers
`scripts/heap_alloc/`:
- `allocring.hpp` counts every `operator new` and, once armed, keeps the first 1024 with their size, time and
five caller PCs, about 28 KB of RAM. It disarms itself when full. The frame walker
(`esp_backtrace_get_next_frame`) exists only on Xtensa (ESP32, S2, S3); on RISC-V chips entries keep size and
time without callers. Aligned `operator new` (`std::align_val_t`) is not replaced, so it is not counted;
nothrow `new` goes through the replaced one.
- `allocring.yaml` (package, substitution `alloc_arm_s`) arms it that many seconds after boot and adds a
"Dump allocations" button that logs what was kept. The press stops recording until the next reboot, so
reboot (or reflash) before each run; a second press returns the same entries.
- `alloc_marker(a, b)` from a lambda writes a marker entry, for example on each cover operation change, so the
dump splits into phases.
- `dump.py HOST OUT` presses the button and writes the dump to a fresh OUT, pressing again until the header
and every entry arrived (a late log subscription or dropped log lines). `decode.py LOG ELF [ADDR2LINE]` prints each entry with its callers as function and file:line, and exits 2
when the recorder never armed, an entry was still being written, entries are missing, repeated or out of
range, no Xtensa entry has a caller, the ELF is from another build (the buffer address in the header differs), or the buffer
filled. A PR build and its base can share that address, so copy each build's `firmware.elf` next to its dump. On RISC-V it prints sizes and times only. It reads only the newest dump in the file. The ESP-IDF ELF is `.esphome/build/<name>/build/firmware.elf`.
- `cycles_cover.py HOST N OUT` waits for the "Allocation recorder armed" binary sensor, then moves the first
cover to the far end and back N times (`cover_command(position=...)`, waiting for idle each way) with a states-only API client, then saves the dump the same way. It exits 2 when a move
never reached its position.
Findings:
- Arm well after boot: the scheduler regrows its storage once after the freelist trim (see `scheduler.md`).
- Repeat the action several times in one session and count only what comes back. On a ratgdo cover (an external
component, ESP32 ESP-IDF) the first door open after boot allocated 18 times: four 36 B SchedulerItems,
`to_add_` and `items_` growth, and the API `DeferredBatch` vector. Cycles 2 and 3 allocated nothing.
- Keep the API client out of the count (see `api.md`): connect before the run, subscribe to states only, and
subscribe to logs only for the dump.
@@ -45,44 +45,3 @@ without the fix logs `Applying the report that arrived during the transition` an
ends at 100% on both builds because the last call's target differs from the held report, so it is a regression
check only, not a reproduction. Separate button presses are too slow (about 1.5 s each) to land inside the fade.
The mirrors are not API entities, so read their state from the "Log state" button's line. Results: https://github.com/esphome/esphome/pull/21144#issuecomment-6278257695
## Wizard-linked homeassistant entities on every platform (#21908)
`scripts/homeassistant_wizard/`: `ha-all-common.yaml` adds to the buttons of `ha-button-common.yaml` one entity with a
fixed `entity_id` and one linked to an `api: wizard:` input for each of sensor (plus one with `attribute:`),
binary_sensor, text_sensor, number, switch, select, text and light (the wizard light is in `ha-wiz-light.yaml`), and
logs the ESP8266 heap every 5 s (`ramchk`). `hadrive.py HOST` plays Home Assistant over a plaintext API connection: it
records the state subscriptions the device asks for, sends states (including number and text min, max, step and mode,
select options and light supported_color_modes), commands every writer, sets each wizard input with
`WizardInputSetRequest` (key = FNV-1 hash of the input id), changes it to a second ID in another domain, sends an
invalid one and reconnects. It prints SUB, STATE and ACTION lines that diff cleanly between two builds, and PASS or
FAIL. `run.sh LABEL` (env `TREE`, `PORT`, `SC`, `PY`; `NO_LIGHT=1` for a build without #21908) flashes a D1 mini, takes
three idle boots of heap readings and drives one boot. `lightcheck.py HOST` checks again that the wizard light follows
only its current ID.
- Builds before #21908 cannot compile a wizard-linked homeassistant light (the wizard called `set_entity_id()` on the
LightState); `NO_LIGHT=1` leaves `ha-wiz-light.yaml` out.
- An unset wizard number or select rejects a command in the core (`> max 0`, `has no options`) before the
`No entity ID set yet` check; nothing is sent either way.
- Field 3 of LightStateResponse is brightness, not `missing_state`; SwitchStateResponse has no missing flag.
- A light publishes only changes, so a report of the state it already has publishes nothing.
## homeassistant switch on a script, host build against a throwaway Home Assistant (#22548)
No board is needed when the change is in validation or platform independent code: an ESPHome `host:` build serves the
API on 127.0.0.1:6053. `scripts/homeassistant_script_switch/`:
- `ha-configuration.yaml` is the Home Assistant config (port 8129, a 20 s `long_job` script that sets and clears
`input_boolean.script_ran`, a `quick_job` toggle script). Onboard it with `scripts/mqtt/ha_discovery/onboard.py DIR`.
- `hsw.yaml` mirrors both scripts and the flag with homeassistant switches, with template buttons that call
`switch.turn_on` and `switch.turn_off`.
- `haflow.py TOKENFILE` adds the device with the esphome config flow and turns on `allow_service_calls` in the options
flow; without it the device's action calls are refused.
- `drive.py TOKENFILE NAME` presses the buttons through the REST API and prints the script and flag states after each
step.
Notes:
- homeassistant import entities default to `internal: true`, so the mirrors are not Home Assistant entities; read
them from the device's `Got state` log lines.
- Turning a script off stops it mid run (the flag stays on). A script that finishes at once reports on, then off, in
the same second.
@@ -1,20 +0,0 @@
# I2C master interrupt storm and stuck bus faults (esphome#22270 / #22396, 2026-11-06 to 11-07)
`scripts/i2c_isr_fault/inasim.hpp` is a software INA219 at 0x40 answered on the ESP32 master's own pads (SDA GPIO32,
SCL GPIO33, nothing wired): the slave pulls a line low by switching the pad's output mux from the I2C0 signal to plain
GPIO output 0 (a wired AND with the master), from a GPIO ISR on core 0 that spins through one transaction. A core 0
task re-asserts long stuck lines every tick. From 20 s after boot it injects seeded faults into `p_fault` per mille of
transactions (garbage bits, NACK, clock stretch, SCL/SDA held low up to `hold_max_ms`, glitches, vanish).
`i2cbase.yaml` (package): `i2c:` at `freq` with `scan: true`, an `ina219` polled every `ina_interval`, a bus voltage
register read every `hammer`, a boot self test, and 30 s `MASTER`/`SLAVE` counter lines (ok, timeouts by kind,
garbage, longest failed run, loop stalls). `example.yaml` shows the substitutions: framework and version (`fw`,
`fwver`), `seed` (seed 1 at 5 % hangs dev with an interrupt watchdog on CPU0 at about 245 s, the #22270 report),
`p_fault`, `fault_mask`. `run.sh LABEL [SECONDS]` builds, flashes and captures (640 s default); `summary.sh` prints
crashes, watchdogs, timeout kinds and the minimum ok reads per window.
Findings: ESP-IDF 5.4.2 has a different failure (the driver waits for bus idle with no timeout after a NACK, so the task
watchdog reboots in a loop with or without the guard); 5.5.5, 5.5.6 and 6.0.1 hang on the storm without the guard and
run clean with it. ESPHome never uses the driver's async mode, so there is no asynchronous path to test.
Results: https://github.com/esphome/esphome/pull/22396#issuecomment-6713396173
@@ -1,28 +0,0 @@
# IPv6 link-local and SLAAC on ESP32 Wi-Fi: boot timing and race injection (#22364)
`scripts/ipv6_boot/` (ESP32-S3 and ESP32-C3, native USB console, ESP-IDF). One YAML per build
(`example-s3.yaml`, `example-c3.yaml` show the shape) with the substitutions `min_count` (network
`min_ipv6_addr_count`) and `cap_ms`.
- `v6probe.hpp` and `v6common.yaml`: `network: enable_ipv6`, VERBOSE logs, and a 50 ms interval that logs milestones
from boot (`MS assoc|ipv4|linklocal|slaac|connected <ms>`; addresses come from `esp_netif_get_all_ip6`, so they
count once they are preferred, after duplicate address detection), then `RESULT boot ...` with `heap_used`, then
disables Wi-Fi for 3 s and logs `RESULT reconnect ...` the same way, then reboots. A phase ends when SLAAC and the
connection are both seen, or after `cap_ms` (`timeout=1`).
- `v6hf.yaml` adds a `HighFrequencyLoopRequester` (a busy main loop, as with a display or audio).
- `v6inject.yaml` links with `-Wl,--wrap=esp_netif_action_connected` and defines `V6_INJECT`; the wrapper in
`v6probe.hpp` sleeps 300 ms in the event task before esp-netif brings the station interface up, after the wifi
component already queued STA_CONNECTED. That makes the "interface not up yet" race happen on every connect.
- `run.sh NAME SECONDS` (env `TREE`, `PORT`, `SC`, `PY`) builds `NAME.yaml` as device `NAME`, flashes, captures the
self-rebooting boots and runs `an.py CAPTURE...`: per phase the `esp_netif_create_ip6_linklocal failed` count,
boots without link-local, SLAAC or a connection, connection timeouts, and median and worst times.
- Check the network first: a computer on the same network shows an `autoconf` IPv6 address when a router advertises
a prefix. SLAAC arrived about 2 s after link-local on the test network.
- The natural race did not show: no failure in about 160 boots on the S3 (normal and busy loop) and the C3 without
the fix. esp_event runs handlers in registration order and the wifi component registers before esp-netif, but the
event task reaches esp-netif's handler within microseconds. Only the injected delay reproduced the failure.
- An extra event handler registered from `on_boot` cannot inject the race: it runs after esp-netif's handler, when
the interface is already up. Wrap the esp-netif call instead.
- `wifi.disable` removes the addresses, so the reconnect phase creates link-local again on the same path.
- Count failures by the probe's `ll=0` too, not only by the warning: the USB console drops lines.
@@ -160,21 +160,3 @@ auth frames.
Known LibreTiny behaviour seen here: after a join with a BSSID and channel, LibreTiny's partial-scan reset leaves
the next SSID-only join with nothing to scan (`RTW_NONE_NETWORK` in about 18 ms). That is a firmware bug, not
the board; passing channel 0 avoids it.
## Pulse counting and edge interrupts on LibreTiny (esphome#22391, #22400, #22403, #22404, 2026-11-06 to 11-07)
`scripts/libretiny_edges/`:
- `pulse-bk.yaml` + `pulsegen.hpp` (BK7238, generator pins `GEN_PIN_A`/`GEN_PIN_B` jumpered to the counted pins) and
`pulse-rtl.yaml` + the same header (BW15, mbed `pwmout` on `GEN_PIN_NAME`): one pulse shape per 35 s stage, from
BL0937-like 38 µs pulses at 1 kHz up to 50 ms, counted by `pulse_counter` with `inverted` and filters as
substitutions; `meas` lines give count per stage. Found: Beken counted half of BL0937 pulses (readings halve, so a
calibration note was needed), RTL8720C counted none, RTL8710B none with inverted pins, LN882H counted correctly.
- `bw12-base.yaml` with two pin layouts (`common-bw12.yaml`, `common-bw12-b.yaml`) + `bwdbg.hpp` (BW12, RTL8710B): edges
without wiring by toggling the pad pull on floating pins (`PAD_PullCtrl`), `-Wl,--wrap=digitalRead` to count ISR
versus task reads, and a pin/IRQ register dump; `summ.py` summarizes. A jumper IO12 to IO13 on the jig gave a real
driven edge.
- `common-bw12-time.yaml`: `millis()`/`micros()` every 5 s against host time, a DWT cycle bench per call (also under
`InterruptLock`), and a mask ROM dump of `SYSTIMER_TickGet`.
Results: #22400 https://github.com/esphome/esphome/pull/22400#issuecomment-6712520146, #22403
https://github.com/esphome/esphome/pull/22403#issuecomment-6712901242, #22404
https://github.com/esphome/esphome/pull/22404#issuecomment-6713095035.
@@ -1,17 +0,0 @@
# MCP23017 IOCON and interrupt line (#22232)
Bench: an ESP8266 (Feather HUZZAH) with an MCP23017 breakout at 0x27, SDA GPIO4, SCL GPIO5, INTA to GPIO14, INTB to
GPIO13, PA0 jumpered to PB0 and PA1 to PB1, no extra pull-ups (the breakout's were enough at 100 kHz). The esptool
reset keeps the expander powered, so a reflash shows whether register bits from the previous build survive.
`scripts/mcp23017/`: the `mcptest` external component (`ext/mcptest`) reads IOCON at 0x0A and 0x0B 6 s after boot,
toggles PA0 and PA1 20 times, samples the INT levels right after and 200 ms after each write, counts INTB edges in
an interrupt, checks the PB binary sensors (`SUMMARY` line), then checks whether IOCONB aliases IOCONA
(`IOCONB_TEST`). `int.yaml` wires INTA to the hub's `interrupt_pin`, open drain or push-pull by the `open_drain`
substitution (variants `int-od` and `int-pp`); `no-int.yaml` has no interrupt pin and counts INTA edges in
`mcptest`. `run.sh VARIANT LABEL` (env `TREE`, `PORT`, `SC`, `PY`) builds, flashes and captures 14 s.
- In BANK=0, 0x0A and 0x0B are the same register: a write to one reads back from the other.
- A push-pull INT line works without a pull-up. Open drain with no pull-up stays low after the first interrupt
(ESP8266 INPUT), so the hub loop never stops and the inputs still update by polling.
- Builds before #22232 leave ODR and MIRROR set across reflashes.
@@ -1,33 +0,0 @@
# MCP2518FD CAN FD over SPI (esphome#21951, 2026-11-07)
Bench: ESP32-PICO-D4 (`board: pico32`, ESP-IDF) wired to an MCP2518FD module with an ATA6563 transceiver: SCK GPIO25,
SDI GPIO26, SDO GPIO27, nCS GPIO32, INT GPIO33, module 3V3/GND, CLK pin to GPIO34. No second CAN node, so everything
runs in `LOOPBACK`, or with the controller forced into internal loopback behind the driver.
- **Read the crystal.** The module's can is marked `H20.00` (20 MHz) even though listings say "8 Mbps"; the PR default
is `40MHZ`. Loopback never leaves the chip, so a wrong `clock:` still passes at half the configured rates. Measure it:
`pulse_counter` on the CLK pin with `internal_filter: 0us` reads SYSCLK/10 (2 MHz) at reset. Older heads wrote 0 to
`OSC` byte 0, which set `CLKODIV` to /1 (CLKO = 20 MHz, still countable by PCNT).
- `scripts/mcp251xfd/canlb.hpp`: stream checker (one frame in flight, burst of 8 every 64th; standard/extended IDs, FD
with and without BRS, every 4th classic, DLC 0 to 15, sequence in the payload; starts 5 s after boot so the
`dump_config` stall cannot trip the 50 ms in-flight timeout), a queue checker for bursts, RTR and TX objects, raw SPI
register access on the driver's own `SPIDevice` (`rd32`, `wr8`, `chip_reset` = `0x00 0x00`), forced modes
(`force_mode` writes `CON+3`; `force_via_config` goes through Configuration so the chip accepts it), `watch_start`
(polls `CON` every 2 ms until `OPMOD` and `REQOP` equal the target, logs the time), `txcheck` (reads each TX object
back from chip RAM after a frame full of 0xAA, padding included), `burst` (sends N frames in one call so the loop
cannot drain RX: FD depth 20, CLASSIC depth 32 + one overrun) and a PCNT unit on the CS pin, which counts SPI
transactions while the pin stays an output (`cs_mark`/`cs_report`: idle cost per second).
- `common-can.yaml` is the package; each config adds its `canbus` block and a `phases` script: `can-loopback.yaml`
(stream, RTR, TX objects, idle cost, chip reset, two refused `REQOP` writes), `can-classic.yaml` (CLASSIC with a
forced internal-loopback window: force once to trigger the driver's recovery, force again right after it restores,
then burst 48 and RTR inside the next 5 s check interval), `can-normal-busoff.yaml` (no node acknowledges, so the
controller cycles error passive/bus-off; each forced fault and reset waits for TREC `TXBO`).
`run.sh CONFIG LABEL [SECONDS]` builds, flashes, captures and prints the summary lines.
- Gotchas found: a refused `REQOP` (LISTENONLY or Restricted from LOOPBACK) leaves `OPMOD` unchanged and stalls TX;
the chip refuses LOOPBACK to CLASSIC directly (go through Configuration); after a chip reset TREC reads `0x00200000`
(`TXBO` set in Configuration mode), so a driver that skips recovery on bus-off never recovers a reset during an error
state. Neither a reset nor a refused request sets an interrupt flag, so detection waits for the periodic check.
Results: https://github.com/esphome/esphome/pull/21951#issuecomment-6715452893 (first run),
#issuecomment-6716105991 (TX objects, idle cost), #issuecomment-6716741859 (reset during bus-off bug),
#issuecomment-6716957543 (fixed).
@@ -16,18 +16,3 @@ states, saves the device log). Before 8cc8df4a1f a modem only config with `api:`
config dependent `AUTO_LOAD` added `network` after the dependency check; an explicit `network:` block was the
workaround. Results: https://github.com/esphome/esphome/pull/6721#issuecomment-6347395159 and
https://github.com/esphome/esphome/pull/6721#issuecomment-6348520416
## SIM7080G on an M5Stack Atom Lite (#6721)
The M5 Unit CatM (SIM7080G) on the Atom Lite's Grove port: RX GPIO32, TX GPIO26, `model: SIM7080`.
`templates/modem/modem-passthrough.yaml` is a raw UART passthrough with both directions logged, and
`scripts/modem/mdrive.py HOST LOGFILE text:AT+CPIN? wait:5 ...` sends AT commands through it and saves the
replies, to check what the module does with no component in the way.
- With no SIM, the component sets the `SIM not ready` warning and polls `AT+CPIN?` once a second.
- With a SIM that has no data plan, `AT+CGATT=1` blocks the AT channel for about 141 s and then answers `ERROR`.
The module answers nothing meanwhile, so the component logs `Network attachment request refused` and then `Modem not
responding`, and loops every 2 minutes 22 seconds.
- `AT+CFUN=1,1` leaves the module silent for about 2.5 minutes. It comes back without a power cycle.
- A module left in CMUX (`AT+CMUX=0`, then an ESP restart) recovered 10 of 10 times (`Autodetect mode: 5`).
- PPP and a warm reboot during PPP need a SIM with a working data plan.
@@ -101,14 +101,3 @@ once on a build without TCP, or `tcp ok udp ok` twice and no error with it. Add
come back, and connect with `apicheck.py ADDR` over Thread. Measured on the ItsyBitsy: 12.9 KB less flash and 17.1 KB
less RAM without TCP (Zephyr's default pools, 16/16 instead of 24/48), and SMP over UDP OTA still works with them.
Results: https://github.com/esphome/esphome/pull/21910#issuecomment-6518287139
## sdk-nrf 3.4.1 on the ItsyBitsy (#22233, #22558)
- Set `framework: version: 3.4.1` and `board: adafruit_itsybitsy`.
- Trees before #22233 include `<nrf.h>` in `logger_zephyr.cpp`, which 3.4.1 no longer has on the include path, so
Adafruit bootloader boards fail to build (`nrf.h: No such file or directory`). To test such a tree on 3.4.1, change
that include to `<nrfx.h>` locally.
- On the new USB stack, `usb_cdc_acm` bulk echo ran at about half the legacy rate (67 against 121 KiB/s for 64 KiB),
and `flush()` returned `3`. The logger, DTR, line coding and the 1200 baud touch worked.
- The zephyr_ble_server flow above, on 3.4.1: before #22558 advertising failed to restart after the first disconnect
(`Advertising failed to start (rc -12)`); with it the server took 39 connections in 120 s.
@@ -1,32 +0,0 @@
# one_wire
## ESP8266 interrupt locks under Wi-Fi load (#22452, #22453)
Code that holds interrupts off for long on the ESP8266 starves the Wi-Fi interrupt handler (reset signatures in
`reference/boards.md`). `scripts/esp8266_lock_load/` is the shared harness for any component that takes such locks:
- `base.yaml` is the shared DUT base: reset reason and info logged at boot, `power_save_mode: none`, `web_server` for
the HTTP load, `api: reboot_timeout: 0s`.
- `one-wire.yaml` reads a DS18B20 on GPIO4 every 2 s and runs a bus search every `search_interval`; `busy_control:
"true"` replaces the search with the same busy time (13 ms) and no 1-Wire traffic.
- `ir-one-pin.yaml` is the remote_transmitter case (see [remote_base.md](remote_base.md)).
- `wifi_load.py HOST SECONDS PPS` sends 512 byte UDP datagrams to port 9 at a fixed rate plus back to back GETs of
the web_server page.
- `run.sh` builds and flashes a DUT, captures the serial log, runs the load and prints every reset reason (usage in its
header). A clean run has one BOOT line.
Make one file per build with `substitutions: {name: ...}` and `packages: {t: !include one-wire.yaml}`, with
`secrets.yaml` next to it, and give dev and the PR different names.
Notes:
- Run each build for 45 minutes at 500 packets per second. Shorter or lighter runs often show no reset on either
build: one search every 5 s survived on both.
- The busy-wait control separates the lock from the CPU time. On #22452 it had no Hardware Watchdog in 45 minutes,
while the PR still had 2 (dev had 6), so the remaining resets came from the slot locks. All three builds had one
Software Watchdog, which is the busy time plus the load.
- A search with no sensor answering is much shorter than one with a sensor (about 190 slot locks per search with
one DS18B20), so test with a real sensor on the bus.
- A stale `secrets.yaml` gives `No networks found` and the run has no IP to load.
- The 1-Wire bus code is shared by every platform, so check a lock change on an ESP32 too: the same DS18B20 search
config on ESP-IDF with `bluetooth_proxy` scanning and the same load gave 0 resets and no missed searches on dev and
on #22452.
@@ -47,6 +47,7 @@ each ring size at least the encoder window, three times, and logs the best time
`BENCHDONE`. `bench.sh CONFIG DEVICE` builds and uploads `bench-esp32s3.yaml` or `bench-bk72xx.yaml`, collects one pass
over syslog and fails when a case did not reach the end of its stream.
- LibreTiny defines `micros` as a macro, so the header calls `(esphome::micros)()`.
- Keep the streams near 130 KB on a BK7231N: an image with about 330 KB of extra incompressible data failed its
OTA with "Writing OTA data to flash memory failed". Use one image and `--windows 12` there.
- `bench.hpp` targets the decoder interface with `dest_limit`. #21641 drops it (the output runs into the window up
@@ -83,24 +84,3 @@ case's fires in order with progress collapsed to a count; diff dev against the P
- A random file through web_server gives `error 128` on the ESP32 and `error 132` on the ESP8266.
- A SIGTERM to the uploader lets it finish the transfer; use SIGKILL for the native error path.
- Results: https://github.com/esphome/esphome/pull/21903#issuecomment-6516529409
## Signed OTA with several trusted keys (`verification_keys`, #22398)
`scripts/signed_ota/` on an ESP32-C3 (`signed-ota-c3.yaml`, `signed_ota_verification: signing_scheme: rsa3072,
verification_keys: [keyA.pem]`):
1. `python -m espsecure generate-signing-key --version 2 --scheme rsa3072 keyA.pem` (and keyB).
2. Build four tagged images with `esphome -s tag <name> compile`, serial-flash the first factory image (no on-boot
signature check without secure boot), copy each `firmware.ota.bin`.
3. `sign_variants.py <image> keyA.pem keyB.pem <prefix>` writes key A, key B, B then A, stored CRC flipped, byte 2
changed (covered only by the CRC), the same with the CRC recomputed by zlib, and B then A with block 1's CRC flipped.
4. `otaseq.sh <ip> FILE=reject... FILE=<tag>...` sends them with `esphome upload --file` and checks the first verdict
in the device log and the upload result against the expectation (a rejection, or the `BUILDTAG` of that build);
send the rejects first so every check runs in the same build, then the accepted ones.
Expected: unsigned, B only and every CRC edit rejected; A, B+A and the zlib-fixed CRC accepted. The byte 2 pair isolates
the CRC from the signature and key digest.
Gotchas: an ESP-IDF image can contain `E7 02` at a 4 KiB boundary by chance; find the signature sector by the CRC, as
`sign_variants.py` does. An unsigned image sent after a signed one can be rejected with "trusted key failed to verify"
instead of "no signature block": the partition still holds the old signature sector past the new image's end.
Results: https://github.com/esphome/esphome/pull/22398#issuecomment-6716330334
@@ -1,15 +0,0 @@
# Per period PWM capture on the same chip (#15438)
`scripts/pwm_capture/` (ESP32-C6): `pwmcap.hpp` routes the `ledc` pad through the GPIO matrix into RMT receive at
80 MHz (ping pong buffers, `en_partial_rx`, `mem_block_symbols: 96`) and a PCNT unit, checks every period in the IRAM
safe RMT interrupt and logs a `WIN` line every 10 s (high time and period histograms, minimum and maximum, level
changes, spikes, reversals). `common.yaml` needs `include_builtin_idf_components: [esp_driver_rmt, esp_driver_pcnt]`
and `CONFIG_RMT_ISR_IRAM_SAFE` and `CONFIG_PCNT_ISR_IRAM_SAFE`; build it with `-s name NAME -s ps light` (or `none`).
`run.sh NAME SECONDS [--every 5 --transition 1]` (env `TREE`, `PORT`, `SC`, `PY`) resets and captures while
`apiclient.py` holds an API connection and optionally sends light transitions; `summ.py` totals the windows.
- LEDC and RMT share PLL_F80M, so a 20 kHz 11 bit output reads exactly 4000 ticks per period with no jitter.
- Result: no glitch in about 10 million periods with power save `light` or `none`; #15718 (LEDC in IRAM) changed
nothing. Positive control: a lambda that changes the duty for a few periods is caught every time.
- Window boundaries follow RMT chunks (about 1000 periods), so `n` alternates between 199584 and 200592; sum over
the run.
@@ -12,8 +12,7 @@ markers and prints what each sent frame decoded as.
- ESP32: `remote_transmitter` and two `remote_receiver`s on the same GPIO (`allow_other_uses`), carrier 100%, no
inversion. RMT reads its own output, every frame decodes. Second receiver `dump: [nec, raw]` shows raw runs only
where no primary matched.
- ESP8266 before #22453: the transmitter sends the whole frame with interrupts off, so a receiver on the same pin
sees nothing (from #22453 on it decodes, see below). For older trees, `softtx.hpp`
- ESP8266: the transmitter sends with interrupts off, so a receiver on the same pin sees nothing. `softtx.hpp`
bit-bangs frames built with each protocol's `encode()` with interrupts on, and an `interval` sets the pin back to
OUTPUT after the receiver's setup. Bit-banged NEC decodes as NEC about 2 of 3 times (timing), on every build.
- Dish never decodes in loopback on dev either. Pronto matches every frame, so raw on `dump: all` never prints.
@@ -29,18 +28,3 @@ Results: https://github.com/esphome/esphome/pull/21563#issuecomment-6381304387
Retest of the moved stack with 5 rounds per build: https://github.com/esphome/esphome/pull/21563#issuecomment-6382075726.
A D1 mini CH340 sometimes fails the 460800 baud upload; esphome retries at 115200 by itself, but a runner that reads
only the last line sees "Failed to upload" first, so rerun before blaming the build.
## ESP8266 transmitter to receiver on the same board (#22453)
- Wire the `remote_transmitter` pin to a `remote_receiver` pin (GPIO12 to GPIO14 on a D1 mini), `inverted: false`.
- Keep the receiver's default `idle`. `idle: 4ms` is shorter than the NEC leader space (4.5 ms), so every frame is
split and nothing decodes.
- Dev before #22453 sends the whole frame inside one `InterruptLock`, so a receiver on the same chip decodes 0
frames. That is expected on old builds, not a wiring fault. With locks per edge, 3,692 of 3,695 frames decoded
under Wi-Fi load.
- Count with `on_nec` matching a counter in the command, and log the counts on an `interval`.
- Without a jumper, put both on GPIO12 with `allow_other_uses: true` and call `pinMode(12, OUTPUT)` before each frame
(the receiver's setup leaves the shared pin as an input); the receiver's edge interrupt sees the transmitter's own
output. `scripts/esp8266_lock_load/ir-one-pin.yaml` does this, and its `run.sh` adds Wi-Fi load (see
[one_wire.md](one_wire.md)). Under load, the build that sends the whole frame in one lock hit a Hardware Watchdog in
`wDev_ProcessFiq` after 11 minutes.
@@ -21,9 +21,3 @@ captures 40 s from boot and runs `ivsum.py`, which prints the run count, first r
runs), so do not compare those offsets between builds.
- `main.cpp` shows the class each interval gets (`IntervalTrigger` or `DelayedIntervalTrigger` on #21799).
- Results: https://github.com/esphome/esphome/pull/21799#issuecomment-6516798398
## Freelist trim after boot (ratgdo/esphome-ratgdo#705)
10 s after setup the scheduler frees its item pool and shrinks its `to_add_` and `items_` vectors
(`SCHEDULER_FREELIST_TRIM_DELAY_MS` in `application.cpp`). The first burst of timers after that allocates new
items and regrows the vectors once, so heap or timing measurements should skip the first run after boot.
@@ -1,32 +0,0 @@
# SPI bus capture on the same board, no chips or wiring (#22376)
`scripts/spi_self/`: `gen.py` writes `spi<board>.yaml` (e32 classic ESP32, c3, e66 ESP8266, rp Pico 2 W) with a
hardware bus, a software bus, several `spi_device` instances (all four modes, MSB and LSB first, different rates and
CS pins) and a `max7219`. `spitest.hpp` drives every device from a 20 ms interval (an 8 byte transfer, write16, a 12
bit write, read_array, write_array, back to back transfers, teardown plus setup, a second setup, a max7219 update),
three passes, then reboots. `run.sh BOARD LABEL SECONDS` (env `TREE`, `PORT`, `SC`, `PY`, and `PORTS` for the Pico)
builds, flashes, captures and checks; `analyze.py spi<board>.yaml BASE.txt PR.txt` checks each record against the
expected wire data and diffs the two builds by the majority payload per record, so interrupt jitter does not count.
- Classic ESP32: the ESPHome bus is on SPI2 through the matrix and SPI3 runs as a slave without DMA (64 bytes at
most), set up again in the device's mode for each operation. Its nominal CS must be a spare pad held high
(GPIO14); the device CS is routed into `spics_in` only after `spi_slave_queue_trans()`, because the slave driver
reconnects the nominal pad when a transaction loads. Never give the slave the device's CS pad:
`spicommon_cs_initialize` takes over its output and `spi_slave_free` resets it. `gpio_set_direction(..., OUTPUT)`
turns a pad back into a plain GPIO output, so only call `gpio_input_enable` on pads the master drives.
- With that slave attached, full duplex reads on the master lose their last bytes after the first read of a boot,
even through a MOSI to MISO loopback; a config without the slave reads fine. Do not judge reads on that layout.
- The slave sees bit-banged mode 1 data one bit late; the interrupt captures decode it correctly.
- C3, ESP8266 and Pico 2 W: GPIO edge interrupts on SCK and CS take snapshots of the input register. C3 SPI2 cannot
clock below about 78 kHz; at 80 to 100 kHz the capture works with
`gpio_install_isr_service(ESP_INTR_FLAG_LEVEL3 | ESP_INTR_FLAG_IRAM)`, but one build lost an SCK pair at the start
of a frame in every run and a rebuild did not: compare two builds before calling a missing edge real. Let the log
output drain (20 ms) before a capture that follows `spi_setup()`.
- ESP8266: the HSPI pads (12, 13, 14) do not show in `GPI` while in SPI function; the script logs `SPI1CLK`,
`SPI1C`, `SPI1U` and `SPI1P` after each hardware bus operation instead.
- Pico 2 W: `IO_BANK0` INOVER on the MISO pad forces the SPI receive input low or high (reads 00 and ff).
- Software bus loopback: MOSI and MISO on one pad with `allow_other_uses`, then set the pad back to output.
- Seen on dev before #22376: the software bus ignores `data_rate` (`cycle_clock_()` never waits), and SCK moves to
the new idle level only after CS is low when devices with different clock polarity share the software bus.
- LibreTiny (BK72xx, RTL87xx, LN882x) has no `spi:` (config validation allows esp32, esp8266, nrf52 and rp2 only),
so SPI changes cannot be run there.
@@ -24,8 +24,3 @@ identical payloads share one table, a lambda is sent from its vector. An empty c
(`sendto() error 22` in the log, no datagram) until it accepted `nullptr` with a zero length; the old
`TemplatableBytes::visit` had passed a zero length stack buffer instead. Compare sizes against the stacked PR's own
head when the PR is stacked, not against dev. Results: https://github.com/esphome/esphome/pull/21263
## Two sockets on one port (#22509)
lwIP lets two sockets bind one UDP port when `SO_REUSEADDR` is set, and unicast then goes to the socket opened
last; without it the second bind fails with errno 112.
@@ -93,27 +93,3 @@ connected channel starts and ends on the bench channel with another channel in b
- Dropping the AP under a connected device gives a `Beacon Timeout` link loss.
- The 90 s interval fires once right after boot.
- A `Disconnected ... Association Leave` right after `Connecting to` is the aborted attempt, not a missed connect.
## Soft AP with the BLE tracker scanning (#22534, #22566)
`scripts/ble_scan_soft_ap/`: `dut.yaml` runs only `wifi: ap:` with `captive_portal:` and `bluetooth_proxy:`, and
`client.yaml` (on a second ESP32) joins that AP and fetches the portal page every 5 s, logging `JOIN` and `STAT`
lines. `run.sh` erases and flashes the DUT, resets the client and prints the generated scan window, the first join, the
page counts and the client's disconnect reasons (usage in its header).
- A scan window equal to the scan interval (320 ms of 320 ms) leaves the radio no time for the soft AP: on dev no
client joined on the classic ESP32, S3, C3 or C6. With #22566 the tracker uses its 30 ms connection window while
the AP runs, and every chip took a client in about 4 s.
- Only one AP with the test SSID may be up at a time; list the other AP boards in `PARK` so they wait in their ROM
bootloader.
- To check that the full window comes back after provisioning, log `esp32_ble_tracker` at VERBOSE and provision
with `templates/client-wifisave.yaml` (set `ap_ssid` to the DUT's and `ap_password: ""`, the DUT's AP is open): `Connection or AP active, using 48 unit scan window` must stop once the AP stops.
- Some clients do not join an ESP8266 AP at all (the C6 failed on dev too with `Too Many Associations`); use a C3.
## ESP8266 power save after captive portal provisioning (#22542, #22564)
The DUT runs `ap:` plus `captive_portal:` with no station network and logs `wifi_get_sleep_type()` every 5 s (0 is
`NONE_SLEEP_T`, 2 is `MODEM_SLEEP_T`); an ESP32-C3 with `templates/client-wifisave.yaml` posts the real credentials.
Erase the flash before each run so no saved credentials skip the portal. On dev the sleep type stayed 2 after
provisioning although the config asked for none; with #22564 it read 0 from the moment the AP started, and the
`networks:` path still read 0.
@@ -1,9 +0,0 @@
# wled
## Realtime sender on the bench (#22509)
When the access point stops passing traffic between clients (lwIP `sendto` on the sender fails with errno 12
because ARP never resolves), run the sender on a board instead of the host: an ESP32-S3 runs `wifi: ap:` and a
25 ms interval lambda sends WARLS, DRGB or DNRGB frames with lwIP `sendto` to the devices under test, which join
that AP with `manual_ip`. Two lights on the same port: see `udp.md`. Heap after an effect stops: see
`heap_alloc.md`.
@@ -1,31 +0,0 @@
#pragma once
// Test only: swaps the first two records of an ATT Read By Type Response whose first record declares characteristic
// 0xFFF1, the way some peripherals answer out of handle order (esphome#22518). Count of swaps in attswap_count.
#include <cstdint>
#include <cstring>
#include "esphome/core/log.h"
struct AttSwapHdr {
uint16_t event, len, offset, layer_specific;
};
extern "C" void __real_l2c_rcv_acl_data(void *p_msg);
static volatile uint32_t attswap_count = 0;
extern "C" void __wrap_l2c_rcv_acl_data(void *p_msg) {
auto *h = static_cast<AttSwapHdr *>(p_msg);
uint8_t *p = reinterpret_cast<uint8_t *>(h + 1) + h->offset;
// HCI ACL header (4), L2CAP header (4), then ATT: opcode, record length, records
if (h->len >= 4 + 4 + 2 + 14) {
uint16_t cid = p[6] | (p[7] << 8);
uint8_t *att = p + 8;
// record: handle(2) properties(1) value handle(2) uuid16(2)
if (cid == 0x0004 && att[0] == 0x09 && att[1] == 7 && att[2 + 5] == 0xF1 && att[2 + 6] == 0xFF) {
uint8_t tmp[7];
memcpy(tmp, att + 2, 7);
memcpy(att + 2, att + 9, 7);
memcpy(att + 9, tmp, 7);
attswap_count = attswap_count + 1;
}
}
__real_l2c_rcv_acl_data(p_msg);
}
@@ -1,11 +0,0 @@
"""Test only: wrap Bluedroid's l2c_rcv_acl_data so attswap.h can reorder a Read By Type response."""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.types import ConfigType
CONFIG_SCHEMA = cv.Schema({})
async def to_code(config: ConfigType) -> None:
cg.add_build_flag("-Wl,--wrap=l2c_rcv_acl_data")
@@ -1,5 +0,0 @@
substitutions:
name: attprx6
idf: 5.5.6
packages:
c: !include proxy.yaml
@@ -1,125 +0,0 @@
"""Through the proxy (no cache): print the full GATT table, read FFF1/FFF2/FFF3 by handle, and enable notify on
FFF1, FFF2 and 2ACD (writing the CCCD the way Home Assistant does), counting what arrives per handle for 6 s.
usage: gattcheck.py PROXY PEER_MAC env: API_KEY (the proxy's api encryption key)
Exits 1 when a characteristic or CCCD is missing, a read or notification carries another characteristic's value,
a notify fails, or a notifying characteristic sends nothing."""
import asyncio
import os
import sys
from aioesphomeapi import APIClient
SFX = "-0000-1000-8000-00805f9b34fb"
def short(u: str) -> str:
return u[4:8] if u.endswith(SFX) else u
async def main(host: str, peer: str) -> None:
addr = int(peer.replace(":", ""), 16)
cli = APIClient(host, 6053, None, noise_psk=os.environ["API_KEY"])
await cli.connect(login=True)
seen = asyncio.Event()
cli.subscribe_bluetooth_le_raw_advertisements(
lambda m: (
seen.set() if any(a.address == addr for a in m.advertisements) else None
)
)
await asyncio.wait_for(seen.wait(), 30)
state: asyncio.Queue = asyncio.Queue()
cancel = await cli.bluetooth_device_connect(
addr,
lambda c, mtu, err: state.put_nowait((c, mtu, err)),
timeout=20,
address_type=0,
feature_flags=0xFF,
has_cache=False,
)
c, mtu, err = await asyncio.wait_for(state.get(), 25)
if not c:
sys.exit(f"connect failed err={err}")
svcs = await cli.bluetooth_gatt_get_services(addr)
chars = {}
for s in svcs.services:
print(f"SVC {short(s.uuid)} 0x{s.handle:04X}", flush=True)
for ch in s.characteristics:
chars[short(ch.uuid)] = ch
print(
f" CHR {short(ch.uuid)} 0x{ch.handle:04X} props=0x{ch.properties:02X}",
flush=True,
)
for d in ch.descriptors:
print(f" DSC {short(d.uuid)} 0x{d.handle:04X}", flush=True)
wanted = ("fff1", "fff2", "fff3", "2acc", "2acd")
problems = [f"{u} missing" for u in wanted if u not in chars]
for u in ("fff1", "fff2", "fff3", "2acc"):
if u in chars:
v = bytes(await cli.bluetooth_gatt_read(addr, chars[u].handle))
text = v.decode(errors="replace")
print(f"READ {u} 0x{chars[u].handle:04X} -> {text}", flush=True)
# every server value starts with its own UUID, so a mixed-up handle shows here
if not text.startswith(f"{u}-"):
problems.append(f"{u} read returned {text!r}")
got: dict[int, list[str]] = {}
def on_notify(handle: int, data: bytearray) -> None:
got.setdefault(handle, []).append(bytes(data).decode(errors="replace"))
for u in ("fff1", "fff2", "2acd"):
if u in chars:
try:
await cli.bluetooth_gatt_start_notify(addr, chars[u].handle, on_notify)
# like Home Assistant's client: write the first CCCD listed under the characteristic
cccd = next(
(d for d in chars[u].descriptors if short(d.uuid) == "2902"), None
)
if cccd is None:
print(
f"NOTIFY {u} 0x{chars[u].handle:04X} NO CCCD in its descriptor list",
flush=True,
)
problems.append(f"{u} has no CCCD")
continue
await cli.bluetooth_gatt_write_descriptor(
addr, cccd.handle, b"\x01\x00"
)
print(
f"NOTIFY {u} 0x{chars[u].handle:04X} enabled, CCCD 0x{cccd.handle:04X} written",
flush=True,
)
except Exception as exc: # noqa: BLE001
print(
f"NOTIFY {u} 0x{chars[u].handle:04X} FAILED {type(exc).__name__}: {exc}",
flush=True,
)
problems.append(f"{u} notify failed")
await asyncio.sleep(6)
if not got:
print("GOT nothing", flush=True)
for h, vals in sorted(got.items()):
name = next((u for u, ch in chars.items() if ch.handle == h), "?")
print(
f"GOT 0x{h:04X} ({name}) x{len(vals)}: {vals[0]} .. {vals[-1]}", flush=True
)
problems.extend(
f"0x{h:04X} ({name}) notified {v!r}"
for v in vals
if name == "?" or not v.startswith(f"{name}-")
)
# the server notifies FFF1 and 2ACD every second
problems.extend(
f"{u} sent nothing"
for u in ("fff1", "2acd")
if u in chars and chars[u].handle not in got
)
await cli.bluetooth_device_disconnect(addr)
cancel()
await cli.disconnect()
if problems:
sys.exit("FAIL: " + ", ".join(problems))
print("PASS", flush=True)
asyncio.run(main(sys.argv[1], sys.argv[2]))
@@ -1,56 +0,0 @@
"""Connect to PEER_MAC through the proxy like Home Assistant (v3 without cache) and print the services it returns.
usage: gattget.py PROXY PEER_MAC ROUNDS env: API_KEY (the proxy's api encryption key)"""
import asyncio
import os
import sys
from aioesphomeapi import APIClient
async def main(host: str, peer: str, rounds: int) -> None:
addr = int(peer.replace(":", ""), 16)
cli = APIClient(host, 6053, None, noise_psk=os.environ["API_KEY"])
await cli.connect(login=True)
seen = asyncio.Event()
def adv(msg) -> None:
if any(a.address == addr for a in msg.advertisements):
seen.set()
cli.subscribe_bluetooth_le_raw_advertisements(adv)
await asyncio.wait_for(seen.wait(), 30)
failed = 0
for r in range(1, rounds + 1):
state: asyncio.Queue = asyncio.Queue()
cancel = await cli.bluetooth_device_connect(
addr,
lambda c, mtu, err, q=state: q.put_nowait((c, mtu, err)),
timeout=20,
address_type=0,
feature_flags=0xFF,
has_cache=False,
)
c, mtu, err = await asyncio.wait_for(state.get(), 25)
if not c:
print(f"R{r} connect failed err={err}", flush=True)
failed += 1
cancel()
await asyncio.sleep(5)
continue
svcs = await cli.bluetooth_gatt_get_services(addr)
sfx = "-0000-1000-8000-00805f9b34fb"
out = [
f"{s.uuid[4:8] if s.uuid.endswith(sfx) else s.uuid}({len(s.characteristics)})"
for s in svcs.services
]
print(f"R{r} mtu={mtu} services: {' '.join(out)}", flush=True)
await cli.bluetooth_device_disconnect(addr)
cancel()
await asyncio.sleep(5)
await cli.disconnect()
if failed:
sys.exit(f"{failed} of {rounds} rounds did not connect")
asyncio.run(main(sys.argv[1], sys.argv[2], int(sys.argv[3])))
@@ -1,37 +0,0 @@
# Proxy (classic ESP32): bluetooth_proxy without service cache, encrypted API, the reorder wrapper.
# Substitutions: name, idf.
# Keep the per-build file in this directory: attswap.hpp and components/ are found relative to it.
esphome:
name: ${name}
includes: [attswap.hpp]
esp32:
board: esp32dev
framework:
type: esp-idf
version: ${idf}
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
reboot_timeout: 0s
# a plaintext proxy can be taken over by any Home Assistant on the network mid-test
encryption:
key: !secret api_key
ota:
- platform: esphome
external_components:
- source: {type: local, path: components}
components: [attswap]
attswap:
esp32_ble_tracker:
scan_parameters:
active: true
bluetooth_proxy:
active: true
cache_services: false
interval:
- interval: 5s
then:
- lambda: 'ESP_LOGI("swap", "attswap swaps=%u", (unsigned) attswap_count);'
@@ -1,36 +0,0 @@
#!/bin/bash
# run.sh PROXY_YAML [ROUNDS]: build server.yaml and PROXY_YAML from TREE, flash both, capture the proxy and read the
# server's services through it ROUNDS times (default 3). Prints the services per round and the Bluedroid discovery
# errors. Exit 0: every round got all services; 1: a round got a partial table (the bug); 2: setup failed, a round
# did not connect, or the swap never fired (the result would be meaningless). PROXY_YAML must sit in this
# directory, next to attswap.hpp and components/.
# env: TREE, SERVER_YAML (default server.yaml), API_KEY (proxy api key), SERVER_PORT, PROXY_PORT, SERVER_BLE_MAC
# (the server's BLE address), SC (path to serial_capture.py), PY
D=$(cd "$(dirname "$0")" && pwd); Y=$(cd "$(dirname "${1:?proxy yaml}")" && pwd)/$(basename "$1"); R=${2:-3}
W=${TREE:?set TREE}; SP=${SERVER_PORT:?set SERVER_PORT}; PP=${PROXY_PORT:?set PROXY_PORT}; MAC=${SERVER_BLE_MAC:?set SERVER_BLE_MAC}
API_KEY=${API_KEY:?set API_KEY}; SC=${SC:?set SC}; PY=${PY:-$W/venv/bin/python}; N=$(basename "$Y" .yaml); O=$D/out-$N; mkdir -p "$O"
(cd "$W" && PYTHONPATH=$W $PY -m esphome -s name attsrv run --no-logs "$D/${SERVER_YAML:-server.yaml}" --device "$SP") > "$O/server.log" 2>&1 ||
{ echo "server build or upload failed"; exit 2; }
(cd "$W" && PYTHONPATH=$W $PY -m esphome run --no-logs "$Y" --device "$PP") > "$O/proxy-upload.log" 2>&1 ||
{ echo "$N build or upload failed"; exit 2; }
rm -f "$O/proxy.txt"
$PY "$SC" "$PP" $((R * 30 + 160)) "$O/proxy.txt" --reset > "$O/capture.log" 2>&1 &
CAP=$!
IP=""
for _ in $(seq 40); do
IP=$(LC_ALL=C grep -a -o -E "IP Address: [0-9.]+" "$O/proxy.txt" | tail -1 | cut -d' ' -f3)
[ -n "$IP" ] && break; sleep 2
done
[ -n "$IP" ] || { kill "$CAP"; echo "$N no IP"; exit 2; }
sleep 5
timeout $((R * 30 + 60)) $PY "$D/gattget.py" "$IP" "$MAC" "$R" | tee "$O/client.txt"
[ "${PIPESTATUS[0]}" = 0 ] || { kill "$CAP"; echo "$N client failed"; exit 2; }
wait "$CAP" || { echo "$N capture failed (see $O/capture.log)"; exit 2; }
echo "== $N"
LC_ALL=C grep -a -E "invalid handle|Service discovery complete|attswap swaps=[1-9]" "$O/proxy.txt" | sed 's/^\[[0-9:]*\]//' |
sort | uniq -c
grep -a -q -E "attswap swaps=[1-9]" "$O/proxy.txt" || { echo "$N INVALID: the reorder never fired"; exit 2; }
# every round must list the reordered service (fff0) and the one after it (1826) with all their characteristics
grep -a -q "^R[0-9]* mtu=" "$O/client.txt" || { echo "$N no client output"; exit 2; }
grep -a "^R[0-9]* mtu=" "$O/client.txt" | grep -v -q "fff0(6) 1826(2)" && exit 1
exit 0
@@ -1,45 +0,0 @@
# Peripheral (ESP32-S3) for gattcheck.py: FFF1 to FFF6 each hold their own value, FFF1, FFF2 and 2ACD can notify,
# and FFF1 and 2ACD send a changing value every second. Substitution: name.
esphome:
name: ${name}
esp32:
variant: esp32s3
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
esp32_ble:
name: ${name}
esp32_ble_server:
manufacturer: ESPHome
model: attcheck
services:
- uuid: FFF0
advertise: true
characteristics:
- {id: c1, uuid: FFF1, read: true, notify: true, value: "fff1-v"}
- {uuid: FFF2, read: true, notify: true, value: "fff2-v"}
- {uuid: FFF3, read: true, value: "fff3-v"}
- {uuid: FFF4, read: true, value: "fff4-v"}
- {uuid: FFF5, read: true, value: "fff5-v"}
- {uuid: FFF6, read: true, value: "fff6-v"}
- uuid: "1826"
characteristics:
- {uuid: 2ACC, read: true, value: "2acc-v"}
- {id: cd, uuid: 2ACD, read: true, notify: true, value: "2acd-0"}
on_connect:
- logger.log: {format: "SRV connected", level: INFO}
globals:
- {id: n, type: int, initial_value: "0"}
interval:
- interval: 1s
then:
- lambda: id(n)++;
- ble_server.characteristic.set_value:
id: cd
value: !lambda 'std::string v = "2acd-" + to_string(id(n)); return std::vector<uint8_t>(v.begin(), v.end());'
- ble_server.characteristic.notify: {id: cd}
- ble_server.characteristic.set_value:
id: c1
value: !lambda 'std::string v = "fff1-n" + to_string(id(n)); return std::vector<uint8_t>(v.begin(), v.end());'
- ble_server.characteristic.notify: {id: c1}
@@ -1,32 +0,0 @@
# Peripheral (ESP32-S3): a 6-characteristic service 0xFFF0, then an FTMS-like 0x1826 service. Substitution: name.
esphome:
name: ${name}
esp32:
variant: esp32s3
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
esp32_ble:
name: ${name}
esp32_ble_server:
manufacturer: ESPHome
model: g22518
services:
- uuid: FFF0
advertise: true
characteristics:
- {uuid: FFF1, read: true, value: [1]}
- {uuid: FFF2, read: true, value: [2]}
- {uuid: FFF3, read: true, value: [3]}
- {uuid: FFF4, read: true, value: [4]}
- {uuid: FFF5, read: true, value: [5]}
- {uuid: FFF6, read: true, value: [6]}
- uuid: "1826"
characteristics:
- {uuid: 2ACC, read: true, value: [0, 0, 0, 0]}
- {uuid: 2ACD, read: true, notify: true, value: [0, 0]}
on_connect:
- logger.log: {format: "SRV connected", level: INFO}
on_disconnect:
- logger.log: {format: "SRV disconnected", level: INFO}
@@ -1,48 +0,0 @@
# Joins the DUT's open AP, fetches the portal page every 5 s and logs JOIN and STAT lines. Substitutions: name, board,
# logger_uart, ap_ssid (same as dut.yaml).
substitutions:
board: esp32-c3-devkitm-1
logger_uart: USB_SERIAL_JTAG
ap_ssid: bleaptest
esphome:
name: ${name}
esp32:
board: ${board}
framework:
type: esp-idf
logger:
hardware_uart: ${logger_uart}
wifi:
ssid: ${ap_ssid}
reboot_timeout: 0s
on_connect:
- lambda: |-
id(joins)++;
ESP_LOGI("cli", "JOIN %d at %u ms", id(joins), (unsigned) millis());
on_disconnect:
- lambda: ESP_LOGI("cli", "LEFT at %u ms", (unsigned) millis());
http_request:
timeout: 5s
globals:
- {id: joins, type: int, initial_value: "0"}
- {id: pages_ok, type: int, initial_value: "0"}
- {id: pages_fail, type: int, initial_value: "0"}
interval:
- interval: 5s
then:
- if:
condition: wifi.connected
then:
- http_request.get:
url: http://192.168.4.1/
on_response:
then:
- lambda: if (response->status_code == 200) id(pages_ok)++; else id(pages_fail)++;
on_error:
then:
- lambda: id(pages_fail)++;
- interval: 15s
then:
- lambda: |-
ESP_LOGI("cli", "STAT up=%u joins=%d connected=%d pages_ok=%d pages_fail=%d", (unsigned) (millis() / 1000),
id(joins), (int) wifi::global_wifi_component->is_connected(), id(pages_ok), id(pages_fail));
@@ -1,23 +0,0 @@
# Soft AP only (no station network) with captive_portal and bluetooth_proxy, as in #22534. Substitutions: name,
# board, logger_uart (USB_SERIAL_JTAG on native USB chips, UART0 on a classic devkit), ap_ssid.
substitutions:
board: esp32dev
logger_uart: UART0
ap_ssid: bleaptest
esphome:
name: ${name}
esp32:
board: ${board}
framework:
type: esp-idf
logger:
hardware_uart: ${logger_uart}
api:
reboot_timeout: 0s
ota:
- platform: esphome
wifi:
ap:
ssid: ${ap_ssid}
captive_portal:
bluetooth_proxy:
@@ -1,29 +0,0 @@
#!/bin/bash
# run.sh DUT_YAML SECONDS: erase and flash DUT_YAML from TREE, let it boot, then reset the client (already flashed with
# client.yaml) and capture both for SECONDS. Prints the generated scan window, the client's first JOIN and last STAT
# lines, its disconnect reasons and the DUT's resets. One AP with this SSID at a time: ports in PARK are held in their
# ROM bootloader first. The yaml file name must equal its `name:` (the build directory is found by it).
# Exit 0: pass; 1: the run finished and failed the checks; 2: setup or capture failed.
# env: TREE, DUT_PORT, CLIENT_PORT, PARK (space separated ports, optional), SC (path to serial_capture.py), PY
Y=$(cd "$(dirname "${1:?dut yaml}")" && pwd)/$(basename "$1"); T=${2:?seconds}; N=$(basename "$Y" .yaml)
W=${TREE:?set TREE}; DUT=${DUT_PORT:?set DUT_PORT}; CLI=${CLIENT_PORT:?set CLIENT_PORT}; SC=${SC:?set SC}
PY=${PY:-$W/venv/bin/python}; O=$(dirname "$Y")/out-$N; mkdir -p "$O"
for p in $PARK; do $PY -m esptool --port "$p" --after no-reset chip-id > /dev/null 2>&1 || { echo "park $p failed"; exit 2; }; done
(cd "$W" && PYTHONPATH=$W $PY -m esphome compile "$Y") > "$O/compile.log" 2>&1 || { echo "$N compile failed"; exit 2; }
$PY -m esptool --port "$DUT" erase_flash > "$O/erase.log" 2>&1 || { echo "$N erase failed"; exit 2; }
(cd "$W" && PYTHONPATH=$W $PY -m esphome upload "$Y" --device "$DUT") > "$O/upload.log" 2>&1 || { echo "$N upload failed"; exit 2; }
$PY "$SC" "$DUT" $((T + 40)) "$O/dut.txt" --reset > "$O/dut-capture.log" 2>&1 &
CAP=$!
trap 'kill $CAP 2>/dev/null' EXIT
sleep 30
$PY "$SC" "$CLI" "$T" "$O/cli.txt" --reset > "$O/cli-capture.log" 2>&1 || { echo "$N client capture failed"; exit 2; }
wait "$CAP" || { echo "$N DUT capture failed"; exit 2; }
B=$(dirname "$Y")/.esphome/build/$N/src/main.cpp
[ -f "$B" ] || { echo "$N: $B not found (file name differs from name:?)"; exit 2; }
echo "== $N window=$(grep -o 'set_scan_window([0-9]*)' "$B" || echo none)"
J=$(grep -a -m1 "JOIN 1" "$O/cli.txt" | cut -c1-100); echo "${J:-$N NO JOIN}"
STAT=$(grep -a "STAT" "$O/cli.txt" | tail -1 | cut -c1-140); echo "$STAT"
echo "client disconnect reasons: $(grep -a -o "reason='[^']*'" "$O/cli.txt" | sort | uniq -c | tr '\n' ' ')"
echo "dut resets: $(grep -a -c 'rst:' "$O/dut.txt")"
# pass: the client joined and fetched pages, and the DUT reset exactly once (the capture's own reset)
[ -n "$J" ] && [ -n "$STAT" ] && [[ "$STAT" != *"pages_ok=0 "* ]] && [ "$(grep -a -c 'rst:' "$O/dut.txt")" = 1 ]
@@ -1,194 +0,0 @@
#pragma once
// BMP390 bench helpers: register dump, INT data-ready edge counter, start write fault injection (software NACK or
// SDA held through the GPIO matrix) and a bus probe. ESP32-S3, ESP-IDF.
#include <cmath>
#include "esphome/components/i2c/i2c_bus.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "driver/gpio.h"
#include "driver/i2c_master.h"
#include "esp_rom_gpio.h"
#include "soc/gpio_sig_map.h"
#include "esp_heap_caps.h"
#include "soc/gpio_reg.h"
#include "soc/soc.h"
#include "soc/io_mux_reg.h"
#include "soc/gpio_periph.h"
namespace bmpt {
static const char *const TAG = "bmpt";
inline volatile uint32_t g_int_edges = 0;
inline void int_isr(void *) { g_int_edges = g_int_edges + 1; }
inline void int_counter_begin(int pin) {
gpio_config_t c{};
c.pin_bit_mask = 1ULL << pin;
c.mode = GPIO_MODE_INPUT;
c.pull_down_en = GPIO_PULLDOWN_ENABLE;
c.intr_type = GPIO_INTR_POSEDGE;
gpio_config(&c);
esp_err_t e = gpio_install_isr_service(0);
if (e != ESP_OK && e != ESP_ERR_INVALID_STATE)
ESP_LOGE(TAG, "isr service %d", e);
gpio_isr_handler_add((gpio_num_t) pin, int_isr, nullptr);
}
inline int g_sda = -1;
inline int g_port = 0;
inline bool g_held = false;
inline int g_hold_level = 0; // 0: SDA shorted low, 1: SDA disconnected from the controller (open line, pulled high)
inline bool g_bus_reset_on_probe = false;
inline uint32_t g_out_sel = 0;
inline bool g_saved = false;
inline bool g_log_pad_once = true;
inline uint32_t out_sel_reg() { return REG_READ(GPIO_FUNC0_OUT_SEL_CFG_REG + 4 * g_sda); }
inline uint32_t pin_reg() { return REG_READ(GPIO_PIN0_REG + 4 * g_sda); }
inline uint32_t mux_reg() { return REG_READ(GPIO_PIN_MUX_REG[g_sda]); }
inline uint32_t en_bit() { return (REG_READ(GPIO_ENABLE_REG) >> g_sda) & 1; }
inline uint32_t g_pin = 0, g_mux = 0, g_en = 0;
inline void log_pad(const char *when) {
ESP_LOGI(TAG, "PAD %s out_sel=0x%08X pin=0x%08X mux=0x%08X en=%u", when, (unsigned) out_sel_reg(),
(unsigned) pin_reg(), (unsigned) mux_reg(), (unsigned) en_bit());
}
inline void sda_hold() {
if (!g_saved) {
g_out_sel = out_sel_reg(); // routing the i2c driver set up; restored verbatim on release
g_pin = pin_reg();
g_mux = mux_reg();
g_en = en_bit();
log_pad("before-hold");
g_saved = true;
ESP_LOGI(TAG, "SDA out_sel saved 0x%08X (I2CEXT0_SDA_OUT_IDX=%d port=%d)", (unsigned) g_out_sel,
I2CEXT0_SDA_OUT_IDX, g_port);
}
gpio_set_level((gpio_num_t) g_sda, g_hold_level);
esp_rom_gpio_connect_out_signal(g_sda, SIG_GPIO_OUT_IDX, false, false);
g_held = true;
if (g_log_pad_once) {
log_pad("held");
}
}
inline void sda_release() {
gpio_set_level((gpio_num_t) g_sda, 1);
REG_WRITE(GPIO_FUNC0_OUT_SEL_CFG_REG + 4 * g_sda, g_out_sel);
REG_WRITE(GPIO_PIN0_REG + 4 * g_sda, g_pin);
REG_WRITE(GPIO_PIN_MUX_REG[g_sda], g_mux);
if (g_en)
REG_WRITE(GPIO_ENABLE_W1TS_REG, 1u << g_sda);
else
REG_WRITE(GPIO_ENABLE_W1TC_REG, 1u << g_sda);
g_held = false;
if (g_log_pad_once) {
log_pad("released");
g_log_pad_once = false;
}
}
// Wraps the sensor's bus: injects the start write faults and logs every failed transfer
class FaultBus : public esphome::i2c::I2CBus {
public:
explicit FaultBus(esphome::i2c::I2CBus *real) : real_(real) {}
uint32_t hold_until{0}; // millis() deadline of a timed hold
bool arm_start{false}; // hold SDA only during the next forced mode start write (PWR_CTRL mode bits 01)
// until this deadline every transfer fails on its own: free-bus dummy read, hold, transfer, release
uint32_t window_until{0};
bool soft_arm{false}; // next start write fails with NACK in software, the wire is not touched
uint32_t soft_until{0}; // every start write fails with NACK in software until this deadline
uint32_t xfers{0}, fails{0}, start_writes{0}, start_fails{0}, normal_writes{0};
esphome::i2c::ErrorCode write_readv(uint8_t address, const uint8_t *w, size_t wn, uint8_t *r, size_t rn) override {
// mode bits of a PWR_CTRL write: 1 forced (a start), 3 normal
int mode = wn == 2 && w[0] == 0x1B ? (w[1] >> 4) & 3 : -1;
bool is_start = mode == 1;
bool targeted = is_start && this->arm_start;
if (is_start && (this->soft_arm || (int32_t) (this->soft_until - esphome::millis()) > 0)) {
this->soft_arm = false;
this->start_writes++;
this->start_fails++;
this->xfers++;
this->fails++;
ESP_LOGI(TAG, "SOFTFAIL start write reg=0x1B val=0x%02X -> NACK", w[1]);
return esphome::i2c::ERROR_NOT_ACKNOWLEDGED;
}
bool timed = (int32_t) (this->hold_until - esphome::millis()) > 0;
bool window = (int32_t) (this->window_until - esphome::millis()) > 0;
if (window) {
// let the driver run its post-error FSM reset with free lines, so each injected failure starts from a clean bus
uint8_t reg = 0x00, v;
this->real_->write_readv(address, &reg, 1, &v, 1);
targeted = true;
}
if (timed || targeted)
sda_hold(); // reasserted per transfer in case the driver re-routes the pin after an error
if (is_start)
this->start_writes++;
if (mode == 3)
this->normal_writes++;
auto err = this->real_->write_readv(address, w, wn, r, rn);
if (targeted) {
this->arm_start = false;
sda_release();
} else if (!timed && g_held) {
sda_release();
}
this->xfers++;
if (err != esphome::i2c::ERROR_OK) {
this->fails++;
if (is_start)
this->start_fails++;
ESP_LOGI(TAG, "XFERFAIL addr=0x%02X reg=0x%02X wn=%u rn=%u val=0x%02X err=%d start=%d", address,
wn > 0 ? w[0] : 0xFF, (unsigned) wn, (unsigned) rn, wn > 1 ? w[1] : 0, (int) err, is_start);
}
return err;
}
protected:
esphome::i2c::I2CBus *real_;
};
inline FaultBus *g_fb = nullptr;
inline esphome::i2c::I2CBus *g_real = nullptr;
// Release a timed hold once its deadline passes even when no transfer happens.
inline void fault_tick() {
if (g_fb != nullptr && g_held && !g_fb->arm_start && (int32_t) (g_fb->hold_until - esphome::millis()) <= 0)
sda_release();
}
inline bool rd(uint8_t addr, uint8_t reg, uint8_t *v, size_t n = 1) {
return g_real->write_readv(addr, &reg, 1, v, n) == esphome::i2c::ERROR_OK;
}
inline bool wr(uint8_t addr, uint8_t reg, uint8_t v) {
uint8_t b[2] = {reg, v};
return g_real->write_readv(addr, b, 2, nullptr, 0) == esphome::i2c::ERROR_OK;
}
inline void dump_regs(uint8_t addr) {
uint8_t chip = 0, err = 0, ictl = 0, pwr = 0, osr = 0, odr = 0, cfg = 0;
bool ok = rd(addr, 0x00, &chip) && rd(addr, 0x02, &err) && rd(addr, 0x19, &ictl) && rd(addr, 0x1B, &pwr) &&
rd(addr, 0x1C, &osr) && rd(addr, 0x1D, &odr) && rd(addr, 0x1F, &cfg);
ESP_LOGI(TAG, "REGS ok=%d chip=0x%02X err=0x%02X int_ctrl=0x%02X pwr=0x%02X mode=%u osr=0x%02X odr=0x%02X cfg=0x%02X",
ok, chip, err, ictl, pwr, (pwr >> 4) & 3, osr, odr, cfg);
}
inline void log_heap() {
ESP_LOGI(TAG, "HEAP internal_free=%u internal_total=%u min_free=%u",
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned) heap_caps_get_total_size(MALLOC_CAP_INTERNAL),
(unsigned) heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL));
}
// Probe the chip; on failure log the line levels, reset the IDF bus once and probe again.
inline void probe(uint8_t addr, int scl) {
uint8_t v = 0;
bool ok = rd(addr, 0x00, &v);
ESP_LOGI(TAG, "PROBE %s chip=0x%02X held=%d sel=0x%08X sda=%d scl=%d", ok ? "ok" : "err", v, g_held,
(unsigned) out_sel_reg(), gpio_get_level((gpio_num_t) g_sda), gpio_get_level((gpio_num_t) scl));
if (ok || !g_bus_reset_on_probe)
return;
i2c_master_bus_handle_t h;
esp_err_t e = i2c_master_get_bus_handle((i2c_port_num_t) g_port, &h);
if (e == ESP_OK)
e = i2c_master_bus_reset(h);
ok = rd(addr, 0x00, &v);
ESP_LOGI(TAG, "PROBE after bus reset (%d): %s chip=0x%02X", e, ok ? "ok" : "err", v);
}
} // namespace bmpt
@@ -1,208 +0,0 @@
"""Write a BMP390 bench config (ESP32-S3, no Wi-Fi) next to this script.
usage: gen.py NAME [--rate 50Hz] [--iir 128x|off] [--interval 1s] [--fault --kinds NMTWLSHD --probe-delay MS]
[--reset-at 40,80] [--open-line] [--bus-reset]
Fault kinds, one per event at 30 s and then every 20 s: N next start write NACKs in software, M every start write
NACKs for 5 s, T SDA held low for the next start write, W the same for every transfer for 5 s, L a 5 s continuous
hold, S a 300 ms hold, H and D harness-only held transfers.
"""
import argparse
import pathlib
HERE = pathlib.Path(__file__).parent
FAULT = """
- interval: 100ms
then:
- lambda: |-
bmpt::fault_tick();
static constexpr char KIND[] = "KINDS";
static uint8_t i = 0;
static uint32_t probe_at = 0;
uint32_t now = millis();
if (i < sizeof(KIND) - 1 && now >= 30000u + 20000u * i) { // events at 30 s, then every 20 s
char k = KIND[i++];
ESP_LOGI("bmpt", "FAULT %c at %u failed=%d warn=%d", k, (unsigned) now, id(bmp)->is_failed(),
id(bmp)->status_has_warning());
// S and L hold SDA at once; fault_tick() releases it at the deadline
if (k == 'S') {
bmpt::g_fb->hold_until = now + 300;
bmpt::sda_hold();
}
if (k == 'W') bmpt::g_fb->window_until = now + 5000;
if (k == 'L' || k == 'D') bmpt::g_fb->hold_until = now + 5000;
if (k == 'L')
bmpt::sda_hold();
if (k == 'N') bmpt::g_fb->soft_arm = true;
if (k == 'M') bmpt::g_fb->soft_until = now + 5000;
if (k == 'H') {
uint8_t reg = 0x00, v;
bmpt::g_fb->arm_start = false;
bmpt::sda_hold();
bool hok = bmpt::g_real->write_readv(ADDR, &reg, 1, &v, 1) == esphome::i2c::ERROR_OK;
bmpt::sda_release();
ESP_LOGI("bmpt", "HARNESS held transfer ok=%d", hok);
}
if (k == 'D') {
uint8_t reg = 0x00, v;
for (int n = 0; n < 2; n++)
bmpt::g_fb->write_readv(ADDR, &reg, 1, &v, 1);
}
if (k == 'T') bmpt::g_fb->arm_start = true;
probe_at = now + PROBEDELAY;
}
if (probe_at != 0 && now >= probe_at) {
probe_at = 0;
bmpt::probe(ADDR, SCLPIN);
}
"""
RESET = """
- interval: 1s
then:
- lambda: |-
static constexpr uint32_t AT[] = {RESETAT};
static uint8_t i = 0;
if (i < sizeof(AT) / sizeof(AT[0]) && millis() >= AT[i] * 1000) {
i++;
bool ok = bmpt::wr(ADDR, 0x7E, 0xB6);
delay(10);
ESP_LOGI("bmpt", "SOFTRESET ok=%d", ok);
bmpt::dump_regs(ADDR);
ESP_LOGI("bmpt", "DRDY ok=%d", bmpt::wr(ADDR, 0x19, 0x42));
}
"""
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("name")
ap.add_argument("--addr", default="0x77")
ap.add_argument("--rate")
ap.add_argument("--iir", default="128x")
ap.add_argument("--interval", default="1s")
ap.add_argument("--posr", default="8x")
ap.add_argument("--tosr", default="none")
ap.add_argument("--fault", action="store_true")
ap.add_argument("--kinds", default="WTSWTSWT")
ap.add_argument("--probe-delay", default="7000")
ap.add_argument(
"--open-line",
action="store_true",
help="fault routes SDA away (floats high) instead of low",
)
ap.add_argument(
"--bus-reset",
action="store_true",
help="reset the IDF bus after a failed probe",
)
ap.add_argument("--sda", default="1")
ap.add_argument("--scl", default="2")
ap.add_argument("--int-pin", default="42")
ap.add_argument(
"--no-drdy", action="store_true", help="do not enable data ready on INT"
)
ap.add_argument(
"--reset-at",
default="",
help="seconds, comma separated: soft reset the chip behind the driver",
)
a = ap.parse_args()
rate = f"\n data_rate: {a.rate}" if a.rate else ""
# data ready on INT, push-pull, active high; the driver's soft reset in setup clears it
drdy = (
""
if a.no_drdy
else f' ESP_LOGI("bmpt", "DRDY ok=%d", bmpt::wr({a.addr}, 0x19, 0x42));\n'
)
extra = ""
if a.fault:
extra += (
FAULT.replace("KINDS", a.kinds)
.replace("PROBEDELAY", a.probe_delay)
.replace("SCLPIN", a.scl)
)
if a.reset_at:
extra += RESET.replace("RESETAT", a.reset_at)
yaml = f"""esphome:
name: {a.name}
includes:
- bmp390probe.hpp
on_boot:
- priority: 800
then:
- lambda: |-
bmpt::g_real = id(bus);
bmpt::g_sda = {a.sda};
bmpt::g_port = id(bus)->get_port();
static bmpt::FaultBus fb(id(bus));
id(bmp)->set_i2c_bus(&fb);
bmpt::g_fb = &fb;
bmpt::g_bus_reset_on_probe = {"true" if a.bus_reset else "false"};
bmpt::g_hold_level = {1 if a.open_line else 0};
ESP_LOGI("bmpt", "PRESETUP");
bmpt::dump_regs({a.addr});
- priority: -100
then:
- lambda: |-
ESP_LOGI("bmpt", "POSTSETUP failed=%d", id(bmp)->is_failed());
bmpt::dump_regs({a.addr});
{drdy} bmpt::int_counter_begin({a.int_pin});
bmpt::dump_regs({a.addr});
bmpt::log_heap();
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
logger:
level: DEBUG
hardware_uart: USB_SERIAL_JTAG
i2c:
id: bus
sda: GPIO{a.sda}
scl: GPIO{a.scl}
scan: true
sensor:
- platform: bmp3xx_i2c
id: bmp
address: {a.addr}
pressure:
name: pressure
oversampling: {a.posr}
on_value:
- lambda: 'ESP_LOGI("bmpt", "READ %u %.4f", (unsigned) millis(), x);'
temperature:
name: temperature
oversampling: {a.tosr}
iir_filter: "{a.iir}"{rate}
update_interval: {a.interval}
interval:
- interval: 10s
then:
- lambda: |-
static uint32_t last_edges = 0, last_ms = 0;
uint32_t e = bmpt::g_int_edges, now = millis();
ESP_LOGI("bmpt", "INT edges=%u in %u ms failed=%d warn=%d xfers=%u fails=%u starts=%u "
"start_fails=%u normal_writes=%u",
(unsigned) (e - last_edges), (unsigned) (now - last_ms), id(bmp)->is_failed(),
id(bmp)->status_has_warning(), bmpt::g_fb->xfers, bmpt::g_fb->fails, bmpt::g_fb->start_writes,
bmpt::g_fb->start_fails, bmpt::g_fb->normal_writes);
last_edges = e;
last_ms = now;
if (!bmpt::g_held) bmpt::dump_regs({a.addr});
bmpt::log_heap();
{extra.replace("ADDR", a.addr)}"""
out = HERE / f"{a.name}.yaml"
out.write_text(yaml, encoding="utf-8")
print(out)
if __name__ == "__main__":
main()
@@ -1,15 +0,0 @@
#!/bin/bash
# run.sh NAME SECONDS [TAG]: build NAME.yaml (written by gen.py) from TREE, flash the ESP32-S3, capture SECONDS from
# a fresh boot into out-NAME/TAG.txt and print stats.py's summary.
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python)
R=$(cd "$(dirname "$0")" && pwd); N=${1:?name}; S=${2:?seconds}; G=${3:-run1}
T=${TREE:?set TREE}; P=${PORT:?set PORT}; PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
[ -f "$R/$N.yaml" ] || { echo "no $N.yaml, run gen.py first"; exit 1; }
OUT=$R/out-$N; mkdir -p "$OUT"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && $PY -m esphome run --no-logs "$R/$N.yaml" --device "$P" > "$OUT/upload.log" 2>&1) ||
{ echo "build or upload failed"; tail -5 "$OUT/upload.log"; exit 1; }
grep -a -E '^(RAM|Flash):' "$OUT/upload.log"
$PY "$SC" "$P" "$S" "$OUT/$G.txt" --reset > /dev/null 2>&1
[ -s "$OUT/$G.txt" ] || { echo "serial capture got nothing"; exit 1; }
$PY "$R/stats.py" "$OUT/$G.txt"
@@ -1,63 +0,0 @@
"""Pressure statistics from a bmp390 capture.
usage: stats.py CAPTURE [SKIP_S]
Prints the read count and cadence, the noise (standard deviation, of consecutive differences and detrended over a
21 sample window), identical consecutive readings, the INT data ready rate per 10 s window and counts of errors,
warnings and harness lines. SKIP_S drops the readings before that many seconds after boot (IIR settling).
"""
import pathlib
import re
import statistics
import sys
def main() -> None:
lines = pathlib.Path(sys.argv[1]).read_bytes().decode(errors="replace").splitlines()
skip = float(sys.argv[2]) if len(sys.argv) > 2 else 0
reads = [
(int(m[1]), float(m[2]))
for line in lines
if (m := re.search(r"READ (\d+) ([\d.]+)", line))
]
r = [p for t, p in reads if t / 1000 >= skip]
if len(r) > 2:
d = [b - a for a, b in zip(r, r[1:], strict=False)]
same = sum(1 for x in d if x == 0)
print(
f"reads={len(reads)} used={len(r)} mean={statistics.mean(r):.4f} sd={statistics.pstdev(r) * 100:.2f}Pa "
f"sd_diff={statistics.pstdev(d) * 100:.2f}Pa identical_consecutive={same} min={min(r):.4f} max={max(r):.4f}"
)
ts = [t for t, _ in reads]
if len(ts) > 2:
gaps = [b - a for a, b in zip(ts, ts[1:], strict=False)]
print(f"cadence mean={statistics.mean(gaps):.0f}ms max_gap={max(gaps)}ms")
ints = [
(int(m[1]), int(m[2]))
for line in lines
if (m := re.search(r"INT edges=(\d+) in (\d+) ms", line))
]
# the first window starts at boot, before data ready is on
if rates := [e * 1000 / ms for e, ms in ints[1:] if ms > 9000]:
print(f"INT rate Hz: {' '.join(f'{x:.2f}' for x in rates)}")
for pat in (
r"\[E\]",
r"\[W\]",
"XFERFAIL",
"SOFTFAIL",
"FAULT",
"PROBE",
"marked FAILED",
"REGS",
):
print(f"{pat}: {sum(1 for line in lines if re.search(pat, line))}")
if len(r) > 30:
k = 10
res = [
r[i] - statistics.mean(r[i - k : i + k + 1]) for i in range(k, len(r) - k)
]
print(f"detrended sd (21 sample window)={statistics.pstdev(res) * 100:.3f}Pa")
if __name__ == "__main__":
main()
@@ -1,170 +0,0 @@
# CC1101 sleep and wake checks T0 to T6 on an ESP32-S3 (SPI on GPIO12/11/13, CS GPIO10, GDO0 GPIO9), run 4 s after
# boot; the result is the "DONE fails=N" line. Substitution: name.
esphome:
name: ${name}
includes: [cctest.hpp]
on_boot:
- priority: -100
then:
- delay: 4s
- script.execute: run_tests
esp32:
variant: esp32s3
framework:
type: esp-idf
logger:
level: DEBUG
hardware_uart: USB_SERIAL_JTAG
spi:
clk_pin: GPIO12
mosi_pin: GPIO11
miso_pin: GPIO13
cc1101:
id: transceiver
cs_pin: GPIO10
gdo0_pin: GPIO9
frequency: 433.92MHz
modulation_type: GFSK
symbol_rate: 4800
packet_mode: true
packet_length: 8
crc_enable: true
sync_mode: "16/16"
output_power: 0
on_packet:
then:
- lambda: ESP_LOGI("cct", "RX packet %s rssi %.1f", format_hex(x).c_str(), rssi);
script:
- id: run_tests
then:
- lambda: |-
using namespace cct;
auto *c = id(transceiver);
Snap base, s;
int fails = 0;
snap(c, base);
ESP_LOGI("cct", "T0 baseline MARCSTATE=%02X FREQ=%02X%02X%02X TEST2=%02X TEST1=%02X TEST0=%02X so_high_awake=%d",
base.marc, base.reg[0x0D], base.reg[0x0E], base.reg[0x0F], base.reg[0x2C], base.reg[0x2D],
base.reg[0x2E], cs_low_so_high(c));
hexpa("T0 baseline", base.pa);
// the sleep checks below need a probe that sees an awake chip as awake
if (cs_low_so_high(c)) {
ESP_LOGW("cct", "T0 FAIL: SO reads high on an awake chip, the sleep checks cannot be trusted");
fails++;
}
// T1: mark PATABLE 1-7, sleep, wake by hand: lost entries prove the chip slept
const uint8_t marks[8] = {base.pa[0], 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77};
write_pa(c, marks);
snap(c, s); hexpa("T1 marked", s.pa);
c->power_down();
bool so = cs_low_so_high(c);
snap(c, s);
hexpa("T1 after raw wake", s.pa);
bool lost = s.pa[1] != 0x11;
ESP_LOGI("cct", "T1 sleep proof: so_high_on_cs=%d pa_lost=%d MARCSTATE=%02X TEST0=%02X -> %s", so, lost, s.marc,
s.reg[0x2E], (so && lost) ? "PASS" : "FAIL");
if (!(so && lost)) fails++;
// the component still thinks it sleeps
c->wake_up();
snap(c, s);
int d = diff(base, s, "T1 restore");
ESP_LOGI("cct", "T1 restore after wake_up: %d differences, MARCSTATE=%02X -> %s", d, s.marc, d == 0 ? "PASS" : "FAIL");
if (d) fails++;
c->begin_rx();
// T2: change settings while asleep, compare with the same change made awake
Snap awake;
c->set_frequency(433.42e6); c->set_output_power(5); c->set_symbol_rate(9600);
snap(c, awake);
c->set_frequency(433.92e6); c->set_output_power(0); c->set_symbol_rate(4800);
c->power_down();
c->set_frequency(433.42e6); c->set_output_power(5); c->set_symbol_rate(9600);
bool so2 = cs_low_so_high(c); // wakes the chip by hand; component still asleep
c->wake_up();
c->begin_rx();
snap(c, s);
awake.marc = s.marc;
d = diff(awake, s, "T2 asleep vs awake");
ESP_LOGI("cct", "T2 settings changed asleep: so_high=%d %d differences FREQ=%02X%02X%02X MARCSTATE=%02X -> %s", so2, d,
s.reg[0x0D], s.reg[0x0E], s.reg[0x0F], s.marc, (so2 && d == 0) ? "PASS" : "FAIL");
if (!(so2 && d == 0)) fails++;
c->set_frequency(433.92e6); c->set_output_power(0); c->set_symbol_rate(4800);
c->begin_rx();
snap(c, base);
// T3: send while asleep wakes the radio by itself
c->power_down();
uint8_t pkt[8] = {1, 2, 3, 4, 5, 6, 7, 8};
int err = (int) c->transmit_packet(pkt, sizeof(pkt));
snap(c, s);
d = diff(base, s, "T3 after send");
ESP_LOGI("cct", "T3 send while asleep: err=%d MARCSTATE=%02X TXBYTES=%02X %d differences -> %s", err, s.marc,
status(c, 0x3A), d, (err == 0 && d == 0) ? "PASS" : "FAIL");
if (!(err == 0 && d == 0)) fails++;
// T4: begin_rx while asleep
c->power_down();
c->begin_rx();
snap(c, s);
d = diff(base, s, "T4 begin_rx");
ESP_LOGI("cct", "T4 begin_rx while asleep: MARCSTATE=%02X %d differences -> %s", s.marc, d,
(s.marc == 0x0D && d == 0) ? "PASS" : "FAIL");
if (!(s.marc == 0x0D && d == 0)) fails++;
// T5: 200 cycles, each one proven asleep and restored
int bad = 0, notslept = 0;
uint32_t t0 = micros();
for (int i = 0; i < 200; i++) {
write_pa(c, marks);
c->power_down();
if (!cs_low_so_high(c)) notslept++;
c->wake_up();
c->begin_rx();
snap(c, s);
if (diff(base, s, "T5") != 0) bad++;
if ((i & 31) == 0) App.feed_wdt();
}
ESP_LOGI("cct", "T5 200 cycles: not_slept=%d restore_fail=%d in %u ms heap=%u -> %s", notslept, bad,
(unsigned) ((micros() - t0) / 1000), (unsigned) esp_get_free_heap_size(),
(notslept == 0 && bad == 0) ? "PASS" : "FAIL");
if (notslept || bad) fails++;
// T6: each setter on its own while asleep: does it wake the chip, and in which state does it leave it
struct Case { const char *name; int kind; };
const Case cases[] = {{"set_frequency", 0}, {"set_channel", 1}, {"set_modulation_type", 2}, {"set_symbol_rate", 3},
{"set_output_power", 4}, {"set_filter_bandwidth", 5}, {"set_fsk_deviation", 6}};
for (auto &k : cases) {
c->begin_rx();
c->power_down();
switch (k.kind) {
case 0: c->set_frequency(433.92e6); break;
case 1: c->set_channel(0); break;
case 2: c->set_modulation_type(cc1101::Modulation::MODULATION_GFSK); break;
case 3: c->set_symbol_rate(4800); break;
case 4: c->set_output_power(0); break;
case 5: c->set_filter_bandwidth(203e3); break;
case 6: c->set_fsk_deviation(1.5e3); break;
}
bool asleep = cs_low_so_high(c);
uint8_t marc = status(c, 0x35) & 0x1F;
ESP_LOGI("cct", "T6 %-20s while asleep: chip %s, MARCSTATE=%02X", k.name, asleep ? "still asleep" : "AWAKE", marc);
if (!asleep) fails++;
c->wake_up();
}
c->begin_rx();
uint8_t agc_rx = rd(c, 0x2B);
c->set_idle();
uint8_t agc_idle = rd(c, 0x2B);
ESP_LOGI("cct", "AGCTEST in RX=%02X in IDLE=%02X", agc_rx, agc_idle);
c->begin_rx();
ESP_LOGI("cct", "DONE fails=%d", fails);
button:
- platform: template
id: yaml_actions
name: Yaml actions
on_press:
- cc1101.power_down: transceiver
- cc1101.wake_up: transceiver
- cc1101.power_down: transceiver
- cc1101.send_packet: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
- lambda: ESP_LOGI("cct", "YAML actions done MARCSTATE=%02X", cct::status(id(transceiver), 0x35) & 0x1F);
@@ -1,70 +0,0 @@
// Register and PATABLE snapshots plus the CHIP_RDYn sleep check for the cc1101 sleep tests in cc1101-sleep.yaml.
#pragma once
#include "esphome/components/cc1101/cc1101.h"
#include "driver/gpio.h"
#include <cstring>
namespace cct {
using esphome::cc1101::CC1101Component;
static constexpr gpio_num_t MISO = GPIO_NUM_13; // the SPI MISO pin in cc1101-sleep.yaml
static constexpr uint8_t NREG = 0x2F; // IOCFG2..TEST0
struct Snap {
uint8_t reg[NREG];
uint8_t pa[8];
uint8_t marc;
};
// Chip ready flag: with CSn low, SO stays high until the crystal runs (SLEEP), low at once otherwise
inline bool cs_low_so_high(CC1101Component *c) {
c->enable();
bool high = gpio_get_level(MISO);
delayMicroseconds(300);
c->disable();
return high;
}
inline uint8_t rd(CC1101Component *c, uint8_t a) {
c->enable();
c->write_byte(a | 0x80);
uint8_t v = c->read_byte();
c->disable();
return v;
}
inline uint8_t status(CC1101Component *c, uint8_t a) { return rd(c, a | 0x40); }
inline void snap(CC1101Component *c, Snap &s) {
for (uint8_t i = 0; i < NREG; i++)
s.reg[i] = rd(c, i);
c->enable();
c->write_byte(0x3E | 0xC0);
for (auto &b : s.pa)
b = c->read_byte();
c->disable();
s.marc = status(c, 0x35) & 0x1F;
}
inline void write_pa(CC1101Component *c, const uint8_t *pa) {
c->enable();
c->write_byte(0x3E | 0x40);
for (int i = 0; i < 8; i++)
c->write_byte(pa[i]);
c->disable();
}
inline int diff(const Snap &a, const Snap &b, const char *what) {
int n = 0;
for (uint8_t i = 0; i < NREG; i++)
// FSCAL3..FSCAL1 hold calibration results, FSTEST and AGCTEST are chip owned test registers
if (i != 0x23 && i != 0x24 && i != 0x25 && i != 0x29 && i != 0x2B && a.reg[i] != b.reg[i]) {
ESP_LOGW("cct", "%s reg 0x%02X: %02X vs %02X", what, i, a.reg[i], b.reg[i]);
n++;
}
for (int i = 0; i < 8; i++)
if (a.pa[i] != b.pa[i]) {
ESP_LOGW("cct", "%s pa[%d]: %02X vs %02X", what, i, a.pa[i], b.pa[i]);
n++;
}
return n;
}
inline void hexpa(const char *tag, const uint8_t *pa) {
ESP_LOGI("cct", "%s PA %02X %02X %02X %02X %02X %02X %02X %02X", tag, pa[0], pa[1], pa[2], pa[3], pa[4], pa[5], pa[6],
pa[7]);
}
} // namespace cct
@@ -1,15 +0,0 @@
esphome:
includes:
- crctest.hpp
on_boot:
- priority: -100
then:
- delay: 3s
- lambda: |-
// unqualified call, as a lambda user would write it; counted in the CRCRESULT totals
crctest::run(crc32(0, reinterpret_cast<const uint8_t *>("123456789"), 9));
interval:
- interval: 20s
then:
- lambda: crctest::run();
@@ -1,73 +0,0 @@
#pragma once
// crc32 vectors for esphome#22398, expected values from Python zlib.crc32
#include "esphome/core/helpers.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cstdlib>
#include <cstring>
namespace crctest {
inline int pass_n = 0, fail_n = 0;
inline void check(const char *name, uint32_t got, uint32_t want) {
if (got == want)
pass_n++;
else
fail_n++;
ESP_LOGI("crctest", "%s %s got=%08X want=%08X", got == want ? "PASS" : "FAIL", name, (unsigned) got, (unsigned) want);
}
// unqualified: a crc32() of "123456789" computed in the caller's lambda, or -1 to skip that check
inline void run(int64_t unqualified = -1) {
pass_n = fail_n = 0;
const uint8_t *s = reinterpret_cast<const uint8_t *>("123456789");
check("check-123456789", esphome::crc32(0, s, 9), 0xCBF43926u);
check("empty-seed0", esphome::crc32(0, s, 0), 0u);
check("empty-seed12345678", esphome::crc32(0x12345678u, s, 0), 0x12345678u);
check("chained-4+5", esphome::crc32(esphome::crc32(0, s, 4), s + 4, 5), 0xCBF43926u);
uint8_t all[256];
for (int i = 0; i < 256; i++)
all[i] = i;
check("bytes-0-255", esphome::crc32(0, all, 256), 0x29058C73u);
uint8_t ff[32];
memset(ff, 0xFF, sizeof(ff));
check("32x0xFF", esphome::crc32(0, ff, 32), 0xFF6CAB0Bu);
uint8_t *pat = static_cast<uint8_t *>(malloc(4100 + 8192));
if (pat == nullptr) {
ESP_LOGE("crctest", "malloc failed");
fail_n++;
ESP_LOGI("crctest", "CRCRESULT pass=%d fail=%d", pass_n, fail_n);
return;
}
for (int i = 0; i < 4100; i++)
pat[i] = (i * 7 + 3) & 0xFF;
static const uint32_t EXP_OFFSET[4] = {0xDF4C2BA9u, 0x5D4B54F6u, 0x3B885695u, 0xAE989C6Eu};
for (int o = 0; o < 4; o++) {
char n[24];
snprintf(n, sizeof(n), "unaligned-off%d", o);
check(n, esphome::crc32(0, pat + o, 4093), EXP_OFFSET[o]);
}
uint32_t c = 0;
for (int i = 0; i < 300; i++)
c = esphome::crc32(c, pat + i, 1);
check("chained-1-byte-x300", c, 0xDE0E57CEu);
c = 0;
for (int i = 0; i < 4093; i += 97)
c = esphome::crc32(c, pat + 1 + i, std::min(97, 4093 - i));
check("chained-97-byte-unaligned", c, 0x5D4B54F6u);
uint8_t *big = pat + 4100;
for (int i = 0; i < 8192; i++)
big[i] = (i * 13 + 5) & 0xFF;
uint32_t best = 0xFFFFFFFF, r = 0;
for (int k = 0; k < 5; k++) {
const uint32_t t0 = (esphome::micros) ();
r = esphome::crc32(0, big, 8192);
const uint32_t dt = (esphome::micros) () - t0;
if (dt < best)
best = dt;
}
check("8KiB", r, 0xF6950930u);
free(pat);
ESP_LOGI("crctest", "CRCSPEED 8192 bytes in %u us = %.1f us/KiB", (unsigned) best, best / 8.0f);
if (unqualified >= 0)
check("unqualified", (uint32_t) unqualified, 0xCBF43926u);
ESP_LOGI("crctest", "CRCRESULT pass=%d fail=%d", pass_n, fail_n);
}
} // namespace crctest
@@ -1,20 +0,0 @@
# Shared ESP8266 base for the lock load DUTs: reset reason at boot, no Wi-Fi sleep, web_server for the HTTP load.
esphome:
name: ${name}
on_boot:
- priority: 800
then:
- lambda: ESP_LOGI("lock", "BOOT reason=%s info=%s", ESP.getResetReason().c_str(), ESP.getResetInfo().c_str());
esp8266:
board: ${board}
logger:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
@@ -1,39 +0,0 @@
# remote_transmitter and remote_receiver on the same pin (GPIO12), no jumper: one NEC frame every 250 ms, each one
# checked by the receiver's edge interrupt. Substitutions: name, board.
substitutions:
board: d1_mini
packages:
base: !include base.yaml
globals:
- {id: sent, type: uint32_t, initial_value: "0"}
- {id: rx_ok, type: uint32_t, initial_value: "0"}
- {id: rx_bad, type: uint32_t, initial_value: "0"}
remote_transmitter:
id: irtx
pin:
number: GPIO12
allow_other_uses: true
carrier_duty_percent: 100%
remote_receiver:
pin:
number: GPIO12
allow_other_uses: true
on_nec:
then:
- lambda: |-
if (x.address == 0x1234 && x.command == (uint16_t) (0x7800 | (id(sent) & 0xFF))) id(rx_ok)++; else id(rx_bad)++;
interval:
- interval: 250ms
then:
# The receiver's setup leaves the shared pin as an input; drive it again before each frame
- lambda: |-
pinMode(12, OUTPUT);
id(sent)++;
- remote_transmitter.transmit_nec:
address: 0x1234
command: !lambda return (uint16_t) (0x7800 | (id(sent) & 0xFF));
- interval: 10s
then:
- lambda: |-
ESP_LOGI("lock", "ALIVE up=%u sent=%u rx_ok=%u rx_bad=%u heap=%u", (unsigned) (millis() / 1000),
(unsigned) id(sent), (unsigned) id(rx_ok), (unsigned) id(rx_bad), (unsigned) ESP.getFreeHeap());
@@ -1,43 +0,0 @@
# DS18B20 on GPIO4 with a bus search every ${search_interval}. Set busy_control to "true" to replace the search with
# the same busy time and no 1-Wire traffic (the control run). Substitutions: name, board, search_interval, busy_control.
substitutions:
board: d1_mini
search_interval: 100ms
busy_control: "false"
packages:
base: !include base.yaml
one_wire:
- platform: gpio
pin: GPIO4
id: owbus
globals:
- {id: searches, type: uint32_t, initial_value: "0"}
- {id: search_bad, type: uint32_t, initial_value: "0"}
- {id: reads_ok, type: uint32_t, initial_value: "0"}
- {id: reads_nan, type: uint32_t, initial_value: "0"}
sensor:
- platform: dallas_temp
one_wire_id: owbus
id: t1
name: Temp
update_interval: 2s
on_raw_value:
- lambda: if (std::isnan(x)) id(reads_nan)++; else id(reads_ok)++;
interval:
- interval: ${search_interval}
then:
- lambda: |-
if (${busy_control}) {
delayMicroseconds(13000); // about one search with one sensor answering
return;
}
id(owbus)->search();
id(searches)++;
if (id(owbus)->get_devices().size() != 1)
id(search_bad)++;
- interval: 10s
then:
- lambda: |-
ESP_LOGI("lock", "ALIVE up=%u ok=%u nan=%u searches=%u search_bad=%u heap=%u t=%.2f",
(unsigned) (millis() / 1000), (unsigned) id(reads_ok), (unsigned) id(reads_nan),
(unsigned) id(searches), (unsigned) id(search_bad), (unsigned) ESP.getFreeHeap(), id(t1).state);
@@ -1,47 +0,0 @@
#!/bin/bash
# run.sh NAME SECONDS PPS: build NAME.yaml (next to this script, or a path) from TREE, flash it over serial, capture
# the log for SECONDS plus 160 (boot, up to 120 s for the IP, the load) and run wifi_load.py at PPS packets per second
# (0: no load). Prints the boot count, every reset reason and the last ALIVE line. Exit 0: a clean run (exactly one
# BOOT line); 1: the run finished and saw resets; 2: setup failed or the run is invalid (no load, capture cut short,
# or the sensor or IR loopback never worked).
# env: TREE (checkout under test), PORT, SC (path to serial_capture.py), PY (checkout python)
D=$(cd "$(dirname "$0")" && pwd); N=${1:?name}; T=${2:?seconds}; P=${3:?pps}
W=${TREE:?set TREE}; PORT=${PORT:?set PORT}; SC=${SC:?set SC}; PY=${PY:-$W/venv/bin/python}
Y=$N; [ -f "$Y" ] || Y=$D/$N.yaml; Y=$(cd "$(dirname "$Y")" && pwd)/$(basename "$Y"); N=$(basename "$Y" .yaml)
O=$D/out-$N; mkdir -p "$O"
pkill -f "[s]erial_capture.py $PORT "
rm -f "$O/serial.txt" "$O/load.txt"
(cd "$W" && PYTHONPATH=$W $PY -m esphome compile "$Y") > "$O/compile.log" 2>&1 || { echo "$N compile failed"; exit 2; }
(cd "$W" && PYTHONPATH=$W $PY -m esphome upload "$Y" --device "$PORT") > "$O/upload.log" 2>&1 || { echo "$N upload failed"; exit 2; }
$PY "$SC" "$PORT" $((T + 160)) "$O/serial.txt" --reset > "$O/capture.log" 2>&1 &
CAP=$!
for _ in $(seq 60); do
IP=$(LC_ALL=C grep -a -o -E "IP Address: [0-9.]+" "$O/serial.txt" | tail -1 | cut -d' ' -f3)
[ -n "$IP" ] && break; sleep 2
done
if [ "$P" -gt 0 ]; then
[ -n "$IP" ] || { kill "$CAP"; echo "$N no IP (check the secrets file)"; exit 2; }
python3 "$D/wifi_load.py" "$IP" "$T" "$P" > "$O/load.txt" 2>&1 || { echo "$N INVALID: load failed (see $O/load.txt)"; BAD=1; }
else
sleep "$T"
fi
wait "$CAP" || { echo "$N INVALID: capture failed (see $O/capture.log)"; BAD=1; }
BOOTS=$(LC_ALL=C grep -a -c 'BOOT reason' "$O/serial.txt")
echo "== $N pps=$P boots=$BOOTS"
LC_ALL=C grep -a "BOOT reason" "$O/serial.txt" | cut -c1-150
LC_ALL=C grep -a "ALIVE" "$O/serial.txt" | tail -1 | cut -c1-200
tail -1 "$O/load.txt" 2>/dev/null
ALIVE=$(LC_ALL=C grep -a "ALIVE" "$O/serial.txt" | tail -1)
# the workload must have run: a sensor answering searches, or decoded IR frames
case "$ALIVE" in
*" ok=0 "*|*" rx_ok=0 "*|"") echo "$N INVALID: no ALIVE line or the workload did not run"; BAD=1 ;;
esac
# one-wire: the searches must find the sensor (busy_control runs no searches)
S=$(echo "$ALIVE" | sed -n 's/.* searches=\([0-9]*\) search_bad=\([0-9]*\).*/\1 \2/p')
if [ -n "$S" ]; then
set -- $S
[ "$1" -gt 0 ] && [ "$2" -ge "$1" ] && { echo "$N INVALID: no search found the sensor"; BAD=1; }
fi
# resets are a failure even when they also broke the load or the workload
[ "$BOOTS" -gt 1 ] && exit 1
[ -z "$BAD" ] && [ "$BOOTS" = 1 ] || exit 2
@@ -1,60 +0,0 @@
"""Wi-Fi load on an ESP8266 under test: a fixed rate UDP flood to port 9 plus back to back web_server GETs.
usage: wifi_load.py HOST SECONDS PPS
Prints the counts every minute and a final "done" line.
"""
import socket
import sys
import threading
import time
import urllib.request
host, secs, pps = sys.argv[1], float(sys.argv[2]), int(sys.argv[3])
end = time.monotonic() + secs
stats = {"udp": 0, "udp_err": 0, "http_ok": 0, "http_err": 0, "http_gap_s": 0}
last_ok = [time.monotonic()]
def http_load() -> None:
while time.monotonic() < end:
try:
with urllib.request.urlopen(f"http://{host}/", timeout=5) as r:
r.read()
stats["http_ok"] += 1
now = time.monotonic()
stats["http_gap_s"] = max(stats["http_gap_s"], round(now - last_ok[0]))
last_ok[0] = now
except Exception: # noqa: BLE001 any failure counts; the thread must not die
stats["http_err"] += 1
time.sleep(1)
threading.Thread(target=http_load, daemon=True).start()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
payload = b"x" * 512
nxt = last = time.monotonic()
while (now := time.monotonic()) < end:
try:
s.sendto(payload, (host, 9))
stats["udp"] += 1
except OSError: # mostly ENOBUFS when the host outruns the network
stats["udp_err"] += 1
time.sleep(0.01)
nxt += 1 / pps
if (d := nxt - time.monotonic()) > 0:
time.sleep(d)
if now - last > 60:
last = now
print(time.strftime("%H:%M:%S"), stats, flush=True)
stats["http_gap_s"] = max(stats["http_gap_s"], round(time.monotonic() - last_ok[0]))
print("done", stats, flush=True)
if (
stats["http_ok"] == 0
or stats["http_gap_s"] > 60 # the device stopped answering for over a minute
or stats["http_err"] > stats["http_ok"]
or stats["udp"] < secs * pps / 2
):
sys.exit("the load did not reach the device; the run is invalid")
@@ -9,7 +9,6 @@ before the LinkChecks instead.
import sys
import bk7231tools.serial.main as bk_main
from lt_platform import with_platform
from ltchiptool.__main__ import cli
from ltchiptool.soc.bk72xx.flash import BK72XXFlash
from serial import Serial
@@ -48,5 +47,5 @@ def flash_connect(self) -> None:
BK72XXFlash.flash_hw_reset = flash_hw_reset
BK72XXFlash.flash_connect = flash_connect
sys.argv = ["ltchiptool", *with_platform(sys.argv[1:])]
sys.argv = ["ltchiptool", *sys.argv[1:]]
cli()
@@ -11,7 +11,6 @@ Usage: python flash_rtl87xx_kit.py flash write -d <port> firmware.uf2
import sys
import time
from lt_platform import with_platform
import serial
_first = [True]
@@ -62,9 +61,7 @@ def boot_app(port: str) -> None:
def main() -> int:
rc = 1
try:
result = cli_entrypoint.main(
args=with_platform(sys.argv[1:]), standalone_mode=False
)
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)
@@ -1,79 +0,0 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <cstdlib>
#include <new>
#ifdef __XTENSA__
#include "esp_cpu_utils.h"
#include "esp_debug_helpers.h"
#endif
#include "esp_timer.h"
// Counts every global operator new and, once armed, keeps the first ALLOC_CAP of them with five caller PCs
// (callers on Xtensa only; RISC-V entries keep size and time). Aligned operator new is not counted.
// See reference/recipes/heap_alloc.md.
struct AllocEntry {
uint32_t ms;
uint32_t size; // ALLOC_MARKER for a marker: pc[0] and pc[1] then hold its two values
uint32_t pc[5];
};
static constexpr uint32_t ALLOC_CAP = 1024;
static constexpr uint32_t ALLOC_MARKER = UINT32_MAX;
AllocEntry g_allocs[ALLOC_CAP];
std::atomic<uint32_t> g_alloc_n{0};
std::atomic<uint32_t> g_new_count{0};
// seq_cst with g_alloc_busy: the dump stores false then loads busy, a recorder adds busy then loads this
std::atomic<bool> g_alloc_armed{false};
uint32_t g_alloc_armed_ms = 0; // when recording started, 0 if it never did
// Recorders between claiming a slot and filling it; the dump waits for 0 after disarming.
std::atomic<uint32_t> g_alloc_busy{0};
// Claims the next slot, or returns nullptr and disarms once the buffer is full.
static inline AllocEntry *alloc_claim(uint32_t size) {
uint32_t i = g_alloc_n.fetch_add(1);
if (i >= ALLOC_CAP) {
g_alloc_armed = false;
return nullptr;
}
AllocEntry &e = g_allocs[i];
e.ms = (uint32_t) (esp_timer_get_time() / 1000);
e.size = size;
return &e;
}
void *operator new(size_t size) {
g_new_count.fetch_add(1, std::memory_order_relaxed);
g_alloc_busy.fetch_add(1); // before the armed check, so a dump that disarmed first sees it
AllocEntry *e = g_alloc_armed ? alloc_claim(size) : nullptr;
if (e != nullptr) {
e->pc[0] = e->pc[1] = e->pc[2] = e->pc[3] = e->pc[4] = 0;
#ifdef __XTENSA__
esp_backtrace_frame_t f;
esp_backtrace_get_start(&f.pc, &f.sp, &f.next_pc);
// The start frame is this function; record the five callers above it.
// Stop at the first frame that does not unwind; the slots after it stay 0.
for (int k = 0; k < 5 && f.next_pc != 0 && esp_backtrace_get_next_frame(&f); k++)
e->pc[k] = esp_cpu_process_stack_pc(f.pc);
#endif
}
g_alloc_busy.fetch_sub(1);
void *p = malloc(size ? size : 1);
if (p == nullptr)
abort();
return p;
}
void *operator new[](size_t size) { return operator new(size); }
void operator delete(void *p) noexcept { free(p); }
void operator delete[](void *p) noexcept { free(p); }
void operator delete(void *p, size_t) noexcept { free(p); }
void operator delete[](void *p, size_t) noexcept { free(p); }
// Splits the dump into phases, for example on each change of an entity's state.
static inline void alloc_marker(uint32_t a, uint32_t b) {
g_alloc_busy.fetch_add(1);
if (AllocEntry *e = g_alloc_armed ? alloc_claim(ALLOC_MARKER) : nullptr) {
e->pc[0] = a;
e->pc[1] = b;
e->pc[2] = e->pc[3] = e->pc[4] = 0;
}
g_alloc_busy.fetch_sub(1);
}
@@ -1,53 +0,0 @@
# Package for allocring.hpp: arms it ${alloc_arm_s} s after boot, reports that on a binary sensor and adds the
# dump button. See heap_alloc.md.
esphome:
includes:
- allocring.hpp
on_boot:
then:
- delay: ${alloc_arm_s}s
- lambda: |-
g_alloc_armed_ms = millis();
g_alloc_armed = true;
ESP_LOGI("allocs", "ARMED total=%u", g_new_count.load());
binary_sensor:
# Drivers wait for this before the first action, so no step runs before recording starts
- platform: template
name: Allocation recorder armed
lambda: return g_alloc_armed.load();
interval:
- interval: 30s
then:
- lambda: |-
ESP_LOGI("allocs", "total=%u kept=%u heap=%u", g_new_count.load(), (unsigned) g_alloc_n.load(),
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
button:
- platform: template
name: Dump allocations
on_press:
# Recording stops for good; reboot before the next run, or a second press returns the same entries.
- lambda: |-
g_alloc_armed = false;
// let recorders on other tasks finish the entry they claimed
for (int w = 0; w < 100 && g_alloc_busy.load() != 0; w++)
delay(1);
uint32_t claimed = g_alloc_n.load();
uint32_t n = std::min<uint32_t>(claimed, ALLOC_CAP);
// claimed > n means the buffer filled and recording stopped early
// armed_ms=0: the recorder never ran; busy>0: an entry may still be half written;
// buf is the buffer's address, which decode.py matches against the ELF
ESP_LOGI("allocs", "DUMP n=%u claimed=%u total=%u armed_ms=%u busy=%u buf=%08x", n, claimed,
g_new_count.load(), g_alloc_armed_ms, g_alloc_busy.load(), (unsigned) (uintptr_t) g_allocs);
for (uint32_t i = 0; i < n; i++) {
const AllocEntry &e = g_allocs[i];
ESP_LOGI("allocs", "E %u %u %u %08x %08x %08x %08x %08x", i, e.ms, e.size, e.pc[0], e.pc[1], e.pc[2],
e.pc[3], e.pc[4]);
if ((i & 15) == 15) {
delay(20); // let the log drain
App.feed_wdt(); // a full dump over a 115200 baud UART log outlasts the task watchdog
}
}
ESP_LOGI("allocs", "DUMP END");
@@ -1,108 +0,0 @@
"""Move the device's first cover to the far end and back N times with a states-only client, then save the dump.
usage: cycles_cover.py HOST CYCLES OUT env: API_KEY (unset for a plaintext API)
Waits for the recorder's armed binary sensor first. Exits 2 when a move did not reach its position (the
cover never moved, so the dump proves nothing).
"""
import asyncio
import pathlib
import sys
import time
from aioesphomeapi import (
BinarySensorInfo,
BinarySensorState,
CoverInfo,
CoverOperation,
CoverState,
)
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from apiconn import connect # noqa: E402
from heap_alloc.dump import dump # noqa: E402
# After each move, so allocations that come once the cover has stopped land in the same cycle
SETTLE_S = 8
ARM_WAIT_S = 600
async def main() -> None:
host, cycles, out = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with pathlib.Path(out).open("w", encoding="utf-8") as f:
def w(s: str) -> None:
f.write(time.strftime("%H:%M:%S ") + s + "\n")
f.flush()
c = await connect(host)
ents, _ = await c.list_entities_services()
cover = next(e for e in ents if isinstance(e, CoverInfo))
armed_info = next(
e
for e in ents
if isinstance(e, BinarySensorInfo)
and e.object_id == "allocation_recorder_armed"
)
states: asyncio.Queue = asyncio.Queue()
armed = asyncio.Event()
def on_state(s) -> None:
if isinstance(s, CoverState) and s.key == cover.key:
states.put_nowait(s)
elif (
isinstance(s, BinarySensorState) and s.key == armed_info.key and s.state
):
armed.set()
c.subscribe_states(on_state)
latest = await asyncio.wait_for(states.get(), 10)
# moves made before recording starts would be missing from the dump
await asyncio.wait_for(armed.wait(), ARM_WAIT_S)
w("ARMED")
def drain() -> CoverState:
"""The newest queued state, so no stale one is read as this command's result."""
nonlocal latest
while not states.empty():
latest = states.get_nowait()
return latest
async def move(pos: float) -> bool:
drain()
w(f"CMD {pos}")
c.cover_command(cover.key, position=pos)
moved = False
deadline = time.monotonic() + 60
while (left := deadline - time.monotonic()) > 0:
try:
s = await asyncio.wait_for(states.get(), left)
except TimeoutError:
break
idle = s.current_operation == CoverOperation.IDLE
moved |= not idle
if moved and idle and abs(s.position - pos) < 0.01:
w(f"IDLE pos={s.position:.3f}")
return True
w("TIMEOUT")
return False
timeouts = 0
# start toward the far end, so the first command always moves the cover
order = (0.0, 1.0) if drain().position > 0.5 else (1.0, 0.0)
for i in range(cycles):
w(f"CYCLE {i + 1}")
for pos in order:
timeouts += not await move(pos)
await asyncio.sleep(SETTLE_S)
if timeouts:
# before the dump, which exits on its own failure
print(f"{timeouts} moves timed out", file=sys.stderr)
await dump(c, w)
await c.disconnect()
if timeouts:
sys.exit(2)
if __name__ == "__main__":
asyncio.run(main())
@@ -1,159 +0,0 @@
"""Decode an allocring dump into callers: decode.py LOG ELF [ADDR2LINE]
Prints each entry with its caller frames as function and file:line, and markers (size 0xFFFFFFFF, ALLOC_MARKER) as phase
separators.
ADDR2LINE defaults to an Xtensa one found in ESPHome's ESP-IDF tools cache, ~/.platformio or ~/.espressif.
Exits 2 when the dump is incomplete (missing header or entries, or the buffer filled during the run).
"""
from pathlib import Path
import re
import subprocess
import sys
ENTRY = re.compile(r"\bE (\d+) (\d+) (\d+) ((?:[0-9a-f]{8} ?){5})")
HEADER = re.compile(
r"\bDUMP n=(\d+) claimed=(\d+) total=\d+ armed_ms=(\d+) busy=(\d+) buf=([0-9a-f]{8})"
)
MARKER = 0xFFFFFFFF
XTENSA = 94 # ELF e_machine; the recorder walks frames only on Xtensa
def tool_roots() -> list[Path]:
roots = [Path.home() / ".platformio", Path.home() / ".espressif"]
try:
from esphome.espidf.framework import get_idf_tools_path
except ImportError:
return roots
return [get_idf_tools_path(), *roots]
def is_xtensa(elf: str) -> bool:
return int.from_bytes(Path(elf).read_bytes()[18:20], "little") == XTENSA
def find_addr2line() -> str:
for root in tool_roots():
for path in root.glob("**/bin/xtensa-esp*-elf-addr2line"):
if path.is_file():
return str(path)
sys.exit("no addr2line found; pass it as the third argument")
def short(path: str) -> str:
for marker in ("esphome/components/", "esphome/core/", "esp-idf/", "include/c++/"):
if marker in path:
return marker + path.split(marker, 1)[1]
return path
def decode(
addr2line: str, elf: str, pcs: list[str]
) -> dict[str, list[tuple[str, str]]]:
"""Every PC's frames (inlined ones too); -a prints each address before its frames."""
out = subprocess.run(
[addr2line, "-afiC", "-e", elf, *(f"0x{pc}" for pc in pcs)],
capture_output=True,
text=True,
check=True,
).stdout.splitlines()
names: dict[str, list[tuple[str, str]]] = {}
frames: list[tuple[str, str]] = []
i = 0
while i < len(out):
if out[i].startswith("0x"):
frames = names.setdefault(f"{int(out[i], 16):08x}", [])
i += 1
else:
frames.append((out[i], short(out[i + 1])))
i += 2
return names
def last_dump(text: str) -> str:
"""The newest dump in the file (a retried press or an appended file holds several); check() rejects it if cut off."""
start = max((m.start() for m in HEADER.finditer(text)), default=len(text))
return text[start:]
def check(dump: str, entries: list, xtensa: bool) -> list[str]:
"""Reasons the dump is incomplete, empty when it is whole."""
if not (header := HEADER.search(dump)):
return ["no DUMP header (log subscription started late?)"]
n, claimed, armed_ms, busy = (int(header[i]) for i in range(1, 5))
problems = []
if not armed_ms:
problems.append("the recorder was never armed (pressed before alloc_arm_s?)")
if busy:
problems.append(f"{busy} recorders were still writing an entry")
indexes = [int(e[0]) for e in entries]
if missing := sorted(set(range(n)) - set(indexes)):
problems.append(f"{len(missing)} of {n} entries missing, first {missing[:10]}")
if len(indexes) != len(set(indexes)):
problems.append("duplicate entries")
if stray := sorted(i for i in set(indexes) if i >= n):
problems.append(f"entries past n={n}: {stray[:10]}")
allocs = [pcs for _, _, size, pcs in entries if size != MARKER]
if xtensa and allocs and all(pcs[0] == "00000000" for pcs in allocs):
problems.append("no entry has a caller (frame walk failed?)")
if claimed > n:
problems.append(f"buffer filled: recording stopped after {n} entries")
if "DUMP END" not in dump:
problems.append("no DUMP END")
return problems
def main() -> None:
log, elf = sys.argv[1], sys.argv[2]
text = last_dump(Path(log).read_text(encoding="utf-8", errors="replace"))
entries = [
(idx, int(ms), int(size), pcs.split())
for idx, ms, size, pcs in ENTRY.findall(text)
]
wanted = {
pc
for _, _, size, pcs in entries
if size != MARKER
for pc in pcs
if pc != "00000000"
}
# Only Xtensa dumps carry callers; RISC-V ones still print sizes and times
names = {}
if wanted:
addr2line = sys.argv[3] if len(sys.argv) > 3 else find_addr2line()
names = decode(addr2line, elf, sorted(wanted))
for idx, ms, size, pcs in entries:
if size == MARKER:
print(
f"--- marker at {ms / 1000:.3f} s: {int(pcs[0], 16)} {int(pcs[1], 16)}"
)
continue
print(f"#{idx} {ms / 1000:.3f} s {size} B")
for pc in pcs:
if pc == "00000000":
break
for func, loc in names[pc]:
print(f" {func[:110]} {loc}")
problems = check(text, entries, is_xtensa(elf))
# addr2line names any address, so check the ELF is the build that made the dump
if wanted and (header := HEADER.search(text)):
nm = addr2line[: -len("addr2line")] + "nm"
if not Path(nm).is_file():
problems.append(f"cannot check the ELF: no {nm} next to addr2line")
elif not re.search(
rf"^0*{int(header[5], 16):x} [bBdD] g_allocs$",
subprocess.run(
[nm, elf], capture_output=True, text=True, check=True
).stdout,
re.MULTILINE,
):
problems.append(
"the ELF is not the build that made this dump (g_allocs address differs)"
)
if problems:
print("INCOMPLETE DUMP: " + "; ".join(problems), file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()
@@ -1,84 +0,0 @@
"""Press the "Dump allocations" button from allocring.yaml and save the dump.
usage: dump.py HOST OUT env: API_KEY (unset for a plaintext API)
"""
import asyncio
from collections.abc import Callable
import contextlib
import pathlib
import re
import sys
from aioesphomeapi import APIClient, ButtonInfo
from aioesphomeapi.model import LogLevel
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from apiconn import connect, log_text # noqa: E402
HEADER = re.compile(r"\bDUMP n=(\d+) claimed=\d+ total=\d+ armed_ms=(\d+) busy=(\d+)")
ENTRY = re.compile(r"\bE (\d+) \d+ \d+ ")
async def dump(c: APIClient, write: Callable[[str], None], tries: int = 3) -> None:
"""Subscribe to logs, press the dump button and pass every line to WRITE until DUMP END.
subscribe_logs does not confirm the subscription and the device drops log lines when busy, so the press is
repeated (the dump is repeatable) until the header and all n entries arrived; exits 2 after TRIES presses.
"""
ents, _ = await c.list_entities_services()
button = next(
e
for e in ents
if isinstance(e, ButtonInfo) and e.object_id == "dump_allocations"
)
seen: dict = {"n": None, "idx": set()}
done = asyncio.Event()
def on_log(msg) -> None:
text = log_text(msg)
write(text)
if m := HEADER.search(text):
seen["n"], seen["idx"] = int(m[1]), set()
seen["armed"], seen["busy"] = int(m[2]) != 0, int(m[3])
elif m := ENTRY.search(text):
seen["idx"].add(int(m[1]))
elif "DUMP END" in text and seen["n"] is not None:
# without this press's header it is a late end from an earlier press
done.set()
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_INFO)
for _ in range(tries):
await asyncio.sleep(2)
seen["n"] = None
done.clear()
c.button_command(button.key)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(done.wait(), 120)
# the device drops log lines when its send buffer is full, so require every entry
if done.is_set() and seen["n"] is not None and not seen["armed"]:
print(
"the recorder was never armed (pressed before alloc_arm_s?)",
file=sys.stderr,
)
sys.exit(2)
if (
done.is_set()
and seen["n"] is not None
and not seen["busy"]
and seen["idx"] == set(range(seen["n"]))
):
return
print(f"no whole dump after {tries} presses", file=sys.stderr)
sys.exit(2)
async def main() -> None:
c = await connect(sys.argv[1])
with pathlib.Path(sys.argv[2]).open("w", encoding="utf-8") as f:
await dump(c, lambda s: f.write(s + "\n"))
await c.disconnect()
if __name__ == "__main__":
asyncio.run(main())
@@ -70,9 +70,7 @@ def frame(msg_type: int, payload: bytes) -> bytes:
return b"\x00" + varint(len(payload)) + varint(msg_type) + payload
def f_bytes(num: int, data: bytes | str) -> bytes:
if isinstance(data, str):
data = data.encode()
def f_bytes(num: int, data: bytes) -> bytes:
return varint((num << 3) | 2) + varint(len(data)) + data
@@ -1,51 +0,0 @@
"""Press the device's buttons through Home Assistant and print the script and flag states after each step.
usage: drive.py TOKENFILE NAME env: HA_URL (default http://127.0.0.1:8129)
NAME is the device name from hsw.yaml (button entity ids are button.<NAME>_long_on and so on).
"""
import pathlib
import sys
import time
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent / "mqtt" / "ha_discovery")
)
from ha_rest import req_or_exit # noqa: E402
tok = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip()
name = sys.argv[2].replace("-", "_") # entity ids use underscores
t0 = time.monotonic()
def state(entity: str) -> str:
return req_or_exit(f"/api/states/{entity}", token=tok)["state"]
def show(tag: str) -> None:
print(
f"{time.monotonic() - t0:6.1f}s {tag:30s} long_job={state('script.long_job'):4s}"
f" quick_job={state('script.quick_job'):4s} flag={state('input_boolean.script_ran')}",
flush=True,
)
def call(domain: str, action: str, entity: str, wait: float = 3) -> None:
req_or_exit(f"/api/services/{domain}/{action}", {"entity_id": entity}, token=tok)
time.sleep(wait)
show("start")
call("button", "press", f"button.{name}_long_on")
show("device Long on")
call("button", "press", f"button.{name}_long_off")
show("device Long off")
call("input_boolean", "turn_off", "input_boolean.script_ran", 1)
call("button", "press", f"button.{name}_long_on")
show("device Long on again")
time.sleep(20)
show("script finished on its own")
call("button", "press", f"button.{name}_quick_on")
show("device Quick on")
call("script", "turn_on", "script.long_job")
show("HA starts the script")
@@ -1,33 +0,0 @@
# Throwaway Home Assistant on port 8129: a 20 s script that sets and clears a flag, and a toggle script.
http:
server_port: 8129
api:
config:
onboarding:
frontend:
input_boolean:
script_ran:
script:
long_job:
alias: Long job
mode: single
sequence:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.script_ran
- delay: 20
- action: input_boolean.turn_off
target:
entity_id: input_boolean.script_ran
quick_job:
alias: Quick job
sequence:
- action: input_boolean.toggle
target:
entity_id: input_boolean.script_ran
logger:
default: warning
logs:
homeassistant.components.esphome: debug
homeassistant.components.script: debug
homeassistant.helpers.script: info
@@ -1,36 +0,0 @@
"""Add a host build's API to a throwaway Home Assistant and allow it to call actions.
usage: haflow.py TOKENFILE env: HA_URL (default http://127.0.0.1:8129)
Runs the esphome config flow against 127.0.0.1:6053, then turns on allow_service_calls in the options flow.
"""
import pathlib
import sys
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent / "mqtt" / "ha_discovery")
)
from ha_rest import req_or_exit # noqa: E402
tok = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip()
flow = "/api/config/config_entries/flow"
f = req_or_exit(flow, {"handler": "esphome"}, token=tok)
r = req_or_exit(
f"{flow}/{f['flow_id']}", {"host": "127.0.0.1", "port": 6053}, token=tok
)
if r.get("type") == "form" and r.get("errors"):
sys.exit(f"config flow step {r.get('step_id')} failed: {r['errors']}")
if r.get("type") == "form": # confirmation step
r = req_or_exit(f"{flow}/{r['flow_id']}", {}, token=tok)
if r.get("type") != "create_entry":
sys.exit(
f"config flow ended with {r.get('type')} {r.get('errors') or r.get('reason')}"
)
opts = "/api/config/config_entries/options/flow"
o = req_or_exit(opts, {"handler": r["result"]["entry_id"]}, token=tok)
o = req_or_exit(f"{opts}/{o['flow_id']}", {"allow_service_calls": True}, token=tok)
if o.get("type") != "create_entry":
sys.exit(
f"options flow ended with {o.get('type')} {o.get('errors') or o.get('reason')}"
)
print("added", r.get("title"), "with allow_service_calls")
@@ -1,39 +0,0 @@
# Host build mirroring two Home Assistant scripts and the flag they set. Substitution: name.
esphome:
name: ${name}
host:
logger:
level: DEBUG
api:
reboot_timeout: 0s
switch:
- platform: homeassistant
id: sw_long
name: "Long job mirror"
entity_id: script.long_job
on_turn_on:
- logger.log: "EV long ON"
on_turn_off:
- logger.log: "EV long OFF"
- platform: homeassistant
id: sw_quick
name: "Quick job mirror"
entity_id: script.quick_job
- platform: homeassistant
id: sw_bool
name: "Ran flag mirror"
entity_id: input_boolean.script_ran
on_turn_on:
- logger.log: "EV flag ON"
on_turn_off:
- logger.log: "EV flag OFF"
button:
- platform: template
name: "Long on"
on_press: [switch.turn_on: sw_long]
- platform: template
name: "Long off"
on_press: [switch.turn_off: sw_long]
- platform: template
name: "Quick on"
on_press: [switch.turn_on: sw_quick]
@@ -1,59 +0,0 @@
# One fixed entity_id entity and one wizard-linked entity (no entity_id) per homeassistant platform that a wizard
# input can stand for, on top of the three buttons of ha-button-common.yaml, on an ESP8266 (the ramchk interval reads
# the ESP8266 heap). run.sh adds esphome name and board, and ha-wiz-light.yaml unless NO_LIGHT is set.
packages:
buttons: !include ../homeassistant_button/ha-button-common.yaml
api:
wizard:
pages:
- inputs:
- entity: s_wiz
- entity: sa_wiz
- entity: b_wiz
- entity: t_wiz
- entity: n_wiz
- entity: sw_wiz
- entity: sel_wiz
- entity: tx_wiz
# Free heap every 5 s, and the heap size, for static + heap used
interval:
- interval: 5s
then:
- lambda: |-
uint32_t heap_size = 0x3FFFC000u - reinterpret_cast<uint32_t>(&_heap_start);
ESP_LOGI("ramchk", "heap_size=%u free=%u max_block=%u", heap_size, ESP.getFreeHeap(), ESP.getMaxFreeBlockSize());
# homeassistant entities are internal by default; internal: false lets the API client list and drive them
sensor:
- {platform: homeassistant, id: s_fix, internal: false, name: S fix, entity_id: sensor.outside}
- {platform: homeassistant, id: s_wiz, internal: false, name: S wiz}
- {platform: homeassistant, id: sa_wiz, internal: false, name: SA wiz, attribute: temperature}
binary_sensor:
- {platform: homeassistant, id: b_fix, internal: false, name: B fix, entity_id: binary_sensor.door}
- {platform: homeassistant, id: b_wiz, internal: false, name: B wiz}
text_sensor:
- {platform: homeassistant, id: t_fix, internal: false, name: T fix, entity_id: sensor.weather}
- {platform: homeassistant, id: t_wiz, internal: false, name: T wiz}
number:
- {platform: homeassistant, id: n_fix, internal: false, name: N fix, entity_id: input_number.target}
- {platform: homeassistant, id: n_wiz, internal: false, name: N wiz}
switch:
- {platform: homeassistant, id: sw_fix, internal: false, name: SW fix, entity_id: switch.porch}
- {platform: homeassistant, id: sw_wiz, internal: false, name: SW wiz}
select:
- {platform: homeassistant, id: sel_fix, internal: false, name: SEL fix, entity_id: input_select.mode}
- {platform: homeassistant, id: sel_wiz, internal: false, name: SEL wiz}
text:
- {platform: homeassistant, id: tx_fix, internal: false, name: TX fix, entity_id: input_text.note}
- {platform: homeassistant, id: tx_wiz, internal: false, name: TX wiz}
light:
- {platform: homeassistant, id: l_fix, internal: false, name: L fix, entity_id: light.kitchen}
@@ -1,9 +0,0 @@
# The wizard-linked light, which builds before #21908 cannot compile; run.sh leaves it out when NO_LIGHT is set.
api:
wizard:
pages:
- inputs:
- entity: l_wiz
light:
- {platform: homeassistant, id: l_wiz, internal: false, name: L wiz}
@@ -1,495 +0,0 @@
"""Drive every homeassistant platform over a plaintext native API connection, playing Home Assistant.
usage: hadrive.py HOST [--no-light] (exits 1 on any failed check)
Matches ha-all-common.yaml: one fixed entity_id entity and one wizard-linked entity per platform. The client
subscribes to entity states, Home Assistant actions and Home Assistant state subscriptions, then:
A sends Home Assistant states for the fixed entities (and for a wizard ID that is not set yet), commands every
writer, and checks that the wizard-linked ones send nothing;
B sets every wizard input (WizardInputSetRequest), records the subscriptions the device sends for it, sends
states for the new IDs and commands the writers;
C changes every wizard input to a second ID: old IDs must be ignored, new ones followed;
D sends an invalid ID, which the device must reject.
Prints canonical lines (SUB, STATE, ACTION, CHECK) so two builds can be diffed, and PASS or FAIL at the end.
"""
import argparse
from collections.abc import Callable
import pathlib
import struct
import sys
import time
from typing import NamedTuple
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parents[1] / "homeassistant_button")
)
from habdrive import ( # noqa: E402
Client as BaseClient,
f_bytes,
f_fixed32,
f_varint,
fields,
fnv1_hash,
varint,
)
def f_float(num: int, value: float) -> bytes:
return varint((num << 3) | 5) + struct.pack("<f", value)
# ListEntities response type -> platform
LIST_TYPES = {
12: "binary_sensor",
15: "light",
16: "sensor",
17: "switch",
18: "text_sensor",
49: "number",
52: "select",
61: "button",
97: "text",
}
STATE_TYPES = {
21: "bool",
24: "bool",
25: "float",
26: "bool",
27: "str",
50: "float",
53: "str",
98: "str",
}
class Client(BaseClient):
def __init__(self, host: str) -> None:
super().__init__(host)
self.keys: dict[str, int] = {}
self.names: dict[int, str] = {}
self.failures: list[str] = []
self.last: dict[
str, str
] = {} # the last state the device published, by entity name
def login(
self, name: str, connect_wait: float = 1, list_entities: bool = True
) -> None:
"""Hello (API 1.14) and Connect, then ListEntities into keys and names."""
self.send(1, f_bytes(1, name) + f_varint(2, 1) + f_varint(3, 14))
self.pump(10, until=2)
self.send(3)
self.pump(connect_wait)
if not list_entities:
return
self.send(11)
for msg_type, payload in self.pump(10, until=19):
if msg_type in LIST_TYPES:
d = dict(fields(payload))
key, name = struct.unpack("<I", d[2])[0], d[3].decode()
self.keys[name] = key
self.names[key] = name
def report(
self, frames: list[tuple[int, bytes]], label: str
) -> dict[str, list[str]]:
"""Print SUB, STATE and ACTION lines for frames and return them by kind."""
out = self.parse(frames)
for line in out["STATE"]:
name, _, value = line.partition(" = ")
self.last[name] = value
for kind in ("SUB", "STATE", "ACTION"):
for line in sorted(out[kind]) if kind == "SUB" else out[kind]:
print(f" {label} {kind} {line}")
return out
def collect(
self,
label: str,
done: Callable[[dict[str, list[str]]], bool] | None = None,
timeout: float = 6.0,
settle: float = 0.5,
) -> dict[str, list[str]]:
"""Read until done(parsed) holds (or for timeout without one), then settle more seconds for strays."""
frames: list[tuple[int, bytes]] = []
end = time.time() + timeout
while time.time() < end:
frames += self.pump(0.2)
if done is not None and done(self.parse(frames)):
break
frames += self.pump(settle)
return self.report(frames, label)
def parse(self, frames: list[tuple[int, bytes]]) -> dict[str, list[str]]:
out: dict[str, list[str]] = {"SUB": [], "STATE": [], "ACTION": []}
for msg_type, payload in frames:
f = fields(payload)
d = dict(f)
if msg_type == 39: # SubscribeHomeAssistantStateResponse
attr = d.get(2, b"").decode() or "-"
line = f"{d.get(1, b'').decode()} {attr}{' once' if d.get(3) else ''}"
out["SUB"].append(line)
elif msg_type in STATE_TYPES:
key = struct.unpack("<I", d[1])[0]
kind = STATE_TYPES[msg_type]
if kind == "float":
value = d.get(2)
value = (
f"{struct.unpack('<f', value)[0]:g}"
if value is not None
else "0"
)
elif kind == "bool":
value = "ON" if d.get(2) else "OFF"
else:
value = repr(d.get(2, b"").decode())
if msg_type not in (24, 26) and d.get(
3
): # field 3 of a light state is brightness
value += " missing"
out["STATE"].append(f"{self.names.get(key, key)} = {value}")
elif msg_type == 35: # HomeassistantActionRequest
service, data = "", []
for num, value in f:
if num == 1:
service = value.decode()
elif num in (2, 3):
kv = dict(fields(value))
data.append(
f"{kv.get(1, b'').decode()}={kv.get(2, b'').decode()}"
)
out["ACTION"].append(f"{service} {' '.join(data)}")
return out
def check(self, label: str, ok: bool, detail: str) -> None:
print(f" CHECK {label}: {'ok' if ok else 'FAIL'} {detail}")
if not ok:
self.failures.append(f"{label}: {detail}")
def ha_state(self, entity_id: str, state: str, attribute: str = "") -> None:
payload = f_bytes(1, entity_id) + f_bytes(2, state)
if attribute:
payload += f_bytes(3, attribute)
self.send(40, payload)
def wizard(self, input_id: str, entity_id: str) -> None:
self.send(158, f_fixed32(1, fnv1_hash(input_id)) + f_bytes(2, entity_id))
def feed(c: Client, platform: str, eid: str, n: int) -> None:
"""Send what Home Assistant sends for a platform; n varies the values between rounds."""
if platform == "sensor":
c.ha_state(eid, f"{20 + n}.5")
elif platform == "sensor_attr":
c.ha_state(eid, "heat") # the state itself must not reach an attribute sensor
c.ha_state(eid, f"{30 + n}", "temperature")
elif platform == "binary_sensor":
c.ha_state(eid, "on" if n % 2 else "off")
elif platform == "text_sensor":
c.ha_state(eid, f"text{n}")
elif platform == "number":
c.ha_state(eid, "0", "min")
c.ha_state(eid, "100", "max")
c.ha_state(eid, "1", "step")
c.ha_state(eid, f"{40 + n}")
elif platform == "switch":
c.ha_state(eid, "on" if n % 2 else "off")
elif platform == "select":
c.ha_state(eid, "['eco', 'comfort', 'away']", "options")
c.ha_state(eid, ["eco", "comfort", "away"][n % 3])
elif platform == "text":
c.ha_state(eid, "0", "min")
c.ha_state(eid, "100", "max")
c.ha_state(eid, "text", "mode")
c.ha_state(eid, f"note{n}")
elif platform == "light":
c.ha_state(eid, "['onoff']", "supported_color_modes")
c.ha_state(eid, "onoff", "color_mode")
c.ha_state(eid, "on" if n % 2 else "off")
def expected_state(platform: str, n: int) -> str | None:
return {
"sensor": f"{20 + n}.5",
"sensor_attr": f"{30 + n}",
"binary_sensor": "ON" if n % 2 else "OFF",
"text_sensor": repr(f"text{n}"),
"number": f"{40 + n}",
"switch": "ON" if n % 2 else "OFF",
"select": repr(["eco", "comfort", "away"][n % 3]),
"text": repr(f"note{n}"),
"light": "ON" if n % 2 else "OFF",
}.get(platform)
class Row(NamedTuple):
fix: str | None # name of the fixed entity_id entity
fix_id: str | None
wiz: str | None # name of the wizard-linked entity
input_id: str | None # ESPHome id of its wizard input
platform: str
one: str | None # the first and second entity IDs the wizard input is set to
two: str | None
# fmt: off
PLATFORMS = [
Row("S fix", "sensor.outside", "S wiz", "s_wiz", "sensor", "sensor.wiz_one", "sensor.wiz_two"),
Row(None, None, "SA wiz", "sa_wiz", "sensor_attr", "climate.wiz_one", "climate.wiz_two"),
Row("B fix", "binary_sensor.door", "B wiz", "b_wiz", "binary_sensor", "binary_sensor.wiz_one",
"binary_sensor.wiz_two"),
Row("T fix", "sensor.weather", "T wiz", "t_wiz", "text_sensor", "sensor.wiz_text_one", "sensor.wiz_text_two"),
Row("N fix", "input_number.target", "N wiz", "n_wiz", "number", "input_number.wiz_one", "number.wiz_two"),
Row("SW fix", "switch.porch", "SW wiz", "sw_wiz", "switch", "input_boolean.wiz_one", "switch.wiz_two"),
Row("SEL fix", "input_select.mode", "SEL wiz", "sel_wiz", "select", "input_select.wiz_one", "select.wiz_two"),
Row("TX fix", "input_text.note", "TX wiz", "tx_wiz", "text", "input_text.wiz_one", "text.wiz_two"),
Row("L fix", "light.kitchen", "L wiz", "l_wiz", "light", "light.wiz_one", "light.wiz_two"),
]
# fmt: on
# the fixed buttons come from ha-button-common.yaml
WIZARD_BUTTON = Row(
None,
None,
"HA wizard",
"hab_wiz",
"button",
"input_button.wiz_one",
"button.wiz_two",
)
# The subscriptions each platform sends for one entity ID
SUBS = {
"button": [],
"sensor": ["{} -"],
"sensor_attr": ["{} temperature"],
"binary_sensor": ["{} -"],
"text_sensor": ["{} -"],
"number": ["{} -", "{} max once", "{} min once", "{} step once"],
"switch": ["{} -"],
"select": ["{} -", "{} options"],
"text": ["{} -", "{} max once", "{} min once", "{} mode once"],
"light": [
"{} -",
"{} brightness",
"{} color_mode",
"{} color_temp_kelvin",
"{} max_color_temp_kelvin",
"{} min_color_temp_kelvin",
"{} rgb_color",
"{} supported_color_modes",
],
}
def command(c: Client, name: str, platform: str, n: int) -> str | None:
"""Command a writer entity; returns the action it must send for entity ID {}, or None for a reader."""
key = c.keys[name]
if platform == "number":
c.send(51, f_fixed32(1, key) + f_float(2, 10 + n))
return "{d}.set_value entity_id={e} value=" + f"{10 + n}"
if platform == "switch":
state = n % 2 == 0 # the opposite of what feed() reported
c.send(33, f_fixed32(1, key) + f_varint(2, int(state)))
return f"homeassistant.turn_{'on' if state else 'off'} entity_id={{e}}"
if platform == "select":
option = ["eco", "comfort", "away"][(n + 1) % 3]
c.send(54, f_fixed32(1, key) + f_bytes(2, option))
return "{d}.select_option entity_id={e} option=" + option
if platform == "text":
c.send(99, f_fixed32(1, key) + f_bytes(2, f"cmd{n}"))
return "{d}.set_value entity_id={e} value=" + f"cmd{n}"
if platform == "light":
state = n % 2 == 0
c.send(32, f_fixed32(1, key) + f_varint(2, 1) + f_varint(3, int(state)))
return f"light.turn_{'on' if state else 'off'} entity_id={{e}}"
if platform == "button":
c.send(62, f_fixed32(1, key))
return "{d}.press entity_id={e}"
return None
def action_for(template: str, eid: str) -> str:
# number, select, text and button call the action of the entity's own domain (input_number.set_value, ...)
if template.startswith("{d}"):
return template.format(d=eid.split(".", maxsplit=1)[0], e=eid)
return template.format(e=eid)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("host")
parser.add_argument(
"--no-light", action="store_true", help="the config has no wizard light"
)
args = parser.parse_args()
c = Client(args.host)
c.login("hadrive", connect_wait=2)
print("entities", len(c.keys), sorted(c.keys))
# only the wizard light may be left out (builds before #21908 cannot compile it)
rows: list[Row] = []
for row in [*PLATFORMS, WIZARD_BUTTON]:
if row.wiz and row.wiz not in c.keys:
c.check(
f"{row.wiz} listed", args.no_light and row.wiz == "L wiz", "missing"
)
row = row._replace(wiz=None, input_id=None)
if row.fix or row.wiz:
rows.append(row)
wizard = [row for row in rows if row.wiz]
c.send(20) # SubscribeStatesRequest
c.report(c.pump(3), "INIT")
c.send(34) # SubscribeHomeassistantServicesRequest
c.pump(1)
c.send(38) # SubscribeHomeAssistantStatesRequest
expected = sorted(
s.format(r.fix_id) for r in rows if r.fix for s in SUBS[r.platform]
)
subs = c.collect("A", lambda o: len(o["SUB"]) >= len(expected), 10, 2)["SUB"]
c.check(
"A subscriptions are the fixed entities only",
sorted(subs) == expected,
f"{len(subs)} subs",
)
def drive(
label: str, entries: list[tuple[str, str | None, str]], n: int, stale: list[str]
) -> None:
"""entries: (name, entity ID or None if unset, platform). stale: IDs the device must ignore."""
for eid in stale:
c.ha_state(eid, "999")
c.ha_state(eid, "999", "temperature")
prev = dict(c.last)
for _, eid, platform in entries:
if eid:
feed(c, platform, eid, n)
wants = {
f"{name} = {expected_state(p, n)}"
for name, eid, p in entries
if eid and expected_state(p, n)
}
got = c.collect(f"{label}-feed", lambda o: wants <= set(o["STATE"]), 8, 1.5)
states = got["STATE"]
for name, eid, platform in entries:
want = expected_state(platform, n)
if want is None:
continue
mine = [s for s in states if s.startswith(f"{name} = ")]
if eid:
# A light publishes only a change, so a report of the state it already has publishes nothing
same = platform == "light" and not mine and prev.get(name) == want
c.check(
f"{label} {name} follows {eid}",
f"{name} = {want}" in mine or same,
str(mine),
)
else:
c.check(f"{label} {name} unset publishes nothing", not mine, str(mine))
c.check(
f"{label} stale IDs ignored",
not any("999" in s or "= 'heat'" in s for s in states),
"",
)
for name, eid, platform in entries:
if platform in ("sensor", "sensor_attr", "binary_sensor", "text_sensor"):
continue
template = command(c, name, platform, n)
if eid is None:
got = c.collect(f"{label}-cmd {name}", None, 2)
else:
got = c.collect(f"{label}-cmd {name}", lambda o: bool(o["ACTION"]), 6)
actions = got["ACTION"]
if eid is None:
c.check(
f"{label} {name} unset sends nothing", not actions, str(actions)
)
continue
want = action_for(template, eid)
c.check(
f"{label} {name} action", actions == [want], f"{actions} want {want}"
)
if platform == "light":
c.ha_state(
eid, "on" if n % 2 == 0 else "off"
) # Home Assistant echoes the change
c.collect(f"{label}-echo {name}", None, 1.5)
fixed = [(r.fix, r.fix_id, r.platform) for r in rows if r.fix]
fixed += [
("HA button", "button.doorbell", "button"),
("HA input", "input_button.chime", "button"),
]
wiz = [(r.wiz, None, r.platform) for r in wizard]
print("== A: fixed entities, wizard inputs not set")
drive("A", fixed + wiz, 1, ["sensor.wiz_one", "climate.wiz_one"])
def set_all(label: str, idx: int) -> dict[str, str]:
ids = {}
for row in wizard:
name = row.wiz
eid = row.one if idx == 1 else row.two
ids[name] = eid
c.wizard(row.input_id, eid)
want = sorted(s.format(eid) for s in SUBS[row.platform])
n_want = len(want)
if want:
subs = c.collect(
f"{label}-set {name}",
lambda o, n=n_want: len(o["SUB"]) >= n,
6,
0.5,
)["SUB"]
else:
subs = c.collect(f"{label}-set {name}", None, 2)["SUB"]
c.check(
f"{label} {name} subscribes to {eid}",
sorted(subs) == want,
f"{sorted(subs)}",
)
return ids
print("== B: wizard inputs set to the first IDs")
ids = set_all("B", 1)
drive("B", [(name, ids[name], p) for name, _, p in wiz], 2, [])
print("== C: wizard inputs changed to the second IDs")
old = [row.one for row in wizard]
ids = set_all("C", 2)
drive("C", [(name, ids[name], p) for name, _, p in wiz], 3, old)
print("== D: invalid ID rejected")
for input_id in ("n_wiz", "hab_wiz", "s_wiz"):
c.wizard(input_id, "not_an_entity")
subs = c.collect(f"D-set {input_id}", None, 2)["SUB"]
c.check(f"D {input_id} invalid set sends no subscription", not subs, str(subs))
drive(
"D",
[
(name, ids[name], p)
for name, _, p in wiz
if name in ("N wiz", "HA wizard", "S wiz")
],
4,
[],
)
print("== E: a new connection gets every subscription with the current IDs")
c.sock.close()
c2 = Client(parser.parse_args().host)
c2.login("hadrive2", list_entities=False)
c2.send(38)
expected = sorted(
[s.format(r.fix_id) for r in rows if r.fix for s in SUBS[r.platform]]
+ [s.format(ids[r.wiz]) for r in wizard for s in SUBS[r.platform]]
)
subs = c2.collect("E", lambda o: len(o["SUB"]) >= len(expected), 10, 2)["SUB"]
c.check(
"E subscriptions on reconnect",
sorted(subs) == expected,
f"{len(subs)} subs, want {len(expected)}",
)
if c.failures:
print("FAIL: " + "; ".join(c.failures))
sys.exit(1)
print("PASS")
if __name__ == "__main__":
main()
@@ -1,32 +0,0 @@
"""After hadrive.py: the wizard light must follow its current entity ID (light.wiz_two) and ignore the old one.
usage: lightcheck.py HOST
Sends on and off states for both IDs and prints what the device publishes after each; only light.wiz_two may move
"L wiz". A light publishes only changes, so a repeated state shows nothing.
"""
import sys
from hadrive import Client
def main() -> None:
c = Client(sys.argv[1])
c.login("lightcheck")
c.send(20) # SubscribeStatesRequest
c.collect("init", None, 3)
c.send(38) # SubscribeHomeAssistantStatesRequest
c.collect("subs", None, 4)
for eid, st in (
("light.wiz_one", "on"),
("light.wiz_two", "on"),
("light.wiz_one", "off"),
("light.wiz_two", "off"),
):
print(f"send {eid} = {st}")
c.ha_state(eid, st)
c.collect(" ->", None, 2.5)
if __name__ == "__main__":
main()
@@ -1,32 +0,0 @@
#!/bin/bash
# run.sh LABEL: build ha-all-common.yaml from TREE as hawiz<LABEL> for a D1 mini, flash, take three idle boots of heap
# readings (ramchk lines), then drive one boot with hadrive.py and print its result and the heap after the client.
# NO_LIGHT=1 drops the wizard-linked light, which builds before #21908 cannot compile.
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python)
R=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; N=hawiz$L
T=${TREE:?set TREE}; P=${PORT:?set PORT}; PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
OUT=$R/out-$N; rm -rf "$OUT"; mkdir -p "$OUT"
LIGHT=' light: !include ha-wiz-light.yaml\n'; NL=(); [ -n "$NO_LIGHT" ] && { LIGHT=; NL=(--no-light); }
printf "esphome:\n name: %s\n\nesp8266:\n board: d1_mini\n\npackages:\n common: !include ha-all-common.yaml\n$LIGHT" "$N" \
> "$R/$N.yaml"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && $PY -m esphome run --no-logs "$R/$N.yaml" --device "$P" > "$OUT/upload.log" 2>&1) ||
{ echo "build or upload failed"; tail -5 "$OUT/upload.log"; exit 1; }
grep -a -E '^(RAM|Flash):' "$OUT/upload.log"
for b in 1 2 3; do
$PY "$SC" "$P" 45 "$OUT/boot$b.txt" --reset > /dev/null 2>&1
echo "boot$b $(grep -a 'ramchk' "$OUT/boot$b.txt" | tail -1 | sed -E 's/.*(heap_size)/\1/')"
done
$PY "$SC" "$P" 150 "$OUT/serial.txt" --reset > /dev/null 2>&1 & CAP=$!
for _ in $(seq 1 30); do
IP=$(grep -a -o "IP Address: [0-9.]*" "$OUT/serial.txt" 2>/dev/null | tail -1 | cut -d' ' -f3)
[ -n "$IP" ] && break; sleep 2
done
[ -n "$IP" ] || { echo "no IP in the capture"; kill $CAP; exit 1; }
sleep 8
$PY "$R/hadrive.py" "$IP" "${NL[@]}" > "$OUT/drive.txt" 2>&1; RC=$?
sleep 6; kill $CAP
echo "drive rc=$RC $(tail -1 "$OUT/drive.txt" | cut -c1-300)"
echo "after-client $(grep -a 'ramchk' "$OUT/serial.txt" | tail -1 | sed -E 's/.*(heap_size)/\1/')"
echo "crash lines: $(grep -a -c -E 'rst cause|Exception|Soft WDT' "$OUT/serial.txt")"
exit $RC
@@ -1,16 +0,0 @@
# One run: p_fault is per mille of transactions, seed fixes the fault sequence (seed 1 hangs dev at about 245 s on #22270)
substitutions:
name: i2cfault-pr
freq: 10kHz
hammer: 100ms
ina_interval: 1s
p_fault: "50"
fault_mask: "0xFF"
hold_max_ms: "3000"
diag_recover: "false"
seed: "1"
fw: esp-idf
fwver: 5.5.5
packages:
base: !include i2cbase.yaml
@@ -1,132 +0,0 @@
# Package: reporter-shaped I2C bus + INA219 on GPIO32/33, answered by the in-chip software INA219 (inasim.hpp)
esphome:
name: ${name}
includes:
- inasim.hpp
on_boot:
- priority: 700
then:
- lambda: |-
if (${seed} != 0) inasim::rng = ${seed};
inasim::install();
uint8_t w[3] = {0x05, 0x12, 0x34};
uint8_t r = 0x05;
for (int pass = 1; pass <= 2; pass++) {
uint8_t b[2] = {0, 0};
auto ew = id(bus_a)->write_readv(0x40, w, 3, nullptr, 0);
auto er = id(bus_a)->write_readv(0x40, &r, 1, b, 2);
ESP_LOGI("inasim", "selftest%d write=%d read=%d val=%02X%02X starts=%u", pass, ew, er, b[0], b[1],
(unsigned) inasim::st.starts);
}
esp32:
board: pico32
framework:
type: ${fw}
version: ${fwver}
logger:
level: INFO
baud_rate: 115200
i2c:
id: bus_a
sda: GPIO32
scl: GPIO33
frequency: ${freq}
scan: true
globals:
- id: n_ok
type: uint32_t
initial_value: '0'
- id: n_to
type: uint32_t
initial_value: '0'
- id: n_nack
type: uint32_t
initial_value: '0'
- id: n_other
type: uint32_t
initial_value: '0'
- id: n_bad
type: uint32_t
initial_value: '0'
- id: n_ina_bad
type: uint32_t
initial_value: '0'
- id: max_us
type: uint32_t
initial_value: '0'
- id: streak
type: uint32_t
initial_value: '0'
- id: max_streak
type: uint32_t
initial_value: '0'
interval:
- interval: ${hammer}
then:
- lambda: |-
if (millis() < 20000) return;
if (!inasim::armed) {
inasim::p_fault = ${p_fault};
inasim::fault_mask = ${fault_mask};
inasim::hold_max_ms = ${hold_max_ms};
// The ISR picks faults until one is in the mask, so an empty mask would spin there forever
if (inasim::p_fault && !(inasim::fault_mask & ((1u << inasim::F_COUNT) - 1))) {
ESP_LOGE("inasim", "fault_mask 0x%x selects no fault, running without faults", (unsigned) inasim::fault_mask);
inasim::p_fault = 0;
}
inasim::armed = true;
ESP_LOGI("inasim", "faults armed p=%u/1000 mask=0x%x", (unsigned) inasim::p_fault, (unsigned) inasim::fault_mask);
}
inasim::hb = millis();
inasim::diag_recover = ${diag_recover};
inasim::drain();
uint8_t r = 0x02, buf[2] = {0, 0};
uint32_t t0 = micros();
auto e = id(bus_a)->write_readv(0x40, &r, 1, buf, 2);
uint32_t dt = micros() - t0;
if (dt > id(max_us)) id(max_us) = dt;
if (e == i2c::ERROR_OK) {
id(streak) = 0;
id(n_ok)++;
if (((buf[0] << 8) | buf[1]) != ((3410 << 3) | 2)) id(n_bad)++;
} else {
id(streak)++;
if (id(streak) > id(max_streak)) id(max_streak) = id(streak);
if (e == i2c::ERROR_TIMEOUT) id(n_to)++;
else if (e == i2c::ERROR_NOT_ACKNOWLEDGED) id(n_nack)++;
else id(n_other)++;
}
- interval: 30s
then:
- lambda: |-
ESP_LOGI("inasim", "MASTER up=%us ok=%u timeout=%u nack=%u other=%u badval=%u ina_bad=%u max_us=%u heap=%u fail_streak_now=%u max_fail_streak=%u",
(unsigned) (millis() / 1000), (unsigned) id(n_ok), (unsigned) id(n_to), (unsigned) id(n_nack),
(unsigned) id(n_other), (unsigned) id(n_bad), (unsigned) id(n_ina_bad), (unsigned) id(max_us),
(unsigned) esp_get_free_heap_size(), (unsigned) id(streak), (unsigned) id(max_streak));
id(max_us) = 0;
inasim::log_stats();
sensor:
- platform: ina219
address: 0x40
shunt_resistance: 0.1 ohm
max_voltage: 32.0V
max_current: 3.2A
update_interval: ${ina_interval}
bus_voltage:
name: Starter battery
accuracy_decimals: 2
on_value:
- lambda: |-
if (fabsf(x - 13.64f) > 0.005f) {
id(n_ina_bad)++;
ESP_LOGW("inasim", "INA bus voltage garbage %.2f V", x);
}
current:
name: Charge current
accuracy_decimals: 2
@@ -1,435 +0,0 @@
#pragma once
// Software INA219 at 0x40 answered on the master's own pads (GPIO32 SDA, GPIO33 SCL) plus fault injection.
// The slave drives a line low by switching the pad's output mux from the I2C0 signal to the plain GPIO output
// (level 0), which is a wired AND with the master. It runs inside a GPIO ISR on core 0 that spins through one
// transaction. Long stuck lines are held by a task on core 0 that re-asserts every tick.
#include <cstdint>
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "driver/gpio.h"
#include "soc/gpio_reg.h"
#include "soc/gpio_sig_map.h"
#include "esp_cpu.h"
#include "esp_rom_sys.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
namespace inasim {
static const char *const TAG = "inasim";
constexpr int SDA = 32, SCL = 33;
constexpr uint32_t SDA_BIT = 1u << (SDA - 32), SCL_BIT = 1u << (SCL - 32);
constexpr uint32_t LOW_VAL = SIG_GPIO_OUT_IDX | GPIO_FUNC0_OEN_SEL;
constexpr uint32_t CPU_MHZ = 240;
inline volatile uint32_t &reg(uint32_t a) { return *reinterpret_cast<volatile uint32_t *>(a); }
inline uint32_t out_sel_addr(int pin) { return GPIO_FUNC0_OUT_SEL_CFG_REG + 4 * pin; }
inline uint32_t in() { return reg(GPIO_IN1_REG); }
enum Fault : uint8_t {
F_GARBAGE = 0, // flip a bit in returned data
F_NACK, // do not ack the address
F_STRETCH, // hold SCL low 0.2..20 ms inside the transaction, then continue
F_HOLD_SCL, // SCL stuck low 50..3000 ms (task)
F_HOLD_SDA, // SDA stuck low 50..3000 ms (task)
F_GLITCH_SCL, // 1..4 us low pulse on SCL while it is high
F_GLITCH_SDA, // 1..4 us low pulse on SDA while SCL is high (false START/STOP)
F_VANISH, // slave stops responding mid transaction
F_COUNT
};
struct Stats {
volatile uint32_t starts, matched, stops, idle_abort, cap_abort, rd_bytes, wr_bytes;
volatile uint32_t fault[F_COUNT];
};
inline Stats st{};
inline uint32_t rel_sda = 0, rel_scl = 0;
inline volatile bool armed = false;
inline volatile uint32_t p_fault = 0; // per mille of matched transactions
inline volatile uint32_t fault_mask = 0xFF; // which faults may be chosen
inline volatile uint32_t hold_max_ms = 3000;
inline uint16_t regs[6] = {0x399F, 0x044C, (3410 << 3) | 2, 0, 0, 0};
inline uint8_t ptr = 0;
inline uint32_t rng = 0x2270A5C3;
inline uint32_t rnd() {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
return rng;
}
struct FaultEv {
uint32_t ms;
uint8_t type;
uint32_t arg;
};
inline FaultEv ring[64];
inline volatile uint32_t ring_w = 0;
inline uint32_t ring_r = 0;
inline void note(uint8_t type, uint32_t arg) {
FaultEv &e = ring[ring_w & 63];
e.ms = (uint32_t) (esp_timer_get_time() / 1000);
e.type = type;
e.arg = arg;
ring_w = ring_w + 1;
}
inline TaskHandle_t holder = nullptr;
inline volatile uint32_t hold_mask = 0, hold_ms = 0;
inline volatile bool holding = false;
inline void drive_low(uint32_t bits) {
reg(GPIO_OUT1_W1TC_REG) = bits;
reg(GPIO_ENABLE1_W1TS_REG) = bits;
if (bits & SDA_BIT)
reg(out_sel_addr(SDA)) = LOW_VAL;
if (bits & SCL_BIT)
reg(out_sel_addr(SCL)) = LOW_VAL;
}
inline void release(uint32_t bits) {
if (bits & SDA_BIT)
reg(out_sel_addr(SDA)) = rel_sda;
if (bits & SCL_BIT)
reg(out_sel_addr(SCL)) = rel_scl;
}
inline void spin_us(uint32_t us) {
uint32_t s = esp_cpu_get_cycle_count();
while (esp_cpu_get_cycle_count() - s < us * CPU_MHZ) {
}
}
inline void update_derived() {
// current = shunt * cal / 4096, power = current * bus / 5000 (datasheet scaling)
int32_t shunt = (int16_t) regs[1];
uint32_t cal = regs[5];
int32_t cur = (int32_t) ((int64_t) shunt * cal / 4096);
regs[4] = (uint16_t) cur;
regs[3] = (uint16_t) ((int64_t) (cur < 0 ? -cur : cur) * (regs[2] >> 3) / 5000);
}
inline void start_hold(uint32_t bits, uint32_t ms, BaseType_t *woken) {
hold_mask = bits;
hold_ms = ms;
holding = true;
drive_low(bits);
vTaskNotifyGiveFromISR(holder, woken);
}
// Spins through one transaction from a START. Returns when STOP, idle or cap hits.
inline void run_transaction(BaseType_t *woken) {
enum { ADDR, WDATA, RDATA, IGNORE } phase = ADDR;
uint32_t prev = in();
uint32_t start = esp_cpu_get_cycle_count(), last = start;
uint8_t byte = 0, cnt = 0, out = 0, rbyte_idx = 0, wbyte_idx = 0, wmsb = 0;
bool acked = false, mack = false, sda_driven = false;
int fault = -1;
uint32_t fault_at = 0, rises = 0;
bool garbage_done = false;
auto set_sda = [&](bool low) {
if (low && !sda_driven) {
drive_low(SDA_BIT);
sda_driven = true;
} else if (!low && sda_driven) {
release(SDA_BIT);
sda_driven = false;
}
};
auto load_byte = [&]() {
uint16_t v = ptr < 6 ? regs[ptr] : 0xFFFF;
out = rbyte_idx == 0 ? (v >> 8) : (rbyte_idx == 1 ? (v & 0xFF) : 0xFF);
if (fault == F_GARBAGE && !garbage_done) {
out ^= 1u << (rnd() & 7);
note(F_GARBAGE, out);
garbage_done = true;
}
rbyte_idx++;
};
for (;;) {
uint32_t v = in();
uint32_t now = esp_cpu_get_cycle_count();
if (v == prev) {
if (now - last > 15000u * CPU_MHZ) {
st.idle_abort++;
break;
}
if (now - start > 60000u * CPU_MHZ) {
st.cap_abort++;
break;
}
continue;
}
last = now;
uint32_t ch = v ^ prev;
prev = v;
if (ch & SCL_BIT) {
if (v & SCL_BIT) {
// rising edge: sample
cnt++;
rises++;
if (cnt <= 8 && (phase == ADDR || phase == WDATA))
byte = (byte << 1) | ((v & SDA_BIT) ? 1 : 0);
else if (cnt == 9 && phase == RDATA)
mack = !(v & SDA_BIT);
if (fault >= 0 && rises == fault_at) {
if (fault == F_GLITCH_SCL || fault == F_GLITCH_SDA) {
spin_us(2);
uint32_t b = fault == F_GLITCH_SCL ? SCL_BIT : SDA_BIT;
bool was = b == SDA_BIT && sda_driven;
if (!was) {
note(fault, rises);
drive_low(b);
spin_us(1 + (rnd() & 3));
release(b);
}
fault = -1;
prev = in();
}
}
} else {
// falling edge: drive
if (fault >= 0 && rises == fault_at) {
if (fault == F_STRETCH) {
drive_low(SCL_BIT);
uint32_t us = 200 + rnd() % 19800;
note(F_STRETCH, us);
spin_us(us);
release(SCL_BIT);
fault = -1;
prev = in();
last = esp_cpu_get_cycle_count();
} else if (fault == F_HOLD_SCL) {
set_sda(false);
uint32_t ms = 50 + rnd() % hold_max_ms;
note(F_HOLD_SCL, ms);
start_hold(SCL_BIT, ms, woken);
return;
} else if (fault == F_HOLD_SDA) {
drive_low(SDA_BIT);
sda_driven = false;
uint32_t ms = 50 + rnd() % hold_max_ms;
note(F_HOLD_SDA, ms);
start_hold(SDA_BIT, ms, woken);
return;
} else if (fault == F_VANISH) {
note(F_VANISH, rises);
set_sda(false);
return;
}
}
if (cnt == 8) {
if (phase == ADDR) {
bool match = (byte >> 1) == 0x40;
if (match) {
st.matched++;
if (armed && p_fault && (rnd() % 1000) < p_fault) {
uint32_t f;
do {
f = rnd() % F_COUNT;
} while (!(fault_mask & (1u << f)));
fault = f;
fault_at = rises + 1 + rnd() % 30;
st.fault[f]++;
}
}
if (match && fault == F_NACK)
note(F_NACK, 0);
if (match && fault != F_NACK) {
set_sda(true);
acked = true;
phase = (byte & 1) ? RDATA : WDATA;
rbyte_idx = 0;
wbyte_idx = 0;
} else {
phase = IGNORE;
acked = false;
}
} else if (phase == WDATA) {
st.wr_bytes++;
if (wbyte_idx == 0)
ptr = byte;
else if (wbyte_idx == 1)
wmsb = byte;
else if (wbyte_idx == 2 && ptr < 6) {
uint16_t w = (wmsb << 8) | byte;
if (ptr == 0 && (w & 0x8000))
w = 0x399F;
if (ptr == 0 || ptr == 5)
regs[ptr] = w;
update_derived();
}
wbyte_idx++;
set_sda(true);
acked = true;
} else if (phase == RDATA) {
set_sda(false); // master acks
acked = false;
}
} else if (cnt == 9) {
if (acked) {
set_sda(false);
acked = false;
}
if (phase == RDATA) {
if (rbyte_idx == 0 || mack) {
load_byte();
st.rd_bytes++;
set_sda(!(out & 0x80));
} else {
phase = IGNORE;
}
}
cnt = 0;
byte = 0;
} else if (cnt >= 1 && cnt <= 7 && phase == RDATA) {
set_sda(!(out & (0x80 >> cnt)));
}
}
} else if ((ch & SDA_BIT) && (v & SCL_BIT)) {
if (v & SDA_BIT) {
st.stops++;
break;
}
// repeated START
st.starts++;
set_sda(false);
phase = ADDR;
cnt = 0;
byte = 0;
acked = false;
}
}
set_sda(false);
}
inline void slave_isr(void *) {
reg(GPIO_STATUS1_W1TC_REG) = SDA_BIT;
if (holding)
return;
uint32_t v = in();
if (!(v & SCL_BIT) || (v & SDA_BIT))
return;
st.starts++;
BaseType_t woken = pdFALSE;
run_transaction(&woken);
reg(GPIO_STATUS1_W1TC_REG) = SDA_BIT;
if (woken)
portYIELD_FROM_ISR();
}
inline void holder_task(void *) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
uint32_t end = (esphome::millis) () + hold_ms;
while ((int32_t) (end - (esphome::millis) ()) > 0) {
drive_low(hold_mask);
vTaskDelay(1);
}
release(hold_mask);
holding = false;
}
}
inline volatile uint32_t hb = 0;
inline volatile bool diag_recover = false;
constexpr uint32_t I2C0 = 0x3FF53000;
inline void dump_i2c(const char *why, uint32_t stale) {
esp_rom_printf("DIAG %s stale=%ums ctr=%08x sr=%08x to=%08x raw=%08x ena=%08x st=%08x fifo=%08x hold=%d in=%x\n", why,
(unsigned) stale, (unsigned) reg(I2C0 + 0x04), (unsigned) reg(I2C0 + 0x08),
(unsigned) reg(I2C0 + 0x0c), (unsigned) reg(I2C0 + 0x20), (unsigned) reg(I2C0 + 0x28),
(unsigned) reg(I2C0 + 0x2c), (unsigned) reg(I2C0 + 0x18), (int) holding, (unsigned) (in() & 3));
esp_rom_printf("DIAG cmd:");
for (int i = 0; i < 16; i++)
esp_rom_printf(" %08x", (unsigned) reg(I2C0 + 0x58 + 4 * i));
esp_rom_printf("\nDIAG app_intr_status %08x %08x %08x pro %08x %08x %08x\n", (unsigned) reg(0x3FF000F8),
(unsigned) reg(0x3FF000FC), (unsigned) reg(0x3FF00100), (unsigned) reg(0x3FF000EC),
(unsigned) reg(0x3FF000F0), (unsigned) reg(0x3FF000F4));
}
inline void monitor_task(void *) {
uint32_t stage = 0;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(50));
uint32_t now = (esphome::millis) ();
uint32_t h = hb;
if (h == 0)
continue;
uint32_t stale = now - h;
if (stale < 1000) {
if (stage >= 3)
esp_rom_printf("DIAG loop resumed after %u ms, stage %u\n", (unsigned) stale, (unsigned) stage);
stage = 0;
continue;
}
if (stage == 0 && stale >= 1000) {
dump_i2c("stuck1", stale);
stage = 1;
} else if (stage == 1 && stale >= 1500) {
dump_i2c("stuck2", stale);
stage = 2;
} else if (stage == 2 && stale >= 2500) {
dump_i2c("stuck3", stale);
if (diag_recover) {
reg(I2C0 + 0x28) = 0;
reg(I2C0 + 0x24) = 0xFFFF;
esp_rom_printf("DIAG cleared I2C0 int_ena\n");
}
stage = 3;
} else if (stage == 3 && stale >= 3500) {
dump_i2c("stuck4", stale);
stage = 4;
}
}
}
inline volatile bool installed = false;
inline void install_task(void *) {
rel_sda = reg(out_sel_addr(SDA));
rel_scl = reg(out_sel_addr(SCL));
// ESP_ERR_INVALID_STATE: the service is already installed, which is fine
esp_err_t e1 = gpio_install_isr_service(0);
esp_err_t et = gpio_set_intr_type((gpio_num_t) SDA, GPIO_INTR_NEGEDGE);
esp_err_t e2 = gpio_isr_handler_add((gpio_num_t) SDA, slave_isr, nullptr);
ESP_LOGI(TAG, "slave on core %d: isr service %d, intr type %d, handler %d, rel_sda 0x%x rel_scl 0x%x",
xPortGetCoreID(), e1, et, e2, (unsigned) rel_sda, (unsigned) rel_scl);
if ((e1 == ESP_OK || e1 == ESP_ERR_INVALID_STATE) && et == ESP_OK && e2 == ESP_OK) {
installed = true;
} else {
ESP_LOGE(TAG, "slave ISR install failed: service %s, intr type %s, handler %s", esp_err_to_name(e1),
esp_err_to_name(et), esp_err_to_name(e2));
}
vTaskDelete(nullptr);
}
inline void install() {
if (xTaskCreatePinnedToCore(holder_task, "inahold", 3072, nullptr, configMAX_PRIORITIES - 2, &holder, 0) != pdPASS ||
xTaskCreatePinnedToCore(monitor_task, "inamon", 3072, nullptr, configMAX_PRIORITIES - 1, nullptr, 0) != pdPASS ||
xTaskCreatePinnedToCore(install_task, "inainst", 3072, nullptr, 5, nullptr, 0) != pdPASS) {
ESP_LOGE(TAG, "task creation failed, slave not installed");
return;
}
for (int i = 0; i < 1000 && !installed; i++)
vTaskDelay(1);
if (!installed)
ESP_LOGE(TAG, "slave install did not finish");
}
inline void drain() {
static const char *const NAMES[F_COUNT] = {"garbage", "nack", "stretch_us", "hold_scl_ms",
"hold_sda_ms", "glitch_scl", "glitch_sda", "vanish"};
const uint32_t w = ring_w;
if (w - ring_r > 64) {
ESP_LOGW(TAG, "F dropped %u events (ring overrun)", (unsigned) (w - ring_r - 64));
ring_r = w - 64;
}
while (ring_r != w) {
const FaultEv &e = ring[ring_r & 63];
ESP_LOGI(TAG, "F (%u) %s %u", (unsigned) e.ms, NAMES[e.type], (unsigned) e.arg);
ring_r++;
}
}
inline void log_stats() {
ESP_LOGI(TAG,
"SLAVE starts=%u matched=%u stops=%u idle_abort=%u cap_abort=%u rd=%u wr=%u | faults garbage=%u nack=%u "
"stretch=%u hold_scl=%u hold_sda=%u glitch_scl=%u glitch_sda=%u vanish=%u",
(unsigned) st.starts, (unsigned) st.matched, (unsigned) st.stops, (unsigned) st.idle_abort,
(unsigned) st.cap_abort, (unsigned) st.rd_bytes, (unsigned) st.wr_bytes, (unsigned) st.fault[0],
(unsigned) st.fault[1], (unsigned) st.fault[2], (unsigned) st.fault[3], (unsigned) st.fault[4],
(unsigned) st.fault[5], (unsigned) st.fault[6], (unsigned) st.fault[7]);
}
} // namespace inasim
@@ -1,12 +0,0 @@
#!/bin/bash
# run.sh LABEL [SECONDS]: build example.yaml from TREE as i2cfault-<LABEL> (edit its substitutions for the framework,
# seed and fault rate), flash the ESP32, capture SECONDS (default 640) from a reset and summarize.
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python)
R=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; S=${2:-640}; T=${TREE:?set TREE}; P=${PORT:?set PORT}
PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}; NAME=i2cfault-$L; OUT=$R/out-$NAME; rm -rf "$OUT"; mkdir -p "$OUT"
sed -E "s/^ name: .*/ name: $NAME/" "$R/example.yaml" > "$R/$NAME.yaml"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && $PY -m esphome run --no-logs "$R/$NAME.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "build or upload failed"; exit 1; }
grep -a -E '^(RAM|Flash):' "$OUT/upload.log"
$PY "$SC" "$P" "$S" "$OUT/serial.txt" --reset > /dev/null 2>&1 || { echo "serial capture got nothing"; exit 1; }
"$R/summary.sh" "$OUT/serial.txt"
@@ -1,11 +0,0 @@
#!/bin/bash
# summary.sh CAPTURE...: crashes, watchdogs, timeout kinds and the read counters per capture
for f in "$@"; do
first=$(grep -a -m1 'rst:0x' "$f" | cut -c2-9); last=$(tail -1 "$f" | cut -c2-9)
echo "== $(basename "$f") $first..$last boots=$(grep -a -c 'rst:0x' "$f") guru=$(grep -a -c Guru "$f") twdt=$(grep -a -c 'task_wdt: Task watchdog' "$f") diag=$(grep -a -c 'DIAG stuck' "$f")"
echo " sw=$(grep -a -c 'software timeout' "$f") hw=$(grep -a -c 'hardware timeout detected' "$f") trans=$(grep -a -c 'transaction timeout detected' "$f") busy=$(grep -a -c 'still busy' "$f") ina_garbage_lines=$(grep -a -c 'INA bus voltage garbage' "$f")"
grep -a 'MASTER' "$f" | tail -1 | sed 's/^.*MASTER/ MASTER/'
grep -a 'SLAVE' "$f" | tail -1 | sed 's/^.*SLAVE/ SLAVE/'
echo " ok per 30s window (min over windows after arm): $(grep -a MASTER "$f" | sed 's/.* ok=\([0-9]*\).*/\1/' | awk 'NR>2{d=$1-p; if(min==""||d<min)min=d} {p=$1} END{print (min == "" ? "n/a (fewer than 3 MASTER windows)" : min)}')"
echo " harness: install_errors=$(grep -a -c -E 'task creation failed|slave ISR install failed|slave install did not finish|selects no fault' "$f") $(grep -a -o 'selftest1 write=[-0-9]* read=[-0-9]* val=[0-9A-F]*' "$f" | head -1)"
done
@@ -1,90 +0,0 @@
"""Per phase IPv6 link-local and SLAAC statistics from v6probe captures.
usage: an.py CAPTURE...
For the boot and reconnect phases: boots, boots with an esp_netif_create_ip6_linklocal failure, boots without
link-local, SLAAC or a connection, connection timeouts, and median/worst times in seconds. Counts missing
addresses from the RESULT line too, since the USB console can drop a warning line. Exits 1 when a boot other
than the last of a capture has no RESULT for a phase.
"""
import pathlib
import re
import statistics
import sys
def parse(paths: list[str]) -> list[dict]:
boots: list[dict] = []
cur: dict | None = None
for p in paths:
for line in pathlib.Path(p).read_bytes().decode(errors="replace").splitlines():
if "BOOTMARK" in line:
cur = {
"boot": {},
"reconnect": {},
"fail": [0, 0],
"created": [0, 0],
"timeouts": [0, 0],
"ph": 0,
}
boots.append(cur)
continue
if cur is None:
continue
ph = cur["ph"]
if "esp_netif_create_ip6_linklocal failed" in line:
cur["fail"][ph] += 1
if "IPv6 link-local address created" in line or "No lwIP netif" in line:
cur["created"][ph] += 1
if re.search(
r"Connection timeout|Connection incomplete|Connection lost", line
):
cur["timeouts"][ph] += 1
if "REENABLE" in line:
cur["ph"] = 1
if m := re.search(r"RESULT (\w+) (.*)", line):
cur[m.group(1)] = dict(kv.split("=") for kv in m.group(2).split())
if cur is not None:
cur["last"] = True
cur = None
return boots
def med_max(xs: list[int]) -> str:
return f"{statistics.median(xs) / 1000:.1f}/{max(xs) / 1000:.1f}" if xs else "-"
def summ(boots: list[dict], phase: str, idx: int) -> int:
rs = [b for b in boots if b[phase]]
# a capture usually ends inside its last boot
no_result = sum(1 for b in boots if not b[phase] and "last" not in b)
def seen(key: str) -> list[int]:
return [int(b[phase][key]) for b in rs if b[phase][key] != "0"]
def missing(key: str) -> int:
return sum(1 for b in rs if b[phase][key] == "0")
print(
f"{phase:9} n={len(rs)} no_result={no_result} esp_fail={sum(1 for b in rs if b['fail'][idx])} no_ll={missing('ll')} "
f"no_slaac={missing('gl')} not_connected={missing('full')} timeouts={sum(b['timeouts'][idx] for b in rs)} "
f"ll(med/max s)={med_max(seen('ll'))} slaac={med_max(seen('gl'))} connected={med_max(seen('full'))} "
f"created_logs={sum(b['created'][idx] for b in rs)}"
)
return no_result
def main() -> None:
boots = parse(sys.argv[1:])
print("boots", len(boots))
no_result = summ(boots, "boot", 0) + summ(boots, "reconnect", 1)
print(
"heap_used first 3 boots",
[int(b["boot"]["heap_used"]) for b in boots if b["boot"]][:3],
)
if no_result or not boots:
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,12 +0,0 @@
substitutions:
min_count: "0"
cap_ms: "30000"
esp32:
board: esp32-c3-devkitm-1
framework:
type: esp-idf
packages:
common: !include v6common.yaml
inject: !include v6inject.yaml
@@ -1,12 +0,0 @@
# One file per build; run.sh passes the name. Add v6hf.yaml or v6inject.yaml to packages.
substitutions:
min_count: "0"
cap_ms: "30000"
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
packages:
common: !include v6common.yaml
@@ -1,13 +0,0 @@
#!/bin/bash
# run.sh NAME SECONDS: build NAME.yaml from TREE as device NAME, flash, then capture SECONDS of self-rebooting boots
# (each boot is a boot phase and a reconnect phase) into out-NAME/cap.txt and summarize them with an.py.
# env: TREE (esphome checkout), PORT (serial port), SC (path to serial_capture.py), PY (checkout python)
R=$(cd "$(dirname "$0")" && pwd); N=${1:?name}; S=${2:?seconds}
T=${TREE:?set TREE}; P=${PORT:?set PORT}; PY=${PY:-$T/venv/bin/python}; SC=${SC:?set SC}
OUT=$R/out-$N; rm -rf "$OUT"; mkdir -p "$OUT"
pkill -f "[s]erial_capture.py $P"
(cd "$T" && $PY -m esphome -s name "$N" run --no-logs "$R/$N.yaml" --device "$P" > "$OUT/upload.log" 2>&1) ||
{ echo "build or upload failed"; tail -5 "$OUT/upload.log"; exit 1; }
$PY "$SC" "$P" "$S" "$OUT/cap.txt" --ms > /dev/null 2>&1
[ -s "$OUT/cap.txt" ] || { echo "serial capture got nothing"; exit 1; }
$PY "$R/an.py" "$OUT/cap.txt"
@@ -1,28 +0,0 @@
# IPv6 boot and reconnect probe (#22364): logs milestones per phase, then disables Wi-Fi for 3 s, measures the
# reconnect and reboots. Substitutions: name (run.sh passes it), min_count (network min_ipv6_addr_count), cap_ms (phase time limit).
esphome:
name: ${name}
includes:
- v6probe.hpp
on_boot:
- priority: 800
then:
- lambda: 'ESP_LOGI("v6probe", "BOOTMARK reason=%d", (int) esp_reset_reason());'
logger:
level: VERBOSE
hardware_uart: USB_SERIAL_JTAG
network:
enable_ipv6: true
min_ipv6_addr_count: ${min_count}
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
reboot_timeout: 0s
interval:
- interval: 50ms
then:
- lambda: "v6probe::tick(${cap_ms});"
@@ -1,9 +0,0 @@
# Add to v6common.yaml: keeps the main loop busy, like a display or audio config
esphome:
on_boot:
- priority: 800
then:
- lambda: |-
static HighFrequencyLoopRequester hf;
hf.start();
ESP_LOGI("v6probe", "HF loop on");
@@ -1,5 +0,0 @@
# Add to v6common.yaml: delays esp-netif's STA_CONNECTED handler by 300 ms (wrapper in v6probe.hpp)
esphome:
build_flags:
- -Wl,--wrap=esp_netif_action_connected
- -DV6_INJECT
@@ -1,100 +0,0 @@
#pragma once
// Boot and reconnect timing probe for IPv6 link-local and SLAAC (#22364). Call tick(cap_ms) every 50 ms.
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/components/wifi/wifi_component.h"
#include <esp_netif.h>
#include <esp_wifi.h>
#include <esp_heap_caps.h>
namespace v6probe {
static const char *const PTAG = "v6probe";
struct Times {
uint32_t assoc, v4, ll, gl, full;
};
static int phase = 0; // 0 boot, 1 wifi disabled, 2 reconnecting, 3 done
static uint32_t start = 0;
static Times t{};
inline void tick(uint32_t cap_ms) {
using esphome::millis;
uint32_t now = millis();
if (phase == 3)
return;
if (phase == 1) {
if (now - start >= 3000) {
ESP_LOGI(PTAG, "REENABLE");
t = {};
start = now;
phase = 2;
esphome::wifi::global_wifi_component->enable();
}
return;
}
uint32_t rel = (now - start) | 1; // never 0, 0 means not seen
wifi_ap_record_t ap;
if (!t.assoc && esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
t.assoc = rel;
ESP_LOGI(PTAG, "MS assoc %u", rel);
}
// a missing netif still has to reach the cap_ms timeout below
esp_netif_t *nif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
esp_netif_ip_info_t ip;
if (!t.v4 && nif != nullptr && esp_netif_get_ip_info(nif, &ip) == ESP_OK && ip.ip.addr != 0) {
t.v4 = rel;
ESP_LOGI(PTAG, "MS ipv4 %u", rel);
}
esp_ip6_addr_t a[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
int n = 0;
if (nif != nullptr) {
esphome::LwIPLock lock;
n = esp_netif_get_all_ip6(nif, a);
}
bool ll = false, gl = false;
for (int i = 0; i < n; i++) {
if (esp_netif_ip6_get_addr_type(&a[i]) == ESP_IP6_ADDR_IS_LINK_LOCAL)
ll = true;
else
gl = true;
}
if (!t.ll && ll) {
t.ll = rel;
ESP_LOGI(PTAG, "MS linklocal %u", rel);
}
if (!t.gl && gl) {
t.gl = rel;
ESP_LOGI(PTAG, "MS slaac %u", rel);
}
if (!t.full && esphome::wifi::global_wifi_component->is_connected()) {
t.full = rel;
ESP_LOGI(PTAG, "MS connected %u", rel);
}
bool done = t.gl && t.full;
if (done || now - start >= cap_ms) {
size_t used = heap_caps_get_total_size(MALLOC_CAP_INTERNAL) - heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
ESP_LOGI(PTAG, "RESULT %s assoc=%u v4=%u ll=%u gl=%u full=%u n6=%d timeout=%d heap_used=%u",
phase == 0 ? "boot" : "reconnect", t.assoc, t.v4, t.ll, t.gl, t.full, n, done ? 0 : 1, (unsigned) used);
if (phase == 0) {
phase = 1;
start = now;
esphome::wifi::global_wifi_component->disable();
} else {
phase = 3;
esphome::App.safe_reboot();
}
}
}
} // namespace v6probe
#ifdef V6_INJECT
// Fault injection: esp-netif's STA_CONNECTED handler (which brings the netif up) is delayed by 300 ms in the
// event task, after the wifi component's own handler has already queued the event for the main loop.
extern "C" void __real_esp_netif_action_connected(void *esp_netif, esp_event_base_t base, int32_t id, void *data);
extern "C" void __wrap_esp_netif_action_connected(void *esp_netif, esp_event_base_t base, int32_t id, void *data) {
ESP_LOGI("v6probe", "INJECT hold start");
vTaskDelay(pdMS_TO_TICKS(300));
ESP_LOGI("v6probe", "INJECT hold end");
__real_esp_netif_action_connected(esp_netif, base, id, data);
}
#endif
@@ -1,82 +0,0 @@
# Package: BW12 heartbeat, pull-toggle edge generator, BS binary sensor on ${bs_pin}, meas lines every 10 s
esphome:
name: ${name}
friendly_name: BW12 ISR Test
platformio_options:
upload_speed: 115200
build_flags:
- -Wl,--wrap=digitalRead
includes:
- bwdbg.hpp
rtl87xx:
board: bw12
framework:
version: recommended
logger:
level: DEBUG
baud_rate: 115200
logs:
sensor: WARN
binary_sensor: WARN
pulse_counter: WARN
api:
reboot_timeout: 0s
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
captive_portal:
output:
- platform: gpio
pin: PA05
id: heartbeat_output
globals:
- id: bs_changes
type: uint32_t
initial_value: "0"
interval:
- interval: 1s
then:
- output.turn_on: heartbeat_output
- delay: 100ms
- output.turn_off: heartbeat_output
- interval: 50ms
then:
- lambda: bwdbg::tick();
- interval: 10s
then:
- lambda: |-
static uint32_t lg = 0, lc = 0;
uint32_t g = bwdbg::gen_rise + bwdbg::gen_fall, c = id(bs_changes);
ESP_LOGI("meas", "BS changes %u gen_edges %u state %d", c - lc, g - lg, id(bs).state);
lg = g; lc = c;
bwdbg::report();
binary_sensor:
- platform: gpio
id: bs
name: BS
pin:
number: ${bs_pin}
mode: INPUT
use_interrupt: true
interrupt_type: ANY
trigger_on_initial_state: true
on_state:
- lambda: id(bs_changes) += 1;
sensor:
- platform: uptime
name: Uptime
update_interval: 60s
@@ -1,64 +0,0 @@
#pragma once
// bw12isr: pull-toggle edge generator on floating pins, ISR read counters and pin/IRQ register dump (RTL8710B)
#include <Arduino.h>
#include "esphome/core/log.h"
extern "C" {
PinStatus __real_digitalRead(pin_size_t pin);
__attribute__((long_call)) void PAD_PullCtrl(uint8_t pin, uint8_t pull);
}
namespace bwdbg {
static constexpr uint8_t PINS[] = {12, 0, 14, 15, 19, 22};
static constexpr int NP = sizeof(PINS);
inline volatile uint32_t isr_reads[32], task_reads[32];
inline uint32_t gen_rise = 0, gen_fall = 0, checks = 0, mismatch[NP];
inline bool up = false, started = false;
inline uint32_t reg(uint32_t off) { return *reinterpret_cast<volatile uint32_t *>(0x40001000 + off); }
inline bool in_isr() {
uint32_t r;
__asm volatile("mrs %0, ipsr" : "=r"(r));
return r != 0;
}
// Every call: check each pin follows the pull set on the previous call, then flip all pulls
inline void tick() {
uint32_t lvl = reg(0x50);
if (started) {
checks++;
for (int i = 0; i < NP; i++)
if (((lvl >> PINS[i]) & 1) != (up ? 1u : 0u))
mismatch[i]++;
}
up = !up;
started = true;
for (int i = 0; i < NP; i++)
PAD_PullCtrl(PINS[i], up ? 2 : 1); // GPIO_PuPd_UP : GPIO_PuPd_DOWN
if (up)
gen_rise++;
else
gen_fall++;
}
inline void report() {
uint32_t en = reg(0x30), mask = reg(0x34), type = reg(0x38), pol = reg(0x3C), deb = reg(0x48), lvl = reg(0x50);
ESP_LOGI("dbg", "gen rise %u fall %u checks %u", gen_rise, gen_fall, checks);
for (int i = 0; i < NP; i++) {
uint8_t p = PINS[i];
PinInfo *info = pinInfo(p);
// -1 marks a pin with no PinInfo entry
const uint32_t e = info ? info->enabled : 0;
const int gpio = info ? !!(e & PIN_GPIO) : -1, irq = info ? !!(e & PIN_IRQ) : -1, pwm = info ? !!(e & PIN_PWM) : -1;
ESP_LOGI("dbg",
"PA%02u LT 0x%x (GPIO %d IRQ %d PWM %d) HW int_en %u mask %u edge %u pol_high %u deb %u lvl %u | "
"mismatch %u isr digitalRead %u task digitalRead %u",
p, e, gpio, irq, pwm, (en >> p) & 1, (mask >> p) & 1, (type >> p) & 1, (pol >> p) & 1, (deb >> p) & 1,
(lvl >> p) & 1, mismatch[i], isr_reads[p], task_reads[p]);
}
}
} // namespace bwdbg
extern "C" __attribute__((used)) PinStatus __wrap_digitalRead(pin_size_t pin) {
if (pin < 32) {
if (bwdbg::in_isr())
bwdbg::isr_reads[pin] = bwdbg::isr_reads[pin] + 1;
else
bwdbg::task_reads[pin] = bwdbg::task_reads[pin] + 1;
}
return __real_digitalRead(pin);
}
@@ -1,27 +0,0 @@
# Layout B: BS on PA19; F0/I0 moved to PA12/PA00
substitutions:
name: bw12-edges-b
bs_pin: PA19
packages:
base: !include bw12-base.yaml
sensor:
- platform: pulse_counter
name: F0
pin:
number: PA12
inverted: false
internal_filter: 0us
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "F0 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: I0
pin:
number: PA00
inverted: true
internal_filter: 0us
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "I0 raw %.0f gen_rise %u", x, g - l); l = g;'
@@ -1,104 +0,0 @@
substitutions:
name: bw12-time
esphome:
name: ${name}
friendly_name: BW12 Time Test
platformio_options:
upload_speed: 115200
rtl87xx:
board: bw12
framework:
version: recommended
logger:
level: DEBUG
baud_rate: 115200
api:
reboot_timeout: 0s
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
captive_portal:
button:
- platform: restart
name: Restart
interval:
- interval: 20s
then:
- lambda: |-
static bool done = false;
if (done) return;
done = true;
// mask ROM: SYSTIMER_TickGet at 0x33a0 (32 bytes), RTIM_GetCount at 0xb42c (72 bytes)
const uint32_t ranges[2][2] = {{0x33a0, 32}, {0xb42c, 72}};
for (auto &r : ranges) {
char hex[3 * 72 + 1];
const volatile uint8_t *p = (const volatile uint8_t *) r[0];
for (uint32_t i = 0; i < r[1]; i++) sprintf(hex + 3 * i, "%02x ", p[i]);
ESP_LOGI("tm", "ROM %05x: %s", (unsigned) r[0], hex);
}
- interval: 5s
then:
- lambda: 'ESP_LOGI("tm", "CLOCK ms %u us %u", millis(), micros());'
- interval: 30s
then:
- lambda: |-
volatile uint32_t *demcr = (volatile uint32_t *) 0xE000EDFC, *dwt = (volatile uint32_t *) 0xE0001000;
*demcr |= 1u << 24;
dwt[0] |= 1;
const float mhz = SystemCoreClock / 1e6f;
// cycles per call, 1000 calls, loop overhead subtracted
volatile uint32_t sink = 0;
uint32_t c0 = dwt[1];
for (int i = 0; i < 1000; i++) sink = sink + i;
uint32_t base = dwt[1] - c0;
c0 = dwt[1];
for (int i = 0; i < 1000; i++) sink = sink + millis();
uint32_t ms_c = dwt[1] - c0 - base;
c0 = dwt[1];
for (int i = 0; i < 1000; i++) sink = sink + micros();
uint32_t us_c = dwt[1] - c0 - base;
uint32_t isr_c;
{
InterruptLock lock;
c0 = dwt[1];
for (int i = 0; i < 1000; i++) sink = sink + micros();
isr_c = dwt[1] - c0 - base;
}
ESP_LOGI("tm", "BENCH cycles per call millis %.1f micros %.1f micros_locked %.1f (core %.1f MHz)",
ms_c / 1000.0f, us_c / 1000.0f, isr_c / 1000.0f, mhz);
uint32_t got[6];
const uint32_t req_us[3] = {50, 500, 5000};
for (int i = 0; i < 3; i++) {
uint32_t a = dwt[1];
delayMicroseconds(req_us[i]);
got[i] = uint32_t((dwt[1] - a) / mhz);
}
const uint32_t req_ms[3] = {1, 10, 100};
for (int i = 0; i < 3; i++) {
uint32_t a = dwt[1];
delay(req_ms[i]);
got[3 + i] = uint32_t((dwt[1] - a) / mhz);
}
ESP_LOGI("tm", "DELAY real us: delayMicroseconds 50->%u 500->%u 5000->%u, delay 1->%u 10->%u 100->%u",
got[0], got[1], got[2], got[3], got[4], got[5]);
uint32_t first = micros(), prev = first, maxstep = 0, back = 0, distinct = 0, mism = 0;
for (int i = 0; i < 20000; i++) {
uint32_t ms = millis(), m = micros(), ms2 = millis();
if (ms == ms2 && m / 1000 != ms) mism++;
if (m != prev) { distinct++; if (m < prev) back++; else if (m - prev > maxstep) maxstep = m - prev; }
prev = m;
}
ESP_LOGI("tm", "MICROS span %u distinct %u maxstep %u backwards %u, micros/1000 != millis %u of 20000",
prev - first, distinct, maxstep, back, mism);
@@ -1,48 +0,0 @@
# Layout A: BS on PA22; F13/I13 on PA12/PA00, F0/I0 on PA14/PA15, B13 on PA19 counting both edges
substitutions:
name: bw12-edges-a
bs_pin: PA22
packages:
base: !include bw12-base.yaml
sensor:
- platform: pulse_counter
name: F13
pin: PA12
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "F13 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: I13
pin:
number: PA00
inverted: true
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "I13 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: F0
pin: PA14
internal_filter: 0us
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "F0 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: I0
pin:
number: PA15
inverted: true
internal_filter: 0us
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "I0 raw %.0f gen_rise %u", x, g - l); l = g;'
- platform: pulse_counter
name: B13
pin: PA19
count_mode:
rising_edge: INCREMENT
falling_edge: INCREMENT
update_interval: 10s
on_raw_value:
- lambda: 'static uint32_t l = 0; uint32_t g = bwdbg::gen_rise; ESP_LOGI("meas", "B13 raw %.0f gen_rise %u", x, g - l); l = g;'
@@ -1,47 +0,0 @@
substitutions:
name: pulse-bk
inv: "false"
esphome:
name: ${name}
platformio_options:
build_flags:
- -DGEN_PIN_A=26
- -DGEN_PIN_B=8
includes:
- pulsegen.hpp
bk72xx:
board: xh-wb3s
logger:
hardware_uart: UART1
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
interval:
- interval: 50ms
then:
- lambda: pulsegen::tick();
sensor:
- platform: pulse_counter
name: PC default filter
pin:
number: P6
inverted: ${inv}
update_interval: 5s
on_raw_value:
- lambda: 'ESP_LOGI("meas", "F13 %.1f", x);'
- platform: pulse_counter
name: PC filter 0
pin:
number: P24
inverted: ${inv}
internal_filter: 0us
update_interval: 5s
on_raw_value:
- lambda: 'ESP_LOGI("meas", "F0 %.1f", x);'
@@ -1,39 +0,0 @@
substitutions:
name: pulse-rtl
inv: "false"
filt: 13us
key: PC
esphome:
name: ${name}
platformio_options:
build_flags:
- -DGEN_PIN_NAME=17
includes:
- pulsegen.hpp
rtl87xx:
board: bw15
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
interval:
- interval: 50ms
then:
- lambda: pulsegen::tick();
sensor:
- platform: pulse_counter
name: PC
pin:
number: PA18
inverted: ${inv}
internal_filter: ${filt}
update_interval: 5s
on_raw_value:
- lambda: 'ESP_LOGI("meas", "${key} %.1f", x);'
@@ -1,59 +0,0 @@
#pragma once
// One pulse shape per 35 s stage on GEN_PIN_A and GEN_PIN_B (BK72xx analogWrite) or GEN_PIN_NAME (AmebaZ2 pwmout)
#include <Arduino.h>
#ifdef GEN_PIN_NAME
// mbed pwmout API (its header clashes with Arduino's PinMode); pwmout_t is held in an oversized buffer
extern "C" {
void pwmout_init(void *obj, int pin);
void pwmout_period_us(void *obj, int us);
void pwmout_pulsewidth_us(void *obj, int us);
}
#endif
#include "esphome/core/log.h"
namespace pulsegen {
struct Stage {
uint32_t period_us;
uint32_t high_us;
};
static constexpr Stage STAGES[] = {
{1000, 38}, {10000, 38}, {10000, 50}, {10000, 75}, {10000, 100}, {10000, 150},
{10000, 200}, {1000, 200}, {10000, 300}, {10000, 500}, {10000, 1000}, {100000, 1000},
{100000, 5000}, {100000, 50000}, {2000, 1000}, {1000, 500}, {250, 125}, {100000, 38},
};
static constexpr int N = sizeof(STAGES) / sizeof(STAGES[0]);
static constexpr uint32_t STAGE_MS = 35000;
#ifdef GEN_PIN_NAME
// Arduino analogWrite re-init faults on the 2nd period change on AmebaZ2: keep one pwmout and retune it
inline void apply(const Stage &st) {
alignas(8) static uint8_t pwm[512];
static bool init = false;
if (!init) {
pwmout_init(pwm, GEN_PIN_NAME);
init = true;
}
pwmout_period_us(pwm, st.period_us);
pwmout_pulsewidth_us(pwm, st.high_us);
}
#else
inline void apply_pin(uint8_t pin, const Stage &s) {
pinMode(pin, INPUT); // drop PWM mode so the new period is taken
analogWriteResolution(16);
analogWritePeriod(s.period_us);
analogWrite(pin, (int) (65535.0f * s.high_us / s.period_us + 0.5f));
}
inline void apply(const Stage &st) {
apply_pin(GEN_PIN_A, st);
apply_pin(GEN_PIN_B, st);
}
#endif
inline void tick() {
static int last = -1;
int s = (millis() / STAGE_MS) % N;
if (s == last)
return;
last = s;
apply(STAGES[s]);
ESP_LOGI("gen", "STAGE %d period %u high %u expect_per_min %.1f", s, STAGES[s].period_us, STAGES[s].high_us,
60e6f / STAGES[s].period_us);
}
} // namespace pulsegen
@@ -1,47 +0,0 @@
"""Sum the bw12 edge test `meas` lines per counter: counted pulses against generated rising edges."""
from collections import defaultdict
import pathlib
import re
import sys
MEAS = re.compile(r"meas:\d+\]: (\w+) raw ([\d.]+) gen_rise (\d+)")
BS = re.compile(r"BS changes (\d+) gen_edges (\d+) state (\d)")
def summarize(path: str) -> None:
counters: dict[str, list[float]] = defaultdict(lambda: [0.0, 0, 0])
bs = [0, 0, 0]
state = "?"
seen: set[str] = set()
with pathlib.Path(path).open(encoding="utf-8", errors="replace") as f:
for line in f:
if m := MEAS.search(line):
key = m[1]
if key not in seen: # the first window is partial
seen.add(key)
continue
c = counters[key]
c[0] += float(m[2]) / 6 # pulses per minute over a 10 s window
c[1] += int(m[3])
c[2] += 1
elif m := BS.search(line):
if "BS" not in seen:
seen.add("BS")
continue
bs[0] += int(m[1])
bs[1] += int(m[2])
bs[2] += 1
state = m[3]
print(path.rsplit("/", 1)[-1])
for key, (counted, rises, windows) in sorted(counters.items()):
ratio = f"{counted / rises:.3f}" if rises else "n/a (no generated edges)"
print(
f" {key}: windows {windows} counted {counted:.0f} gen_rise {rises} ratio_to_rise {ratio}"
)
print(f" BS: windows {bs[2]} changes {bs[0]} gen_edges {bs[1]} last_state {state}")
if __name__ == "__main__":
for arg in sys.argv[1:]:
summarize(arg)
@@ -1,45 +0,0 @@
"""Default ltchiptool's -L to the newest installed LibreTiny platform.
ltchiptool reads its chip list from ~/.platformio/platforms/libretiny, which can be an older copy than the one a
build used (BK7238 UF2s then fail with "UF2 of unsupported family").
"""
import json
from pathlib import Path
import re
import sys
def _version(platform: Path) -> tuple[int, ...]:
try:
text = json.loads((platform / "platform.json").read_text(encoding="utf-8"))[
"version"
]
except (OSError, KeyError, ValueError):
return ()
# Numeric prefix of each part, so 1.9.0-dev still sorts
return tuple(
int(m[1]) for part in text.split(".")[:3] if (m := re.match(r"(\d+)", part))
)
def with_platform(args: list[str]) -> list[str]:
if any(a.startswith(("-L", "--libretiny-path")) for a in args):
return args
found = list((Path.home() / ".platformio" / "platforms").glob("libretiny*"))
platforms = [p for p in found if _version(p)]
for p in found:
if p not in platforms:
print(
f"lt_platform: skipped {p} (no numeric version in platform.json)",
file=sys.stderr,
)
if not platforms:
print(
f"lt_platform: no versioned LibreTiny platform in {[str(p) for p in found]}, using ltchiptool's default",
file=sys.stderr,
)
return args
chosen = max(platforms, key=_version)
print(f"lt_platform: using {chosen}", file=sys.stderr)
return ["-L", str(chosen), *args]
@@ -1,64 +0,0 @@
# MCP23017 at 0x27 on an ESP8266 (Feather HUZZAH): SDA GPIO4, SCL GPIO5, INTA to GPIO14, INTB to GPIO13,
# PA0 jumpered to PB0 and PA1 to PB1. The variant file adds the mcp23017 hub and mcptest. Substitution: name.
esphome:
name: ${name}
esp8266:
board: huzzah
logger:
baud_rate: 115200
external_components:
- source:
type: local
path: ext
components: [mcptest]
i2c:
sda: GPIO4
scl: GPIO5
scan: false
frequency: 100kHz
binary_sensor:
- platform: gpio
id: pb0
name: PB0
pin:
mcp23xxx: mcp
number: 8
mode:
input: true
interrupt: CHANGE
on_state:
- lambda: id(tst).count_change();
- platform: gpio
id: pb1
name: PB1
pin:
mcp23xxx: mcp
number: 9
mode:
input: true
interrupt: CHANGE
on_state:
- lambda: id(tst).count_change();
mcptest:
id: tst
address: 0x27
label: ${name}
isr_inta: ${isr_inta}
pa0:
mcp23xxx: mcp
number: 0
mode:
output: true
pa1:
mcp23xxx: mcp
number: 1
mode:
output: true
pb0: pb0
pb1: pb1
Loaded 100 of 1057 files, more files were not shown because too many files have changed in this diff. Show more