mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 05:46:12 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ce8cc6b4 | ||
|
|
552fcebbba | ||
|
|
e90b219e00 | ||
|
|
cff5cf7ad5 | ||
|
|
a9b1fc361b | ||
|
|
b7a5056f3e | ||
|
|
a19893b168 | ||
|
|
2344929dae | ||
|
|
46de7335b2 | ||
|
|
1cb2136c38 | ||
|
|
d1f7e71efb | ||
|
|
cf97e4c4e8 | ||
|
|
4e2ff94588 | ||
|
|
c2118778ee | ||
|
|
c655a2a442 | ||
|
|
b8a79a26fb | ||
|
|
34caf86839 | ||
|
|
1eb4cff6b6 | ||
|
|
b60950da76 | ||
|
|
7e6db4d335 |
@@ -1,116 +0,0 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Review guidance for ESPHome pull requests. Use this when reviewing a pull request that changes ESPHome Python, C++, or component code, to check it against the project's coding conventions, embedded-systems memory rules, testing requirements, and breaking-change policy.
|
||||
---
|
||||
|
||||
# Reviewing ESPHome pull requests
|
||||
|
||||
ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers
|
||||
(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and
|
||||
flash are scarce, and code runs unattended for months.
|
||||
|
||||
`AGENTS.md` in the repository root is the full contributor guide and the
|
||||
authority when it disagrees with this summary. The developer documentation at
|
||||
https://developers.esphome.io explains the component lifecycle and the reasoning
|
||||
behind these rules. This skill lists the concrete things worth flagging in a
|
||||
review; read `AGENTS.md` for the detail behind any item.
|
||||
|
||||
Only raise findings that the diff actually introduces or changes. Do not ask for
|
||||
drive-by cleanup of pre-existing code the PR did not touch.
|
||||
|
||||
## Memory and embedded constraints (highest value)
|
||||
|
||||
Heap allocation after `setup()` is treated as a reliability bug, not a
|
||||
performance nit, because it fragments a small shared heap. Flag:
|
||||
|
||||
- New heap allocation on a hot path or after setup that could be avoided.
|
||||
- `std::vector` where the size is known at compile time (use `std::array`, or
|
||||
`StaticVector<T, N>` when a `push_back` API is needed) or fixed at runtime
|
||||
init (use `FixedVector<T>`).
|
||||
- Listener / child-entity registration lists stored as `std::vector`; these have
|
||||
a compile-time-known count and should use `cg.slot_counter()` plus
|
||||
`StaticVector`.
|
||||
- `std::vector<uint8_t>` for a byte buffer that never grows: prefer
|
||||
`std::unique_ptr<uint8_t[]>` or `std::array`.
|
||||
- `std::map` / `std::set` / `std::unordered_map` for small datasets (1-16
|
||||
elements): a `std::vector` of a small struct with linear search is lighter.
|
||||
- `std::deque` anywhere: it allocates 512-byte blocks and should be avoided.
|
||||
- `std::string` storing a value set once from config: prefer `StringRef` (the
|
||||
literal already lives in flash).
|
||||
- `std::string` / `std::to_string` / string-returning helpers on hot paths where
|
||||
a buffer or view API exists.
|
||||
|
||||
## C++ conventions
|
||||
|
||||
- Include what you use: a file referencing a symbol must include the header that
|
||||
declares it, even if it currently arrives transitively. New or changed uses of
|
||||
a symbol need the matching include.
|
||||
- Prefix all member access with `this->`.
|
||||
- Naming: `lower_snake_case` for functions/methods/variables, `UpperCamelCase`
|
||||
for classes/structs/enums, `UPPER_SNAKE_CASE` for namespace-scope constants,
|
||||
trailing underscore on protected/private fields.
|
||||
- `enum class` values must be prefixed with the enum name in `UPPER_SNAKE_CASE`
|
||||
(e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Bare names like `SUCCESS`,
|
||||
`FAIL`, or `OK` collide with SDK macros on some platforms and break the build.
|
||||
- Prefer `const`/`enum` over `#define`; `#define` is only for conditional
|
||||
compilation and code-generation sizes.
|
||||
- Never call `millis()` in a `loop()` body; use
|
||||
`App.get_loop_component_start_time()`. A rate-limit gate below ~16 ms (the loop
|
||||
period) does nothing.
|
||||
- Pick the timing primitive by cadence: gated `loop()` under 250 ms,
|
||||
`set_interval` at 500 ms and above.
|
||||
- Do not override a base method to return the value it already returns (e.g.
|
||||
`get_setup_priority()` returning `setup_priority::DATA`).
|
||||
- Wrap string literals passed as printf `%s` args in `LOG_STR_LITERAL()`.
|
||||
- Required, invariant dependencies should be constructor parameters, not setters.
|
||||
- Callback registration methods must be templated (`template<typename F>`), not
|
||||
typed as `std::function`, so lightweight forwarders avoid a heap allocation.
|
||||
- Two-space indent, `using` over `typedef`, wrap at 120 columns.
|
||||
|
||||
## Python conventions
|
||||
|
||||
- Type-annotate every new function signature (params and return), new dataclass
|
||||
fields, and new module-level variables. Import `ConfigType` from
|
||||
`esphome.types`.
|
||||
- Use the walrus operator to avoid a double lookup, e.g.
|
||||
`if (blah := config.get(CONF_BLAH)) is not None:`.
|
||||
- Reuse existing validators from `config_validation.py` (`cv.rename_key`,
|
||||
`cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling.
|
||||
- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in
|
||||
the component's own `.py`, or in `esphome/components/const/__init__.py` when
|
||||
shared. The same constant defined in three or more component files fails CI.
|
||||
- State that must persist during code generation goes in `CORE.data` namespaced
|
||||
under the component `DOMAIN` (a `@dataclass`), not module-level mutable globals.
|
||||
- Prefer callback-based triggers via `build_callback_automation()`; only use a
|
||||
`Trigger<Ts...>` subclass when the forwarder needs mutable state.
|
||||
|
||||
## Testing and coverage
|
||||
|
||||
- New and changed lines and branches need test coverage, including defensive
|
||||
early-returns, error paths, and no-op guards. A mocked-out function is not
|
||||
covered; exercise the real call path too.
|
||||
- Component YAML tests live in `tests/components/<component>/`. Never define
|
||||
buses (uart, i2c, spi, modbus) directly in a test file: pull them from
|
||||
`tests/test_build_components/common/` through dict-style `packages:` so CI can
|
||||
group builds. List-style packages or top-level merge keys block grouping.
|
||||
- Config-only checks use the `validate.*.yaml` prefix; compiled checks use
|
||||
`test.*.yaml`.
|
||||
|
||||
## Breaking changes and public API
|
||||
|
||||
- Base classes under `esphome/core/` and documented config options are public
|
||||
API. Undocumented `public` members of a component are internal.
|
||||
- A breaking change needs justification, a migration path in the PR description,
|
||||
and a deprecation window where feasible (`ESPDEPRECATED` in C++,
|
||||
`cv.rename_key(..., removed_in=...)` in Python). Changing a codegen-injected
|
||||
lambda signature is not a breaking change.
|
||||
|
||||
## Process and PR hygiene
|
||||
|
||||
- PR titles start with a `[tag]` prefix: the component name (e.g. `[uart] ...`)
|
||||
or `[core]` for shared code.
|
||||
- Prose in docs, comments, and commit messages should be plain English. Keep
|
||||
inline comments short and only where the code is not self-explanatory; do not
|
||||
restate what the code says.
|
||||
- Verify the PR fills out `.github/PULL_REQUEST_TEMPLATE.md` and adds
|
||||
`CODEOWNERS` entries for a new component.
|
||||
@@ -1,146 +0,0 @@
|
||||
---
|
||||
name: hardware-test
|
||||
description: Test an ESPHome change on real boards over USB. Use this whenever a task asks to test, verify, reproduce, bench, A/B or soak a pull request, a branch or a crash on hardware, or mentions flashing, serial logs, esphome upload, a captive portal or fallback AP test, or results to post on a PR. Covers finding the serial ports, building from the right checkout, flashing each chip family (including the LibreTiny kits that stock tools cannot flash), bounded log capture, proven test flows and how to report results.
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
||||
---
|
||||
|
||||
# Hardware testing
|
||||
|
||||
The goal of a hardware test is evidence a reviewer can trust: which build ran on which board, what was
|
||||
driven, what the logs show, and the same flow on a `dev` build when a quirk could be pre-existing.
|
||||
`$SKILL` below is this directory (`.agents/skills/hardware-test`).
|
||||
|
||||
## Ground rules
|
||||
|
||||
1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that
|
||||
port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py <port> "`,
|
||||
naming the port so a capture on another board survives; the bracket keeps the pattern from matching
|
||||
the shell that runs it. When more than one agent session shares the bench, run a copy of the script
|
||||
under a session-specific name (`cap<topic>.py`) so another session's `pkill` cannot take your capture
|
||||
down. Never run `esphome logs` in a `while true` loop: the loop respawns it and holds the port.
|
||||
2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with
|
||||
`!secret`. A copy of a device's own config keeps using the secrets file next to that config: two
|
||||
checkouts can hold different networks, and the wrong one leaves the device unreachable after the OTA. Never paste a config with credentials into a PR, never commit scratch configs or logs.
|
||||
3. **Build from the tree under test.** `python -m esphome` puts the current directory first on
|
||||
`sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by
|
||||
its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the
|
||||
checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep
|
||||
the build's `src/` for the change before trusting any number. Tool calls sent together share one
|
||||
shell, so a `cd` in one can land another's compile in the wrong tree: pass `PYTHONPATH=<worktree>` on
|
||||
every compile and upload command and print `esphome.__file__` into the compile log.
|
||||
4. **PR build and baseline build get different `name:` values.** Builds share
|
||||
`.esphome/build/<name>`, so the same name means `esphome upload` can flash the wrong binary. Saved
|
||||
Wi-Fi credentials are keyed by the config hash when the config has `networks:`, so a test that must
|
||||
start unprovisioned gets a fresh name every run. A config with `ap:` only and no `networks:` uses one
|
||||
fixed key shared by every such config on the board, so a new name does not help there: wipe once at
|
||||
boot with an `on_boot` lambda that resets the preferences and saves a marker key
|
||||
(`templates/dut-ap-only-wipe-once-bk72xx.yaml`).
|
||||
5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD`
|
||||
checked out in a throwaway worktree), with the baseline commit named in the table. When a delta
|
||||
looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it.
|
||||
6. **Prove a quirk is pre-existing before blaming the PR:** run the identical flow on a `dev` build in
|
||||
the same session and say so in the results.
|
||||
7. **Pipe safely.** Set `PYTHONUNBUFFERED=1` on any `esphome run` or `esphome logs` whose output is
|
||||
piped, or lines land minutes late. Bound every long command with `timeout` (coreutils) or
|
||||
`perl -e 'alarm shift; exec @ARGV' <seconds> <command>` on macOS without it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Find the boards:** `python $SKILL/scripts/ports.py` (any Python with pyserial, for example the
|
||||
ESPHome venv). Port names follow the USB hub port and change on every replug, so never reuse a name
|
||||
from an earlier session. CH340 adapters (D1 mini, BW15 kit, BK7238, CB3S) all look the same; tell
|
||||
them apart with a 10 s capture using `serial_capture.py --reset` (ESP8266 prints its ROM banner at
|
||||
74880 baud, LibreTiny boards print `[LT]` and ESPHome lines, BK7238 prints its bootloader version).
|
||||
`ports.py --wait 2e8a` blocks until a Pico re-enumerates.
|
||||
2. **Make a scratch directory** outside the checkout, with `secrets.yaml` and the templates you need.
|
||||
One yaml per board per variant, file name equal to `name:`.
|
||||
3. **Compile** in the right tree:
|
||||
`(cd <worktree> && PYTHONUNBUFFERED=1 timeout 1500 python -m esphome compile <abs yaml> > <name>-compile.log 2>&1)`.
|
||||
Keep the RAM and flash lines at the end. Two ESP-IDF or two LibreTiny builds at the same time race
|
||||
over tool caches; run them one at a time.
|
||||
4. **Flash** per chip family (table below). Stop the capture on that port first.
|
||||
5. **Capture** with `serial_capture.py PORT SECONDS OUT` in the background, then poll the file with
|
||||
`grep` in an `until ... sleep 2` loop with a cap. USB CDC boards (Pico, nRF52, native USB ESP32)
|
||||
drop the port on reboot; the script reopens it until its deadline.
|
||||
6. **Drive** the test: a second board running a client config, `curl`, an aioesphomeapi script that
|
||||
presses a template button and subscribes to logs, MQTT publishes, or `interval:` and `on_boot:`
|
||||
automations in the config itself.
|
||||
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
|
||||
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
|
||||
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
|
||||
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
|
||||
no IP addresses.
|
||||
|
||||
## Flashing by chip family
|
||||
|
||||
| Board | Flash | Log |
|
||||
|---|---|---|
|
||||
| ESP8266 D1 mini (CH340) | `python -m esphome upload <yaml> --device <port>` | `serial_capture.py` at 115200; `esphome logs` on some CH340s emits garbage after the RTS reset. A failed esptool write can wedge the adapter until the board is replugged. `esptool erase_flash` before a test that needs an unprovisioned device: the preference sector survives a reflash |
|
||||
| ESP32 devkit (CP2102 or CH340) | `esphome upload --device <port>`, ESP-IDF or Arduino | `serial_capture.py`. Default Wi-Fi light sleep delays API driven actions by seconds; judge timing from device logs. `power_save_mode: none` for OTA overlap tests |
|
||||
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
|
||||
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
|
||||
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
|
||||
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner |
|
||||
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
|
||||
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
|
||||
| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port |
|
||||
| Any reachable device | `esphome upload <yaml> --device <ip or name.local>`; `--file <bin>` for a prebuilt image | `esphome logs --device <ip>` bounded with `timeout` |
|
||||
|
||||
ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA port is 8892.
|
||||
|
||||
## Scripts in `$SKILL/scripts`
|
||||
|
||||
- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`,
|
||||
`--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and
|
||||
`--serial-of PORT` (the serial number behind a path).
|
||||
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped
|
||||
capture, ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
|
||||
deadline. OUT is truncated unless `--append` is given, so counts over it cover one run.
|
||||
- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot
|
||||
strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not.
|
||||
- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now
|
||||
and then; retry up to three times.
|
||||
- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`:
|
||||
the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the
|
||||
templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0),
|
||||
`RESULT FAIL` (exit 3: a crash, a reboot after the AP came up, or the device never joined the network),
|
||||
`RESULT INVALID`
|
||||
(exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run)
|
||||
or exits 1 when a build, flash or port step failed.
|
||||
|
||||
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `online_image/`
|
||||
(server, image generator, API driver), `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 and
|
||||
capture decoder).
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and
|
||||
serves the captive portal. `example-dut.yaml` is the per-run file with the platform block.
|
||||
- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real
|
||||
credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a
|
||||
plain provisioning check.
|
||||
- Per-topic device configs, each described in `reference/recipes/<component>.md`: `online-image-dut.yaml`,
|
||||
`usb-cdc-acm-dongle-dut.yaml`, `nrf52-preferences-dut.yaml`, `rtsp-waveshare-s3-eth-dut.yaml`,
|
||||
`waveshare-s3-eth-camdiag.yaml`, `s31-cse7766-dut.yaml`, `nrf52-coredump-crash-dut.yaml`,
|
||||
`bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`.
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
- A capture stopped early or a line is missing: check the timestamps before trusting it.
|
||||
- The client never joins the AP: another board may still run an AP with the same SSID, or the device
|
||||
booted with saved credentials because the name was reused. One AP device at a time on the bench.
|
||||
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse
|
||||
too: two binaries that differ only in the build timestamp and in `__LINE__` values of log calls below
|
||||
the changed code are the same build; map the offsets with `objdump -h` and `addr2line` first.
|
||||
- A boot the host cannot capture (a power cut, an OTA-only board): keep the boot report in a flash
|
||||
preference and repeat it in a periodic alive line, as `scripts/libretiny_prefs/ltprefs.hpp` does.
|
||||
- Multicast from the host never arrives on some access points: test it device to device. Broadcast
|
||||
is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate.
|
||||
- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for
|
||||
soak tests.
|
||||
|
||||
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes/` has the proven
|
||||
flows. Add new findings there.
|
||||
@@ -1,190 +0,0 @@
|
||||
# Board facts
|
||||
|
||||
Ports are not listed on purpose: they follow the USB hub port and change on every replug. Run
|
||||
`scripts/ports.py`.
|
||||
|
||||
## ESP8266 (D1 mini, NodeMCU, CH340 or CP2102)
|
||||
|
||||
- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset`
|
||||
pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`).
|
||||
- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset;
|
||||
a plain pyserial capture at 115200 is reliable.
|
||||
- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug.
|
||||
- The preference sector survives a reflash: a provisioned API key or saved Wi-Fi credentials stay
|
||||
active. `esptool erase_flash` gives a clean device.
|
||||
- `Panic core_esp8266_main.cpp:191 __yield` with `ctx: sys` means a yield from lwIP context (a web
|
||||
handler, an lwIP callback). Decode stacks with
|
||||
`xtensa-lx106-elf-addr2line -e <build>/.pioenvs/<name>/firmware.elf -f -C -i <addr...>` from the
|
||||
toolchain under `~/.platformio/packages/toolchain-xtensa/bin/` (PlatformIO builds) or the ESPHome
|
||||
cache directory (native toolchain builds).
|
||||
- Constant data lives in RAM unless marked `PROGMEM`; with 80 KB of RAM a few hundred bytes are worth a
|
||||
row in the PR.
|
||||
- AsyncMqttClient drops inbound topics over 128 characters; topics of 255 or more wrap a counter and
|
||||
stall the link until the broker drops TCP.
|
||||
|
||||
## ESP32 classic devkit
|
||||
|
||||
- Takes ESP-IDF and Arduino. Default Wi-Fi light sleep delays API driven actions by up to 6 s; judge
|
||||
timings from device logs, not from the host. `power_save_mode: none` for OTA overlap tests (an IDF web
|
||||
upload of 739 KB took 107 s with power save and 45 s without).
|
||||
- Receives only a few percent of IPv6 multicast on some access points with power save on; a raw lwIP
|
||||
socket loses the same, so that is the AP or driver, not a PR.
|
||||
- Decode with `xtensa-esp32-elf-addr2line` from the IDF tools directory against
|
||||
`<build>/.pioenvs/<name>/firmware.elf` (PlatformIO) or `<build>/build/<name>.elf` (IDF native).
|
||||
- Clean reboot between runs with the capture stopped: `esptool --after hard_reset chip_id`.
|
||||
- Trigger a forced crash only after the app is marked valid (45 s after boot is plenty), never on an
|
||||
image still pending OTA rollback.
|
||||
- Signed OTA testing: build with `esp32.framework.advanced.signed_ota_verification.verification_keys`
|
||||
under a different name, sign with `python -m espsecure sign_data --version 2 --keyfile
|
||||
tests/components/esp32/dummy_signing_key.pem`, upload with `esphome upload --file`. The verifier
|
||||
refuses unsigned images afterwards, so restore with a signed plain build.
|
||||
|
||||
## ESP32-S3, C3, C6, H2 with native USB
|
||||
|
||||
- `/dev/cu.usbmodem*`, esptool works, `logger: hardware_uart: USB_SERIAL_JTAG`.
|
||||
- The USB console can die at the moment the app takes over the console during a reboot (log stops after
|
||||
`Disabling RNG early entropy source`, then 0 bytes, esptool reports `No serial data received`). Only a
|
||||
USB replug fixes it. Fallback: `esphome logs --device <name>.local` (API log stream, which drops lines
|
||||
while Wi-Fi is off).
|
||||
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry).
|
||||
|
||||
## RP2040 / RP2350 (Pico W, Pico 2 W)
|
||||
|
||||
- The CDC port re-enumerates after every reboot and after `esphome upload` (1200 baud touch). Re-detect
|
||||
with `ports.py --wait 2e8a` before capturing; `serial_capture.py` reopens on its own if the port
|
||||
vanishes mid-capture.
|
||||
- First flash ever: BOOTSEL, then `esphome upload --device /Volumes/RPI-RP2` (or `RP2350`).
|
||||
- arduino-pico creates the lwIP netif in `WiFi.begin()`, so any IGMP join during component setup fails;
|
||||
joins must wait for `network::is_connected()`.
|
||||
- Parallel Pico builds race on package extraction after a PlatformIO package wipe; one at a time.
|
||||
- An RTS pulse from pyserial does not reset the board; reboot with `App.safe_reboot()` or the touch.
|
||||
|
||||
## BW15 kit (RTL8720CF, LibreTiny rtl87xx)
|
||||
|
||||
- The kit's CH340 wires RTS to CEN and DTR to PA00 (boot strap). A plain pyserial open asserts both and
|
||||
holds the chip in reset, which is why `esphome upload`, plain `ltchiptool` and `esphome logs` all fail
|
||||
("Timeout while linking", or silence). `scripts/flash_rtl87xx_kit.py` sets DTR high and RTS released
|
||||
on every open, pulses RTS once so the ROM answers `ping`, and after the command drops DTR and pulses
|
||||
RTS to boot the app. Run it with the PlatformIO LibreTiny penv Python.
|
||||
- `flash write -d <port> <uf2>` takes no family argument; `flash read -d <port> -s 0 -l <len>
|
||||
realtek-ambz2 <out>` does.
|
||||
- Capture with `serial_capture.py --released`. The ESPHome log is on the flashing UART already.
|
||||
- The macOS CH340 driver only pushes DTR/RTS to the wire once a session has driven them, so the first
|
||||
plain open after a replug streams logs and the second is silent; always open with the lines released.
|
||||
- Driver quirks seen on `dev`: `WiFi.begin` can refuse the real network on the first try after bogus
|
||||
credentials (it connects after two cooldowns); right after connecting while the AP is still up the
|
||||
driver can bus-fault in `rtw_is_adapter_up` (`R0 = 0xdeadbeef`) and reboot; the AP restarts on every
|
||||
STA retry, so stations cannot stay joined; `WiFi.begin` fails synchronously for an absent network and
|
||||
lands in a cooldown at once; connecting blocks the loop 2.5 to 4.1 s.
|
||||
- Decode faults with `arm-none-eabi-addr2line -e <build>/.pioenvs/<name>/raw_firmware.elf -f -C -i <PC>`
|
||||
from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`).
|
||||
Fault dumps print PC, LR and R0 to R3.
|
||||
- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`.
|
||||
|
||||
## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN)
|
||||
|
||||
- Stock ltchiptool closes and reopens the port after its reset, and pyserial raises both lines on open,
|
||||
holding CEN low through the BootROM window. `scripts/flash_bk72xx.py` opens with both released and
|
||||
pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset
|
||||
reaches the downloader; no software reboot does.
|
||||
- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter.
|
||||
- Opening the port may reset the chip (bootloader banner on open) or may not, depending on the adapter and
|
||||
what pyserial does with DTR and RTS; `serial_capture.py --reset` pulses RTS on CEN and always does, and
|
||||
`lt_get_reboot_reason()` reports that as Power-On. Check for the banner at the start of a capture before
|
||||
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.
|
||||
- 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
|
||||
for 10 s and the hardware watchdog resets the chip. With Wi-Fi connected a dev build hangs forever after a
|
||||
crash, and never resets from a plain `portDISABLE_INTERRUPTS(); while (1) {}` either: the boot-time
|
||||
watchdog is dead once Wi-Fi is up, for any lockup. The crash handler fix (#21012) re-arms the watchdog
|
||||
from its wrapper, after which the reboot comes within a second (BK7238 and BK7231N).
|
||||
- The SDK prints nothing on UART1 under `sdk_silent: all`; the bootloader then prints the banked
|
||||
`cpsr/spsr/r13/r14` of SVC, IRQ, FIQ and SYS plus `ST:` (the retention register) before `go os_addr`.
|
||||
A CEN or power reset wipes ITCM (reads `0xaaaaaaaa`), so only a watchdog reset keeps a record there. What
|
||||
survives that reboot on the BK7238: ITCM (`0x3F0000..0x3F4000`) and the top 96 KB of SRAM
|
||||
(`0x428000..0x440000`, the heap); the bootloader zeroes SRAM up to about `0x420000` and rewrites
|
||||
`0x400024..0x4000FF` with its own register dump every boot, so neither a `.noinit` after `.bss` nor the
|
||||
SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception
|
||||
handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit`
|
||||
placed there after `.itcm.code` works for a crash record.
|
||||
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
|
||||
radio enable 160 to 278 ms, disable about 200 ms.
|
||||
|
||||
## BW16 kit (RTL8720DN, AmebaD)
|
||||
|
||||
- The kit's own USB-C (CH340) is on LOG_UART and flashes fine. Its two-transistor circuit resets while
|
||||
RTS alone is asserted and straps BURN while DTR alone is; both asserted does nothing. ROM entry is
|
||||
esptool's classic sequence (`dtr:false,rts:true` 100 ms, `dtr:true,rts:false` 400 ms, both false); the
|
||||
first pulse after a replug can be lost, retry up to three times.
|
||||
- Any reopen resets the chip, 460800 is flaky, 115200 is solid: one open per session, in-place baud
|
||||
changes only. The ROM and the loader NAK continuously while idle, so ACK scans must drain first.
|
||||
- The AmebaD core has no mDNS responder (`mdns: disabled: true`).
|
||||
|
||||
## LN882H
|
||||
|
||||
- ltchiptool forces 1 Mbaud for writes; adapters that cannot hold it fail with "Timeout while linking"
|
||||
or die mid-write. Patch `LN882H_YM_BAUDRATE` in the penv's `ltchiptool/soc/ln882h/util/ln882htool.py`
|
||||
to 115200 (lost on reprovision). Weak 3.3 V from the adapter: feed the module's 5 V pad instead. Short
|
||||
BOOT to GND while the tool connects. OTA afterwards.
|
||||
- `USE_WIFI_AP_EXCLUSIVE` (one radio mode at a time) exists only on LN882x; compile-check changes there.
|
||||
|
||||
## nRF52840
|
||||
|
||||
- Nordic PCA10059 dongle: the factory Open DFU bootloader enumerates as `1915:521F` on its own port
|
||||
name; the app as `2FE3:0100` with two CDC ports, ESPHome log on the second. Only Nordic
|
||||
`nrfutil nrf5sdk-tools` works (`pkg generate --hw-version 52 --sd-req 0x00 --application-version 1
|
||||
--application zephyr.hex app.zip`, then `dfu usb-serial -pkg app.zip -p <port>`); ESPHome downloads
|
||||
it on the first build. GPREGRET does not enter the bootloader; a pin reset through P0.19 does, which
|
||||
`dfu: true` wires up on this board. A pulsing red LED means bootloader; the side RESET button
|
||||
re-enters it.
|
||||
- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume,
|
||||
adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24),
|
||||
button P0.18.
|
||||
- Adafruit ItsyBitsy nRF52840 Express (UF2 bootloader with S140, bootloader port `239a:0051` plus an
|
||||
`ITSY840BOOT` volume): the board DTS already has `cdc_acm_uart0`, so the logger port is the first CDC
|
||||
port and a `usb_cdc_acm` interface 1 the second. A `dfu: true` build enters the bootloader on the
|
||||
1200 bps touch (an app without it needs a double tap on RESET); `adafruit-nrfutil` over the bootloader
|
||||
serial port can fail, copying `.pioenvs/<name>/zephyr/zephyr.uf2` to the volume works (the copy reports
|
||||
an I/O error as the board reboots, which is normal). Reboot a running `dfu: true` app with a 2001 bps touch.
|
||||
- The PCA10059 board DTS already defines a `cdc_acm_uart` node, so every ESPHome CDC port is one USB port
|
||||
later than its number (log on `cdc_acm_uart0` is the second port). A third CDC instance needs
|
||||
`CONFIG_USB_MAX_NUM_TRANSFERS` above 4 or it never moves data (see `recipes/usb_cdc_acm.md`).
|
||||
- USB CDC boot output: the Zephyr legacy stack's `cdc_acm` TX ring is 1 KB and `uart_poll_out` drops when it
|
||||
is full, so anything printed in one burst beyond about 1 KB (a coredump, a long table) is lost even with
|
||||
the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead.
|
||||
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
|
||||
a never-saved value.
|
||||
|
||||
## Waveshare ESP32-S3-ETH with the OV5640 camera module
|
||||
|
||||
- Native USB console (`303a:1001`), ESP32-S3R8, 16 MB flash, 8 MB octal PSRAM, W5500 on SPI (MOSI 11,
|
||||
MISO 12, SCLK 13, CS 14, INT 10, RST 9), TF card on 4..7, WS2812 on 21.
|
||||
- Camera header (vendor schematic): XCLK 3, SIOD 48, SIOC 47, VSYNC 1, HREF 2, PCLK 39, D0..D7 = 41, 45,
|
||||
46, 42, 40, 38, 15, 18. PWDN is tied to GND and RESET is an RC to 3.3 V on the board, so no reset or
|
||||
power-down GPIO exists.
|
||||
- GPIO8 switches the camera 3.3 V rail through a P-channel MOSFET: LOW = camera powered, gate pulled up so
|
||||
the camera is off by default. `power_down_pin: GPIO8` only pulls it low 10 ms before the probe, which is
|
||||
not enough; use `switch: gpio GPIO8 restore_mode: ALWAYS_OFF setup_priority: 800` plus `on_boot:
|
||||
priority: 700` with `delay(1200)` and `esp32_camera: setup_priority: 200`
|
||||
(`templates/rtsp-waveshare-s3-eth-dut.yaml`). `esphome config` rejects a config that uses GPIO8 for both the switch
|
||||
and `power_down_pin` (pin reuse), so it is one or the other.
|
||||
- The sensor at SCCB 0x3C answers only with XCLK running, so the ESPHome i2c scan at boot (before XCLK)
|
||||
reporting no devices on 48/47 is expected. If the SCCB ID register (0x3100 = 0x78) reads fine but every
|
||||
0x30xx register reads 0x00 (`Detected camera not supported`, 0x106), reseat the ribbon before blaming
|
||||
software; `templates/waveshare-s3-eth-camdiag.yaml` runs that probe with LEDC XCLK and raw SCCB reads.
|
||||
|
||||
## ESP Thread Border Router board v1.2 (S3 host + H2 RCP)
|
||||
|
||||
- S3 host on USB JTAG serial, H2 RCP on its own USB. Spinel UART1 from the host: `rx_pin: GPIO17`,
|
||||
`tx_pin: GPIO18`, RCP reset `GPIO7` active low, 460800. The config needs `network: enable_ipv6: true`.
|
||||
- The shipped H2 firmware fails `CheckRadioCapabilities` with IDF 5.5; build `examples/openthread/ot_rcp`
|
||||
for esp32h2 with ESPHome's own IDF (`IDF_PATH` at the cached framework, its penv and tools,
|
||||
`IDF_PYTHON_CHECK_CONSTRAINTS=no`) and flash with esptool at 0x0, 0x8000 and 0x10000.
|
||||
- A crash-looping S3 pulses the H2 reset every boot, so the H2 cannot be flashed and the S3 will not
|
||||
enter its bootloader over USB. Hold S3 BOOT, tap S3 RST, flash the H2, release, then replug both.
|
||||
- `dns-sd -L <name> _meshcop._udp local.` shows the `omr` prefix; it disappears when routing is dead.
|
||||
Keep one capture per chip; never reset the H2 over USB mid-test unless that is the test (the host
|
||||
logs `Unexpected RCP reset: RESET_OTHER` and recovers).
|
||||
@@ -1,26 +0,0 @@
|
||||
# Proven test flows
|
||||
|
||||
Each flow names the pull requests it was used on, so their result tables show what to post.
|
||||
|
||||
One file per component, named after it:
|
||||
|
||||
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266
|
||||
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
|
||||
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
|
||||
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
|
||||
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
|
||||
- [`ld2450.md`](ld2450.md): polygon zones
|
||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
|
||||
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT
|
||||
- [`nrf52.md`](nrf52.md): dongle board support
|
||||
- [`online_image.md`](online_image.md): stalled and malformed downloads
|
||||
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
|
||||
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
|
||||
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
|
||||
- [`scheduler.md`](scheduler.md): loop wake A/B
|
||||
- [`udp.md`](udp.md): datagrams, multicast, sockets
|
||||
- [`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)
|
||||
- [`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
|
||||
@@ -1,7 +0,0 @@
|
||||
# api
|
||||
|
||||
## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233)
|
||||
|
||||
Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over
|
||||
two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with
|
||||
`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml.
|
||||
@@ -1,21 +0,0 @@
|
||||
# captive_portal
|
||||
|
||||
## Captive portal `/wifisave` hammer (#20802, #20784)
|
||||
|
||||
Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board.
|
||||
Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`,
|
||||
`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the
|
||||
device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown
|
||||
is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on
|
||||
ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md).
|
||||
|
||||
Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash
|
||||
markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`.
|
||||
A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters
|
||||
the adapter restart), no panic, and the real credentials connect the device within about 10 s.
|
||||
|
||||
Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in
|
||||
`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag
|
||||
(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive
|
||||
portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection
|
||||
refused proves the portal's server is gone.
|
||||
@@ -1,11 +0,0 @@
|
||||
# cse7766
|
||||
|
||||
## cse7766 on a Sonoff S31 (#20948)
|
||||
|
||||
`templates/s31-cse7766-dut.yaml`: `logger: baud_rate: 0` because the CSE7766 owns the UART, VERBOSE logger
|
||||
with everything but cse7766 quieted, 2 s throttles, and a `globals` frame counter fed by the voltage sensor's
|
||||
`on_raw_value` and logged every 10 s (about 96 frames per 10 s). OTA only, every reflash reboots the relay.
|
||||
`scripts/cse7766/run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]` subscribes to states and logs over the
|
||||
API with a concurrent ping and prints the frame counter, API gaps and warnings. API state gaps of 10 to 20 s
|
||||
can appear on a weak Wi-Fi path while the device counter stays flat, so judge a UART parser by the
|
||||
device-side counter, not by the API cadence. With nothing plugged in, current and power read 0.
|
||||
@@ -1,7 +0,0 @@
|
||||
# espnow
|
||||
|
||||
## ESP-NOW on the ESP8266 (#14407)
|
||||
|
||||
The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is
|
||||
up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only
|
||||
mode the device transmits from the soft-AP MAC.
|
||||
@@ -1,9 +0,0 @@
|
||||
# hdmi_cec
|
||||
|
||||
## HDMI-CEC two-board wire (#9189)
|
||||
|
||||
Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:`
|
||||
filter on the reply handler, template buttons for each target plus lambdas calling
|
||||
`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms,
|
||||
round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep
|
||||
lags the press).
|
||||
@@ -1,7 +0,0 @@
|
||||
# ld2450
|
||||
|
||||
## LD2450 polygon zones (#20154)
|
||||
|
||||
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
|
||||
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
|
||||
Writing those numbers blocks the ld2450 loop about 50 ms.
|
||||
@@ -1,58 +0,0 @@
|
||||
# libretiny
|
||||
|
||||
## LibreTiny rtc preference emulation (#21007)
|
||||
|
||||
The PR keeps `storage: rtc` preferences in FlashDB and deletes them on the first load or save after a boot
|
||||
whose `lt_get_reboot_reason()` is not software, watchdog, crash or sleep. Only the families listed in
|
||||
`_libretiny_rtc_supported()` accept the config keys; on others force the backend in with
|
||||
`platformio_options: build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]` after confirming the rejection.
|
||||
|
||||
- Harness: `scripts/libretiny_prefs/ltprefs.hpp` plus `templates/bk72xx-rtc-prefs-dut.yaml`. `ltprefs_boot()`
|
||||
loads one rtc and one flash preference, a second pair that shares one key as rtc and as flash, and a third
|
||||
rtc entry (so a hard reset drops three entries in one pass), logs the reason and both results, saves `100 + step`, syncs; after
|
||||
a templated delay `ltprefs_exit()` bumps the flash-backed step and runs the step's action (`lt_reboot()`,
|
||||
`__builtin_trap()`, `lt_reboot_wdt()` or idle). The last 8 boot reports are kept in a flash preference and
|
||||
repeated in the 10 s alive line, so a boot missed by the capture (a power cut, an OTA-only board) is still
|
||||
readable. Bump `run:` for fresh keys.
|
||||
- A store to an unmapped address does not fault on the ARM968, the board carries on: use `__builtin_trap()`
|
||||
for the crash row. The SDK crash handler reboots about 10 s later. Works on BK7238 and BK7231N.
|
||||
- Hard resets: `serial_capture.py ... --reset` pulses RTS on CEN (reported as Power-On). A real power cut
|
||||
needs a hand or a smart plug; the history in the alive line covers the boot the host cannot see. On the
|
||||
Beken SDK a chip-enable reset and a power cut both report Power-On, there is no separate reset-pin source.
|
||||
- Step 0 and step 8 wait 30 s before acting so the host can open the port (and pulse a reset) after a flash
|
||||
without losing the first boot; the flash tool's own reset starts the sequence immediately otherwise.
|
||||
- OTA-only board: keep the device's wifi, api and ota blocks verbatim, keep safe_mode, read logs with
|
||||
`esphome logs <yaml> --device <name>.local` (the API attaches late, hence the history), and restore the
|
||||
device's own config afterwards.
|
||||
- Plain-build comparison: `raw_firmware.bin` differs by the timestamp and by `__LINE__` in log calls of
|
||||
functions below the added code; map offsets through `objdump -h` load addresses and `addr2line` before
|
||||
calling a byte difference a code change.
|
||||
|
||||
## BK72xx crash report on the next boot (#21012)
|
||||
|
||||
Harness: `scripts/libretiny_crash/bkprobe.hpp` plus `templates/bk72xx-crash-probe-dut.yaml`. A flash
|
||||
preference holds the step; `on_boot` (priority -100) logs the reset reason, the SDK start type, two canaries
|
||||
and hex dumps of the low SRAM, a set of marker words and the record area after `.bss`, waits 30 s on step 0
|
||||
(so a capture can be opened after the flash) or 5 s, saves `step + 1`, syncs, then acts: 0 `__builtin_trap()`,
|
||||
1 call into unmapped memory, 2 unmapped and unaligned reads and a store (no fault on the ARM968), 3
|
||||
`lt_reboot()`. A `globals: restore_value` step counter does not work here: it is only saved from the global's
|
||||
loop, which a crash 50 ms later never reaches; save an `ESPPreferenceObject` and `global_preferences->sync()`
|
||||
first. Bump `run:` for fresh keys. Keep the `wifi:` block: the bug the fix addresses only shows with Wi-Fi up.
|
||||
|
||||
Flow: `flash_bk72xx.py` (retry up to three times), one `serial_capture.py ... --reset` capture of 150 to 230 s,
|
||||
then `grep -a "libretiny.crash\|probe"`. Decode with
|
||||
`arm-none-eabi-addr2line -pfiaC -e .pioenvs/<name>/raw_firmware.elf <addr...>` (the ELF is `raw_firmware.elf`
|
||||
on LibreTiny), or run `scripts/libretiny_crash/decode_capture.py <yaml> <capture>` from the config directory
|
||||
to feed the capture through the CLI's `LogLineProcessor`.
|
||||
|
||||
Expected: `*** CRASH DETECTED ON PREVIOUS BOOT ***`, `Reason: Undefined instruction`, PC on the `udf`,
|
||||
LR = PC + 1, BT0..BT7 walking the lambda, `StatelessLambdaAction`, `Action::play_complex`, `DelayAction`,
|
||||
`Scheduler::call`, `Application::loop`. The wild call reports where the core ended up (it executes zeros as
|
||||
Thumb NOPs until it wraps into real code), not the called address. Without the fix a dev build with Wi-Fi
|
||||
connected never comes back from the crash; see the BK72xx section in `boards.md`.
|
||||
|
||||
OTA-only board: copy the device's own config, add the probe include and the step machine (step 0 crashes
|
||||
45 s after boot, later steps idle), keep its wifi, api, ota and safe_mode blocks and use the secrets file
|
||||
next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the
|
||||
upload in a short timeout. Read the replayed report with `esphome logs ... --device <name>.local` (an API
|
||||
client gets it on subscribe) or on the board's serial log port if it has one.
|
||||
@@ -1,12 +0,0 @@
|
||||
# mqtt
|
||||
|
||||
## MQTT (#20433, #17935, #20762, #20767, #20768)
|
||||
|
||||
A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives
|
||||
each subscription's QoS, the device address (`New client connected from <ip> as <name>-<mac>`) and exact
|
||||
subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription.
|
||||
Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right
|
||||
after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a
|
||||
leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish.
|
||||
`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a
|
||||
paho script publishes and polls the serial capture file.
|
||||
@@ -1,6 +0,0 @@
|
||||
# nrf52
|
||||
|
||||
## nRF52840 dongle boards (#19009, #20814)
|
||||
|
||||
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
|
||||
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
|
||||
@@ -1,20 +0,0 @@
|
||||
# online_image
|
||||
|
||||
## online_image stalled downloads (#20594)
|
||||
|
||||
One classic ESP32, both frameworks (the ESP32 Arduino build also uses `http_request_idf.cpp`). The host
|
||||
serves the images: `scripts/online_image/make_images.py <www>` writes 16x16 BMP/PNG/JPEG/QOI plus an 8 bit
|
||||
palette BMP with a 1078 byte header; `scripts/online_image/oi_server.py <www> 8765 <log>` answers one
|
||||
misbehaviour per path name (`ok`, `truncated`, `large_header`, `empty`, `stall` holds the socket 40 s,
|
||||
`closed_early`, `slow` pauses 2.5 s, `chunked`, `chunked_cut`, `noclen`, `huge` claims 20 MB) with an ETag
|
||||
and a 304 on If-None-Match. `templates/online-image-dut.yaml` has two AUTO-format images (default and
|
||||
256 byte buffer), API actions `fetch`/`fetch_small` (set_url with `update: false`, then component.update)
|
||||
and `refetch`/`refetch_small` (component.update only, keeps the cache headers), and a restart button.
|
||||
`scripts/online_image/drive.py <device ip> <api key> <capture> http://<host ip>:8765 pr|base` runs the
|
||||
case list over aioesphomeapi and judges each from the serial capture. ESP-IDF facts: a stalled server
|
||||
ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout, an early close with
|
||||
`-1`, both before and after the PR; a body without Content-Length and without chunking reads as complete
|
||||
(Size: 0) and finishes. A build without the fix hangs on the truncated, oversized header, empty, chunked PNG
|
||||
and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs
|
||||
a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list.
|
||||
A case expected as `any` passes on `finished` or `error`, never on no outcome.
|
||||
@@ -1,5 +0,0 @@
|
||||
# openthread
|
||||
|
||||
## Thread border router (#18104)
|
||||
|
||||
See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR.
|
||||
@@ -1,8 +0,0 @@
|
||||
# ota
|
||||
|
||||
## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037)
|
||||
|
||||
All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release
|
||||
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
|
||||
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
|
||||
`pkill -f "a\|b"` does not alternate; use two pkills.
|
||||
@@ -1,18 +0,0 @@
|
||||
# rtsp_server
|
||||
|
||||
## RTSP camera streaming on the Waveshare ESP32-S3-ETH (#17990)
|
||||
|
||||
`templates/rtsp-waveshare-s3-eth-dut.yaml` (OV5640 pins, GPIO8 camera power switch with the 1.2 s boot delay,
|
||||
`rtsp_server`, logger VERBOSE for `rtsp_server`); swap the `wifi:` block for the W5500 `ethernet:` block when
|
||||
the cable is in, and add `network: enable_high_performance: true` for the big TCP buffers. Flash over native
|
||||
USB, capture the log with `serial_capture.py`, take the IP from the last `IP Address` line that is not
|
||||
0.0.0.0 (or `dns-sd -Q <name>.local`). `scripts/rtsp_server/rtsp_client.py IP [cases]` covers play, udp,
|
||||
pipeline, badreq, replay, getparam, teardown, third, stall, timeout; `rawrate2.py IP quiet|chatty|rtcp|bigbuf`
|
||||
measures the raw receive rate; `measure.sh IP LABEL LOGFILE [OUTDIR]` runs the fixed set (ping, ffmpeg 12 s
|
||||
twice, two ffmpeg clients, raw reader) and appends to `results.txt`; `cam_api.py IP` pulls one image over the
|
||||
API; `ffmpeg -rtsp_transport tcp -i rtsp://IP:554/ -frames:v 30 -f image2 f%03d.jpg` plus Pillow checks
|
||||
decoding. The 5760 byte default TCP send buffer makes the stream stall over Wi-Fi (about 1 fps, dozens of
|
||||
stall drops per run); `enable_high_performance` gives several fps on Wi-Fi and removes the stalls on Ethernet.
|
||||
Frame counts vary a lot between runs (auto exposure changes the JPEG size), so always measure twice. Home
|
||||
Assistant's stream pipeline does not accept MJPEG over RTSP; its ffmpeg camera opens one RTSP session per
|
||||
snapshot, so `max_connections` needs headroom, and go2rtc transcoding to H.264 is the way to a live view.
|
||||
@@ -1,8 +0,0 @@
|
||||
# scheduler
|
||||
|
||||
## Scheduler and loop wake A/B (#20356)
|
||||
|
||||
An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266;
|
||||
the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout
|
||||
logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus
|
||||
`button_command`). Build the same yaml twice with different names, one per tree.
|
||||
@@ -1,11 +0,0 @@
|
||||
# udp
|
||||
|
||||
## UDP, multicast, sockets (#20556 to #20559, #20585, #20590)
|
||||
|
||||
A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device
|
||||
multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock
|
||||
listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate
|
||||
(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi
|
||||
reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect`
|
||||
(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action
|
||||
rejects `%.*s`; use a lambda.
|
||||
@@ -1,20 +0,0 @@
|
||||
# usb_cdc_acm
|
||||
|
||||
## usb_cdc_acm on the nRF52840 dongle (#11634)
|
||||
|
||||
`templates/usb-cdc-acm-dongle-dut.yaml`: logger on USB_CDC port 0, `usb_cdc_acm` interface 0 disabled,
|
||||
interface 1 echoing from a 20 ms lambda, 0x01-prefixed commands for burst, stream, flush, peek, line coding
|
||||
and counters. Build and flash with `dfu: true` through the app port; the dongle then shows three CDC ports:
|
||||
the board DTS node `cdc_acm_uart` (unused), `cdc_acm_uart0` (log) and `cdc_acm_uart1` (data), in that order.
|
||||
Capture the log port with `serial_capture.py`, drive the data port with `scripts/usb_cdc_acm/drive.py
|
||||
DATA_PORT` (11 cases, prints PASS/FAIL and a summary) and grep the log for `Opened`, `Closed`, `buffer full`,
|
||||
`flush result`, `line coding`. Boot lines are held in the driver's 1 KB ring until the host opens the port, so
|
||||
a capture started later still shows the first 1 KB of the boot and then a truncated line.
|
||||
|
||||
With three CDC instances the Zephyr legacy stack's `CONFIG_USB_MAX_NUM_TRANSFERS=4` starves the last instance
|
||||
(each instance holds an OUT read and an IN ZLP), so interface 1 shows DTR changes but moves no data.
|
||||
`scripts/usb_cdc_acm/cdc_probe.hpp` (include it, call `cdc_probe()` from an interval) reads the Zephyr driver
|
||||
directly to tell a dead endpoint from a component bug. Raising the transfer pool to 8, or disabling the
|
||||
board's `cdc_acm_uart` node, fixes it. Design limit of the driver: `fifo_fill` holds `tx_ready` until the
|
||||
write callback and `write_array` drops when the ring is full, so a 64 KiB full-duplex echo loses one 64 byte
|
||||
tail on every buffer size.
|
||||
@@ -1,25 +0,0 @@
|
||||
# wifi
|
||||
|
||||
## `reboot_timeout` with a fallback AP (#9958)
|
||||
|
||||
Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network,
|
||||
`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and
|
||||
ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes:
|
||||
`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles
|
||||
are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot
|
||||
with no station joined, no reboot for minutes while one is, then a reboot within a second of the last
|
||||
disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way.
|
||||
|
||||
## DHCP lease cache (#17767)
|
||||
|
||||
Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the
|
||||
`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep
|
||||
sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use
|
||||
`upload` then `logs`.
|
||||
|
||||
## Wi-Fi Easy Connect (DPP) enrollee (#17934)
|
||||
|
||||
Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's
|
||||
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
|
||||
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
|
||||
supplicant state machine.
|
||||
@@ -1,10 +0,0 @@
|
||||
# zephyr
|
||||
|
||||
## Zephyr preferences on the nRF52840 dongle (#20810)
|
||||
|
||||
`templates/nrf52-preferences-dut.yaml`: logger on USB_CDC at VERBOSE, `flash_write_interval: 5s`, one
|
||||
`globals` with `restore_value` incremented every 15 s. Give each build its own global id so each first boot
|
||||
sees a never-saved key (settings survive a reflash). Flow per build: flash through the log port, 40 s capture
|
||||
of the first boot, then start a second capture and reboot with a 2001 baud touch on the dongle's other CDC
|
||||
port (the dfu rate callback is global, and the capture holds the log port); `serial_capture.py` reopens the
|
||||
log port after the USB re-enumeration and gets the buffered boot lines (`load setting`, `Loaded N settings`).
|
||||
@@ -1,17 +0,0 @@
|
||||
# zephyr_coredump
|
||||
|
||||
## zephyr_coredump crash and decode on nRF52 boards (#13122)
|
||||
|
||||
`templates/nrf52-coredump-crash-dut.yaml` (dongle; change `board:` to `adafruit_itsybitsy_nrf52840` for the
|
||||
ItsyBitsy): `zephyr_coredump:`, `dfu: true`, a `restore_value` global keyed by the `run` substitution
|
||||
(settings survive a reflash, so bump `run` for every new crash), and an `on_boot` sequence that sets the
|
||||
flag, yields 3 s so the globals loop saves it (a blocking lambda never gets there and the board crash-loops),
|
||||
syncs preferences, then writes to 0xDEADBEEF. Flash, keep every port closed for 80 s, then
|
||||
`scripts/zephyr_coredump/flow.sh LABEL PORT ELF [OUTDIR]` does the three opens (3 s cut, 100 s full print,
|
||||
20 s reopen) and calls `decode.sh`, which keeps the first complete `#CD:BEGIN#..END#` block, runs Zephyr's
|
||||
`coredump_serial_log_parser.py` and `coredump_gdbserver.py` from the sdk-nrf ESPHome installed and prints
|
||||
`bt` and `info threads` with its `arm-zephyr-eabi-gdb`. The ELF is `.pioenvs/<name>/zephyr/zephyr/zephyr.elf`.
|
||||
Expect about 9 KB dumps, 140 to 150 `#CD:` lines, a 673 ms loop warning for the erase, and `Has stored
|
||||
dump: 0` on the reopen. Check `compiled on` in every capture: another session flashing the same board
|
||||
mid-test looks exactly like a failed test otherwise. Boards without a network cannot exercise the API and
|
||||
MQTT delivery paths of the component; say so in the results.
|
||||
@@ -1,23 +0,0 @@
|
||||
# zwave_proxy
|
||||
|
||||
## zwave_proxy frame checksums on a live proxy (#20922)
|
||||
|
||||
A Z-Wave proxy (ESP32-S3 with the module on `usb_uart`) is a live device: flash it over OTA and read logs
|
||||
over the API, there is no serial console because the native USB is the host port for the module, so the
|
||||
boot-time `GET_NETWORK_IDS` exchange is never captured. Home Assistant's Z-Wave JS holds the single API
|
||||
subscription (it polls `0x3B` every 30 s and resubscribes after every reboot), so do not subscribe from a
|
||||
script, and keep the device `name:` so it stays on Z-Wave JS; give each build its own scratch directory
|
||||
instead.
|
||||
|
||||
1. `logger: level: VERY_VERBOSE` with `logs: zwave_proxy: VERY_VERBOSE` and DEBUG for the rest: the
|
||||
`Sending:`, `Received frame:` and `CHECKSUM Received ... Calculated` lines are `ESP_LOGVV`, which needs
|
||||
the compile-time level.
|
||||
2. `md5` the two `firmware.ota.bin` files to be sure the A and B builds differ, then `esphome upload
|
||||
--device <name>.local` and a `timeout 330 esphome logs --device <name>.local` capture started right after
|
||||
the OTA to catch the resubscribe traffic.
|
||||
3. `scripts/zwave_proxy/verify_checksums.py <captures>` recomputes the XOR (init 0xFF, LENGTH..payload) over
|
||||
every logged frame and counts bad checksums and ACK/NAK sent and received; `scripts/zwave_proxy/devinfo.py
|
||||
<name>.local` prints the firmware version, compile time and the Home ID from the API (a Home ID proves the
|
||||
device-built `GET_NETWORK_IDS` frame was accepted at boot).
|
||||
4. Pre-existing: one `zwave_proxy took a long time for an operation` warning on the first frame after the
|
||||
log client connects, caused by VERY_VERBOSE logging; it appears on dev too.
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Subscribe to states and logs of a device over the API for N seconds and summarize its float sensors.
|
||||
|
||||
usage: observe.py IP SECONDS OUTFILE [NAME_FILTER]
|
||||
|
||||
Every float sensor state whose entity name contains NAME_FILTER (default: all) is appended to OUTFILE
|
||||
with a relative timestamp, log lines are appended with a LOG prefix, and a one-line summary per sensor
|
||||
(count, min, max, last) plus the warning and error count is printed at the end.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, LogLevel
|
||||
|
||||
IP, SECS, OUT = sys.argv[1], float(sys.argv[2]), sys.argv[3]
|
||||
FILTER = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
# ESPHome LOG_SECRET span; one cut off before its end marker is secret to the end of the line
|
||||
SECRET = re.compile(r"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL)
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
di = await c.device_info()
|
||||
ents, _ = await c.list_entities_services()
|
||||
names = {e.key: e.name for e in ents}
|
||||
readings: dict[str, list[float]] = {}
|
||||
warnings: list[str] = []
|
||||
t0 = time.monotonic()
|
||||
with pathlib.Path(OUT).open("w", encoding="utf-8") as out:
|
||||
out.write(
|
||||
f"device {di.name} version {di.esphome_version} compiled {di.compilation_time}\n"
|
||||
)
|
||||
|
||||
def on_state(st) -> None:
|
||||
n = names.get(st.key, str(st.key))
|
||||
if FILTER in n and hasattr(st, "state") and isinstance(st.state, float):
|
||||
readings.setdefault(n, []).append(st.state)
|
||||
out.write(f"[{time.monotonic() - t0:6.1f}] {n}: {st.state}\n")
|
||||
out.flush()
|
||||
|
||||
def on_log(msg) -> None:
|
||||
line = (
|
||||
msg.message.decode(errors="replace")
|
||||
if isinstance(msg.message, bytes)
|
||||
else msg.message
|
||||
)
|
||||
line = ANSI.sub("", SECRET.sub("<secret>", line))
|
||||
out.write(f"[{time.monotonic() - t0:6.1f}] LOG {line}\n")
|
||||
out.flush()
|
||||
if "[W]" in line or "[E]" in line or "Invalid" in line:
|
||||
warnings.append(line)
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
|
||||
await asyncio.sleep(SECS)
|
||||
await c.disconnect()
|
||||
summary = "; ".join(
|
||||
f"{k.split()[-1]} n={len(v)} min={min(v):.3f} max={max(v):.3f} last={v[-1]:.3f}"
|
||||
for k, v in sorted(readings.items())
|
||||
)
|
||||
print(f"{di.compilation_time}: {summary}; warnings/errors {len(warnings)}")
|
||||
for w in warnings[:5]:
|
||||
print(" ", w)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/zsh
|
||||
# usage: run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]
|
||||
# Observes states and logs over the API with a concurrent ping and prints a summary: the device-side
|
||||
# "frames parsed" counter per 10 s, gaps over 5 s between voltage updates seen by the API, and warnings.
|
||||
# Needs a python with aioesphomeapi (PYTHON, default python3).
|
||||
D=${0:A:h}
|
||||
IP=$1; SECS=$2; L=$3; S=${4:-.}; F=${5:-}
|
||||
PY=${PYTHON:-python3}
|
||||
setopt pipefail
|
||||
ping -i 1 -c $SECS $IP > $S/ping-$L.txt 2>&1 &
|
||||
P=$!
|
||||
if ! timeout $((SECS + 40)) $PY $D/observe.py $IP $SECS $S/obs-$L.txt "$F" 2>&1 | sed "s/$IP/<ip>/g"; then
|
||||
echo "observe.py failed or timed out, no summary"; wait $P; exit 1
|
||||
fi
|
||||
wait $P
|
||||
echo "ping: $(tail -2 $S/ping-$L.txt | head -1 | sed "s/$IP/<ip>/g") $(tail -1 $S/ping-$L.txt)"
|
||||
python3 - "$S/obs-$L.txt" <<'PY'
|
||||
import re, sys
|
||||
txt = open(sys.argv[1]).read()
|
||||
fr = [int(m.group(1)) for m in re.finditer(r"frames parsed: (\d+) in 10 s", txt)]
|
||||
print(f"device frame counter per 10 s: n={len(fr)} min={min(fr) if fr else None} max={max(fr) if fr else None} mean={sum(fr)/len(fr) if fr else 0:.1f}")
|
||||
ts = [float(m.group(1)) for m in re.finditer(r"^\[\s*([0-9.]+)\] .*Voltage: ", txt, re.M)]
|
||||
gaps = [round(b - a, 1) for a, b in zip(ts, ts[1:]) if b - a > 5]
|
||||
print(f"API voltage updates {len(ts)}, gaps over 5 s: {gaps}")
|
||||
warn = [l for l in txt.splitlines() if "LOG" in l and ("[W]" in l or "[E]" in l or "Invalid" in l)]
|
||||
print(f"warnings/errors in log: {len(warn)}", *warn[:3], sep="\n ")
|
||||
PY
|
||||
@@ -1,51 +0,0 @@
|
||||
"""ltchiptool for boards whose RTS drives CEN: reset and link on one open port.
|
||||
|
||||
Stock ltchiptool closes and reopens the port after its DTR/RTS reset, and
|
||||
pyserial raises both lines on open, holding CEN low through the BootROM's
|
||||
listen window. Open with both lines released and pulse the reset right
|
||||
before the LinkChecks instead.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import bk7231tools.serial.main as bk_main
|
||||
from ltchiptool.__main__ import cli
|
||||
from ltchiptool.soc.bk72xx.flash import BK72XXFlash
|
||||
from serial import Serial
|
||||
|
||||
|
||||
class ReleasedSerial(Serial):
|
||||
def __init__(self, port=None, **kwargs):
|
||||
super().__init__(None, **kwargs)
|
||||
self.dtr = False
|
||||
self.rts = False
|
||||
self.port = port
|
||||
self.open()
|
||||
|
||||
|
||||
bk_main.Serial = ReleasedSerial
|
||||
|
||||
_connect = BK72XXFlash.flash_connect
|
||||
|
||||
|
||||
def flash_hw_reset(self) -> None:
|
||||
self._reset_pending = True
|
||||
|
||||
|
||||
def flash_connect(self) -> None:
|
||||
pending = getattr(self, "_reset_pending", False)
|
||||
if self.bk and self.conn.linked and not pending:
|
||||
return
|
||||
self.flash_build_protocol()
|
||||
if pending:
|
||||
self._reset_pending = False
|
||||
self.bk.hw_reset()
|
||||
self.conn.linked = False # the chip rebooted, so link again below
|
||||
_connect(self)
|
||||
|
||||
|
||||
BK72XXFlash.flash_hw_reset = flash_hw_reset
|
||||
BK72XXFlash.flash_connect = flash_connect
|
||||
|
||||
sys.argv = ["ltchiptool", *sys.argv[1:]]
|
||||
cli()
|
||||
@@ -1,87 +0,0 @@
|
||||
"""ltchiptool for the BW15 kit whose CH340 drives CEN (RTS) and PA00 (DTR).
|
||||
|
||||
A plain open asserts both lines and holds the chip in reset. Here every open keeps DTR high and RTS
|
||||
released; the first open pulses RTS so the ROM comes up; after the command DTR low plus an RTS pulse
|
||||
boots the app. The exit status is ltchiptool's; 2 means the flash succeeded but the app was not
|
||||
booted (boot pulse failed, or no -d PORT to send it to), so the board sits in the ROM until reset.
|
||||
|
||||
Usage: python flash_rtl87xx_kit.py flash write -d <port> firmware.uf2
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
_first = [True]
|
||||
_orig_open = serial.Serial.open
|
||||
|
||||
|
||||
def _open(self):
|
||||
self.dtr = True # PA00 high: ROM download mode on the next reset
|
||||
self.rts = False # CEN released
|
||||
_orig_open(self)
|
||||
if _first[0]:
|
||||
_first[0] = False
|
||||
self.rts = True
|
||||
time.sleep(0.1)
|
||||
self.rts = False
|
||||
time.sleep(0.3) # reset into the ROM
|
||||
self.reset_input_buffer()
|
||||
|
||||
|
||||
serial.Serial.open = _open
|
||||
|
||||
from ltchiptool.__main__ import cli_entrypoint # noqa: E402
|
||||
|
||||
|
||||
def port_argument(argv: list[str]) -> str | None:
|
||||
for i, a in enumerate(argv):
|
||||
if a in ("-d", "--device") and i + 1 < len(argv):
|
||||
return argv[i + 1]
|
||||
if a.startswith(("-d=", "--device=")):
|
||||
return a.split("=", 1)[1]
|
||||
if a.startswith("-d") and len(a) > 2:
|
||||
return a[2:]
|
||||
return None
|
||||
|
||||
|
||||
def boot_app(port: str) -> None:
|
||||
s = serial.Serial(port=None)
|
||||
s.port = port
|
||||
s.dtr = False
|
||||
s.rts = False
|
||||
_orig_open(s)
|
||||
s.rts = True
|
||||
time.sleep(0.1)
|
||||
s.rts = False
|
||||
s.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rc = 1
|
||||
try:
|
||||
result = cli_entrypoint.main(args=sys.argv[1:], standalone_mode=False)
|
||||
rc = int(result) if isinstance(result, int) else 0
|
||||
finally:
|
||||
port = port_argument(sys.argv)
|
||||
if not port:
|
||||
print(
|
||||
"[flash_rtl87xx_kit] no -d PORT given, the application was not booted",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if rc == 0:
|
||||
rc = 2
|
||||
else:
|
||||
try:
|
||||
boot_app(port)
|
||||
print("[flash_rtl87xx_kit] application booted")
|
||||
except (serial.SerialException, OSError) as err:
|
||||
print(f"[flash_rtl87xx_kit] boot pulse failed: {err}", file=sys.stderr)
|
||||
if rc == 0:
|
||||
rc = 2 # flashed, but the board is still in the ROM: press reset
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,116 +0,0 @@
|
||||
// BK72xx crash probe: forces one crash per step and shows what the SDK and the bootloader leave behind.
|
||||
// Steps: 0 undefined instruction, 1 call into unmapped memory, 2 unmapped and unaligned accesses (no fault),
|
||||
// 3 lt_reboot(). The step lives in a flash preference saved and synced before the crash.
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
#include <cstdint>
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
extern "C" unsigned bk_misc_get_start_type(void);
|
||||
extern "C" char _bss_end[];
|
||||
|
||||
static constexpr uint32_t PROBE_CANARY_LOW = 0x004000F8; // free word in the SDK-reserved low SRAM
|
||||
static constexpr uint32_t PROBE_CANARY_ITCM = 0x003F3FF0; // top of ITCM
|
||||
static char bkprobe_report[112];
|
||||
static constexpr uint32_t PROBE_MARKS[] = {0x40A1B0, 0x40B000, 0x40C000, 0x40E000, 0x410000, 0x414000, 0x418000,
|
||||
0x41C000, 0x420000, 0x428000, 0x430000, 0x438000, 0x43C000, 0x43E000,
|
||||
0x43F000, 0x43F800, 0x43FC00, 0x43FE00, 0x43FFF0};
|
||||
static constexpr size_t PROBE_MARK_COUNT = sizeof(PROBE_MARKS) / sizeof(PROBE_MARKS[0]);
|
||||
|
||||
static inline uint32_t rd(uint32_t a) { return *(volatile uint32_t *) a; }
|
||||
|
||||
inline esphome::ESPPreferenceObject bkprobe_pref(uint32_t run) {
|
||||
return esphome::global_preferences->make_preference<int>(0xBC000000u + run);
|
||||
}
|
||||
|
||||
inline int bkprobe_boot(uint32_t run) {
|
||||
using namespace esphome;
|
||||
int step = 0;
|
||||
bkprobe_pref(run).load(&step);
|
||||
unsigned st = bk_misc_get_start_type();
|
||||
snprintf(bkprobe_report, sizeof(bkprobe_report), "step=%d reason=%s(%d) start_type=%u low=%08lx itcm=%08lx top=%08lx",
|
||||
step, lt_get_reboot_reason_name(lt_get_reboot_reason()), (int) lt_get_reboot_reason(), st,
|
||||
(unsigned long) rd(PROBE_CANARY_LOW), (unsigned long) rd(PROBE_CANARY_ITCM),
|
||||
(unsigned long) rd(0x0043FFF0u));
|
||||
ESP_LOGI("probe", "%s", bkprobe_report);
|
||||
for (uint32_t a = 0x400000; a < 0x400100; a += 32) {
|
||||
ESP_LOGI("probe", "%06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a,
|
||||
(unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12),
|
||||
(unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24),
|
||||
(unsigned long) rd(a + 28));
|
||||
}
|
||||
for (size_t i = 0; i < PROBE_MARK_COUNT; i += 7) {
|
||||
char line[120];
|
||||
int n = 0;
|
||||
for (size_t j = i; j < i + 7 && j < PROBE_MARK_COUNT; j++)
|
||||
n += snprintf(line + n, sizeof(line) - n, " %06lx=%08lx", (unsigned long) PROBE_MARKS[j],
|
||||
(unsigned long) rd(PROBE_MARKS[j]));
|
||||
ESP_LOGI("probe", "marks%s", line);
|
||||
}
|
||||
uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u;
|
||||
for (uint32_t a = rec; a < rec + 64; a += 32) {
|
||||
ESP_LOGI("probe", "rec %06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a,
|
||||
(unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12),
|
||||
(unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24),
|
||||
(unsigned long) rd(a + 28));
|
||||
}
|
||||
*(volatile uint32_t *) PROBE_CANARY_LOW = 0xC0DE0000u | (uint32_t) step;
|
||||
*(volatile uint32_t *) PROBE_CANARY_ITCM = 0x17C40000u | (uint32_t) step;
|
||||
return step;
|
||||
}
|
||||
|
||||
__attribute__((noinline)) void bkprobe_data_abort() {
|
||||
using namespace esphome;
|
||||
volatile uint32_t v = rd(0xFFFFFFF0);
|
||||
ESP_LOGI("probe", "read 0xFFFFFFF0 -> %08lx (no fault)", (unsigned long) v);
|
||||
uint32_t r;
|
||||
__asm__ volatile("ldr %0, [%1]" : "=r"(r) : "r"(0x00400101) : "memory");
|
||||
ESP_LOGI("probe", "unaligned ldr 0x400101 -> %08lx (no fault)", (unsigned long) r);
|
||||
*(volatile uint32_t *) 0xFFFFFFF0 = 1;
|
||||
ESP_LOGI("probe", "store 0xFFFFFFF0 (no fault)");
|
||||
}
|
||||
|
||||
// act is the step number before it was bumped
|
||||
inline void bkprobe_act(uint32_t run, int act) {
|
||||
using namespace esphome;
|
||||
int next = act + 1;
|
||||
bkprobe_pref(run).save(&next);
|
||||
global_preferences->sync();
|
||||
ESP_LOGI("probe", "acting on step %d (saved %d)", act, next);
|
||||
// mark candidate storage areas so the next boot shows what survives
|
||||
uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u;
|
||||
for (uint32_t i = 0; i < 8; i++)
|
||||
*(volatile uint32_t *) (rec + 32 + i * 4) = 0xA5A50000u | i; // second half of the record area
|
||||
for (size_t i = 0; i < PROBE_MARK_COUNT; i++)
|
||||
*(volatile uint32_t *) PROBE_MARKS[i] = 0x70700000u | (uint32_t) i;
|
||||
ESP_LOGI("probe", "ramtop before: %08lx", (unsigned long) rd(0x0043FFF0u));
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
switch (act) {
|
||||
case 0:
|
||||
__builtin_trap(); // undefined instruction
|
||||
break;
|
||||
case 1: {
|
||||
void (*fn)() = (void (*)()) 0xFFFFFFF1; // prefetch abort (unmapped, Thumb)
|
||||
fn();
|
||||
ESP_LOGI("probe", "call 0xFFFFFFF1 returned (no fault)");
|
||||
break;
|
||||
}
|
||||
case 2:
|
||||
bkprobe_data_abort();
|
||||
break;
|
||||
case 3:
|
||||
lt_reboot();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
inline void bkprobe_alive(int step) {
|
||||
using namespace esphome;
|
||||
ESP_LOGI("probe", "alive step=%d uptime=%lus boot: %s", step, (unsigned long) (millis() / 1000), bkprobe_report);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Feed a serial capture through the CLI's stacktrace decoder for a build.
|
||||
|
||||
usage: decode_capture.py CONFIG_YAML CAPTURE
|
||||
|
||||
Run from the directory that holds the config (its .esphome build is used), with the esphome checkout that
|
||||
built it on PYTHONPATH. Lines of the crash report are echoed, decoded frames follow them.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
yaml, capture = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
CORE.config_path = yaml
|
||||
config = read_config({})
|
||||
proc = LogLineProcessor(config, CORE.target_platform)
|
||||
for line in capture.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if "libretiny.crash" in line:
|
||||
print(line[:120])
|
||||
proc.process_line(line)
|
||||
@@ -1,155 +0,0 @@
|
||||
#pragma once
|
||||
// BK7238 bench harness for esphome#21007: one rtc and one flash preference, a step counter in flash,
|
||||
// and an exit action per step so the board drives its own soft reboot, crash and watchdog reboot.
|
||||
#include <libretiny.h>
|
||||
#include <cstring>
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
using namespace esphome;
|
||||
|
||||
// Boot report kept for the alive line, so a boot the host could not capture is still visible
|
||||
static char ltprefs_report[128];
|
||||
// Last boots' reports, kept in a flash preference so boots the host never saw stay readable
|
||||
struct LtprefsHistory {
|
||||
char line[8][128];
|
||||
};
|
||||
static LtprefsHistory ltprefs_hist;
|
||||
|
||||
inline ESPPreferenceObject ltprefs_step_pref(uint32_t run) {
|
||||
return global_preferences->make_preference<int>(0x3B210070u ^ run, true);
|
||||
}
|
||||
|
||||
// Loads the step and both preferences, logs them, saves this step's value.
|
||||
// Returns the exit action: 0 idle, 1 lt_reboot, 2 crash, 3 watchdog.
|
||||
inline int ltprefs_boot(int &step, uint32_t run) {
|
||||
auto reason = lt_get_reboot_reason();
|
||||
step = 0;
|
||||
ltprefs_step_pref(run).load(&step);
|
||||
auto rtcp = global_preferences->make_preference<uint32_t>(0x1B210070u ^ run, false);
|
||||
auto flp = global_preferences->make_preference<uint32_t>(0x2B210070u ^ run, true);
|
||||
uint32_t rv = 0, fv = 0;
|
||||
// a second pair sharing one key as rtc and as flash, and a third rtc entry, so a hard reset drops three
|
||||
auto rtcp2 = global_preferences->make_preference<uint32_t>(0x5B210070u ^ run, false);
|
||||
auto flp2 = global_preferences->make_preference<uint32_t>(0x5B210070u ^ run, true);
|
||||
auto rtcp3 = global_preferences->make_preference<uint32_t>(0x6B210070u ^ run, false);
|
||||
uint32_t rv2 = 0, fv2 = 0, rv3 = 0;
|
||||
bool rok = rtcp.load(&rv);
|
||||
bool fok = flp.load(&fv);
|
||||
bool rok2 = rtcp2.load(&rv2);
|
||||
bool fok2 = flp2.load(&fv2);
|
||||
bool rok3 = rtcp3.load(&rv3);
|
||||
snprintf(ltprefs_report, sizeof(ltprefs_report),
|
||||
"step=%d reason=%s(%d) rtc=%s%u flash=%s%u sharedkey rtc=%s%u flash=%s%u rtc3=%s%u", step,
|
||||
lt_get_reboot_reason_name(reason), (int) reason, rok ? "" : "none ", rv, fok ? "" : "none ", fv,
|
||||
rok2 ? "" : "none ", rv2, fok2 ? "" : "none ", fv2, rok3 ? "" : "none ", rv3);
|
||||
ESP_LOGI("lttest", "%s", ltprefs_report);
|
||||
auto histp = global_preferences->make_preference<LtprefsHistory>(0x4B210070u ^ run, true);
|
||||
if (!histp.load(<prefs_hist))
|
||||
memset(<prefs_hist, 0, sizeof(ltprefs_hist));
|
||||
memmove(<prefs_hist.line[1], <prefs_hist.line[0], sizeof(ltprefs_hist.line) - sizeof(ltprefs_hist.line[0]));
|
||||
strncpy(ltprefs_hist.line[0], ltprefs_report, sizeof(ltprefs_hist.line[0]) - 1);
|
||||
ltprefs_hist.line[0][sizeof(ltprefs_hist.line[0]) - 1] = 0;
|
||||
if (!histp.save(<prefs_hist)) {
|
||||
ESP_LOGW("lttest", "history save failed");
|
||||
}
|
||||
int act = 0;
|
||||
bool save = true;
|
||||
switch (step) {
|
||||
case 0:
|
||||
act = 1;
|
||||
break; // soft reboot
|
||||
case 1:
|
||||
act = 2;
|
||||
break; // crash
|
||||
case 2:
|
||||
act = 3;
|
||||
break; // watchdog
|
||||
case 3:
|
||||
act = 0;
|
||||
break; // idle: chip-enable reset from the host
|
||||
case 4:
|
||||
act = 1;
|
||||
break; // soft reboot after the hard reset
|
||||
case 5:
|
||||
act = 0;
|
||||
break; // idle: chip-enable reset from the host
|
||||
case 6:
|
||||
act = 1;
|
||||
save = false;
|
||||
break; // no save, soft reboot: deleted, not skipped?
|
||||
case 7:
|
||||
act = 0;
|
||||
break; // idle
|
||||
case 8:
|
||||
act = 2;
|
||||
break; // crash
|
||||
case 9:
|
||||
act = 0;
|
||||
break; // idle
|
||||
case 10:
|
||||
act = 0;
|
||||
break; // idle: power cut by the user
|
||||
default:
|
||||
act = 0;
|
||||
save = false;
|
||||
break;
|
||||
}
|
||||
if (save) {
|
||||
uint32_t v = 100 + step, v2r = 200 + step, v2f = 300 + step, v3 = 400 + step;
|
||||
bool srtc = rtcp.save(&v);
|
||||
bool sfl = flp.save(&v);
|
||||
bool srtc2 = rtcp2.save(&v2r);
|
||||
bool sfl2 = flp2.save(&v2f);
|
||||
bool srtc3 = rtcp3.save(&v3);
|
||||
bool synced = global_preferences->sync();
|
||||
ESP_LOGI("lttest", "save %u/%u/%u/%u: rtc=%d flash=%d sharedkey rtc=%d flash=%d rtc3=%d sync=%d", v, v2r, v2f, v3,
|
||||
srtc, sfl, srtc2, sfl2, srtc3, synced);
|
||||
}
|
||||
ESP_LOGI("lttest", "exit action=%d", act);
|
||||
return act;
|
||||
}
|
||||
|
||||
inline void ltprefs_exit(int &step, uint32_t run, int act) {
|
||||
step++;
|
||||
bool saved = ltprefs_step_pref(run).save(&step);
|
||||
bool synced = global_preferences->sync();
|
||||
ESP_LOGI("lttest", "step %d save=%d sync=%d", step, saved, synced);
|
||||
if (!saved || !synced) {
|
||||
ESP_LOGE("lttest", "step not stored, staying idle instead of repeating the step");
|
||||
return;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
switch (act) {
|
||||
case 1:
|
||||
ESP_LOGW("lttest", "soft reboot now");
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
lt_reboot();
|
||||
break;
|
||||
case 2:
|
||||
ESP_LOGW("lttest", "crashing now");
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
__builtin_trap(); // undefined instruction: a plain bad store does not fault on the ARM968
|
||||
break;
|
||||
case 3:
|
||||
ESP_LOGW("lttest", "watchdog reboot now");
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
lt_reboot_wdt();
|
||||
break;
|
||||
default:
|
||||
ESP_LOGW("lttest", "idle, waiting for an external reset");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
inline void ltprefs_alive(int step, int act) {
|
||||
ESP_LOGI("lttest", "alive step=%d act=%d boot: %s", step, act, ltprefs_report);
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
if (ltprefs_hist.line[i][0] != 0) {
|
||||
ESP_LOGI("lttest", " history[-%d]: %s", i, ltprefs_hist.line[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"""Drive the online_image cases over the native API and judge each from the serial capture."""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIConnectionError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
RequiresEncryptionAPIError,
|
||||
)
|
||||
|
||||
HOST, KEY, CAPTURE, BASE, MODE = sys.argv[1:6]
|
||||
CAP_S = 45
|
||||
|
||||
CASES = [
|
||||
# (label, action, path, expected outcome)
|
||||
("ok bmp", "fetch", "ok.bmp", "finished"),
|
||||
("ok png", "fetch", "ok.png", "finished"),
|
||||
("ok jpg", "fetch", "ok.jpg", "finished"),
|
||||
("ok qoi", "fetch", "ok.qoi", "finished"),
|
||||
("truncated bmp", "fetch", "truncated.bmp", "error"),
|
||||
("ok bmp after error", "fetch", "ok.bmp", "finished"),
|
||||
("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"),
|
||||
("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"),
|
||||
("empty body", "fetch", "empty.bmp", "error"),
|
||||
("stalled connection", "fetch", "stall.bmp", "error"),
|
||||
("closed early", "fetch", "closed_early.bmp", "error"),
|
||||
("slow but alive", "fetch", "slow.bmp", "finished"),
|
||||
("chunked bmp", "fetch", "chunked.bmp", "finished"),
|
||||
("chunked png", "fetch", "chunked.png", "finished"),
|
||||
("chunked qoi", "fetch", "chunked.qoi", "finished"),
|
||||
("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"),
|
||||
("chunked png cut", "fetch", "chunked_cut.png", "any"),
|
||||
("no content-length", "fetch", "noclen.bmp", "any"),
|
||||
("huge jpeg claim", "fetch", "huge.jpg", "error"),
|
||||
("ok jpg after huge", "fetch", "ok.jpg", "finished"),
|
||||
("ok bmp again", "fetch", "ok.bmp", "finished"),
|
||||
("ok bmp refetch: 304", "refetch", "", "finished"),
|
||||
("ok png", "fetch", "ok.png", "finished"),
|
||||
("ok png refetch: 304", "refetch", "", "finished"),
|
||||
("chunked bmp", "fetch", "chunked.bmp", "finished"),
|
||||
("chunked bmp refetch: 304", "refetch", "", "finished"),
|
||||
("chunked png", "fetch", "chunked.png", "finished"),
|
||||
("chunked png refetch: not cached", "refetch", "", "finished"),
|
||||
("chunked png cut", "fetch", "chunked_cut.png", "any"),
|
||||
("chunked png cut refetch: not cached", "refetch", "", "any"),
|
||||
]
|
||||
if MODE == "base":
|
||||
# Each stuck case is followed by a fetch that shows the symptom, then a reboot.
|
||||
CASES = [
|
||||
("ok bmp", "fetch", "ok.bmp", "finished"),
|
||||
("truncated bmp", "fetch", "truncated.bmp", "error"),
|
||||
("ok bmp after truncated", "fetch", "ok.bmp", "finished"),
|
||||
("REBOOT", "", "", ""),
|
||||
("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"),
|
||||
("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"),
|
||||
("REBOOT", "", "", ""),
|
||||
("empty body", "fetch", "empty.bmp", "error"),
|
||||
("ok bmp after empty", "fetch", "ok.bmp", "finished"),
|
||||
("REBOOT", "", "", ""),
|
||||
("stalled connection", "fetch", "stall.bmp", "error"),
|
||||
("ok bmp after stall", "fetch", "ok.bmp", "finished"),
|
||||
("REBOOT", "", "", ""),
|
||||
("closed early", "fetch", "closed_early.bmp", "error"),
|
||||
("ok bmp after closed early", "fetch", "ok.bmp", "finished"),
|
||||
("REBOOT", "", "", ""),
|
||||
("chunked png", "fetch", "chunked.png", "finished"),
|
||||
("ok bmp after chunked png", "fetch", "ok.bmp", "finished"),
|
||||
("REBOOT", "", "", ""),
|
||||
("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"),
|
||||
("ok bmp after chunked cut", "fetch", "ok.bmp", "finished"),
|
||||
("REBOOT", "", "", ""),
|
||||
("huge jpeg claim", "fetch", "huge.jpg", "error"),
|
||||
("ok jpg after huge", "fetch", "ok.jpg", "finished"),
|
||||
]
|
||||
|
||||
OUTCOME = re.compile(r"download (finished|error): (img_small|img)")
|
||||
INTEREST = re.compile(
|
||||
r"\[(E|W)\]\[online_image|Image already being updated|download (finished|error)|Downloading image|Updating image|Image fully downloaded|HTTP 304|allocat|Panic|abort\(\)|Guru|Backtrace|rst:0x",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def tail(path: str, offset: int) -> tuple[list[str], int]:
|
||||
with pathlib.Path(path).open("rb") as f:
|
||||
f.seek(offset)
|
||||
data = f.read()
|
||||
return data.decode("utf-8", "replace").splitlines(), offset + len(data)
|
||||
|
||||
|
||||
async def connect() -> tuple[APIClient, dict, int]:
|
||||
for _ in range(60):
|
||||
try:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||
await c.connect(login=True)
|
||||
ents, services = await c.list_entities_services()
|
||||
except (InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError) as exc:
|
||||
raise SystemExit(f"API key problem, not retrying: {exc}") from exc
|
||||
except (APIConnectionError, OSError) as exc:
|
||||
# only connection trouble is retried; a wrong key or a missing button must fail at once
|
||||
print(f"connect: {exc}", flush=True)
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
btn = next(
|
||||
(e.key for e in ents if getattr(e, "object_id", "") == "reboot"), None
|
||||
)
|
||||
if btn is None:
|
||||
raise SystemExit("the config has no reboot button")
|
||||
return c, {s.name: s for s in services}, btn
|
||||
raise SystemExit("API never connected")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
client, actions, btn = await connect()
|
||||
with pathlib.Path(CAPTURE).open("rb") as f:
|
||||
f.seek(0, 2)
|
||||
offset = f.tell()
|
||||
rows = []
|
||||
for label, action, path, expect in CASES:
|
||||
if label == "REBOOT":
|
||||
client.button_command(btn)
|
||||
await asyncio.sleep(1)
|
||||
await client.disconnect()
|
||||
await asyncio.sleep(8)
|
||||
client, actions, btn = await connect()
|
||||
_, offset = tail(CAPTURE, offset)
|
||||
print("--- rebooted", flush=True)
|
||||
continue
|
||||
img = "img_small" if action.endswith("_small") else "img"
|
||||
t0 = time.monotonic()
|
||||
args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"}
|
||||
await client.execute_service(actions[action], args)
|
||||
outcome, notes = "no outcome", []
|
||||
while time.monotonic() - t0 < CAP_S:
|
||||
await asyncio.sleep(0.5)
|
||||
lines, offset = tail(CAPTURE, offset)
|
||||
done = False
|
||||
for line in lines:
|
||||
if INTEREST.search(line):
|
||||
notes.append(line.strip())
|
||||
m = OUTCOME.search(line)
|
||||
if m and m.group(2) == img:
|
||||
outcome = m.group(1)
|
||||
done = True
|
||||
if done:
|
||||
break
|
||||
took = time.monotonic() - t0
|
||||
if outcome == "no outcome":
|
||||
took = CAP_S
|
||||
# drain a little more so the "already being updated" line lands
|
||||
await asyncio.sleep(1)
|
||||
lines, offset = tail(CAPTURE, offset)
|
||||
notes += [line.strip() for line in lines if INTEREST.search(line)]
|
||||
ok = outcome == expect or (expect == "any" and outcome != "no outcome")
|
||||
rows.append((label, path, expect, outcome, took, ok, notes))
|
||||
print(
|
||||
f"{'PASS' if ok else 'FAIL'} {label:32s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
for n in notes:
|
||||
print(f" {n}", flush=True)
|
||||
await asyncio.sleep(1.5)
|
||||
await client.disconnect()
|
||||
print(
|
||||
"\nSUMMARY",
|
||||
"all expectations met"
|
||||
if all(r[5] for r in rows)
|
||||
else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[5]),
|
||||
flush=True,
|
||||
)
|
||||
if not all(r[5] for r in rows):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Build the small test images the HTTP server serves."""
|
||||
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
out = Path(sys.argv[1])
|
||||
out.mkdir(exist_ok=True)
|
||||
w = h = 16
|
||||
im = Image.new("RGB", (w, h))
|
||||
px = im.load()
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
px[x, y] = (x * 16, y * 16, (x + y) * 8)
|
||||
im.save(out / "ok.bmp", "BMP")
|
||||
im.save(out / "ok.png", "PNG")
|
||||
im.save(out / "ok.jpg", "JPEG", quality=90)
|
||||
|
||||
# QOI with only QOI_OP_RGB codes: valid and trivially encoded.
|
||||
qoi = b"qoif" + struct.pack(">IIBB", w, h, 3, 0)
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
qoi += bytes([0xFE, *px[x, y]])
|
||||
qoi += bytes(7) + b"\x01"
|
||||
(out / "ok.qoi").write_bytes(qoi)
|
||||
|
||||
# 8x8 8-bit palette BMP: 1078 byte header, pixels start beyond a 256 byte buffer.
|
||||
pw = ph = 8
|
||||
data_offset = 14 + 40 + 256 * 4
|
||||
file_size = data_offset + pw * ph
|
||||
hdr = b"BM" + struct.pack("<IHHI", file_size, 0, 0, data_offset)
|
||||
dib = struct.pack("<IiiHHIIiiII", 40, pw, ph, 1, 8, 0, pw * ph, 0, 0, 256, 0)
|
||||
(out / "large_header.bmp").write_bytes(hdr + dib + bytes(256 * 4) + bytes(pw * ph))
|
||||
|
||||
for p in sorted(out.iterdir()):
|
||||
print(p.name, p.stat().st_size)
|
||||
@@ -1,142 +0,0 @@
|
||||
"""HTTP server with one misbehaviour per path, for the online_image stalled-download test."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
WWW = Path(sys.argv[1])
|
||||
PORT = int(sys.argv[2])
|
||||
LOG = Path(sys.argv[3]).open("a", encoding="utf-8", buffering=1) # noqa: SIM115
|
||||
|
||||
MIME = {"bmp": "image/bmp", "png": "image/png", "jpg": "image/jpeg", "qoi": "image/qoi"}
|
||||
IMG = {n: (WWW / f"ok.{n}").read_bytes() for n in MIME}
|
||||
BMP = IMG["bmp"]
|
||||
BMP_HDR = 54 # 16x16 24-bit: pixel rows follow directly
|
||||
LARGE_HEADER = (WWW / "large_header.bmp").read_bytes()
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
LOG.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
|
||||
|
||||
|
||||
def headers(ctype: str, extra: list[bytes]) -> bytes:
|
||||
lines = [
|
||||
b"HTTP/1.1 200 OK",
|
||||
b"Content-Type: " + ctype.encode(),
|
||||
b"Connection: close",
|
||||
*extra,
|
||||
b"",
|
||||
b"",
|
||||
]
|
||||
return b"\r\n".join(lines)
|
||||
|
||||
|
||||
def etag(path: str) -> bytes:
|
||||
return b'"' + path.strip("/").encode() + b'-v1"'
|
||||
|
||||
|
||||
def chunked(body: bytes, size: int) -> bytes:
|
||||
out = b""
|
||||
for i in range(0, len(body), size):
|
||||
part = body[i : i + size]
|
||||
out += b"%x\r\n" % len(part) + part + b"\r\n"
|
||||
return out + b"0\r\n\r\n"
|
||||
|
||||
|
||||
async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str:
|
||||
name, _, ext = path.lstrip("/").rpartition(".")
|
||||
ctype = MIME.get(ext, "application/octet-stream")
|
||||
body = IMG.get(ext, BMP)
|
||||
tag = [b"ETag: " + etag(path)]
|
||||
if b"if-none-match: " + etag(path).lower() in req.lower():
|
||||
w.write(
|
||||
b"HTTP/1.1 304 Not Modified\r\nETag: "
|
||||
+ etag(path)
|
||||
+ b"\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
return "304 not modified"
|
||||
if name == "ok":
|
||||
w.write(headers(ctype, [b"Content-Length: %d" % len(body), *tag]) + body)
|
||||
return f"full {len(body)} bytes"
|
||||
if name == "truncated": # cut one byte into the sixth pixel, Content-Length matches
|
||||
cut = BMP[: BMP_HDR + 5 * 3 + 1]
|
||||
w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut)
|
||||
return f"truncated {len(cut)} bytes"
|
||||
if name == "large_header":
|
||||
w.write(
|
||||
headers(ctype, [b"Content-Length: %d" % len(LARGE_HEADER)]) + LARGE_HEADER
|
||||
)
|
||||
return f"large header {len(LARGE_HEADER)} bytes"
|
||||
if name == "empty":
|
||||
w.write(headers(ctype, [b"Content-Length: 0"]))
|
||||
return "empty body"
|
||||
if (
|
||||
name == "stall"
|
||||
): # claims the full length, sends part, then holds the socket open
|
||||
part = body[: BMP_HDR + 15]
|
||||
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part)
|
||||
await w.drain()
|
||||
await asyncio.sleep(40)
|
||||
return f"stalled after {len(part)} bytes, closed after 40 s"
|
||||
if name == "closed_early": # claims the full length, sends part, closes at once
|
||||
part = body[: BMP_HDR + 15]
|
||||
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part)
|
||||
return f"closed after {len(part)} of {len(body)} bytes"
|
||||
if name == "slow": # pauses under the timeout, then completes
|
||||
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + body[:BMP_HDR])
|
||||
await w.drain()
|
||||
await asyncio.sleep(2.5)
|
||||
w.write(body[BMP_HDR:])
|
||||
return f"full {len(body)} bytes with a 2.5 s pause"
|
||||
if name == "chunked":
|
||||
w.write(
|
||||
headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(body, 64)
|
||||
)
|
||||
return f"chunked full {len(body)} bytes"
|
||||
if (
|
||||
name == "chunked_cut"
|
||||
): # chunked, ends cleanly at a pixel boundary (or half a png)
|
||||
cut = body[: BMP_HDR + 5 * 3] if ext == "bmp" else body[: len(body) // 2]
|
||||
w.write(
|
||||
headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(cut, 64)
|
||||
)
|
||||
return f"chunked cut {len(cut)} of {len(body)} bytes"
|
||||
if name == "noclen": # body delimited by connection close
|
||||
w.write(headers(ctype, []) + body)
|
||||
return f"no content-length, full {len(body)} bytes"
|
||||
if name == "huge": # claims 20 MB so the JPEG buffer resize fails
|
||||
w.write(headers(ctype, [b"Content-Length: 20000000"]) + body)
|
||||
return f"claims 20000000, sent {len(body)} bytes"
|
||||
w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||
return "404"
|
||||
|
||||
|
||||
async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
|
||||
peer = w.get_extra_info("peername")
|
||||
try:
|
||||
async with asyncio.timeout(5):
|
||||
line = await r.readuntil(b"\r\n")
|
||||
req = await r.readuntil(b"\r\n\r\n")
|
||||
path = line.split(b" ")[1].decode()
|
||||
log(
|
||||
f"GET {path} from {peer}"
|
||||
+ (" (If-None-Match)" if b"if-none-match" in req.lower() else "")
|
||||
)
|
||||
what = await respond(path, req, w)
|
||||
await w.drain()
|
||||
log(f" -> {path}: {what}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f" !! {peer}: {exc!r}")
|
||||
finally:
|
||||
w.close()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
server = await asyncio.start_server(handle, "0.0.0.0", PORT)
|
||||
log(f"listening on {PORT}")
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,123 +0,0 @@
|
||||
"""List the boards on USB, or wait for one to enumerate.
|
||||
|
||||
ports.py table: port, vid:pid, product, serial number, guess
|
||||
ports.py --wait 2e8a print the first port with VID 0x2e8a once it appears (default 60 s)
|
||||
ports.py --wait 1a86:7523 --timeout 30
|
||||
ports.py --product "USB Serial" first port whose USB product string matches (case-insensitive)
|
||||
ports.py --wait 2e8a --serial SN the port of one specific board, whatever name it comes back under
|
||||
ports.py --serial-of PORT print the USB serial number behind a port name
|
||||
|
||||
Run with a Python that has pyserial (any ESPHome venv). Port names follow the hub port on macOS, so
|
||||
re-run this after every replug instead of trusting a name from an earlier session.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from serial.tools import list_ports
|
||||
|
||||
GUESS = [
|
||||
(
|
||||
(0x1A86, 0x7523),
|
||||
"CH340: D1 mini, BW15 kit, BK7238/CB3S or LN882H adapter; identify by the boot banner",
|
||||
),
|
||||
((0x1A86, 0x55D3), "CH343: ESP32-P4 devkit console"),
|
||||
((0x1A86, 0x55D4), "CH343: ESP32 devkit"),
|
||||
((0x1A86, None), "WCH CH34x USB serial bridge"),
|
||||
((0x10C4, 0xEA60), "CP2102: classic ESP32 devkit (esp32dev)"),
|
||||
(
|
||||
(0x303A, 0x1001),
|
||||
"Espressif USB JTAG serial: S3/C3/C6/H2 native USB (logger hardware_uart USB_SERIAL_JTAG)",
|
||||
),
|
||||
(
|
||||
(0x2E8A, None),
|
||||
"Raspberry Pi Pico W / Pico 2 W CDC; the port vanishes and comes back on every reboot",
|
||||
),
|
||||
((0x1915, 0x521F), "nRF52840 Dongle (PCA10059) in the Open DFU bootloader"),
|
||||
(
|
||||
(0x2FE3, 0x0100),
|
||||
"nRF52840 Dongle running a Zephyr app; two CDC ports, ESPHome log on the second",
|
||||
),
|
||||
((0x1915, 0xCAFE), "Makerdiary nRF52840 MDK dongle running a Zephyr app"),
|
||||
((0x239A, None), "Adafruit style nRF52 board (UF2 bootloader family)"),
|
||||
]
|
||||
|
||||
|
||||
def guess(vid: int, pid: int) -> str:
|
||||
for (gv, gp), text in GUESS:
|
||||
if vid == gv and (gp is None or pid == gp):
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def parse_id(text: str) -> tuple[int, int | None]:
|
||||
vid, _, pid = text.partition(":")
|
||||
return int(vid, 16), (int(pid, 16) if pid else None)
|
||||
|
||||
|
||||
def matches(
|
||||
port, vid: int | None, pid: int | None, product: str | None, serial: str | None
|
||||
) -> bool:
|
||||
if vid is not None and (port.vid != vid or (pid is not None and port.pid != pid)):
|
||||
return False
|
||||
if product and (port.product or "").lower() != product.lower():
|
||||
return False
|
||||
return not serial or (port.serial_number or "") == serial
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument(
|
||||
"--wait",
|
||||
metavar="VID[:PID]",
|
||||
help="block until a matching port appears, print it",
|
||||
)
|
||||
ap.add_argument("--product", help="USB product string to match")
|
||||
ap.add_argument("--serial", help="USB serial number to match")
|
||||
ap.add_argument(
|
||||
"--serial-of", metavar="PORT", help="print the USB serial number of PORT"
|
||||
)
|
||||
ap.add_argument("--timeout", type=float, default=60)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.serial_of:
|
||||
for p in list_ports.comports():
|
||||
if p.device == args.serial_of and p.serial_number:
|
||||
print(p.serial_number)
|
||||
return 0
|
||||
print(f"no USB serial number for {args.serial_of}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
vid = pid = None
|
||||
if args.wait:
|
||||
vid, pid = parse_id(args.wait)
|
||||
|
||||
if args.wait or args.product or args.serial:
|
||||
deadline = time.time() + args.timeout
|
||||
while True:
|
||||
for p in sorted(list_ports.comports(), key=lambda p: p.device):
|
||||
if p.vid and matches(p, vid, pid, args.product, args.serial):
|
||||
print(p.device)
|
||||
return 0
|
||||
if time.time() >= deadline:
|
||||
print("no matching port", file=sys.stderr)
|
||||
return 1
|
||||
time.sleep(1)
|
||||
|
||||
rows = [p for p in list_ports.comports() if p.vid]
|
||||
if not rows:
|
||||
print("no USB serial ports")
|
||||
return 1
|
||||
for p in sorted(rows, key=lambda p: p.device):
|
||||
print(
|
||||
f"{p.device:32} {p.vid:04x}:{p.pid:04x} {p.product or '?':34} "
|
||||
f"{p.serial_number or '':18} {guess(p.vid, p.pid)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Request one camera image over the native API and report its size (direct esp32_camera check)."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import APIClient
|
||||
|
||||
|
||||
async def main():
|
||||
c = APIClient(sys.argv[1], 6053, None)
|
||||
await c.connect(login=True)
|
||||
ents, _ = await c.list_entities_services()
|
||||
cams = [e for e in ents if type(e).__name__ == "CameraInfo"]
|
||||
print("camera entities:", [e.name for e in cams])
|
||||
got = asyncio.get_event_loop().create_future()
|
||||
|
||||
def on_state(state):
|
||||
if type(state).__name__ == "CameraState" and not got.done():
|
||||
got.set_result(len(state.data))
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.request_single_image()
|
||||
try:
|
||||
n = await asyncio.wait_for(got, 10)
|
||||
print("single image bytes:", n)
|
||||
except TimeoutError:
|
||||
print("no image within 10 s")
|
||||
await c.disconnect()
|
||||
sys.exit(1)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/zsh
|
||||
# usage: measure.sh IP LABEL LOGFILE [OUTDIR]
|
||||
# Runs the fixed RTSP measurement set against one build and appends a result line to OUTDIR/results.txt:
|
||||
# ping RTT, two 12 s ffmpeg runs, two concurrent ffmpeg clients, and the raw receive rate with the default
|
||||
# and a 1 MiB socket buffer. LOGFILE is the device capture, used to count "stalled" drops during the run.
|
||||
# A measurement that produced nothing is written as ERR and the script exits 1, so a broken run never looks
|
||||
# like a measured one. Needs ffmpeg and a python with the rtsp scripts' dependencies (PYTHON, default python3).
|
||||
D=${0:A:h}
|
||||
IP=$1; LABEL=$2; LOG=$3; S=${4:-.}
|
||||
PY=${PYTHON:-python3}
|
||||
[ -f "$LOG" ] || { echo "device capture not found: $LOG"; exit 1; }
|
||||
rtt=$(ping -c 10 -i 0.2 $IP 2>&1 | tail -1 | awk -F/ '{print $5}')
|
||||
stall0=$(grep -a -c "stalled" $LOG)
|
||||
frames() { # OUTFILE RC: the frame count of a run that finished on its own, nothing for a failed or killed one
|
||||
[ "$2" = 0 ] && grep -o 'frame= *[0-9]*' $1 | tail -1 | tr -dc 0-9
|
||||
}
|
||||
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f1.txt 2>&1; f1=$(frames $S/m-f1.txt $?); sleep 2
|
||||
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f2.txt 2>&1; f2=$(frames $S/m-f2.txt $?); sleep 2
|
||||
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoA.txt 2>&1 & A=$!; sleep 1
|
||||
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoB.txt 2>&1 & B=$!
|
||||
wait $A; ra=$?; wait $B; rb=$?
|
||||
fa=$(frames $S/m-twoA.txt $ra); fb=$(frames $S/m-twoB.txt $rb); sleep 2
|
||||
timeout 40 $PY $D/rawrate2.py $IP quiet > $S/m-raw.txt 2>&1 && raw=$(grep -o "[0-9.]* KiB/s" $S/m-raw.txt | head -1); sleep 2
|
||||
timeout 40 $PY $D/rawrate2.py $IP bigbuf > $S/m-raw2.txt 2>&1 && raw2=$(grep -o "[0-9.]* KiB/s" $S/m-raw2.txt | head -1); sleep 1
|
||||
stall1=$(grep -a -c "stalled" $LOG)
|
||||
bad=0
|
||||
for v in rtt f1 f2 fa fb raw raw2; do
|
||||
if [ -z "${(P)v}" ]; then eval "$v=ERR"; bad=1; fi
|
||||
done
|
||||
line="$LABEL | rtt avg ${rtt} ms | ffmpeg single 12 s: ${f1}, ${f2} frames | two clients: ${fa} + ${fb} | python raw: ${raw} (1 MiB rcvbuf ${raw2}) | stalls logged: $((stall1 - stall0))"
|
||||
echo "$line" | tee -a $S/results.txt
|
||||
if [ $bad = 1 ]; then echo "one or more measurements failed (ERR above)"; exit 1; fi
|
||||
@@ -1,83 +0,0 @@
|
||||
import contextlib
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
IP = sys.argv[1]
|
||||
mode = sys.argv[2]
|
||||
secs = 8
|
||||
s = socket.create_connection((IP, 554), timeout=5)
|
||||
if mode == "bigbuf":
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
|
||||
cseq = 0
|
||||
|
||||
|
||||
buf = b""
|
||||
|
||||
|
||||
def read_response() -> bytes:
|
||||
"""One complete RTSP response: headers through the blank line plus a Content-Length body."""
|
||||
global buf # noqa: PLW0603
|
||||
while (end := buf.find(b"\r\n\r\n")) < 0:
|
||||
chunk = s.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("closed")
|
||||
buf += chunk
|
||||
head = buf[:end].decode(errors="replace")
|
||||
body_len = 0
|
||||
for line in head.split("\r\n"):
|
||||
if line.lower().startswith("content-length:"):
|
||||
body_len = int(line.split(":", 1)[1])
|
||||
while len(buf) < end + 4 + body_len:
|
||||
chunk = s.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("closed")
|
||||
buf += chunk
|
||||
resp, buf = buf[: end + 4 + body_len], buf[end + 4 + body_len :]
|
||||
return resp
|
||||
|
||||
|
||||
def req(m, extra="", wait=True):
|
||||
global cseq # noqa: PLW0603
|
||||
cseq += 1
|
||||
url = f"rtsp://{IP}:554/trackID=0" if m == "SETUP" else f"rtsp://{IP}:554/"
|
||||
s.sendall(f"{m} {url} RTSP/1.0\r\nCSeq: {cseq}\r\n{extra}\r\n".encode())
|
||||
return read_response() if wait else b""
|
||||
|
||||
|
||||
req("OPTIONS")
|
||||
req("DESCRIBE", "Accept: application/sdp\r\n")
|
||||
r = req("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")
|
||||
sess = (
|
||||
[x for x in r.decode(errors="replace").split("\r\n") if x.startswith("Session")][0]
|
||||
.split()[1]
|
||||
.split(";")[0]
|
||||
)
|
||||
req("PLAY", f"Session: {sess}\r\n")
|
||||
t0 = time.monotonic()
|
||||
total = 0
|
||||
chunks = 0
|
||||
last_chat = t0
|
||||
s.settimeout(2)
|
||||
while time.monotonic() - t0 < secs:
|
||||
now = time.monotonic()
|
||||
if mode == "chatty" and now - last_chat > 0.2:
|
||||
req("GET_PARAMETER", f"Session: {sess}\r\n", wait=False)
|
||||
last_chat = now
|
||||
if mode == "rtcp" and now - last_chat > 0.2:
|
||||
s.sendall(b"$\x01\x00\x08" + b"\x80\xc9\x00\x01\x00\x00\x00\x01")
|
||||
last_chat = now
|
||||
try:
|
||||
d = s.recv(65536)
|
||||
except TimeoutError:
|
||||
continue
|
||||
if not d:
|
||||
break
|
||||
total += len(d)
|
||||
chunks += 1
|
||||
print(
|
||||
f"{mode:7s}: {total / 1024 / secs:5.1f} KiB/s, {chunks} recvs, avg {total // max(chunks, 1)} B/recv"
|
||||
)
|
||||
with contextlib.suppress(OSError):
|
||||
req("TEARDOWN", f"Session: {sess}\r\n")
|
||||
s.close()
|
||||
@@ -1,345 +0,0 @@
|
||||
"""Raw RTSP/TCP client for the esphome rtsp_server hardware test.
|
||||
|
||||
usage: rtsp_client.py HOST [case ...]
|
||||
cases: play, udp, pipeline, third, stall, teardown, getparam, badreq, replay, timeout
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = sys.argv[1]
|
||||
PORT = 554
|
||||
cases = sys.argv[2:] or [
|
||||
"play",
|
||||
"udp",
|
||||
"pipeline",
|
||||
"badreq",
|
||||
"replay",
|
||||
"getparam",
|
||||
"teardown",
|
||||
"third",
|
||||
"stall",
|
||||
]
|
||||
results = []
|
||||
|
||||
|
||||
def say(case, ok, detail):
|
||||
results.append((case, ok, detail))
|
||||
print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True)
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, timeout=5):
|
||||
self.s = socket.create_connection((HOST, PORT), timeout=timeout)
|
||||
self.cseq = 0
|
||||
self.session = None
|
||||
self.buf = b""
|
||||
|
||||
def send(self, method, extra="", url=None):
|
||||
self.cseq += 1
|
||||
url = (
|
||||
url or f"rtsp://{HOST}:{PORT}/trackID=0"
|
||||
if method == "SETUP"
|
||||
else (url or f"rtsp://{HOST}:{PORT}/")
|
||||
)
|
||||
req = f"{method} {url} RTSP/1.0\r\nCSeq: {self.cseq}\r\n"
|
||||
if self.session:
|
||||
req += f"Session: {self.session}\r\n"
|
||||
req += extra + "\r\n"
|
||||
self.s.sendall(req.encode())
|
||||
return self.cseq
|
||||
|
||||
def _recv(self):
|
||||
chunk = self.s.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("closed")
|
||||
self.buf += chunk
|
||||
|
||||
def read_response(self):
|
||||
# Read until a full RTSP response (headers + optional body); skip any interleaved data in between
|
||||
while True:
|
||||
if self.buf.startswith(b"$"):
|
||||
if len(self.buf) < 4:
|
||||
self._recv()
|
||||
continue
|
||||
ln = struct.unpack(">H", self.buf[2:4])[0]
|
||||
if len(self.buf) < 4 + ln:
|
||||
self._recv()
|
||||
continue
|
||||
self.buf = self.buf[4 + ln :]
|
||||
continue
|
||||
end = self.buf.find(b"\r\n\r\n")
|
||||
if end < 0:
|
||||
self._recv()
|
||||
continue
|
||||
head = self.buf[:end].decode(errors="replace")
|
||||
body_len = 0
|
||||
for line in head.split("\r\n"):
|
||||
if line.lower().startswith("content-length:"):
|
||||
body_len = int(line.split(":", 1)[1])
|
||||
while len(self.buf) < end + 4 + body_len:
|
||||
self._recv()
|
||||
body = self.buf[end + 4 : end + 4 + body_len]
|
||||
self.buf = self.buf[end + 4 + body_len :]
|
||||
status = int(head.split(" ", 2)[1])
|
||||
for line in head.split("\r\n"):
|
||||
if line.lower().startswith("session:"):
|
||||
self.session = line.split(":", 1)[1].strip().split(";")[0]
|
||||
return status, head, body
|
||||
|
||||
def read_frames(self, seconds):
|
||||
"""Collect interleaved RTP packets on channel 0 for `seconds`; return (frames, packets, seq_gaps, sizes)."""
|
||||
deadline = time.monotonic() + seconds
|
||||
frames = 0
|
||||
packets = 0
|
||||
gaps = 0
|
||||
last_seq = None
|
||||
cur = 0
|
||||
sizes = []
|
||||
self.s.settimeout(1.0)
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
chunk = self.s.recv(65536)
|
||||
except TimeoutError:
|
||||
continue
|
||||
if not chunk:
|
||||
break
|
||||
self.buf += chunk
|
||||
while True:
|
||||
if len(self.buf) < 4 or self.buf[0:1] != b"$":
|
||||
if self.buf and self.buf[0:1] != b"$":
|
||||
end = self.buf.find(b"\r\n\r\n")
|
||||
if end < 0:
|
||||
break
|
||||
self.buf = self.buf[end + 4 :]
|
||||
continue
|
||||
break
|
||||
ch = self.buf[1]
|
||||
ln = struct.unpack(">H", self.buf[2:4])[0]
|
||||
if len(self.buf) < 4 + ln:
|
||||
break
|
||||
pkt = self.buf[4 : 4 + ln]
|
||||
self.buf = self.buf[4 + ln :]
|
||||
if ch != 0 or len(pkt) < 12:
|
||||
continue
|
||||
packets += 1
|
||||
seq = struct.unpack(">H", pkt[2:4])[0]
|
||||
marker = pkt[1] & 0x80
|
||||
if last_seq is not None and seq != ((last_seq + 1) & 0xFFFF):
|
||||
gaps += 1
|
||||
last_seq = seq
|
||||
cur += len(pkt) - 12
|
||||
if marker:
|
||||
frames += 1
|
||||
sizes.append(cur)
|
||||
cur = 0
|
||||
return frames, packets, gaps, sizes
|
||||
|
||||
def close(self):
|
||||
self.s.close()
|
||||
|
||||
|
||||
def setup_play(c):
|
||||
c.send("OPTIONS")
|
||||
st, head, _ = c.read_response()
|
||||
assert st == 200, head
|
||||
c.send("DESCRIBE", "Accept: application/sdp\r\n")
|
||||
st, head, body = c.read_response()
|
||||
assert st == 200, head
|
||||
c.send("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")
|
||||
st, head, _ = c.read_response()
|
||||
assert st == 200, head
|
||||
c.send("PLAY")
|
||||
st, head, _ = c.read_response()
|
||||
assert st == 200, head
|
||||
return body
|
||||
|
||||
|
||||
for case in cases:
|
||||
try:
|
||||
if case == "play":
|
||||
c = Client()
|
||||
sdp = setup_play(c)
|
||||
frames, packets, gaps, sizes = c.read_frames(10)
|
||||
c.send("TEARDOWN")
|
||||
c.read_response()
|
||||
c.close()
|
||||
ok = frames >= 50 and gaps == 0
|
||||
say(
|
||||
case,
|
||||
ok,
|
||||
f"{frames} frames in 10 s ({frames / 10:.1f} fps), {packets} RTP packets, {gaps} seq gaps, frame size min {min(sizes) if sizes else 0} max {max(sizes) if sizes else 0}; sdp has m=video {'m=video' in sdp.decode()}",
|
||||
)
|
||||
elif case == "udp":
|
||||
c = Client()
|
||||
c.send("OPTIONS")
|
||||
c.read_response()
|
||||
c.send("SETUP", "Transport: RTP/AVP;unicast;client_port=5000-5001\r\n")
|
||||
st, head, _ = c.read_response()
|
||||
c.close()
|
||||
say(case, st == 461, f"UDP SETUP answered {st}")
|
||||
elif case == "pipeline":
|
||||
c = Client()
|
||||
c.s.sendall(
|
||||
f"OPTIONS rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 1\r\n\r\nDESCRIBE rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n".encode()
|
||||
)
|
||||
c.cseq = 2
|
||||
st1, h1, _ = c.read_response()
|
||||
st2, h2, b2 = c.read_response()
|
||||
c.close()
|
||||
cs = [x for x in (h1 + h2).split("\r\n") if x.startswith("CSeq")]
|
||||
say(
|
||||
case,
|
||||
st1 == 200 and st2 == 200 and cs == ["CSeq: 1", "CSeq: 2"],
|
||||
f"two pipelined requests answered {st1}, {st2} with {cs}",
|
||||
)
|
||||
elif case == "badreq":
|
||||
c = Client()
|
||||
c.s.sendall(b"BOGUS rtsp://x/ RTSP/1.0\r\nCSeq: 7\r\n\r\n")
|
||||
st, head, _ = c.read_response()
|
||||
c.close()
|
||||
say(
|
||||
case,
|
||||
st in (400, 405, 501) and "CSeq: 7" in head,
|
||||
f"unknown method answered {st}, CSeq echoed {'CSeq: 7' in head}",
|
||||
)
|
||||
elif case == "replay":
|
||||
c = Client()
|
||||
setup_play(c)
|
||||
f1, _, _, _ = c.read_frames(3)
|
||||
c.send("PLAY")
|
||||
st, head, _ = c.read_response()
|
||||
f2, _, gaps, _ = c.read_frames(3)
|
||||
c.send("TEARDOWN")
|
||||
c.read_response()
|
||||
c.close()
|
||||
say(
|
||||
case,
|
||||
st == 200 and f2 >= 10 and gaps == 0,
|
||||
f"second PLAY answered {st}, {f1} then {f2} frames, {gaps} seq gaps after repeat PLAY",
|
||||
)
|
||||
elif case == "getparam":
|
||||
c = Client()
|
||||
setup_play(c)
|
||||
c.read_frames(1)
|
||||
c.send("GET_PARAMETER")
|
||||
st, head, _ = c.read_response()
|
||||
f, _, _, _ = c.read_frames(2)
|
||||
c.send("TEARDOWN")
|
||||
c.read_response()
|
||||
c.close()
|
||||
say(
|
||||
case,
|
||||
st == 200 and f > 5,
|
||||
f"GET_PARAMETER while playing answered {st}, stream continued with {f} frames in 2 s",
|
||||
)
|
||||
elif case == "teardown":
|
||||
c = Client()
|
||||
setup_play(c)
|
||||
c.read_frames(2)
|
||||
c.send("TEARDOWN")
|
||||
st, head, _ = c.read_response()
|
||||
c.s.settimeout(3)
|
||||
time.sleep(1.5)
|
||||
try:
|
||||
rest = c.s.recv(65536)
|
||||
closed = rest == b""
|
||||
except TimeoutError:
|
||||
rest = b"x"
|
||||
closed = False
|
||||
except ConnectionError:
|
||||
closed = True
|
||||
c.close()
|
||||
say(
|
||||
case,
|
||||
st == 200,
|
||||
f"TEARDOWN answered {st}; connection closed by server afterwards: {closed}",
|
||||
)
|
||||
elif case == "third":
|
||||
a = Client()
|
||||
setup_play(a)
|
||||
b = Client()
|
||||
setup_play(b)
|
||||
fa, _, ga, _ = a.read_frames(4)
|
||||
fb, _, gb, _ = b.read_frames(4)
|
||||
try:
|
||||
d = Client(timeout=3)
|
||||
d.send("OPTIONS")
|
||||
st, _, _ = d.read_response()
|
||||
third = f"third got {st}"
|
||||
d.close()
|
||||
except ConnectionError as e:
|
||||
# refused, reset or closed by the server; a timeout or a bug propagates and fails the case
|
||||
third = f"third rejected ({type(e).__name__})"
|
||||
a.send("TEARDOWN")
|
||||
a.read_response()
|
||||
a.close()
|
||||
b.send("TEARDOWN")
|
||||
b.read_response()
|
||||
b.close()
|
||||
say(
|
||||
case,
|
||||
fa > 10 and fb > 10 and ga == 0 and gb == 0 and "rejected" in third,
|
||||
f"two clients: {fa} and {fb} frames in 4 s each, gaps {ga}/{gb}; {third}",
|
||||
)
|
||||
elif case == "stall":
|
||||
a = Client()
|
||||
setup_play(a)
|
||||
b = Client()
|
||||
setup_play(b)
|
||||
# stop reading on a for 4 s while b keeps playing
|
||||
a.s.settimeout(None)
|
||||
fb1, _, gb1, _ = b.read_frames(4)
|
||||
a.s.settimeout(1.0)
|
||||
fa, _, ga, _ = a.read_frames(4)
|
||||
fb2, _, gb2, _ = b.read_frames(3)
|
||||
a.send("TEARDOWN")
|
||||
a.read_response()
|
||||
a.close()
|
||||
b.send("TEARDOWN")
|
||||
b.read_response()
|
||||
b.close()
|
||||
say(
|
||||
case,
|
||||
fb1 > 10 and fb2 > 10 and fa > 5,
|
||||
f"b kept {fb1} then {fb2} frames while a stalled 4 s; a recovered {fa} frames after resuming (gaps {ga}); b gaps {gb1}/{gb2}; device log must show stalled/dropping frame",
|
||||
)
|
||||
elif case == "timeout":
|
||||
c = Client(timeout=70)
|
||||
setup_play(c)
|
||||
c.s.settimeout(
|
||||
2
|
||||
) # a server that stops sending but never closes must not hang the run
|
||||
t0 = time.monotonic()
|
||||
dead = False
|
||||
try:
|
||||
while time.monotonic() - t0 < 68:
|
||||
try:
|
||||
d = c.s.recv(65536)
|
||||
except TimeoutError:
|
||||
continue
|
||||
if not d:
|
||||
dead = True
|
||||
break
|
||||
except ConnectionError:
|
||||
dead = True
|
||||
elapsed = time.monotonic() - t0
|
||||
say(
|
||||
case,
|
||||
dead and 50 <= elapsed <= 68,
|
||||
f"connection closed by server after idle: {dead} at {elapsed:.0f} s (expect the 60 s session timeout when the client sends nothing)",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
say(case, False, f"exception {type(e).__name__}: {e}")
|
||||
time.sleep(1)
|
||||
|
||||
print(
|
||||
"SUMMARY",
|
||||
sum(1 for r in results if r[1]),
|
||||
"pass",
|
||||
sum(1 for r in results if not r[1]),
|
||||
"fail",
|
||||
)
|
||||
sys.exit(1 if any(not r[1] for r in results) else 0)
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Bounded, timestamped serial capture that always exits and closes the port.
|
||||
|
||||
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append]
|
||||
|
||||
default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico.
|
||||
--released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin
|
||||
(DTR) and reset (RTS), so a plain open holds the chip in reset.
|
||||
--reset pulse reset after opening (DTR low, RTS high 100 ms, RTS low) to capture a fresh boot.
|
||||
--baud 115200 by default; 74880 shows the ESP8266 ROM boot messages.
|
||||
--append keep what OUTFILE already holds; by default it is truncated so counts cover this run only.
|
||||
|
||||
Exit status 1 when nothing was received (wrong port name, or a port another capture still holds).
|
||||
Text ESPHome marks as secret (LOG_SECRET, the Wi-Fi password) is written as <secret>.
|
||||
Lines are written as [HH:MM:SS]text with ANSI colour stripped and flushed one by one, so a shell
|
||||
loop can grep the file while the capture runs. If the port is missing or disappears (USB CDC boards
|
||||
re-enumerate on reboot) the script retries until the deadline, and follows the board by its USB serial
|
||||
number when it comes back under another device path.
|
||||
|
||||
Never run an open-ended serial session: every capture has a deadline, and a flash on the same port
|
||||
needs the capture stopped first (pkill -f "[s]erial_capture.py" does not match the killing shell).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
from serial.tools import list_ports
|
||||
|
||||
ANSI = re.compile(rb"\x1b\[[0-9;]*[A-Za-z]")
|
||||
# ESPHome's LOG_SECRET span (a Wi-Fi password); a span cut off before its end marker is secret to the end
|
||||
SECRET = re.compile(rb"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL)
|
||||
PORT_ERRORS = (serial.SerialException, OSError)
|
||||
|
||||
|
||||
def identify(port: str) -> tuple[str | None, int]:
|
||||
"""USB serial number of the port and its position among that device's ports (a dongle has two)."""
|
||||
ports = sorted(list_ports.comports(), key=lambda p: p.device)
|
||||
serial_number = next((p.serial_number for p in ports if p.device == port), None)
|
||||
siblings = [
|
||||
p.device for p in ports if serial_number and p.serial_number == serial_number
|
||||
]
|
||||
return serial_number, siblings.index(port) if port in siblings else 0
|
||||
|
||||
|
||||
def current_path(port: str, serial_number: str | None, index: int) -> str:
|
||||
"""The port's current device path: a USB CDC board can come back under a new name after a reboot."""
|
||||
if serial_number:
|
||||
ports = sorted(list_ports.comports(), key=lambda p: p.device)
|
||||
siblings = [p.device for p in ports if p.serial_number == serial_number]
|
||||
if index < len(siblings):
|
||||
return siblings[index]
|
||||
return port
|
||||
|
||||
|
||||
def open_port(args: argparse.Namespace, path: str) -> serial.Serial:
|
||||
s = serial.Serial(port=None, baudrate=args.baud, timeout=0.2)
|
||||
s.port = path
|
||||
if args.released:
|
||||
s.dtr = False
|
||||
s.rts = False
|
||||
s.open()
|
||||
if args.reset:
|
||||
try:
|
||||
s.dtr = False
|
||||
s.rts = True
|
||||
time.sleep(0.1)
|
||||
s.rts = False
|
||||
s.reset_input_buffer()
|
||||
except PORT_ERRORS:
|
||||
s.close()
|
||||
raise
|
||||
return s
|
||||
|
||||
|
||||
def write_line(out, tag: bytes, data: bytes) -> None:
|
||||
stamp = time.strftime("[%H:%M:%S]").encode()
|
||||
out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"<secret>", data)) + b"\n")
|
||||
out.flush()
|
||||
|
||||
|
||||
def note(out, text: str) -> None:
|
||||
write_line(out, b"[capture] ", text.encode())
|
||||
|
||||
|
||||
def capture(args: argparse.Namespace, out) -> int:
|
||||
"""Returns the number of bytes received."""
|
||||
deadline = time.time() + args.seconds
|
||||
buf = b""
|
||||
s = None
|
||||
received = 0
|
||||
serial_number, index = None, 0
|
||||
while time.time() < deadline:
|
||||
if s is None:
|
||||
path = current_path(args.port, serial_number, index)
|
||||
try:
|
||||
s = open_port(args, path)
|
||||
args.reset = False # only reset on the first open
|
||||
if serial_number is None:
|
||||
serial_number, index = identify(path)
|
||||
if path != args.port:
|
||||
note(out, f"reopened as {path}")
|
||||
except PORT_ERRORS as err:
|
||||
note(out, f"open failed: {err}")
|
||||
time.sleep(1)
|
||||
continue
|
||||
try:
|
||||
data = s.read(4096)
|
||||
except PORT_ERRORS as err:
|
||||
if buf: # keep the cut-off line apart from the next boot's first line
|
||||
write_line(out, b"[partial]", buf)
|
||||
buf = b""
|
||||
note(out, f"port lost: {err}")
|
||||
with contextlib.suppress(*PORT_ERRORS):
|
||||
s.close()
|
||||
s = None
|
||||
time.sleep(1)
|
||||
continue
|
||||
if not data:
|
||||
continue
|
||||
received += len(data)
|
||||
buf += data
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
write_line(out, b"", line.rstrip(b"\r"))
|
||||
if buf:
|
||||
write_line(out, b"[partial]", buf)
|
||||
if s is not None:
|
||||
s.close()
|
||||
return received
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument("port")
|
||||
ap.add_argument("seconds", type=float)
|
||||
ap.add_argument("outfile")
|
||||
ap.add_argument("--baud", type=int, default=115200)
|
||||
ap.add_argument("--released", action="store_true")
|
||||
ap.add_argument("--reset", action="store_true")
|
||||
ap.add_argument("--append", action="store_true")
|
||||
args = ap.parse_args()
|
||||
with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out:
|
||||
received = capture(args, out)
|
||||
if received == 0:
|
||||
print(f"[capture] nothing received from {args.port}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,13 +0,0 @@
|
||||
#pragma once
|
||||
#include <zephyr/device.h>
|
||||
#include <zephyr/drivers/uart.h>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Direct look at the Zephyr cdc_acm_uart1 driver state, bypassing the usb_cdc_acm component
|
||||
inline void cdc_probe() {
|
||||
const device *d = DEVICE_DT_GET(DT_NODELABEL(cdc_acm_uart1));
|
||||
uint8_t tmp[32];
|
||||
int rr = uart_irq_rx_ready(d);
|
||||
int n = uart_fifo_read(d, tmp, sizeof(tmp));
|
||||
ESP_LOGI("probe", "driver rx_ready=%d fifo_read=%d first=0x%02X", rr, n, n > 0 ? tmp[0] : 0);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
"""Drive the usb_cdc_acm echo firmware on the nRF52840 dongle from the Mac.
|
||||
|
||||
usage: drive.py DATA_PORT [case ...]
|
||||
Cases: short, bulk1k, bulk16k, bulk64k, burst, flush, peek, linecoding, dtr, stream, counters
|
||||
"""
|
||||
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
port = sys.argv[1]
|
||||
cases = sys.argv[2:] or [
|
||||
"short",
|
||||
"bulk1k",
|
||||
"bulk16k",
|
||||
"bulk64k",
|
||||
"burst",
|
||||
"flush",
|
||||
"peek",
|
||||
"linecoding",
|
||||
"dtr",
|
||||
"stream",
|
||||
"counters",
|
||||
]
|
||||
results = []
|
||||
|
||||
|
||||
def say(case, ok, detail):
|
||||
results.append((case, ok, detail))
|
||||
print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True)
|
||||
|
||||
|
||||
def info(case, detail):
|
||||
"""A case whose result lives in the device log; printed, not counted."""
|
||||
print(f"INFO {case}: {detail}", flush=True)
|
||||
|
||||
|
||||
def open_port(baud=115200):
|
||||
s = serial.Serial(port, baudrate=baud, timeout=1)
|
||||
time.sleep(0.3) # let the 100 ms line-state poll see DTR and the echo loop settle
|
||||
return s
|
||||
|
||||
|
||||
def read_exact(s, n, timeout):
|
||||
buf = bytearray()
|
||||
deadline = time.monotonic() + timeout
|
||||
while len(buf) < n and time.monotonic() < deadline:
|
||||
chunk = s.read(min(4096, n - len(buf)))
|
||||
if chunk:
|
||||
buf += chunk
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def payload(n):
|
||||
# No 0x01 anywhere: the device treats a chunk starting with 0x01 as a command
|
||||
rnd = random.Random(n)
|
||||
return bytes(rnd.choice(range(2, 256)) for _ in range(n))
|
||||
|
||||
|
||||
def cmd(s, c, extra=b""):
|
||||
s.write(b"\x01" + c + extra)
|
||||
s.flush()
|
||||
|
||||
|
||||
def echo_case(name, n, chunk):
|
||||
s = open_port()
|
||||
s.reset_input_buffer()
|
||||
data = payload(n)
|
||||
t0 = time.monotonic()
|
||||
for i in range(0, n, chunk):
|
||||
s.write(data[i : i + chunk])
|
||||
got = read_exact(s, n, timeout=max(5, n / 2000))
|
||||
dt = time.monotonic() - t0
|
||||
s.close()
|
||||
ok = got == data
|
||||
first_bad = next((i for i in range(min(len(got), n)) if got[i] != data[i]), None)
|
||||
say(
|
||||
name,
|
||||
ok,
|
||||
f"{len(got)}/{n} bytes echoed in {dt:.2f}s ({n / dt / 1024:.1f} KiB/s), first mismatch {first_bad}",
|
||||
)
|
||||
|
||||
|
||||
for case in cases:
|
||||
if case == "short":
|
||||
s = open_port()
|
||||
s.reset_input_buffer()
|
||||
ok = True
|
||||
lat = []
|
||||
for i in range(20):
|
||||
msg = f"hello {i}\n".encode()
|
||||
t0 = time.monotonic()
|
||||
s.write(msg)
|
||||
got = read_exact(s, len(msg), 2)
|
||||
lat.append(time.monotonic() - t0)
|
||||
ok = ok and got == msg
|
||||
s.close()
|
||||
say(
|
||||
case,
|
||||
ok,
|
||||
f"20 lines, latency min {min(lat) * 1000:.0f} ms max {max(lat) * 1000:.0f} ms",
|
||||
)
|
||||
elif case == "bulk1k":
|
||||
echo_case(case, 1024, 1024)
|
||||
elif case == "bulk16k":
|
||||
echo_case(case, 16 * 1024, 4096)
|
||||
elif case == "bulk64k":
|
||||
echo_case(case, 64 * 1024, 16384)
|
||||
elif case == "burst":
|
||||
s = open_port()
|
||||
s.reset_input_buffer()
|
||||
cmd(s, b"B")
|
||||
got = read_exact(s, 1000, 3)
|
||||
s.close()
|
||||
expect = bytes((ord("A") + (i % 26)) for i in range(1000))
|
||||
prefix_ok = got == expect[: len(got)]
|
||||
say(
|
||||
case,
|
||||
prefix_ok and 0 < len(got) < 1000,
|
||||
f"{len(got)} of 1000 bytes arrived (256 byte TX ring, rest must be dropped and warned), prefix intact {prefix_ok}",
|
||||
)
|
||||
elif case == "flush":
|
||||
s = open_port()
|
||||
s.reset_input_buffer()
|
||||
cmd(s, b"F")
|
||||
got = read_exact(s, 200, 3)
|
||||
s.close()
|
||||
expect = bytes((ord("a") + (i % 26)) for i in range(200))
|
||||
say(case, got == expect, f"{len(got)}/200 bytes, flush result in device log")
|
||||
time.sleep(0.5)
|
||||
elif case == "peek":
|
||||
s = open_port()
|
||||
s.reset_input_buffer()
|
||||
cmd(s, b"K", b"Z")
|
||||
time.sleep(0.5)
|
||||
s.close()
|
||||
info(
|
||||
case,
|
||||
"peek/read result is in the device log: expect `peek ok=1 0x5A read ok=1 0x5A`",
|
||||
)
|
||||
elif case == "linecoding":
|
||||
for baud in (9600, 115200, 921600):
|
||||
s = open_port(baud)
|
||||
s.reset_input_buffer()
|
||||
cmd(s, b"L")
|
||||
time.sleep(0.4)
|
||||
s.close()
|
||||
time.sleep(0.3)
|
||||
info(case, "opened at 9600, 115200, 921600; the device log must show each baud")
|
||||
elif case == "dtr":
|
||||
for _i in range(5):
|
||||
s = open_port()
|
||||
time.sleep(0.3)
|
||||
s.close()
|
||||
time.sleep(0.3)
|
||||
info(
|
||||
case, "5 open/close cycles; the device log must show 5 Opened and 5 Closed"
|
||||
)
|
||||
elif case == "stream":
|
||||
# device streams 100 x 100 byte lines over 10 s; close the port for 3 s in the middle
|
||||
s = open_port()
|
||||
s.reset_input_buffer()
|
||||
cmd(s, b"S")
|
||||
part1 = bytearray()
|
||||
t0 = time.monotonic()
|
||||
while time.monotonic() - t0 < 2.5:
|
||||
part1 += s.read(4096)
|
||||
s.close()
|
||||
time.sleep(3.0)
|
||||
s = open_port()
|
||||
part2 = bytearray()
|
||||
t0 = time.monotonic()
|
||||
while time.monotonic() - t0 < 6.0:
|
||||
part2 += s.read(4096)
|
||||
s.close()
|
||||
lines1 = [x for x in bytes(part1).split(b"\n") if x.startswith(b"STREAM")]
|
||||
lines2 = [x for x in bytes(part2).split(b"\n") if x.startswith(b"STREAM")]
|
||||
n1 = [int(x[7:10]) for x in lines1]
|
||||
n2 = [int(x[7:10]) for x in lines2]
|
||||
resumed = bool(n2) and max(n2) >= 90
|
||||
say(
|
||||
case,
|
||||
resumed,
|
||||
f"before close: lines {min(n1) if n1 else None}-{max(n1) if n1 else None} ({len(n1)}), after reopen: {min(n2) if n2 else None}-{max(n2) if n2 else None} ({len(n2)}); streaming resumed after reopen {resumed}",
|
||||
)
|
||||
elif case == "counters":
|
||||
s = open_port()
|
||||
cmd(s, b"C")
|
||||
time.sleep(0.3)
|
||||
s.close()
|
||||
info(case, "counters are in the device log")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(
|
||||
"SUMMARY",
|
||||
sum(1 for r in results if r[1]),
|
||||
"pass",
|
||||
sum(1 for r in results if not r[1]),
|
||||
"fail",
|
||||
)
|
||||
sys.exit(1 if any(not r[1] for r in results) else 0)
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/bin/bash
|
||||
# wifisave_hammer.sh: the device under test (DUT) falls back to its AP; a client board joins it and posts
|
||||
# /wifisave repeatedly, then the real credentials. Compiles, flashes, captures both serial logs and
|
||||
# prints counts. Configs come from templates/example-dut.yaml and templates/example-client.yaml.
|
||||
#
|
||||
# Usage: wifisave_hammer.sh DUT_KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE_S]
|
||||
# DUT_KIND esp (esphome upload), pico (upload, then re-find the CDC port by USB serial number),
|
||||
# bw15 (flash_rtl87xx_kit.py) or bk (flash_bk72xx.py)
|
||||
# *_YAML absolute path; the file name without .yaml must equal the config's `name`
|
||||
# *_TREE checkout or worktree whose esphome code must build that yaml (cwd for python -m esphome)
|
||||
# SETTLE_S seconds to keep capturing after the client reports the real save (default 40; BW15 wants 90)
|
||||
# Env: PY (python with esphome; default DUT_TREE/venv/bin/python, else python3 on PATH; an explicit PY must exist), LTPY
|
||||
# (LibreTiny penv python with ltchiptool; default ~/.platformio/penv/.libretiny/bin/python)
|
||||
# Output next to each yaml: NAME-compile.log, NAME-upload.log, NAME-serial.log, NAME-capture.err, and DUT NAME-events.txt
|
||||
# (addresses redacted so the extract can go on a PR; the serial logs themselves stay local).
|
||||
# Exit status: 0 RESULT PASS (no crash marker or reboot after the AP came up, the device joined the network,
|
||||
# and real stimulus: saves answered and a cooldown exit); 3 RESULT FAIL (the counts show
|
||||
# a failure); 2 RESULT INVALID (a precondition or the stimulus was missed, or a capture did not last the run);
|
||||
# 1 when a build, flash or port step fails (nothing is reported).
|
||||
set -u
|
||||
SKILL=$(cd "$(dirname "$0")/.." && pwd)
|
||||
KIND=$1; DPORT=$2; DYAML=$3; DTREE=$4; CPORT=$5; CYAML=$6; CTREE=$7; SETTLE=${8:-40}
|
||||
if [ -n "${PY:-}" ]; then [ -x "$PY" ] || { echo "[hammer] FAILED: PY=$PY is not executable"; exit 1; }
|
||||
else PY=$DTREE/venv/bin/python; [ -x "$PY" ] || PY=$(command -v python3) || { echo "[hammer] FAILED: no python found, set PY"; exit 1; }; fi
|
||||
LTPY=${LTPY:-$HOME/.platformio/penv/.libretiny/bin/python}
|
||||
DDIR=$(dirname "$DYAML"); DNAME=$(basename "$DYAML" .yaml)
|
||||
CDIR=$(dirname "$CYAML"); CNAME=$(basename "$CYAML" .yaml)
|
||||
DLOG=$DDIR/$DNAME-serial.log; CLOG=$CDIR/$CNAME-serial.log
|
||||
INVALID=""
|
||||
AP_WAIT=120; UPLOAD_WAIT=300; SAVE_WAIT=240
|
||||
# Crash signatures (extended regex), not bare words: "aborting connection attempt" is a normal Wi-Fi retry line
|
||||
CRASH_RE='Panic |abort\(\)|Fatal exception|Exception \(|Guru Meditation|HardFault|Bus Fault|Backtrace:|Soft WDT reset|wdt reset|Brownout|rst cause:[24]'
|
||||
# Boot banners and port losses are counted apart: a Pico reboot shows as a port loss (and maybe a banner too),
|
||||
# while on a UART board a port loss comes from the adapter or the hub, not the chip
|
||||
BANNER_RE='Log initialized'; LOSS_RE='\[capture\] port lost'
|
||||
DUT_CAPTURE=$((AP_WAIT + UPLOAD_WAIT + SAVE_WAIT + SETTLE + 60)); CLIENT_CAPTURE=$((SAVE_WAIT + SETTLE + 60))
|
||||
|
||||
bound() { local t=$1; shift; if command -v timeout >/dev/null; then timeout "$t" "$@"; else perl -e 'alarm shift; exec @ARGV' "$t" "$@"; fi; }
|
||||
count() { LC_ALL=C grep -a -c "$1" "$2"; }
|
||||
wait_for() { # wait_for FILE PATTERN SECONDS -> prints elapsed, status 1 on timeout
|
||||
local n=0; until LC_ALL=C grep -a -q "$2" "$1"; do sleep 2; n=$((n+2)); [ $n -ge "$3" ] && { echo "$n"; return 1; }; done; echo "$n"; }
|
||||
# Only this run's captures: another capture on the host (a soak on a third board) must survive
|
||||
stop_tails() { pkill -f "[s]erial_capture.py $DPORT " 2>/dev/null; pkill -f "[s]erial_capture.py $CPORT " 2>/dev/null; sleep 1; }
|
||||
fail() { echo "[hammer] FAILED: $1"; stop_tails; exit 1; }
|
||||
port_never_opened() { # an open failure and no line that is not a capture note: the port, not the firmware
|
||||
LC_ALL=C grep -a -q '\[capture\] open failed' "$1" && ! LC_ALL=C grep -a -q -v '^\[[0-9:]*\]\[capture\]' "$1"; }
|
||||
compile() { # compile YAML TREE
|
||||
(cd "$2" && PYTHONUNBUFFERED=1 bound 1500 "$PY" -m esphome compile "$1" > "${1%.yaml}-compile.log" 2>&1); local rc=$?
|
||||
echo "[hammer] compile $(basename "$1"): rc=$rc"; return $rc; }
|
||||
esp_upload() { # esp_upload YAML PORT TREE
|
||||
(cd "$3" && bound $UPLOAD_WAIT "$PY" -m esphome upload "$1" --device "$2" > "${1%.yaml}-upload.log" 2>&1)
|
||||
local ok; ok=$(count 'Successfully uploaded' "${1%.yaml}-upload.log"); echo "[hammer] upload $(basename "$1"): ok=$ok"; [ "$ok" -ge 1 ]; }
|
||||
|
||||
stop_tails
|
||||
compile "$DYAML" "$DTREE" || fail "DUT build failed, see $DDIR/$DNAME-compile.log"
|
||||
compile "$CYAML" "$CTREE" || fail "client build failed, see $CDIR/$CNAME-compile.log"
|
||||
|
||||
TAILOPT=""
|
||||
case $KIND in
|
||||
esp) esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" ;;
|
||||
pico)
|
||||
SN=$("$PY" "$SKILL/scripts/ports.py" --serial-of "$DPORT") || fail "no USB serial number for $DPORT"
|
||||
esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed"
|
||||
# The Pico drops its CDC port on reboot and may already be back: take whatever path this board has now;
|
||||
# the capture follows the serial number if the path changes again
|
||||
DPORT=$("$PY" "$SKILL/scripts/ports.py" --wait 2e8a --serial "$SN" --timeout 60) || fail "pico did not re-enumerate"
|
||||
echo "[hammer] pico port after upload: $DPORT"; sleep 2 ;;
|
||||
bw15|bk)
|
||||
UF2=$(ls "$DDIR/.esphome/build/$DNAME/.pioenvs/$DNAME/"*.uf2 2>/dev/null | head -1)
|
||||
[ -n "$UF2" ] || fail "no uf2 under $DDIR/.esphome/build/$DNAME"
|
||||
echo "[hammer] uf2: ${UF2##*/}"
|
||||
W=$SKILL/scripts/flash_rtl87xx_kit.py; [ "$KIND" = bk ] && W=$SKILL/scripts/flash_bk72xx.py
|
||||
(cd "$DDIR" && bound 400 "$LTPY" "$W" flash write -d "$DPORT" "$UF2" > "$DDIR/$DNAME-upload.log" 2>&1); rc=$?
|
||||
echo "[hammer] $KIND flash: rc=$rc"; [ $rc -eq 0 ] || fail "DUT flash failed, see $DDIR/$DNAME-upload.log"
|
||||
TAILOPT="--released" ;;
|
||||
*) echo "unknown DUT_KIND $KIND"; exit 1 ;;
|
||||
esac
|
||||
|
||||
: > "$DLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$DPORT" $DUT_CAPTURE "$DLOG" $TAILOPT 2> "${DLOG%-serial.log}-capture.err" &)
|
||||
if ! n=$(wait_for "$DLOG" "Captive portal\|Starting fallback AP" $AP_WAIT); then
|
||||
[ -s "$DLOG" ] || fail "the DUT capture produced nothing (the capture itself failed), see ${DLOG%-serial.log}-capture.err"
|
||||
port_never_opened "$DLOG" && fail "could not open $DPORT (held by another process, or the wrong port), see $DLOG"
|
||||
fail "DUT never raised its AP within ${n}s, see $DLOG"
|
||||
fi
|
||||
echo "[hammer] DUT fallback AP after ${n}s"; sleep 2
|
||||
# reboots are counted from here; the first banner may predate the capture
|
||||
BANNER_BASE=$(count "$BANNER_RE" "$DLOG"); LOSS_BASE=$(count "$LOSS_RE" "$DLOG")
|
||||
|
||||
esp_upload "$CYAML" "$CPORT" "$CTREE" || fail "client upload failed"
|
||||
: > "$CLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$CPORT" $CLIENT_CAPTURE "$CLOG" 2> "${CLOG%-serial.log}-capture.err" &)
|
||||
if n=$(wait_for "$CLOG" "real save status\|posting the real" $SAVE_WAIT); then
|
||||
echo "[hammer] client posted the real credentials after ${n}s; settling ${SETTLE}s"; sleep "$SETTLE"
|
||||
else
|
||||
[ -s "$CLOG" ] || fail "the client capture produced nothing (the capture itself failed), see ${CLOG%-serial.log}-capture.err"
|
||||
port_never_opened "$CLOG" && fail "could not open $CPORT (held by another process, or the wrong port), see $CLOG"
|
||||
INVALID="client never reached the real save within ${n}s"; echo "[hammer] WARNING: $INVALID"
|
||||
fi
|
||||
|
||||
# A verdict needs complete evidence: both captures must have lasted the run and logged no error of their own
|
||||
for spec in "$DPORT:${DLOG%-serial.log}-capture.err" "$CPORT:${CLOG%-serial.log}-capture.err"; do
|
||||
port=${spec%%:*}; err=${spec#*:}
|
||||
pgrep -f "[s]erial_capture.py $port " >/dev/null || INVALID="${INVALID:+$INVALID; }the capture on $port ended before the run did, see $err"
|
||||
[ -s "$err" ] && INVALID="${INVALID:+$INVALID; }the capture on $port reported an error, see $err"
|
||||
done
|
||||
CRASHES=$(LC_ALL=C grep -a -c -E "$CRASH_RE" "$DLOG")
|
||||
BANNERS=$(( $(count "$BANNER_RE" "$DLOG") - BANNER_BASE )); LOSSES=$(( $(count "$LOSS_RE" "$DLOG") - LOSS_BASE ))
|
||||
if [ "$KIND" = pico ]; then
|
||||
REBOOTS=$BANNERS; [ "$LOSSES" -gt "$REBOOTS" ] && REBOOTS=$LOSSES # one reboot can show as both, so take the larger
|
||||
else
|
||||
REBOOTS=$BANNERS; [ "$LOSSES" -gt 0 ] && INVALID="${INVALID:+$INVALID; }the DUT port dropped $LOSSES time(s) mid-run (adapter or hub, not the chip)"
|
||||
fi
|
||||
STA=$(LC_ALL=C grep -a 'IP Address:' "$DLOG" | grep -v -c '192\.168\.4\.'); REAL=$(LC_ALL=C grep -a -o 'real save status [-0-9]*' "$CLOG" | tail -1 | awk '{print $4}')
|
||||
SAVES=$(count 'Requested WiFi Settings Change' "$DLOG"); EXITS=$(count 'cooldown early' "$DLOG"); ANSWERED=$(count 'bogus save status 200' "$CLOG")
|
||||
echo "[hammer] $DNAME: saves requested $SAVES, cooldown exits $EXITS, crash markers $CRASHES, after the AP came up: boot banners $BANNERS, port losses $LOSSES, reboots $REBOOTS; STA addresses $STA"
|
||||
FAILED=$(count 'bogus save status -1' "$CLOG")
|
||||
echo "[hammer] $CNAME: bogus saves answered $ANSWERED, failed $FAILED, real save status ${REAL:-none}"
|
||||
# Only whole messages, matched where the message starts, so a credential line or an odd SSID never gets in
|
||||
LC_ALL=C grep -a -E "^\[[0-9:]+\](\[[A-Z]\]\[[a-z_]+:[0-9]+\]: *(Exiting cooldown early|Starting fallback AP|Captive portal|Requested WiFi Settings Change|Retry phase|Connected\$|IP Address: )|\[capture\] port lost|($CRASH_RE))" "$DLOG" | LC_ALL=C tr -cd '\11\12\15\40-\176' | sed -E 's/(IP Address: )[^ ]+/\1<redacted>/' | cut -c1-110 | head -80 > "$DDIR/$DNAME-events.txt"
|
||||
echo "[hammer] events: $DDIR/$DNAME-events.txt"
|
||||
stop_tails
|
||||
[ -z "$INVALID" ] || { echo "[hammer] RESULT INVALID: $INVALID"; exit 2; }
|
||||
# Provisioning is proven by the device joining the network; the client's answer to the real save can time
|
||||
# out because the device leaves the AP before replying, so its status is information only
|
||||
if [ "$CRASHES" -gt 0 ] || [ "$REBOOTS" -gt 0 ] || [ "$STA" -lt 1 ]; then
|
||||
echo "[hammer] RESULT FAIL: crash markers $CRASHES, reboots $REBOOTS, STA addresses $STA, real save status ${REAL:-none}"; exit 3
|
||||
fi
|
||||
# A pass needs the stimulus the flow is about: at least half the configured bogus saves answered and one cooldown exit
|
||||
CONFIGURED=$(LC_ALL=C grep -a -E '^[[:space:]]*bogus_saves:' "$CYAML" | head -1 | tr -dc '0-9'); CONFIGURED=${CONFIGURED:-15}
|
||||
if [ "$EXITS" -lt 1 ] || [ $((ANSWERED * 2)) -lt "$CONFIGURED" ]; then
|
||||
echo "[hammer] RESULT INVALID: insufficient stimulus (bogus saves answered $ANSWERED of $CONFIGURED, cooldown exits $EXITS)"; exit 2
|
||||
fi
|
||||
[ "$FAILED" -eq 0 ] || echo "[hammer] WARNING: $FAILED of $CONFIGURED bogus saves got no answer"
|
||||
echo "[hammer] RESULT PASS"; exit 0
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/zsh
|
||||
# usage: decode.sh CAPTURE ELF OUTPREFIX
|
||||
# Parses the #CD: lines of a serial capture with Zephyr's coredump tools from the sdk-nrf that ESPHome
|
||||
# installed (SDK_NRF, default: the sdk-nrf directory under the ESPHome cache), serves the dump to GDB on a
|
||||
# random port and prints the backtrace and the thread list. The ELF is .pioenvs/<name>/zephyr/zephyr/zephyr.elf.
|
||||
# Every step is checked: a missing tool, a capture without a complete dump, an empty .bin or a gdbserver
|
||||
# that died all stop the script with exit 1, and the exit status is GDB's otherwise.
|
||||
CAP=$1; ELF=$2; OUT=$3
|
||||
SDK_NRF=${SDK_NRF:-$(ls -d ~/Library/Caches/esphome/sdk-nrf ~/.cache/esphome/sdk-nrf 2>/dev/null | head -1)}
|
||||
[ -n "$SDK_NRF" ] || { echo "sdk-nrf not found, set SDK_NRF"; exit 1; }
|
||||
F=$(ls -d $SDK_NRF/frameworks/v*/zephyr/scripts/coredump 2>/dev/null | tail -1)
|
||||
PY=${PYTHON:-$(ls $SDK_NRF/penvs/*/bin/python 2>/dev/null | head -1)}
|
||||
# SDK 0.x keeps the tools under arm-zephyr-eabi/, SDK 1.0+ under gnu/arm-zephyr-eabi/
|
||||
GDB=$(ls $SDK_NRF/toolchains/*/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb $SDK_NRF/toolchains/*/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb 2>/dev/null | tail -1)
|
||||
for v in F PY GDB; do
|
||||
[ -n "${(P)v}" ] || { echo "$v not found under $SDK_NRF"; exit 1; }
|
||||
done
|
||||
[ -f "$ELF" ] || { echo "ELF not found: $ELF"; exit 1; }
|
||||
# strip capture timestamps and ANSI so the parser sees the raw log lines
|
||||
sed -E 's/^\[[0-9:.]+\]//; s/\x1b\[[0-9;]*m//g' "$CAP" > $OUT-all.log
|
||||
# keep only the first complete #CD:BEGIN# .. #CD:END# block
|
||||
awk '/#CD:BEGIN#/{if(!done){p=1;buf=""}} p{buf=buf $0 "\n"} /#CD:END#/{if(p){printf "%s", buf; done=1; p=0}}' $OUT-all.log > $OUT-raw.log
|
||||
echo "CD lines in capture: $(grep -c '#CD:' $OUT-all.log) (BEGIN $(grep -c '#CD:BEGIN#' $OUT-all.log), END $(grep -c '#CD:END#' $OUT-all.log)); first complete block: $(grep -c '#CD:' $OUT-raw.log) lines"
|
||||
[ -s $OUT-raw.log ] || { echo "no complete #CD:BEGIN# .. #CD:END# block in the capture"; exit 1; }
|
||||
if ! $PY $F/coredump_serial_log_parser.py $OUT-raw.log $OUT.bin > $OUT-parser.log 2>&1; then
|
||||
echo "parser failed:"; tail -5 $OUT-parser.log; exit 1
|
||||
fi
|
||||
tail -3 $OUT-parser.log
|
||||
[ -s $OUT.bin ] || { echo "parser wrote an empty $OUT.bin"; exit 1; }
|
||||
ls -la $OUT.bin | awk '{print "bin bytes", $5}'
|
||||
PORT=$((20000 + RANDOM % 10000))
|
||||
$PY $F/coredump_gdbserver.py --port $PORT "$ELF" $OUT.bin > $OUT-gdbserver.log 2>&1 &
|
||||
G=$!
|
||||
sleep 3
|
||||
if ! kill -0 $G 2>/dev/null; then echo "gdbserver exited:"; tail -5 $OUT-gdbserver.log; exit 1; fi
|
||||
$GDB -q -batch -ex "target remote :$PORT" -ex "bt" -ex "info threads" "$ELF" 2>&1 | grep -v "^warning\|Reading symbols\|^$" | head -40
|
||||
rc=$pipestatus[1]
|
||||
kill $G 2>/dev/null
|
||||
exit $rc
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/zsh
|
||||
# usage: flow.sh LABEL PORT ELF [OUTDIR]
|
||||
# Run at least 60 s after the board rebooted from its crash, with the port closed. Proves the stored
|
||||
# coredump waits for a reader and survives a reader that goes away:
|
||||
# 1) open the port for 3 s and close it (a reader that leaves mid-print)
|
||||
# 2) open it for 100 s: the whole dump must print again, then be erased
|
||||
# 3) open it again for 20 s: dump_config must report no stored dump
|
||||
# then decode.sh parses the first complete dump and prints the GDB backtrace.
|
||||
# Needs a python with pyserial (PYTHON, default python3).
|
||||
D=${0:A:h}
|
||||
L=$1; P=$2; ELF=$3; S=${4:-.}
|
||||
PY=${PYTHON:-python3}
|
||||
CAP=$D/../serial_capture.py
|
||||
capture() { # SECONDS OUTFILE: stop the flow when the capture fails (busy or missing port)
|
||||
if ! $PY $CAP $P $1 $2 > $2.err 2>&1; then
|
||||
echo "capture of $2 failed:"; cat $2.err; exit 1
|
||||
fi
|
||||
}
|
||||
capture 3 $S/$L-brief.txt
|
||||
echo "$L brief 3 s open: $(wc -l < $S/$L-brief.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-brief.txt), END $(grep -a -c '#CD:END#' $S/$L-brief.txt)"
|
||||
sleep 5
|
||||
capture 100 $S/$L-full.txt
|
||||
echo "$L full 100 s open: $(wc -l < $S/$L-full.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-full.txt), BEGIN $(grep -a -c '#CD:BEGIN#' $S/$L-full.txt), END $(grep -a -c '#CD:END#' $S/$L-full.txt)"
|
||||
grep -a "crashtest\|zephyr_coredump\|Reset reason\|Has stored\| Size:\| Error:\|took a long\|compiled on" $S/$L-full.txt | grep -a -v "#CD:[0-9A-F]" | head -16
|
||||
sleep 3
|
||||
capture 20 $S/$L-after.txt
|
||||
echo "$L reopen 20 s: CD lines $(grep -a -c '#CD:' $S/$L-after.txt); $(grep -a 'Has stored\| Size:' $S/$L-after.txt | sed -E 's/^\[[^]]*\]//' | tr '\n' ' ')"
|
||||
echo "== decode"
|
||||
$D/decode.sh $S/$L-full.txt $ELF $S/$L
|
||||
@@ -1,24 +0,0 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from aioesphomeapi import APIClient
|
||||
|
||||
|
||||
async def main():
|
||||
c = APIClient(sys.argv[1], 6053, None)
|
||||
await c.connect(login=True)
|
||||
di = await c.device_info()
|
||||
print(
|
||||
"version",
|
||||
di.esphome_version,
|
||||
"compiled",
|
||||
di.compilation_time,
|
||||
"home_id",
|
||||
hex(di.zwave_home_id),
|
||||
"flags",
|
||||
di.zwave_proxy_feature_flags,
|
||||
)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Recompute the Z-Wave checksum (XOR of LENGTH..payload, initial 0xFF) over every frame the proxy logged."""
|
||||
|
||||
from functools import reduce
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def zw_checksum(frame: bytes) -> int:
|
||||
return reduce(lambda a, b: a ^ b, frame[1:-1], 0xFF)
|
||||
|
||||
|
||||
def complete(frame: bytes) -> bool:
|
||||
"""A data frame is SOF, LENGTH, then LENGTH bytes; the proxy logs at most 168 bytes, so longer ones are cut."""
|
||||
return len(frame) >= 3 and frame[0] == 0x01 and len(frame) == frame[1] + 2
|
||||
|
||||
|
||||
for path in sys.argv[1:]:
|
||||
text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
sent = re.findall(r"Sending: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text)
|
||||
recv = re.findall(r"Received frame: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text)
|
||||
calc = re.findall(
|
||||
r"CHECKSUM Received: 0x([0-9A-F]{2}) - Calculated: 0x([0-9A-F]{2})", text
|
||||
)
|
||||
bad = len(re.findall(r"Bad checksum", text))
|
||||
acks_sent = len(re.findall(r"Sending ACK", text))
|
||||
naks_sent = len(re.findall(r"Sending NAK/CAN", text))
|
||||
acks_recv = len(re.findall(r"Received ACK", text))
|
||||
naks_recv = len(re.findall(r"Received NAK", text))
|
||||
reboots = len(re.findall(r"Boot count|Reset reason|rst:0x", text))
|
||||
long_ops = len(re.findall(r"zwave_proxy took a long time", text))
|
||||
rx_frames = [bytes.fromhex(f.replace(":", "")) for f in recv]
|
||||
tx_frames = [bytes.fromhex(f.replace(":", "")) for f in sent]
|
||||
rx_cut = sum(1 for b in rx_frames if not complete(b))
|
||||
tx_cut = sum(1 for b in tx_frames if len(b) > 1 and not complete(b))
|
||||
rx_ok = sum(1 for b in rx_frames if complete(b) and zw_checksum(b) == b[-1])
|
||||
tx_ok = sum(1 for b in tx_frames if complete(b) and zw_checksum(b) == b[-1])
|
||||
calc_ok = sum(1 for r, c in calc if r == c)
|
||||
cmds = sorted({f.split(":")[3] for f in recv})
|
||||
print(
|
||||
f"{path.rsplit('/', 1)[-1]}: tx frames {len(sent)} (checksum ok {tx_ok}, truncated in log {tx_cut}), "
|
||||
f"rx frames {len(recv)} (recomputed ok {rx_ok}, truncated in log {rx_cut}), "
|
||||
f"device calc==received {calc_ok}/{len(calc)}, bad checksum warnings {bad}, ACK sent {acks_sent}, NAK sent {naks_sent}, "
|
||||
f"ACK received {acks_recv}, NAK received {naks_recv}, long ops {long_ops}, rx cmd ids {cmds}"
|
||||
)
|
||||
@@ -1,44 +0,0 @@
|
||||
substitutions:
|
||||
run: "1"
|
||||
|
||||
esphome:
|
||||
name: bk72xx-crash-probe
|
||||
includes: [../scripts/libretiny_crash/bkprobe.hpp]
|
||||
# step 0 waits 30 s so a capture can be opened (and a reset pulsed) after the flash; later steps 5 s
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: id(step) = bkprobe_boot(${run});
|
||||
- delay: !lambda 'return id(step) == 0 ? 30000 : 5000;'
|
||||
- lambda: bkprobe_act(${run}, id(step));
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7238-tuya
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
level: DEBUG
|
||||
|
||||
preferences:
|
||||
flash_write_interval: 1s
|
||||
|
||||
globals:
|
||||
- id: step
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: bkprobe_alive(id(step));
|
||||
|
||||
# Wi-Fi matters: a crashed BK72xx with Wi-Fi up never reboots on its own without the crash handler fix
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -1,38 +0,0 @@
|
||||
substitutions:
|
||||
run: "1"
|
||||
|
||||
esphome:
|
||||
name: lt21007e
|
||||
includes: [../scripts/libretiny_prefs/ltprefs.hpp]
|
||||
platformio_options:
|
||||
build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: id(act) = ltprefs_boot(id(step), ${run});
|
||||
# the first boot waits so the host can open the port and pulse a reset before the sequence starts
|
||||
- delay: !lambda 'return (id(step) == 0 || id(step) == 8) ? 30000 : 3000;'
|
||||
- lambda: ltprefs_exit(id(step), ${run}, id(act));
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7238-tuya
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
level: DEBUG
|
||||
|
||||
preferences:
|
||||
flash_write_interval: 1s
|
||||
|
||||
globals:
|
||||
- id: step
|
||||
type: int
|
||||
initial_value: "0"
|
||||
- id: act
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: ltprefs_alive(id(step), id(act));
|
||||
@@ -1,77 +0,0 @@
|
||||
# Package for the client board: it joins the device under test's AP (priority 10), posts
|
||||
# `bogus_saves` bogus /wifisave requests `save_interval` apart, then posts the real credentials once.
|
||||
# Include from a per-run yaml that holds the platform block and the substitutions (example-client.yaml);
|
||||
# real_ssid and real_psk come from the scratch directory's secrets.yaml and must be URL safe.
|
||||
# Set bogus_saves to 1 for a plain provisioning check. Log lines the driver script counts:
|
||||
# "bogus save N", "bogus save status 200", "posting the real credentials", "real save status 200"
|
||||
# The hammer is a single-mode script, so a reconnect to the AP (the device under test rebooted) does not
|
||||
# start a second series on top of the first.
|
||||
substitutions:
|
||||
ap_password: hwtestpass
|
||||
bogus_saves: "15"
|
||||
save_interval: 3s
|
||||
bogus_ssid: hwtest-no-such-network
|
||||
bogus_psk: nosuchpassword
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
http_request:
|
||||
id: http
|
||||
verify_ssl: false
|
||||
timeout: 10s
|
||||
|
||||
wifi:
|
||||
id: wifi_id
|
||||
networks:
|
||||
- ssid: ${ap_ssid}
|
||||
password: ${ap_password}
|
||||
priority: 10
|
||||
- ssid: ${real_ssid}
|
||||
password: ${real_psk}
|
||||
on_connect:
|
||||
- lambda: |-
|
||||
char b[40];
|
||||
ESP_LOGI("test", "connected, ip %s", id(wifi_id).get_ip_addresses()[0].str_to(b));
|
||||
- if:
|
||||
condition:
|
||||
lambda: |-
|
||||
char b[40];
|
||||
return strncmp(id(wifi_id).get_ip_addresses()[0].str_to(b), "192.168.4.", 10) == 0;
|
||||
then:
|
||||
- script.execute: hammer
|
||||
|
||||
script:
|
||||
- id: hammer
|
||||
mode: single
|
||||
then:
|
||||
- delay: 3s
|
||||
- logger.log: "hammering /wifisave with bogus credentials"
|
||||
- repeat:
|
||||
count: ${bogus_saves}
|
||||
then:
|
||||
- lambda: ESP_LOGI("test", "bogus save %u", (unsigned) iteration + 1);
|
||||
- http_request.get:
|
||||
url: "http://192.168.4.1/wifisave?ssid=${bogus_ssid}&psk=${bogus_psk}"
|
||||
capture_response: true
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("test", "bogus save status %d", response->status_code);
|
||||
on_error:
|
||||
- lambda: ESP_LOGW("test", "bogus save status -1");
|
||||
- delay: ${save_interval}
|
||||
- logger.log: "posting the real credentials"
|
||||
- http_request.get:
|
||||
url: "http://192.168.4.1/wifisave?ssid=${real_ssid}&psk=${real_psk}"
|
||||
capture_response: true
|
||||
on_response:
|
||||
- lambda: ESP_LOGI("test", "real save status %d", response->status_code);
|
||||
on_error:
|
||||
- lambda: ESP_LOGW("test", "real save status -1");
|
||||
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
|
||||
@@ -1,34 +0,0 @@
|
||||
esphome:
|
||||
name: i19430-dut3
|
||||
on_boot:
|
||||
priority: 800
|
||||
then:
|
||||
- lambda: |-
|
||||
auto marker = global_preferences->make_preference<uint32_t>(0x19430001, true);
|
||||
uint32_t v = 0;
|
||||
if (!marker.load(&v)) {
|
||||
ESP_LOGW("test", "first boot: wiping preferences");
|
||||
global_preferences->reset();
|
||||
static_cast<esphome::libretiny::LibreTinyPreferences *>(global_preferences)->open();
|
||||
v = 1;
|
||||
marker.save(&v);
|
||||
global_preferences->sync();
|
||||
}
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7238
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
level: VERBOSE
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: i19430-ap
|
||||
|
||||
captive_portal:
|
||||
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
|
||||
@@ -1,31 +0,0 @@
|
||||
# Package for a device under test that never finds its network, falls back to its AP and serves the
|
||||
# captive portal. Include it from a per-run yaml that holds the platform block and the substitutions
|
||||
# (see example-dut.yaml). Bump `name` on every run: saved credentials are keyed by it, and a reused
|
||||
# name boots straight onto the real network and never raises the AP.
|
||||
substitutions:
|
||||
ap_password: hwtestpass
|
||||
ap_timeout: 15s
|
||||
bogus_ssid: hwtest-no-such-network
|
||||
bogus_psk: nosuchpassword
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
- ssid: ${bogus_ssid}
|
||||
password: ${bogus_psk}
|
||||
ap:
|
||||
ssid: ${ap_ssid}
|
||||
password: ${ap_password}
|
||||
ap_timeout: ${ap_timeout}
|
||||
|
||||
captive_portal:
|
||||
|
||||
interval:
|
||||
- interval: 5s
|
||||
then:
|
||||
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
|
||||
@@ -1,18 +0,0 @@
|
||||
# Client board for the /wifisave hammer. Copy into a scratch directory next to a secrets.yaml;
|
||||
# ap_ssid must match the device under test, real_ssid and real_psk must be URL safe.
|
||||
substitutions:
|
||||
name: hwtest-client-1
|
||||
ap_ssid: hwtest-ap
|
||||
real_ssid: !secret wifi_ssid
|
||||
real_psk: !secret wifi_password
|
||||
bogus_saves: "15"
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
# esp8266: {board: d1_mini}
|
||||
|
||||
packages:
|
||||
hwtest: !include client-wifisave.yaml
|
||||
@@ -1,16 +0,0 @@
|
||||
# Device under test for the /wifisave hammer. Copy into a scratch directory, keep the file name equal
|
||||
# to `name`, pick one platform block, bump `name` every run.
|
||||
substitutions:
|
||||
name: hwtest-dut-1
|
||||
ap_ssid: hwtest-ap
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
# esp32: {board: esp32dev, framework: {type: esp-idf}}
|
||||
# rtl87xx: {board: bw15}
|
||||
# bk72xx: {board: generic-bk7238} # plus logger: {hardware_uart: UART1}, BK72xx logs on UART2 by default
|
||||
# rp2040: {board: rpipico2w}
|
||||
|
||||
packages:
|
||||
hwtest: !include dut-fallback-ap.yaml
|
||||
@@ -1,50 +0,0 @@
|
||||
substitutions:
|
||||
run: "3"
|
||||
|
||||
esphome:
|
||||
name: cd13122d
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("crashtest", "boot, crashed_before=%d run=%s", id(crashed_${run}), "${run}");
|
||||
- delay: 20s
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(crashed_${run}) == 0;'
|
||||
then:
|
||||
- lambda: |-
|
||||
id(crashed_${run}) = 1;
|
||||
ESP_LOGW("crashtest", "flag set, letting the loop save it");
|
||||
- delay: 3s
|
||||
- lambda: |-
|
||||
global_preferences->sync();
|
||||
delay(500);
|
||||
ESP_LOGW("crashtest", "writing to 0xDEADBEEF now");
|
||||
delay(100);
|
||||
*reinterpret_cast<volatile uint32_t *>(0xDEADBEEF) = 1;
|
||||
else:
|
||||
- lambda: |-
|
||||
ESP_LOGI("crashtest", "already crashed once, running normally");
|
||||
|
||||
nrf52:
|
||||
board: nrf52840dongle
|
||||
dfu: true
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_CDC
|
||||
level: DEBUG
|
||||
|
||||
zephyr_coredump:
|
||||
|
||||
globals:
|
||||
- id: crashed_${run}
|
||||
type: int
|
||||
restore_value: true
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("crashtest", "alive");
|
||||
@@ -1,31 +0,0 @@
|
||||
esphome:
|
||||
name: pref20810
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "boot: restored counter_pr=%d", id(counter_pr));
|
||||
|
||||
nrf52:
|
||||
board: nrf52840dongle
|
||||
dfu: true
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_CDC
|
||||
level: VERBOSE
|
||||
|
||||
preferences:
|
||||
flash_write_interval: 5s
|
||||
|
||||
globals:
|
||||
- id: counter_pr
|
||||
type: int
|
||||
restore_value: true
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 15s
|
||||
then:
|
||||
- lambda: |-
|
||||
id(counter_pr) += 1;
|
||||
ESP_LOGI("test", "counter_pr now %d", id(counter_pr));
|
||||
@@ -1,84 +0,0 @@
|
||||
esphome:
|
||||
name: online-image-dut
|
||||
friendly_name: online-image-dut
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
actions:
|
||||
- action: fetch
|
||||
variables:
|
||||
url: string
|
||||
then:
|
||||
- online_image.set_url:
|
||||
id: img
|
||||
url: !lambda 'return url;'
|
||||
update: false
|
||||
- component.update: img
|
||||
- action: refetch
|
||||
then:
|
||||
- component.update: img
|
||||
- action: fetch_small
|
||||
variables:
|
||||
url: string
|
||||
then:
|
||||
- online_image.set_url:
|
||||
id: img_small
|
||||
url: !lambda 'return url;'
|
||||
update: false
|
||||
- component.update: img_small
|
||||
- action: refetch_small
|
||||
then:
|
||||
- component.update: img_small
|
||||
|
||||
http_request:
|
||||
timeout: 4.5s
|
||||
verify_ssl: false
|
||||
|
||||
display:
|
||||
|
||||
image:
|
||||
- platform: online_image
|
||||
id: img
|
||||
url: http://127.0.0.1/none.bmp
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
update_interval: never
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished: img cached=%d"
|
||||
args: [cached]
|
||||
on_error:
|
||||
logger.log: "download error: img"
|
||||
- platform: online_image
|
||||
id: img_small
|
||||
url: http://127.0.0.1/none.bmp
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
buffer_size: 256
|
||||
update_interval: never
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished: img_small cached=%d"
|
||||
args: [cached]
|
||||
on_error:
|
||||
logger.log: "download error: img_small"
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
id: reboot_btn
|
||||
name: Reboot
|
||||
@@ -1,105 +0,0 @@
|
||||
esphome:
|
||||
name: rtsp17990
|
||||
on_boot:
|
||||
# GPIO8 low enables the camera's power supply on the Waveshare ESP32-S3-ETH; give the sensor
|
||||
# time to come up before esp32_camera (setup_priority 200) probes it
|
||||
priority: 700
|
||||
then:
|
||||
- switch.turn_off: cam_power
|
||||
- lambda: |-
|
||||
delay(1200);
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
speed: 80MHz
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
logs:
|
||||
rtsp_server: VERBOSE
|
||||
esp32_camera: DEBUG
|
||||
camera: DEBUG
|
||||
wifi: DEBUG
|
||||
api: DEBUG
|
||||
api.connection: DEBUG
|
||||
api.socket: DEBUG
|
||||
socket: DEBUG
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
app: DEBUG
|
||||
mdns: DEBUG
|
||||
ota: DEBUG
|
||||
logger: DEBUG
|
||||
psram: DEBUG
|
||||
esp32: DEBUG
|
||||
network: DEBUG
|
||||
i2c: DEBUG
|
||||
i2c.idf: DEBUG
|
||||
preferences: DEBUG
|
||||
debug: DEBUG
|
||||
sensor: DEBUG
|
||||
wifi_esp32: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
debug:
|
||||
update_interval: 10s
|
||||
|
||||
sensor:
|
||||
- platform: debug
|
||||
free:
|
||||
name: "Heap Free"
|
||||
block:
|
||||
name: "Heap Largest Block"
|
||||
psram:
|
||||
name: "PSRAM Free"
|
||||
|
||||
# Waveshare ESP32-S3-ETH camera header (Waveshare wiki, WIFI_Web_CAM table)
|
||||
i2c:
|
||||
- id: cam_i2c
|
||||
sda: GPIO48
|
||||
scl: GPIO47
|
||||
frequency: 400kHz
|
||||
|
||||
switch:
|
||||
- platform: gpio
|
||||
id: cam_power
|
||||
name: Camera Power
|
||||
pin: GPIO8
|
||||
restore_mode: ALWAYS_OFF
|
||||
setup_priority: 800
|
||||
|
||||
esp32_camera:
|
||||
name: cam
|
||||
setup_priority: 200
|
||||
data_pins: [GPIO41, GPIO45, GPIO46, GPIO42, GPIO40, GPIO38, GPIO15, GPIO18]
|
||||
vsync_pin: GPIO1
|
||||
href_pin: GPIO2
|
||||
pixel_clock_pin: GPIO39
|
||||
external_clock:
|
||||
pin: GPIO3
|
||||
frequency: 20MHz
|
||||
i2c_id: cam_i2c
|
||||
resolution: 640X480
|
||||
max_framerate: 10fps
|
||||
|
||||
rtsp_server:
|
||||
port: 554
|
||||
max_connections: 2
|
||||
@@ -1,107 +0,0 @@
|
||||
# Basic Config
|
||||
|
||||
esphome:
|
||||
name: s31-cse7766
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
toolchain: arduino
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
logger:
|
||||
baud_rate: 0 # (UART logging interferes with cse7766)
|
||||
level: VERBOSE
|
||||
logs:
|
||||
cse7766: VERBOSE
|
||||
api: DEBUG
|
||||
api.connection: DEBUG
|
||||
api.socket: DEBUG
|
||||
socket: DEBUG
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
app: DEBUG
|
||||
wifi: DEBUG
|
||||
mdns: DEBUG
|
||||
ota: DEBUG
|
||||
logger: DEBUG
|
||||
sensor: DEBUG
|
||||
switch: DEBUG
|
||||
binary_sensor: DEBUG
|
||||
status_led: DEBUG
|
||||
uart: DEBUG
|
||||
gpio: DEBUG
|
||||
preferences: DEBUG
|
||||
esp8266: DEBUG
|
||||
wifi_esp8266: DEBUG
|
||||
api:
|
||||
ota:
|
||||
platform: esphome
|
||||
|
||||
# Device Specific Config
|
||||
|
||||
uart:
|
||||
rx_pin: RX
|
||||
baud_rate: 4800
|
||||
parity: EVEN
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
pin:
|
||||
number: GPIO0
|
||||
mode: INPUT_PULLUP
|
||||
inverted: True
|
||||
name: "S31 Button"
|
||||
on_press:
|
||||
- switch.toggle: relay
|
||||
- platform: status
|
||||
name: "S31 Status"
|
||||
|
||||
sensor:
|
||||
- platform: wifi_signal
|
||||
name: "S31 WiFi Signal"
|
||||
update_interval: 60s
|
||||
- platform: cse7766
|
||||
current:
|
||||
accuracy_decimals: 3
|
||||
name: "S31 Current"
|
||||
filters:
|
||||
- throttle: 2s
|
||||
voltage:
|
||||
accuracy_decimals: 1
|
||||
name: "S31 Voltage"
|
||||
filters:
|
||||
- throttle: 2s
|
||||
on_raw_value:
|
||||
then:
|
||||
- lambda: |-
|
||||
id(frames) += 1;
|
||||
power:
|
||||
accuracy_decimals: 1
|
||||
name: "S31 Power"
|
||||
filters:
|
||||
- throttle: 2s
|
||||
switch:
|
||||
- platform: gpio
|
||||
name: "S31 Relay"
|
||||
pin: GPIO12
|
||||
id: relay
|
||||
restore_mode: RESTORE_DEFAULT_ON
|
||||
|
||||
status_led:
|
||||
pin: GPIO13
|
||||
|
||||
globals:
|
||||
- id: frames
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: |-
|
||||
static uint32_t last = 0;
|
||||
ESP_LOGI("frames", "cse7766 frames parsed: %u in 10 s (total %u)", (unsigned) (id(frames) - last), (unsigned) id(frames));
|
||||
last = id(frames);
|
||||
@@ -1,112 +0,0 @@
|
||||
esphome:
|
||||
name: cdc11634
|
||||
|
||||
nrf52:
|
||||
board: nrf52840dongle
|
||||
dfu: true
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_CDC
|
||||
level: VERBOSE
|
||||
logs:
|
||||
usb_cdc_acm: VERBOSE
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
app: DEBUG
|
||||
preferences: DEBUG
|
||||
logger: DEBUG
|
||||
nrf52: DEBUG
|
||||
dfu: DEBUG
|
||||
|
||||
usb_cdc_acm:
|
||||
interfaces:
|
||||
- disabled: true
|
||||
- id: cdc1
|
||||
|
||||
globals:
|
||||
- id: rx_total
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: tx_attempted
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: stream_left
|
||||
type: int32_t
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 20ms
|
||||
then:
|
||||
- lambda: |-
|
||||
uint8_t buf[64];
|
||||
while (id(cdc1).available() > 0) {
|
||||
// a command is exactly two bytes, so what the host sends after it stays in the driver for peek/read
|
||||
uint8_t first = 0;
|
||||
id(cdc1).peek_byte(&first);
|
||||
size_t n = std::min<size_t>(id(cdc1).available(), sizeof(buf));
|
||||
if (first == 0x01 && n >= 2)
|
||||
n = 2;
|
||||
if (!id(cdc1).read_array(buf, n)) {
|
||||
ESP_LOGE("test", "read_array(%u) failed", (unsigned) n);
|
||||
break;
|
||||
}
|
||||
id(rx_total) += n;
|
||||
if (buf[0] == 0x01 && n >= 2) {
|
||||
const char cmd = buf[1];
|
||||
if (cmd == 'B') {
|
||||
// one oversized write: the 256 byte TX ring must drop the rest and warn once
|
||||
static uint8_t big[1000];
|
||||
for (size_t i = 0; i < sizeof(big); i++) big[i] = 'A' + (i % 26);
|
||||
id(cdc1).write_array(big, sizeof(big));
|
||||
id(tx_attempted) += sizeof(big);
|
||||
ESP_LOGI("test", "burst 1000 written");
|
||||
} else if (cmd == 'S') {
|
||||
id(stream_left) = 100;
|
||||
ESP_LOGI("test", "stream started");
|
||||
} else if (cmd == 'L') {
|
||||
ESP_LOGI("test", "line coding baud=%u data=%u stop=%u parity=%u", (unsigned) id(cdc1).get_baud_rate(),
|
||||
id(cdc1).get_data_bits(), id(cdc1).get_stop_bits(), (unsigned) id(cdc1).get_parity());
|
||||
} else if (cmd == 'K') {
|
||||
// the host sends "\x01KZ" in one write; the Z must still be in the driver here
|
||||
uint8_t pb = 0, rb = 0;
|
||||
bool okp = id(cdc1).peek_byte(&pb);
|
||||
bool okr = id(cdc1).read_byte(&rb);
|
||||
ESP_LOGI("test", "peek ok=%d 0x%02X read ok=%d 0x%02X (expect ok=1 0x5A for both)", okp, pb, okr, rb);
|
||||
if (okr) id(rx_total) += 1;
|
||||
} else if (cmd == 'F') {
|
||||
static uint8_t pat[200];
|
||||
for (size_t i = 0; i < sizeof(pat); i++) pat[i] = 'a' + (i % 26);
|
||||
id(cdc1).write_array(pat, sizeof(pat));
|
||||
id(tx_attempted) += sizeof(pat);
|
||||
const uint32_t t0 = millis();
|
||||
auto res = id(cdc1).flush();
|
||||
ESP_LOGI("test", "flush result=%d took %u ms", (int) res, (unsigned) (millis() - t0));
|
||||
} else if (cmd == 'C') {
|
||||
ESP_LOGI("test", "counters rx=%u tx_attempted=%u (write_array drops silently when the ring is full)", (unsigned) id(rx_total), (unsigned) id(tx_attempted));
|
||||
} else {
|
||||
ESP_LOGW("test", "unknown cmd 0x%02X", cmd);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
id(cdc1).write_array(buf, n);
|
||||
id(tx_attempted) += n;
|
||||
}
|
||||
- interval: 100ms
|
||||
then:
|
||||
- lambda: |-
|
||||
if (id(stream_left) > 0) {
|
||||
char line[101];
|
||||
int len = snprintf(line, sizeof(line), "STREAM %03d ", 100 - id(stream_left));
|
||||
for (int i = len; i < 99; i++) line[i] = 'x';
|
||||
line[99] = '\n';
|
||||
id(cdc1).write_array(reinterpret_cast<const uint8_t *>(line), 100);
|
||||
id(tx_attempted) += 100;
|
||||
id(stream_left) -= 1;
|
||||
if (id(stream_left) == 0) {
|
||||
ESP_LOGI("test", "stream done");
|
||||
}
|
||||
}
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "alive rx=%u tx_attempted=%u", (unsigned) id(rx_total), (unsigned) id(tx_attempted));
|
||||
@@ -1,83 +0,0 @@
|
||||
esphome:
|
||||
name: camdiag
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- output.set_level:
|
||||
id: xclk
|
||||
level: 50%
|
||||
- delay: 200ms
|
||||
- lambda: |-
|
||||
auto *bus = id(cam_i2c);
|
||||
auto scan = [&](const char *what) {
|
||||
int found = 0;
|
||||
for (uint8_t a = 0x08; a < 0x78; a++) {
|
||||
if (bus->write(a, nullptr, 0) == i2c::ERROR_OK) {
|
||||
ESP_LOGI("camdiag", " ACK at 0x%02X", a);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
ESP_LOGI("camdiag", "scan %s: %d devices", what, found);
|
||||
};
|
||||
auto rd16 = [&](uint16_t reg) {
|
||||
uint8_t r[2] = {(uint8_t) (reg >> 8), (uint8_t) reg};
|
||||
uint8_t v = 0xEE;
|
||||
auto err = bus->write_readv(0x3C, r, 2, &v, 1);
|
||||
ESP_LOGI("camdiag", " 0x3C [0x%04X] err=%d -> 0x%02X", reg, (int) err, v);
|
||||
return v;
|
||||
};
|
||||
auto rd8 = [&](uint8_t addr, uint8_t reg) {
|
||||
uint8_t v = 0xEE;
|
||||
auto err = bus->write_readv(addr, ®, 1, &v, 1);
|
||||
ESP_LOGI("camdiag", " 0x%02X [0x%02X] (8 bit) err=%d -> 0x%02X", addr, reg, (int) err, v);
|
||||
};
|
||||
id(g8).turn_off(); delay(1500);
|
||||
id(xclk).set_level(0.0f); delay(300);
|
||||
scan("GPIO8 LOW, XCLK OFF");
|
||||
id(xclk).set_level(0.5f); delay(300);
|
||||
scan("GPIO8 LOW, XCLK ON");
|
||||
ESP_LOGI("camdiag", "OV5640 style 16 bit reads (expect 300A=0x56 300B=0x40, 302A=0xB0..B2):");
|
||||
rd16(0x300A); rd16(0x300B); rd16(0x302A); rd16(0x3008); rd16(0x3100); rd16(0x3103); rd16(0x4300);
|
||||
ESP_LOGI("camdiag", "8 bit reads at 0x3C (GC2145 expects F0=0x21 F1=0x45):");
|
||||
rd8(0x3C, 0xF0); rd8(0x3C, 0xF1); rd8(0x3C, 0x0A); rd8(0x3C, 0x0B);
|
||||
ESP_LOGI("camdiag", "write 0x3103=0x11 then read back, then soft reset 0x3008=0x82 and re-read IDs:");
|
||||
uint8_t w[3] = {0x31, 0x03, 0x11};
|
||||
auto werr = bus->write(0x3C, w, 3);
|
||||
ESP_LOGI("camdiag", " write err=%d", (int) werr);
|
||||
rd16(0x3103);
|
||||
uint8_t rst[3] = {0x30, 0x08, 0x82};
|
||||
bus->write(0x3C, rst, 3); delay(100);
|
||||
rd16(0x300A); rd16(0x300B);
|
||||
id(g8).turn_on(); delay(500); scan("GPIO8 HIGH (camera off)");
|
||||
id(g8).turn_off(); delay(1500); scan("GPIO8 LOW again");
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
speed: 80MHz
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
i2c:
|
||||
- id: cam_i2c
|
||||
sda: GPIO48
|
||||
scl: GPIO47
|
||||
frequency: 100kHz
|
||||
scan: true
|
||||
|
||||
output:
|
||||
- platform: gpio
|
||||
id: g8
|
||||
pin: GPIO8
|
||||
- platform: ledc
|
||||
pin: GPIO3
|
||||
id: xclk
|
||||
frequency: 20000000Hz
|
||||
@@ -1 +0,0 @@
|
||||
../.agents/skills
|
||||
@@ -31,4 +31,7 @@ RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
&& platformio settings set check_platformio_interval 1000000
|
||||
|
||||
COPY script/platformio_install_deps.py platformio.ini ./
|
||||
RUN ./platformio_install_deps.py platformio.ini --libraries --platforms --tools
|
||||
|
||||
WORKDIR /workspaces
|
||||
|
||||
@@ -9,29 +9,6 @@ body:
|
||||
If you have a feature request or enhancement, please [request them here instead][fr].
|
||||
|
||||
[fr]: https://github.com/orgs/esphome/discussions
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Use of AI in bug reports
|
||||
|
||||
AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting.
|
||||
Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved
|
||||
your problem, its wild guesses are not likely to help.
|
||||
|
||||
Please DO include your own words and observations, compile/boot logs, and
|
||||
especially a minimal reproducible example of your YAML configuration that demonstrates the problem.
|
||||
|
||||
It is however quite acceptable to use AI to translate your *own* report,
|
||||
if you aren't a competent English speaker.
|
||||
|
||||
If you really think it will be useful to include an AI's analysis, preferably wrap it in a `<details>` block which will be collapsed by default.
|
||||
|
||||
If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is,
|
||||
it can be more useful to ask it to create a step-by-step troubleshooting procedure.
|
||||
AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML
|
||||
configuration that demonstrates the problem.
|
||||
|
||||
Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way.
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -42,7 +42,7 @@ runs:
|
||||
|
||||
- name: Build and push to ghcr by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
@@ -67,7 +67,7 @@ runs:
|
||||
|
||||
- name: Build and push to dockerhub by digest
|
||||
id: build-dockerhub
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Cache Arduino ESP8266
|
||||
description: >
|
||||
Resolve the pinned Arduino core and xtensa toolchain versions and cache the
|
||||
native ESP8266 install (~110 MB framework + toolchain; no ccache store, the
|
||||
seed job saves before any compile runs). Exports
|
||||
ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into
|
||||
the cached path; the Python venv must already be restored. Mirrors
|
||||
cache-esp-idf: only dev-branch pushes write the shared cache, everything
|
||||
else restores.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve the native toolchain cache key
|
||||
# Versions are pinned in code, not a hashable file; resolve them so a
|
||||
# bump changes the cache key. Assignment form so errexit catches a
|
||||
# resolver failure.
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
# One owner for the install prefix: exported here and referenced by
|
||||
# the cache steps below via env, so the caller's install and the
|
||||
# cached path cannot diverge.
|
||||
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
|
||||
. venv/bin/activate
|
||||
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import FRAMEWORK_RELEASES, TOOLCHAIN_VERSION as t; print(f"{FRAMEWORK_RELEASES[f].tag}-{t}")')
|
||||
[ -n "$key" ] || exit 1
|
||||
echo "key=$key" >> "$GITHUB_OUTPUT"
|
||||
- name: Cache the native toolchain (write on dev)
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
||||
- name: Restore the native toolchain (off dev)
|
||||
if: github.ref != 'refs/heads/dev'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
||||
@@ -32,7 +32,7 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -53,7 +53,7 @@ module.exports = async ({ github, context }) => {
|
||||
});
|
||||
const currentLabels = currentLabelsData.map(label => label.name);
|
||||
const managedLabels = currentLabels.filter(label =>
|
||||
label.startsWith('component: ') || label.startsWith('platform: ') || MANAGED_LABELS.includes(label)
|
||||
label.startsWith('component: ') || MANAGED_LABELS.includes(label)
|
||||
);
|
||||
|
||||
// Check for mega-PR early - if present, skip most automatic labeling
|
||||
|
||||
@@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
|
||||
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
|
||||
|
||||
message +=
|
||||
`Hey @${prAuthor}, thanks for the contribution. This PR is on the ` +
|
||||
`large side `;
|
||||
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
|
||||
`this PR is on the large side `;
|
||||
|
||||
if (tooManyLabels && tooManyChanges) {
|
||||
message +=
|
||||
@@ -57,31 +57,16 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
|
||||
}
|
||||
|
||||
message += `, which makes it harder for maintainers to review.\n\n`;
|
||||
let condition;
|
||||
if (tooManyLabels && tooManyChanges) {
|
||||
condition =
|
||||
`is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` +
|
||||
`at most ${MAX_LABELS} components/areas`;
|
||||
} else if (tooManyLabels) {
|
||||
condition = `touches at most ${MAX_LABELS} components/areas`;
|
||||
} else {
|
||||
condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`;
|
||||
}
|
||||
message +=
|
||||
`**This PR will not be reviewed until it ${condition}.** We know ` +
|
||||
`that is not what you want to hear after this much work, and we want ` +
|
||||
`to be upfront so you do not wait for a review that is not coming: ` +
|
||||
`please split the change. The label is removed automatically once ` +
|
||||
`the PR ${condition}.\n\n`;
|
||||
`Smaller, focused PRs tend to be reviewed much faster since they ` +
|
||||
`fit into the short gaps between other maintainer work; large ones ` +
|
||||
`often have to wait for a rare long uninterrupted block of time. ` +
|
||||
`If you can break this up into smaller pieces that can be reviewed ` +
|
||||
`independently, it will almost certainly land faster overall.\n\n`;
|
||||
message +=
|
||||
`There is no exception you can request. Rarely, a maintainer agrees in ` +
|
||||
`\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` +
|
||||
`genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` +
|
||||
`but that is rare enough that you should plan on splitting rather than ` +
|
||||
`wait for it. Smaller, focused PRs are picked up much faster because ` +
|
||||
`they fit into the short gaps between other maintainer work. For a new ` +
|
||||
`component, the first PR brings the hub and a single platform (for ` +
|
||||
`example \`sensor\`); each further platform is its own PR on top of it.\n\n`;
|
||||
`Before putting more time in, it's also worth popping into ` +
|
||||
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
|
||||
`you scope things and flag anything already in flight.\n\n`;
|
||||
message +=
|
||||
`For more details (including how to split the work up), see: ` +
|
||||
`https://developers.esphome.io/contributing/submitting-your-work/` +
|
||||
@@ -229,7 +214,6 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateReviewMessages,
|
||||
handleReviews,
|
||||
handleMaintainerAccessComment
|
||||
};
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { generateReviewMessages } = require('../reviews');
|
||||
|
||||
const MAX_LABELS = 15;
|
||||
const TOO_BIG_THRESHOLD = 1000;
|
||||
|
||||
function tooBigMessage(labelCount, additions) {
|
||||
const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }];
|
||||
const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD);
|
||||
assert.equal(messages.length, 1);
|
||||
return messages[0];
|
||||
}
|
||||
|
||||
describe('too-big review message', () => {
|
||||
it('names the line cap when the PR is too long', () => {
|
||||
const message = tooBigMessage(3, 1500);
|
||||
assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./);
|
||||
assert.match(message, /removed automatically once the PR is at most 1000 lines/);
|
||||
assert.doesNotMatch(message, /components\/areas\./);
|
||||
});
|
||||
|
||||
it('names the area cap when the PR touches too many areas but is short', () => {
|
||||
const message = tooBigMessage(20, 200);
|
||||
assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./);
|
||||
assert.doesNotMatch(message, /until it is at most 1000 lines/);
|
||||
});
|
||||
|
||||
it('names both caps when both are exceeded', () => {
|
||||
const message = tooBigMessage(20, 1500);
|
||||
assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./);
|
||||
});
|
||||
|
||||
it('tells new-component authors to bring one platform first', () => {
|
||||
assert.match(tooBigMessage(3, 1500), /hub and a single platform/);
|
||||
assert.match(tooBigMessage(3, 1500), /mega-pr/);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
../.agents/skills
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
run: git diff
|
||||
- if: failure()
|
||||
name: Archive artifacts
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: generated-proto-files
|
||||
path: |
|
||||
|
||||
@@ -12,14 +12,15 @@ on:
|
||||
- ".github/workflows/ci-docker.yml"
|
||||
- "requirements*.txt"
|
||||
- "pyproject.toml"
|
||||
- "platformio.ini"
|
||||
- "esphome/idf_component.yml"
|
||||
- "script/platformio_install_deps.py"
|
||||
# Core, build pipeline, toolchain, and target-platform changes can change
|
||||
# how a toolchain is set up or built, so re-run the per-toolchain compile
|
||||
# smoke test when they change.
|
||||
- "esphome/core/**"
|
||||
- "esphome/writer.py"
|
||||
- "esphome/build_gen/**"
|
||||
- "esphome/build_helpers/**"
|
||||
- "esphome/espidf/**"
|
||||
- "esphome/platformio/**"
|
||||
- "esphome/components/bk72xx/**"
|
||||
@@ -66,7 +67,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Determine tag and whether to push
|
||||
id: tag
|
||||
@@ -128,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Upload compile-test image artifact
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# The tar is already compressed, so upload it as-is. archive: false
|
||||
# skips the redundant zip and makes the file name the artifact name
|
||||
@@ -158,7 +159,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
@@ -196,7 +197,6 @@ jobs:
|
||||
# the default.
|
||||
id:
|
||||
- esp8266-arduino
|
||||
- esp8266-arduino-native
|
||||
- esp32-arduino-platformio
|
||||
- esp32-arduino-esp-idf
|
||||
- esp32-idf-platformio
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: compile-test-image.tar.zst
|
||||
- name: Load image
|
||||
@@ -219,6 +219,5 @@ jobs:
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||
-e ESPHOME_LDGEN_STRICT=1 \
|
||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||
compile "${{ matrix.id }}.yaml"
|
||||
|
||||
+83
-268
@@ -49,7 +49,7 @@ jobs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -81,8 +81,8 @@ jobs:
|
||||
- name: Install apt packages (cached)
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
|
||||
determine-jobs:
|
||||
name: Determine which jobs to run
|
||||
@@ -102,8 +102,6 @@ jobs:
|
||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
|
||||
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
|
||||
esp8266-native: ${{ steps.determine.outputs.esp8266-native }}
|
||||
esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }}
|
||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||
@@ -167,8 +165,6 @@ jobs:
|
||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
|
||||
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
|
||||
echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT
|
||||
echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
@@ -187,33 +183,6 @@ jobs:
|
||||
path: .temp/components_graph.json
|
||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
||||
|
||||
seed-esp8266-native-cache:
|
||||
name: Seed the esp8266 native toolchain cache
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
# PR-branch cache saves are invisible to other PRs, so dev pushes seed
|
||||
# the shared entry the component matrix, the memory impact jobs and
|
||||
# test-esp8266-native restore. Only dev: the composite action saves
|
||||
# nowhere else, so a beta/release push would download the toolchain and
|
||||
# discard it.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Cache the native toolchain
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
- name: Install the native toolchain
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)"
|
||||
|
||||
ci-custom:
|
||||
name: Run script/ci-custom
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -286,7 +255,7 @@ jobs:
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
|
||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
||||
with:
|
||||
prek-version: ${{ steps.prek.outputs.version }}
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
@@ -299,7 +268,7 @@ jobs:
|
||||
# Pushes any fixes the hooks made back to the pull request. This step
|
||||
# must keep its default name: the GitHub App that performs the push
|
||||
# locates the workflow run by that name.
|
||||
- uses: pre-commit-ci/lite-action@062bca0919bc9d6e66755cc05074b70c77e111fc # v1.2.0
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
if: always()
|
||||
with:
|
||||
msg: apply automatic formatting fixes
|
||||
@@ -352,7 +321,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
- name: Save Python virtual environment cache
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
@@ -381,7 +350,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Report empty upload to Codecov
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
run_command: empty-upload
|
||||
force: true
|
||||
@@ -395,16 +364,19 @@ jobs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.integration-tests == 'true'
|
||||
# A hung test is failed by pytest's --timeout below; this bounds the rest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
|
||||
env:
|
||||
# Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds
|
||||
# download, shared per xdist worker by tests/integration/conftest.py
|
||||
INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components
|
||||
# What the cache steps persist; libdeps is excluded (keyed per xdist
|
||||
# worker and env, it never crosses runs).
|
||||
INTEGRATION_PIO_CACHE_PATH: |
|
||||
~/.esphome-integration-tests/platformio/platforms
|
||||
~/.esphome-integration-tests/platformio/packages
|
||||
~/.esphome-integration-tests/platformio/appstate.json
|
||||
~/.esphome-integration-tests/platformio/.cache
|
||||
~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -417,20 +389,21 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
- name: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Restore integration library cache
|
||||
# A miss or a changed pin self-heals with a download, so no restore-keys
|
||||
id: library-cache
|
||||
- name: Restore integration PlatformIO cache
|
||||
# Native platform + toolchain installed by shared_platformio_cache in
|
||||
# tests/integration/conftest.py; a miss self-heals, so no restore-keys.
|
||||
id: pio-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
||||
key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }}
|
||||
path: ${{ env.INTEGRATION_PIO_CACHE_PATH }}
|
||||
key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
@@ -440,7 +413,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -473,14 +446,13 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
|
||||
# A hung test fails after 30 min: the 15 min shared build lock wait plus a cold compile
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal --timeout=1800 \
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \
|
||||
--junitxml=junit-integration.xml "${test_files[@]}"
|
||||
- name: Upload test artifacts
|
||||
# Tests that compare rendered output write the image they actually got here, so a
|
||||
# failure can be looked at without reproducing the whole build locally.
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: integration-test-artifacts-${{ matrix.bucket.name }}
|
||||
path: test_artifacts/
|
||||
@@ -491,7 +463,7 @@ jobs:
|
||||
# script/update_integration_test_durations.py; only full matrix dev
|
||||
# runs produce usable data.
|
||||
if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: junit-integration-${{ strategy.job-index }}
|
||||
path: junit-integration.xml
|
||||
@@ -499,16 +471,16 @@ jobs:
|
||||
# A full cron period of margin for the weekly refresh
|
||||
retention-days: 14
|
||||
- name: Print ccache statistics
|
||||
# esphome stores the host build's ccache under the machine-global
|
||||
# cache dir (see get_build_env() in esphome/host/toolchain.py).
|
||||
run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s
|
||||
- name: Save integration library cache
|
||||
# esphome stores the PlatformIO ccache under the machine-global cache
|
||||
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
|
||||
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
|
||||
- name: Save integration PlatformIO cache
|
||||
# Bucket 0 only; the others would race the same immutable key.
|
||||
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true'
|
||||
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
||||
key: ${{ steps.library-cache.outputs.cache-primary-key }}
|
||||
path: ${{ env.INTEGRATION_PIO_CACHE_PATH }}
|
||||
key: ${{ steps.pio-cache.outputs.cache-primary-key }}
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
@@ -531,7 +503,7 @@ jobs:
|
||||
script/check_import_time.py --check --har importtime.har
|
||||
- name: Upload waterfall HAR
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: import-time-waterfall
|
||||
path: importtime.har
|
||||
@@ -629,7 +601,7 @@ jobs:
|
||||
apt-get install -y libc6-dbg
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2
|
||||
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -683,7 +655,7 @@ jobs:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP8266
|
||||
options: --environment esp8266-arduino-tidy --grep USE_ESP8266 --grep USE_SOCKET_IMPL_LWIP_TCP --grep USE_SHD_FIRMWARE_DATA
|
||||
options: --environment esp8266-arduino-tidy --grep USE_ESP8266
|
||||
pio_cache_key: tidyesp8266
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 Arduino
|
||||
@@ -696,16 +668,12 @@ jobs:
|
||||
ignore_errors: false
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for RP2
|
||||
options: --environment rp2-tidy --grep USE_RP2 --grep USE_BLE_SCAN_RESPONSE_MERGER
|
||||
options: --environment rp2-tidy --grep USE_RP2
|
||||
pio_cache_key: tidyrp2
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for host
|
||||
options: --environment host-tidy --grep USE_HOST
|
||||
pio_cache_key: tidyhost
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for LibreTiny
|
||||
environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy
|
||||
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X --grep USE_LN882H_BLE --grep USE_SOCKET_IMPL_LWIP_SOCKETS
|
||||
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X
|
||||
pio_cache_key: tidylibretiny
|
||||
|
||||
steps:
|
||||
@@ -714,16 +682,6 @@ jobs:
|
||||
with:
|
||||
# Need history for HEAD~1 to work for checking changed files
|
||||
fetch-depth: 2
|
||||
- name: Install apt packages (cached)
|
||||
if: matrix.pio_cache_key == 'tidyhost'
|
||||
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
|
||||
# by dsmr and dlms_meter. Packages and version must match seed-apt-cache
|
||||
# exactly.
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
@@ -910,20 +868,14 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 1/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 1
|
||||
name: Run script/clang-tidy for ESP32 IDF 1/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 1
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 2/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 2
|
||||
name: Run script/clang-tidy for ESP32 IDF 2/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 2
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 3/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 3
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 4/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 4
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 IDF 5/5
|
||||
options: --environment esp32-idf-tidy --split-num 5 --split-at 5
|
||||
name: Run script/clang-tidy for ESP32 IDF 3/3
|
||||
options: --environment esp32-idf-tidy --split-num 3 --split-at 3
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
@@ -1006,14 +958,14 @@ jobs:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 S3
|
||||
# yamllint disable-line rule:line-length
|
||||
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM --grep USE_OPENTHREAD
|
||||
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
|
||||
tidy_environment: esp32s3-idf-tidy
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 P4
|
||||
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
|
||||
# so their code paths differ -- lint them under the P4 build too.
|
||||
# yamllint disable-line rule:line-length
|
||||
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep CONFIG_IDF_TARGET_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
|
||||
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
|
||||
tidy_environment: esp32p4-idf-tidy
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 C6
|
||||
@@ -1119,8 +1071,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev libmbedtls-dev ccache
|
||||
version: 1.2
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -1145,11 +1097,6 @@ jobs:
|
||||
uses: ./.github/actions/cache-sdk-nrf
|
||||
with:
|
||||
restore-only: true
|
||||
- name: Cache the native ESP8266 toolchain
|
||||
# Only batches whose test platforms include esp8266; never saves
|
||||
# here, it reuses the install the dev seed job cached.
|
||||
if: matrix.batch.needs_arduino8266
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
- name: Validate and compile components with intelligent grouping
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -1248,28 +1195,9 @@ jobs:
|
||||
|
||||
if [ -n "$compile_csv" ]; then
|
||||
# Run compilation with grouping and isolation
|
||||
# The bootloader has no ESPHome code and these builds never
|
||||
# flash; the check_idf_py batch keeps the full build so the
|
||||
# native sub-build and the equivalence check stay covered.
|
||||
skip_flag="--skip-bootloader"
|
||||
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
|
||||
skip_flag=""
|
||||
fi
|
||||
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" $skip_flag
|
||||
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
|
||||
# The real idf.py must find nothing to configure or build in a
|
||||
# tree built above; catches drift on ESP-IDF bumps.
|
||||
echo "Checking the native ESP-IDF build matches idf.py"
|
||||
python3 script/check_idf_py_equivalence.py
|
||||
fi
|
||||
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
|
||||
else
|
||||
echo "All components in this batch are validate-only -- skipping compile stage."
|
||||
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
|
||||
# determine-jobs and this step disagree on what compiles; fail
|
||||
# rather than let the check run nowhere.
|
||||
echo "::error::This batch was picked for the idf.py check but compiled nothing"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Print ccache statistics
|
||||
@@ -1309,7 +1237,7 @@ jobs:
|
||||
|
||||
# compile validates config first, so a separate config pass is
|
||||
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
|
||||
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests
|
||||
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
echo ""
|
||||
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
|
||||
@@ -1318,125 +1246,6 @@ jobs:
|
||||
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
|
||||
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
test-esp8266-native:
|
||||
name: Test esp8266 components with the native toolchain
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
|
||||
env:
|
||||
# Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Cache the native toolchain
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
|
||||
- name: Run native toolchain compile test
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
echo "Testing components: $TEST_COMPONENTS"
|
||||
echo ""
|
||||
|
||||
# ESP8266 Arduino built directly (no PlatformIO); compile validates
|
||||
# config first, so a separate config pass is redundant.
|
||||
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
|
||||
|
||||
test-esp8266-native-windows:
|
||||
name: Compile esp8266 with the native toolchain on Windows (ccache ${{ matrix.ccache }})
|
||||
runs-on: windows-latest
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
|
||||
# A hung compiler must fail the job, not hold the runner for six hours.
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Both ways the build runs on Windows: ESPHome Desktop bundles
|
||||
# ccache and puts it on PATH, a pip install has none.
|
||||
ccache: ["1", "0"]
|
||||
env:
|
||||
ESPHOME_CCACHE_ENABLE: ${{ matrix.ccache }}
|
||||
# The test script prints characters the cp1252 console cannot encode
|
||||
PYTHONUTF8: "1"
|
||||
# The exact static build ESPHome Desktop ships
|
||||
CCACHE_URL: https://github.com/ccache/ccache/releases/download/v4.14/ccache-4.14-windows-x86_64.zip
|
||||
CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Install ccache
|
||||
if: matrix.ccache == '1'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$dir = Join-Path $env:RUNNER_TEMP "ccache"
|
||||
New-Item -ItemType Directory -Force $dir | Out-Null
|
||||
$zip = Join-Path $dir "ccache.zip"
|
||||
Invoke-WebRequest -Uri $env:CCACHE_URL -OutFile $zip
|
||||
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
||||
if ($hash -ne $env:CCACHE_SHA256) {
|
||||
Write-Host "::error::ccache archive sha256 $hash does not match $env:CCACHE_SHA256"
|
||||
exit 1
|
||||
}
|
||||
Expand-Archive -Path $zip -DestinationPath $dir
|
||||
$bin = Get-ChildItem -Path $dir -Recurse -Filter ccache.exe | Select-Object -First 1
|
||||
Add-Content -Path $env:GITHUB_PATH -Value $bin.DirectoryName
|
||||
& $bin.FullName --version
|
||||
|
||||
- name: Compile
|
||||
shell: pwsh
|
||||
# One component test: web_server brings wifi, api and the async web
|
||||
# server libraries, the shape of a typical ESP8266 device
|
||||
run: |
|
||||
. .\venv\Scripts\Activate.ps1
|
||||
$arguments = "script/test_build_components.py", "-e", "compile", "-t", "esp8266-ard", "-c", "web_server", "--base-only", "--toolchain", "arduino", "--fail-on-no-tests"
|
||||
$p = Start-Process -FilePath python -ArgumentList $arguments -NoNewWindow -PassThru
|
||||
# Reading the handle keeps ExitCode available after the process exits
|
||||
$null = $p.Handle
|
||||
if (-not $p.WaitForExit(25 * 60 * 1000)) {
|
||||
Write-Host "::error::The compile did not finish in 25 minutes; processes still running:"
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.CommandLine -match 'xtensa|ccache|ninja|esphome' } |
|
||||
Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine |
|
||||
Format-List | Out-String -Width 4096 | Write-Host
|
||||
Get-Process |
|
||||
Where-Object { $_.Name -match 'cc1|ccache|ninja|xtensa|python' } |
|
||||
Select-Object Id, Name, CPU, WorkingSet64 |
|
||||
Format-Table | Out-String | Write-Host
|
||||
taskkill /PID $p.Id /T /F
|
||||
exit 1
|
||||
}
|
||||
exit $p.ExitCode
|
||||
|
||||
- name: Show ccache statistics
|
||||
# Also after a failed or killed compile: the counters say whether
|
||||
# ccache was ever reached
|
||||
if: always() && matrix.ccache == '1'
|
||||
shell: pwsh
|
||||
# The build keeps its store under the esphome tools cache
|
||||
run: |
|
||||
$env:CCACHE_DIR = Join-Path $env:LOCALAPPDATA "esphome\Cache\arduino8266\ccache"
|
||||
ccache -s
|
||||
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1465,7 +1274,7 @@ jobs:
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -1524,27 +1333,31 @@ jobs:
|
||||
echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis"
|
||||
fi
|
||||
|
||||
# The build below uses --base-only, so skip unless the target branch has
|
||||
# test.<platform>.yaml for every requested component (test-*.yaml
|
||||
# variants alone compile nothing, and a partial set would compare a
|
||||
# smaller target build against the PR build). Checked here, not in
|
||||
# determine-jobs.py, because determine-jobs runs on the PR branch and
|
||||
# cannot see the target branch.
|
||||
# Check if test files exist on the target branch for the requested
|
||||
# components and platform. When a PR adds new test files for a platform,
|
||||
# the target branch won't have them yet, so skip instead of failing.
|
||||
# This check must be done here (not in determine-jobs.py) because
|
||||
# determine-jobs runs on the PR branch and cannot see what the target
|
||||
# branch has.
|
||||
- name: Check for test files on target branch
|
||||
id: check-tests
|
||||
if: steps.check-script.outputs.skip != 'true'
|
||||
run: |
|
||||
components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}'
|
||||
platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}"
|
||||
missing=""
|
||||
found=false
|
||||
for component in $(echo "$components" | jq -r '.[]'); do
|
||||
if [ ! -f "tests/components/${component}/test.${platform}.yaml" ]; then
|
||||
missing="${missing} ${component}"
|
||||
fi
|
||||
# Check for test files matching the platform (test.platform.yaml or test-*.platform.yaml)
|
||||
for f in tests/components/${component}/test*.${platform}.yaml; do
|
||||
if [ -f "$f" ]; then
|
||||
found=true
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
if [ "$found" = false ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "::warning::No test.${platform}.yaml on target branch for${missing}, skipping memory impact analysis"
|
||||
echo "::warning::No test files found on target branch for platform ${platform}, skipping memory impact analysis"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
@@ -1602,9 +1415,12 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Cache the native ESP8266 toolchain
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
- name: Cache platformio
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
|
||||
|
||||
- name: Build, compile, and analyze memory
|
||||
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
|
||||
@@ -1664,7 +1480,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload memory analysis JSON
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memory-analysis-target
|
||||
path: memory-analysis-target.json
|
||||
@@ -1689,9 +1505,11 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Cache the native ESP8266 toolchain
|
||||
if: fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
- name: Cache platformio
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.platformio
|
||||
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
|
||||
- name: Build, compile, and analyze memory
|
||||
id: extract
|
||||
run: |
|
||||
@@ -1727,7 +1545,7 @@ jobs:
|
||||
--platform "$platform"
|
||||
|
||||
- name: Upload memory analysis JSON
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memory-analysis-pr
|
||||
path: memory-analysis-pr.json
|
||||
@@ -1757,13 +1575,13 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Download target analysis JSON
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: memory-analysis-target
|
||||
path: ./memory-analysis
|
||||
continue-on-error: true
|
||||
- name: Download PR analysis JSON
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: memory-analysis-pr
|
||||
path: ./memory-analysis
|
||||
@@ -1790,7 +1608,6 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
- seed-apt-cache
|
||||
- seed-esp8266-native-cache
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
@@ -1806,8 +1623,6 @@ jobs:
|
||||
- clang-tidy-esp32-variants
|
||||
- test-build-components-split
|
||||
- test-esp32-platformio
|
||||
- test-esp8266-native
|
||||
- test-esp8266-native-windows
|
||||
- device-builder
|
||||
- memory-impact-target-branch
|
||||
- memory-impact-pr-branch
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -125,20 +125,14 @@ jobs:
|
||||
}
|
||||
|
||||
async function getEsphomeAndComponentChanges(github, owner, repo, prNumber) {
|
||||
const changedFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
}
|
||||
);
|
||||
const changedFiles = await github.rest.pulls.listFiles({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
// Files used only for development and CI, which do not affect use as an external component
|
||||
const ignoredFiles = ["esphome/core/defines.h", "esphome/idf_component.yml"];
|
||||
const esphomeChanges = changedFiles
|
||||
.filter(file => !ignoredFiles.includes(file.filename) && file.filename.startsWith('esphome/'))
|
||||
const esphomeChanges = changedFiles.data
|
||||
.filter(file => file.filename !== "esphome/core/defines.h" && file.filename.startsWith('esphome/'))
|
||||
.map(file => {
|
||||
const match = file.filename.match(/esphome\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
@@ -150,7 +144,7 @@ jobs:
|
||||
}
|
||||
|
||||
const uniqueEsphomeChanges = [...new Set(esphomeChanges)];
|
||||
const componentChanges = changedFiles
|
||||
const componentChanges = changedFiles.data
|
||||
.filter(file => file.filename.startsWith('esphome/components/'))
|
||||
.map(file => {
|
||||
const match = file.filename.match(/esphome\/components\/([^/]+)\//);
|
||||
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
permissions:
|
||||
issues: write # issues.lock on closed issues
|
||||
pull-requests: write # issues.lock on closed pull requests
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
# version: ${{ needs.init.outputs.tag }}
|
||||
|
||||
- name: Upload digests
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ matrix.platform.arch }}
|
||||
path: /tmp/digests
|
||||
@@ -195,14 +195,14 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: digests-*
|
||||
path: /tmp/digests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -16,18 +16,16 @@ jobs:
|
||||
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
|
||||
# GitHub App token so the labels, comments and closures come from
|
||||
# esphome[bot] instead of github-actions[bot].
|
||||
uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main
|
||||
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
|
||||
secrets:
|
||||
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
# Live only on dev: a workflow_dispatch from any other branch is a dry run
|
||||
dry-run: ${{ github.ref != 'refs/heads/dev' }}
|
||||
days-before-stale: 90
|
||||
days-before-stale-conflicting: 30
|
||||
days-before-close: 7
|
||||
stale-label: stale
|
||||
exempt-label: not-stale
|
||||
conflict-label: has-conflicts
|
||||
ignored-users: esphbot,codecov-commenter
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
@@ -35,21 +33,9 @@ jobs:
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
If you are the author of this PR, please leave a comment if you want
|
||||
to keep it open. Also, please merge the latest dev branch into your
|
||||
to keep it open. Also, please rebase your PR onto the latest dev
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
conflict-pr-message: >
|
||||
This pull request has merge conflicts. Please merge the latest dev
|
||||
branch into your branch to resolve them.
|
||||
stale-conflicting-pr-message: >
|
||||
The merge conflicts on this pull request have not been resolved for
|
||||
30 days, so it has been marked as stale and will be closed if they
|
||||
are not resolved within 7 days.
|
||||
|
||||
Please merge the latest dev branch into your branch. Once the pull
|
||||
request is mergeable again the stale label is removed.
|
||||
|
||||
Thank you for your contribution!
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check blocking labels
|
||||
|
||||
@@ -15,8 +15,6 @@ on:
|
||||
# base branch checkout, so fork code never runs with the token.
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- requirements_dev.txt
|
||||
- requirements_test.txt
|
||||
@@ -43,7 +41,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# setup-python interpreter so subsequent ``prek`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -10,7 +10,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.17.0
|
||||
rev: v0.16.6
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.4.1
|
||||
rev: 7.3.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
@@ -36,7 +36,7 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.22.0
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
|
||||
@@ -67,7 +67,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
- Function-local constants: `lower_snake_case`
|
||||
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
|
||||
- Favor descriptive names over abbreviations
|
||||
- Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix
|
||||
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
|
||||
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
|
||||
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
|
||||
@@ -144,9 +143,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
* **C++ Preprocessor Directives:**
|
||||
* **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums.
|
||||
* **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope
|
||||
and inside functions; `static const` is only for a value computed at run time. A function-local
|
||||
`static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too.
|
||||
* **Use `#define` only for:**
|
||||
- Conditional compilation (`#ifdef`, `#ifndef`)
|
||||
- Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`)
|
||||
@@ -181,15 +177,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use
|
||||
`set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms:
|
||||
https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb
|
||||
* **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm
|
||||
rebuilds the callback and re-queues a scheduler item, and the period drifts.
|
||||
* **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a
|
||||
`static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other
|
||||
components, so number them from 0 and keep all of a component's ids together in one place.
|
||||
```cpp
|
||||
static constexpr uint32_t READ_TIMEOUT_ID = 0;
|
||||
this->set_timeout(READ_TIMEOUT_ID, 50, [this]() { this->read_(); });
|
||||
```
|
||||
* **Don't override a default with the same value:** if a base class method already returns what you
|
||||
want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a
|
||||
component that wants `DATA` should simply leave it alone.
|
||||
@@ -197,45 +184,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
// Bad - this is exactly what the base class already does
|
||||
float get_setup_priority() const override { return setup_priority::DATA; }
|
||||
```
|
||||
* **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope
|
||||
constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the
|
||||
table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`);
|
||||
for a table of structs copy the whole entry out and return the copy, never a pointer into flash.
|
||||
On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with
|
||||
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
|
||||
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
|
||||
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
|
||||
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
|
||||
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
|
||||
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
|
||||
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`);
|
||||
pick the helper's polynomial and bit-order arguments instead of writing a CRC loop.
|
||||
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
|
||||
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
|
||||
end of `setup()` when the entities need a state at boot.
|
||||
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
|
||||
resend and give-up logic handles it; never advance on an answer that was only logged.
|
||||
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
|
||||
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
|
||||
listener base (a second vptr per entity).
|
||||
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
|
||||
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
|
||||
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
|
||||
(`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check
|
||||
stays inside the `#ifdef`.
|
||||
* **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure
|
||||
sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`.
|
||||
* **`dump_config()` already prints the failure reason:** for a failed component, core prints
|
||||
`<component> is marked FAILED: <reason>` right after the component's `dump_config()`, using the reason
|
||||
passed to `mark_failed()`. So `dump_config()` never checks `is_failed()` to log a failure, and no
|
||||
`error_code_` member exists only to pick that message. Pass the reason where setup fails instead:
|
||||
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))` for a failed bus transfer, a short specific reason
|
||||
otherwise (`LOG_STR("Wrong chip ID")`). Core also logs it at that moment, so no `ESP_LOGE` repeats it
|
||||
before `mark_failed()`; keep a separate line only when it prints a value read at run time, such as the
|
||||
chip ID that did not match. Only the first reason is kept, so do not call `status_set_error()` before
|
||||
`mark_failed()` and do not mark the component failed again after a helper already did.
|
||||
* **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a
|
||||
runtime check of something the build already knows.
|
||||
* **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they
|
||||
can be stored in flash rather than RAM.
|
||||
```cpp
|
||||
@@ -282,13 +230,8 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
```
|
||||
|
||||
* **Component Metadata:**
|
||||
- `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain
|
||||
string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g.
|
||||
`from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other
|
||||
metadata constants. CI (`lint_component_domain`) fails without it.
|
||||
- `DEPENDENCIES`: List of required components
|
||||
- `AUTO_LOAD`: Components to automatically load. Not a way to put entities under the component's
|
||||
own key; see "Entities of a hub" below.
|
||||
- `AUTO_LOAD`: Components to automatically load
|
||||
- `CONFLICTS_WITH`: Incompatible components
|
||||
- `CODEOWNERS`: GitHub usernames responsible for maintenance
|
||||
- `MULTI_CONF`: Whether multiple instances are allowed
|
||||
@@ -300,7 +243,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_KEY, CONF_ID
|
||||
|
||||
DOMAIN = "my_component"
|
||||
CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py
|
||||
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
@@ -380,55 +322,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
|
||||
- **Entities of a hub:** a new component's entities go in platform files (`sensor.py`,
|
||||
`binary_sensor.py`, ...) that take the hub's id, never under the hub's own key with their platforms
|
||||
added to `AUTO_LOAD` to make that work. Auto loading a platform because the C++ uses its type is
|
||||
fine. See https://developers.esphome.io/architecture/components/#exposing-multiple-entities.
|
||||
```yaml
|
||||
# Bad - entities under the hub's key
|
||||
my_hub:
|
||||
connected:
|
||||
name: Connected
|
||||
|
||||
# Good - the hub, then one platform entry that holds its entities
|
||||
my_hub:
|
||||
id: hub1
|
||||
binary_sensor:
|
||||
- platform: my_hub
|
||||
my_hub_id: hub1 # optional with a single hub
|
||||
connected:
|
||||
name: Connected
|
||||
```
|
||||
|
||||
- **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or
|
||||
`sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`,
|
||||
`sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented<T>`,
|
||||
then make one call per key, even when there is only one. A call creates the entity only when its key
|
||||
is configured, passes it to the setter and returns it (or `None`); extra arguments such as
|
||||
`min_value` or `options` go on the call. Always name the setter explicitly on the object that owns
|
||||
it, never with `getattr` and an f-string, and keep that variable short (`var` for the component
|
||||
itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose
|
||||
setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are.
|
||||
```python
|
||||
# my_hub/sensor.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
|
||||
|
||||
|
||||
# my_hub/button.py
|
||||
async def to_code(config):
|
||||
hub = await cg.get_variable(config[CONF_MY_HUB_ID])
|
||||
buttons = button.sub_buttons(config, parent=hub)
|
||||
await buttons(CONF_RESTART, hub.set_restart_button)
|
||||
```
|
||||
|
||||
- **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])`
|
||||
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
|
||||
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`.
|
||||
|
||||
* **Automations (Triggers, Actions, Conditions):**
|
||||
|
||||
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true).
|
||||
@@ -472,25 +365,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
)
|
||||
```
|
||||
|
||||
When the callback's parameters differ from the automation's arguments, or the trigger should only
|
||||
fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less
|
||||
lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build
|
||||
`forward` from `MockObj` calls, not f-strings of C++:
|
||||
```python
|
||||
parent = automation.parent_ref(var)
|
||||
index = cg.RawExpression("index")
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_state_callback",
|
||||
[(cg.StringRef, "x"), (cg.size_t, "i")],
|
||||
conf,
|
||||
params=[(cg.size_t, "index")],
|
||||
forward=[cg.StringRef(parent.option_at(index)), index],
|
||||
)
|
||||
```
|
||||
`build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and
|
||||
`build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`.
|
||||
|
||||
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
|
||||
```cpp
|
||||
class MyComponent : public Component {
|
||||
@@ -557,31 +431,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
MyComponent *parent_;
|
||||
};
|
||||
```
|
||||
Register it without writing a builder:
|
||||
```python
|
||||
automation.register_simple_action(
|
||||
"my_component.do_something", MyAction, schema, synchronous=True
|
||||
)
|
||||
```
|
||||
The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a
|
||||
no-argument constructor, `register_parented_action` for a class deriving from `Parented<T>`, and
|
||||
the `@automation.register_action(...)` decorator only when the builder must also set fields.
|
||||
|
||||
Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use.
|
||||
|
||||
**Actions that only forward templatable values to their parent need no C++ class.** Register them
|
||||
with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape.
|
||||
```python
|
||||
automation.register_apply_action(
|
||||
"my_component.set_gains",
|
||||
schema,
|
||||
automation.ApplyField(CONF_KP, "set_kp", cg.float_),
|
||||
automation.ApplyField(CONF_KI, "set_ki", cg.float_),
|
||||
)
|
||||
```
|
||||
The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover
|
||||
the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with
|
||||
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
|
||||
Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use.
|
||||
|
||||
* **Conditions:**
|
||||
```cpp
|
||||
@@ -593,29 +443,11 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
MyComponent *parent_;
|
||||
};
|
||||
```
|
||||
Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`;
|
||||
`register_bare_condition`, `register_parented_condition` and the decorator follow the action rules.
|
||||
|
||||
**Conditions that only test their parent need no C++ class either.** Register them with
|
||||
`register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares
|
||||
against config values.
|
||||
```python
|
||||
automation.register_apply_condition("my_component.is_active", schema, "is_active()")
|
||||
automation.register_apply_condition(
|
||||
"my_component.state_is",
|
||||
schema,
|
||||
automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)),
|
||||
)
|
||||
```
|
||||
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
|
||||
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
|
||||
|
||||
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
|
||||
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
|
||||
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
|
||||
that rejects keys and fills defaults afterwards.
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
@@ -649,18 +481,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
* **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
|
||||
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
|
||||
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
|
||||
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
|
||||
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its
|
||||
`responses` match on content every time they see it; to answer a repeated request differently, use an
|
||||
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
|
||||
on. A variant per board for platform-identical code only adds CI time.
|
||||
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
|
||||
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
|
||||
```
|
||||
tests/
|
||||
@@ -707,7 +527,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
name: My Sensor
|
||||
```
|
||||
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
|
||||
`!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead.
|
||||
|
||||
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
|
||||
```bash
|
||||
@@ -810,9 +629,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
_request_listener_slot()
|
||||
cg.add(hub.register_listener(var))
|
||||
```
|
||||
When several instances each own a list declared at the same size (one per hub of a
|
||||
`MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`;
|
||||
the define is then the largest count any one key requested instead of the total.
|
||||
```cpp
|
||||
#ifdef MY_COMPONENT_LISTENER_COUNT
|
||||
void register_listener(MyComponentListener *listener);
|
||||
@@ -880,9 +696,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices.
|
||||
|
||||
7. **Never use `new (std::nothrow)`:** On ESP-IDF exceptions are disabled, so a failed nothrow allocation aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects `std::nothrow`.
|
||||
|
||||
8. **Detection:** Look for these patterns in compiler output:
|
||||
7. **Detection:** Look for these patterns in compiler output:
|
||||
- Large code sections with STL symbols (vector, map, set)
|
||||
- `alloc`, `realloc`, `dealloc` in symbol names
|
||||
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
|
||||
|
||||
+5
-101
@@ -30,9 +30,6 @@ esphome/components/airthings_ble/* @jeromelaban
|
||||
esphome/components/airthings_wave_base/* @jeromelaban @ncareau
|
||||
esphome/components/airthings_wave_mini/* @ncareau
|
||||
esphome/components/airthings_wave_plus/* @jeromelaban @precurse
|
||||
esphome/components/airton/* @Procsiab
|
||||
esphome/components/airton/select/* @Procsiab
|
||||
esphome/components/airton/switch/* @Procsiab
|
||||
esphome/components/alarm_control_panel/* @grahambrown11 @hwstar
|
||||
esphome/components/alpha3/* @jan-hofmeier
|
||||
esphome/components/am2315c/* @swoboda1337
|
||||
@@ -42,17 +39,12 @@ esphome/components/am43/sensor/* @buxtronix
|
||||
esphome/components/analog_threshold/* @ianchi
|
||||
esphome/components/animation/* @syndlex
|
||||
esphome/components/anova/* @buxtronix
|
||||
esphome/components/apc1/* @ademuri
|
||||
esphome/components/apc_proteous/* @clydebarrow
|
||||
esphome/components/apds9306/* @aodrenah
|
||||
esphome/components/apds9930/* @iyesin
|
||||
esphome/components/api/* @esphome/core
|
||||
esphome/components/aqi/* @freekode @jasstrong @ximex
|
||||
esphome/components/aranet/* @patrickod
|
||||
esphome/components/as5600/* @ammmze
|
||||
esphome/components/as5600/sensor/* @ammmze
|
||||
esphome/components/as7341/* @mrgnr
|
||||
esphome/components/as734x/* @latonita @mrgnr
|
||||
esphome/components/async_tcp/* @esphome/core
|
||||
esphome/components/at581x/* @X-Ryl669
|
||||
esphome/components/atc_mithermometer/* @ahpohl
|
||||
@@ -73,7 +65,6 @@ esphome/components/bedjet/climate/* @jhansche
|
||||
esphome/components/bedjet/fan/* @jhansche
|
||||
esphome/components/bedjet/sensor/* @javawizard @jhansche
|
||||
esphome/components/beken_spi_led_strip/* @Mat931
|
||||
esphome/components/bh1745/* @latonita
|
||||
esphome/components/bh1750/* @OttoWinter
|
||||
esphome/components/bh1900nux/* @B48D81EFCC
|
||||
esphome/components/binary_sensor/* @esphome/core
|
||||
@@ -109,10 +100,6 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
|
||||
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
|
||||
esphome/components/bp1658cj/* @Cossid
|
||||
esphome/components/bp5758d/* @Cossid
|
||||
esphome/components/bq25186/* @Rapsssito
|
||||
esphome/components/bq27220/* @rggammon
|
||||
esphome/components/bq76972/* @limpkin
|
||||
esphome/components/bridge/* @kbx81
|
||||
esphome/components/bthome_mithermometer/* @nagyrobi
|
||||
esphome/components/button/* @esphome/core
|
||||
esphome/components/bytebuffer/* @clydebarrow
|
||||
@@ -124,8 +111,6 @@ esphome/components/captive_portal/* @esphome/core
|
||||
esphome/components/cc1101/* @gabest11 @lygris
|
||||
esphome/components/ccs811/* @habbie
|
||||
esphome/components/cd74hc4067/* @asoehlke
|
||||
esphome/components/cdc_acm_uart/* @kbx81
|
||||
esphome/components/cdc_acm_uart/bridge/* @kbx81
|
||||
esphome/components/ch422g/* @clydebarrow @jesterret
|
||||
esphome/components/ch423/* @dwmw2
|
||||
esphome/components/chsc6x/* @kkosik20
|
||||
@@ -137,7 +122,6 @@ esphome/components/combination/* @Cat-Ion @kahrendt
|
||||
esphome/components/const/* @esphome/core
|
||||
esphome/components/coolix/* @glmnet
|
||||
esphome/components/copy/* @OttoWinter
|
||||
esphome/components/counter/* @clydebarrow
|
||||
esphome/components/cover/* @esphome/core
|
||||
esphome/components/cs5460a/* @balrog-kun
|
||||
esphome/components/cse7761/* @berfenger
|
||||
@@ -160,10 +144,6 @@ esphome/components/delonghi/* @grob6000
|
||||
esphome/components/dew_point/* @CFlix
|
||||
esphome/components/dfplayer/* @glmnet
|
||||
esphome/components/dfrobot_sen0395/* @niklasweber
|
||||
esphome/components/dfrobot_sen0465_base/* @tzabbi
|
||||
esphome/components/dfrobot_sen0465_i2c/* @tzabbi
|
||||
esphome/components/dfrobot_sen0465_uart/* @tzabbi
|
||||
esphome/components/dfrobot_sen0609/* @remcom
|
||||
esphome/components/dht/* @OttoWinter
|
||||
esphome/components/display_menu_base/* @numo68
|
||||
esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
|
||||
@@ -172,10 +152,8 @@ esphome/components/ds1307/* @badbadc0ffee
|
||||
esphome/components/ds1603l/* @JakeLC15
|
||||
esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/ds3231/* @linkedupbits
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ecocomfort2/* @gledian
|
||||
esphome/components/ee895/* @Stock-M
|
||||
esphome/components/ektf2232/touchscreen/* @jesserockz
|
||||
esphome/components/emc2101/* @ellull
|
||||
@@ -192,7 +170,6 @@ esphome/components/es7243e/* @kbx81
|
||||
esphome/components/es8156/* @kbx81
|
||||
esphome/components/es8311/* @kahrendt @kroimon
|
||||
esphome/components/es8388/* @P4uLT
|
||||
esphome/components/es9038q2m_katana/* @GioMez
|
||||
esphome/components/esp32/* @esphome/core
|
||||
esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito
|
||||
esphome/components/esp32_ble_client/* @bdraco @jesserockz
|
||||
@@ -202,34 +179,27 @@ esphome/components/esp32_camera_web_server/* @ayufan
|
||||
esphome/components/esp32_can/* @Sympatron
|
||||
esphome/components/esp32_hosted/* @swoboda1337
|
||||
esphome/components/esp32_hosted/update/* @swoboda1337
|
||||
esphome/components/esp32_power_management/* @rwrozelle
|
||||
esphome/components/esp32_improv/* @jesserockz
|
||||
esphome/components/esp32_rmt/* @jesserockz
|
||||
esphome/components/esp32_rmt_led_strip/* @jesserockz
|
||||
esphome/components/esp8266/* @esphome/core
|
||||
esphome/components/esp_ldo/* @clydebarrow
|
||||
esphome/components/espectre/* @francescopace
|
||||
esphome/components/esphome/ota/* @esphome/core
|
||||
esphome/components/espnow/* @jesserockz
|
||||
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
|
||||
esphome/components/ethernet_info/* @gtjadsonsantos
|
||||
esphome/components/event/* @nohat
|
||||
esphome/components/exponential_moving_average/* @clydebarrow
|
||||
esphome/components/exposure_notifications/* @OttoWinter
|
||||
esphome/components/ezo/* @ssieb
|
||||
esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
esphome/components/ezsp_proxy_tap/* @kbx81 @puddly
|
||||
esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
esphome/components/file/* @esphome/core
|
||||
esphome/components/filter_lifetime/* @tuct
|
||||
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
|
||||
esphome/components/font/* @clydebarrow @esphome/core
|
||||
esphome/components/friedrich/* @rwrozelle
|
||||
esphome/components/fs3000/* @kahrendt
|
||||
esphome/components/ft5x06/* @clydebarrow
|
||||
esphome/components/ft63x6/* @gpambrozio
|
||||
esphome/components/fusb302b/* @remcom
|
||||
esphome/components/gcja5/* @gcormier
|
||||
esphome/components/gdk101/* @Szewcson
|
||||
esphome/components/gl_r01_i2c/* @pkejval
|
||||
@@ -251,14 +221,9 @@ esphome/components/gt911/* @clydebarrow @jesserockz
|
||||
esphome/components/haier/* @paveldn
|
||||
esphome/components/haier/binary_sensor/* @paveldn
|
||||
esphome/components/haier/button/* @paveldn
|
||||
esphome/components/haier/select/* @paveldn
|
||||
esphome/components/haier/sensor/* @paveldn
|
||||
esphome/components/haier/switch/* @paveldn
|
||||
esphome/components/haier/text_sensor/* @paveldn
|
||||
esphome/components/haier_tundra/* @fauxpark
|
||||
esphome/components/haier_tundra/button/* @fauxpark
|
||||
esphome/components/haier_tundra/switch/* @fauxpark
|
||||
esphome/components/haier_water_heater/* @TheLexus
|
||||
esphome/components/havells_solar/* @sourabhjaiswal
|
||||
esphome/components/hbridge/fan/* @WeekendWarrior
|
||||
esphome/components/hbridge/light/* @DotNetDann
|
||||
@@ -267,7 +232,6 @@ esphome/components/hc8/* @omartijn
|
||||
esphome/components/hdc2010/* @optimusprimespace @ssieb
|
||||
esphome/components/hdc2080/* @G-Pereira @jesserockz
|
||||
esphome/components/hdc302x/* @joshuasing
|
||||
esphome/components/hdmi_cec/* @JosVanEijndhoven
|
||||
esphome/components/he60r/* @clydebarrow
|
||||
esphome/components/heatpumpir/* @rob-deutsch
|
||||
esphome/components/hitachi_ac424/* @sourabhjaiswal
|
||||
@@ -278,12 +242,8 @@ esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/button/* @jesserockz
|
||||
esphome/components/homeassistant/light/* @egormanga
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/select/* @jesserockz
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
esphome/components/homeassistant/text/* @jesserockz
|
||||
esphome/components/honeywell_hih_i2c/* @Benichou34
|
||||
esphome/components/honeywellabp/* @RubyBailey
|
||||
esphome/components/honeywellabp2_i2c/* @jpfaff
|
||||
@@ -295,7 +255,6 @@ esphome/components/http_request/ota/* @oarcher
|
||||
esphome/components/http_request/update/* @jesserockz
|
||||
esphome/components/htu31d/* @betterengineering
|
||||
esphome/components/hub75/* @stuartparmenter
|
||||
esphome/components/husb238/* @latonita
|
||||
esphome/components/hydreon_rgxx/* @functionpointer
|
||||
esphome/components/hyt271/* @Philippe12
|
||||
esphome/components/i2c/* @esphome/core
|
||||
@@ -303,13 +262,9 @@ esphome/components/i2c_device/* @gabest11
|
||||
esphome/components/i2s_audio/* @jesserockz
|
||||
esphome/components/i2s_audio/microphone/* @jesserockz
|
||||
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
|
||||
esphome/components/i2s_clockless_led_strip/* @j9brown
|
||||
esphome/components/iaqcore/* @yozik04
|
||||
esphome/components/icm20689/* @lboue
|
||||
esphome/components/icnt86/* @danepowell
|
||||
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
|
||||
esphome/components/improv_base/* @esphome/core
|
||||
esphome/components/improv_ble/* @jesserockz
|
||||
esphome/components/improv_serial/* @esphome/core
|
||||
esphome/components/ina226/* @latonita @Sergio303
|
||||
esphome/components/ina260/* @mreditor97
|
||||
@@ -322,7 +277,6 @@ esphome/components/inkplate/* @jesserockz @JosipKuci
|
||||
esphome/components/integration/* @OttoWinter
|
||||
esphome/components/internal_temperature/* @Mat931
|
||||
esphome/components/interval/* @esphome/core
|
||||
esphome/components/ir_rf_base/* @bdraco @kbx81
|
||||
esphome/components/ir_rf_proxy/* @kbx81
|
||||
esphome/components/it8951/* @koosoli @limengdu @Passific
|
||||
esphome/components/jsn_sr04t/* @Mafus1
|
||||
@@ -334,11 +288,9 @@ esphome/components/kuntze/* @ssieb
|
||||
esphome/components/lc709203f/* @ilikecake
|
||||
esphome/components/lcd_menu/* @numo68
|
||||
esphome/components/ld2410/* @regevbr @sebcaps
|
||||
esphome/components/ld2410s/* @NovakIrs
|
||||
esphome/components/ld2412/* @Rihan9
|
||||
esphome/components/ld2420/* @descipher
|
||||
esphome/components/ld2450/* @hareeshmu
|
||||
esphome/components/ld2460/* @jesusvallejo
|
||||
esphome/components/ld24xx/* @kbx81
|
||||
esphome/components/ld6002b/* @hepter
|
||||
esphome/components/ledc/* @OttoWinter
|
||||
@@ -347,10 +299,6 @@ esphome/components/libretiny_pwm/* @kuba2k2
|
||||
esphome/components/light/* @esphome/core
|
||||
esphome/components/lightwaverf/* @max246
|
||||
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
|
||||
esphome/components/lis2dw12/* @latonita
|
||||
esphome/components/lis2dw12_i2c/* @latonita
|
||||
esphome/components/lis2dw12_spi/* @latonita
|
||||
esphome/components/lis3dh/* @zebble
|
||||
esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
|
||||
@@ -358,7 +306,6 @@ esphome/components/ln882x/* @lamauny
|
||||
esphome/components/lock/* @esphome/core
|
||||
esphome/components/logger/* @esphome/core
|
||||
esphome/components/logger/select/* @clydebarrow
|
||||
esphome/components/lp5562/* @DennisGaida
|
||||
esphome/components/lps22/* @nagisa
|
||||
esphome/components/lsm6ds/* @clydebarrow
|
||||
esphome/components/ltr390/* @latonita @sjtrny
|
||||
@@ -369,9 +316,7 @@ esphome/components/m5stack_8angle/* @rnauber
|
||||
esphome/components/mapping/* @clydebarrow
|
||||
esphome/components/matrix_keypad/* @ssieb
|
||||
esphome/components/max17043/* @blacknell
|
||||
esphome/components/max17048/* @B48D81EFCC
|
||||
esphome/components/max31865/* @DAVe3283
|
||||
esphome/components/max31888/* @JiriPrchal
|
||||
esphome/components/max44009/* @berfenger
|
||||
esphome/components/max6956/* @looping40
|
||||
esphome/components/max7219digit/* @rspaargaren
|
||||
@@ -390,6 +335,7 @@ esphome/components/mcp4461/* @p1ngb4ck
|
||||
esphome/components/mcp4728/* @berfenger
|
||||
esphome/components/mcp47a1/* @jesserockz
|
||||
esphome/components/mcp9600/* @mreditor97
|
||||
esphome/components/mcp9808/* @k7hpn
|
||||
esphome/components/md5/* @esphome/core
|
||||
esphome/components/mdns/* @esphome/core
|
||||
esphome/components/media_player/* @jesserockz
|
||||
@@ -398,14 +344,11 @@ esphome/components/micro_wake_word/* @jesserockz @kahrendt
|
||||
esphome/components/micronova/* @edenhaus @jorre05
|
||||
esphome/components/microphone/* @jesserockz @kahrendt
|
||||
esphome/components/mics_4514/* @jesserockz
|
||||
esphome/components/mics_5524/* @nliaudat
|
||||
esphome/components/midea/* @dudanov
|
||||
esphome/components/midea_ir/* @dudanov
|
||||
esphome/components/midea_portasplit/* @Fexiven
|
||||
esphome/components/mipi_dsi/* @clydebarrow
|
||||
esphome/components/mipi_rgb/* @clydebarrow
|
||||
esphome/components/mipi_spi/* @clydebarrow
|
||||
esphome/components/mistral_ir/* @lerhum
|
||||
esphome/components/mitsubishi/* @RubyBailey
|
||||
esphome/components/mitsubishi_cn105/* @crnjan
|
||||
esphome/components/mixer/speaker/* @kahrendt
|
||||
@@ -432,9 +375,6 @@ esphome/components/mpl3115a2/* @kbickar
|
||||
esphome/components/mpu6886/* @fabaff
|
||||
esphome/components/ms8607/* @e28eta
|
||||
esphome/components/msa3xx/* @latonita
|
||||
esphome/components/mt6701/* @slimcdk
|
||||
esphome/components/mt6701_i2c/* @slimcdk
|
||||
esphome/components/mt6701_spi/* @slimcdk
|
||||
esphome/components/nau7802/* @cujomalainey
|
||||
esphome/components/network/* @esphome/core
|
||||
esphome/components/nextion/* @edwardtfn @senexcrenshaw
|
||||
@@ -447,8 +387,6 @@ esphome/components/noblex/* @AGalfra
|
||||
esphome/components/noise/* @esphome/core
|
||||
esphome/components/npi19/* @bakerkj
|
||||
esphome/components/nrf52/* @tomaszduda23
|
||||
esphome/components/ns2009/* @tchilov
|
||||
esphome/components/ns2009/touchscreen/* @tchilov
|
||||
esphome/components/number/* @esphome/core
|
||||
esphome/components/one_wire/* @ssieb
|
||||
esphome/components/online_image/* @clydebarrow @guillempages
|
||||
@@ -459,12 +397,10 @@ esphome/components/ota/* @esphome/core
|
||||
esphome/components/output/* @esphome/core
|
||||
esphome/components/packet_transport/* @clydebarrow
|
||||
esphome/components/pca6416a/* @Mat931
|
||||
esphome/components/pca9505/* @ajfriesen @ssieb
|
||||
esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
|
||||
esphome/components/pcf85063/* @brogon
|
||||
esphome/components/pcf8563/* @KoenBreeman
|
||||
esphome/components/pcm5122/* @remcom
|
||||
esphome/components/pd_pioneer_ir/* @martinsah
|
||||
esphome/components/pi4ioe5v6408/* @jesserockz
|
||||
esphome/components/pid/* @OttoWinter
|
||||
esphome/components/pipsolar/* @andreashergert1984
|
||||
@@ -482,8 +418,6 @@ esphome/components/pn7150_i2c/* @jesserockz @kbx81
|
||||
esphome/components/pn7160/* @jesserockz @kbx81
|
||||
esphome/components/pn7160_i2c/* @jesserockz @kbx81
|
||||
esphome/components/pn7160_spi/* @jesserockz @kbx81
|
||||
esphome/components/pn71xx/* @jesserockz @kbx81
|
||||
esphome/components/power_management/* @rwrozelle
|
||||
esphome/components/power_supply/* @esphome/core
|
||||
esphome/components/preferences/* @esphome/core
|
||||
esphome/components/provisioning/* @esphome/core
|
||||
@@ -491,7 +425,6 @@ esphome/components/psram/* @esphome/core
|
||||
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
|
||||
esphome/components/pvvx_mithermometer/* @pasiz
|
||||
esphome/components/pylontech/* @functionpointer
|
||||
esphome/components/pzem6l24/* @nuttytree
|
||||
esphome/components/qmi8658/* @clydebarrow
|
||||
esphome/components/qmp6988/* @andrewpc
|
||||
esphome/components/qr_code/* @wjtje
|
||||
@@ -504,8 +437,6 @@ esphome/components/rc522/* @glmnet
|
||||
esphome/components/rc522_i2c/* @glmnet
|
||||
esphome/components/rc522_spi/* @glmnet
|
||||
esphome/components/rd03d/* @jasstrong
|
||||
esphome/components/remko_ar715/* @naKruul
|
||||
esphome/components/renogy_inverter_ble/* @emilioaray-dev
|
||||
esphome/components/resampler/speaker/* @kahrendt
|
||||
esphome/components/restart/* @esphome/core
|
||||
esphome/components/rf_bridge/* @jesserockz
|
||||
@@ -519,15 +450,11 @@ esphome/components/rp2040_pwm/* @jesserockz
|
||||
esphome/components/rp2_ble_tracker/* @bdraco
|
||||
esphome/components/rpi_dpi_rgb/* @clydebarrow
|
||||
esphome/components/rtl87xx/* @kuba2k2
|
||||
esphome/components/rtsp_server/* @max246
|
||||
esphome/components/rtttl/* @glmnet @ximex
|
||||
esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt
|
||||
esphome/components/runtime_stats/* @bdraco
|
||||
esphome/components/rx8025t/* @remcom
|
||||
esphome/components/rx8111/* @techdregs
|
||||
esphome/components/rx8130/* @beormund
|
||||
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti
|
||||
esphome/components/samsung/* @jorofi
|
||||
esphome/components/scd4x/* @martgras @sjtrny
|
||||
esphome/components/script/* @esphome/core
|
||||
esphome/components/sdl/* @bdm310 @clydebarrow
|
||||
@@ -547,7 +474,6 @@ esphome/components/sendspin/image/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
esphome/components/sendspin/switch/* @kahrendt
|
||||
esphome/components/sendspin/text_sensor/* @kahrendt
|
||||
esphome/components/sensirion_common/* @martgras
|
||||
esphome/components/sensor/* @esphome/core
|
||||
@@ -556,14 +482,11 @@ esphome/components/sfa30/* @ghsensdev
|
||||
esphome/components/sfa40/* @NoQuarrel
|
||||
esphome/components/sgp40/* @SenexCrenshaw
|
||||
esphome/components/sgp4x/* @martgras @SenexCrenshaw
|
||||
esphome/components/sh1122_base/* @pl4nkton
|
||||
esphome/components/sh1122_spi/* @pl4nkton
|
||||
esphome/components/sha256/* @esphome/core
|
||||
esphome/components/shelly_dimmer/* @edge90 @rnauber
|
||||
esphome/components/sht3xd/* @mrtoy-me
|
||||
esphome/components/sht4x/* @sjtrny
|
||||
esphome/components/shutdown/* @esphome/core @jsuanet
|
||||
esphome/components/siemens_ira211/* @fonix232
|
||||
esphome/components/sigma_delta_output/* @Cat-Ion
|
||||
esphome/components/sim800l/* @glmnet
|
||||
esphome/components/sm10bit_base/* @Cossid
|
||||
@@ -580,7 +503,6 @@ esphome/components/sound_level/* @kahrendt
|
||||
esphome/components/spa06_base/* @danielkent-net
|
||||
esphome/components/spa06_i2c/* @danielkent-net
|
||||
esphome/components/spa06_spi/* @danielkent-net
|
||||
esphome/components/spd2010/* @grischard
|
||||
esphome/components/speaker/* @jesserockz @kahrendt
|
||||
esphome/components/speaker/media_player/* @kahrendt @synesthesiam
|
||||
esphome/components/speaker_source/* @kahrendt
|
||||
@@ -610,7 +532,6 @@ esphome/components/st7735/* @SenexCrenshaw
|
||||
esphome/components/st7789v/* @kbx81
|
||||
esphome/components/st7920/* @marsjan155
|
||||
esphome/components/statsd/* @Links2004
|
||||
esphome/components/stcc4/* @j9brown
|
||||
esphome/components/stts22h/* @B48D81EFCC
|
||||
esphome/components/substitutions/* @esphome/core
|
||||
esphome/components/sun/* @OttoWinter
|
||||
@@ -621,38 +542,29 @@ esphome/components/sx126x/* @swoboda1337
|
||||
esphome/components/sx127x/* @swoboda1337
|
||||
esphome/components/sy6970/* @linkedupbits
|
||||
esphome/components/syslog/* @clydebarrow
|
||||
esphome/components/systa_bus/* @Mat931
|
||||
esphome/components/t6615/* @tylermenezes
|
||||
esphome/components/tas2780/* @remcom
|
||||
esphome/components/tas58xx/* @mrtoy-me @remcom
|
||||
esphome/components/tc74/* @sethgirvan
|
||||
esphome/components/tca8418/* @zebble
|
||||
esphome/components/tca9548a/* @andreashergert1984
|
||||
esphome/components/tca9555/* @mobrembski
|
||||
esphome/components/tcl112/* @glmnet
|
||||
esphome/components/tcp_uart/* @Bascht74
|
||||
esphome/components/tee501/* @Stock-M
|
||||
esphome/components/teleinfo/* @0hax @FredM67
|
||||
esphome/components/teleinfo/* @0hax
|
||||
esphome/components/tem3200/* @bakerkj
|
||||
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
|
||||
esphome/components/template/datetime/* @rfdarter
|
||||
esphome/components/template/event/* @nohat
|
||||
esphome/components/template/fan/* @ssieb
|
||||
esphome/components/text/* @mauritskorse
|
||||
esphome/components/tfluna/* @candrews
|
||||
esphome/components/thermopro_ble/* @sittner
|
||||
esphome/components/thermostat/* @kbx81
|
||||
esphome/components/time/* @esphome/core
|
||||
esphome/components/tinyusb/* @kbx81
|
||||
esphome/components/tinyusb_keyboard/* @frederikbolding
|
||||
esphome/components/tlc5947/* @rnauber
|
||||
esphome/components/tlc5971/* @IJIJI
|
||||
esphome/components/tm1621/* @Philippe12
|
||||
esphome/components/tm1637/* @glmnet
|
||||
esphome/components/tm1638/* @skykingjwc
|
||||
esphome/components/tm1651/* @mrtoy-me
|
||||
esphome/components/tmc2209/* @remcom
|
||||
esphome/components/tmc22xx/* @remcom
|
||||
esphome/components/tmp102/* @timsavage
|
||||
esphome/components/tmp1075/* @sybrenstuvel
|
||||
esphome/components/tmp117/* @Azimath
|
||||
@@ -674,17 +586,13 @@ esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
esphome/components/udp/* @clydebarrow
|
||||
esphome/components/ufire_ec/* @pvizeli
|
||||
esphome/components/ufire_ise/* @pvizeli
|
||||
esphome/components/ufm01/* @ljungqvist
|
||||
esphome/components/ultrasonic/* @ssieb @swoboda1337
|
||||
esphome/components/unicode/* @esphome/core
|
||||
esphome/components/update/* @jesserockz
|
||||
esphome/components/uponor_smatrix/* @kroimon
|
||||
esphome/components/usb_audio/* @ab-tools
|
||||
esphome/components/usb_cdc_acm/* @kbx81
|
||||
esphome/components/usb_host/* @clydebarrow
|
||||
esphome/components/usb_uart/* @clydebarrow
|
||||
@@ -693,11 +601,10 @@ esphome/components/vbus/* @ssieb
|
||||
esphome/components/veml3235/* @kbx81
|
||||
esphome/components/veml7700/* @latonita
|
||||
esphome/components/version/* @esphome/core
|
||||
esphome/components/vevor_dtx/* @Bofr5555
|
||||
esphome/components/voice_assistant/* @jesserockz @kahrendt
|
||||
esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
|
||||
esphome/components/watchdog/* @oarcher
|
||||
esphome/components/water_heater/* @esphome/core
|
||||
esphome/components/water_heater/* @dhoeben
|
||||
esphome/components/waveshare_epaper/* @clydebarrow
|
||||
esphome/components/waveshare_io_ch32v003/* @latonita
|
||||
esphome/components/web_server/ota/* @esphome/core
|
||||
@@ -706,7 +613,7 @@ esphome/components/web_server_idf/* @dentra
|
||||
esphome/components/weikai/* @DrCoolZic
|
||||
esphome/components/weikai_i2c/* @DrCoolZic
|
||||
esphome/components/weikai_spi/* @DrCoolZic
|
||||
esphome/components/whirlpool/* @arpena @glmnet
|
||||
esphome/components/whirlpool/* @glmnet
|
||||
esphome/components/whynter/* @aeonsablaze
|
||||
esphome/components/wiegand/* @ssieb
|
||||
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
|
||||
@@ -723,11 +630,9 @@ esphome/components/wts01/* @alepee
|
||||
esphome/components/x9c/* @EtienneMD
|
||||
esphome/components/xdb401/* @RT530
|
||||
esphome/components/xgzp68xx/* @gcormier
|
||||
esphome/components/xiaomi_body_scale/* @dckiller51
|
||||
esphome/components/xiaomi_hhccjcy10/* @fariouche
|
||||
esphome/components/xiaomi_lywsd02mmc/* @juanluss31
|
||||
esphome/components/xiaomi_lywsd03mmc/* @ahpohl
|
||||
esphome/components/xiaomi_mccgq02hl/* @ahpohl @morph027
|
||||
esphome/components/xiaomi_mhoc303/* @drug123
|
||||
esphome/components/xiaomi_mhoc401/* @vevsvevs
|
||||
esphome/components/xiaomi_rtcgq02lm/* @jesserockz
|
||||
@@ -736,7 +641,6 @@ esphome/components/xl9535/* @mreditor97
|
||||
esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
|
||||
esphome/components/xxtea/* @clydebarrow
|
||||
esphome/components/zephyr/* @tomaszduda23
|
||||
esphome/components/zephyr_coredump/* @tomaszduda23
|
||||
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
|
||||
esphome/components/zephyr_pwm/* @wiomoc
|
||||
esphome/components/zhlt01/* @cfeenstra1024
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.11.0-dev
|
||||
PROJECT_NUMBER = 2026.10.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+4
-1
@@ -22,13 +22,16 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
&& platformio settings set check_platformio_interval 1000000 \
|
||||
&& mkdir -p /piolibs
|
||||
|
||||
COPY script/platformio_install_deps.py platformio.ini /
|
||||
RUN /platformio_install_deps.py /platformio.ini --libraries
|
||||
|
||||
ARG BUILD_VERSION
|
||||
|
||||
LABEL \
|
||||
|
||||
@@ -21,14 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
|
||||
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
|
||||
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
|
||||
|
||||
# Keep the native toolchain installs and compiler caches on the persistent
|
||||
# cache root, not the container's user cache dir: it is lost on every
|
||||
# restart, and not writable when the container runs as a non-root user.
|
||||
# Keep the native toolchain installs on the persistent cache root, not the
|
||||
# container's ephemeral user cache dir (re-downloaded on every restart).
|
||||
export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
|
||||
export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf"
|
||||
export ESPHOME_ARDUINO8266_PREFIX="$(dirname "${pio_cache_base}")/arduino8266"
|
||||
export ESPHOME_HOST_PREFIX="$(dirname "${pio_cache_base}")/host"
|
||||
export ESPHOME_PLATFORMIO_CCACHE_DIR="$(dirname "${pio_cache_base}")/platformio-ccache"
|
||||
|
||||
# If /build is mounted, use that as the build path
|
||||
# otherwise use path in /config (so that builds aren't lost on container restart)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# Home Assistant Add-on: ESPHome
|
||||
# Sends discovery information to Home Assistant.
|
||||
# ==============================================================================
|
||||
declare config
|
||||
declare port
|
||||
|
||||
# We only disable it when disabled explicitly
|
||||
@@ -18,17 +19,14 @@ port=$(bashio::addon.ingress_port)
|
||||
# Wait for the ESPHome Device Builder to become available
|
||||
bashio::net.wait_for "${port}" "127.0.0.1" 300
|
||||
|
||||
# Send one discovery message; the config is a JSON string built with bashio::var.json.
|
||||
send_discovery() {
|
||||
local service=$1
|
||||
local config=$2
|
||||
if bashio::discovery "${service}" "${config}" > /dev/null; then
|
||||
bashio::log.info "Successfully send ${service} discovery information to Home Assistant."
|
||||
else
|
||||
bashio::log.error "${service} discovery message to Home Assistant failed!"
|
||||
fi
|
||||
}
|
||||
config=$(\
|
||||
bashio::var.json \
|
||||
host "127.0.0.1" \
|
||||
port "^${port}" \
|
||||
)
|
||||
|
||||
send_discovery "esphome" "$(bashio::var.json host "127.0.0.1" port "^${port}")"
|
||||
# The Device Builder MCP server, consumed by Home Assistant's mcp integration.
|
||||
send_discovery "mcp" "$(bashio::var.json url "http://127.0.0.1:${port}/api/mcp")"
|
||||
if bashio::discovery "esphome" "${config}" > /dev/null; then
|
||||
bashio::log.info "Successfully send discovery information to Home Assistant."
|
||||
else
|
||||
bashio::log.error "Discovery message to Home Assistant failed!"
|
||||
fi
|
||||
|
||||
@@ -15,14 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
|
||||
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
|
||||
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
|
||||
|
||||
# Keep the native toolchain installs and compiler caches on the persistent
|
||||
# /data volume, not the container's ephemeral user cache dir (wiped on every
|
||||
# add-on update/restart).
|
||||
# Keep the native toolchain installs on the persistent /data volume, not the
|
||||
# container's ephemeral user cache dir (wiped on every add-on update/restart).
|
||||
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
|
||||
export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf
|
||||
export ESPHOME_ARDUINO8266_PREFIX=/data/cache/arduino8266
|
||||
export ESPHOME_HOST_PREFIX=/data/cache/host
|
||||
export ESPHOME_PLATFORMIO_CCACHE_DIR=/data/cache/platformio-ccache
|
||||
|
||||
if bashio::config.true 'leave_front_door_open'; then
|
||||
export DISABLE_HA_AUTHENTICATION=true
|
||||
|
||||
@@ -5,15 +5,3 @@ bk72xx:
|
||||
board: generic-bk7231n-qfn32-tuya
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
ap:
|
||||
|
||||
# mqtt and captive_portal together pull in AsyncTCP and ESPAsyncWebServer;
|
||||
# a stray ESP32 AsyncTCP copy on the library search path breaks this build
|
||||
captive_portal:
|
||||
|
||||
mqtt:
|
||||
broker: 192.168.178.84
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
esphome:
|
||||
name: docker-test-esp8266-native
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
toolchain: arduino
|
||||
|
||||
logger:
|
||||
@@ -3,7 +3,5 @@ esphome:
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
# The PlatformIO path stays covered whatever the default is
|
||||
toolchain: platformio
|
||||
|
||||
logger:
|
||||
|
||||
+234
-343
@@ -7,7 +7,6 @@ import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
@@ -17,8 +16,8 @@ from typing import TYPE_CHECKING, Protocol
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const, platform_hooks
|
||||
from esphome.build_helpers.native import analysis_backend, native_backend
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_API,
|
||||
@@ -36,6 +35,7 @@ from esphome.const import (
|
||||
CONF_LOGGER,
|
||||
CONF_MDNS,
|
||||
CONF_MQTT,
|
||||
CONF_NAME,
|
||||
CONF_NAME_ADD_MAC_SUFFIX,
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
@@ -48,24 +48,19 @@ from esphome.const import (
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_ESP32,
|
||||
KEY_TARGET_PLATFORM,
|
||||
KEY_VARIANT,
|
||||
PLATFORM_HOST,
|
||||
SECRETS_FILES,
|
||||
ErrorFormat,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, coroutine
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address
|
||||
from esphome.helpers import get_bool_env, indent, is_ip_address
|
||||
from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.upload_targets import PortType, get_port_type
|
||||
from esphome.util import (
|
||||
ESPHOME_COMMAND,
|
||||
PICOTOOL_PACKAGE,
|
||||
FlashImage,
|
||||
detect_rp2040_bootsel,
|
||||
@@ -73,7 +68,6 @@ from esphome.util import (
|
||||
get_serial_ports,
|
||||
is_picotool_usb_permission_error,
|
||||
list_yaml_files,
|
||||
read_secret_line,
|
||||
run_external_command,
|
||||
run_external_process,
|
||||
safe_print,
|
||||
@@ -90,6 +84,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
|
||||
|
||||
# Maximum buffer size for serial log reading to prevent unbounded memory growth
|
||||
SERIAL_BUFFER_MAX_SIZE = 65536
|
||||
@@ -154,7 +149,6 @@ class ArgsProtocol(Protocol):
|
||||
file: str | None
|
||||
no_logs: bool
|
||||
only_generate: bool
|
||||
skip_bootloader: bool
|
||||
show_secrets: bool
|
||||
dashboard: bool
|
||||
configuration: str
|
||||
@@ -483,9 +477,12 @@ def has_ota() -> bool:
|
||||
|
||||
def has_native_ota() -> bool:
|
||||
"""Check if native API OTA upload is available (``platform: esphome``)."""
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
|
||||
return bool(esphome_ota_items(CORE.config))
|
||||
if CONF_OTA not in CORE.config:
|
||||
return False
|
||||
return any(
|
||||
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
for ota_item in CORE.config[CONF_OTA]
|
||||
)
|
||||
|
||||
|
||||
def has_web_server_ota() -> bool:
|
||||
@@ -820,9 +817,7 @@ def write_cpp_file() -> int:
|
||||
from esphome.build_gen import espidf
|
||||
|
||||
espidf.write_project()
|
||||
elif not CORE.using_native_toolchain:
|
||||
# Other native builds generate their project at compile time;
|
||||
# never write a platformio.ini for them
|
||||
else:
|
||||
from esphome.build_gen import platformio
|
||||
|
||||
platformio.write_project()
|
||||
@@ -831,14 +826,6 @@ def write_cpp_file() -> int:
|
||||
|
||||
|
||||
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf):
|
||||
# Debug only: an orchestrator cannot see YAML toolchain overrides,
|
||||
# so this is its expected no-op, and a full build is safe.
|
||||
_LOGGER.debug(
|
||||
"--skip-bootloader ignored: only supported on ESP32 with the "
|
||||
"esp-idf toolchain"
|
||||
)
|
||||
CORE.skip_bootloader = False
|
||||
# Keep this gate here, NOT in config validation: device-builder needs
|
||||
# `esphome config` to keep succeeding with placeholders so onboarding can run.
|
||||
if CONF_WIFI in config:
|
||||
@@ -848,7 +835,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
|
||||
# working on machines that cannot run the toolchain.
|
||||
if CORE.is_esp8266 and CORE.using_toolchain_platformio:
|
||||
if CORE.is_esp8266:
|
||||
from esphome.components.esp8266 import check_rosetta
|
||||
|
||||
check_rosetta()
|
||||
@@ -869,20 +856,23 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
return rc
|
||||
|
||||
# Create factory.bin, ota.bin, and firmware.elf copy
|
||||
if not toolchain.create_factory_bin():
|
||||
# A build whose factory image could not be produced must not
|
||||
# exit 0; downloads would serve an image from an older build.
|
||||
return 1
|
||||
toolchain.create_factory_bin()
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||
|
||||
warn_if_idedata_missing(toolchain.get_idedata)
|
||||
elif CORE.using_native_toolchain:
|
||||
raise EsphomeError(
|
||||
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
|
||||
"backend claimed the build"
|
||||
)
|
||||
try:
|
||||
if toolchain.get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
# The firmware already built; an idedata failure must not fail
|
||||
# a successful build.
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -985,16 +975,12 @@ def upload_using_esptool(
|
||||
|
||||
if file is not None:
|
||||
flash_images = [FlashImage(path=file, offset="0x0")]
|
||||
elif (native := native_backend()) is not None:
|
||||
# Every native backend supplies its own 0x0 flash image (bootloader
|
||||
# and partitions included where the target needs them)
|
||||
image = native.get_factory_firmware_path()
|
||||
if not image.is_file():
|
||||
hint = getattr(native, "missing_image_hint", lambda: None)()
|
||||
raise EsphomeError(
|
||||
hint or f"{image} does not exist; compile the configuration first"
|
||||
)
|
||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
||||
elif CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
flash_images = [
|
||||
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
||||
]
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -1233,28 +1219,14 @@ def upload_program(
|
||||
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
||||
) -> tuple[int, str | None]:
|
||||
host = devices[0]
|
||||
port_type = get_port_type(host)
|
||||
ota_key = getattr(args, "ota_key", None)
|
||||
if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL):
|
||||
_LOGGER.warning(
|
||||
"The presented OTA key only applies to network uploads; ignored for %s",
|
||||
host,
|
||||
)
|
||||
|
||||
platform_upload = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "upload_program"
|
||||
)
|
||||
if platform_upload is not None and platform_upload(config, args, host):
|
||||
if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL):
|
||||
# A platform's own network path (nrf52 mcumgr) has no key handshake
|
||||
_LOGGER.warning(
|
||||
"The presented OTA key only applies to the %s OTA platform; "
|
||||
"ignored for %s",
|
||||
CONF_ESPHOME,
|
||||
host,
|
||||
)
|
||||
return 0, host
|
||||
|
||||
port_type = get_port_type(host)
|
||||
|
||||
# MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by
|
||||
# _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths.
|
||||
is_partition_table = getattr(args, "partition_table", False)
|
||||
@@ -1297,8 +1269,6 @@ def upload_program(
|
||||
network_devices = _resolve_network_devices(devices, config, args)
|
||||
|
||||
if chosen_platform == CONF_WEB_SERVER:
|
||||
if ota_key:
|
||||
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
|
||||
if is_partition_table or is_bootloader:
|
||||
raise EsphomeError(
|
||||
f"{option_string} is only supported with the esphome OTA platform; "
|
||||
@@ -1312,17 +1282,6 @@ def upload_program(
|
||||
return _upload_via_native_api(config, network_devices, args)
|
||||
|
||||
|
||||
def _ota_upload_platforms(config: ConfigType) -> dict[str, None]:
|
||||
"""The network OTA platforms the config provides, one entry each, in
|
||||
config order; the web_server final-validate hook merges duplicates."""
|
||||
return {
|
||||
ota_platform: None
|
||||
for ota_item in config.get(CONF_OTA, [])
|
||||
if (ota_platform := ota_item.get(CONF_PLATFORM))
|
||||
in (CONF_ESPHOME, CONF_WEB_SERVER)
|
||||
}
|
||||
|
||||
|
||||
def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
"""Pick the OTA platform to use, optionally honoring ``--ota-platform``.
|
||||
|
||||
@@ -1330,12 +1289,22 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
The native API uses challenge-response auth with MD5/SHA256 hashing of a
|
||||
server-issued nonce, so the password is never sent over the wire; the
|
||||
``web_server`` path uses HTTP Basic auth which transmits credentials in
|
||||
cleartext over the LAN. (The native path also compresses the upload:
|
||||
gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate
|
||||
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
|
||||
cleartext over the LAN. (The native path also supports gzip compression
|
||||
on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
|
||||
backend reports ``supports_compression() == false`` and the firmware is
|
||||
sent uncompressed regardless of which platform is used.) Falls back to
|
||||
``web_server`` only when that is the only available platform.
|
||||
"""
|
||||
available = _ota_upload_platforms(config)
|
||||
# Use a dict (insertion-ordered) instead of a list so error messages and
|
||||
# membership checks see one entry per platform even if the user has
|
||||
# multiple ``ota:`` items of the same platform; the web_server OTA
|
||||
# platform's final-validate hook merges duplicates anyway.
|
||||
available: dict[str, None] = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
platform = ota_item.get(CONF_PLATFORM)
|
||||
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
|
||||
available[platform] = None
|
||||
|
||||
if not available:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload Over the Air as the {CONF_OTA} configuration is not "
|
||||
@@ -1356,17 +1325,14 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
return CONF_WEB_SERVER
|
||||
|
||||
|
||||
def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
|
||||
"""The validated esphome OTA platform block, if configured."""
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
|
||||
return next(iter(esphome_ota_items(config)), None)
|
||||
|
||||
|
||||
def _upload_via_native_api(
|
||||
config: ConfigType, network_devices: list[str], args: ArgsProtocol
|
||||
) -> tuple[int, str | None]:
|
||||
ota_conf = _esphome_ota_conf(config) or {}
|
||||
ota_conf: ConfigType = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME:
|
||||
ota_conf = ota_item
|
||||
break
|
||||
|
||||
from esphome import espota2
|
||||
from esphome.components.noise import static_encryption_key
|
||||
@@ -1376,19 +1342,9 @@ def _upload_via_native_api(
|
||||
# Fail closed: an encryption block whose key did not resolve must never
|
||||
# fall back to a plaintext upload
|
||||
noise_psk = None
|
||||
old_noise_psk = None
|
||||
plaintext_fallback = False
|
||||
allow_plaintext_upload = False
|
||||
if ota_key := getattr(args, "ota_key", None):
|
||||
# The user presents a key the device still runs instead of the
|
||||
# configured one (a key change in progress); an explicit key never
|
||||
# downgrades to plaintext
|
||||
noise_psk = ota_key
|
||||
elif (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
|
||||
noise_psk = encryption_conf.get(CONF_KEY)
|
||||
allow_plaintext_upload = bool(
|
||||
encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD)
|
||||
)
|
||||
if not noise_psk:
|
||||
raise EsphomeError(
|
||||
"OTA encryption is configured but no key was resolved; "
|
||||
@@ -1397,8 +1353,6 @@ def _upload_via_native_api(
|
||||
# Ensure the key is a string, as required by the underlying OTA implementation.
|
||||
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
|
||||
noise_psk = str(noise_psk)
|
||||
if old_key := encryption_conf.get(espota2.CONF_OLD_KEY):
|
||||
old_noise_psk = str(old_key)
|
||||
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
|
||||
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
|
||||
noise_psk = str(api_key)
|
||||
@@ -1423,12 +1377,6 @@ def _upload_via_native_api(
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
|
||||
elif getattr(args, "bootloader", False):
|
||||
check_partition_access("--bootloader")
|
||||
if (
|
||||
getattr(args, "file", None) is None
|
||||
and (native := native_backend())
|
||||
and (hint := getattr(native, "missing_image_hint", lambda: None)())
|
||||
):
|
||||
raise EsphomeError(hint)
|
||||
binary = CORE.bootloader_bin
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
|
||||
if getattr(args, "file", None) is not None:
|
||||
@@ -1447,31 +1395,19 @@ def _upload_via_native_api(
|
||||
ota_type,
|
||||
noise_psk,
|
||||
plaintext_fallback=plaintext_fallback,
|
||||
allow_plaintext_upload=allow_plaintext_upload,
|
||||
old_noise_psk=old_noise_psk,
|
||||
)
|
||||
|
||||
|
||||
def _old_key_notice(config: ConfigType) -> None:
|
||||
"""After `run`, whose image was just built from this config; a plain
|
||||
`upload` may send a stale image that still carries the previous key."""
|
||||
from esphome import espota2
|
||||
|
||||
for item in espota2.esphome_ota_items(config):
|
||||
if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY):
|
||||
_LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE)
|
||||
|
||||
|
||||
def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
from esphome import web_server_ota
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
if any(
|
||||
ota_item.get(CONF_ENCRYPTION) is not None
|
||||
for ota_item in esphome_ota_items(config)
|
||||
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
and ota_item.get(CONF_ENCRYPTION) is not None
|
||||
for ota_item in config.get(CONF_OTA, [])
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"This config has OTA encryption, but the web_server OTA path sends "
|
||||
@@ -1773,66 +1709,27 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
if CORE.is_host:
|
||||
_LOGGER.info("Successfully compiled program to path '%s'", CORE.firmware_bin)
|
||||
_LOGGER.info(
|
||||
"Successfully compiled program to path '%s'", _host_program_path(config)
|
||||
)
|
||||
else:
|
||||
_LOGGER.info("Successfully compiled program.")
|
||||
return 0
|
||||
|
||||
|
||||
ENV_OTA_KEY = "ESPHOME_OTA_KEY"
|
||||
OTA_KEY_PLATFORM_ERROR = (
|
||||
f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA "
|
||||
"platform; to flash by serial instead, name the port with --device"
|
||||
)
|
||||
def _host_program_path(config: ConfigType) -> str:
|
||||
"""Return the compiled host ELF path."""
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
return str(toolchain.get_elf_path())
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
def _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None:
|
||||
"""Take the key to present to the device for this upload, from the
|
||||
prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is
|
||||
not buried in build output."""
|
||||
prompt = getattr(args, "prompt_ota_key", False)
|
||||
env_key = getattr(args, "env_ota_key", None)
|
||||
if not prompt and env_key is None:
|
||||
return
|
||||
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST:
|
||||
_LOGGER.warning("A host program runs locally; the presented OTA key is ignored")
|
||||
return
|
||||
# The HTTP path has no key handshake; decide before prompting or
|
||||
# compiling. A serial target is a serial flash: the key is not even
|
||||
# asked for, and --ota-platform means nothing to it. Every entry must
|
||||
# be serial: a missing port is skipped and the next entry may be a host
|
||||
devices = getattr(args, "device", None) or []
|
||||
serial_target = bool(devices) and all(
|
||||
device == "SERIAL"
|
||||
or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL)
|
||||
for device in devices
|
||||
)
|
||||
chosen = (
|
||||
_choose_ota_platform(config, getattr(args, "ota_platform", None))
|
||||
if not serial_target and _ota_upload_platforms(config)
|
||||
else None
|
||||
)
|
||||
if chosen == CONF_WEB_SERVER:
|
||||
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
|
||||
from esphome.components.noise import validate_encryption_key
|
||||
from esphome.config_validation import Invalid
|
||||
|
||||
if serial_target:
|
||||
_LOGGER.warning(
|
||||
"The presented OTA key only applies to network uploads; ignored for %s",
|
||||
", ".join(devices),
|
||||
)
|
||||
return
|
||||
key = read_secret_line("OTA encryption key: ") if prompt else env_key
|
||||
try:
|
||||
validate_encryption_key(key)
|
||||
except Invalid as err:
|
||||
raise EsphomeError(f"Invalid OTA encryption key: {err}") from err
|
||||
args.ota_key = key
|
||||
# Memoized by compile_program's own call; this is a dict lookup
|
||||
return str(get_idedata(config).firmware_elf_path)
|
||||
|
||||
|
||||
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
_read_ota_key(args, config)
|
||||
# Get devices, resolving special identifiers like OTA
|
||||
devices = choose_upload_log_host(
|
||||
default=args.device,
|
||||
@@ -1868,19 +1765,6 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
|
||||
def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if (
|
||||
CORE.skip_bootloader
|
||||
and CORE.is_esp32
|
||||
and CORE.using_toolchain_esp_idf
|
||||
and any(
|
||||
get_port_type(device) == PortType.SERIAL for device in (args.device or [])
|
||||
)
|
||||
):
|
||||
# Fail before the compile: the result could never flash over serial.
|
||||
# Elsewhere the flag is ignored, so serial stays fine there.
|
||||
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
|
||||
return 1
|
||||
_read_ota_key(args, config)
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
@@ -1889,7 +1773,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
return exit_code
|
||||
_LOGGER.info("Successfully compiled program.")
|
||||
if CORE.is_host:
|
||||
program_path = str(CORE.firmware_bin)
|
||||
program_path = _host_program_path(config)
|
||||
_LOGGER.info("Running program from path '%s'", program_path)
|
||||
return run_external_process(program_path)
|
||||
|
||||
@@ -1906,7 +1790,6 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
exit_code, successful_device = upload_program(config, args, devices)
|
||||
if exit_code == 0:
|
||||
_LOGGER.info("Successfully uploaded program.")
|
||||
_old_key_notice(config)
|
||||
else:
|
||||
_LOGGER.warning("Failed to upload to %s", devices)
|
||||
return exit_code
|
||||
@@ -2043,10 +1926,7 @@ def run_multiple_configs(
|
||||
safe_print()
|
||||
|
||||
cmd = command_builder(f)
|
||||
# The parent already logged the Intel macOS warning; children skip it.
|
||||
rc = run_external_process(
|
||||
*cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"}
|
||||
)
|
||||
rc = run_external_process(*cmd)
|
||||
|
||||
if rc == 0:
|
||||
print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}")
|
||||
@@ -2085,12 +1965,12 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
||||
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
import json
|
||||
|
||||
native_toolchain = native_backend()
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Native ESP-IDF derives idedata from the build's compile_commands.json,
|
||||
# so the configuration must already be compiled.
|
||||
from esphome.espidf import toolchain as espidf_toolchain
|
||||
|
||||
if native_toolchain is not None:
|
||||
# Native toolchains derive idedata from the build's
|
||||
# compile_commands.json, so the configuration must already be compiled.
|
||||
idedata = native_toolchain.get_idedata()
|
||||
idedata = espidf_toolchain.get_idedata()
|
||||
if idedata is None:
|
||||
_LOGGER.error(
|
||||
"No idedata available; compile the configuration first",
|
||||
@@ -2129,22 +2009,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
||||
|
||||
# Refuse an unsupported toolchain before paying for a full compile
|
||||
analysis_toolchain = analysis_backend()
|
||||
if analysis_toolchain is None and not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"analyze-memory is not supported with the '%s' toolchain on %s; "
|
||||
"re-run with --toolchain platformio",
|
||||
CORE.toolchain.value if CORE.toolchain else "unresolved",
|
||||
CORE.target_platform,
|
||||
)
|
||||
return 1
|
||||
if (
|
||||
check_supported := getattr(analysis_toolchain, "check_analysis_supported", None)
|
||||
) is not None:
|
||||
# Raises with the reason; before the compile, not after it
|
||||
check_supported()
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
@@ -2156,30 +2020,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
# Get idedata for analysis
|
||||
idedata = None
|
||||
if analysis_toolchain is not None:
|
||||
objdump = analysis_toolchain.get_objdump_path()
|
||||
readelf = analysis_toolchain.get_readelf_path()
|
||||
for tool in (objdump, readelf):
|
||||
if not tool.is_file():
|
||||
# The analyzer would silently fall back to host
|
||||
# binutils, which cannot read the target ELF
|
||||
_LOGGER.error(
|
||||
"%s is missing; the toolchain install may be incomplete "
|
||||
"(recompile, or run 'esphome clean-all' if it persists)",
|
||||
tool,
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(objdump)
|
||||
readelf_path = str(readelf)
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
firmware_elf = analysis_toolchain.get_elf_path()
|
||||
if not firmware_elf.is_file():
|
||||
# The analyzer swallows tool failures, so a missing ELF would
|
||||
# produce an exit-0 zeroed report
|
||||
_LOGGER.error(
|
||||
"%s is missing; compile the configuration first", firmware_elf
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(toolchain.get_objdump_path())
|
||||
readelf_path = str(toolchain.get_readelf_path())
|
||||
|
||||
firmware_elf = toolchain.get_elf_path()
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -2233,9 +2080,155 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
|
||||
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome.cli.rename import command_rename as run
|
||||
from esphome import yaml_util
|
||||
|
||||
return run(args, config)
|
||||
new_name = args.name
|
||||
for c in new_name:
|
||||
if c not in ALLOWED_NAME_CHARS:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{c}' is an invalid character for names. Valid characters are: "
|
||||
f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
# Load existing yaml file
|
||||
raw_contents = CORE.config_path.read_text(encoding="utf-8")
|
||||
|
||||
yaml = yaml_util.load_yaml(CORE.config_path)
|
||||
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
|
||||
)
|
||||
)
|
||||
return 1
|
||||
old_name = yaml[CONF_ESPHOME][CONF_NAME]
|
||||
match = re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name)
|
||||
if match is None:
|
||||
# Only swap the ``name:`` line that sits directly under the
|
||||
# top-level ``esphome:`` block. A naked ``re.sub`` would
|
||||
# also clobber any other ``name:`` line whose value happens
|
||||
# to match (e.g. a sensor / output / wifi entry sharing the
|
||||
# device's hostname), silently rewriting unrelated user
|
||||
# configuration. The pattern anchors:
|
||||
# - at the start of the line so ``friendly_name:``,
|
||||
# ``device_name:`` etc. don't match the trailing ``name:``
|
||||
# substring; and
|
||||
# - at the end of the value (lookahead for whitespace +
|
||||
# comment + EOL) so ``old_name`` doesn't match as a
|
||||
# prefix of a longer value (``kitchen`` vs ``kitchen2``).
|
||||
name_pattern = re.compile(
|
||||
rf"^(\s*)name:\s+[\"']?{re.escape(old_name)}[\"']?(?=\s*(?:#|$))"
|
||||
)
|
||||
out_lines: list[str] = []
|
||||
in_esphome_block = False
|
||||
for line in raw_contents.splitlines(keepends=True):
|
||||
if line and not line[0].isspace() and line.strip():
|
||||
in_esphome_block = line.lstrip().startswith("esphome:")
|
||||
out_lines.append(line)
|
||||
continue
|
||||
if in_esphome_block:
|
||||
line = name_pattern.sub(rf'\1name: "{new_name}"', line, count=1)
|
||||
out_lines.append(line)
|
||||
new_raw = "".join(out_lines)
|
||||
else:
|
||||
old_name = yaml[CONF_SUBSTITUTIONS][match.group(1)]
|
||||
if (
|
||||
len(
|
||||
re.findall(
|
||||
rf"^\s+{match.group(1)}:\s+[\"']?{old_name}[\"']?",
|
||||
raw_contents,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
)
|
||||
> 1
|
||||
):
|
||||
safe_print(
|
||||
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
|
||||
)
|
||||
return 1
|
||||
|
||||
new_raw = re.sub(
|
||||
rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?",
|
||||
f'\\1: "{new_name}"',
|
||||
raw_contents,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# ``new_name == old_name`` (after substitution resolution) is
|
||||
# a no-op rewrite that would still queue a pointless re-flash.
|
||||
# Catch it before the path-equality check below — covers the
|
||||
# case where the config filename doesn't match the device name
|
||||
# (e.g. ``weird-file.yaml`` whose ``esphome.name`` is
|
||||
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
|
||||
# would otherwise just re-flash the same hostname).
|
||||
if new_name == old_name:
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
new_path: Path = CORE.config_dir / (new_name + ".yaml")
|
||||
if new_path.resolve() == CORE.config_path.resolve():
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
if new_path.exists():
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Cannot rename: {new_path} already exists. "
|
||||
"Refusing to overwrite an existing configuration.",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
safe_print(
|
||||
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
|
||||
)
|
||||
print()
|
||||
|
||||
new_path.write_text(new_raw, encoding="utf-8")
|
||||
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
cli_args = [
|
||||
"run",
|
||||
str(new_path),
|
||||
"--no-logs",
|
||||
"--device",
|
||||
CORE.address,
|
||||
]
|
||||
|
||||
if args.dashboard:
|
||||
cli_args.insert(0, "--dashboard")
|
||||
|
||||
try:
|
||||
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
|
||||
except KeyboardInterrupt:
|
||||
rc = 1
|
||||
if rc != 0:
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
if CORE.config_path != new_path:
|
||||
CORE.config_path.unlink()
|
||||
|
||||
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
PRE_CONFIG_ACTIONS = {
|
||||
@@ -2270,15 +2263,6 @@ SIMPLE_CONFIG_ACTIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _add_skip_bootloader_arg(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--skip-bootloader",
|
||||
help="Do not build the bootloader or the factory image; "
|
||||
"the result can only be flashed over OTA.",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
|
||||
def _add_states_args(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add mutually exclusive ``--states``/``--no-states`` flags to a parser.
|
||||
|
||||
@@ -2302,25 +2286,6 @@ def _add_states_args(parser: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _error_format(value: str) -> ErrorFormat:
|
||||
try:
|
||||
return ErrorFormat(value.lower())
|
||||
except ValueError:
|
||||
choices = ", ".join(ErrorFormat)
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"invalid choice: '{value}' (choose from {choices})"
|
||||
) from None
|
||||
|
||||
|
||||
ARGUMENT_HELP_PROMPT_OTA_KEY = (
|
||||
"Ask for the OTA encryption key to present to the device instead of using "
|
||||
"the one in the configuration, for a device that still runs a previous key. "
|
||||
"The key is read without echo, or as one line from stdin when that is not "
|
||||
"a terminal; it is never taken from the command line. A calling program "
|
||||
f"can hand the key over in the {ENV_OTA_KEY} environment variable instead."
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
options_parser = argparse.ArgumentParser(add_help=False)
|
||||
options_parser.add_argument(
|
||||
@@ -2378,19 +2343,7 @@ def parse_args(argv):
|
||||
metavar="{" + ",".join(t.value for t in Toolchain) + "}",
|
||||
help=(
|
||||
"Select toolchain for compiling. Overrides '<platform>.toolchain' in YAML. "
|
||||
"Default: the platform's native toolchain where it has one, else "
|
||||
f"{Toolchain.PLATFORMIO.value}."
|
||||
),
|
||||
)
|
||||
|
||||
options_parser.add_argument(
|
||||
"--error-format",
|
||||
type=_error_format,
|
||||
metavar="{" + ",".join(f.value for f in ErrorFormat) + "}",
|
||||
help=(
|
||||
"How configuration errors are shown. 'yaml' shows them inside the "
|
||||
"failing YAML; 'line' prints one 'file:line:column: error: message' "
|
||||
"line per error. Default: $ESPHOME_ERROR_FORMAT, else 'yaml'."
|
||||
f"Default: {Toolchain.PLATFORMIO.value}."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2450,7 +2403,6 @@ def parse_args(argv):
|
||||
help="Only generate source code, do not compile.",
|
||||
action="store_true",
|
||||
)
|
||||
_add_skip_bootloader_arg(parser_compile)
|
||||
|
||||
parser_upload = subparsers.add_parser(
|
||||
"upload",
|
||||
@@ -2489,11 +2441,6 @@ def parse_args(argv):
|
||||
help="Upload as partition table (OTA).",
|
||||
action="store_true",
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--prompt-ota-key",
|
||||
action="store_true",
|
||||
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--bootloader",
|
||||
help="Upload as bootloader (OTA).",
|
||||
@@ -2519,7 +2466,7 @@ def parse_args(argv):
|
||||
"-r",
|
||||
action="store_true",
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
_add_states_args(parser_logs)
|
||||
|
||||
@@ -2552,7 +2499,6 @@ def parse_args(argv):
|
||||
parser_run.add_argument(
|
||||
"--no-logs", help="Disable starting logs.", action="store_true"
|
||||
)
|
||||
_add_skip_bootloader_arg(parser_run)
|
||||
|
||||
_add_states_args(parser_run)
|
||||
|
||||
@@ -2561,7 +2507,7 @@ def parse_args(argv):
|
||||
"-r",
|
||||
action="store_true",
|
||||
help="Reset the device before starting serial logs.",
|
||||
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
|
||||
)
|
||||
parser_run.add_argument(
|
||||
"--ota-platform",
|
||||
@@ -2574,11 +2520,6 @@ def parse_args(argv):
|
||||
"(HTTP Basic auth) when that is the only configured platform."
|
||||
),
|
||||
)
|
||||
parser_run.add_argument(
|
||||
"--prompt-ota-key",
|
||||
action="store_true",
|
||||
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
|
||||
)
|
||||
|
||||
parser_clean = subparsers.add_parser(
|
||||
"clean-mqtt",
|
||||
@@ -2707,18 +2648,9 @@ def parse_args(argv):
|
||||
args, unknown_args = parser.parse_known_args(arguments)
|
||||
if unknown_args:
|
||||
_LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args)
|
||||
else:
|
||||
args = parser.parse_args(arguments)
|
||||
return args
|
||||
|
||||
# Read here rather than as the option default, so a bad value is reported
|
||||
# against the environment variable instead of --error-format
|
||||
if args.error_format is None:
|
||||
env_value = os.getenv("ESPHOME_ERROR_FORMAT", ErrorFormat.YAML.value)
|
||||
try:
|
||||
args.error_format = _error_format(env_value)
|
||||
except argparse.ArgumentTypeError as err:
|
||||
parser.error(f"ESPHOME_ERROR_FORMAT: {err}")
|
||||
return args
|
||||
return parser.parse_args(arguments)
|
||||
|
||||
|
||||
def _warn_if_source_tree_mismatch() -> None:
|
||||
@@ -2764,34 +2696,12 @@ def _warn_if_source_tree_mismatch() -> None:
|
||||
)
|
||||
|
||||
|
||||
_INTEL_MACOS_REMOVAL = "2027.6.0"
|
||||
_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED"
|
||||
|
||||
|
||||
def _warn_if_intel_macos() -> None:
|
||||
"""Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL."""
|
||||
if (
|
||||
not IS_MACOS
|
||||
or platform.machine() != "x86_64"
|
||||
or _INTEL_MACOS_WARNED_ENV in os.environ
|
||||
):
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"Support for Intel Macs will end in ESPHome %s or earlier. The Python "
|
||||
"packages ESPHome depends on have stopped publishing Intel macOS builds, "
|
||||
"so future releases will not install on this machine. On an Apple "
|
||||
"Silicon Mac, switch to a native arm64 Python.",
|
||||
_INTEL_MACOS_REMOVAL,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
args = parse_args(argv)
|
||||
CORE.dashboard = args.dashboard
|
||||
CORE.testing_mode = args.testing_mode
|
||||
CORE.error_format = args.error_format
|
||||
|
||||
# Create address cache from command-line arguments
|
||||
CORE.address_cache = AddressCache.from_cli_args(
|
||||
@@ -2804,25 +2714,7 @@ def run_esphome(argv):
|
||||
args.log_level = "CRITICAL"
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
# Taken out of the environment before any command runs, so no build
|
||||
# tool inherits a key a calling program handed over
|
||||
args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None)
|
||||
if args.command in ("upload", "run"):
|
||||
# One key is one device's; several configs run in child processes
|
||||
# that would never see it
|
||||
if (args.env_ota_key is not None or args.prompt_ota_key) and len(
|
||||
args.configuration
|
||||
) > 1:
|
||||
raise EsphomeError(
|
||||
f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; "
|
||||
f"{len(args.configuration)} were given"
|
||||
)
|
||||
elif args.env_ota_key is not None:
|
||||
_LOGGER.warning(
|
||||
"%s is set but the %s command does not use it", ENV_OTA_KEY, args.command
|
||||
)
|
||||
_warn_if_source_tree_mismatch()
|
||||
_warn_if_intel_macos()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
@@ -2866,7 +2758,6 @@ def run_esphome(argv):
|
||||
|
||||
CORE.config_path = conf_path
|
||||
CORE.dashboard = args.dashboard
|
||||
CORE.skip_bootloader = getattr(args, "skip_bootloader", False)
|
||||
if args.toolchain is not None:
|
||||
# CLI toolchain wins over esp32.toolchain in YAML.
|
||||
CORE.toolchain = args.toolchain
|
||||
|
||||
@@ -148,13 +148,6 @@ class AddressCache:
|
||||
continue
|
||||
hostname, ips = arg.split("=", 1)
|
||||
# Normalize hostname for consistent lookups
|
||||
normalized = normalize_hostname(hostname.strip())
|
||||
addresses = [ip for value in ips.split(",") if (ip := value.strip())]
|
||||
if not normalized or not addresses:
|
||||
_LOGGER.warning(
|
||||
"Invalid cache entry: %s (hostname and at least one address are required)",
|
||||
arg,
|
||||
)
|
||||
continue
|
||||
cache[normalized] = addresses
|
||||
normalized = normalize_hostname(hostname)
|
||||
cache[normalized] = [ip.strip() for ip in ips.split(",")]
|
||||
return cache
|
||||
|
||||
@@ -37,7 +37,7 @@ def find_elf_path(build_path: Path) -> Path | None:
|
||||
"""
|
||||
name = build_path.name
|
||||
for candidate in (
|
||||
# Native ESP-IDF: the build writes build/<name>.elf, which ESPHome copies
|
||||
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
|
||||
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
|
||||
build_path / "build" / "firmware.elf",
|
||||
# PlatformIO
|
||||
@@ -68,16 +68,12 @@ def idedata_candidates(build_path: Path) -> list[Path]:
|
||||
The candidate idedata JSON paths, most specific first
|
||||
"""
|
||||
name = build_path.name
|
||||
data_dir = build_path.parent.parent / "idedata"
|
||||
# Native backends suffix the cache by toolchain (<name>.arduino.json)
|
||||
suffixed = sorted(data_dir.glob(f"{name}.*.json")) if data_dir.is_dir() else []
|
||||
return [
|
||||
# In .pioenvs for test builds
|
||||
build_path / ".pioenvs" / name / "idedata.json",
|
||||
# Both toolchains cache it in the data dir, which holds this build dir:
|
||||
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
|
||||
build_path.parent.parent / "idedata" / f"{name}.json",
|
||||
*suffixed,
|
||||
# Regular builds, invoked from the config dir or from anywhere
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{name}.json",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user