mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 13:56:10 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a167a04ce | ||
|
|
edda565237 | ||
|
|
3616052a7c | ||
|
|
fbc4bc9e10 | ||
|
|
01b29dbf53 | ||
|
|
e15797c18b | ||
|
|
3c6b1e66e7 | ||
|
|
179f9ed395 | ||
|
|
2f7105fa54 | ||
|
|
e3590bdac7 | ||
|
|
54440a3559 |
@@ -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++;
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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 +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,
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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_),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_{};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]));
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user