Compare commits

..
Author SHA1 Message Date
J. Nick Koston 7a167a04ce [lock] Pack the lock traits flags into one byte 2026-10-21 16:28:32 -10:00
Jonathan Swoboda edda565237 [esp32_ble][esp32_ble_tracker][bluetooth_proxy][bluetooth_connection] Re-export ble_device_base types with using-declarations (#21337) 2026-10-21 16:09:19 -10:00
Clyde StubbsandClaude Code 3616052a7c [ci] Add display-test agent skill (#21098)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-22 15:08:53 +13:00
Jonathan Swoboda fbc4bc9e10 [core] Fix the performance and modernize findings of clang-tidy 23 (#21336) 2026-10-22 02:03:00 +00:00
01b29dbf53 [cover] Add stop tilt support (#20248)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-10-21 15:49:07 -10:00
esphome[bot] e15797c18b Bump aioesphomeapi from 46.17.0 to 46.18.0 (#21332) 2026-10-22 01:46:31 +00:00
J. Nick Koston 3c6b1e66e7 [ci] Add the MQTT TLS, udp.write and entity icon flows to the hardware-test skill (#21264) 2026-10-21 15:30:27 -10:00
J. Nick Koston 179f9ed395 [mt6701_i2c][opentherm][const] Move CONF_OUT_PIN to the shared component constants (#21329) 2026-10-21 15:25:39 -10:00
J. Nick Koston 2f7105fa54 [core] Fix the findings of clang-tidy 23 (#21301) 2026-10-21 21:20:54 -04:00
J. Nick Koston e3590bdac7 [ci] Label pull requests that change api.proto (#21294) 2026-10-21 15:15:43 -10:00
Jonathan Swoboda 54440a3559 [pulse_counter] Attach the software interrupt to the counted edges only (#21271) 2026-10-21 20:46:48 -04:00
88 changed files with 1513 additions and 1211 deletions
+156
View File
@@ -0,0 +1,156 @@
---
name: display-test
description: Test an ESPHome display driver on a real panel with a camera. Use when a task asks to verify a display (LCD, e-paper, OLED, RLCD) on hardware, check orientation/rotation, partial updates, colours, contrast, or that text and shapes render correctly. Covers finding the USB port, flashing a test pattern, capturing the panel with any camera, and reading the photo.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---
# Display testing with a camera
Point a camera at the panel to get visual evidence that a driver renders correctly. Any camera
and capture tool works; this skill covers the common ones. `$SKILL` below is this directory
(`.agents/skills/display-test`).
## Ask before using a camera
A camera can show people and rooms, not only the panel. Before the first capture, ask the user
for permission and for which camera to use. List the cameras the host has (the commands below
show how), name the one you intend to use, and wait for the user to confirm it. Do not pick a
camera yourself, and do not fall back to another camera if the chosen one fails; ask again.
The permission covers this session and the camera the user named. Ask again for a different
camera or in a new session. If the user declines, stop and report that the display was not
checked visually.
## Capturing a frame
Grab a still image, skipping the first few frames so auto-exposure and white balance settle.
Read the JPEG with the Read tool; the image is shown visually.
### macOS
The bundled Swift tool uses AVFoundation and avoids the frame-grabbing problems some USB
cameras have with other tools:
```bash
mkdir -p /tmp/camsnap /tmp/display-test
swiftc -O "$SKILL/scripts/camsnap.swift" -o /tmp/camsnap/camsnap
/tmp/camsnap/camsnap <camera-name-substring> /tmp/display-test/after.jpg 30
```
The camera argument is a case-insensitive substring of the name AVFoundation reports. If it does
not match, the tool lists the cameras it found, so a run with a name that matches nothing lists
the cameras without capturing. Use a substring that matches only the camera the user approved.
It creates the output directory if needed and reports encoding or write errors on stderr. Other
options include `ffmpeg` with `avfoundation` input, `imagesnap`, or Photo Booth's still capture.
### Linux
Use `ffmpeg` with the v4l2 input, `fswebcam`, or `v4l2-ctl`. With `ffmpeg`, `-ss 2` after the
input discards the first two seconds of frames while exposure settles:
```bash
mkdir -p /tmp/display-test
ffmpeg -f v4l2 -i /dev/video0 -ss 2 -frames:v 1 -y /tmp/display-test/after.jpg
# or
fswebcam -d /dev/video0 --skip 10 /tmp/display-test/after.jpg
```
List devices with `v4l2-ctl --list-devices`.
### Windows
Use `ffmpeg` with the DirectShow input (`-ss 2` skips the first frames, as on Linux), or any
camera application that can save a still image:
```powershell
mkdir C:\temp -Force | Out-Null
ffmpeg -f dshow -list_devices true -i dummy # find the device name
ffmpeg -f dshow -i video="<device name>" -ss 2 -frames:v 1 -y C:\temp\after.jpg
```
### Camera caveats
- The board may sit at an angle. Do not trust absolute pixel positions or aspect ratios from the
photo; judge orientation, ordering and approximate spacing, using the corner markers as
references.
- Reflections or a glossy cover can brighten one side. Capture again if a region is unclear.
- A moving pattern is caught at an arbitrary phase. Take several captures, or use a static marker
per position.
## Board and port
Find the port; names change on every replug:
```bash
python3 "$SKILL/../hardware-test/scripts/ports.py"
```
Native-USB ESP32-S3/C3/C6/H2 boards show up as `USB JTAG/serial debug unit` and need
`logger: hardware_uart: USB_SERIAL_JTAG`.
## Test pattern
Write a scratch config outside the checkout. Use one `name:` per board and include:
- a white background, a black border and a filled square at the centre, drawn in the first
update (full-window check and scale);
- a 60x20 bar near each corner, one bar per later update with `auto_clear_enabled: false`, so
each bar is written by its own partial window. The bars are wider than tall, which shows a
90 or 270 degree rotation; the corners exercise the window alignment;
- a 20x20 square under the left end of the top-left bar, drawn in the same update, which marks
the top-left corner and shows a 180 degree rotation or a mirrored image.
Use `Color::BLACK` and `Color::WHITE` in lambdas. Do not use the old `COLOR_OFF`/`COLOR_ON`
constants; black must mean black.
A ready template is in `$SKILL/templates/display-pattern.yaml`. Substitute the display platform,
pins and (for a driver with fonts) a font. The marker pattern needs no font.
## Build, flash, capture
Run from the worktree under test so `python -m esphome` imports the checkout, not an installed
copy:
```bash
cd <worktree>
python3 -c "import esphome; print(esphome.__file__)" # must print the worktree path
PYTHONUNBUFFERED=1 python3 -m esphome compile /tmp/display-test/display-pattern.yaml
PYTHONUNBUFFERED=1 python3 -m esphome upload /tmp/display-test/display-pattern.yaml \
--device /dev/cu.usbmodemXXXX
sleep 6 # boot plus a few update intervals
<capture command> /tmp/display-test/after.jpg
```
The device path differs by OS and host; use the path printed by `ports.py`. If the USB console
wedges during flashing (esptool `No serial data received`), replug the board and retry. A board
powered only by a battery may need the USB cable for flashing; the panel keeps its image when
power is removed, which is useful for checking that an image is bistable.
## What to check
1. **Orientation**: the extra square sits under the bar in the expected top-left corner. If it is
in another corner, the image is rotated or mirrored. Test `rotation:` values with the same
pattern. The logical width and height swap at 90 and
270 degrees, so the 60x20 corner bars should then appear taller than wide on the physical
panel.
2. **Coverage**: the border touches all four edges, with no dead rows or columns at the edges.
3. **Partial updates**: with `auto_clear_enabled: false`, draw one marker per update. Each should
appear exactly at its requested position with no smearing, offset or mirrored placement.
Include all four corners.
4. **Text and contrast**: sharp glyphs, correct polarity (black text on white, not inverted), no
ghosting from the previous frame.
5. **Speed**: if the driver logs transfer sizes, check that a full refresh and a partial window
take the expected times.
Record the build commit, board, config, and the captured image path when reporting results.
## When a partial update looks wrong
Take a marker-only capture before assuming the mapping is wrong: the camera's perspective makes
absolute positions deceptive. If markers land in mirrored positions, the controller's address
order (`MADCTL` MX/MY/MV) or the RAM block mapping is inverted for the window; verify the
full-frame path separately, then fix the window computation.
## Add findings
Add new capture tools, panel quirks, or recovery steps here so the next session starts ahead.
@@ -0,0 +1,71 @@
// camsnap <camera name substring> <output.jpg> [frames to skip]
// macOS only: grabs one frame from an AVFoundation camera after letting exposure settle.
// See SKILL.md for capture tools on other systems.
import AVFoundation
import AppKit
import CoreImage
let args = CommandLine.arguments
guard args.count >= 3 else {
FileHandle.standardError.write("usage: camsnap <camera name> <out.jpg> [skip]\n".data(using: .utf8)!)
exit(2)
}
let wanted = args[1].lowercased()
let outPath = args[2]
let skip = args.count > 3 ? Int(args[3]) ?? 30 : 30
let discovery = AVCaptureDevice.DiscoverySession(
deviceTypes: [.external, .builtInWideAngleCamera], mediaType: .video, position: .unspecified)
guard let device = discovery.devices.first(where: { $0.localizedName.lowercased().contains(wanted) }) else {
let names = discovery.devices.map { $0.localizedName }.joined(separator: ", ")
FileHandle.standardError.write("no camera matching '\(wanted)'; found: \(names)\n".data(using: .utf8)!)
exit(1)
}
final class Grabber: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
var seen = 0
let skip: Int
let out: String
init(skip: Int, out: String) { self.skip = skip; self.out = out }
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
seen += 1
guard seen > skip, let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let image = CIImage(cvPixelBuffer: pixels)
let rep = NSBitmapImageRep(ciImage: image)
guard let jpeg = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else {
FileHandle.standardError.write("failed to encode \(out) as JPEG\n".data(using: .utf8)!)
exit(1)
}
let url = URL(fileURLWithPath: out)
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
do {
try jpeg.write(to: url)
} catch {
FileHandle.standardError.write("failed to write \(out): \(error)\n".data(using: .utf8)!)
exit(1)
}
print("saved \(out) after \(seen) frames")
exit(0)
}
}
let session = AVCaptureSession()
session.sessionPreset = .high
guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else {
FileHandle.standardError.write("cannot open \(device.localizedName)\n".data(using: .utf8)!)
exit(1)
}
session.addInput(input)
let output = AVCaptureVideoDataOutput()
output.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
let grabber = Grabber(skip: skip, out: outPath)
output.setSampleBufferDelegate(grabber, queue: DispatchQueue(label: "camsnap"))
session.addOutput(output)
session.startRunning()
DispatchQueue.main.asyncAfter(deadline: .now() + 15) {
FileHandle.standardError.write("timed out waiting for frames\n".data(using: .utf8)!)
exit(1)
}
RunLoop.main.run()
@@ -0,0 +1,43 @@
# Display test pattern: corner markers, centre reference and partial-update markers.
# Replace the display platform and pins with the driver under test.
esphome:
name: display-pattern
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
logger:
hardware_uart: USB_SERIAL_JTAG
level: DEBUG
spi:
clk_pin: GPIO11
mosi_pin: GPIO12
display:
- platform: st7305
id: test_display
cs_pin: GPIO40
dc_pin: GPIO5
reset_pin: GPIO41
update_interval: 1s
auto_clear_enabled: false
lambda: |-
static int frame = 0;
if (frame == 0) {
it.fill(Color::WHITE);
it.rectangle(0, 0, it.get_width(), it.get_height(), Color::BLACK);
it.filled_rectangle(it.get_width() / 2 - 10, it.get_height() / 2 - 10, 20, 20, Color::BLACK);
} else if (frame == 1) {
it.filled_rectangle(20, 20, 60, 20, Color::BLACK);
it.filled_rectangle(20, 50, 20, 20, Color::BLACK);
} else if (frame == 2) {
it.filled_rectangle(20, it.get_height() - 40, 60, 20, Color::BLACK);
} else if (frame == 3) {
it.filled_rectangle(it.get_width() - 80, 20, 60, 20, Color::BLACK);
} else if (frame == 4) {
it.filled_rectangle(it.get_width() - 80, it.get_height() - 40, 60, 20, Color::BLACK);
}
frame++;
+5 -2
View File
@@ -115,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
(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), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with WebSocket listeners, test CA, transport case runner).
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
## Templates in `$SKILL/templates`
@@ -132,11 +132,14 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
`globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`,
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`,
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
`mqtt-transport-dut.yaml`.
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`.
## When something looks wrong
- A capture stopped early or a line is missing: check the timestamps before trusting it.
- A script that pipes into `tee` never ends although its work is done: a background command written as
`(cd dir && server & echo $!)` backgrounds the whole `cd && server` list, so a shell stays behind holding the
pipe open and `$!` is that shell, not the server. Write `(cd dir && exec server > out 2>&1 < /dev/null) &`.
- 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
@@ -11,14 +11,14 @@ One file per component, named after it:
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback
- [`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, WebSocket transports with a local and a public broker
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
- [`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
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP)
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
@@ -29,4 +29,4 @@ One file per component, named after it:
- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin
- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply
- [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
@@ -20,7 +20,7 @@ the TLS listener's certificate, both run in the scratch directory so keys, logs
to `NAME/#`, sends a switch command and an `on_message` topic, presses the 3000 byte publish button over the API,
restarts the local broker when the last argument is `restart` (the PID in `mosquitto.pid`, written when the broker
was started with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` in the working directory next to the
certificates), checks its three ports answer again, and prints the broker's message counts with the device log. `mosquitto_sub` cannot speak
certificates), checks every listener in the broker config answers again, and prints the broker's message counts with the device log. `mosquitto_sub` cannot speak
WebSocket, so it subscribes on the plain listener of the same broker. Cases: `tcp` on 1883, `ws` on 9001, `wss` on
9002 with `certificate_authority` (the certificate names an address, so `skip_cert_cn_check: true`), `wss` to
test.mosquitto.org on 8081 with no CA for the certificate bundle, and two negatives that must never connect: a
@@ -29,3 +29,25 @@ different CA (`mbedtls_ssl_handshake returned -0x2700`) and the self-signed serv
default in the generated sdkconfig. The TLS session costs about 29 kB of internal heap while connected; the public
broker carries other devices' `esphome/discover` traffic, which is noise. Results:
https://github.com/esphome/esphome/pull/16056#issuecomment-6301034019
## TLS trust sources on ESP-IDF (#14318)
One build per trust source from `templates/mqtt-tls-dut.yaml`: broker only (port must resolve to 1883),
`certificate_authority` with no port (must resolve to 8883), `verify_ssl: false` with no CA, the CA plus
`client_certificate` and `client_certificate_key` on the 8884 listener that requires one, `transport: wss` with the CA,
`tls: true` to test.mosquitto.org on 8886 for the certificate bundle, and `tls: true` to the local self-signed broker as
the negative. `scripts/mqtt/mosquitto-ws.conf` now has the 8883 and 8884 listeners and `scripts/mqtt/make_test_ca.sh`
also writes `client.crt`/`client.key`. Before flashing, check each build's `src/esphome/core/defines.h` for
`USE_MQTT_TLS`, `USE_MQTT_CERT_BUNDLE` and `USE_MQTT_TLS_INSECURE` and its `sdkconfig.<name>` for
`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE` and `CONFIG_ESP_TLS_INSECURE`: only the bundle builds may carry the bundle and only
the unverified build the insecure flags. `scripts/mqtt/flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST
local|public|neg` runs one build end to end (flash, bounded capture, `cases.sh` with a restart for the local broker,
counts and the broker's per-listener connection lines) and prints the config dump's `TLS:` line (`CA certificate`,
`certificate bundle` or `no verification`). The CLI side (`esphome/mqtt.py`) is covered without a board by
`scripts/mqtt/cli_tls_check.sh TREE [neg] YAML...`, which runs `esphome clean-mqtt` under a timeout: status 124
after `Connected to MQTT broker!` is a pass, and with `neg` each config must instead fail with
`CERTIFICATE_VERIFY_FAILED` and never connect. Python's default SSL
context rejects a CA that lacks a `keyUsage` extension (`CA cert does not include key usage extension`) while mbedTLS
on the device accepts it, which is why the test CA carries `basicConstraints` and `keyUsage`. A broker kill shows as one
`Last esp-tls error: 0x8008` on every build, TLS or not. Results:
https://github.com/esphome/esphome/pull/14318#issuecomment-6311863827
@@ -9,3 +9,18 @@ listener. Broadcast to boards is lossy under power save, unicast is not. Flood a
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.
## udp.write payload shapes (#21263, ApplyBytes)
`templates/udp-write-dut.yaml` puts every payload shape behind a template button (constant 4 bytes twice, 3, 9, 0,
128, 129 and 600 bytes, a 300 byte lambda, a simple value, a string) plus an `on_receive` that replies with the
received bytes reversed. `scripts/udp/write_cases.sh DEVICE_IP KEY` records the device's datagrams with
`udp_listen.py` on the test host, presses the buttons two seconds apart, sends one unicast datagram for the echo and
compares everything byte for byte with `check.py`; a missing datagram shifts every later one, so read the first
mismatch. Before flashing, check the generated `main.cpp`: on ESP8266 a constant payload is copied out of its
`PROGMEM` table into `uint8_t esphome__bytes[N]` up to the stack limit and into `std::make_unique<uint8_t[]>` beyond,
identical payloads share one table, a lambda is sent from its vector. An empty constant payload is generated as
`send_packet(nullptr, 0)`, and the raw lwIP UDP `sendto` used on ESP8266 rejected a null buffer with `EINVAL`
(`sendto() error 22` in the log, no datagram) until it accepted `nullptr` with a zero length; the old
`TemplatableBytes::visit` had passed a zero length stack buffer instead. Compare sizes against the stacked PR's own
head when the PR is stacked, not against dev. Results: https://github.com/esphome/esphome/pull/21263
@@ -29,3 +29,32 @@ clients. Entity URLs use the entity name, URL encoded, not an object id. The HTT
request of the boot; compare after the mix and after the SSE round, with two boots when the numbers are close.
Results: https://github.com/esphome/esphome/pull/21109#issuecomment-6300582328 and
https://github.com/esphome/esphome/pull/21038#issuecomment-6301310182
## Entity icon and device class readback (#21111)
Covers every path that reads an entity's icon or device class: the native API listing, MQTT discovery, the web_server
`/events` listing and REST endpoints, and `dump_config`. `templates/entity-icon-dut.yaml` (ESP8266 board with wifi,
encrypted api, ota, web_server and mqtt against a local broker) carries a sensor with a 63 character icon, one with no
icon or device class, an internal sensor, and binary_sensor, switch, button, text_sensor, number, select, event, cover
and text entities with icons and device classes. The per-run file supplies `name` and `broker_ip` through
substitutions. Flash the PR and base builds under different names, then from the scratch directory:
```sh
scripts/entity_icons/readback.sh DEVICE_IP KEY NAME BROKER_HOST cap-NAME.txt # once per build
scripts/entity_icons/compare.sh ic-base ic-pr # api, web, mqttn, dump all identical
```
`readback.sh` takes about 25 s once the device is on the network: 12 s of `mosquitto_sub` on `homeassistant/#` and
`NAME/#`, the API listing (`api_list.py`, one JSON line per entity with icon, device class, unit, options and event
types) and `web_dump.py` (6 s on `/events`, keeping only the listing rows that carry `domain`, then a GET of each
listed id with `?detail=all` and one retry, because the ESP8266 async server occasionally leaves a request unanswered
within 5 s). The web ids are `domain/Name`, not object ids, and `/event/<name>` has no GET handler (404 on every
build). `mqtt_norm.py` drops the `dev` block (compile time) and the `NAME/debug` topic (heap probe), so a clean run
differs in nothing. `dump_config` prints icons and device classes only for some domains; button, event and
binary_sensor icons never appear there.
Found with this flow on 2026-10-21: since #19577 every ESP8266 `web_server` build that lists a cover, climate, lock,
valve, update, alarm_control_panel or water_heater entity reboots with `Exception (3) LoadStoreErr` in
`json::linked(const char *)` (the ROM `strlen` on a flash address), fixed by #21316. The cover in the template keeps
that path covered; before #21316 is merged, apply its one line change to both trees or the web_server comparison
cannot run.
@@ -0,0 +1,56 @@
"""List every entity over the native API with its icon, device class and unit: one sorted JSON line each.
usage: api_list.py HOST KEY OUT
"""
import asyncio
import json
import pathlib
import sys
from aioesphomeapi import APIClient
HOST, KEY, OUT = sys.argv[1], sys.argv[2], sys.argv[3]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
rows = []
for e in ents:
row = {
"type": type(e).__name__,
"name": e.name,
"object_id": e.object_id,
"icon": e.icon,
}
for k in (
"device_class",
"unit_of_measurement",
"event_types",
"options",
"mode",
):
if hasattr(e, k):
v = getattr(e, k)
row[k] = (
list(v)
if isinstance(v, (list, tuple))
else (v.name if hasattr(v, "name") else v)
)
row["entity_category"] = (
e.entity_category.name
if hasattr(e.entity_category, "name")
else e.entity_category
)
row["disabled_by_default"] = e.disabled_by_default
rows.append(row)
rows.sort(key=lambda r: (r["type"], r["name"]))
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
f.writelines(json.dumps(r, sort_keys=True) + "\n" for r in rows)
print(f"{len(rows)} entities listed")
await c.disconnect()
asyncio.run(main())
@@ -0,0 +1,13 @@
#!/bin/sh
# compare.sh NAME_A NAME_B : diff the readback.sh outputs of two device names (api, web, mqttn and, when present, dump)
# after replacing each name with DEV. Exit 1 when any pair differs; the diffs stay in cmp-KIND.diff.
A=$1; B=$2; rc=0
[ -n "$A" ] && [ -n "$B" ] || { echo "usage: compare.sh NAME_A NAME_B"; exit 2; }
for k in api web mqttn dump; do
ext=jsonl; [ $k = dump ] && ext=txt
[ -f "$PWD/$k-$A.$ext" ] && [ -f "$PWD/$k-$B.$ext" ] || { echo "$k: missing"; [ $k = dump ] || rc=1; continue; }
sed "s/$A/DEV/g; s/$(echo "$A" | tr - _)/DEV/g" "$PWD/$k-$A.$ext" > "$PWD/cmp-$k-a.txt"
sed "s/$B/DEV/g; s/$(echo "$B" | tr - _)/DEV/g" "$PWD/$k-$B.$ext" > "$PWD/cmp-$k-b.txt"
if diff -u "$PWD/cmp-$k-a.txt" "$PWD/cmp-$k-b.txt" > "$PWD/cmp-$k.diff"; then echo "$k: identical ($(wc -l < "$PWD/cmp-$k-b.txt" | tr -d ' ') lines)"; else echo "$k: DIFFERS"; head -20 "$PWD/cmp-$k.diff"; rc=1; fi
done
exit $rc
@@ -0,0 +1,37 @@
"""Normalise mosquitto_sub output (topic TAB payload) for one device: drop other devices, replace the name, drop dev block.
usage: mqtt_norm.py IN NAME OUT
"""
import json
import pathlib
import sys
IN, NAME, OUT = sys.argv[1], sys.argv[2], sys.argv[3]
rows = {}
with pathlib.Path(IN).open(encoding="utf-8") as f:
for line in f:
topic, _, payload = line.rstrip("\n").partition("\t")
if (
f"/{NAME}/" not in topic and not topic.startswith(f"{NAME}/")
) or topic == f"{NAME}/debug":
continue
topic = topic.replace(NAME, "DEV")
try:
obj = json.loads(payload)
except ValueError:
rows[topic] = payload.replace(NAME, "DEV")
continue
if isinstance(obj, dict):
obj.pop("dev", None)
obj = {
k: (v.replace(NAME, "DEV") if isinstance(v, str) else v)
for k, v in obj.items()
}
rows[topic] = obj
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
f.writelines(
json.dumps({"topic": t, "payload": rows[t]}, sort_keys=True) + "\n"
for t in sorted(rows)
)
print(f"{len(rows)} topics for {NAME}")
@@ -0,0 +1,18 @@
#!/bin/sh
# readback.sh DEVICE_IP KEY NAME BROKER_HOST [CAPTURE] : record how a device built from templates/entity-icon-dut.yaml
# reports its entity icons and device classes: the native API listing (api-NAME.jsonl), the retained MQTT discovery
# payloads and states (mqtt-NAME.txt, normalised to mqttn-NAME.jsonl with the device name replaced and the debug topic
# dropped), the web_server /events listing plus every REST endpoint with ?detail=all (web-NAME.jsonl) and, when a
# serial CAPTURE is given, the dump_config Icon and Device Class lines (dump-NAME.txt, timestamps and line numbers
# removed). KEY as for api_button.py. Outputs go to the working directory; compare two names with compare.sh.
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; DEV=$1; KEY=$2; N=$3; B=$4; CAP=$5; rc=0; TAB=$(printf '\t')
[ -n "$DEV" ] && [ -n "$KEY" ] && [ -n "$N" ] && [ -n "$B" ] || { echo "usage: readback.sh DEVICE_IP KEY NAME BROKER_HOST [CAPTURE]"; exit 2; }
timeout 12 mosquitto_sub -h "$B" -v -t 'homeassistant/#' -t "$N/#" -F "%t$TAB%p" > "$PWD/mqtt-$N.txt" 2> "$PWD/mqtt-$N.err"
$PY "$HERE/mqtt_norm.py" "$PWD/mqtt-$N.txt" "$N" "$PWD/mqttn-$N.jsonl" || rc=1
$PY "$HERE/api_list.py" "$DEV" "$KEY" "$PWD/api-$N.jsonl" || { echo "API listing failed"; rc=1; }
$PY "$HERE/web_dump.py" "$DEV" "$PWD/web-$N.jsonl" || { echo "web_server dump failed"; rc=1; }
if [ -n "$CAP" ]; then
LC_ALL=C grep -a '\]: Icon: \|\]: Device Class: ' "$CAP" | LC_ALL=C sed 's/\x1b\[[0-9;]*m//g; s/^\[[^]]*\]//; s/:[0-9]*\]:/]:/' | sort > "$PWD/dump-$N.txt"
echo "dump_config icon lines $(grep -c 'Icon:' "$PWD/dump-$N.txt"), device class lines $(grep -c 'Device Class:' "$PWD/dump-$N.txt")"
fi
exit $rc
@@ -0,0 +1,99 @@
"""Dump the web_server view of every entity: the /events listing (detail all) and each REST endpoint with ?detail=all.
usage: web_dump.py HOST OUT
"""
import json
import pathlib
import socket
import sys
import time
import urllib.parse
import urllib.request
HOST, OUT = sys.argv[1], sys.argv[2]
def sse_listing(seconds: float) -> list[dict]:
s = socket.create_connection((HOST, 80), timeout=5)
s.sendall(
b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n"
% HOST.encode()
)
buf = b""
deadline = time.monotonic() + seconds
s.settimeout(1.0)
while time.monotonic() < deadline:
try:
data = s.recv(65536)
except TimeoutError:
continue
if not data:
break
buf += data
s.close()
head, _, body = buf.partition(b"\r\n\r\n")
if b"chunked" in head.lower():
out = b""
while True:
i = body.find(b"\r\n")
if i < 0:
break
size = int(body[:i].split(b";")[0], 16)
out += body[i + 2 : i + 2 + size]
body = body[i + 2 + size + 2 :]
if size == 0:
break
body = out
rows = []
for block in body.decode(errors="replace").split("\n\n"):
ev, data = None, None
for line in block.split("\n"):
line = line.rstrip("\r")
if line.startswith("event:"):
ev = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
if ev in ("state", "state_detail_all") and data:
try:
obj = json.loads(data)
except ValueError:
rows.append({"event": ev, "invalid": data})
continue
# bare state updates that land inside the window carry no domain; only the listing rows are kept
if "domain" in obj:
rows.append({"event": ev, **obj})
return rows
def get_json(url: str) -> dict:
"""One retry: the ESP8266 async server occasionally leaves a request unanswered."""
for attempt in (1, 2):
try:
with urllib.request.urlopen(url, timeout=5) as resp:
return json.loads(resp.read())
except TimeoutError:
if attempt == 2:
raise
print(f"retrying {url}", flush=True)
return {}
rows = sse_listing(6)
ids = sorted({r["id"] for r in rows if "id" in r})
rest = []
for ident in ids:
domain, _, obj = ident.partition("/")
url = f"http://{HOST}/{domain}/{urllib.parse.quote(obj)}?detail=all"
try:
rest.append({"url": url.replace(HOST, "HOST"), **get_json(url)})
except Exception as e: # noqa: BLE001
rest.append({"url": url.replace(HOST, "HOST"), "error": str(e)})
time.sleep(0.3)
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
f.writelines(
json.dumps(r, sort_keys=True) + "\n"
for r in sorted(rows, key=lambda r: (r.get("id", ""), r["event"]))
)
f.writelines(json.dumps(r, sort_keys=True) + "\n" for r in rest)
print(f"{len(rows)} sse rows, {len(ids)} ids, {len(rest)} rest rows")
@@ -24,8 +24,10 @@ if [ "$RESTART" = restart ]; then
pid=$(cat "$WORK/mosquitto.pid" 2>/dev/null); [ -n "$pid" ] || { echo "no mosquitto.pid in $WORK, cannot restart the broker"; exit 1; }
kill "$pid" || { echo "broker PID $pid is not running, nothing to restart"; exit 1; }; sleep 8
nc -z -w 3 "$B" 1883 && { echo "the old broker still answers on 1883 after the kill"; exit 1; }
(cd "$WORK" && mosquitto -c mosquitto-ws.conf > mosquitto.out 2>&1 & echo $! > "$WORK/mosquitto.pid"); sleep 25
for port in 1883 9001 9002; do nc -z -w 3 "$B" "$port" || { echo "broker port $port not listening after the restart"; exit 1; }; done
# exec so the PID is the broker's own and no shell stays behind holding the caller's output pipe open
(cd "$WORK" && exec mosquitto -c mosquitto-ws.conf > mosquitto.out 2>&1 < /dev/null) &
echo $! > "$WORK/mosquitto.pid"; sleep 25
for port in $(awk '/^listener/ {print $2}' "$WORK/mosquitto-ws.conf"); do nc -z -w 3 "$B" "$port" || { echo "broker port $port not listening after the restart"; exit 1; }; done
tail -n +$((n1+1)) "$LOG" | grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected" || { echo "the device did not reconnect after the broker restart"; exit 1; }
else
echo "=== $N: no broker restart (pass 'restart' as the 7th argument for a local broker)"
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# cli_tls_check.sh TREE [neg] YAML... : run `esphome clean-mqtt` from the checkout TREE for each config, which goes
# through the same TLS setup in esphome/mqtt.py as `esphome logs` over MQTT, without needing a board. clean-mqtt never
# exits on its own, so it runs under a 25 s timeout: status 124 after "Connected to MQTT broker!" means the CLI
# connected. With neg every config must instead fail with CERTIFICATE_VERIFY_FAILED and never connect, so a negative
# that fails for another reason (bad YAML, broker down) is reported as a failure too. A relative YAML path is taken
# from the working directory, where the cleanmqtt-NAME.log outputs also go. Exit 1 when any config missed its result.
TREE=$1; shift; PY=${PYTHON:-python3}; NEG=0
[ "$1" = neg ] && { NEG=1; shift; }
[ -d "$TREE/esphome" ] && [ -n "$1" ] || { echo "usage: cli_tls_check.sh TREE [neg] YAML..."; exit 2; }
bad=0
for y in "$@"; do
case $y in /*) ;; *) y=$PWD/$y;; esac
n=$(basename "$y" .yaml); log=$PWD/cleanmqtt-$n.log
(cd "$TREE" && PYTHONUNBUFFERED=1 timeout 25 $PY -m esphome clean-mqtt "$y" > "$log" 2>&1); rc=$?
if [ "$NEG" = 0 ] && [ "$rc" = 124 ] && grep -q "Connected to MQTT broker" "$log"; then
echo "$n: connected"
elif [ "$NEG" = 1 ] && grep -q "CERTIFICATE_VERIFY_FAILED" "$log" && ! grep -q "Connected to MQTT broker" "$log"; then
echo "$n: rejected (CERTIFICATE_VERIFY_FAILED)"
else
echo "$n: FAILED (status $rc): $(grep -a -m1 -i 'cannot connect\|error' "$log" | cut -c1-160)"; bad=$((bad+1))
fi
done
[ "$bad" = 0 ]
@@ -0,0 +1,53 @@
#!/bin/sh
# flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST MODE [SUB_PORT] : flash YAML from the checkout TREE over the serial
# PORT, capture the serial log (cap-NAME.txt in the working directory), wait for the first MQTT connect or TLS error,
# then drive the device with cases.sh (KEY as for cases.sh, - for a plaintext API). MODE local runs cases.sh with a
# broker restart (the working directory must hold mosquitto-ws.conf and mosquitto.pid), public runs it without a
# restart, neg only watches for 100 s (a build that must never connect). SUB_PORT is the plain listener cases.sh
# subscribes on (default 1883). Ends with connect, disconnect, handshake error and crash counts, the config dump's
# TLS line, the probe heap lines and the broker listener each connection used. Exit 1 when the capture did not start,
# the device reached no MQTT event within 90 s (local and public), it crashed, or the mode's own check failed.
TREE=$1; YAML=$2; PORT=$3; KEY=$4; B=$5; MODE=$6; SP=${7:-1883}
case $YAML in /*) ;; *) YAML=$PWD/$YAML;; esac
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; N=$(basename "$YAML" .yaml); CAP=$PWD/cap-$N.txt; ULOG=$PWD/upload-$N.log
[ -d "$TREE/esphome" ] && [ -e "$PORT" ] && [ -n "$KEY" ] && [ -n "$B" ] || { echo "usage: flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST local|public|neg [SUB_PORT]"; exit 2; }
case $MODE in local|public|neg) ;; *) echo "MODE must be local, public or neg"; exit 2;; esac
pkill -f "[s]erial_capture.py $PORT "; sleep 1
echo "=== $N: flash ($(date +%T))"
(cd "$TREE" && PYTHONUNBUFFERED=1 timeout 300 $PY -m esphome upload "$YAML" --device "$PORT" > "$ULOG" 2>&1) || { echo "upload failed"; tail -5 "$ULOG"; exit 1; }
SECS=200; [ "$MODE" = neg ] && SECS=100
# a stale capture from an earlier run must not satisfy the wait below
rm -f "$CAP"
$PY "$HERE/../serial_capture.py" "$PORT" $SECS "$CAP" > "$PWD/cap-$N.stderr" 2>&1 &
cap_pid=$!
i=0; until grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected\|mbedtls_ssl_handshake returned\|Failed to verify" "$CAP" 2>/dev/null || [ $i -ge 45 ]; do sleep 2; i=$((i+1)); done
kill -0 $cap_pid 2>/dev/null && [ -f "$CAP" ] || { echo "serial capture is not running on $PORT"; tail -3 "$PWD/cap-$N.stderr"; exit 1; }
DEV=$(grep -a -o 'IP Address: [0-9.]*' "$CAP" | head -1 | awk '{print $3}')
if [ $i -ge 45 ] || [ -z "$DEV" ]; then
echo "=== $N: no MQTT event or no IP within 90 s ($(date +%T))"
[ "$MODE" = neg ] || { pkill -f "[s]erial_capture.py $PORT "; exit 1; }
else
echo "=== $N: first MQTT event after $((i*2)) s, device $DEV ($(date +%T))"
fi
grep -a 'ESPHome version\|\]: Transport\|\]: TLS:' "$CAP" | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-120
rc=0
case $MODE in
local) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV" restart; rc=$?;;
public) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV"; rc=$?;;
neg) sleep 70;;
esac
wait $cap_pid
strip() { sed 's/\x1b\[[0-9;]*m//g'; }
# count the backend's own events so the result does not depend on on_connect/on_disconnect log lines in the config
connects=$(grep -a -c 'MQTT_EVENT_CONNECTED' "$CAP"); errors=$(grep -a -c 'mbedtls_ssl_handshake returned\|Failed to verify certificate' "$CAP")
crashes=$(grep -a -c 'Guru\|abort()\|Backtrace' "$CAP")
echo "=== $N: connects $connects, disconnects $(grep -a -c 'MQTT_EVENT_DISCONNECTED' "$CAP"), handshake errors $errors, crashes $crashes"
grep -a 'mbedtls_ssl_handshake returned\|Failed to verify\|esp-tls' "$CAP" | strip | sed 's/^\[[^]]*\]//' | sort | uniq -c | sort -rn | head -4 | cut -c1-150
grep -a '\[probe:' "$CAP" | strip | sed 's/.*\[probe:[0-9]*\]: //' | sed -n '1p;$p'
[ -f "$PWD/mosquitto.log" ] && [ -n "$DEV" ] && echo "broker listeners used by $DEV: $(grep "New connection from $DEV" "$PWD/mosquitto.log" | sed 's/.*on port //' | sort | uniq -c | tr '\n' ';')"
[ "$crashes" = 0 ] || { echo "device crashed $crashes time(s)"; exit 1; }
if [ "$MODE" = neg ]; then
[ "$connects" = 0 ] && [ "$errors" -gt 0 ] || { echo "negative case: expected no connect and a handshake error"; exit 1; }
else
[ "$rc" = 0 ] || { echo "cases.sh failed with status $rc"; exit 1; }
fi
@@ -1,13 +1,21 @@
#!/bin/sh
# make_test_ca.sh BROKER_IP : a throwaway CA and a server certificate for the broker address (30 days), written into
# the working directory; run it in the scratch directory where a copy of mosquitto-ws.conf lives. The device
# config takes ca.crt as certificate_authority (skip_cert_cn_check: true, the name check does not match an address);
# other-ca.crt is a second CA for the negative case.
# make_test_ca.sh BROKER_IP : a throwaway CA, a server certificate for the broker address and a client certificate
# (30 days), written into the working directory; run it in the scratch directory where a copy of mosquitto-ws.conf
# lives. The device config takes ca.crt as certificate_authority (skip_cert_cn_check: true, the name check does not
# match an address) and client.crt/client.key as client_certificate/client_certificate_key for the listener that
# requires one; other-ca.crt is a second CA for the negative case. The CA carries basicConstraints and keyUsage
# because Python's default SSL context (esphome logs, esphome clean-mqtt) rejects a CA without them.
set -e
IP=$1
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 30 -subj "/CN=hwtest-ca"
[ -n "$IP" ] || { echo "usage: make_test_ca.sh BROKER_IP"; exit 2; }
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 30 -subj "/CN=hwtest-ca" \
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=hwtest-broker"
printf "subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n" "$IP" > san.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 30 -extfile san.ext
openssl req -x509 -newkey rsa:2048 -nodes -keyout other-ca.key -out other-ca.crt -days 30 -subj "/CN=other-ca"
openssl verify -CAfile ca.crt server.crt
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=hwtest-client"
printf "extendedKeyUsage=clientAuth\n" > client.ext
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 30 -extfile client.ext
openssl req -x509 -newkey rsa:2048 -nodes -keyout other-ca.key -out other-ca.crt -days 30 -subj "/CN=other-ca" \
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl verify -CAfile ca.crt server.crt client.crt
@@ -1,8 +1,20 @@
# Copy next to the certificates in the scratch directory and start it there with
# `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` (cases.sh restarts that PID): plain MQTT, MQTT over WebSocket, and MQTT over WebSocket with TLS (server.crt and
# server.key from make_test_ca.sh in the same directory). log_type all gives the listener port per connection.
# `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` (cases.sh restarts that PID): plain MQTT, MQTT over TLS,
# MQTT over TLS that requires a client certificate, MQTT over WebSocket, and MQTT over WebSocket with TLS
# (server.crt, server.key, ca.crt from make_test_ca.sh in the same directory). log_type all gives the listener port
# per connection.
listener 1883
protocol mqtt
listener 8883
protocol mqtt
certfile server.crt
keyfile server.key
listener 8884
protocol mqtt
certfile server.crt
keyfile server.key
cafile ca.crt
require_certificate true
listener 9001
protocol websockets
listener 9002
@@ -0,0 +1,40 @@
"""Compare the listener log (JSON lines from udp_listen.py) against the udp-write-dut.yaml payload sequence.
usage: check.py LISTEN_JSONL ECHO_HEX (ECHO_HEX is the datagram sent to the device for the echo case)
"""
import json
import pathlib
import sys
with pathlib.Path(sys.argv[1]).open(encoding="utf-8") as f:
log = [json.loads(line) for line in f if line.strip()]
echo_in = bytes.fromhex(sys.argv[2])
small = bytes([0xAA, 0x55, 0x01, 0x02])
expected = [
("small 1/2", small),
("small 2/2", small),
("simple", bytes([1, 2, 3])),
("text", b"hello udp"),
("empty", b""),
("stack128", bytes((0x10 + i) & 0xFF for i in range(128))),
("heap129", bytes((0x20 + i) & 0xFF for i in range(129))),
("heap600", bytes((0x30 + i) & 0xFF for i in range(600))),
("lambda300", bytes((i * 7) & 0xFF for i in range(300))),
("echo", echo_in[::-1]),
]
got = [bytes.fromhex(e["hex"]) for e in log]
ok = 0
for i, (name, want) in enumerate(expected):
have = got[i] if i < len(got) else None
res = (
"OK"
if have == want
else f"MISMATCH got {None if have is None else have[:16].hex() + '... len ' + str(len(have))}"
)
ok += res == "OK"
print(f"{name:10s} {len(want):4d} B {res}")
print(
f"{ok}/{len(expected)} payloads byte-exact, {len(got)} datagrams received, sources {sorted({e['src'] for e in log})}"
)
sys.exit(0 if ok == len(expected) and len(got) == len(expected) else 1)
@@ -0,0 +1,32 @@
"""Log every datagram on PORT for SECONDS as one JSON line: time, source, length, hex payload."""
import json
import pathlib
import socket
import sys
import time
port, secs, out = int(sys.argv[1]), float(sys.argv[2]), sys.argv[3]
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", port))
s.settimeout(1.0)
end = time.monotonic() + secs
with pathlib.Path(out).open("w", encoding="utf-8") as f:
while time.monotonic() < end:
try:
data, (ip, sport) = s.recvfrom(65535)
except TimeoutError:
continue
f.write(
json.dumps(
{
"t": time.strftime("%H:%M:%S"),
"src": ip,
"len": len(data),
"hex": data.hex(),
}
)
+ "\n"
)
f.flush()
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# write_cases.sh DEVICE_IP KEY [PORT] : drive a device built from templates/udp-write-dut.yaml. Records every datagram
# the device sends to PORT (the udp component's port, default 18511) with udp_listen.py in the working directory,
# presses the eight payload buttons over the API two seconds apart (KEY as for api_button.py, - for a plaintext API),
# sends one 16 byte unicast datagram to the device for the echo case, then compares the recorded datagrams with
# check.py. The recorder runs until the driver stops it. Exit 1 when the recorder did not start or a payload is
# missing or differs; listen.jsonl stays in the working directory.
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; DEV=$1; KEY=$2; PORT=${3:-18511}
[ -n "$DEV" ] && [ -n "$KEY" ] || { echo "usage: write_cases.sh DEVICE_IP KEY [PORT]"; exit 2; }
ECHO=0102030405060708090a0b0c0d0e0f10
rm -f "$PWD/listen.jsonl"
$PY "$HERE/udp_listen.py" "$PORT" 600 "$PWD/listen.jsonl" &
lst=$!
sleep 2
kill -0 $lst 2>/dev/null || { echo "udp_listen.py is not running on port $PORT"; exit 1; }
for b in Small Simple Text Empty Stack128 Heap129 Heap600 Lambda300; do
$PY "$HERE/../api_button.py" "$DEV" "$KEY" press "$b" > /dev/null || echo "press $b failed"; sleep 2
done
$PY -c "import socket; socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(bytes.fromhex('$ECHO'), ('$DEV', $PORT))"
sleep 5
kill $lst 2>/dev/null; wait $lst 2>/dev/null
$PY "$HERE/check.py" "$PWD/listen.jsonl" "$ECHO"
@@ -0,0 +1,148 @@
# Entity icon and device class readback (esphome#21111): the per-run file supplies name and broker_ip through substitutions and includes this file as a package. Every readback path is covered: API listing, MQTT discovery, web_server JSON and dump_config. See reference/recipes/web_server.md.
esp8266:
board: d1_mini
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
ota:
- platform: esphome
web_server:
port: 80
mqtt:
broker: ${broker_ip}
reboot_timeout: 0s
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("probe", "free heap %u, max block %u", (unsigned) ESP.getFreeHeap(), (unsigned) ESP.getMaxFreeBlockSize());
esphome:
name: ${name}
sensor:
- platform: template
name: Temp
id: temp
device_class: temperature
unit_of_measurement: "°C"
icon: mdi:thermometer
accuracy_decimals: 1
lambda: return 21.5;
update_interval: 30s
- platform: template
name: Long Icon
id: long_icon
icon: mdi:this-icon-name-is-exactly-sixty-three-characters-long-abcde
device_class: humidity
unit_of_measurement: "%"
lambda: return 40.0;
update_interval: 30s
- platform: template
name: Plain
id: plain
lambda: return 1.0;
update_interval: 30s
- platform: template
name: Hidden
id: hidden
internal: true
icon: mdi:eye-off
device_class: power
lambda: return 2.0;
update_interval: 30s
binary_sensor:
- platform: template
name: Motion
id: motion
device_class: motion
icon: mdi:motion-sensor
lambda: return false;
switch:
- platform: template
name: Relay
id: relay
device_class: outlet
icon: mdi:power-plug
optimistic: true
button:
- platform: template
name: Reboot
id: reboot_btn
device_class: restart
icon: mdi:restart
on_press:
- logger.log: "Reboot pressed"
text_sensor:
- platform: template
name: Version
id: version
device_class: timestamp
icon: mdi:tag
lambda: return {"2026-10-21T00:00:00+00:00"};
update_interval: 30s
number:
- platform: template
name: Level
id: level
device_class: battery
icon: mdi:knob
unit_of_measurement: "%"
min_value: 0
max_value: 100
step: 1
optimistic: true
initial_value: 50
select:
- platform: template
name: Mode
id: mode
icon: mdi:format-list-bulleted
options: [a, b, c]
optimistic: true
initial_option: a
event:
- platform: template
name: Doorbell
id: doorbell
device_class: doorbell
icon: mdi:doorbell
event_types: [ring, knock]
cover:
- platform: template
name: Garage
id: garage
device_class: garage
icon: mdi:garage
optimistic: true
text:
- platform: template
name: Secret Note
id: note
mode: password
icon: mdi:form-textbox-password
optimistic: true
initial_value: "x"
@@ -0,0 +1,99 @@
# MQTT TLS trust-source test (#14318, needs that PR's tree for tls and verify_ssl): one build per trust source, with
# the name and the mqtt block edited per file (see reference/recipes/mqtt.md). Secrets: broker_ip (the local broker
# from make_test_ca.sh), mqtt_ca, mqtt_client_cert and mqtt_client_key as block scalars from the same run.
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: VERBOSE
logs:
wifi: DEBUG
api: DEBUG
api.connection: DEBUG
scheduler: DEBUG
component: DEBUG
sensor: DEBUG
esp32: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("probe", "mqtt connected %d, internal free %u, largest block %u", id(mq).is_connected(),
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
sensor:
- platform: template
name: Counter
update_interval: 2s
accuracy_decimals: 0
lambda: |-
static uint32_t n = 0;
return n++;
switch:
- platform: template
name: Relay
id: relay
optimistic: true
on_turn_on:
- logger.log: "Relay ON"
on_turn_off:
- logger.log: "Relay OFF"
button:
- platform: template
name: Publish big
on_press:
- mqtt.publish:
topic: ${name}/big
payload: !lambda |-
return std::string(3000, 'q');
substitutions:
name: mqtt-dut
esphome:
name: ${name}
mqtt:
id: mq
broker: !secret broker_ip # test.mosquitto.org for the bundle run
# No port: it must resolve to 1883 without TLS options and to 8883 with them. Spell it out only for the
# client-certificate listener (8884), wss (9002) and the public bundle broker (8886).
# The next two lines are the own-CA build. Remove both for the broker-only build (skip_cert_cn_check alone turns
# TLS on), replace both with verify_ssl: false for the unverified build (the flag is rejected next to it), or with
# tls: true for the bundle builds (public broker, and the local one as the negative).
skip_cert_cn_check: true # with the local CA: the certificate names an address
certificate_authority: !secret mqtt_ca
# client_certificate: !secret mqtt_client_cert # with the CA, port 8884
# client_certificate_key: !secret mqtt_client_key
# transport: wss # with the CA, port 9002
reboot_timeout: 0s
discovery: false
on_message:
- topic: ${name}/cmd
then:
- logger.log:
format: "cmd payload: %s"
args: ["x.c_str()"]
on_connect:
- logger.log: "MQTT on_connect"
on_disconnect:
- logger.log: "MQTT on_disconnect"
@@ -0,0 +1,93 @@
# udp.write payload shapes (#21263): every shape the action takes behind a template button, plus an on_receive that
# replies with the received bytes reversed through a lambda. Per run file: substitutions name and listener_ip (the test
# host that runs scripts/udp/udp_listen.py), then packages: common: !include this file. Drive it with
# scripts/udp/write_cases.sh DEVICE_IP KEY, which presses the buttons, sends the echo datagram and checks every payload.
esp8266:
board: d1_mini
logger:
level: VERBOSE
logs:
wifi: DEBUG
api: DEBUG
api.connection: DEBUG
scheduler: DEBUG
component: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
interval:
- interval: 10s
then:
- lambda: |-
ESP_LOGI("probe", "free heap %u, max block %u", (unsigned) ESP.getFreeHeap(), (unsigned) ESP.getMaxFreeBlockSize());
esphome:
name: ${name}
udp:
id: u
addresses:
- ${listener_ip}
on_receive:
- lambda: ESP_LOGI("echo", "received %u bytes", (unsigned) data.size());
- udp.write: !lambda |-
std::vector<uint8_t> v(data.rbegin(), data.rend());
return v;
button:
- platform: template
name: Small
on_press:
- logger.log: "case small"
- udp.write: [0xAA, 0x55, 0x01, 0x02]
- udp.write: [0xAA, 0x55, 0x01, 0x02]
- platform: template
name: Simple
on_press:
- logger.log: "case simple"
- udp.write: [1, 2, 3]
- platform: template
name: Text
on_press:
- logger.log: "case text"
- udp.write: "hello udp"
- platform: template
name: Empty
on_press:
- logger.log: "case empty"
- udp.write: []
- platform: template
name: Stack128
on_press:
- logger.log: "case stack128"
- udp.write: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F]
- platform: template
name: Heap129
on_press:
- logger.log: "case heap129"
- udp.write: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA0]
- platform: template
name: Heap600
on_press:
- logger.log: "case heap600"
- udp.write: [0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87]
- platform: template
name: Lambda300
on_press:
- logger.log: "case lambda300"
- udp.write: !lambda |-
std::vector<uint8_t> v(300);
for (size_t i = 0; i < v.size(); i++) v[i] = (uint8_t) (i * 7);
return v;
-1
View File
@@ -1 +0,0 @@
b024c362cb4f55a7498b76dfd5602131167411a31e119657a79e909df12c752b
@@ -21,6 +21,7 @@ module.exports = {
'github-actions',
'by-code-owner',
'has-tests',
'api.proto',
'needs-tests',
'needs-docs',
'needs-developer-docs',
@@ -272,6 +272,18 @@ async function detectTests(changedFiles) {
return labels;
}
// Strategy: API protocol change detection
// aioesphomeapi carries copies of both files, so every change here needs a sync there
const API_PROTO_FILES = ['esphome/components/api/api.proto', 'esphome/components/api/api_options.proto'];
async function detectApiProto(changedFiles) {
const labels = new Set();
if (changedFiles.some(file => API_PROTO_FILES.includes(file))) {
labels.add('api.proto');
}
return labels;
}
// Strategy: PR Template Checkbox detection
async function detectPRTemplateCheckboxes(context) {
const labels = new Set();
@@ -460,6 +472,7 @@ module.exports = {
detectGitHubActionsChanges,
detectCodeOwner,
detectTests,
detectApiProto,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
+4
View File
@@ -10,6 +10,7 @@ const {
detectGitHubActionsChanges,
detectCodeOwner,
detectTests,
detectApiProto,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
@@ -114,6 +115,7 @@ module.exports = async ({ github, context }) => {
actionsLabels,
codeOwnerLabels,
testLabels,
apiProtoLabels,
checkboxLabels,
deprecatedResult,
maintainerAccess
@@ -128,6 +130,7 @@ module.exports = async ({ github, context }) => {
detectGitHubActionsChanges(changedFiles),
detectCodeOwner(github, context, changedFiles),
detectTests(changedFiles),
detectApiProto(changedFiles),
detectPRTemplateCheckboxes(context),
detectDeprecatedComponents(github, context, changedFiles),
detectMaintainerAccess(context)
@@ -156,6 +159,7 @@ module.exports = async ({ github, context }) => {
...actionsLabels,
...codeOwnerLabels,
...testLabels,
...apiProtoLabels,
...checkboxLabels,
...deprecatedLabels
]);
@@ -1,6 +1,7 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
detectApiProto,
detectMergeBranch,
detectNewPlatforms,
detectNewComponents,
@@ -375,6 +376,27 @@ describe('detectRequirements', () => {
});
});
// ---------------------------------------------------------------------------
// detectApiProto
// ---------------------------------------------------------------------------
describe('detectApiProto', () => {
it('labels a PR that changes api.proto', async () => {
const labels = await detectApiProto(['esphome/components/api/api.proto', 'esphome/components/api/api_pb2.cpp']);
assert.deepEqual(Array.from(labels), ['api.proto']);
});
it('labels a PR that changes only api_options.proto', async () => {
const labels = await detectApiProto(['esphome/components/api/api_options.proto']);
assert.deepEqual(Array.from(labels), ['api.proto']);
});
it('ignores the generated protocol files and other components', async () => {
const labels = await detectApiProto(['esphome/components/api/api_pb2.cpp', 'esphome/components/uart/api.proto']);
assert.equal(labels.size, 0);
});
});
// ---------------------------------------------------------------------------
// MANAGED_LABELS
// ---------------------------------------------------------------------------
@@ -387,6 +409,10 @@ describe('MANAGED_LABELS', () => {
it('includes needs-developer-docs so the workflow syncs it', () => {
assert.ok(MANAGED_LABELS.includes('needs-developer-docs'));
});
it('includes api.proto so the workflow removes it when the file drops out of the diff', () => {
assert.ok(MANAGED_LABELS.includes('api.proto'));
});
});
// ---------------------------------------------------------------------------
-1
View File
@@ -425,7 +425,6 @@ esphome/components/modbus_controller/sensor/* @martgras
esphome/components/modbus_controller/switch/* @martgras
esphome/components/modbus_controller/text_sensor/* @martgras
esphome/components/modbus_server/* @exciton
esphome/components/modem/* @oarcher
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
esphome/components/mopeka_pro_check/* @spbrogan
esphome/components/mopeka_std_check/* @Fabian-Schmidt
+2
View File
@@ -607,6 +607,7 @@ message ListEntitiesCoverResponse {
EntityCategory entity_category = 11;
bool supports_stop = 12;
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
bool supports_stop_tilt = 14;
}
// Deprecated in API version 1.1
@@ -668,6 +669,7 @@ message CoverCommandRequest {
float tilt = 7;
bool stop = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
bool stop_tilt = 10;
}
// ==================== FAN ====================
@@ -554,6 +554,7 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c
msg.supports_position = traits.get_supports_position();
msg.supports_tilt = traits.get_supports_tilt();
msg.supports_stop = traits.get_supports_stop();
msg.supports_stop_tilt = traits.get_supports_stop_tilt();
return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, conn, remaining_size);
}
void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
@@ -564,6 +565,8 @@ void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
call.set_tilt(msg.tilt);
if (msg.stop)
call.set_command_stop();
if (msg.stop_tilt)
call.set_command_stop_tilt();
call.perform();
}
#endif
+5
View File
@@ -477,6 +477,7 @@ uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__rest
#ifdef USE_DEVICES
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id);
#endif
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.supports_stop_tilt);
return pos;
}
uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
@@ -498,6 +499,7 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
#ifdef USE_DEVICES
size += ProtoSize::calc_uint32(1, msg.device_id);
#endif
size += ProtoSize::calc_bool(1, msg.supports_stop_tilt);
return size;
}
uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
@@ -554,6 +556,9 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *
msg.device_id = value.as_varint();
break;
#endif
case proto_tag(10, WIRE_TYPE_VARINT):
msg.stop_tilt = value.as_bool();
break;
}
}
#endif
+4 -2
View File
@@ -911,7 +911,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage {
class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 13;
static constexpr uint8_t ESTIMATED_SIZE = 57;
static constexpr uint8_t ESTIMATED_SIZE = 59;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); }
#endif
@@ -920,6 +920,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
bool supports_tilt{false};
ProgmemStringRef device_class{};
bool supports_stop{false};
bool supports_stop_tilt{false};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
@@ -957,7 +958,7 @@ class CoverStateResponse final : public StateResponseProtoMessage {
class CoverCommandRequest final : public CommandProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 30;
static constexpr uint8_t ESTIMATED_SIZE = 25;
static constexpr uint8_t ESTIMATED_SIZE = 27;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("cover_command_request"); }
#endif
@@ -966,6 +967,7 @@ class CoverCommandRequest final : public CommandProtoMessage {
bool has_tilt{false};
float tilt{0.0f};
bool stop{false};
bool stop_tilt{false};
void decode(const uint8_t *buffer, size_t length) {
ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);
}
+2
View File
@@ -1245,6 +1245,7 @@ const char *ListEntitiesCoverResponse::dump_to(DumpBuffer &out) const {
#ifdef USE_DEVICES
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("supports_stop_tilt"), this->supports_stop_tilt);
return out.c_str();
}
const char *CoverStateResponse::dump_to(DumpBuffer &out) const {
@@ -1269,6 +1270,7 @@ const char *CoverCommandRequest::dump_to(DumpBuffer &out) const {
#ifdef USE_DEVICES
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("stop_tilt"), this->stop_tilt);
return out.c_str();
}
#endif
@@ -22,8 +22,8 @@ class BluetoothProxy;
namespace esphome::bluetooth_connection {
using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
using ble_device_base::ClientState;
using ble_device_base::ConnectionType;
/// A refused GATT reply owed to the current subscriber. Payload-free only:
/// these rebuild from address + handle + error, so a retry costs no buffered
@@ -30,8 +30,8 @@ using bluetooth_connection::INIT_SENDING_SERVICES;
using bluetooth_connection::SERVICES_DONE_PENDING;
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
using ClientState = ble_device_base::ClientState;
using bluetooth_connection::BluetoothConnection;
using ble_device_base::ClientState;
#endif
// Legacy versions:
+1
View File
@@ -60,6 +60,7 @@ CONF_ON_RECEIVE = "on_receive"
CONF_ON_SCAN_END = "on_scan_end"
CONF_ON_STATE_CHANGE = "on_state_change"
CONF_ON_STOP = "on_stop"
CONF_OUT_PIN = "out_pin"
CONF_OUT_PIN_LEVEL = "out_pin_level"
CONF_PARITY = "parity"
CONF_PROVIDER = "provider"
@@ -31,12 +31,14 @@ cover::CoverTraits CopyCover::get_traits() {
traits.set_supports_position(base.get_supports_position());
traits.set_supports_tilt(base.get_supports_tilt());
traits.set_supports_toggle(base.get_supports_toggle());
traits.set_supports_stop_tilt(base.get_supports_stop_tilt());
return traits;
}
void CopyCover::control(const cover::CoverCall &call) {
auto call2 = source_->make_call();
call2.set_stop(call.get_stop());
call2.set_stop_tilt(call.get_stop_tilt());
auto tilt = call.get_tilt();
if (tilt.has_value())
call2.set_tilt(*tilt);
+1 -4
View File
@@ -8,10 +8,7 @@ ESPHOME_LOG_TAG(TAG, "copy.lock");
void CopyLock::setup() {
source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); });
traits.set_assumed_state(source_->traits.get_assumed_state());
traits.set_requires_code(source_->traits.get_requires_code());
traits.set_supported_states_mask(source_->traits.get_supported_states_mask());
traits.set_supports_open(source_->traits.get_supports_open());
this->traits = this->source_->traits;
this->publish_state(source_->state);
}
+4
View File
@@ -99,6 +99,7 @@ CONF_ON_CLOSED = "on_closed"
CONF_ON_OPENED = "on_opened"
CONF_ON_OPENING = "on_opening"
CONF_ON_CLOSING = "on_closing"
CONF_STOP_TILT = "stop_tilt"
TRIGGERS = {
CONF_ON_OPEN: CoverOpenedTrigger, # Deprecated, use on_opened
@@ -248,6 +249,7 @@ for _name, _command in (
("cover.open", "set_command_open()"),
("cover.close", "set_command_close()"),
("cover.stop", "set_command_stop()"),
("cover.stop_tilt", "set_command_stop_tilt()"),
("cover.toggle", "set_command_toggle()"),
):
automation.register_apply_action(
@@ -259,6 +261,7 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(Cover),
cv.Optional(CONF_STOP): cv.templatable(cv.boolean),
cv.Optional(CONF_STOP_TILT): cv.templatable(cv.boolean),
cv.Exclusive(CONF_STATE, "pos"): cv.templatable(validate_cover_state),
cv.Exclusive(CONF_POSITION, "pos"): cv.templatable(cv.percentage),
cv.Optional(CONF_TILT): cv.templatable(cv.percentage),
@@ -272,6 +275,7 @@ automation.register_apply_action(
"cover.control",
COVER_CONTROL_ACTION_SCHEMA,
automation.ApplyField(CONF_STOP, "set_stop", cg.bool_),
automation.ApplyField(CONF_STOP_TILT, "set_stop_tilt", cg.bool_),
automation.ApplyField(CONF_STATE, "set_position", cg.float_),
automation.ApplyField(CONF_POSITION, "set_position", cg.float_),
automation.ApplyField(CONF_TILT, "set_tilt", cg.float_),
+20
View File
@@ -55,6 +55,10 @@ CoverCall &CoverCall::set_command_stop() {
this->stop_ = true;
return *this;
}
CoverCall &CoverCall::set_command_stop_tilt() {
this->stop_tilt_ = true;
return *this;
}
CoverCall &CoverCall::set_command_toggle() {
this->toggle_ = true;
return *this;
@@ -74,6 +78,9 @@ void CoverCall::perform() {
if (this->stop_) {
ESP_LOGV(TAG, " Command: STOP");
}
if (this->stop_tilt_) {
ESP_LOGV(TAG, " Command: STOP_TILT");
}
if (this->position_.has_value()) {
if (traits.get_supports_position()) {
ESP_LOGV(TAG, " Position: %.0f%%", *this->position_ * 100.0f);
@@ -122,6 +129,15 @@ void CoverCall::validate_() {
this->toggle_.reset();
}
}
if (this->stop_tilt_) {
if (!traits.get_supports_stop_tilt()) {
ESP_LOGW(TAG, "'%s': stop tilt unsupported", name);
this->stop_tilt_ = false;
} else if (this->tilt_.has_value()) {
ESP_LOGW(TAG, "'%s': cannot tilt when stopping tilt", name);
this->tilt_.reset();
}
}
if (this->stop_) {
if (this->position_.has_value() || this->tilt_.has_value() || this->toggle_.has_value()) {
ESP_LOGW(TAG, "'%s': cannot position/tilt/toggle when stopping", name);
@@ -135,6 +151,10 @@ CoverCall &CoverCall::set_stop(bool stop) {
this->stop_ = stop;
return *this;
}
CoverCall &CoverCall::set_stop_tilt(bool stop_tilt) {
this->stop_tilt_ = stop_tilt;
return *this;
}
void Cover::publish_state(bool save) {
this->position = clamp(this->position, 0.0f, 1.0f);
this->tilt = clamp(this->tilt, 0.0f, 1.0f);
+6
View File
@@ -36,6 +36,8 @@ class CoverCall {
CoverCall &set_command_close();
/// Set the command to stop the cover.
CoverCall &set_command_stop();
/// Set the command to stop the cover tilt.
CoverCall &set_command_stop_tilt();
/// Set the command to toggle the cover.
CoverCall &set_command_toggle();
/// Set the call to a certain target position.
@@ -44,12 +46,15 @@ class CoverCall {
CoverCall &set_tilt(float tilt);
/// Set whether this cover call should stop the cover.
CoverCall &set_stop(bool stop);
/// Set whether this cover call should stop the cover tilt.
CoverCall &set_stop_tilt(bool stop_tilt);
/// Perform the cover call.
void perform();
const optional<float> &get_position() const;
bool get_stop() const { return this->stop_; }
bool get_stop_tilt() const { return this->stop_tilt_; }
const optional<float> &get_tilt() const;
const optional<bool> &get_toggle() const;
@@ -58,6 +63,7 @@ class CoverCall {
Cover *parent_;
bool stop_{false};
bool stop_tilt_{false};
optional<float> position_{};
optional<float> tilt_{};
optional<bool> toggle_{};
+3
View File
@@ -16,6 +16,8 @@ class CoverTraits {
void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; }
bool get_supports_stop() const { return this->supports_stop_; }
void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; }
bool get_supports_stop_tilt() const { return this->supports_stop_tilt_; }
void set_supports_stop_tilt(bool supports_stop_tilt) { this->supports_stop_tilt_ = supports_stop_tilt; }
protected:
bool is_assumed_state_{false};
@@ -23,6 +25,7 @@ class CoverTraits {
bool supports_tilt_{false};
bool supports_toggle_{false};
bool supports_stop_{false};
bool supports_stop_tilt_{false};
};
} // namespace esphome::cover
@@ -1,6 +1,7 @@
#pragma once
#include <cinttypes>
#include <utility>
#include "esphome/core/component.h"
#include "esphome/core/preferences.h"
@@ -26,7 +27,7 @@ class DutyTimeSensor final : public sensor::Sensor, public PollingComponent {
#ifdef USE_BINARY_SENSOR
void set_sensor(binary_sensor::BinarySensor *sensor);
#endif
void set_lambda(std::function<bool()> &&func) { this->func_ = func; }
void set_lambda(std::function<bool()> &&func) { this->func_ = std::move(func); }
void set_last_duty_time_sensor(sensor::Sensor *sensor) { this->last_duty_time_sensor_ = sensor; }
void set_restore(bool restore) { this->restore_ = restore; }
+1 -1
View File
@@ -15,7 +15,7 @@
namespace esphome::esp32_ble {
using ble_device_base::UUID_STR_LEN;
using ESPBTUUID = ble_device_base::ESPBTUUID;
using ble_device_base::ESPBTUUID;
} // namespace esphome::esp32_ble
@@ -33,18 +33,18 @@ namespace esphome::esp32_ble_tracker {
using namespace esp32_ble;
using adv_data_t = ble_device_base::adv_data_t;
using ble_device_base::adv_data_t;
#ifdef USE_ESP32_BLE_UUID
using ServiceData = ble_device_base::ServiceData;
using ble_device_base::ServiceData;
#endif
#ifdef USE_ESP32_BLE_DEVICE
// The advertisement device types are owned by the platform-neutral
// ble_device_base layer; re-exported here (esp32 only) for backward
// compatibility. ESPBTDevice::parse_scan_rst() (esp32-only) adapts BLEScanResult.
using ESPBLEiBeacon = ble_device_base::ESPBLEiBeacon;
using ESPBTDevice = ble_device_base::ESPBTDevice;
using ble_device_base::ESPBLEiBeacon;
using ble_device_base::ESPBTDevice;
#endif // USE_ESP32_BLE_DEVICE
class ESP32BLETracker;
@@ -82,12 +82,12 @@ struct ClientStateCounts {
// The client connection state types are owned by the platform-neutral
// ble_device_base layer; re-exported here for backward compatibility.
using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
using ble_device_base::ClientState;
using ble_device_base::ConnectionType;
using ble_device_base::client_state_to_string;
// Neutral scanner lifecycle re-exported for backward compatibility.
using ScannerState = ble_device_base::ScannerState;
using ble_device_base::ScannerState;
/// Base class for BLE GATT clients that connect to remote devices.
///
@@ -425,7 +425,7 @@ esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len
// task, and a shared build buffer here would let two callers corrupt each
// other's frame. Passing the body through also drops a full-frame copy per
// transmit, on the path this shim exists to keep quick.
uint8_t hdr[sizeof(esp_now_hosted_send_req_t)];
uint8_t hdr[sizeof(esp_now_hosted_send_req_t)] = {};
auto *s = reinterpret_cast<esp_now_hosted_send_req_t *>(hdr);
s->has_addr = peer_addr != nullptr ? 1 : 0;
if (peer_addr != nullptr) {
+4 -2
View File
@@ -48,7 +48,8 @@ static inline bool esp_rtc_user_mem_read(uint32_t index, uint32_t *dest) {
if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) {
return false;
}
*dest = ESP_RTC_USER_MEM[index]; // NOLINT(performance-no-int-to-ptr,clang-analyzer-core.FixedAddressDereference)
// NOLINTNEXTLINE(performance-no-int-to-ptr,clang-analyzer-core.FixedAddressDereference,clang-analyzer-optin.core.FixedAddressDereference)
*dest = ESP_RTC_USER_MEM[index];
return true;
}
@@ -61,7 +62,8 @@ static inline bool esp_rtc_user_mem_write(uint32_t index, uint32_t value) {
}
auto *ptr = &ESP_RTC_USER_MEM[index]; // NOLINT(performance-no-int-to-ptr)
*ptr = value; // NOLINT(clang-analyzer-core.FixedAddressDereference)
// NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference,clang-analyzer-optin.core.FixedAddressDereference)
*ptr = value;
return true;
}
+5 -5
View File
@@ -4,6 +4,7 @@
#include <cstddef>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
#include "esphome/core/defines.h"
@@ -50,15 +51,15 @@ template<size_t STACK_SIZE = 640> class SerializationBuffer {
~SerializationBuffer() { delete[] heap_buffer_; }
// Move constructor - works with same template instantiation
SerializationBuffer(SerializationBuffer &&other) noexcept : heap_buffer_(other.heap_buffer_), size_(other.size_) {
SerializationBuffer(SerializationBuffer &&other) noexcept
: heap_buffer_(std::exchange(other.heap_buffer_, nullptr)), size_(other.size_) {
if (other.buffer_ == other.stack_buffer_) {
// Stack buffer - must copy content
std::memcpy(stack_buffer_, other.stack_buffer_, size_ + 1);
buffer_ = stack_buffer_;
} else {
// Heap buffer - steal ownership
// Heap buffer - ownership was taken above
buffer_ = heap_buffer_;
other.heap_buffer_ = nullptr;
}
// Leave moved-from object in valid empty state
other.stack_buffer_[0] = '\0';
@@ -70,14 +71,13 @@ template<size_t STACK_SIZE = 640> class SerializationBuffer {
SerializationBuffer &operator=(SerializationBuffer &&other) noexcept {
if (this != &other) {
delete[] heap_buffer_;
heap_buffer_ = other.heap_buffer_;
heap_buffer_ = std::exchange(other.heap_buffer_, nullptr);
size_ = other.size_;
if (other.buffer_ == other.stack_buffer_) {
std::memcpy(stack_buffer_, other.stack_buffer_, size_ + 1);
buffer_ = stack_buffer_;
} else {
buffer_ = heap_buffer_;
other.heap_buffer_ = nullptr;
}
// Leave moved-from object in valid empty state
other.stack_buffer_[0] = '\0';
+2 -2
View File
@@ -407,7 +407,7 @@ void LD2410Component::handle_periodic_data_() {
}
#ifdef USE_NUMBER
std::function<void(void)> set_number_value(number::Number *n, float value) {
std::function<void()> set_number_value(number::Number *n, float value) {
if (n != nullptr && (!n->has_state() || n->state != value)) {
n->state = value;
return [n, value]() { n->publish_state(value); };
@@ -560,7 +560,7 @@ bool LD2410Component::handle_ack_data_() {
Moving distance range: 13th byte
Still distance range: 14th byte
*/
std::vector<std::function<void(void)>> updates;
std::vector<std::function<void()>> updates;
updates.push_back(set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12]));
updates.push_back(set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13]));
/*
+2 -1
View File
@@ -230,7 +230,8 @@ bool LibreTinyPreferences::is_changed_(fdb_kvdb_t db, const NVSData &to_save, co
return true;
}
// Compare the actual data
// Compare the actual data. fdb_kv_get_blob filled stored_data through this->blob, which the analyzer cannot see.
// NOLINTNEXTLINE(clang-analyzer-unix.cstring.UninitializedRead)
return memcmp(to_save.data.data(), stored_data.get(), kv.value_len) != 0;
}
+14 -10
View File
@@ -35,12 +35,12 @@ class LockTraits {
public:
LockTraits() = default;
bool get_supports_open() const { return this->supports_open_; }
void set_supports_open(bool supports_open) { this->supports_open_ = supports_open; }
bool get_requires_code() const { return this->requires_code_; }
void set_requires_code(bool requires_code) { this->requires_code_ = requires_code; }
bool get_assumed_state() const { return this->assumed_state_; }
void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; }
bool get_supports_open() const { return this->flags_.supports_open; }
void set_supports_open(bool supports_open) { this->flags_.supports_open = supports_open; }
bool get_requires_code() const { return this->flags_.requires_code; }
void set_requires_code(bool requires_code) { this->flags_.requires_code = requires_code; }
bool get_assumed_state() const { return this->flags_.assumed_state; }
void set_assumed_state(bool assumed_state) { this->flags_.assumed_state = assumed_state; }
bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); }
void set_supported_states(std::initializer_list<LockState> states) {
@@ -54,9 +54,13 @@ class LockTraits {
void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); }
protected:
bool supports_open_{false};
bool requires_code_{false};
bool assumed_state_{false};
// Bit-packed flags to save memory (1 byte instead of 3)
struct LockTraitFlags {
uint8_t supports_open : 1;
uint8_t requires_code : 1;
uint8_t assumed_state : 1;
uint8_t reserved : 5;
} flags_{};
uint8_t supported_states_mask_{(1 << LOCK_STATE_NONE) | (1 << LOCK_STATE_LOCKED) | (1 << LOCK_STATE_UNLOCKED)};
};
@@ -176,8 +180,8 @@ class Lock : public EntityBase {
*/
virtual void control(const LockCall &call) = 0;
Deduplicator<LockState> publish_dedup_; // packs with state and traits ahead of the pointer members
LazyCallbackManager<void(LockState)> state_callback_{};
Deduplicator<LockState> publish_dedup_;
ESPPreferenceObject rtc_;
};
@@ -652,7 +652,7 @@ ModbusCommandItem ModbusCommandItem::create_custom_command(
ESP_LOGI(TAG, "Custom Command sent");
};
} else {
cmd.on_data_func = handler;
cmd.on_data_func = std::move(handler);
}
cmd.payload.set(values.data(), values.size());
@@ -669,7 +669,7 @@ ModbusCommandItem ModbusCommandItem::create_custom_command(
ESP_LOGI(TAG, "Custom Command sent");
};
} else {
cmd.on_data_func = handler;
cmd.on_data_func = std::move(handler);
}
uint8_t *p = cmd.payload.init(values.size() * 2);
for (auto v : values) {
-204
View File
@@ -1,204 +0,0 @@
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import uart
from esphome.components.const import CONF_RTS_PIN
from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option
from esphome.components.text_sensor import new_text_sensor, text_sensor_schema
import esphome.config_validation as cv
from esphome.const import (
CONF_BAUD_RATE,
CONF_COMMAND,
CONF_ENABLE_ON_BOOT,
CONF_ID,
CONF_MODEL,
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_REBOOT_TIMEOUT,
CONF_RX_BUFFER_SIZE,
CONF_RX_PIN,
CONF_TX_BUFFER_SIZE,
CONF_TX_PIN,
CONF_USE_ADDRESS,
DEVICE_CLASS_EMPTY,
)
from esphome.core import coroutine_with_priority
CODEOWNERS = ["@oarcher"]
DEPENDENCIES = ["esp32"]
AUTO_LOAD = ["network", "uart", "text_sensor"]
CONFLICTS_WITH = ["captive_portal", "ethernet"]
DOMAIN = "modem"
CONF_MODEM = "modem"
CONF_MODEM_ID = "modem_id"
CONF_PIN_CODE = "pin_code"
CONF_APN = "apn"
CONF_CTS_PIN = "cts_pin"
CONF_INIT_AT = "init_at"
CONF_ON_SYNC = "on_sync"
CONF_DTE_BUFFER_SIZE = "dte_buffer_size"
CONF_URC = "urc"
MODEM_MODELS = [
"BG96",
"SIM800",
"SIM7000",
"SIM7070",
"SIM7080",
"SIM7600",
"SIM7670",
"GENERIC",
]
modem_ns = cg.esphome_ns.namespace("modem")
ModemComponent = modem_ns.class_("ModemComponent", cg.Component, uart.UARTComponent)
ModemComponentState = modem_ns.enum("ModemComponentState", is_class=True)
MODEM_COMPONENT_SCHEMA = cv.Schema(
{cv.GenerateID(CONF_MODEM_ID): cv.use_id(ModemComponent)}
)
# Each trigger fires on one state, and none while the component is failed
_NOT_FAILED = "!{parent}->is_failed()"
def _on_state(conf_key: str, state: str) -> automation.CallbackAutomation:
return automation.CallbackAutomation(
conf_key,
"add_on_state_callback",
params=[(ModemComponentState, "state")],
forward=[],
when=f"{_NOT_FAILED} && state == modem::ModemComponentState::{state}",
)
_CALLBACK_AUTOMATIONS = (
_on_state(CONF_ON_CONNECT, "MODEM_CONNECTED"),
_on_state(CONF_ON_DISCONNECT, "MODEM_DISCONNECTING"),
_on_state(CONF_ON_SYNC, "MODEM_SYNCED"),
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(ModemComponent),
cv.Required(CONF_TX_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_RX_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RTS_PIN): pins.internal_gpio_output_pin_schema,
cv.Optional(CONF_CTS_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_BAUD_RATE): cv.positive_int,
cv.Required(CONF_MODEL): cv.one_of(*MODEM_MODELS, upper=True),
cv.Required(CONF_APN): cv.string,
cv.Optional(CONF_PIN_CODE): cv.sensitive(cv.string_strict),
cv.Optional(CONF_USE_ADDRESS): cv.string,
cv.Optional(CONF_INIT_AT): cv.All(cv.ensure_list(cv.string)),
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
cv.Optional(CONF_TX_BUFFER_SIZE, default=1024): cv.positive_int,
cv.Optional(CONF_RX_BUFFER_SIZE, default=1024): cv.positive_int,
cv.Optional(CONF_DTE_BUFFER_SIZE, default=1024): cv.positive_int,
cv.Optional(
CONF_REBOOT_TIMEOUT, default="10min"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_SYNC): automation.validate_automation({}),
cv.Optional(CONF_URC): text_sensor_schema(
device_class=DEVICE_CLASS_EMPTY,
),
}
)
.extend(MODEM_COMPONENT_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
cv.require_framework_version(
esp_idf=cv.Version(4, 0, 0), # 5.2.0 OK
),
)
MODEM_ID_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(ModemComponent)}
)
SEND_AT_SCHEMA = cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(ModemComponent),
cv.Required(CONF_COMMAND): cv.templatable(cv.string),
},
key=CONF_COMMAND,
)
automation.register_apply_action(
"modem.enable", MODEM_ID_SCHEMA, automation.ApplyCall("enable()")
)
automation.register_apply_action(
"modem.disable", MODEM_ID_SCHEMA, automation.ApplyCall("disable()")
)
automation.register_apply_action(
"modem.send_at",
SEND_AT_SCHEMA,
automation.ApplyCall("send_at({}, 1000, true)", ((CONF_COMMAND, cg.std_string),)),
)
automation.register_apply_condition(
"modem.connected", MODEM_ID_SCHEMA, "is_connected()"
)
automation.register_apply_condition("modem.enabled", MODEM_ID_SCHEMA, "is_enabled()")
@coroutine_with_priority(60.0)
async def to_code(config):
add_idf_component(
name="esp_modem",
repo="https://github.com/espressif/esp-protocols.git",
ref="modem-v2.0.0",
path="components/esp_modem",
)
add_idf_sdkconfig_option("CONFIG_PPP", True)
add_idf_sdkconfig_option("CONFIG_LWIP_PPP_SUPPORT", True)
add_idf_sdkconfig_option("CONFIG_ESP_MODEM_CMUX_DELAY_AFTER_DLCI_SETUP", 500)
add_idf_sdkconfig_option("CONFIG_PPP_SUPPORT", True)
add_idf_sdkconfig_option(
"CONFIG_ESP_MODEM_CMUX_USE_SHORT_PAYLOADS_ONLY", True
) # Slower but more reliable
add_idf_sdkconfig_option("CONFIG_ESP_MODEM_URC_HANDLER", True)
# If "Uart queue full" messages appear (e.g., with A7672), these config options might need adjustment
# See: https://github.com/espressif/esp-protocols/issues/272#issuecomment-1558682967
# add_idf_sdkconfig_option("CONFIG_ESP_MODEM_CMUX_DEFRAGMENT_PAYLOAD", True)
# add_idf_sdkconfig_option("CONFIG_ESP_MODEM_USE_INFLATABLE_BUFFER_IF_NEEDED", True)
# add_idf_sdkconfig_option("CONFIG_ESP_MODEM_CMUX_USE_SHORT_PAYLOADS_ONLY", False)
cg.add_define("USE_MODEM")
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT]))
cg.add_optional(var.set_use_address, config.get(CONF_USE_ADDRESS))
cg.add_optional(var.set_pin_code, config.get(CONF_PIN_CODE))
if config[CONF_ENABLE_ON_BOOT]:
cg.add(var.enable())
for cmd in config.get(CONF_INIT_AT, []):
cg.add(var.add_init_at_command(cmd))
cg.add(var.set_model(config[CONF_MODEL]))
cg.add(var.set_apn(config[CONF_APN]))
await cg.add_pin(var.set_tx_pin, config[CONF_TX_PIN])
await cg.add_pin(var.set_rx_pin, config[CONF_RX_PIN])
await cg.add_pin(var.set_rts_pin, config.get(CONF_RTS_PIN))
await cg.add_pin(var.set_cts_pin, config.get(CONF_CTS_PIN))
cg.add_optional(var.set_baud_rate, config.get(CONF_BAUD_RATE))
cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE]))
cg.add(var.set_tx_buffer_size(config[CONF_TX_BUFFER_SIZE]))
cg.add(var.set_dte_buffer_size(config[CONF_DTE_BUFFER_SIZE]))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if urc_config := config.get(CONF_URC, None):
cg.add_define("USE_MODEM_URC")
urc_text_sensor = await new_text_sensor(urc_config)
cg.add(var.set_urc_text_sensor(urc_text_sensor))
await cg.register_component(var, config)
@@ -1,469 +0,0 @@
#ifdef USE_ESP32
#include "modem_component.h"
#include "modem_handler.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/core/defines.h"
#include "esphome/components/network/util.h"
#include <esp_netif.h>
#include <esp_netif_ppp.h>
#include <esp_event.h>
#include <driver/gpio.h>
#include <lwip/dns.h>
#include <cxx_include/esp_modem_dte.hpp>
#include <esp_modem_config.h>
#include <cxx_include/esp_modem_api.hpp>
#include <cstring>
#include <iostream>
#include <cmath>
#define ESPHL_ERROR_CHECK(err, message) \
if ((err) != ESP_OK) { \
ESP_LOGE(TAG, message ": (%d) %s", err, esp_err_to_name(err)); \
this->mark_failed(); \
return; \
}
namespace esphome::modem {
ESPHOME_LOG_TAG(TAG, "modem");
ModemComponent *global_modem_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
ModemComponent::ModemComponent() {
assert(global_modem_component == nullptr);
global_modem_component = this;
this->modem_handler = std::make_unique<ModemHandler>();
}
// Delegated methods
AtCommandResult ModemComponent::send_at(const std::string &cmd, uint32_t timeout, bool verbose) {
return this->modem_handler->send_at(cmd, timeout, verbose);
}
void ModemComponent::enable() {
ESP_LOGI(TAG, "Setting modem target state to CONNECTED");
this->target_state_ = ModemComponentState::MODEM_CONNECTED;
// State machine in loop() will handle the ascent
}
void ModemComponent::disable() {
ESP_LOGI(TAG, "Setting modem target state to DISABLED");
this->target_state_ = ModemComponentState::MODEM_DISABLED;
// State machine in loop() will handle the descent
}
network::IPAddresses ModemComponent::get_ip_addresses() {
network::IPAddresses addresses;
if (this->component_state_ == ModemComponentState::MODEM_CONNECTED) {
addresses[0] = network::IPAddress(&this->modem_handler->network_infos.ip_info.ip);
}
return addresses;
}
void ModemComponent::setup() {
ESP_LOGI(TAG, "Modem setup...");
this->pref_ = global_preferences->make_preference<ModemRestoreState>(76007670UL);
this->pref_.load(&this->modem_restore_state_);
// find an available UART
#ifdef UART_NUM_2
for (auto p : {UART_NUM_2, UART_NUM_1}) {
#else
for (auto p : {UART_NUM_1}) {
#endif
if (!uart_is_driver_installed(p)) {
this->modem_handler->uart_port_num = p;
break;
}
}
if (this->modem_handler->uart_port_num == UART_NUM_MAX) {
ESP_LOGE(TAG, "No free UART port for modem");
this->mark_failed(LOG_STR("No free UART port for modem"));
return;
}
ESP_LOGCONFIG(TAG, "Config Modem:");
ESP_LOGCONFIG(TAG, " Model : %s", this->modem_handler->model.c_str());
ESP_LOGCONFIG(TAG, " APN : %s", this->modem_handler->apn.c_str());
ESP_LOGCONFIG(TAG, " PIN code : %s",
(this->modem_handler->pin_code.empty()) ? LOG_STR_LITERAL("No") : LOG_STR_LITERAL("Yes (not shown)"));
ESP_LOGCONFIG(TAG, " Tx Pin : GPIO%u", this->modem_handler->tx_pin->get_pin());
ESP_LOGCONFIG(TAG, " Rx Pin : GPIO%u", this->modem_handler->rx_pin->get_pin());
ESP_LOGCONFIG(
TAG, " Enabled : %s",
(this->component_state_ != ModemComponentState::MODEM_DISABLED) ? LOG_STR_LITERAL("Yes") : LOG_STR_LITERAL("No"));
if (this->modem_handler->baud_rate != 0) {
ESP_LOGCONFIG(TAG, " Baud rate : %d", this->modem_handler->baud_rate);
}
ESP_LOGCONFIG(TAG, " UART port : %d", this->modem_handler->uart_port_num);
ESP_LOGCONFIG(TAG, " TX buffer size : %d", this->modem_handler->tx_buffer_size);
ESP_LOGCONFIG(TAG, " RX buffer size : %d", this->modem_handler->rx_buffer_size);
ESP_LOGCONFIG(TAG, " DTE buffer size : %d", this->modem_handler->dte_buffer_size);
if (CONFIG_ESP_TASK_WDT_TIMEOUT_S <= 10) {
ESP_LOGW(TAG, "WDT timeout (%d s) may be too low for modem. Increase if WDT triggers.",
CONFIG_ESP_TASK_WDT_TIMEOUT_S);
}
esp_netif_config_t netif_ppp_config = ESP_NETIF_DEFAULT_PPP();
this->modem_handler->ppp_netif = esp_netif_new(&netif_ppp_config);
assert(this->modem_handler->ppp_netif);
esp_err_t err = esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &ModemHandler::ip_event_handler,
this->modem_handler.get());
ESPHL_ERROR_CHECK(err, "IP event handler register failed");
this->modem_handler->modem_create_dte_dce(this->modem_handler->baud_rate);
ESP_LOGV(TAG, "Setup complete");
}
void ModemComponent::loop() {
if (App.get_loop_component_start_time() < this->next_loop_millis_) {
// Some commands require a delay.
delay(10);
return;
}
// Calculate next step toward target_state_
ModemComponentState next_state = this->compute_next_state_();
if (next_state != this->component_state_) {
this->transition_to_(next_state);
}
}
ModemComponentState ModemComponent::compute_next_state_() {
// Priority 1: If target = DISABLED and we're not there, descend
if (this->target_state_ == ModemComponentState::MODEM_DISABLED) {
if (this->component_state_ == ModemComponentState::MODEM_DISABLED) {
return ModemComponentState::MODEM_DISABLED; // Stay
}
if (this->component_state_ != ModemComponentState::MODEM_DISCONNECTING) {
return ModemComponentState::MODEM_DISCONNECTING; // Start descent
}
// Otherwise we're already in DISCONNECTING, let handler manage
}
// Priority 2: Execute current state logic
switch (this->component_state_) {
case ModemComponentState::MODEM_DISABLED:
return this->handle_state_disabled_();
case ModemComponentState::MODEM_ENABLING:
return this->handle_state_enabling_();
case ModemComponentState::MODEM_SYNCED:
return this->handle_state_synced_();
case ModemComponentState::MODEM_CONNECTING:
return this->handle_state_connecting_();
case ModemComponentState::MODEM_WAIT_IP:
return this->handle_state_wait_ip_();
case ModemComponentState::MODEM_CONNECTED:
return this->handle_state_connected_();
case ModemComponentState::MODEM_DISCONNECTING:
return this->handle_state_disconnecting_();
}
return this->component_state_; // Fallback
}
ModemComponentState ModemComponent::handle_state_disabled_() {
this->loop_delay_(3000);
// If target is DISABLED, stay here
if (this->target_state_ == ModemComponentState::MODEM_DISABLED) {
return ModemComponentState::MODEM_DISABLED;
}
// Otherwise, target is CONNECTED, start ascent
return ModemComponentState::MODEM_ENABLING;
}
ModemComponentState ModemComponent::handle_state_enabling_() {
// Connect to the modem (testing different baud), and set the modem in a fresh state (disconnect from a previous
// connection) the modem will be synced at expected baud rate when exiting this state.
auto try_autobaud = [&](int baud) {
this->modem_handler->modem_create_dte_dce(baud);
this->modem_handler->dce->set_mode(esp_modem::modem_mode::AUTODETECT);
bool success = this->modem_handler->dce->get_mode() != esp_modem::modem_mode::UNDEF;
// Sometimes the modem does not answer autobaud commands,
// so try sending an AT command to confirm it's responsive at this baud rate.
if (!success) {
App.feed_wdt();
auto result = this->modem_handler->send_at("AT", 100);
success = result.esp_modem_command_result == command_result::OK;
}
if (success) {
this->modem_handler->current_baud_rate = baud;
this->modem_restore_state_.baud_rate = baud;
this->pref_.save(&this->modem_restore_state_);
}
return success;
};
// possible baud rate to try
std::vector<int> bauds = {this->modem_handler->current_baud_rate, this->modem_restore_state_.baud_rate,
this->modem_handler->baud_rate, 115200};
std::sort(bauds.begin(), bauds.end());
bauds.erase(std::unique(bauds.begin(), bauds.end()), bauds.end());
this->modem_handler->dce->set_mode(esp_modem::modem_mode::AUTODETECT);
auto mode = this->modem_handler->dce->get_mode();
ESP_LOGI(TAG, "Autodetect mode (1st): %d", static_cast<int>(mode));
switch (mode) {
case modem_mode::CMUX_MANUAL_MODE:
ESP_LOGD(TAG, "Modem warm reboot. Exiting CMUX");
this->modem_handler->dce->set_mode(modem_mode::CMUX_MANUAL_EXIT);
break;
case modem_mode::UNDEF: {
ESP_LOGD(TAG, "Trying other baud rate");
bool responded = false;
for (int b : bauds) {
if (try_autobaud(b)) {
ESP_LOGD(TAG, "Modem responded at baud %d", b);
responded = true;
break;
}
}
if (!responded) {
ESP_LOGW(TAG, "Modem not responding. Check power, PWRKEY and TX/RX wiring.");
this->loop_delay_(1000);
return ModemComponentState::MODEM_ENABLING;
}
break;
}
case modem_mode::COMMAND_MODE:
ESP_LOGD(TAG, "OK: Modem in COMMAND mode.");
break;
case modem_mode::DATA_MODE:
// Can block 20s! But no workaround found (with esp_modem 2.0.0)
ESP_LOGW(TAG, "Exiting DATA_MODE. (long blocking call, make sur to have a long WDT!)");
this->modem_handler->dce->set_mode(modem_mode::COMMAND_MODE);
break;
default:
ESP_LOGW(TAG, "Modem in unexpected mode %d. Attempting to switch to COMMAND mode.", static_cast<int>(mode));
this->modem_handler->dce->set_mode(modem_mode::COMMAND_MODE);
this->modem_handler->dce->set_mode(modem_mode::UNDEF);
// return ModemComponentState::MODEM_ENABLING;
break;
}
if (this->modem_handler->dce->sync() != esp_modem::command_result::OK) {
ESP_LOGW(TAG, "Waiting SYNC");
this->loop_delay_(1000);
return ModemComponentState::MODEM_ENABLING;
}
#ifdef USE_MODEM_URC
auto urc_handler = [this](const esp_modem::DTE::UrcBufferInfo &buffer_info) {
if (!buffer_info.is_command_active) {
std::string line(reinterpret_cast<const char *>(buffer_info.new_data_start), buffer_info.new_data_size);
// Publish to text sensor if configured
if (this->urc_text_sensor != nullptr) {
// defer so we won't be in the callback context
this->defer([this, line]() { this->urc_text_sensor->publish_state(line); });
}
}
return esp_modem::DTE::UrcConsumeInfo{esp_modem::DTE::UrcConsumeResult::CONSUME_NONE, 0};
};
this->modem_handler->dce->set_enhanced_urc(urc_handler);
#endif
int radio_state = 1;
this->modem_handler->dce->get_radio_state(radio_state);
if (radio_state == 1) {
// On warm reboot, we need to disconnect modem radio.
if (this->modem_handler->dce->set_radio_state(0) != command_result::OK) {
ESP_LOGW(TAG, "Failed to disconnect previous network session");
}
return ModemComponentState::MODEM_ENABLING;
}
if (this->modem_handler->baud_rate != this->modem_handler->current_baud_rate) {
ESP_LOGD(TAG, "Setting baud rate: %d -> %d", this->modem_handler->current_baud_rate,
this->modem_handler->baud_rate);
this->modem_handler->dce->sync();
if (this->modem_handler->dce->set_baud(this->modem_handler->baud_rate) == esp_modem::command_result::OK) {
ESP_LOGD(TAG, "Modem baud rate set to %d.", this->modem_handler->baud_rate);
delay(200); // NOLINT
this->modem_handler->modem_create_dte_dce(this->modem_handler->baud_rate);
App.feed_wdt();
delay(200); // NOLINT
this->modem_handler->dce->sync();
if (this->modem_handler->dce->sync() == esp_modem::command_result::OK) {
ESP_LOGI(TAG, "Modem synced at baud rate %d.", this->modem_handler->current_baud_rate);
this->modem_restore_state_.baud_rate = this->modem_handler->current_baud_rate;
this->pref_.save(&this->modem_restore_state_);
global_preferences->sync();
} else {
ESP_LOGE(TAG, "Failed to sync modem at baud rate %d.", this->modem_handler->baud_rate);
return ModemComponentState::MODEM_DISCONNECTING;
}
} else {
ESP_LOGW(TAG, "Failed to set modem baud rate to %d. Using %d.", this->modem_handler->baud_rate,
this->modem_handler->current_baud_rate);
this->loop_delay_(1000);
return ModemComponentState::MODEM_ENABLING;
}
}
return ModemComponentState::MODEM_SYNCED;
}
ModemComponentState ModemComponent::handle_state_synced_() { return ModemComponentState::MODEM_CONNECTING; }
ModemComponentState ModemComponent::handle_state_connecting_() {
// Merges INIT_NETWORK and START_PPP states
if (this->modem_handler->dce->sync() != esp_modem::command_result::OK) {
ESP_LOGW(TAG, "Modem not synced during network init");
return ModemComponentState::MODEM_ENABLING;
}
int attachement_state = 0;
this->modem_handler->dce->set_radio_state(1);
this->modem_handler->prepare_sim();
this->modem_handler->dce->set_network_attachment_state(1);
this->modem_handler->dce->get_network_attachment_state(attachement_state);
if (!attachement_state) {
ESP_LOGW(TAG, "Modem not yet ready to connect");
this->modem_handler->modem_log_status();
this->loop_delay_(4000);
return ModemComponentState::MODEM_CONNECTING;
}
ESP_LOGI(TAG, "Modem initialized and ready, starting PPP");
if (!this->modem_handler->dce->set_mode(esp_modem::modem_mode::CMUX_MODE)) {
ESP_LOGE(TAG, "Failed to enter PPP. Resetting modem.");
return ModemComponentState::MODEM_DISCONNECTING;
}
return ModemComponentState::MODEM_WAIT_IP;
}
ModemComponentState ModemComponent::handle_state_wait_ip_() {
if (this->modem_handler->network_infos.got_ip) {
return ModemComponentState::MODEM_CONNECTED;
}
if (this->wait_ip_retry_ > 0) {
--this->wait_ip_retry_;
}
if (this->wait_ip_retry_ > 0) {
ESP_LOGD(TAG, "Wait IP left retry: %d", this->wait_ip_retry_);
this->loop_delay_(this->modem_handler->connect_retry_delay);
return ModemComponentState::MODEM_WAIT_IP;
}
ESP_LOGE(TAG, "Unable to get IP address");
return ModemComponentState::MODEM_DISCONNECTING;
}
ModemComponentState ModemComponent::handle_state_connected_() {
if (!this->modem_handler->network_infos.got_ip) {
ESP_LOGW(TAG, "Lost IP");
this->loop_delay_(this->modem_handler->connect_retry_delay);
return ModemComponentState::MODEM_DISCONNECTING;
}
return ModemComponentState::MODEM_CONNECTED;
}
ModemComponentState ModemComponent::handle_state_disconnecting_() {
this->modem_handler->dce->set_mode(modem_mode::CMUX_MANUAL_EXIT);
// turn off radio.It can take up to 2 min to get lost IP event.
if (this->modem_handler->dce->set_radio_state(0) == esp_modem::command_result::OK) {
ESP_LOGI(TAG, "Modem set to minimal functionality.");
} else {
ESP_LOGE(TAG, "Failed to set modem to minimal functionality.");
}
return ModemComponentState::MODEM_DISABLED;
}
void ModemComponent::transition_to_(ModemComponentState next_state) {
if (next_state == this->component_state_) {
return;
}
ModemComponentState previous_state = this->component_state_;
this->component_state_ = next_state;
ESP_LOGD(TAG, "State change: %d -> %d", static_cast<int>(previous_state), static_cast<int>(next_state));
this->on_state_callback_.call(next_state);
this->on_enter_state_(next_state);
}
void ModemComponent::on_enter_state_(ModemComponentState state) {
// State invariants (entry establishes these):
// - MODEM_WAIT_IP: waiting for PPP got IP, retry counter initialized.
// - MODEM_CONNECTED: got_ip expected, connect timeout cancelled, params dumped.
// - DISABLED: no modem timeouts active, no retries pending.
switch (state) {
case ModemComponentState::MODEM_ENABLING:
this->set_timeout(CONNECT_TIMEOUT_ID, this->timeout_,
[this]() { this->abort_("Modem was not able to connect (timeout)"); });
break;
case ModemComponentState::MODEM_WAIT_IP:
this->wait_ip_retry_ = 10;
break;
case ModemComponentState::MODEM_CONNECTED:
this->cancel_timeout(CONNECT_TIMEOUT_ID);
this->status_clear_warning();
this->dump_connect_params_();
break;
case ModemComponentState::MODEM_DISCONNECTING:
ESP_LOGI(TAG, "Disconnecting modem");
this->cancel_timeout(CONNECT_TIMEOUT_ID);
this->loop_delay_(this->modem_handler->command_delay);
break;
case ModemComponentState::MODEM_DISABLED:
this->cancel_timeout(CONNECT_TIMEOUT_ID);
this->wait_ip_retry_ = 0;
break;
default:
break;
}
}
void ModemComponent::abort_(const char *message) {
ESP_LOGE(TAG, "Aborting: %s.", message);
this->pref_.save(&this->modem_restore_state_);
App.reboot();
}
void ModemComponent::loop_delay_(uint32_t delay_ms) {
this->next_loop_millis_ = App.get_loop_component_start_time() + delay_ms;
}
void ModemComponent::dump_connect_params_() {
char buffer[network::IP_ADDRESS_BUFFER_SIZE];
if (this->component_state_ != ModemComponentState::MODEM_CONNECTED) {
ESP_LOGCONFIG(TAG, "Modem connection: Not connected.");
return;
}
esp_netif_ip_info_t ip = this->modem_handler->network_infos.ip_info;
esp_netif_dns_info_t dns_main = this->modem_handler->network_infos.dns_main;
esp_netif_dns_info_t dns_backup = this->modem_handler->network_infos.dns_backup;
ESP_LOGCONFIG(TAG, "Modem connection:");
ESP_LOGCONFIG(TAG, " IP Address : %s", network::IPAddress(&ip.ip).str_to(buffer));
ESP_LOGCONFIG(TAG, " Hostname : '%s'", App.get_name().c_str());
ESP_LOGCONFIG(TAG, " Subnet : %s", network::IPAddress(&ip.netmask).str_to(buffer));
ESP_LOGCONFIG(TAG, " Gateway : %s", network::IPAddress(&ip.gw).str_to(buffer));
ESP_LOGCONFIG(TAG, " DNS main : %s", network::IPAddress(&dns_main.ip.u_addr.ip4).str_to(buffer));
ESP_LOGCONFIG(TAG, " DNS backup : %s", network::IPAddress(&dns_backup.ip.u_addr.ip4).str_to(buffer));
}
} // namespace esphome::modem
#endif
-133
View File
@@ -1,133 +0,0 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/core/gpio.h"
#include "esphome/core/automation.h"
#include "esphome/core/preferences.h"
#include "esphome/components/network/ip_address.h"
#ifdef USE_MODEM_URC
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#include <memory>
#include <unordered_map>
#include <utility>
#include "modem_handler.h"
namespace esphome::modem {
enum class ModemComponentState {
MODEM_DISABLED,
MODEM_ENABLING,
MODEM_SYNCED,
MODEM_CONNECTING, // Merges INIT_NETWORK + START_PPP
MODEM_WAIT_IP,
MODEM_CONNECTED,
MODEM_DISCONNECTING, // Renamed from DISABLING
};
struct ModemRestoreState {
int baud_rate{0};
} __attribute__((packed));
class ModemComponent : public Component {
public:
void set_reboot_timeout(uint32_t timeout) { this->timeout_ = timeout; }
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
// Setters now modify the handler's attributes
void set_rx_pin(InternalGPIOPin *rx_pin) { this->modem_handler->rx_pin = rx_pin; }
void set_tx_pin(InternalGPIOPin *tx_pin) { this->modem_handler->tx_pin = tx_pin; }
void set_rts_pin(InternalGPIOPin *rts_pin) { this->modem_handler->rts_pin = rts_pin; }
void set_cts_pin(InternalGPIOPin *cts_pin) { this->modem_handler->cts_pin = cts_pin; }
void set_baud_rate(int baud_rate) { this->modem_handler->baud_rate = baud_rate; }
void set_model(const std::string &model) { this->modem_handler->model = model; }
void set_pin_code(const std::string &pin_code) { this->modem_handler->pin_code = pin_code; }
void set_tx_buffer_size(uint16_t tx_buffer_size) { this->modem_handler->tx_buffer_size = tx_buffer_size; }
void set_rx_buffer_size(uint16_t rx_buffer_size) { this->modem_handler->rx_buffer_size = rx_buffer_size; }
void set_dte_buffer_size(uint16_t dte_buffer_size) { this->modem_handler->dte_buffer_size = dte_buffer_size; }
void set_apn(const std::string &apn) { this->modem_handler->apn = apn; }
void add_init_at_command(const std::string &cmd) { this->modem_handler->init_at_commands.push_back(cmd); }
#ifdef USE_MODEM_URC
void set_urc_text_sensor(text_sensor::TextSensor *urc_text_sensor) { this->urc_text_sensor = urc_text_sensor; }
text_sensor::TextSensor *urc_text_sensor{nullptr};
#endif
bool is_connected() { return this->component_state_ == ModemComponentState::MODEM_CONNECTED; }
bool is_disabled() {
return this->component_state_ == ModemComponentState::MODEM_DISABLED &&
this->target_state_ == ModemComponentState::MODEM_DISABLED;
}
bool is_enabled() { return !is_disabled(); }
// Delegated methods
AtCommandResult send_at(const std::string &cmd, uint32_t timeout = 0, bool verbose = false);
void enable();
void disable();
network::IPAddresses get_ip_addresses();
const char *get_use_address() const { return this->use_address_; };
// ========== INTERNAL METHODS ==========
// (In most use cases, you won't need these)
ModemComponent();
void setup() override;
void loop() override;
void dump_config() override { this->dump_connect_params_(); }
float get_setup_priority() const override { return setup_priority::WIFI + 1; } // Just before Wi-Fi
template<typename F> void add_on_state_callback(F &&callback) {
this->on_state_callback_.add(std::forward<F>(callback));
}
std::unique_ptr<ModemHandler> modem_handler{nullptr};
protected:
// ===== State handler methods =====
ModemComponentState handle_state_disabled_();
ModemComponentState handle_state_enabling_();
ModemComponentState handle_state_synced_();
ModemComponentState handle_state_connecting_(); // Merges INIT_NETWORK + START_PPP
ModemComponentState handle_state_wait_ip_();
ModemComponentState handle_state_connected_();
ModemComponentState handle_state_disconnecting_();
void transition_to_(ModemComponentState next_state);
void on_enter_state_(ModemComponentState state);
// target_state_ model helpers
ModemComponentState compute_next_state_();
bool is_going_up_() const { return target_state_ == ModemComponentState::MODEM_CONNECTED; }
bool is_going_down_() const { return target_state_ == ModemComponentState::MODEM_DISABLED; }
void abort_(const char *message);
void loop_delay_(uint32_t delay_ms);
void dump_connect_params_();
// Scheduler id of the connect timeout armed while enabling
static constexpr uint32_t CONNECT_TIMEOUT_ID = 0;
// Attributes from YAML config
uint32_t timeout_;
CallbackManager<void(ModemComponentState)> on_state_callback_;
// Changes will trigger user callback
ModemComponentState component_state_{ModemComponentState::MODEM_DISABLED};
ModemComponentState target_state_{ModemComponentState::MODEM_DISABLED}; // Stable target state
uint32_t next_loop_millis_{0};
uint8_t wait_ip_retry_{0};
ModemRestoreState modem_restore_state_{};
ESPPreferenceObject pref_;
private:
// Stores a pointer to a string literal (static storage duration).
// ONLY set from Python-generated code with string literals - never dynamic strings.
const char *use_address_{""};
};
extern ModemComponent *global_modem_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::modem
#endif
-189
View File
@@ -1,189 +0,0 @@
#ifdef USE_ESP32
#include "modem_handler.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include <cmath>
namespace esphome::modem {
using namespace esp_modem;
ESPHOME_LOG_TAG(TAG, "modem_handler");
void ModemHandler::modem_create_dte_dce(int baud_rate) {
this->current_baud_rate = baud_rate;
esp_modem_dte_config_t dte_config = ESP_MODEM_DTE_DEFAULT_CONFIG();
dte_config.uart_config.tx_io_num = this->tx_pin->get_pin();
dte_config.uart_config.rx_io_num = this->rx_pin->get_pin();
dte_config.uart_config.rx_buffer_size = this->rx_buffer_size;
dte_config.uart_config.tx_buffer_size = this->tx_buffer_size;
dte_config.uart_config.event_queue_size = this->uart_event_queue_size;
dte_config.uart_config.port_num = this->uart_port_num;
if (baud_rate != 0) {
ESP_LOGD(TAG, "DTE baud rate: %d", baud_rate);
dte_config.uart_config.baud_rate = baud_rate;
}
if (this->rts_pin != nullptr && this->cts_pin != nullptr) {
ESP_LOGD(TAG, "Using RTS/CTS hardware flow control");
dte_config.uart_config.rts_io_num = this->rts_pin->get_pin();
dte_config.uart_config.cts_io_num = this->cts_pin->get_pin();
dte_config.uart_config.flow_control = ESP_MODEM_FLOW_CONTROL_HW;
} else {
dte_config.uart_config.flow_control = ESP_MODEM_FLOW_CONTROL_NONE;
}
dte_config.task_stack_size = this->uart_event_task_stack_size;
dte_config.task_priority = this->uart_event_task_priority;
dte_config.dte_buffer_size = this->dte_buffer_size;
this->dce.reset();
this->dte.reset();
this->dte = create_uart_dte(&dte_config);
esp_modem_dce_config_t dce_config = ESP_MODEM_DCE_DEFAULT_CONFIG(this->apn.c_str());
if (this->model == "GENERIC") {
this->dce = create_generic_dce(&dce_config, this->dte, this->ppp_netif);
} else if (this->model == "BG96") {
this->dce = create_BG96_dce(&dce_config, this->dte, this->ppp_netif);
} else if (this->model == "SIM800") {
this->dce = create_SIM800_dce(&dce_config, this->dte, this->ppp_netif);
} else if (this->model == "SIM7000" || this->model == "SIM7080") {
this->dce = create_SIM7000_dce(&dce_config, this->dte, this->ppp_netif);
} else if (this->model == "SIM7070") {
this->dce = create_SIM7070_dce(&dce_config, this->dte, this->ppp_netif);
} else if (this->model == "SIM7600" || this->model == "SIM7670") {
this->dce = create_SIM7600_dce(&dce_config, this->dte, this->ppp_netif);
} else {
ESP_LOGE(TAG, "Invalid model %s. DCE not created.", this->model.c_str());
return;
}
ESP_LOGV(TAG, "DTE/DCE created.");
}
AtCommandResult ModemHandler::send_at(const std::string &cmd, uint32_t timeout, bool verbose) {
const uint32_t cmd_timeout = timeout == 0 ? this->command_delay : timeout;
AtCommandResult at_command_result;
at_command_result.success = false;
at_command_result.esp_modem_command_result = command_result::TIMEOUT;
if (this->dce) {
at_command_result.esp_modem_command_result = this->dce->at(cmd, at_command_result.output, cmd_timeout);
at_command_result.success = at_command_result.esp_modem_command_result == command_result::OK;
} else {
ESP_LOGE(TAG, "Modem DCE not ready, cannot send AT command: %s", cmd.c_str());
at_command_result.esp_modem_command_result = command_result::FAIL;
}
return at_command_result;
}
bool ModemHandler::get_signal_quality(float &out_rssi, float &out_ber) {
out_rssi = NAN;
out_ber = NAN;
int modem_rssi = 99;
int modem_ber = 99;
if (this->dce && this->dce->sync() == command_result::OK &&
(this->dce->get_signal_quality(modem_rssi, modem_ber) == command_result::OK)) {
if (modem_rssi != 99)
out_rssi = -113.0f + (modem_rssi * 2);
if (modem_ber != 99)
out_ber = 0.1f * (modem_ber * modem_ber);
return true;
}
return false;
}
void ModemHandler::modem_log_status() {
bool synced = false;
int cfun = -1;
int attached = 0;
int network_mode = 0;
int registration_state = 0;
float rssi = NAN, ber = NAN;
std::string sim_status = "UNKNOWN";
if (this->dce && this->dce->sync() == esp_modem::command_result::OK) {
synced = true;
auto res = this->send_at("AT+CPIN?");
if (res) {
auto &out = res.output;
sim_status = out.starts_with("+CPIN: ") ? out.substr(7) : out;
}
this->dce->get_radio_state(cfun);
this->dce->get_network_attachment_state(attached);
this->dce->get_network_registration_state(registration_state);
this->get_signal_quality(rssi, ber);
this->dce->get_network_system_mode(network_mode);
}
bool connected =
synced && network_mode != 0 && attached && !std::isnan(rssi) && sim_status.find("READY") != std::string::npos;
char cfun_str[16];
if (cfun == 1) {
snprintf(cfun_str, sizeof(cfun_str), "OK");
} else {
snprintf(cfun_str, sizeof(cfun_str), "NOK(%d)", cfun);
}
ESP_LOGI(TAG,
"Modem status: %s, attached: %s, registration state: %d, radio function: %s, SIM: %s, type: %d, ber: %.0f "
"%%, rssi: %.0f dB",
connected ? LOG_STR_LITERAL("Good") : (synced ? LOG_STR_LITERAL("BAD") : LOG_STR_LITERAL("No SYNC")),
attached ? LOG_STR_LITERAL("Yes") : LOG_STR_LITERAL("NO"), registration_state, cfun_str, sim_status.c_str(),
(int) network_mode, ber * 100.0f, rssi);
}
bool ModemHandler::prepare_sim() {
App.feed_wdt();
delay(200); // NOLINT
AtCommandResult result = this->send_at("AT+CPIN?");
if (!result.success) {
ESP_LOGW(TAG, "Failed to check pin: %s", result.c_str());
return false;
}
std::string current_sim_status = result.output.starts_with("+CPIN: ") ? result.output.substr(7) : result.output;
ESP_LOGD(TAG, "SIM: %s", current_sim_status.c_str());
if (current_sim_status.find("READY") != std::string::npos) {
return true;
}
if (this->pin_code.empty()) {
ESP_LOGE(TAG, "SIM PIN empty, but SIM locked.");
return false;
}
ESP_LOGD(TAG, "Unlocking SIM with PIN...");
if (this->dce->set_pin(this->pin_code) == command_result::OK) {
ESP_LOGD(TAG, "PIN set. (But not yet checked).");
return true;
}
ESP_LOGE(TAG, "Failed to set PIN.");
return false;
}
void ModemHandler::ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) {
auto *handler = static_cast<ModemHandler *>(arg);
switch (event_id) {
case IP_EVENT_PPP_GOT_IP: {
ip_event_got_ip_t *event = (ip_event_got_ip_t *) event_data;
ESP_LOGD(TAG, "PPP IP event: Got IP " IPSTR, IP2STR(&event->ip_info.ip));
handler->network_infos.ip_info = event->ip_info;
esp_netif_get_dns_info(event->esp_netif, ESP_NETIF_DNS_MAIN, &handler->network_infos.dns_main);
esp_netif_get_dns_info(event->esp_netif, ESP_NETIF_DNS_BACKUP, &handler->network_infos.dns_backup);
handler->network_infos.got_ip = true;
break;
}
case IP_EVENT_PPP_LOST_IP:
if (handler->network_infos.got_ip) {
ESP_LOGD(TAG, "PPP IP event: Lost IP.");
handler->network_infos.got_ip = false;
}
break;
}
}
} // namespace esphome::modem
#endif
-74
View File
@@ -1,74 +0,0 @@
#pragma once
#ifdef USE_ESP32
#include <memory>
#include "esphome/core/gpio.h"
#include <cxx_include/esp_modem_api.hpp>
#include <esp_modem_config.h>
#include <vector>
#include <cmath>
namespace esphome::modem {
using namespace esp_modem;
struct AtCommandResult {
std::string output{};
bool success{false};
command_result esp_modem_command_result{command_result::TIMEOUT};
operator bool() const { return success; }
const char *c_str() const { return output.c_str(); }
};
struct NetworkInfos {
esp_netif_ip_info_t ip_info{};
esp_netif_dns_info_t dns_main{};
esp_netif_dns_info_t dns_backup{};
bool got_ip{false};
};
class ModemHandler {
public:
// Attributes from YAML config
InternalGPIOPin *tx_pin;
InternalGPIOPin *rx_pin;
InternalGPIOPin *rts_pin{nullptr};
InternalGPIOPin *cts_pin{nullptr};
std::string model;
uint16_t tx_buffer_size = 512;
uint16_t rx_buffer_size = 512;
uint16_t dte_buffer_size = 512;
std::string pin_code;
std::string apn;
std::vector<std::string> init_at_commands;
int baud_rate = 0;
// esp_modem objects
std::unique_ptr<DCE> dce{nullptr};
std::shared_ptr<DTE> dte{nullptr};
esp_netif_t *ppp_netif{nullptr};
// Internal state attributes
int current_baud_rate{0};
NetworkInfos network_infos{};
// UART config
uint8_t uart_event_queue_size = 10;
size_t uart_event_task_stack_size = 4096;
uint8_t uart_event_task_priority = 5;
uint32_t command_delay = 1000;
uint32_t connect_retry_delay = 5000;
uart_port_t uart_port_num = UART_NUM_MAX;
// Methods
void modem_create_dte_dce(int baud_rate);
AtCommandResult send_at(const std::string &cmd, uint32_t timeout = 0, bool verbose = false);
bool get_signal_quality(float &out_rssi, float &out_ber);
static void ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data);
void modem_log_status();
bool prepare_sim();
};
} // namespace esphome::modem
#endif
+1 -2
View File
@@ -3,7 +3,7 @@ from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.const import CONF_PWM_FREQUENCY
from esphome.components.const import CONF_OUT_PIN, CONF_PWM_FREQUENCY
import esphome.config_validation as cv
from esphome.const import CONF_DIRECTION, CONF_HYSTERESIS, CONF_ID, CONF_MODE
from esphome.types import ConfigType
@@ -28,7 +28,6 @@ CONF_PULSES_PER_REVOLUTION = "pulses_per_revolution"
CONF_Z_PULSE_WIDTH = "z_pulse_width"
CONF_UVW = "uvw"
CONF_POLE_PAIRS = "pole_pairs"
CONF_OUT_PIN = "out_pin"
CONF_PWM_POLARITY = "pwm_polarity"
CONF_ANALOG_START = "analog_start"
CONF_ANALOG_STOP = "analog_stop"
+2 -2
View File
@@ -28,12 +28,12 @@ std::string get_random_ha_tag_ndef() {
for (int i = 0; i < 8; i++) {
uri += ALPHANUM[random_uint32() % (sizeof(ALPHANUM) - 1)];
}
uri += "-";
uri += '-';
for (int j = 0; j < 3; j++) {
for (int i = 0; i < 4; i++) {
uri += ALPHANUM[random_uint32() % (sizeof(ALPHANUM) - 1)];
}
uri += "-";
uri += '-';
}
for (int i = 0; i < 12; i++) {
uri += ALPHANUM[random_uint32() % (sizeof(ALPHANUM) - 1)];
+1 -1
View File
@@ -3,6 +3,7 @@ from typing import Any
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_OUT_PIN
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266
@@ -15,7 +16,6 @@ DOMAIN = "opentherm"
MULTI_CONF = True
CONF_IN_PIN = "in_pin"
CONF_OUT_PIN = "out_pin"
CONF_CH_ENABLE = "ch_enable"
CONF_DHW_ENABLE = "dhw_enable"
CONF_COOLING_ENABLE = "cooling_enable"
@@ -29,18 +29,16 @@ void IRAM_ATTR BasicPulseCounterStorage::gpio_intr(BasicPulseCounterStorage *arg
if (discard)
return;
PulseCounterCountMode mode = arg->isr_pin.digital_read() ? arg->rising_edge_mode : arg->falling_edge_mode;
switch (mode) {
case PULSE_COUNTER_DISABLE:
break;
case PULSE_COUNTER_INCREMENT: {
auto x = arg->counter + 1;
arg->counter = x;
} break;
case PULSE_COUNTER_DECREMENT: {
auto x = arg->counter - 1;
arg->counter = x;
} break;
PulseCounterCountMode mode = arg->fixed_mode;
if (mode == PULSE_COUNTER_DISABLE) {
mode = arg->isr_pin.digital_read() ? arg->rising_edge_mode : arg->falling_edge_mode;
}
if (mode == PULSE_COUNTER_INCREMENT) {
auto x = arg->counter + 1;
arg->counter = x;
} else if (mode == PULSE_COUNTER_DECREMENT) {
auto x = arg->counter - 1;
arg->counter = x;
}
}
@@ -48,7 +46,24 @@ bool BasicPulseCounterStorage::pulse_counter_setup(InternalGPIOPin *pin) {
this->pin = pin;
this->pin->setup();
this->isr_pin = this->pin->to_isr();
this->pin->attach_interrupt(BasicPulseCounterStorage::gpio_intr, this, gpio::INTERRUPT_ANY_EDGE);
const bool rising = this->rising_edge_mode != PULSE_COUNTER_DISABLE;
const bool falling = this->falling_edge_mode != PULSE_COUNTER_DISABLE;
if (!rising && !falling) {
return true;
}
gpio::InterruptType type = gpio::INTERRUPT_ANY_EDGE;
if (this->filter_us == 0) {
if (!falling) {
type = gpio::INTERRUPT_RISING_EDGE;
this->fixed_mode = this->rising_edge_mode;
} else if (!rising) {
type = gpio::INTERRUPT_FALLING_EDGE;
this->fixed_mode = this->falling_edge_mode;
} else if (this->rising_edge_mode == this->falling_edge_mode) {
this->fixed_mode = this->rising_edge_mode;
}
}
this->pin->attach_interrupt(BasicPulseCounterStorage::gpio_intr, this, type);
return true;
}
@@ -166,6 +181,32 @@ pulse_counter_t HwPulseCounterStorage::read_raw_value() {
}
#endif // HAS_PCNT
bool PulseCounterSensor::before_setup_() {
if (this->storage_.pin == nullptr) {
return true;
}
ESP_LOGE(TAG, "Edge modes and filter cannot change after setup");
return false;
}
void PulseCounterSensor::set_rising_edge_mode(PulseCounterCountMode mode) {
if (this->before_setup_()) {
this->storage_.rising_edge_mode = mode;
}
}
void PulseCounterSensor::set_falling_edge_mode(PulseCounterCountMode mode) {
if (this->before_setup_()) {
this->storage_.falling_edge_mode = mode;
}
}
void PulseCounterSensor::set_filter_us(uint32_t filter) {
if (this->before_setup_()) {
this->storage_.filter_us = filter;
}
}
void PulseCounterSensor::setup() {
if (!this->storage_.pulse_counter_setup(this->pin_)) {
this->mark_failed();
@@ -28,7 +28,7 @@ struct PulseCounterStorageBase {
virtual bool pulse_counter_setup(InternalGPIOPin *pin) = 0;
virtual pulse_counter_t read_raw_value() = 0;
InternalGPIOPin *pin;
InternalGPIOPin *pin{nullptr};
PulseCounterCountMode rising_edge_mode{PULSE_COUNTER_INCREMENT};
PulseCounterCountMode falling_edge_mode{PULSE_COUNTER_DISABLE};
uint32_t filter_us{0};
@@ -45,6 +45,7 @@ struct BasicPulseCounterStorage : public PulseCounterStorageBase {
volatile uint32_t last_pulse{0};
ISRInternalGPIOPin isr_pin;
PulseCounterCountMode fixed_mode{PULSE_COUNTER_DISABLE};
};
#ifdef HAS_PCNT
@@ -64,9 +65,9 @@ class PulseCounterSensor final : public sensor::Sensor, public PollingComponent
explicit PulseCounterSensor(bool hw_pcnt = false) : storage_(*get_storage(hw_pcnt)) {}
void set_pin(InternalGPIOPin *pin) { pin_ = pin; }
void set_rising_edge_mode(PulseCounterCountMode mode) { storage_.rising_edge_mode = mode; }
void set_falling_edge_mode(PulseCounterCountMode mode) { storage_.falling_edge_mode = mode; }
void set_filter_us(uint32_t filter) { storage_.filter_us = filter; }
void set_rising_edge_mode(PulseCounterCountMode mode);
void set_falling_edge_mode(PulseCounterCountMode mode);
void set_filter_us(uint32_t filter);
void set_total_sensor(sensor::Sensor *total_sensor) { total_sensor_ = total_sensor; }
void set_total_pulses(uint32_t pulses);
@@ -77,6 +78,8 @@ class PulseCounterSensor final : public sensor::Sensor, public PollingComponent
void dump_config() override;
protected:
bool before_setup_();
InternalGPIOPin *pin_;
PulseCounterStorageBase &storage_;
uint32_t last_time_{0};
+1 -1
View File
@@ -147,7 +147,7 @@ void RFBridgeComponent::finish_bucket_frame_() {
buckets--;
}
if ((i < 3) || (buckets % 2) || (i == at - 1)) {
str += " ";
str += ' ';
}
}
ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str());
+1 -1
View File
@@ -331,7 +331,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
this->state_ = STATE_RECEIVED_SMS;
} else {
if (!this->message_.empty())
this->message_ += "\n";
this->message_ += '\n';
this->message_ += message;
}
break;
+1 -1
View File
@@ -108,7 +108,7 @@ bool check_sml_data(const bytes &buffer) {
uint16_t crc_received = (buffer.at(buffer.size() - 2) << 8) | buffer.at(buffer.size() - 1);
uint16_t crc_calculated = crc16(buffer.data() + 8, buffer.size() - 10, 0x6e23, 0x8408, true, true);
crc_calculated = (crc_calculated >> 8) | (crc_calculated << 8);
crc_calculated = byteswap(crc_calculated);
if (crc_received == crc_calculated) {
ESP_LOGV(TAG, "Checksum verification successful with CRC16/X25.");
return true;
+2 -2
View File
@@ -2,6 +2,7 @@
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <map>
#include <utility>
@@ -224,8 +225,7 @@ class SPIDelegate {
// write 16 bits
virtual void write16(uint16_t data) {
if (this->bit_order_ == BIT_ORDER_MSB_FIRST) {
uint16_t buffer;
buffer = (data >> 8) | (data << 8);
uint16_t buffer = byteswap(data);
this->write_array(reinterpret_cast<const uint8_t *>(&buffer), 2);
} else {
this->write_array(reinterpret_cast<const uint8_t *>(&data), 2);
+13 -2
View File
@@ -31,9 +31,11 @@ RESTORE_MODES = TemplateCoverRestoreMode.options(
)
CONF_HAS_POSITION = "has_position"
CONF_STOP_TILT_ACTION = "stop_tilt_action"
CONF_TOGGLE_ACTION = "toggle_action"
CONFIG_SCHEMA = (
CONFIG_SCHEMA = cv.All(
cv.with_visibility(
cover.cover_schema(TemplateCover),
cv.Visibility.UI,
@@ -50,6 +52,9 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_STOP_ACTION): automation.validate_automation(single=True),
cv.Optional(CONF_TILT_ACTION): automation.validate_automation(single=True),
cv.Optional(CONF_TILT_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_STOP_TILT_ACTION): automation.validate_automation(
single=True
),
cv.Optional(CONF_TOGGLE_ACTION): automation.validate_automation(
single=True
),
@@ -61,7 +66,8 @@ CONFIG_SCHEMA = (
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
cv.key_requires(CONF_STOP_TILT_ACTION, CONF_TILT_ACTION),
)
@@ -101,6 +107,11 @@ async def to_code(config):
config[CONF_TILT_LAMBDA], [], return_type=cg.optional.template(float)
)
cg.add(var.set_tilt_lambda(tilt_template_))
if CONF_STOP_TILT_ACTION in config:
await automation.build_automation(
var.get_stop_tilt_trigger(), [], config[CONF_STOP_TILT_ACTION]
)
cg.add(var.set_has_stop_tilt(True))
if CONF_POSITION_ACTION in config:
await automation.build_automation(
var.get_position_trigger(), [(float, "pos")], config[CONF_POSITION_ACTION]
@@ -57,6 +57,7 @@ float TemplateCover::get_setup_priority() const { return setup_priority::HARDWAR
Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; }
Trigger<> *TemplateCover::get_close_trigger() { return &this->close_trigger_; }
Trigger<> *TemplateCover::get_stop_trigger() { return &this->stop_trigger_; }
Trigger<> *TemplateCover::get_stop_tilt_trigger() { return &this->stop_tilt_trigger_; }
Trigger<> *TemplateCover::get_toggle_trigger() { return &this->toggle_trigger_; }
void TemplateCover::dump_config() { LOG_COVER("", "Template Cover", this); }
void TemplateCover::control(const CoverCall &call) {
@@ -66,6 +67,11 @@ void TemplateCover::control(const CoverCall &call) {
this->prev_command_trigger_ = &this->stop_trigger_;
this->publish_state();
}
if (call.get_stop_tilt()) {
this->tilt_trigger_.stop_action();
this->stop_tilt_trigger_.stop_action();
this->stop_tilt_trigger_.trigger();
}
if (call.get_toggle().has_value()) {
this->stop_prev_trigger_();
this->toggle_trigger_.trigger();
@@ -95,6 +101,7 @@ void TemplateCover::control(const CoverCall &call) {
auto tilt_val = call.get_tilt();
if (tilt_val.has_value()) {
auto tilt = *tilt_val;
this->stop_tilt_trigger_.stop_action();
this->tilt_trigger_.trigger(tilt);
if (this->optimistic_) {
@@ -111,6 +118,7 @@ CoverTraits TemplateCover::get_traits() {
traits.set_supports_toggle(this->has_toggle_);
traits.set_supports_position(this->has_position_);
traits.set_supports_tilt(this->has_tilt_);
traits.set_supports_stop_tilt(this->has_stop_tilt_);
return traits;
}
Trigger<float> *TemplateCover::get_position_trigger() { return &this->position_trigger_; }
@@ -22,6 +22,7 @@ class TemplateCover final : public cover::Cover, public Component {
Trigger<> *get_open_trigger();
Trigger<> *get_close_trigger();
Trigger<> *get_stop_trigger();
Trigger<> *get_stop_tilt_trigger();
Trigger<> *get_toggle_trigger();
Trigger<float> *get_position_trigger();
Trigger<float> *get_tilt_trigger();
@@ -30,6 +31,7 @@ class TemplateCover final : public cover::Cover, public Component {
void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; }
void set_has_position(bool has_position) { this->has_position_ = has_position; }
void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; }
void set_has_stop_tilt(bool has_stop_tilt) { this->has_stop_tilt_ = has_stop_tilt; }
void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; }
void set_restore_mode(TemplateCoverRestoreMode restore_mode) { restore_mode_ = restore_mode; }
@@ -59,7 +61,9 @@ class TemplateCover final : public cover::Cover, public Component {
Trigger<float> position_trigger_;
bool has_position_{false};
Trigger<float> tilt_trigger_;
Trigger<> stop_tilt_trigger_;
bool has_tilt_{false};
bool has_stop_tilt_{false};
};
} // namespace esphome::template_
@@ -357,7 +357,7 @@ void VoiceAssistant::loop() {
msg.start = true;
msg.conversation_id = StringRef(this->conversation_id_);
msg.flags = flags;
msg.audio_settings = audio_settings;
msg.audio_settings = std::move(audio_settings);
msg.wake_word_phrase = StringRef(this->wake_word_);
// Reset media player state tracking
+2 -3
View File
@@ -1095,9 +1095,8 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa
auto call = obj->make_call();
static constexpr ActionEntry<cover::CoverCall> ACTIONS[] PROGMEM = {
{"open", &cover::CoverCall::set_command_open},
{"close", &cover::CoverCall::set_command_close},
{"stop", &cover::CoverCall::set_command_stop},
{"open", &cover::CoverCall::set_command_open}, {"close", &cover::CoverCall::set_command_close},
{"stop", &cover::CoverCall::set_command_stop}, {"stop_tilt", &cover::CoverCall::set_command_stop_tilt},
{"toggle", &cover::CoverCall::set_command_toggle},
};
+3 -1
View File
@@ -91,7 +91,9 @@ CompactString &CompactString::operator=(const CompactString &other) {
}
CompactString::CompactString(CompactString &&other) noexcept : length_(other.length_), is_heap_(other.is_heap_) {
// Copy full storage (includes null terminator for inline, or pointer for heap)
// Copy full storage (includes null terminator for inline, or pointer for heap). The bytes past the
// terminator are never read, and a fixed-size copy is 8 bytes of flash smaller than a sized one.
// NOLINTNEXTLINE(clang-analyzer-unix.cstring.UninitializedRead)
std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1);
other.length_ = 0;
other.is_heap_ = 0;
+1 -1
View File
@@ -115,7 +115,7 @@ int ZephyrPreferences::load_setting(const char *name, size_t len, settings_read_
auto type = parse_hex<uint32_t>(name);
if (!type.has_value()) {
std::string full_name(ESPHOME_SETTINGS_KEY);
full_name += "/";
full_name += '/';
full_name += name;
// Delete unusable keys. Otherwise it will stay in flash forever.
settings_delete(full_name.c_str());
-2
View File
@@ -25,8 +25,6 @@ dependencies:
version: 1.1.2
espressif/esp32-camera:
version: 2.1.7
espressif/esp_modem:
version: 2.0.2
espressif/mdns:
version: 1.14.0
espressif/esp_wifi_remote:
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.6.0
click==8.5.0
aioesphomeapi==46.17.0
aioesphomeapi==46.18.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
-4
View File
@@ -26,7 +26,3 @@ CONFIG_BT_ENABLED=y
# esp32_camera
CONFIG_SPIRAM=y
# modem
CONFIG_PPP_SUPPORT=y
CONFIG_ESP_MODEM_URC_HANDLER=y
@@ -0,0 +1,25 @@
"""Tests for template cover config validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.template.cover import CONF_STOP_TILT_ACTION, CONFIG_SCHEMA
from esphome.const import CONF_ID, CONF_NAME, CONF_TILT_ACTION
from esphome.types import ConfigType
def _cover_config(**extra: object) -> ConfigType:
return {CONF_ID: "test_cover", CONF_NAME: "Test Cover", **extra}
def test_stop_tilt_action_with_tilt_action_allowed() -> None:
config = CONFIG_SCHEMA(
_cover_config(**{CONF_TILT_ACTION: [], CONF_STOP_TILT_ACTION: []})
)
assert CONF_STOP_TILT_ACTION in config
def test_stop_tilt_action_without_tilt_action_rejected() -> None:
with pytest.raises(cv.Invalid, match="requires 'tilt_action'") as exc_info:
CONFIG_SCHEMA(_cover_config(**{CONF_STOP_TILT_ACTION: []}))
assert exc_info.value.path == [CONF_STOP_TILT_ACTION]
-18
View File
@@ -1,18 +0,0 @@
esphome:
on_boot:
then:
- modem.disable
- modem.enable
- if:
condition:
modem.enabled:
then:
modem.send_at: "AT+CGSN"
else:
modem.enable:
- if:
condition:
not:
modem.connected:
then:
logger.log: modem not connected
@@ -1,21 +0,0 @@
logger:
modem:
id: atmodem
rx_pin: GPIO26
tx_pin: GPIO27
baud_rate: 115200
model: SIM7600
apn: orange
pin_code: "0000"
enable_on_boot: True
reboot_timeout: 5min
on_connect:
logger.log: "modem connected"
on_disconnect:
logger.log: "modem disconnected"
on_sync:
logger.log: "modem synced - ready for AT commands"
packages:
modem: !include common.yaml
@@ -15,3 +15,8 @@ sensor:
- pulse_counter.set_total_pulses:
id: pulse_counter_sensor
value: !lambda return (uint32_t) x;
- platform: pulse_counter
name: Pulse Counter Rising
pin: 5
internal_filter: 0us
update_interval: 15s
@@ -8,3 +8,9 @@ sensor:
falling_edge: DECREMENT
internal_filter: 13us
update_interval: 15s
- platform: pulse_counter
name: Pulse Counter Rising
pin: 5
use_pcnt: false
internal_filter: 0us
update_interval: 15s
@@ -417,6 +417,10 @@ cover:
- lambda: |-
id(template_cover_actions).tilt = tilt;
id(template_cover_actions).publish_state();
stop_tilt_action:
- cover.template.publish:
id: template_cover_actions
current_operation: IDLE
on_idle:
# position only
- cover.control:
@@ -435,9 +439,14 @@ cover:
- cover.control:
id: template_cover_actions
stop: true
# stop tilt
- cover.control:
id: template_cover_actions
stop_tilt: true
- cover.open: template_cover_actions
- cover.close: template_cover_actions
- cover.stop: template_cover_actions
- cover.stop_tilt: template_cover_actions
- cover.toggle: template_cover_actions
# CONF_STATE alias for position
- cover.control:
@@ -34,6 +34,10 @@ cover:
// Manually set tilt and publish
id(test_cover).tilt = tilt;
id(test_cover).publish_state();
stop_tilt_action:
- cover.template.publish:
id: test_cover
tilt: 0.4
button:
# cover.control: position only
@@ -109,3 +113,17 @@ button:
- cover.control:
id: test_cover
stop: true
- platform: template
id: btn_stop_tilt
name: "Stop Tilt"
on_press:
- cover.stop_tilt: test_cover
- platform: template
id: btn_control_stop_tilt
name: "Control Stop Tilt"
on_press:
- cover.control:
id: test_cover
stop_tilt: true
@@ -0,0 +1,28 @@
esphome:
name: cover-stop-tilt-cancel-test
host:
api:
logger:
level: DEBUG
cover:
- platform: template
name: "Test Cover"
id: test_cover
optimistic: true
tilt_action:
- delay: 1s
- cover.template.publish:
id: test_cover
tilt: 0.9
stop_tilt_action:
- delay: 1s
- cover.template.publish:
id: test_cover
tilt: 0.4
button:
- platform: template
name: "Stop Tilt"
on_press:
- cover.stop_tilt: test_cover
@@ -90,3 +90,13 @@ async def test_cover_control_action(
state = await press_and_wait("Stop Cover")
# CoverOperation.IDLE == 0
assert state.current_operation == 0
# stop_tilt_action publishes tilt 0.4
state = await press_and_wait("Stop Tilt")
assert state.tilt == pytest.approx(0.4, abs=0.01)
state = await press_and_wait("Set Tilt")
assert state.tilt == pytest.approx(0.75, abs=0.01)
state = await press_and_wait("Control Stop Tilt")
assert state.tilt == pytest.approx(0.4, abs=0.01)
@@ -0,0 +1,56 @@
"""Integration test for how stop tilt and tilt commands cancel each other on a template cover."""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, CoverInfo, CoverState, EntityState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
def _seen(tilts: list[float], value: float) -> bool:
return any(t == pytest.approx(value, abs=0.01) for t in tilts)
@pytest.mark.asyncio
async def test_cover_stop_tilt_cancel(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that stop tilt and tilt commands cancel each other's pending actions."""
async with run_compiled(yaml_config), api_client_connected() as client:
tilts: list[float] = []
def on_state(state: EntityState) -> None:
if isinstance(state, CoverState):
tilts.append(state.tilt)
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
cover = require_entity(entities, "test_cover", CoverInfo)
button = require_entity(entities, "stop_tilt", ButtonInfo)
# tilt_action publishes 0.9 after 1s, stop_tilt_action publishes 0.4 after 1s
tilts.clear()
client.cover_command(key=cover.key, tilt=0.75)
await asyncio.sleep(0.2)
client.button_command(button.key)
await asyncio.sleep(2.0)
assert not _seen(tilts, 0.9)
assert tilts[-1] == pytest.approx(0.4, abs=0.01)
tilts.clear()
client.button_command(button.key)
await asyncio.sleep(0.2)
client.cover_command(key=cover.key, tilt=0.6)
await asyncio.sleep(2.0)
after_tilt = tilts[[round(t, 2) for t in tilts].index(0.6) :]
assert not _seen(after_tilt, 0.4)
assert tilts[-1] == pytest.approx(0.9, abs=0.01)