Compare commits

..
Author SHA1 Message Date
J. Nick Koston f659814b2c Merge remote-tracking branch 'origin/dfrobot-sen0395-no-heap-commands' into dfrobot-sen0395-line-reader 2026-10-19 12:34:39 -10:00
J. Nick Koston 82d09be042 Test the queue room check for a running sensor 2026-10-19 12:34:32 -10:00
J. Nick Koston fbbeb5cc4f Derive the prompt length from one named prompt string 2026-10-19 12:33:52 -10:00
J. Nick Koston 469cfa296e Merge remote-tracking branch 'origin/dfrobot-sen0395-no-heap-commands' into dfrobot-sen0395-line-reader 2026-10-19 08:14:48 -10:00
J. Nick Koston eaad486cda Merge remote-tracking branch 'origin/dev' into dfrobot-sen0395-no-heap-commands 2026-10-19 08:12:50 -10:00
J. Nick Koston 15141a1e4f Merge branch 'dfrobot-sen0395-no-heap-commands' into dfrobot-sen0395-line-reader
# Conflicts:
#	esphome/components/dfrobot_sen0395/__init__.py
2026-10-18 07:32:06 -10:00
J. Nick Koston ec2245e595 Merge remote-tracking branch 'origin/dev' into dfrobot-sen0395-no-heap-commands 2026-10-18 07:31:54 -10:00
J. Nick Koston 494acce857 Merge remote-tracking branch 'origin/dfrobot-sen0395-no-heap-commands' into dfrobot-sen0395-line-reader 2026-10-16 20:06:01 -10:00
J. Nick Koston b4b3aae8b9 Merge remote-tracking branch 'origin/dev' into dfrobot-sen0395-no-heap-commands 2026-10-16 20:05:55 -10:00
J. Nick Koston 769c20a131 [dfrobot_sen0395] Note the buffer size choice, type the test helpers 2026-10-16 20:05:33 -10:00
J. Nick Koston f29530ac47 [dfrobot_sen0395] Collect lines with LineReader 2026-10-16 20:04:29 -10:00
J. Nick Koston 5e2375aa01 Merge remote-tracking branch 'origin/dev' into dfrobot-sen0395-line-reader 2026-10-16 20:01:56 -10:00
J. Nick Koston 3f6eafc880 Queue a stop/start sequence only when it fits whole 2026-10-16 13:39:46 -10:00
J. Nick Koston 2369ddea9e Shorten the range and latency field comment 2026-10-16 13:14:01 -10:00
J. Nick Koston e0af4cace1 Name the test helper for clang-tidy 2026-10-16 10:40:00 -10:00
J. Nick Koston c464a48afb Round detection ranges like before, restore the 0 0 fallback and test the command text 2026-10-16 10:29:36 -10:00
J. Nick Koston 2c693fad5b Poll the state on an empty queue, use StaticRingBuffer and keep strings in flash 2026-10-16 10:14:21 -10:00
J. Nick Koston 3d8bfb7f94 [dfrobot_sen0395] Queue commands by value instead of allocating them 2026-10-16 10:08:26 -10:00
578 changed files with 5368 additions and 9699 deletions
+2 -8
View File
@@ -115,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with WebSocket listeners, test CA, transport case runner).
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `homeassistant_light/` (direct bulb driver), `usb_audio/` (URL player).
## Templates in `$SKILL/templates`
@@ -130,9 +130,7 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
`bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`,
`homeassistant-light-dut.yaml`, `deep-sleep-outgoing-dut.yaml`, `outgoing-wifi-drop-dut.yaml`,
`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`.
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`.
## When something looks wrong
@@ -146,10 +144,6 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
preference and repeat it in a periodic alive line, as `scripts/libretiny_prefs/ltprefs.hpp` does.
- Multicast from the host never arrives on some access points: test it device to device. Broadcast
is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate.
- The merge base comes from a freshly fetched `origin/dev`; a stale local `dev` puts dependency bumps into the
PR diff and builds the control from the wrong commit.
- A `.local` name can stop resolving in the middle of a run; keep the address from the boot log and drive by it.
- `wc -l` pads its count on macOS, so `tail -n +$n0` needs `$((n0+0))` or an arithmetic expansion.
- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for
soak tests.
@@ -9,9 +9,9 @@ One file per component, named after it:
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback
- [`ld2450.md`](ld2450.md): polygon zones
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT
- [`nrf52.md`](nrf52.md): dongle board support
- [`online_image.md`](online_image.md): stalled and malformed downloads
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
@@ -25,8 +25,5 @@ One file per component, named after it:
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant
- [`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
@@ -1,21 +0,0 @@
# dfrobot_sen0395
## DFRobot SEN0395 mmWave on an ESP32 DevKit V1 (#20906, #20979)
Wiring: V to VIN (5 V, the module wants 3.6 to 5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17
(TX2), IO2 to GPIO18 (presence output, a gpio binary sensor), I1 unconnected. `templates/dfrobot-sen0395-dut.yaml`
has the hub, both detection binary sensors, the four switches, settings buttons and junk-line buttons.
`scripts/dfrobot_sen0395/ctl.py HOST KEY press:NAME sw:NAME=on|off wait:S` drives it and prints binary sensor and
switch updates; `cases.sh DEV_IP KEY CAPTURE LABEL` runs the whole flow against a running serial capture. Flash by
USB, `serial_capture.py PORT 540 CAPTURE --reset`, then the cases; compare the `Sending command` and `Message:` lines
and the counts of `Done`, `Error`, prompts and frames between builds. The module echoes every command, so the echoed
lines are the bytes the component sent and can be compared between builds as a set.
Module facts: it answers `Done` or `Error` and then the `leapMMW:/>` prompt without a line end; a junk line gets
`<line> is not recognized as a CLI command` (36 chars longer than the line, so 220 and 240 byte junk gives 256 and
276 char replies); input lines of 256 bytes or more make the module assert (`..\User_code\main.c line:232`) and
restart, after which the sensor is stopped; a factory reset restarts the module about 7 s later; `sensorStop` on a
stopped sensor answers `sensor stopped already` then `Error`; a settings press takes 5 to 6 s (stop, change, save,
start). Free heap on the ESP32 is Wi-Fi noise (192 to 198 kB) and says nothing about the component.
Results: https://github.com/esphome/esphome/pull/20979#issuecomment-6279129146 and
https://github.com/esphome/esphome/pull/20906#issuecomment-6279129604
@@ -33,15 +33,3 @@ connected but not yet subscribed is dropped by the api server (`client has not s
fallback to an off report the mirror keeps the unsent brightness; a mirror that never saw its light on since boot has
no levels to restore. Results: https://github.com/esphome/esphome/pull/11354#issuecomment-6253630336 and
https://github.com/esphome/esphome/pull/11354#issuecomment-6254161409
## Held report versus a later call (#21144)
A report that arrives during a transition is held and applied when the transition ends, unless a call came after
it. `scripts/homeassistant_light/heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY]` (a key of `-` is plaintext) keeps one API connection to the mirror
device and one to the bulb so the steps land inside a 3 s fade: press "A 50 fade 3s", 0.7 s later set the bulb to
90% directly (the report is held), 1.4 s in press the same fade again (or "A many calls", 16 calls in one loop
pass). Pass: the mirror and the bulb end at the second call's target. Only `same` reproduces the bug: a build
without the fix logs `Applying the report that arrived during the transition` and the bulb stays at 90%. `many`
ends at 100% on both builds because the last call's target differs from the held report, so it is a regression
check only, not a reproduction. Separate button presses are too slow (about 1.5 s each) to land inside the fade.
The mirrors are not API entities, so read their state from the "Log state" button's line. Results: https://github.com/esphome/esphome/pull/21144#issuecomment-6278257695
@@ -5,11 +5,3 @@
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
Writing those numbers blocks the ld2450 loop about 50 ms.
## Zone numbers round trip (#21146)
`scripts/ld2450/zonedrive.py HOST KEY(- for plaintext) set:NAME=VALUE sel:NAME=OPTION wait:S list` sets numbers and
selects over the API and prints every LD2450 state update. After each set the hub sends the set zone command, queries
the radar and republishes all twelve coordinates from the answer, so set each coordinate of each zone to a distinct
value and check the readbacks; no target is needed. Readbacks can arrive late over Wi-Fi, so match them by content.
Results: https://github.com/esphome/esphome/pull/21146#issuecomment-6278026922
@@ -10,22 +10,3 @@ after bulk subscribe and unsubscribe; read the heap seconds later over several r
leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish.
`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a
paho script publishes and polls the serial capture file.
## WebSocket transports on ESP-IDF (#16056)
One build per transport from `templates/mqtt-transport-dut.yaml`, with `scripts/mqtt/mosquitto-ws.conf` as the
local broker (mosquitto 2.x from brew has `websockets` support; `log_type all` prints `New connection from <ip>:<port>
on port <listener>`, which proves which listener each client used) and `scripts/mqtt/make_test_ca.sh BROKER_IP` for
the TLS listener's certificate, both run in the scratch directory so keys, logs and outputs stay out of the checkout. `scripts/mqtt/cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart]` subscribes
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
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
different CA (`mbedtls_ssl_handshake returned -0x2700`) and the self-signed server through the bundle
(`esp-x509-crt-bundle: Failed to verify certificate`, `-0x3000`). `CONFIG_MQTT_TRANSPORT_WEBSOCKET` is on by
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
@@ -1,19 +0,0 @@
# speaker
## Media player pipeline tasks (#21203)
The `speaker` media player platform (`speaker/media_player/`) creates `ann_read`, `ann_decode`, `med_read` and
`med_decode` FreeRTOS tasks on every play and deletes them after a stop. The AUX-2 and sendspin packages use the
`speaker_source` player instead, which has its own pipeline and never runs that code, so a test of
`speaker/media_player/*` has to swap the player: `templates/aux-2-preprod-speaker-player-dut.yaml` removes the
package's `external_media_player` with `id: !remove` and adds a `speaker` platform player on the package's resampler
speakers, plus a "Task names" button whose lambda calls `xTaskGetHandle()` on the four names and logs internal free
heap. Serve FLAC files from the host (`ffmpeg -f lavfi -i "sine=frequency=440:duration=90" -ac 2 -ar 48000 -c:a flac
media90.flac`, a 3 s mono one for announcements, `python3 -m http.server 8765 --directory www`); `audio_http`
refuses WAV and MP3. `scripts/speaker_media_player/cases.sh HOST KEY MEDIA_URL ANN_URL CAPTURE LABEL` runs three
cycles through `drive.py` (`play:URL | ann:URL | stop | press:NAME | wait:S` on one API connection) with the probe
pressed idle, during media, during the announcement, after it and after the stop. Pass: idle none, media `med_*`
only, announcement all four, after stop none, every cycle. API commands land a second or two late over Wi-Fi, so read
the probe against the `State changed to` lines rather than the driver's timestamps. Internal free heap after a stop
moved by 2.5 kB between two boots of the same build, so a heap delta needs more than one boot per build. Results:
https://github.com/esphome/esphome/pull/21203#issuecomment-6299581943
@@ -1,31 +0,0 @@
# web_server
## State SSE on web_server_idf (#21109) and the HTTPD task stack (#21038)
`templates/web-server-sse-dut.yaml` (ESP32 classic, ESP-IDF, `web_server` version 3) publishes eight sensors every
200 ms (small state events on the 1024 byte stack buffer) and three text sensors whose JSON carries the string twice
(`value` and `state`): 600 chars for about 1.3 kB (the 2048 byte tail), 1800 chars for about 3.7 kB (tail grown to
4096) and 2800 chars for about 5.7 kB, over the 5120 byte JSON room, which must log `State event does not fit`
and never put a truncated frame on the wire. An `interval` lambda logs `uxTaskGetStackHighWaterMark()` of the loop
task (`nullptr`) and of the HTTPD task (`xTaskGetHandle("httpd")`) every 5 s; the ESPHome loop task is `loopTask`
on ESP32. Keep `power_save_mode: none`, and drive the device by address: mDNS names dropped out mid-run once.
`scripts/web_server/sse.py HOST SECONDS [--clients N] [--stall] [--churn CYCLES]` opens raw TCP connections to
`/events`, decodes the chunked body and parses every `state` event as JSON; `--stall` adds a connection with a 2 kB
receive buffer that never reads (the device must log `Closing stuck EventSource connection` after the 20 s stall
timeout), `--churn` opens and resets a connection CYCLES times. `sse_cases.sh HOST KEY LABEL CAPTURE` runs the
set: 1 client 60 s, 4 clients 60 s, a reader plus a stalled client 45 s, a reader plus 10 abrupt closes, then two
clients with the "Burst" button (50 sensor and 50 text publishes in one loop pass). Pass: 0 invalid JSON on every
client, the oversize sensor dropped with its warning on every publish, the stalled session closed after about
20 s, no reset. The loop task watermark is a minimum since boot, so compare the value after the first client, after
four clients and after the burst between the builds (the SSE split in #21109 left 1280 B more on the ESP32 classic).
For the HTTPD task, `web_server: auth: type: digest` (the default `auth` is Basic) and
`scripts/web_server/httpd_cases.sh HOST USER PASS LABEL CAPTURE`: `httpmix.sh` sends a `curl --anyauth` request
mix per round (page, `/sensor/<Name>`, `/text_sensor/<Name>`, `/switch/<Name>`, a `POST` with `Content-Length: 0`
to `/switch/<Name>/toggle`, an unknown path, which the IDF server closes without a response, an unauthenticated
request answered 401, and a 2 s `/events` stream), 20 rounds alone and 12 rounds next to two `ssecurl.sh` SSE
clients. Entity URLs use the entity name, URL encoded, not an object id. The HTTPD watermark follows the deepest
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
@@ -1,6 +1,6 @@
"""Press a template button on the test device over the API, or list its buttons.
usage: api_button.py HOST KEY list | press "Button name" [...] (KEY of - means a plaintext API)
usage: api_button.py HOST KEY list | press "Button name" [...]
"""
import asyncio
@@ -13,7 +13,7 @@ CMD, ARGS = sys.argv[3], sys.argv[4:]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
c = APIClient(HOST, 6053, None, noise_psk=KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
@@ -1,22 +0,0 @@
#!/bin/sh
# cases.sh DEV_IP KEY CAPTURE LABEL : the SEN0395 flow (settings, switches, junk lines, factory reset, reset) against
# templates/dfrobot-sen0395-dut.yaml, with a serial capture of the device running into CAPTURE
D=$1; K=$2; LOG=$3; LABEL=$4; PY=${PYTHON:-python3}; CTL="$(dirname "$0")/ctl.py"
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a 'dfrobot\|Detected\|\[W\]\|\[E\]\|sen\]' | grep -a -v 'JYBSS\|took a long time' | cut -c1-200; }
step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; $PY "$CTL" "$D" "$K" "$@" || { rc=$?; echo "driver failed ($rc), aborting"; exit $rc; }; echo "--- device log:"; show "$n0"; }
frames() { echo "frames: $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,1') present, $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,0') absent"; }
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames ($(date +%T))"; sleep 20; frames "$n0"
step "range 0 to 3 m" "press:Range 0 to 3m" wait:12
step "range 0 to 9 m" "press:Range 0 to 9m" wait:12
step "latency 1 s / 2 s" "press:Latency 1s 2s" wait:12
step "latency 0 / 0" "press:Latency 0 0" wait:12
step "sensitivity 7" "press:Sensitivity 7" wait:12
step "LED off then on" "sw:LED=off" wait:10 "sw:LED=on" wait:10
step "sensor active off then on" "sw:Sensor active=off" wait:10 "sw:Sensor active=on" wait:10
step "presence via UART off then on" "sw:Presence via UART=off" wait:10 "sw:Presence via UART=on" wait:10
step "start after boot off then on" "sw:Start after boot=off" wait:10 "sw:Start after boot=on" wait:10
for n in 100 160 200 220 240 256; do step "junk line of $n bytes" "press:Junk $n" wait:7; done
step "junk 220 then a command" "press:Junk 220" wait:6 "press:Range 0 to 9m" wait:12
step "factory reset" "press:Factory reset" wait:15
step "reset" "press:Reset" wait:15
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames after ($(date +%T))"; sleep 20; frames "$n0"
@@ -1,63 +0,0 @@
"""Drive the SEN0395 test device over the API and print its binary sensor and switch updates.
usage: ctl.py HOST KEY(- for plaintext) OP [OP ...] OP: press:NAME | sw:NAME=on|off | wait:SECONDS
"""
import asyncio
import sys
import time
from aioesphomeapi import (
APIClient,
BinarySensorState,
ButtonInfo,
SwitchInfo,
SwitchState,
)
HOST, KEY = sys.argv[1], sys.argv[2]
OPS = sys.argv[3:]
T0 = time.time()
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
by_key = {e.key: e for e in ents}
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
switches = {e.name: e for e in ents if isinstance(e, SwitchInfo)}
def on_state(s) -> None:
e = by_key.get(s.key)
if isinstance(s, BinarySensorState):
print(
f"{time.time() - T0:7.3f} BIN {e.name} = {'ON' if s.state else 'off'}"
)
elif isinstance(s, SwitchState):
print(
f"{time.time() - T0:7.3f} SW {e.name} = {'on' if s.state else 'off'}"
)
c.subscribe_states(on_state)
await asyncio.sleep(1.0)
for op in OPS:
kind, _, rest = op.partition(":")
if kind == "wait":
await asyncio.sleep(float(rest))
elif kind == "press":
if rest not in buttons:
sys.exit(f"no button {rest!r}; have {sorted(buttons)}")
print(f"{time.time() - T0:7.3f} >>> press {rest}")
c.button_command(buttons[rest].key)
elif kind == "sw":
name, _, val = rest.partition("=")
if name not in switches:
sys.exit(f"no switch {name!r}; have {sorted(switches)}")
print(f"{time.time() - T0:7.3f} >>> switch {name} {val}")
c.switch_command(switches[name].key, val == "on")
else:
sys.exit(f"unknown op {op!r}")
await c.disconnect()
asyncio.run(main())
@@ -1,71 +0,0 @@
"""Held-report cases for esphome#21144 with persistent API connections, so the steps land inside the 3 s fade.
usage: heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY] (KEY or BULB_KEY of - means plaintext)
t0 press "A 50 fade 3s" on the mirror device (3 s fade to 50%)
t0+0.7 set the bulb directly to 90% (Home Assistant reports it to the mirror during the fade: held)
t0+1.4 same: press "A 50 fade 3s" again (same target as the running fade)
many: press "A many calls" (16 calls in one loop pass, the last one 100% with a 1 s fade)
t0+6.5 read the bulb, press Log state so the mirror prints its state
"""
import asyncio
import sys
import time
from aioesphomeapi import APIClient, ButtonInfo, LightInfo, LightState
DEV, KEY, BULB, CASE = sys.argv[1:5]
BULB_KEY = sys.argv[5] if len(sys.argv) > 5 else "-"
if CASE not in ("same", "many"):
sys.exit(f"case must be same or many, got {CASE!r}")
T0 = time.time()
def stamp() -> str:
return f"{time.time() - T0:6.3f}"
async def main() -> None:
dev = APIClient(DEV, 6053, None, noise_psk=None if KEY == "-" else KEY)
bulb = APIClient(BULB, 6053, None, noise_psk=None if BULB_KEY == "-" else BULB_KEY)
await dev.connect(login=True)
await bulb.connect(login=True)
dents, _ = await dev.list_entities_services()
bents, _ = await bulb.list_entities_services()
buttons = {e.name: e for e in dents if isinstance(e, ButtonInfo)}
blight = next(e for e in bents if isinstance(e, LightInfo))
states = {}
def on_bulb(s) -> None:
if isinstance(s, LightState) and s.key == blight.key:
states["bulb"] = s
print(f"{stamp()} bulb state: on={s.state} bri={s.brightness:.2f}")
bulb.subscribe_states(on_bulb)
await asyncio.sleep(1.0)
print(f"{stamp()} >>> bulb to 100% (precondition)")
bulb.light_command(blight.key, state=True, brightness=1.0)
await asyncio.sleep(4.0)
t = time.time()
print(f"{stamp()} >>> t0 press A 50 fade 3s")
dev.button_command(buttons["A 50 fade 3s"].key)
await asyncio.sleep(0.7 - (time.time() - t))
print(f"{stamp()} >>> bulb direct to 90%")
bulb.light_command(blight.key, state=True, brightness=0.9)
await asyncio.sleep(1.4 - (time.time() - t))
name = "A 50 fade 3s" if CASE == "same" else "A many calls"
print(f"{stamp()} >>> press {name}")
dev.button_command(buttons[name].key)
await asyncio.sleep(6.5 - (time.time() - t))
print(f"{stamp()} >>> press Log state (the mirror's state is in the device log)")
dev.button_command(buttons["Log state"].key)
await asyncio.sleep(1.0)
b = states.get("bulb")
print(
f"{stamp()} RESULT bulb "
+ (f"bri={b.brightness:.2f}" if b is not None else "no state")
)
await dev.disconnect()
await bulb.disconnect()
asyncio.run(main())
@@ -1,85 +0,0 @@
"""Drive LD2450 numbers and selects over the API while printing every LD2450 state update.
usage: zonedrive.py HOST KEY(- for plaintext) OP [OP ...]
OP: set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS | list
Every state update of an entity whose name contains "LD2450" is printed with a timestamp.
"""
import asyncio
import sys
import time
from aioesphomeapi import (
APIClient,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
SensorInfo,
SensorState,
)
HOST, KEY = sys.argv[1], sys.argv[2]
OPS = sys.argv[3:]
T0 = time.time()
def stamp() -> str:
return f"{time.time() - T0:7.3f}"
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
by_key = {e.key: e for e in ents}
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
sensors = {e.name: e for e in ents if isinstance(e, SensorInfo)}
def on_state(s) -> None:
e = by_key.get(s.key)
if e is None or "LD2450" not in e.name:
return
if isinstance(s, NumberState):
print(
f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}"
)
elif isinstance(s, SelectState):
print(f"{stamp()} SEL {e.name} = {s.state}")
elif isinstance(s, SensorState) and "Count" in e.name:
print(f"{stamp()} CNT {e.name} = {s.state:.0f}")
c.subscribe_states(on_state)
await asyncio.sleep(1.5)
for op in OPS:
if op == "list":
for n in sorted(numbers):
print("number:", n)
for n in sorted(selects):
print("select:", n, selects[n].options)
for n in sorted(sensors):
if "Count" in n:
print("sensor:", n)
continue
kind, _, rest = op.partition(":")
if kind == "wait":
await asyncio.sleep(float(rest))
continue
name, _, value = rest.partition("=")
if kind == "set":
if name not in numbers:
sys.exit(f"no number named {name!r}; have {sorted(numbers)}")
print(f"{stamp()} >>> set {name} = {value}")
c.number_command(numbers[name].key, float(value))
elif kind == "sel":
if name not in selects:
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
print(f"{stamp()} >>> select {name} = {value}")
c.select_command(selects[name].key, value)
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(0.3)
await c.disconnect()
asyncio.run(main())
@@ -1,42 +0,0 @@
#!/bin/sh
# cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart] : subscribe to NAME/# on the broker, send a
# switch command and an on_message topic, press the 3000 byte publish button over the API (KEY, - for plaintext),
# optionally restart the local broker (its PID from mosquitto.pid in the working directory, written when it was started
# with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid`), and show the device log. Outputs (sub-NAME.txt, mosquitto.out) go to the working directory, which should be the scratch directory.
N=$1; KEY=$2; B=$3; SP=$4; LOG=$5; DEV=${6:-$1.local}; RESTART=$7; HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; WORK=$PWD
BTN=$HERE/../api_button.py
[ -n "$KEY" ] && [ -r "$LOG" ] || { echo "usage: cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart] (CAPTURE must be a readable file)"; exit 2; }
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a 'mqtt\|\[probe:\|cmd payload\|Relay\|\[W\]\|\[E\]\|Guru\|abort\|Backtrace\|ESPHome version' | grep -a -v 'took a long time\|Publish.*Counter\|sensor/counter' | cut -c1-170; }
pub() { mosquitto_pub -h "$B" -p "$SP" -t "$1" -m "$2" || { echo "mosquitto_pub to $1 failed"; exit 1; }; }
n0=$(($(wc -l < "$LOG")+0))
timeout 75 mosquitto_sub -h "$B" -p "$SP" -v -t "$N/#" > "$WORK/sub-$N.txt" 2> "$WORK/sub-$N.err" &
sub_pid=$!
sleep 20
echo "=== $N: command to the switch and the 3000 byte publish ($(date +%T))"
pub "$N/switch/relay/command" ON; sleep 2
pub "$N/cmd" "hello-over-$N"; sleep 2
$PY "$BTN" "$DEV" "$KEY" press "Publish big" || { echo "button press failed"; exit 1; }; sleep 3
pub "$N/switch/relay/command" OFF; sleep 2
if [ "$RESTART" = restart ]; then
[ -f "$WORK/mosquitto-ws.conf" ] || { echo "no mosquitto-ws.conf in $WORK, cannot restart the broker"; exit 1; }
echo "=== $N: broker restart ($(date +%T))"; n1=$(($(wc -l < "$LOG")+0))
# Only this run's broker is signalled: its PID comes from mosquitto.pid, written when it was started
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
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)"
fi
wait $sub_pid; sub_rc=$?
[ "$sub_rc" = 124 ] || { echo "subscriber ended with status $sub_rc: $(cat "$WORK/sub-$N.err")"; exit 1; }
echo "--- broker saw ($(grep -c . "$WORK/sub-$N.txt") messages): $(cut -c1-60 "$WORK/sub-$N.txt" | awk '{print $1}' | sort | uniq -c | tr '\n' ';')"
big=$(grep "^$N/big " "$WORK/sub-$N.txt" | awk '{print length($2)}' | tr '\n' ' ')
echo "--- big payload bytes seen: $big"
echo "--- device log:"; show "$n0"
case " $big " in *" 3000 "*) ;; *) echo "the 3000 byte payload did not reach the broker"; exit 1;; esac
for want in "Relay ON" "cmd payload: hello-over-$N" "Relay OFF"; do
tail -n +$((n0+1)) "$LOG" | grep -a -q "$want" || { echo "the device never logged '$want': the broker to device path failed"; exit 1; }
done
@@ -1,13 +0,0 @@
#!/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.
set -e
IP=$1
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 30 -subj "/CN=hwtest-ca"
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
@@ -1,14 +0,0 @@
# 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.
listener 1883
protocol mqtt
listener 9001
protocol websockets
listener 9002
protocol websockets
certfile server.crt
keyfile server.key
allow_anonymous true
log_type all
log_dest file mosquitto.log
@@ -1,12 +0,0 @@
#!/bin/sh
# cases.sh HOST KEY MEDIA_URL ANN_URL CAPTURE LABEL : three play, announce, stop cycles on a speaker media player with the
# "Task names" probe button pressed idle, during media, during the announcement, after it and after the stop.
# KEY of - is plaintext. Serve FLAC files with `python3 -m http.server 8765 --directory www` (audio_http refuses WAV and MP3).
H=$1; K=$2; MEDIA=$3; ANN=$4; LOG=$5; LABEL=$6; PY=${PYTHON:-python3}; HERE=$(dirname "$0")
[ -r "$LOG" ] || { echo "usage: cases.sh HOST KEY MEDIA_URL ANN_URL CAPTURE LABEL (CAPTURE must be a readable file)"; exit 2; }
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a '\[tasks:\|speaker_media_player\|speaker_mixer\|\[W\]\|\[E\]\|Guru\|abort\|Backtrace' | grep -a -v 'took a long time' | cut -c1-180; }
for cycle in 1 2 3; do
n0=$(wc -l < "$LOG"); echo "=== $LABEL cycle $cycle ($(date +%T))"
$PY "$HERE/drive.py" "$H" "$K" "press:Task names" wait:1 "play:$MEDIA" wait:5 "press:Task names" wait:1 "ann:$ANN" wait:1.5 "press:Task names" wait:5 "press:Task names" wait:1 stop wait:3 "press:Task names" || { rc=$?; echo "driver failed ($rc), aborting"; exit $rc; }
sleep 1; echo "--- device log:"; show "$n0"
done
@@ -1,50 +0,0 @@
"""Drive the speaker platform media player (the first media player found) over one API connection.
usage: drive.py HOST KEY op [op ...]
ops: play:URL | ann:URL | stop | press:BUTTON | wait:S
"""
import asyncio
import sys
from aioesphomeapi import APIClient, ButtonInfo, MediaPlayerCommand, MediaPlayerInfo
HOST, KEY, OPS = sys.argv[1], sys.argv[2], sys.argv[3:]
async def main() -> None:
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
try:
await run_ops(c)
finally:
await c.disconnect()
async def run_ops(c: APIClient) -> None:
ents, _ = await c.list_entities_services()
player = next((e for e in ents if isinstance(e, MediaPlayerInfo)), None)
if player is None:
sys.exit("no media player on this device")
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
for op in OPS:
kind, _, rest = op.partition(":")
print(f"{asyncio.get_running_loop().time():.1f} {op}", flush=True)
if kind == "play":
c.media_player_command(player.key, media_url=rest)
elif kind == "ann":
c.media_player_command(player.key, media_url=rest, announcement=True)
elif kind == "stop":
c.media_player_command(player.key, command=MediaPlayerCommand.STOP)
elif kind == "press":
if rest not in buttons:
sys.exit(f"no button {rest!r}; have {sorted(buttons)}")
c.button_command(buttons[rest].key)
elif kind == "wait":
await asyncio.sleep(float(rest))
else:
sys.exit(f"unknown op {op!r}")
await asyncio.sleep(0.5)
asyncio.run(main())
@@ -1,17 +0,0 @@
#!/bin/sh
# httpd_cases.sh HOST USER PASS LABEL CAPTURE : HTTPD task stack load for the web_server_idf task size; probe lines from
# CAPTURE, SSE client output into the working directory. A failed step ends the run with its status.
H=$1; U=$2; P=$3; LABEL=$4; LOG=$5; HERE=$(dirname "$0")
[ -r "$LOG" ] || { echo "usage: httpd_cases.sh HOST USER PASS LABEL CAPTURE (CAPTURE must be a readable file)"; exit 2; }
probes() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a -o "\[[0-9:]*\]\[I\]\[probe:[0-9]*\]: .*httpd min free stack [0-9]*\|\[[0-9:]*\]\[[WE]\]\[[a-z_.]*:[0-9]*\].*\|Guru.*" | grep -a -v "took a long time\|does not fit\|\]\[httpd\]: /" | sed 's/\[I\]\[probe:[0-9]*\]: loopTask min free stack \([0-9]*\), httpd min free stack \([0-9]*\)/ loop \1 httpd \2/' | uniq -f1 | cut -c1-140; }
step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; "$@" || { rc=$?; echo "step failed ($rc)"; exit $rc; }; echo "--- device log:"; probes "$n0"; }
step "idle 15 s" sleep 15
step "request mix, 20 rounds" sh "$HERE/httpmix.sh" "$H" "$U" "$P" 20
# Two SSE clients next to the request mix; arguments stay quoted, nothing is reparsed as shell code
concurrent() {
sh "$HERE/ssecurl.sh" "$H" "$U" "$P" 40 "$PWD/sse1-$LABEL.txt" & p1=$!
sh "$HERE/ssecurl.sh" "$H" "$U" "$P" 40 "$PWD/sse2-$LABEL.txt" & p2=$!
sh "$HERE/httpmix.sh" "$H" "$U" "$P" 12 && wait $p1 && wait $p2
}
step "2 SSE clients 40 s with the request mix alongside" concurrent
step "idle 15 s after" sleep 15
@@ -1,26 +0,0 @@
#!/bin/sh
# httpmix.sh HOST USER PASS ROUNDS : authenticated request mix (curl --anyauth follows the server's Basic or Digest
# challenge): page, two json endpoints, a switch POST, a 404, an unauthenticated 401 and a 2 s SSE stream per round
H=$1; U=$2; P=$3; N=$4; ok=0; bad=0
# chk EXPECTED LABEL curl-args... : only LABEL is printed on a miss, never the argument list with the credentials
chk() { exp=$1; label=$2; shift 2; c=$(curl -s -m 6 -o /dev/null -w "%{http_code}" "$@"); if [ "$c" = "$exp" ]; then ok=$((ok+1)); else bad=$((bad+1)); echo "expected $exp got $c: $label"; fi; }
sse_tmp=$(mktemp)
for i in $(seq 1 $N); do
page=$(curl -s -m 6 -o /dev/null -w "%{http_code}" -u "$U:$P" --anyauth "http://$H/")
if [ "$page" = 200 ]; then ok=$((ok+1)); else bad=$((bad+1)); echo "expected 200 got $page: /"; fi
chk 200 "GET /sensor/Fast%201" -u "$U:$P" --anyauth "http://$H/sensor/Fast%201"
chk 200 "GET /text_sensor/Big" -u "$U:$P" --anyauth "http://$H/text_sensor/Big"
chk 200 "GET /switch/Toggle" -u "$U:$P" --anyauth "http://$H/switch/Toggle"
chk 200 "POST /switch/Toggle/toggle" -u "$U:$P" --anyauth -X POST -H "Content-Length: 0" "http://$H/switch/Toggle/toggle"
# The IDF server closes an unknown path without a response: curl ends with 52 (empty reply) or 56 (reset), never a
# timeout (28) or a refused connection (7); only trust the close when the page just answered
c=$(curl -s -m 6 -o /dev/null -w "%{http_code}" -u "$U:$P" --anyauth "http://$H/nonexistent"); rc=$?
if [ "$c" = 000 ] && [ "$page" = 200 ] && { [ "$rc" = 52 ] || [ "$rc" = 56 ]; }; then ok=$((ok+1)); else bad=$((bad+1)); echo "unknown path: got $c, curl status $rc, with the page at $page"; fi
chk 401 "GET / without credentials" "http://$H/"
timeout 2 curl -s -N -u "$U:$P" --anyauth -o "$sse_tmp" "http://$H/events" 2>/dev/null; rc=$?
if [ "$rc" = 124 ] && grep -q "^event: state" "$sse_tmp"; then ok=$((ok+1)); else bad=$((bad+1)); echo "SSE stream: curl status $rc, $(grep -c '^event: state' "$sse_tmp") state events"; fi
sleep 1
done
rm -f "$sse_tmp"
echo "httpmix: $ok ok, $bad unexpected"
[ "$bad" = 0 ]
@@ -1,187 +0,0 @@
"""SSE client(s) for a web_server /events stream: counts state events and checks each is valid JSON.
usage: sse.py HOST SECONDS [--clients N] [--stall] [--churn CYCLES]
--stall opens one extra connection that never reads (the device's socket buffer fills); --churn opens
and abruptly closes a connection CYCLES times, 2 s apart, next to the reading clients.
"""
import argparse
from collections import Counter
import contextlib
import json
import socket
import sys
import threading
import time
ap = argparse.ArgumentParser()
ap.add_argument("host")
ap.add_argument("seconds", type=float)
ap.add_argument("--clients", type=int, default=1)
ap.add_argument("--stall", action="store_true")
ap.add_argument("--churn", type=int, default=0)
args = ap.parse_args()
REQ = b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\nConnection: keep-alive\r\n\r\n"
def read_chunked(sock, deadline, on_line):
"""Decode HTTP chunked transfer encoding and feed complete event-stream lines to on_line."""
buf = b""
body = b""
headers_done = False
sock.settimeout(1.0)
while time.monotonic() < deadline:
try:
data = sock.recv(65536)
except TimeoutError:
continue
if not data:
return "closed"
buf += data
if not headers_done:
i = buf.find(b"\r\n\r\n")
if i < 0:
continue
headers_done = True
buf = buf[i + 4 :]
while True:
i = buf.find(b"\r\n")
if i < 0:
break
size = int(buf[:i].split(b";")[0], 16)
if len(buf) < i + 2 + size + 2:
break
body += buf[i + 2 : i + 2 + size]
buf = buf[i + 2 + size + 2 :]
if size == 0:
return "closed"
while b"\n" in body:
line, body = body.split(b"\n", 1)
on_line(line.rstrip(b"\r"))
return "deadline"
class Client(threading.Thread):
def __init__(self, idx, seconds):
super().__init__(daemon=True)
self.idx = idx
self.seconds = seconds
self.events = Counter()
self.ids = Counter()
self.invalid = 0
self.max_data = 0
self.bytes = 0
self.end = "?"
self.cur_event = None
def on_line(self, line):
self.bytes += len(line) + 1
if line.startswith(b"event:"):
self.cur_event = line[6:].strip().decode()
elif line.startswith(b"data:"):
ev = self.cur_event or "message"
self.events[ev] += 1
data = line[5:].strip()
self.max_data = max(self.max_data, len(data))
if ev in ("state", "state_detail_all"):
try:
obj = json.loads(data)
self.ids[obj.get("id", "?")] += 1
except ValueError:
self.invalid += 1
elif line == b"":
self.cur_event = None
def run(self):
try:
s = socket.create_connection((args.host, 80), timeout=5)
s.sendall(REQ % args.host.encode())
self.end = read_chunked(s, time.monotonic() + self.seconds, self.on_line)
s.close()
except (OSError, ValueError) as e:
self.end = f"error: {e}"
clients = [Client(i, args.seconds) for i in range(args.clients)]
for c in clients:
c.start()
time.sleep(0.3)
stall_sock = None
if args.stall:
# The window scale is negotiated in the SYN, so the small buffer must be set before connect()
stall_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
stall_sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2048)
stall_sock.settimeout(5)
stall_sock.connect((args.host, 80))
stall_sock.sendall(REQ % args.host.encode())
print("stalled client opened (never reads)", flush=True)
churn_done = 0
for _ in range(args.churn):
try:
cs = socket.create_connection((args.host, 80), timeout=5)
cs.sendall(REQ % args.host.encode())
cs.settimeout(1.0)
with contextlib.suppress(TimeoutError):
cs.recv(4096)
cs.setsockopt(
socket.SOL_SOCKET, socket.SO_LINGER, b"\x01\x00\x00\x00\x00\x00\x00\x00"
) # RST on close
cs.close()
churn_done += 1
except OSError as e:
print("churn error", e, flush=True)
time.sleep(2)
if args.churn:
print(f"churn: {churn_done}/{args.churn} abrupt closes", flush=True)
for c in clients:
c.join()
stall_closed = None
if stall_sock is not None:
# Drain what the small buffer holds: a close shows as b"" or a reset after it. A session the device left
# open keeps feeding events, so the drain has its own deadline and data past it means "still open".
stall_sock.settimeout(2.0)
drain_deadline = time.monotonic() + 5.0
try:
while stall_sock.recv(65536):
if time.monotonic() > drain_deadline:
break
else:
stall_closed = True
except TimeoutError:
pass
except OSError:
stall_closed = True
if stall_closed is None:
stall_closed = False
print(
"stalled client: closed by device"
if stall_closed
else "stalled client: still open",
flush=True,
)
stall_sock.close()
for c in clients:
states = c.events["state"]
print(
f"client {c.idx}: {c.end}, {states} state events, {c.invalid} invalid JSON, max data {c.max_data} B, "
f"{c.bytes} B, other events { {k: v for k, v in c.events.items() if k != 'state'} }",
flush=True,
)
print(" per id:", dict(sorted(c.ids.items())), flush=True)
failed = [
c for c in clients if c.end != "deadline" or c.invalid or not c.events["state"]
]
if failed:
sys.exit(
f"{len(failed)} client(s) did not run clean to the deadline with valid state events"
)
if stall_closed is False:
sys.exit("stalled session was not closed by the device")
if churn_done < args.churn:
sys.exit(f"only {churn_done} of {args.churn} churn connections were made")
@@ -1,19 +0,0 @@
#!/bin/sh
# sse_cases.sh HOST KEY LABEL CAPTURE : the SSE cases against templates/web-server-sse-dut.yaml with a console capture
# running into CAPTURE; KEY is the API key (- for plaintext). The burst client output goes to the working directory.
H=$1; K=$2; LABEL=$3; LOG=$4; PY=${PYTHON:-python3}; HERE=$(dirname "$0"); BTN=$HERE/../api_button.py
[ -r "$LOG" ] || { echo "usage: sse_cases.sh HOST KEY LABEL CAPTURE (CAPTURE must be a readable file)"; exit 2; }
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a '\[probe:\|\[W\]\|\[E\]\|web_server\|Guru\|abort\|Backtrace\|ESPHome version' | grep -a -v 'took a long time' | cut -c1-200; }
step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; "$@" || { rc=$?; echo "client failed ($rc), aborting"; exit $rc; }; echo "--- device log:"; show "$n0"; }
step "idle 15 s, no client" sleep 15
step "1 client 60 s" $PY "$HERE/sse.py" "$H" 60 --clients 1
step "4 clients 60 s" $PY "$HERE/sse.py" "$H" 60 --clients 4
step "1 reader plus a stalled client 45 s" $PY "$HERE/sse.py" "$H" 45 --clients 1 --stall
step "1 reader plus 10 abrupt closes 30 s" $PY "$HERE/sse.py" "$H" 30 --clients 1 --churn 10
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 2 clients 25 s with the Burst button at 5 s ($(date +%T))"
$PY "$HERE/sse.py" "$H" 25 --clients 2 > "$PWD/burst-$LABEL.txt" 2>&1 &
pid=$!
sleep 5; $PY "$BTN" "$H" "$K" press "Burst" || { echo "button press failed"; exit 1; }
wait $pid || { cat "$PWD/burst-$LABEL.txt"; echo "SSE clients failed during the burst"; exit 1; }
cat "$PWD/burst-$LABEL.txt"; echo "--- device log:"; show "$n0"
step "idle 15 s after" sleep 15
@@ -1,25 +0,0 @@
#!/bin/sh
# ssecurl.sh HOST USER PASS SECONDS OUT : one authenticated SSE client through curl; counts state events and checks JSON
H=$1; U=$2; P=$3; T=$4; OUT=$5
timeout "$T" curl -sS -f -N -u "$U:$P" --anyauth "http://$H/events" > "$OUT"; rc=$?
[ "$rc" = 124 ] || { echo "sse: curl ended with status $rc before the $T s deadline"; exit 1; }
python3 - "$OUT" <<'PY'
import json, sys
states = invalid = 0; biggest = 0; ev = None
data = open(sys.argv[1], "rb").read()
lines = data.split(b"\n")
if not data.endswith(b"\n"):
lines = lines[:-1] # the deadline can cut the last line in half; only complete lines are checked
for line in lines:
line = line.rstrip(b"\r")
if line.startswith(b"event:"):
ev = line[6:].strip()
elif line.startswith(b"data:") and ev == b"state":
states += 1; d = line[5:].strip(); biggest = max(biggest, len(d))
try: json.loads(d)
except ValueError: invalid += 1
elif line == b"":
ev = None
print(f"sse: {states} state events, {invalid} invalid JSON, largest {biggest} B")
sys.exit(1 if invalid or not states else 0)
PY
@@ -1,66 +0,0 @@
# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file (see
# reference/recipes/usb_audio.md). The AUX-2 package with the package's speaker_source player swapped for the speaker
# platform player, which is the one built on speaker/media_player/audio_pipeline.cpp, plus the task name probe.
packages:
core: !include aux-2-pre-prod.yaml
esphome:
name: aux-2-dut
name_add_mac_suffix: false
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
logger:
level: DEBUG
sendspin:
unpaired_access: true
media_player:
- id: !remove external_media_player
- platform: speaker
id: speaker_media_player
name: Media Player
volume_increment: 0.05
volume_min: 0.4
volume_max: 0.85
announcement_pipeline:
speaker: announcement_resampling_speaker
format: FLAC
num_channels: 1
sample_rate: 48000
media_pipeline:
speaker: media_resampling_speaker
format: FLAC
num_channels: 2
sample_rate: 48000
on_announcement:
- mixer_speaker.apply_ducking:
id: media_mixing_input
decibel_reduction: 20
duration: 0.0s
button:
- platform: template
name: Task names
on_press:
- lambda: |-
static const char *const NAMES[] = {"ann_read", "ann_decode", "med_read", "med_decode"};
for (const char *n : NAMES) {
TaskHandle_t h = xTaskGetHandle(n);
if (h == nullptr) {
ESP_LOGI("tasks", "%s: missing", n);
} else {
ESP_LOGI("tasks", "%s: found as '%s', stack high water %u", n, pcTaskGetName(h),
(unsigned) uxTaskGetStackHighWaterMark(h));
}
}
ESP_LOGI("tasks", "internal free %u, largest block %u", (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
@@ -1,186 +0,0 @@
# DFRobot SEN0395 on an ESP32 DevKit V1: V to VIN (5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17 (TX2),
# IO2 to GPIO18, I1 unconnected. Settings buttons, the four switches and junk-line buttons whose echo and
# "is not recognized" reply come back as lines of known length (220 and 240 bytes give replies over 252 chars).
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: VERBOSE
logs:
api: DEBUG
api.connection: DEBUG
api.service: DEBUG
wifi: DEBUG
mdns: DEBUG
scheduler: DEBUG
component: DEBUG
esp32: DEBUG
uart: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
uart:
id: mmwave_uart
tx_pin: GPIO17
rx_pin: GPIO16
baud_rate: 115200
dfrobot_sen0395:
id: mmwave
uart_id: mmwave_uart
binary_sensor:
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
name: Detected via UART
id: uart_detected
- platform: gpio
name: Detected via GPIO
device_class: motion
pin:
number: GPIO18
mode: INPUT_PULLDOWN
switch:
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
type: sensor_active
name: Sensor active
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
type: presence_via_uart
name: Presence via UART
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
type: turn_on_led
name: LED
- platform: dfrobot_sen0395
dfrobot_sen0395_id: mmwave
type: start_after_boot
name: Start after boot
button:
- platform: template
name: Range 0 to 3m
on_press:
- dfrobot_sen0395.settings:
id: mmwave
detection_segments:
- [0cm, 3m]
- platform: template
name: Range 0 to 9m
on_press:
- dfrobot_sen0395.settings:
id: mmwave
detection_segments:
- [0cm, 9m]
- platform: template
name: Latency 1s 2s
on_press:
- dfrobot_sen0395.settings:
id: mmwave
output_latency:
delay_after_detect: 1s
delay_after_disappear: 2s
- platform: template
name: Latency 0 0
on_press:
- dfrobot_sen0395.settings:
id: mmwave
output_latency:
delay_after_detect: 0s
delay_after_disappear: 0s
- platform: template
name: Sensitivity 7
on_press:
- dfrobot_sen0395.settings:
id: mmwave
sensitivity: 7
- platform: template
name: Factory reset
on_press:
- dfrobot_sen0395.settings:
id: mmwave
factory_reset: true
- platform: template
name: Reset
on_press:
- dfrobot_sen0395.reset:
id: mmwave
- platform: template
name: Junk 100
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(100, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 160
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(160, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 200
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(200, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 220
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(220, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 240
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(240, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
- platform: template
name: Junk 256
on_press:
- uart.write:
id: mmwave_uart
data: !lambda |-
std::vector<uint8_t> v(256, 'x');
v.push_back('\r');
v.push_back('\n');
return v;
interval:
- interval: 30s
then:
- lambda: 'ESP_LOGI("sen", "alive uart_detected=%d heap=%u", id(uart_detected).state, (unsigned) esp_get_free_heap_size());'
@@ -124,20 +124,6 @@ button:
name: B off
on_press:
- light.turn_off: bulb_b
- platform: template
name: A 50 fade 3s
on_press:
- light.turn_on:
id: bulb_a
brightness: 50%
transition_length: 3s
- platform: template
name: A many calls
on_press:
- lambda: |-
for (int i = 0; i < 16; i++) {
id(bulb_a).make_call().set_brightness(i == 15 ? 1.0f : 0.6f).set_transition_length(1000).perform();
}
- platform: template
name: Log state
on_press:
@@ -1,88 +0,0 @@
# MQTT transport test (#16056, needs that PR's tree for the transport option): one build per transport, name and mqtt block per file (see reference/recipes/mqtt.md).
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 public bundle run
port: 9002 # 1883 tcp, 9001 ws, 9002 wss, 8081 public wss
transport: wss # drop for tcp
ws_path: /mqtt # ws and wss only
certificate_authority: !secret mqtt_ca # drop for the bundle runs
skip_cert_cn_check: true # with the local CA only: the certificate names an address
reboot_timeout: 0s
discovery: false
on_message:
- topic: ${name}/cmd
then:
- logger.log:
format: "cmd payload: %s"
args: ["x.c_str()"]
@@ -1,136 +0,0 @@
# web_server_idf state SSE and HTTPD task stress (#21109, #21038). Add `auth:` with `type: digest` for the HTTPD
# stack case; the entity endpoints are named after the entities (/sensor/Fast%201, /switch/Toggle/toggle).
esphome:
name: web-server-dut
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
api:
encryption:
key: !secret api_key
reboot_timeout: 0s
ota:
- platform: esphome
web_server:
port: 80
version: 3
# Stack and heap probe from the main loop task (loopTask) and the HTTPD task.
interval:
- interval: 5s
then:
- lambda: |-
TaskHandle_t httpd = xTaskGetHandle("httpd");
if (httpd == nullptr) {
ESP_LOGW("probe", "httpd task not found, loopTask min free stack %u",
(unsigned) uxTaskGetStackHighWaterMark(nullptr));
return;
}
ESP_LOGI("probe", "loopTask min free stack %u, httpd min free stack %u, internal free %u, largest block %u",
(unsigned) uxTaskGetStackHighWaterMark(nullptr), (unsigned) uxTaskGetStackHighWaterMark(httpd),
(unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
# Small state events: eight sensors at 200 ms, well under the 1024 byte stack buffer.
sensor:
- platform: template
name: Fast 1
id: fast1
update_interval: 200ms
accuracy_decimals: 3
lambda: return random_float() * 100.0f;
- platform: template
name: Fast 2
update_interval: 200ms
accuracy_decimals: 3
lambda: return random_float() * 100.0f;
- platform: template
name: Fast 3
update_interval: 200ms
accuracy_decimals: 3
lambda: return random_float() * 100.0f;
- platform: template
name: Fast 4
update_interval: 200ms
accuracy_decimals: 3
lambda: return random_float() * 100.0f;
- platform: template
name: Fast 5
update_interval: 200ms
accuracy_decimals: 3
lambda: return random_float() * 100.0f;
- platform: template
name: Fast 6
update_interval: 200ms
accuracy_decimals: 3
lambda: return random_float() * 100.0f;
- platform: template
name: Fast 7
update_interval: 200ms
accuracy_decimals: 3
lambda: return random_float() * 100.0f;
- platform: template
name: Fast 8
update_interval: 200ms
accuracy_decimals: 3
lambda: return random_float() * 100.0f;
# Large state events: the text sensor JSON carries the string twice (value and state).
# mid ~1.3 kB goes to the 2048 byte tail, big ~3.7 kB grows the tail to 4096, huge ~5.7 kB exceeds the
# 5120 byte JSON room and must be dropped with "State event does not fit".
text_sensor:
- platform: template
name: Mid
id: mid
update_interval: 1s
lambda: |-
static uint32_t n = 0;
std::string s = std::to_string(n++) + ":";
s.append(600 - s.size(), 'm');
return s;
- platform: template
name: Big
id: big
update_interval: 2s
lambda: |-
static uint32_t n = 0;
std::string s = std::to_string(n++) + ":";
s.append(1800 - s.size(), 'b');
return s;
- platform: template
name: Huge
id: huge
update_interval: 7s
lambda: |-
static uint32_t n = 0;
std::string s = std::to_string(n++) + ":";
s.append(2800 - s.size(), 'h');
return s;
switch:
- platform: template
name: Toggle
optimistic: true
button:
- platform: template
name: Burst
on_press:
- lambda: |-
for (int i = 0; i < 50; i++) {
id(fast1).publish_state(i);
id(mid).publish_state(std::string(600, 'x'));
}
+4
View File
@@ -28,6 +28,7 @@
// python
"ms-python.python",
"ms-python.pylint",
"ms-python.flake8",
"charliermarsh.ruff",
"visualstudioexptteam.vscodeintellicode",
// yaml
@@ -47,6 +48,9 @@
"pylint.args": [
"--rcfile=${workspaceFolder}/pyproject.toml"
],
"flake8.args": [
"--config=${workspaceFolder}/.flake8"
],
"ruff.configuration": "${workspaceFolder}/pyproject.toml",
"[python]": {
// VS will say "Value is not accepted" before building the devcontainer, but the warning
+42
View File
@@ -0,0 +1,42 @@
[flake8]
max-line-length = 120
# Following 4 for black compatibility
# E501: line too long
# W503: Line break occurred before a binary operator
# E203: Whitespace before ':'
# D202 No blank lines allowed after function docstring
# TODO fix flake8
# D100 Missing docstring in public module
# D101 Missing docstring in public class
# D102 Missing docstring in public method
# D103 Missing docstring in public function
# D104 Missing docstring in public package
# D105 Missing docstring in magic method
# D107 Missing docstring in __init__
# D200 One-line docstring should fit on one line with quotes
# D205 1 blank line required between summary line and description
# D209 Multi-line docstring closing quotes should be on a separate line
# D400 First line should end with a period
# D401 First line should be in imperative mood
ignore =
E501,
W503,
E203,
D202,
D100,
D101,
D102,
D103,
D104,
D105,
D107,
D200,
D205,
D209,
D400,
D401,
exclude = api_pb2.py
+1 -2
View File
@@ -313,7 +313,6 @@ jobs:
- "3.12"
- "3.13"
- "3.14"
- "3.15"
os:
- ubuntu-latest
- macOS-latest
@@ -630,7 +629,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@821a40cd6c80e3cfe387caf55668287addd808da # v5.5.0
uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2
with:
run: |
. venv/bin/activate
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@31d160e5f0ffa0ee65d5b27cb55c153d415f2abe # 2026.10.2
uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0
@@ -0,0 +1,39 @@
{
"problemMatcher": [
{
"owner": "ruff",
"severity": "error",
"pattern": [
{
"regexp": "^(.*): (Please format this file with the ruff formatter)",
"file": 1,
"message": 2
}
]
},
{
"owner": "flake8",
"severity": "error",
"pattern": [
{
"regexp": "^(.*):(\\d+): ([EFCDNW]\\d{3}.*)$",
"file": 1,
"line": 2,
"message": 3
}
]
},
{
"owner": "pylint",
"severity": "error",
"pattern": [
{
"regexp": "^(.*):(\\d+): (\\[[EFCRW]\\d{4}\\(.*\\),.*\\].*)$",
"file": 1,
"line": 2,
"message": 3
}
]
}
]
}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@31d160e5f0ffa0ee65d5b27cb55c153d415f2abe # main
uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
- name: Verify prek clean
# Second pass: re-run all hooks against the now-fixed tree.
# Auto-fixers exit 0 (nothing to change); any remaining failure
# from a check-only hook (yamllint / ci-custom) is a
# from a check-only hook (flake8 / yamllint / ci-custom) is a
# real issue and fails the workflow loudly. Same PREK_SKIP list as
# above for the same reasons.
env:
+9 -1
View File
@@ -10,13 +10,21 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.17.1
rev: v0.17.0
hooks:
# Run the linter.
- id: ruff
args: [--fix]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/PyCQA/flake8
rev: 7.4.1
hooks:
- id: flake8
additional_dependencies:
- flake8-docstrings==1.7.0
- pydocstyle==5.1.1
files: ^(esphome|tests)/.+\.py$
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
+5 -29
View File
@@ -55,7 +55,7 @@ file does, and it is the authority when they disagree. The most useful starting
* https://developers.esphome.io/contributing/code/ - contribution rules, public API and breaking changes.
* **Formatting:**
* **Python:** Uses `ruff` for linting and formatting. Configuration is in `pyproject.toml`.
* **Python:** Uses `ruff` and `flake8` for linting and formatting. Configuration is in `pyproject.toml`.
* **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`.
* **Naming Conventions:**
@@ -80,15 +80,13 @@ file does, and it is the authority when they disagree. The most useful starting
```python
# Bad - looks up CONF_BLAH twice
if CONF_BLAH in config:
cg.add(var.set_blah_ms(config[CONF_BLAH].total_milliseconds))
cg.add(var.set_blah(config[CONF_BLAH]))
# Good - single lookup, value bound inline
if (blah := config.get(CONF_BLAH)) is not None:
cg.add(var.set_blah_ms(blah.total_milliseconds))
cg.add(var.set_blah(blah))
```
The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line.
When the setter takes the value unchanged, use `cg.add_optional(var.set_blah, config.get(CONF_BLAH))`
instead; it is not awaited, and it skips only `None`, so `0` and `False` are still set.
* **C++ Field Visibility:**
* **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`.
@@ -210,14 +208,8 @@ file does, and it is the authority when they disagree. The most useful starting
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`,
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`); pick the helper's
arguments instead of writing a loop. Epson style 7-byte RTC calendars (one-hot weekday) use
`time::decode_bcd_calendar`.
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`);
pick the helper's polynomial and bit-order arguments instead of writing a CRC loop.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
end of `setup()` when the entities need a state at boot.
@@ -437,22 +429,6 @@ file does, and it is the authority when they disagree. The most useful starting
and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass
`config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`.
- **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
```python
switches = switch.sub_switches(config, parent=hub)
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
```
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
constructs, registers and attaches in one call; `display.add_writer(var, config)` sets the lambda;
`MyEnum.options("on", prefix="MY_ENUM_")` builds an enum dict; `button.restart_button_schema(cls)` (and
`factory_reset_`, `query_`) are button presets; `fv.get_declaring_config(id)` finds a hub's config in
final validation.
* **Automations (Triggers, Actions, Conditions):**
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true).
+1 -4
View File
@@ -133,7 +133,7 @@ esphome/components/climate/* @esphome/core
esphome/components/climate_ir/* @glmnet
esphome/components/cm1106/* @andrewjswan
esphome/components/color_temperature/* @jesserockz
esphome/components/combination/* @kahrendt
esphome/components/combination/* @Cat-Ion @kahrendt
esphome/components/const/* @esphome/core
esphome/components/coolix/* @glmnet
esphome/components/copy/* @OttoWinter
@@ -601,8 +601,6 @@ esphome/components/ssd1331_base/* @kbx81
esphome/components/ssd1331_spi/* @kbx81
esphome/components/ssd1351_base/* @kbx81
esphome/components/ssd1351_spi/* @kbx81
esphome/components/st25r/* @JohnMcLear
esphome/components/st25r_spi/* @JohnMcLear
esphome/components/st7123/* @miniskipper
esphome/components/st7305/* @clydebarrow
esphome/components/st7567_base/* @latonita
@@ -697,7 +695,6 @@ esphome/components/veml3235/* @kbx81
esphome/components/veml7700/* @latonita
esphome/components/version/* @esphome/core
esphome/components/vevor_dtx/* @Bofr5555
esphome/components/vl53l1x/* @will-tm
esphome/components/voice_assistant/* @jesserockz @kahrendt
esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
esphome/components/watchdog/* @oarcher
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.25.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0
RUN \
platformio settings set enable_telemetry No \
-1
View File
@@ -53,7 +53,6 @@ from esphome.cpp_generator import ( # noqa: F401
with_local_variable,
)
from esphome.cpp_helpers import ( # noqa: F401
add_optional,
add_pin,
add_variable,
build_registry_entry,
+18 -10
View File
@@ -184,9 +184,11 @@ void AcDimmer::setup() {
this->store_.zero_cross_pin_number = this->zero_cross_pin_->get_pin();
this->store_.min_power = static_cast<uint16_t>(this->min_power_ * 1000);
this->min_power_ = 0;
this->store_.method = this->method_;
if (setup_zero_cross_pin) {
this->zero_cross_pin_->setup();
this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr();
this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_,
this->zero_cross_interrupt_type_);
}
@@ -225,18 +227,24 @@ void AcDimmer::dump_config() {
ESP_LOGCONFIG(TAG,
"AcDimmer:\n"
" Min Power: %.1f%%\n"
" Init with half cycle: %s\n"
" Interrupt Type: %s\n"
" Method: %s",
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_),
this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE ? LOG_STR_LITERAL("rising")
: this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE ? LOG_STR_LITERAL("falling")
: LOG_STR_LITERAL("any"),
this->store_.method == DIM_METHOD_LEADING_PULSE ? LOG_STR_LITERAL("leading pulse")
: this->store_.method == DIM_METHOD_LEADING ? LOG_STR_LITERAL("leading")
: LOG_STR_LITERAL("trailing"));
" Init with half cycle: %s",
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_));
LOG_PIN(" Output Pin: ", this->gate_pin_);
LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_);
if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) {
ESP_LOGCONFIG(TAG, " Interrupt Type: rising");
} else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) {
ESP_LOGCONFIG(TAG, " Interrupt Type: falling");
} else {
ESP_LOGCONFIG(TAG, " Interrupt Type: any");
}
if (method_ == DIM_METHOD_LEADING_PULSE) {
ESP_LOGCONFIG(TAG, " Method: leading pulse");
} else if (method_ == DIM_METHOD_LEADING) {
ESP_LOGCONFIG(TAG, " Method: leading");
} else {
ESP_LOGCONFIG(TAG, " Method: trailing");
}
LOG_FLOAT_OUTPUT(this);
ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2);
}
+12 -9
View File
@@ -6,11 +6,19 @@
namespace esphome::ac_dimmer {
enum DimMethod : uint8_t { DIM_METHOD_LEADING_PULSE = 0, DIM_METHOD_LEADING, DIM_METHOD_TRAILING };
enum DimMethod { DIM_METHOD_LEADING_PULSE = 0, DIM_METHOD_LEADING, DIM_METHOD_TRAILING };
struct AcDimmerDataStore {
/// Zero-cross pin
ISRInternalGPIOPin zero_cross_pin;
/// Zero-cross pin number - used to share ZC pin across multiple dimmers
uint8_t zero_cross_pin_number;
/// Output pin to write to
ISRInternalGPIOPin gate_pin;
/// Value of the dimmer - 0 to 65535.
uint16_t value;
/// Minimum power for activation
uint16_t min_power;
/// Time between the last two ZC pulses
uint32_t cycle_time_us;
/// Time (in micros()) of last ZC signal
@@ -19,12 +27,6 @@ struct AcDimmerDataStore {
uint32_t enable_time_us;
/// Time since last ZC pulse to disable gate pin. 0 means no disable.
uint32_t disable_time_us;
/// Value of the dimmer - 0 to 65535.
uint16_t value;
/// Minimum power for activation
uint16_t min_power;
/// Zero-cross pin number - used to share ZC pin across multiple dimmers
uint8_t zero_cross_pin_number;
/// Set to send the first half ac cycle complete
bool init_cycle;
/// Dimmer method
@@ -48,16 +50,17 @@ class AcDimmer final : public output::FloatOutput, public Component {
void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; }
void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; }
void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; }
void set_method(DimMethod method) { this->store_.method = method; }
void set_method(DimMethod method) { method_ = method; }
protected:
void write_state(float state) override;
InternalGPIOPin *gate_pin_;
InternalGPIOPin *zero_cross_pin_;
AcDimmerDataStore store_;
gpio::InterruptType zero_cross_interrupt_type_;
AcDimmerDataStore store_;
bool init_with_half_cycle_;
DimMethod method_;
};
} // namespace esphome::ac_dimmer
+17 -5
View File
@@ -136,7 +136,13 @@ void AirtonClimate::transmit_state() {
data->set_carrier_frequency(AIRTON_IR_FREQUENCY);
data->mark(AIRTON_HEADER_MARK);
data->space(AIRTON_HEADER_SPACE);
data->item_bytes<AIRTON_BIT_TIMINGS>(remote_state, AIRTON_STATE_FRAME_SIZE);
for (uint8_t payload_byte : remote_state) {
for (uint8_t payload_bit_cursor = 0; payload_bit_cursor < 8; payload_bit_cursor++) {
data->mark(AIRTON_BIT_MARK);
bool bit = payload_byte & (1 << payload_bit_cursor);
data->space(bit ? AIRTON_ONE_SPACE : AIRTON_ZERO_SPACE);
}
}
data->mark(AIRTON_BIT_MARK);
data->space(AIRTON_MESSAGE_SPACE);
@@ -278,14 +284,20 @@ void AirtonClimate::parse_state_frame_(const uint8_t *frame) {
}
bool AirtonClimate::on_receive(remote_base::RemoteReceiveData data) {
uint8_t remote_state[AIRTON_STATE_FRAME_SIZE];
uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = {};
if (!data.expect_item(AIRTON_HEADER_MARK, AIRTON_HEADER_SPACE)) {
ESP_LOGV(TAG, "Wrong header encoding detected!");
return false;
}
if (!data.expect_bytes<AIRTON_BIT_TIMINGS>(remote_state, AIRTON_STATE_FRAME_SIZE)) {
ESP_LOGV(TAG, "Wrong modulation encoding");
return false;
for (int i = 0; i < AIRTON_STATE_FRAME_SIZE; i++) {
for (int j = 0; j < 8; j++) {
if (data.expect_item(AIRTON_BIT_MARK, AIRTON_ONE_SPACE)) {
remote_state[i] |= 1 << j;
} else if (!data.expect_item(AIRTON_BIT_MARK, AIRTON_ZERO_SPACE)) {
ESP_LOGV(TAG, "Wrong modulation encoding for: Byte %d, bit %d", i, j);
return false;
}
}
}
if (remote_state[0] != AIRTON_HEADER_BYTE0 || remote_state[1] != AIRTON_HEADER_BYTE1) {
+2 -2
View File
@@ -36,8 +36,8 @@ static constexpr uint32_t AIRTON_IR_FREQUENCY = 38000;
static constexpr uint32_t AIRTON_HEADER_MARK = 6630;
static constexpr uint32_t AIRTON_HEADER_SPACE = 3350;
static constexpr uint32_t AIRTON_BIT_MARK = 400;
static constexpr remote_base::BitTimings AIRTON_BIT_TIMINGS{
.bit_mark = AIRTON_BIT_MARK, .one_space = 1260, .zero_space = 430};
static constexpr uint32_t AIRTON_ONE_SPACE = 1260;
static constexpr uint32_t AIRTON_ZERO_SPACE = 430;
static constexpr uint32_t AIRTON_MESSAGE_SPACE = 100000;
static constexpr uint8_t AIRTON_STATE_FRAME_SIZE = 7;
+5 -6
View File
@@ -8,18 +8,19 @@ from ..climate import CONF_AIRTON_ID, CONF_SLEEP_MODE, AirtonClimate, airton_ns
CODEOWNERS = ["@Procsiab"]
AirtonSwitch = airton_ns.class_("AirtonSwitch", switch.Switch)
AirtonSetting = airton_ns.enum("AirtonSetting", is_class=True)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate),
cv.Optional(CONF_DISPLAY): switch.switch_schema(
switch.ApplySwitch,
AirtonSwitch,
entity_category=ENTITY_CATEGORY_CONFIG,
default_restore_mode="DISABLED",
),
cv.Optional(CONF_SLEEP_MODE): switch.switch_schema(
switch.ApplySwitch,
AirtonSwitch,
entity_category=ENTITY_CATEGORY_CONFIG,
default_restore_mode="DISABLED",
),
@@ -31,12 +32,10 @@ async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_AIRTON_ID])
switches = switch.sub_switches(config, parent=hub)
await switches(
CONF_DISPLAY,
hub.set_display_switch,
apply=f"set_setting({AirtonSetting.AIRTON_SETTING_DISPLAY}, state)",
CONF_DISPLAY, hub.set_display_switch, AirtonSetting.AIRTON_SETTING_DISPLAY
)
await switches(
CONF_SLEEP_MODE,
hub.set_sleep_mode_switch,
apply=f"set_setting({AirtonSetting.AIRTON_SETTING_SLEEP_MODE}, state)",
AirtonSetting.AIRTON_SETTING_SLEEP_MODE,
)
@@ -0,0 +1,19 @@
#pragma once
#include "esphome/components/switch/switch.h"
#include "../airton.h"
namespace esphome::airton {
// One of the unit's on/off settings; the parent sends the state and publishes it back
class AirtonSwitch : public switch_::Switch, public Parented<AirtonClimate> {
public:
explicit AirtonSwitch(AirtonSetting setting) : setting_(setting) {}
protected:
void write_state(bool state) override { this->parent_->set_setting(this->setting_, state); }
AirtonSetting setting_;
};
} // namespace esphome::airton
+2 -1
View File
@@ -2,7 +2,7 @@ from esphome import automation, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import sensor, uart
from esphome.components.const import CONF_AQI, CONF_ERROR_CODE
from esphome.components.const import CONF_AQI
import esphome.config_validation as cv
from esphome.const import (
CONF_ECO2,
@@ -55,6 +55,7 @@ CONF_RAW_HUMIDITY = "raw_humidity"
CONF_RS0 = "rs0"
CONF_RS2 = "rs2"
CONF_RS3 = "rs3"
CONF_ERROR_CODE = "error_code"
apc1_ns = cg.esphome_ns.namespace("apc1")
APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component)
+14 -6
View File
@@ -306,6 +306,11 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None:
return config
if CONF_ENCRYPTION not in config:
raise cv.Invalid(
"outgoing_connection requires 'encryption' so the peer is verified by key",
path=[CONF_OUTGOING_CONNECTION],
)
# A device with no client reboots once reboot_timeout passes, so a delay that
# reaches it would reboot the device before it ever dials
reboot_timeout = config[CONF_REBOOT_TIMEOUT]
@@ -440,11 +445,6 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
cv.key_requires(
CONF_OUTGOING_CONNECTION,
CONF_ENCRYPTION,
hint="The peer is verified by its key",
),
_validate_outgoing_connection,
_consume_api_sockets,
_register_provisioning_source,
@@ -728,6 +728,15 @@ VARIABLES_FIELDS_SCHEMA = cv.All(VARIABLES_SCHEMA, cv.Length(max=_FIELD_MAP_MAX)
def _validate_response_config(config: ConfigType) -> ConfigType:
# Validate dependencies:
# - response_template requires capture_response: true
# - capture_response: true requires on_success
if CONF_RESPONSE_TEMPLATE in config and not config[CONF_CAPTURE_RESPONSE]:
raise cv.Invalid(
f"`{CONF_RESPONSE_TEMPLATE}` requires `{CONF_CAPTURE_RESPONSE}: true` to be set.",
path=[CONF_RESPONSE_TEMPLATE],
)
if config[CONF_CAPTURE_RESPONSE] and CONF_ON_SUCCESS not in config:
raise cv.Invalid(
f"`{CONF_CAPTURE_RESPONSE}: true` requires `{CONF_ON_SUCCESS}` to be set.",
@@ -762,7 +771,6 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
),
cv.has_exactly_one_key(CONF_SERVICE, CONF_ACTION),
cv.rename_key(CONF_SERVICE, CONF_ACTION),
cv.only_when(CONF_RESPONSE_TEMPLATE, CONF_CAPTURE_RESPONSE, True),
_validate_response_config,
)
+5 -1
View File
@@ -119,6 +119,11 @@ def _validate_multiple(conf: ConfigType) -> ConfigType:
"is only for standalone inputs (id)",
path=[CONF_MULTIPLE],
)
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
raise cv.Invalid(
"max_length only applies to an input with multiple: true",
path=[CONF_MAX_LENGTH],
)
return conf
@@ -145,7 +150,6 @@ WIZARD_INPUT_SCHEMA = cv.All(
),
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
_validate_multiple,
cv.only_when(CONF_MAX_LENGTH, CONF_MULTIPLE, True),
)
WIZARD_PAGE_SCHEMA = cv.All(
+11 -7
View File
@@ -17,6 +17,16 @@ DEPENDENCIES = ["sensor"]
AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component)
def _validate_extended_range(config: ConfigType) -> ConfigType:
if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI":
raise cv.Invalid(
f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. "
"CAQI has no maximum value by specification, so it is always reported unbounded.",
[CONF_EXTENDED_RANGE],
)
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
AQISensor,
@@ -35,13 +45,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.only_when(
CONF_EXTENDED_RANGE,
CONF_CALCULATION_TYPE,
"CAQI",
negate=True,
hint="CAQI has no maximum by specification, so it is always reported unbounded",
),
_validate_extended_range,
)
-12
View File
@@ -637,18 +637,6 @@ float ATM90E32Component::get_chip_temperature_() {
return (float) ctemp;
}
void ATM90E32Component::press_calibration_button(EntityBase *entity, void (ATM90E32Component::*action)(),
const LogString *hint, const LogString *hint2) {
ESP_LOGI(TAG, "%s", entity->get_name().c_str());
if (hint != nullptr) {
ESP_LOGI(TAG, "%s", LOG_STR_ARG(hint));
}
if (hint2 != nullptr) {
ESP_LOGI(TAG, "%s", LOG_STR_ARG(hint2));
}
(this->*action)();
}
void ATM90E32Component::run_gain_calibrations() {
const char *cs = this->get_calibration_id_();
if (!this->enable_gain_calibration_) {
-3
View File
@@ -139,9 +139,6 @@ class ATM90E32Component final : public PollingComponent,
int16_t calibrate_offset(uint8_t phase, bool voltage);
int16_t calibrate_power_offset(uint8_t phase, bool reactive);
void run_gain_calibrations();
// Body of the calibration buttons: logs the button name and the hint lines, then runs action.
void press_calibration_button(EntityBase *entity, void (ATM90E32Component::*action)(),
const LogString *hint = nullptr, const LogString *hint2 = nullptr);
#ifdef USE_NUMBER
void set_reference_voltage(uint8_t phase, number::Number *ref_voltage) { ref_voltages_[phase] = ref_voltage; }
void set_reference_current(uint8_t phase, number::Number *ref_current) { ref_currents_[phase] = ref_current; }
+74 -58
View File
@@ -4,6 +4,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE
from esphome.types import ConfigType
from .. import atm90e32_ns
from ..sensor import ATM90E32Component
CONF_RUN_GAIN_CALIBRATION = "run_gain_calibration"
@@ -13,68 +14,83 @@ CONF_CLEAR_OFFSET_CALIBRATION = "clear_offset_calibration"
CONF_RUN_POWER_OFFSET_CALIBRATION = "run_power_offset_calibration"
CONF_CLEAR_POWER_OFFSET_CALIBRATION = "clear_power_offset_calibration"
def _calibration_button(icon: str) -> cv.Schema:
return button.button_schema(
button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG, icon=icon
)
ATM90E32GainCalibrationButton = atm90e32_ns.class_(
"ATM90E32GainCalibrationButton", button.Button
)
ATM90E32ClearGainCalibrationButton = atm90e32_ns.class_(
"ATM90E32ClearGainCalibrationButton", button.Button
)
ATM90E32OffsetCalibrationButton = atm90e32_ns.class_(
"ATM90E32OffsetCalibrationButton", button.Button
)
ATM90E32ClearOffsetCalibrationButton = atm90e32_ns.class_(
"ATM90E32ClearOffsetCalibrationButton", button.Button
)
ATM90E32PowerOffsetCalibrationButton = atm90e32_ns.class_(
"ATM90E32PowerOffsetCalibrationButton", button.Button
)
ATM90E32ClearPowerOffsetCalibrationButton = atm90e32_ns.class_(
"ATM90E32ClearPowerOffsetCalibrationButton", button.Button
)
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.use_id(ATM90E32Component),
cv.Optional(CONF_RUN_GAIN_CALIBRATION): _calibration_button("mdi:scale-balance"),
cv.Optional(CONF_CLEAR_GAIN_CALIBRATION): _calibration_button("mdi:delete"),
cv.Optional(CONF_RUN_OFFSET_CALIBRATION): _calibration_button(ICON_SCALE),
cv.Optional(CONF_CLEAR_OFFSET_CALIBRATION): _calibration_button("mdi:delete"),
cv.Optional(CONF_RUN_POWER_OFFSET_CALIBRATION): _calibration_button(ICON_SCALE),
cv.Optional(CONF_CLEAR_POWER_OFFSET_CALIBRATION): _calibration_button("mdi:delete"),
cv.Optional(CONF_RUN_GAIN_CALIBRATION): button.button_schema(
ATM90E32GainCalibrationButton,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:scale-balance",
),
cv.Optional(CONF_CLEAR_GAIN_CALIBRATION): button.button_schema(
ATM90E32ClearGainCalibrationButton,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:delete",
),
cv.Optional(CONF_RUN_OFFSET_CALIBRATION): button.button_schema(
ATM90E32OffsetCalibrationButton,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_SCALE,
),
cv.Optional(CONF_CLEAR_OFFSET_CALIBRATION): button.button_schema(
ATM90E32ClearOffsetCalibrationButton,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:delete",
),
cv.Optional(CONF_RUN_POWER_OFFSET_CALIBRATION): button.button_schema(
ATM90E32PowerOffsetCalibrationButton,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_SCALE,
),
cv.Optional(CONF_CLEAR_POWER_OFFSET_CALIBRATION): button.button_schema(
ATM90E32ClearPowerOffsetCalibrationButton,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:delete",
),
}
def _press(action: str, hint: str | None = None, hint2: str | None = None) -> str:
"""press_calibration_button() logs at most two hint lines."""
args = "".join(
f", {cg.LogStringLiteral(h)}" for h in (hint, hint2) if h is not None
)
return f"press_calibration_button(entity, &{ATM90E32Component}::{action}{args})"
async def to_code(config: ConfigType) -> None:
buttons = button.sub_buttons(config, parent=config[CONF_ID])
await buttons(
CONF_RUN_GAIN_CALIBRATION,
None,
apply=_press(
"run_gain_calibrations",
"[CALIBRATION] Use gain_ct: & gain_voltage: under each phase_x: in your config file to save these values",
),
)
await buttons(
CONF_CLEAR_GAIN_CALIBRATION, None, apply=_press("clear_gain_calibrations")
)
await buttons(
CONF_RUN_OFFSET_CALIBRATION,
None,
apply=_press(
"run_offset_calibrations",
"[CALIBRATION] **NOTE: CTs and ACVs must be 0 during this process. USB power only**",
"[CALIBRATION] Use offset_voltage: & offset_current: under each phase_x: in your config file to save these values",
),
)
await buttons(
CONF_CLEAR_OFFSET_CALIBRATION, None, apply=_press("clear_offset_calibrations")
)
await buttons(
CONF_RUN_POWER_OFFSET_CALIBRATION,
None,
apply=_press(
"run_power_offset_calibrations",
"[CALIBRATION] **NOTE: CTs must be 0 during this process. Voltage reference should be present**",
"[CALIBRATION] Use offset_active_power: & offset_reactive_power: under each phase_x: in your config file to save these values",
),
)
await buttons(
CONF_CLEAR_POWER_OFFSET_CALIBRATION,
None,
apply=_press("clear_power_offset_calibrations"),
)
parent = await cg.get_variable(config[CONF_ID])
if run_gain := config.get(CONF_RUN_GAIN_CALIBRATION):
b = await button.new_button(run_gain)
await cg.register_parented(b, parent)
if clear_gain := config.get(CONF_CLEAR_GAIN_CALIBRATION):
b = await button.new_button(clear_gain)
await cg.register_parented(b, parent)
if run_offset := config.get(CONF_RUN_OFFSET_CALIBRATION):
b = await button.new_button(run_offset)
await cg.register_parented(b, parent)
if clear_offset := config.get(CONF_CLEAR_OFFSET_CALIBRATION):
b = await button.new_button(clear_offset)
await cg.register_parented(b, parent)
if run_power := config.get(CONF_RUN_POWER_OFFSET_CALIBRATION):
b = await button.new_button(run_power)
await cg.register_parented(b, parent)
if clear_power := config.get(CONF_CLEAR_POWER_OFFSET_CALIBRATION):
b = await button.new_button(clear_power)
await cg.register_parented(b, parent)
@@ -0,0 +1,77 @@
#include "atm90e32_button.h"
#include "esphome/core/component.h"
#include "esphome/core/log.h"
namespace esphome::atm90e32 {
ESPHOME_LOG_TAG(TAG, "atm90e32.button");
void ATM90E32GainCalibrationButton::press_action() {
if (this->parent_ == nullptr) {
ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Gain Calibration button [%s]", this->get_name().c_str());
return;
}
ESP_LOGI(TAG, "%s", this->get_name().c_str());
ESP_LOGI(TAG,
"[CALIBRATION] Use gain_ct: & gain_voltage: under each phase_x: in your config file to save these values");
this->parent_->run_gain_calibrations();
}
void ATM90E32ClearGainCalibrationButton::press_action() {
if (this->parent_ == nullptr) {
ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Clear Gain button [%s]", this->get_name().c_str());
return;
}
ESP_LOGI(TAG, "%s", this->get_name().c_str());
this->parent_->clear_gain_calibrations();
}
void ATM90E32OffsetCalibrationButton::press_action() {
if (this->parent_ == nullptr) {
ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Offset Calibration button [%s]", this->get_name().c_str());
return;
}
ESP_LOGI(TAG, "%s", this->get_name().c_str());
ESP_LOGI(TAG, "[CALIBRATION] **NOTE: CTs and ACVs must be 0 during this process. USB power only**");
ESP_LOGI(TAG, "[CALIBRATION] Use offset_voltage: & offset_current: under each phase_x: in your config file to save "
"these values");
this->parent_->run_offset_calibrations();
}
void ATM90E32ClearOffsetCalibrationButton::press_action() {
if (this->parent_ == nullptr) {
ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Clear Offset button [%s]", this->get_name().c_str());
return;
}
ESP_LOGI(TAG, "%s", this->get_name().c_str());
this->parent_->clear_offset_calibrations();
}
void ATM90E32PowerOffsetCalibrationButton::press_action() {
if (this->parent_ == nullptr) {
ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Power Calibration button [%s]", this->get_name().c_str());
return;
}
ESP_LOGI(TAG, "%s", this->get_name().c_str());
ESP_LOGI(TAG, "[CALIBRATION] **NOTE: CTs must be 0 during this process. Voltage reference should be present**");
ESP_LOGI(TAG, "[CALIBRATION] Use offset_active_power: & offset_reactive_power: under each phase_x: in your config "
"file to save these values");
this->parent_->run_power_offset_calibrations();
}
void ATM90E32ClearPowerOffsetCalibrationButton::press_action() {
if (this->parent_ == nullptr) {
ESP_LOGW(TAG, "[CALIBRATION] No meters assigned to Clear Power button [%s]", this->get_name().c_str());
return;
}
ESP_LOGI(TAG, "%s", this->get_name().c_str());
this->parent_->clear_power_offset_calibrations();
}
} // namespace esphome::atm90e32
@@ -0,0 +1,57 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/atm90e32/atm90e32.h"
#include "esphome/components/button/button.h"
namespace esphome::atm90e32 {
class ATM90E32GainCalibrationButton final : public button::Button, public Parented<ATM90E32Component> {
public:
ATM90E32GainCalibrationButton() = default;
protected:
void press_action() override;
};
class ATM90E32ClearGainCalibrationButton final : public button::Button, public Parented<ATM90E32Component> {
public:
ATM90E32ClearGainCalibrationButton() = default;
protected:
void press_action() override;
};
class ATM90E32OffsetCalibrationButton final : public button::Button, public Parented<ATM90E32Component> {
public:
ATM90E32OffsetCalibrationButton() = default;
protected:
void press_action() override;
};
class ATM90E32ClearOffsetCalibrationButton final : public button::Button, public Parented<ATM90E32Component> {
public:
ATM90E32ClearOffsetCalibrationButton() = default;
protected:
void press_action() override;
};
class ATM90E32PowerOffsetCalibrationButton final : public button::Button, public Parented<ATM90E32Component> {
public:
ATM90E32PowerOffsetCalibrationButton() = default;
protected:
void press_action() override;
};
class ATM90E32ClearPowerOffsetCalibrationButton final : public button::Button, public Parented<ATM90E32Component> {
public:
ATM90E32ClearPowerOffsetCalibrationButton() = default;
protected:
void press_action() override;
};
} // namespace esphome::atm90e32
+24 -7
View File
@@ -8,8 +8,8 @@ ESPHOME_LOG_TAG(TAG, "ballu.climate");
const uint16_t BALLU_HEADER_MARK = 9000;
const uint16_t BALLU_HEADER_SPACE = 4500;
const uint16_t BALLU_BIT_MARK = 575;
static constexpr remote_base::BitTimings BALLU_BIT_TIMINGS{
.bit_mark = BALLU_BIT_MARK, .one_space = 1675, .zero_space = 550};
const uint16_t BALLU_ONE_SPACE = 1675;
const uint16_t BALLU_ZERO_SPACE = 550;
const uint32_t BALLU_CARRIER_FREQUENCY = 38000;
@@ -104,7 +104,13 @@ void BalluClimate::transmit_state() {
data->mark(BALLU_HEADER_MARK);
data->space(BALLU_HEADER_SPACE);
// Data
data->item_bytes<BALLU_BIT_TIMINGS>(remote_state, BALLU_STATE_LENGTH);
for (uint8_t i : remote_state) {
for (uint8_t j = 0; j < 8; j++) {
data->mark(BALLU_BIT_MARK);
bool bit = i & (1 << j);
data->space(bit ? BALLU_ONE_SPACE : BALLU_ZERO_SPACE);
}
}
// Footer
data->mark(BALLU_BIT_MARK);
@@ -118,10 +124,21 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
}
uint8_t remote_state[BALLU_STATE_LENGTH];
if (!data.expect_bytes<BALLU_BIT_TIMINGS>(remote_state, BALLU_STATE_LENGTH)) {
ESP_LOGV(TAG, "Data fail");
return false;
uint8_t remote_state[BALLU_STATE_LENGTH] = {0};
// Read all bytes.
for (int i = 0; i < BALLU_STATE_LENGTH; i++) {
// Read bit
for (int j = 0; j < 8; j++) {
if (data.expect_item(BALLU_BIT_MARK, BALLU_ONE_SPACE)) {
remote_state[i] |= 1 << j;
} else if (!data.expect_item(BALLU_BIT_MARK, BALLU_ZERO_SPACE)) {
ESP_LOGV(TAG, "Byte %d bit %d fail", i, j);
return false;
}
}
ESP_LOGVV(TAG, "Byte %d %02X", i, remote_state[i]);
}
// Validate footer
if (!data.expect_mark(BALLU_BIT_MARK)) {
+2 -1
View File
@@ -43,4 +43,5 @@ async def register_bedjet_child(var: MockObj, config: ConfigType) -> None:
async def to_code(config: ConfigType) -> None:
var = await ble_client.new_ble_node_component(config)
await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID))
cg.add_optional(var.set_status_timeout, config.get(CONF_RECEIVE_TIMEOUT))
if (receive_timeout := config.get(CONF_RECEIVE_TIMEOUT)) is not None:
cg.add(var.set_status_timeout(receive_timeout))
@@ -111,7 +111,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_num_leds(config[CONF_NUM_LEDS]))
cg.add(var.set_pin(config[CONF_PIN]))
cg.add_optional(var.set_max_refresh_rate, config.get(CONF_MAX_REFRESH_RATE))
if CONF_MAX_REFRESH_RATE in config:
cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE]))
chipset = CHIPSETS[config[CONF_CHIPSET]]
cg.add(
@@ -9,9 +9,11 @@ ESPHOME_LOG_TAG(TAG, "binary_sensor");
// Function implementation of LOG_BINARY_SENSOR macro to reduce code size
void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj) {
if (!log_entity_header(tag, prefix, type, obj)) {
if (obj == nullptr) {
return;
}
ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str());
LOG_ENTITY_DEVICE_CLASS(tag, prefix, *obj);
}
-1
View File
@@ -122,7 +122,6 @@ float BL0940::calculate_energy_reference_() {
float BL0940::calculate_calibration_value_(float state) { return (100 + state) / 100; }
void BL0940::reset_calibration() {
ESP_LOGI(TAG, "Resetting calibration defaults...");
#ifdef USE_NUMBER
if (this->current_calibration_number_ != nullptr && this->current_cal_ != 1) {
this->current_calibration_number_->make_call().set_value(0).perform();
+18 -9
View File
@@ -1,19 +1,28 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART
from esphome.types import ConfigType
from .. import CONF_BL0940_ID
from .. import CONF_BL0940_ID, bl0940_ns
from ..sensor import BL0940
CONFIG_SCHEMA = button.button_schema(
button.ApplyButton,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_RESTART,
).extend({cv.GenerateID(CONF_BL0940_ID): cv.use_id(BL0940)})
CalibrationResetButton = bl0940_ns.class_(
"CalibrationResetButton", button.Button, cg.Component
)
CONFIG_SCHEMA = cv.All(
button.button_schema(
CalibrationResetButton,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_RESTART,
)
.extend({cv.GenerateID(CONF_BL0940_ID): cv.use_id(BL0940)})
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
await button.sub_buttons(config, parent=config[CONF_BL0940_ID]).create(
config, None, apply="reset_calibration()"
)
var = await button.new_button(config)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_BL0940_ID])
@@ -0,0 +1,18 @@
#include "calibration_reset_button.h"
#include "../bl0940.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::bl0940 {
ESPHOME_LOG_TAG(TAG, "bl0940.button.calibration_reset");
void CalibrationResetButton::dump_config() { LOG_BUTTON("", "Calibration Reset Button", this); }
void CalibrationResetButton::press_action() {
ESP_LOGI(TAG, "Resetting calibration defaults...");
this->parent_->reset_calibration();
}
} // namespace esphome::bl0940
@@ -0,0 +1,17 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/button/button.h"
namespace esphome::bl0940 {
class BL0940; // Forward declaration of BL0940 class
class CalibrationResetButton final : public button::Button, public Component, public Parented<BL0940> {
public:
void dump_config() override;
void press_action() override;
};
} // namespace esphome::bl0940
+20 -12
View File
@@ -91,6 +91,23 @@ def calculate_energy_reference(power_reference: float) -> float:
return power_reference * 3600000 / (1638.4 * 256)
def validate_legacy_mode(config: ConfigType) -> ConfigType:
# Only allow schematic calibration options if legacy_mode is False
if config.get(CONF_LEGACY_MODE, True):
forbidden = [
CONF_REFERENCE_VOLTAGE,
CONF_RESISTOR_SHUNT,
CONF_RESISTOR_ONE,
CONF_RESISTOR_TWO,
]
for key in forbidden:
if key in config:
raise cv.Invalid(
f"Option '{key}' is only allowed when legacy_mode: false"
)
return config
def set_command_defaults(config: ConfigType) -> ConfigType:
# Set defaults for read_command and write_command based on legacy_mode
legacy = config.get(CONF_LEGACY_MODE, True)
@@ -136,7 +153,7 @@ def set_reference_values(config: ConfigType) -> ConfigType:
return config
CONFIG_SCHEMA = cv.All(
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BL0940),
@@ -191,18 +208,9 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("60s"))
.extend(uart.UART_DEVICE_SCHEMA)
.add_extra(validate_legacy_mode)
.add_extra(set_command_defaults)
.add_extra(set_reference_values),
cv.only_when(
(
CONF_REFERENCE_VOLTAGE,
CONF_RESISTOR_SHUNT,
CONF_RESISTOR_ONE,
CONF_RESISTOR_TWO,
),
CONF_LEGACY_MODE,
False,
),
.add_extra(set_reference_values)
)
# BL0940 datasheet: 4800 baud, 8 data bits, no parity (stop bits are 1.5 -- not
@@ -83,6 +83,8 @@ async def to_code(config: ConfigType) -> None:
ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid)
cg.add(var.set_ibeacon_uuid(ibeacon_uuid))
cg.add_optional(var.set_ibeacon_major, config.get(CONF_IBEACON_MAJOR))
if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None:
cg.add(var.set_ibeacon_major(ibeacon_major))
cg.add_optional(var.set_ibeacon_minor, config.get(CONF_IBEACON_MINOR))
if (ibeacon_minor := config.get(CONF_IBEACON_MINOR)) is not None:
cg.add(var.set_ibeacon_minor(ibeacon_minor))
+4 -2
View File
@@ -79,6 +79,8 @@ async def to_code(config: ConfigType) -> None:
ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid)
cg.add(var.set_ibeacon_uuid(ibeacon_uuid))
cg.add_optional(var.set_ibeacon_major, config.get(CONF_IBEACON_MAJOR))
if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None:
cg.add(var.set_ibeacon_major(ibeacon_major))
cg.add_optional(var.set_ibeacon_minor, config.get(CONF_IBEACON_MINOR))
if (ibeacon_minor := config.get(CONF_IBEACON_MINOR)) is not None:
cg.add(var.set_ibeacon_minor(ibeacon_minor))
+18 -9
View File
@@ -94,14 +94,24 @@ def _esp32_config_schema() -> cv.All:
CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
def validate_connections(config: ConfigType) -> ConfigType:
if CONF_CONNECTIONS in config or not config[CONF_ACTIVE]:
return config
connection_slots: int = config[CONF_CONNECTION_SLOTS]
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
return {
**config,
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
}
if CONF_CONNECTIONS in config:
if not config[CONF_ACTIVE]:
raise cv.Invalid(
"Connections can only be used if the proxy is set to active"
)
elif config[CONF_ACTIVE]:
connection_slots: int = config[CONF_CONNECTION_SLOTS]
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
config
)
return {
**config,
CONF_CONNECTIONS: [
CONNECTION_SCHEMA({}) for _ in range(connection_slots)
],
}
return config
return cv.All(
(
@@ -126,7 +136,6 @@ def _esp32_config_schema() -> cv.All:
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
),
cv.only_when(CONF_CONNECTIONS, CONF_ACTIVE, True),
validate_connections,
)
+14 -12
View File
@@ -139,12 +139,12 @@ void BME680BSECComponent::update_subscription_() {
uint8_t num_sensor_settings = BSEC_MAX_PHYSICAL_SENSOR;
this->bsec_status_ =
bsec_update_subscription(virtual_sensors, num_virtual_sensors, sensor_settings, &num_sensor_settings);
ESP_LOGV(TAG, "%s: updating subscription for %d virtual sensors (out=%d sensors)", this->device_id_,
ESP_LOGV(TAG, "%s: updating subscription for %d virtual sensors (out=%d sensors)", this->device_id_.c_str(),
num_virtual_sensors, num_sensor_settings);
}
void BME680BSECComponent::dump_config() {
ESP_LOGCONFIG(TAG, "%s via BSEC:", this->device_id_);
ESP_LOGCONFIG(TAG, "%s via BSEC:", this->device_id_.c_str());
bsec_version_t version;
bsec_get_version(&version);
@@ -208,7 +208,7 @@ void BME680BSECComponent::run_() {
return;
}
ESP_LOGV(TAG, "%s: Performing sensor run", this->device_id_);
ESP_LOGV(TAG, "%s: Performing sensor run", this->device_id_.c_str());
// Restore BSEC library state
// The reinit_bsec_lib_ method is computationally expensive: it takes 1200÷2900 microseconds on a ESP32.
@@ -265,7 +265,7 @@ void BME680BSECComponent::run_() {
}
void BME680BSECComponent::read_() {
ESP_LOGV(TAG, "%s: Reading data", this->device_id_);
ESP_LOGV(TAG, "%s: Reading data", this->device_id_.c_str());
int64_t curr_time_ns = this->get_time_ns_();
if (this->bme680_settings_.trigger_measurement) {
@@ -365,7 +365,8 @@ void BME680BSECComponent::read_() {
ESP_LOGW(TAG, "BSEC failed to process signals (BSEC Error Code %d)", this->bsec_status_);
return;
}
ESP_LOGV(TAG, "%s: after bsec_do_steps: num_inputs=%d num_outputs=%d", this->device_id_, num_inputs, num_outputs);
ESP_LOGV(TAG, "%s: after bsec_do_steps: num_inputs=%d num_outputs=%d", this->device_id_.c_str(), num_inputs,
num_outputs);
// Since we are about to go "out of scope" in the loop, take a snapshot of the state now so we can restore it later
if (BME680BSECComponent::instances.size() > 1)
@@ -380,7 +381,7 @@ void BME680BSECComponent::read_() {
}
void BME680BSECComponent::publish_(const bsec_output_t *outputs, uint8_t num_outputs) {
ESP_LOGV(TAG, "%s: Queuing sensor state publish actions", this->device_id_);
ESP_LOGV(TAG, "%s: Queuing sensor state publish actions", this->device_id_.c_str());
for (uint8_t i = 0; i < num_outputs; i++) {
float signal = outputs[i].signal;
switch (outputs[i].sensor_id) {
@@ -470,7 +471,7 @@ void BME680BSECComponent::snapshot_state_() {
this->bsec_status_ = bsec_get_state(0, this->bsec_state_data_, BSEC_MAX_STATE_BLOB_SIZE, this->work_buffer_,
sizeof(this->work_buffer_), &num_serialized_state);
if (this->bsec_status_ != BSEC_OK) {
ESP_LOGW(TAG, "%s: Failed to fetch BSEC library state for snapshot (BSEC Error Code %d)", this->device_id_,
ESP_LOGW(TAG, "%s: Failed to fetch BSEC library state for snapshot (BSEC Error Code %d)", this->device_id_.c_str(),
this->bsec_status_);
return;
}
@@ -481,7 +482,7 @@ void BME680BSECComponent::snapshot_state_() {
// Used to share the library when using more than one sensor
void BME680BSECComponent::restore_state_() {
if (!this->bsec_state_data_valid_) {
ESP_LOGV(TAG, "%s: BSEC state data NOT valid, aborting restore_state_()", this->device_id_);
ESP_LOGV(TAG, "%s: BSEC state data NOT valid, aborting restore_state_()", this->device_id_.c_str());
return;
}
@@ -526,16 +527,17 @@ void BME680BSECComponent::load_state_() {
return;
}
ESP_LOGV(TAG, "%s: Loading BSEC library state", this->device_id_);
ESP_LOGV(TAG, "%s: Loading BSEC library state", this->device_id_.c_str());
this->bsec_status_ =
bsec_set_state(this->bsec_state_data_, BSEC_MAX_STATE_BLOB_SIZE, this->work_buffer_, sizeof(this->work_buffer_));
if (this->bsec_status_ != BSEC_OK) {
ESP_LOGW(TAG, "%s: Failed to load BSEC library state (BSEC Error Code %d)", this->device_id_, this->bsec_status_);
ESP_LOGW(TAG, "%s: Failed to load BSEC library state (BSEC Error Code %d)", this->device_id_.c_str(),
this->bsec_status_);
return;
}
// All OK: set the BSEC state data as valid
this->bsec_state_data_valid_ = true;
ESP_LOGI(TAG, "%s: Loaded BSEC library state", this->device_id_);
ESP_LOGI(TAG, "%s: Loaded BSEC library state", this->device_id_.c_str());
}
void BME680BSECComponent::save_state_(uint8_t accuracy) {
@@ -550,7 +552,7 @@ void BME680BSECComponent::save_state_(uint8_t accuracy) {
if (!this->bsec_state_data_valid_)
return;
ESP_LOGV(TAG, "%s: Saving state", this->device_id_);
ESP_LOGV(TAG, "%s: Saving state", this->device_id_.c_str());
if (!this->bsec_state_.save(&this->bsec_state_data_)) {
ESP_LOGW(TAG, "Failed to save state");
+2 -2
View File
@@ -36,7 +36,7 @@ enum SampleRate {
class BME680BSECComponent final : public Component, public i2c::I2CDevice {
public:
void set_device_id(const char *devid) { this->device_id_ = devid; }
void set_device_id(const std::string &devid) { this->device_id_.assign(devid); }
void set_temperature_offset(float offset) { this->temperature_offset_ = offset; }
void set_iaq_mode(IAQMode iaq_mode) { this->iaq_mode_ = iaq_mode; }
void set_state_save_interval(uint32_t interval) { this->state_save_interval_ms_ = interval; }
@@ -111,7 +111,7 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice {
uint32_t last_state_save_ms_ = 0;
bsec_bme_settings_t bme680_settings_;
const char *device_id_{nullptr};
std::string device_id_;
float temperature_offset_{0};
IAQMode iaq_mode_{IAQ_MODE_STATIC};
SupplyVoltage supply_voltage_;
+3 -1
View File
@@ -7,9 +7,11 @@ ESPHOME_LOG_TAG(TAG, "button");
// Function implementation of LOG_BUTTON macro to reduce code size
void log_button(const char *tag, const char *prefix, const char *type, Button *obj) {
if (!log_entity_header(tag, prefix, type, obj)) {
if (obj == nullptr) {
return;
}
ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str());
LOG_ENTITY_ICON(tag, prefix, *obj);
}
+2 -1
View File
@@ -69,7 +69,8 @@ async def to_code(config: ConfigType) -> None:
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_VERSION, var.set_version)
cg.add_optional(var.set_baseline, config.get(CONF_BASELINE))
if (baseline := config.get(CONF_BASELINE)) is not None:
cg.add(var.set_baseline(baseline))
await cg.add_variable(var.set_temperature, config.get(CONF_TEMPERATURE))
await cg.add_variable(var.set_humidity, config.get(CONF_HUMIDITY))
+4 -1
View File
@@ -12,7 +12,10 @@
namespace esphome::climate {
#define LOG_CLIMATE(prefix, type, obj) log_entity_header(TAG, prefix, LOG_STR_LITERAL(type), (obj))
#define LOG_CLIMATE(prefix, type, obj) \
if ((obj) != nullptr) { \
ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \
}
class Climate;
+1 -1
View File
@@ -17,7 +17,7 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
CODEOWNERS = ["@kahrendt"]
CODEOWNERS = ["@Cat-Ion", "@kahrendt"]
combination_ns = cg.esphome_ns.namespace("combination")
-4
View File
@@ -11,7 +11,6 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_ADC_AVERAGING = "adc_averaging"
CONF_ADC_TIME = "adc_time"
CONF_ALLOWED_IPS = "allowed_ips"
CONF_ANNOUNCEMENT_VOLUME = "announcement_volume"
CONF_AQI = "aqi"
CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration"
CONF_B_CONSTANT = "b_constant"
@@ -34,7 +33,6 @@ CONF_DRAW_ROUNDING = "draw_rounding"
CONF_DRIVE_STRENGTH = "drive_strength"
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
CONF_ENABLED = "enabled"
CONF_ERROR_CODE = "error_code"
CONF_GREEN_CHANNEL = "green_channel"
CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
@@ -50,7 +48,6 @@ CONF_LIBRETINY = "libretiny"
CONF_LOOP = "loop"
CONF_MANUFACTURER = "manufacturer"
CONF_MAX_PACKET_SIZE = "max_packet_size"
CONF_MODULATION = "modulation"
CONF_NOX_INDEX = "nox_index"
CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
@@ -68,7 +65,6 @@ CONF_REMOTE_ID = "remote_id"
CONF_REQUEST_HEADERS = "request_headers"
CONF_ROLE = "role"
CONF_ROWS = "rows"
CONF_RST_PIN = "rst_pin"
CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SERIAL_NUMBER = "serial_number"
CONF_SHA256 = "sha256"
+20 -7
View File
@@ -1,18 +1,29 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ENTITY_CATEGORY,
CONF_ICON,
CONF_ID,
CONF_SOURCE_ID,
)
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
CONFIG_SCHEMA = button.button_schema(button.ApplyButton).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(button.Button),
}
from .. import copy_ns
CopyButton = copy_ns.class_("CopyButton", button.Button, cg.Component)
CONFIG_SCHEMA = (
button.button_schema(CopyButton)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(button.Button),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -23,6 +34,8 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
await button.sub_buttons(config, parent=config[CONF_SOURCE_ID]).create(
config, None, apply="press()"
)
var = cg.new_Pvariable(config[CONF_ID])
await button.register_button(var, config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
@@ -0,0 +1,12 @@
#include "copy_button.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.button");
void CopyButton::dump_config() { LOG_BUTTON("", "Copy Button", this); }
void CopyButton::press_action() { source_->press(); }
} // namespace esphome::copy
@@ -0,0 +1,19 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/button/button.h"
namespace esphome::copy {
class CopyButton final : public button::Button, public Component {
public:
void set_source(button::Button *source) { source_ = source; }
void dump_config() override;
protected:
void press_action() override;
button::Button *source_;
};
} // namespace esphome::copy
+2 -1
View File
@@ -14,7 +14,8 @@ static constexpr float COVER_OPEN = 1.0f;
static constexpr float COVER_CLOSED = 0.0f;
#define LOG_COVER(prefix, type, obj) \
if (log_entity_header(TAG, prefix, LOG_STR_LITERAL(type), (obj))) { \
if ((obj) != nullptr) { \
ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \
auto traits_ = (obj)->get_traits(); \
if (traits_.get_is_assumed_state()) { \
ESP_LOGCONFIG(TAG, "%s Assumed State: YES", prefix); \
+2 -1
View File
@@ -117,7 +117,8 @@ async def to_code(config: ConfigType) -> None:
)
cg.add(var.set_obstacle_rollback(config[CONF_OBSTACLE_ROLLBACK]))
cg.add_optional(var.set_max_duration, config.get(CONF_MAX_DURATION))
if (max_duration := config.get(CONF_MAX_DURATION)) is not None:
cg.add(var.set_max_duration(max_duration))
cg.add(var.set_malfunction_detection(config[CONF_MALFUNCTION_DETECTION]))
if malfunction_action := config.get(CONF_MALFUNCTION_ACTION):
await automation.build_automation(
+56 -24
View File
@@ -9,14 +9,6 @@ ESPHOME_LOG_TAG(TAG, "daikin.climate");
static constexpr uint32_t BOOST_TIMEOUT_ID = 0;
static constexpr uint8_t DAIKIN_HEADER_BYTE0 = 0x11;
static constexpr uint8_t DAIKIN_HEADER_BYTE1 = 0xDA;
static constexpr uint8_t DAIKIN_HEADER_BYTE2 = 0x27;
static constexpr uint8_t DAIKIN_HEADER_BYTE3 = 0x00;
static constexpr uint8_t DAIKIN_FRAME_TYPE_INDEX = 4;
static constexpr uint8_t DAIKIN_FRAME_HEADER_SIZE = DAIKIN_FRAME_TYPE_INDEX + 1;
static constexpr uint8_t DAIKIN_STATE_FRAME_OFFSET = 2 * DAIKIN_COMFORT_FRAME_SIZE;
void DaikinClimate::setup() {
climate_ir::ClimateIR::setup();
// A boost preset restored from flash gets its timer too
@@ -50,19 +42,37 @@ void DaikinClimate::transmit_state() {
data->mark(DAIKIN_HEADER_MARK);
data->space(DAIKIN_HEADER_SPACE);
data->item_bytes<DAIKIN_BIT_TIMINGS>(remote_state, DAIKIN_COMFORT_FRAME_SIZE);
for (int i = 0; i < 8; i++) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DAIKIN_BIT_MARK);
bool bit = remote_state[i] & mask;
data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE);
}
}
data->mark(DAIKIN_BIT_MARK);
data->space(DAIKIN_MESSAGE_SPACE);
data->mark(DAIKIN_HEADER_MARK);
data->space(DAIKIN_HEADER_SPACE);
data->item_bytes<DAIKIN_BIT_TIMINGS>(remote_state + DAIKIN_COMFORT_FRAME_SIZE, DAIKIN_COMFORT_FRAME_SIZE);
for (int i = 8; i < 16; i++) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DAIKIN_BIT_MARK);
bool bit = remote_state[i] & mask;
data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE);
}
}
data->mark(DAIKIN_BIT_MARK);
data->space(DAIKIN_MESSAGE_SPACE);
data->mark(DAIKIN_HEADER_MARK);
data->space(DAIKIN_HEADER_SPACE);
data->item_bytes<DAIKIN_BIT_TIMINGS>(remote_state + DAIKIN_STATE_FRAME_OFFSET, DAIKIN_STATE_FRAME_SIZE);
for (int i = 16; i < 35; i++) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DAIKIN_BIT_MARK);
bool bit = remote_state[i] & mask;
data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE);
}
}
data->mark(DAIKIN_BIT_MARK);
data->space(0);
@@ -274,24 +284,46 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
}
bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
uint8_t frame[DAIKIN_STATE_FRAME_SIZE];
uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {};
if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) {
return false;
}
// Frame header, then the frame type that sets the frame size
if (!data.expect_bytes<DAIKIN_BIT_TIMINGS>(frame, DAIKIN_FRAME_HEADER_SIZE) || frame[0] != DAIKIN_HEADER_BYTE0 ||
frame[1] != DAIKIN_HEADER_BYTE1 || frame[2] != DAIKIN_HEADER_BYTE2 || frame[3] != DAIKIN_HEADER_BYTE3) {
return false;
}
uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE;
if (frame[DAIKIN_FRAME_TYPE_INDEX] == DAIKIN_FRAME_TYPE_COMFORT) {
frame_size = DAIKIN_COMFORT_FRAME_SIZE;
} else if (frame[DAIKIN_FRAME_TYPE_INDEX] != DAIKIN_FRAME_TYPE_STATE) {
return false;
}
if (!data.expect_bytes<DAIKIN_BIT_TIMINGS>(frame + DAIKIN_FRAME_HEADER_SIZE, frame_size - DAIKIN_FRAME_HEADER_SIZE)) {
return false;
for (uint8_t pos = 0; pos < frame_size; pos++) {
uint8_t byte = 0;
for (int8_t bit = 0; bit < 8; bit++) {
if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) {
byte |= 1 << bit;
} else if (!data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ZERO_SPACE)) {
return false;
}
}
frame[pos] = byte;
if (pos == 0) {
// frame header
if (byte != 0x11)
return false;
} else if (pos == 1) {
// frame header
if (byte != 0xDA)
return false;
} else if (pos == 2) {
// frame header
if (byte != 0x27)
return false;
} else if (pos == 3) { // NOLINT(bugprone-branch-clone)
// frame header
if (byte != 0x00)
return false;
} else if (pos == 4) {
// frame type
if (byte == DAIKIN_FRAME_TYPE_COMFORT) {
frame_size = DAIKIN_COMFORT_FRAME_SIZE;
} else if (byte != DAIKIN_FRAME_TYPE_STATE) {
return false;
}
}
}
if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) {
return false;
-2
View File
@@ -45,8 +45,6 @@ const uint32_t DAIKIN_HEADER_SPACE = 1760;
const uint32_t DAIKIN_BIT_MARK = 520;
const uint32_t DAIKIN_ONE_SPACE = 1370;
const uint32_t DAIKIN_ZERO_SPACE = 360;
static constexpr remote_base::BitTimings DAIKIN_BIT_TIMINGS{
.bit_mark = DAIKIN_BIT_MARK, .one_space = DAIKIN_ONE_SPACE, .zero_space = DAIKIN_ZERO_SPACE};
const uint32_t DAIKIN_MESSAGE_SPACE = 32300;
// Frame types (byte 4) and sizes
+31 -8
View File
@@ -39,7 +39,13 @@ void DaikinArcClimate::transmit_query_() {
data->mark(DAIKIN_HEADER_MARK);
data->space(DAIKIN_HEADER_SPACE);
data->item_bytes<DAIKIN_BIT_TIMINGS>(remote_header, sizeof(remote_header));
for (uint8_t i : remote_header) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DAIKIN_BIT_MARK);
bool bit = i & mask;
data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE);
}
}
data->mark(DAIKIN_BIT_MARK);
data->space(0);
@@ -113,14 +119,26 @@ void DaikinArcClimate::transmit_state() {
data->mark(DAIKIN_HEADER_MARK);
data->space(DAIKIN_HEADER_SPACE);
data->item_bytes<DAIKIN_BIT_TIMINGS>(remote_header, sizeof(remote_header));
for (uint8_t i : remote_header) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DAIKIN_BIT_MARK);
bool bit = i & mask;
data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE);
}
}
data->mark(DAIKIN_BIT_MARK);
data->space(DAIKIN_MESSAGE_SPACE);
data->mark(DAIKIN_HEADER_MARK);
data->space(DAIKIN_HEADER_SPACE);
data->item_bytes<DAIKIN_BIT_TIMINGS>(remote_state, sizeof(remote_state));
for (uint8_t i : remote_state) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DAIKIN_BIT_MARK);
bool bit = i & mask;
data->space(bit ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE);
}
}
data->mark(DAIKIN_BIT_MARK);
data->space(0);
@@ -321,7 +339,7 @@ bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) {
}
bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) {
uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE];
uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {};
bool valid_daikin_frame = false;
if (data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) {
@@ -400,11 +418,16 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) {
}
for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) {
if (!data.expect_bytes<DAIKIN_BIT_TIMINGS>(&state_frame[pos], 1)) {
ESP_LOGI(TAG, "non daikin_arc expect item pos: %d", pos);
return false;
uint8_t byte = 0;
for (int8_t bit = 0; bit < 8; bit++) {
if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) {
byte |= 1 << bit;
} else if (!data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ZERO_SPACE)) {
ESP_LOGI(TAG, "non daikin_arc expect item pos: %d", pos);
return false;
}
}
const uint8_t byte = state_frame[pos];
state_frame[pos] = byte;
if (pos == 0) {
// frame header
if (byte != 0x11) {
@@ -36,8 +36,6 @@ const uint32_t DAIKIN_HEADER_SPACE = 1760;
const uint32_t DAIKIN_BIT_MARK = 400;
const uint32_t DAIKIN_ONE_SPACE = 1300;
const uint32_t DAIKIN_ZERO_SPACE = 480;
static constexpr remote_base::BitTimings DAIKIN_BIT_TIMINGS{
.bit_mark = DAIKIN_BIT_MARK, .one_space = DAIKIN_ONE_SPACE, .zero_space = DAIKIN_ZERO_SPACE};
const uint32_t DAIKIN_MESSAGE_SPACE = 35000;
const uint8_t DAIKIN_DBG_TOLERANCE = 25;
+46 -16
View File
@@ -6,12 +6,6 @@ namespace esphome::daikin_brc {
ESPHOME_LOG_TAG(TAG, "daikin_brc.climate");
static constexpr uint8_t DAIKIN_BRC_HEADER_BYTE0 = 0x11;
static constexpr uint8_t DAIKIN_BRC_HEADER_BYTE1 = 0xDA;
static constexpr uint8_t DAIKIN_BRC_HEADER_BYTE2 = 0x17;
static constexpr uint8_t DAIKIN_BRC_HEADER_BYTE3 = 0x18;
static constexpr uint8_t DAIKIN_BRC_FRAME_TYPE_STATE = 0x00;
void DaikinBrcClimate::control(const climate::ClimateCall &call) {
this->mode_button_ = 0x00;
if (call.get_mode().has_value()) {
@@ -48,14 +42,25 @@ void DaikinBrcClimate::transmit_state() {
data->mark(DAIKIN_BRC_HEADER_MARK);
data->space(DAIKIN_BRC_HEADER_SPACE);
data->item_bytes<DAIKIN_BRC_BIT_TIMINGS>(remote_state, DAIKIN_BRC_PREAMBLE_SIZE);
for (int i = 0; i < DAIKIN_BRC_PREAMBLE_SIZE; i++) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DAIKIN_BRC_BIT_MARK);
bool bit = remote_state[i] & mask;
data->space(bit ? DAIKIN_BRC_ONE_SPACE : DAIKIN_BRC_ZERO_SPACE);
}
}
data->mark(DAIKIN_BRC_BIT_MARK);
data->space(DAIKIN_BRC_MESSAGE_SPACE);
data->mark(DAIKIN_BRC_HEADER_MARK);
data->space(DAIKIN_BRC_HEADER_SPACE);
data->item_bytes<DAIKIN_BRC_BIT_TIMINGS>(remote_state + DAIKIN_BRC_PREAMBLE_SIZE,
DAIKIN_BRC_TRANSMIT_FRAME_SIZE - DAIKIN_BRC_PREAMBLE_SIZE);
for (int i = DAIKIN_BRC_PREAMBLE_SIZE; i < DAIKIN_BRC_TRANSMIT_FRAME_SIZE; i++) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DAIKIN_BRC_BIT_MARK);
bool bit = remote_state[i] & mask;
data->space(bit ? DAIKIN_BRC_ONE_SPACE : DAIKIN_BRC_ZERO_SPACE);
}
}
data->mark(DAIKIN_BRC_BIT_MARK);
data->space(0);
@@ -227,16 +232,41 @@ bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) {
}
bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) {
uint8_t state_frame[DAIKIN_BRC_STATE_FRAME_SIZE];
uint8_t state_frame[DAIKIN_BRC_STATE_FRAME_SIZE] = {};
if (!data.expect_item(DAIKIN_BRC_HEADER_MARK, DAIKIN_BRC_HEADER_SPACE)) {
return false;
}
// Frame header, with this unit's address in the low 3 bits of byte 3, then the frame type
if (!data.expect_bytes<DAIKIN_BRC_BIT_TIMINGS>(state_frame, DAIKIN_BRC_STATE_FRAME_SIZE) ||
state_frame[0] != DAIKIN_BRC_HEADER_BYTE0 || state_frame[1] != DAIKIN_BRC_HEADER_BYTE1 ||
state_frame[2] != DAIKIN_BRC_HEADER_BYTE2 || state_frame[3] != (DAIKIN_BRC_HEADER_BYTE3 | this->sub_unit_) ||
state_frame[4] != DAIKIN_BRC_FRAME_TYPE_STATE) {
return false;
for (uint8_t pos = 0; pos < DAIKIN_BRC_STATE_FRAME_SIZE; pos++) {
uint8_t byte = 0;
for (int8_t bit = 0; bit < 8; bit++) {
if (data.expect_item(DAIKIN_BRC_BIT_MARK, DAIKIN_BRC_ONE_SPACE)) {
byte |= 1 << bit;
} else if (!data.expect_item(DAIKIN_BRC_BIT_MARK, DAIKIN_BRC_ZERO_SPACE)) {
return false;
}
}
state_frame[pos] = byte;
if (pos == 0) {
// frame header
if (byte != 0x11)
return false;
} else if (pos == 1) {
// frame header
if (byte != 0xDA)
return false;
} else if (pos == 2) {
// frame header
if (byte != 0x17)
return false;
} else if (pos == 3) {
// frame header, with this unit's address in the low 3 bits
if (byte != (0x18 | this->sub_unit_))
return false;
} else if (pos == 4) {
// frame type
if (byte != 0x00)
return false;
}
}
return this->parse_state_frame_(state_frame);
}
+2 -2
View File
@@ -31,8 +31,8 @@ const uint32_t DAIKIN_BRC_IR_FREQUENCY = 38000;
const uint32_t DAIKIN_BRC_HEADER_MARK = 5070;
const uint32_t DAIKIN_BRC_HEADER_SPACE = 2140;
const uint32_t DAIKIN_BRC_BIT_MARK = 370;
static constexpr remote_base::BitTimings DAIKIN_BRC_BIT_TIMINGS{
.bit_mark = DAIKIN_BRC_BIT_MARK, .one_space = 1780, .zero_space = 710};
const uint32_t DAIKIN_BRC_ONE_SPACE = 1780;
const uint32_t DAIKIN_BRC_ZERO_SPACE = 710;
const uint32_t DAIKIN_BRC_MESSAGE_SPACE = 29410;
const uint8_t DAIKIN_BRC_IR_DRY_FAN_TEMP_F = 72; // Dry/Fan mode is always 17 Celsius.
+2 -1
View File
@@ -13,7 +13,8 @@
namespace esphome::datetime {
#define LOG_DATETIME_DATE(prefix, type, obj) \
if (log_entity_header(TAG, prefix, LOG_STR_LITERAL(type), (obj))) { \
if ((obj) != nullptr) { \
ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \
LOG_ENTITY_ICON(TAG, prefix, *(obj)); \
}
@@ -13,7 +13,8 @@
namespace esphome::datetime {
#define LOG_DATETIME_DATETIME(prefix, type, obj) \
if (log_entity_header(TAG, prefix, LOG_STR_LITERAL(type), (obj))) { \
if ((obj) != nullptr) { \
ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \
LOG_ENTITY_ICON(TAG, prefix, *(obj)); \
}
+2 -1
View File
@@ -13,7 +13,8 @@
namespace esphome::datetime {
#define LOG_DATETIME_TIME(prefix, type, obj) \
if (log_entity_header(TAG, prefix, LOG_STR_LITERAL(type), (obj))) { \
if ((obj) != nullptr) { \
ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \
LOG_ENTITY_ICON(TAG, prefix, *(obj)); \
}
+10 -4
View File
@@ -164,6 +164,10 @@ _WAKEUP_PIN_MODE_BOTH_LEVELS = (
def validate_config(config: ConfigType) -> ConfigType:
wakeup_pins = config.get(CONF_WAKEUP_PIN)
has_pin_mode = CONF_WAKEUP_PIN_MODE in config
if has_pin_mode and wakeup_pins is None:
raise cv.Invalid(
"'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE]
)
multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1
# wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list
if not CORE.is_bk72xx:
@@ -352,7 +356,6 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_NRF52]),
cv.key_requires(CONF_WAKEUP_PIN_MODE, CONF_WAKEUP_PIN),
validate_config,
)
@@ -361,7 +364,8 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add_optional(var.set_sleep_duration, config.get(CONF_SLEEP_DURATION))
if CONF_SLEEP_DURATION in config:
cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION]))
if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None:
if CORE.is_bk72xx:
cg.add(var.init_wakeup_pins(len(wakeup_pins)))
@@ -376,7 +380,8 @@ async def to_code(config: ConfigType) -> None:
)
else:
await cg.add_pin(var.set_wakeup_pin, wakeup_pins[0][CONF_PIN])
cg.add_optional(var.set_wakeup_pin_mode, config.get(CONF_WAKEUP_PIN_MODE))
if CONF_WAKEUP_PIN_MODE in config:
cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE]))
if CONF_RUN_DURATION in config:
run_duration_config = config[CONF_RUN_DURATION]
if not isinstance(run_duration_config, dict):
@@ -422,7 +427,8 @@ async def to_code(config: ConfigType) -> None:
await automation.build_automation(trigger, [(WakeupCause, "cause")], wake_conf)
cg.add_define("USE_DEEP_SLEEP_ON_WAKE")
cg.add_optional(var.set_touch_wakeup, config.get(CONF_TOUCH_WAKEUP))
if CONF_TOUCH_WAKEUP in config:
cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP]))
if CORE.using_zephyr:
# Devices are suspended when CPU is entering a low power state
# https://github.com/nrfconnect/sdk-zephyr/blob/v3.7.99-ncs2-2/doc/services/pm/device.rst#system-managed-device-power-management
+25 -5
View File
@@ -23,7 +23,13 @@ void DelonghiClimate::transmit_state() {
data->mark(DELONGHI_HEADER_MARK);
data->space(DELONGHI_HEADER_SPACE);
data->item_bytes<DELONGHI_BIT_TIMINGS>(remote_state, DELONGHI_STATE_FRAME_SIZE);
for (unsigned char b : remote_state) {
for (uint8_t mask = 1; mask > 0; mask <<= 1) { // iterate through bit mask
data->mark(DELONGHI_BIT_MARK);
bool bit = b & mask;
data->space(bit ? DELONGHI_ONE_SPACE : DELONGHI_ZERO_SPACE);
}
}
data->mark(DELONGHI_BIT_MARK);
data->space(0);
@@ -149,13 +155,27 @@ bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) {
}
bool DelonghiClimate::on_receive(remote_base::RemoteReceiveData data) {
uint8_t state_frame[DELONGHI_STATE_FRAME_SIZE];
uint8_t state_frame[DELONGHI_STATE_FRAME_SIZE] = {};
if (!data.expect_item(DELONGHI_HEADER_MARK, DELONGHI_HEADER_SPACE)) {
return false;
}
if (!data.expect_bytes<DELONGHI_BIT_TIMINGS>(state_frame, DELONGHI_STATE_FRAME_SIZE) ||
state_frame[0] != DELONGHI_ADDRESS)
return false;
for (uint8_t pos = 0; pos < DELONGHI_STATE_FRAME_SIZE; pos++) {
uint8_t byte = 0;
for (int8_t bit = 0; bit < 8; bit++) {
if (data.expect_item(DELONGHI_BIT_MARK, DELONGHI_ONE_SPACE)) {
byte |= 1 << bit;
} else if (!data.expect_item(DELONGHI_BIT_MARK, DELONGHI_ZERO_SPACE)) {
return false;
}
}
state_frame[pos] = byte;
if (pos == 0) {
// frame header
if (byte != DELONGHI_ADDRESS) {
return false;
}
}
}
return this->parse_state_frame_(state_frame);
}
+2 -2
View File
@@ -33,8 +33,8 @@ const uint32_t DELONGHI_IR_FREQUENCY = 38000;
const uint32_t DELONGHI_HEADER_MARK = 9000;
const uint32_t DELONGHI_HEADER_SPACE = 4500;
const uint32_t DELONGHI_BIT_MARK = 465;
static constexpr remote_base::BitTimings DELONGHI_BIT_TIMINGS{
.bit_mark = DELONGHI_BIT_MARK, .one_space = 1750, .zero_space = 670};
const uint32_t DELONGHI_ONE_SPACE = 1750;
const uint32_t DELONGHI_ZERO_SPACE = 670;
// State Frame size
const uint8_t DELONGHI_STATE_FRAME_SIZE = 8;
@@ -42,6 +42,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_LINE_READER")
await uart.new_uart_component(config)
+18 -16
View File
@@ -1,5 +1,7 @@
#pragma once
#include <algorithm>
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
@@ -27,9 +29,12 @@ class DfrobotSen0395SettingsAction final : public Action<Ts...>, public Parented
TEMPLATABLE_VALUE(float, det_max4)
void play(const Ts &...x) override {
this->parent_->enqueue(make_unique<PowerCommand>(0));
// Worst case: stop, factory reset, six settings, save, start
if (!this->parent_->has_queue_room(10))
return;
this->parent_->enqueue(CommandType::COMMAND_TYPE_POWER, false);
if (this->factory_reset_.has_value() && this->factory_reset_.value(x...) == true) {
this->parent_->enqueue(make_unique<FactoryResetCommand>());
this->parent_->enqueue(CommandType::COMMAND_TYPE_FACTORY_RESET);
}
if (this->det_min1_.has_value() && this->det_max1_.has_value()) {
// Each segment is read once, so the guard and the command see the same values.
@@ -37,10 +42,10 @@ class DfrobotSen0395SettingsAction final : public Action<Ts...>, public Parented
if (min1 >= 0) {
const float max1 = this->det_max1_.value(x...);
if (max1 >= 0) {
this->parent_->enqueue(make_unique<DetRangeCfgCommand>(
min1, max1, this->det_min2_.value_or(x..., -1), this->det_max2_.value_or(x..., -1),
this->det_min3_.value_or(x..., -1), this->det_max3_.value_or(x..., -1),
this->det_min4_.value_or(x..., -1), this->det_max4_.value_or(x..., -1)));
this->parent_->enqueue_det_range_cfg(min1, max1, this->det_min2_.value_or(x..., -1),
this->det_max2_.value_or(x..., -1), this->det_min3_.value_or(x..., -1),
this->det_max3_.value_or(x..., -1), this->det_min4_.value_or(x..., -1),
this->det_max4_.value_or(x..., -1));
}
}
}
@@ -48,38 +53,35 @@ class DfrobotSen0395SettingsAction final : public Action<Ts...>, public Parented
float detect = this->delay_after_detect_.value(x...);
float disappear = this->delay_after_disappear_.value(x...);
if (detect >= 0 && disappear >= 0) {
this->parent_->enqueue(make_unique<SetLatencyCommand>(detect, disappear));
this->parent_->enqueue_set_latency(detect, disappear);
}
}
if (this->start_after_power_on_.has_value()) {
int8_t val = this->start_after_power_on_.value(x...);
if (val >= 0) {
this->parent_->enqueue(make_unique<SensorCfgStartCommand>(val));
this->parent_->enqueue(CommandType::COMMAND_TYPE_SENSOR_CFG_START, val);
}
}
if (this->turn_on_led_.has_value()) {
int8_t val = this->turn_on_led_.value(x...);
if (val >= 0) {
this->parent_->enqueue(make_unique<LedModeCommand>(val));
this->parent_->enqueue(CommandType::COMMAND_TYPE_LED_MODE, val);
}
}
if (this->presence_via_uart_.has_value()) {
int8_t val = this->presence_via_uart_.value(x...);
if (val >= 0) {
this->parent_->enqueue(make_unique<UartOutputCommand>(val));
this->parent_->enqueue(CommandType::COMMAND_TYPE_UART_OUTPUT, val);
}
}
if (this->sensitivity_.has_value()) {
int8_t val = this->sensitivity_.value(x...);
if (val >= 0) {
if (val > 9) {
val = 9;
}
this->parent_->enqueue(make_unique<SensitivityCommand>(val));
this->parent_->enqueue(CommandType::COMMAND_TYPE_SENSITIVITY, std::min<int8_t>(val, 9));
}
}
this->parent_->enqueue(make_unique<SaveCfgCommand>());
this->parent_->enqueue(make_unique<PowerCommand>(1));
this->parent_->enqueue(CommandType::COMMAND_TYPE_SAVE_CFG);
this->parent_->enqueue(CommandType::COMMAND_TYPE_POWER, true);
}
};
@@ -1,332 +0,0 @@
#include "commands.h"
#include <cmath>
#include "esphome/core/log.h"
#include "dfrobot_sen0395.h"
namespace esphome::dfrobot_sen0395 {
ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395.commands");
uint8_t Command::execute(DfrobotSen0395Component *parent) {
this->parent_ = parent;
if (this->cmd_sent_) {
if (this->parent_->read_message_()) {
std::string message(this->parent_->read_buffer_);
if (message.rfind("is not recognized as a CLI command") != std::string::npos) {
ESP_LOGD(TAG, "Command not recognized properly by sensor");
if (this->retries_left_ > 0) {
this->retries_left_ -= 1;
this->cmd_sent_ = false;
ESP_LOGD(TAG, "Retrying");
return 0;
} else {
this->parent_->find_prompt_();
return 1; // Command done
}
}
uint8_t rc = on_message(message);
if (rc == 2) {
if (this->retries_left_ > 0) {
this->retries_left_ -= 1;
this->cmd_sent_ = false;
ESP_LOGD(TAG, "Retrying");
return 0;
} else {
this->parent_->find_prompt_();
return 1; // Command done
}
} else if (rc == 0) {
return 0;
} else {
this->parent_->find_prompt_();
return 1;
}
}
if (millis() - this->parent_->ts_last_cmd_sent_ > this->timeout_ms_) {
ESP_LOGD(TAG, "Command timeout");
if (this->retries_left_ > 0) {
this->retries_left_ -= 1;
this->cmd_sent_ = false;
ESP_LOGD(TAG, "Retrying");
} else {
return 1; // Command done
}
}
} else if (this->parent_->send_cmd_(this->cmd_.c_str(), this->cmd_duration_ms_)) {
this->cmd_sent_ = true;
}
return 0; // Command not done yet
}
uint8_t ReadStateCommand::execute(DfrobotSen0395Component *parent) {
this->parent_ = parent;
if (this->parent_->read_message_()) {
std::string message(this->parent_->read_buffer_);
if (message.rfind("$JYBSS,0, , , *") != std::string::npos) {
this->parent_->set_detected_(false);
this->parent_->set_active(true);
return 1; // Command done
} else if (message.rfind("$JYBSS,1, , , *") != std::string::npos) {
this->parent_->set_detected_(true);
this->parent_->set_active(true);
return 1; // Command done
}
}
if (millis() - this->parent_->ts_last_cmd_sent_ > this->timeout_ms_) {
return 1; // Command done, timeout
}
return 0; // Command not done yet.
}
uint8_t ReadStateCommand::on_message(std::string &message) { return 1; }
uint8_t PowerCommand::on_message(std::string &message) {
if (message == "sensor stopped already") {
this->parent_->set_active(false);
ESP_LOGI(TAG, "Stopped sensor (already stopped)");
return 1; // Command done
} else if (message == "sensor started already") {
this->parent_->set_active(true);
ESP_LOGI(TAG, "Started sensor (already started)");
return 1; // Command done
} else if (message == "new parameter isn't save, can't startSensor") {
this->parent_->set_active(false);
ESP_LOGE(TAG, "Can't start sensor! (Use SaveCfgCommand to save config first)");
return 1; // Command done
} else if (message == "Done") {
this->parent_->set_active(this->power_on_);
if (this->power_on_) {
ESP_LOGI(TAG, "Started sensor");
} else {
ESP_LOGI(TAG, "Stopped sensor");
}
return 1; // Command done
}
return 0; // Command not done yet.
}
DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float max2, float min3, float max3,
float min4, float max4) {
// TODO: Print warning when values are rounded
if (min1 < 0 || max1 < 0) {
this->min1_ = min1 = 0;
this->max1_ = max1 = 0;
this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 =
this->max4_ = max4 = -1;
ESP_LOGW(TAG, "DetRangeCfgCommand invalid input parameters. Using range config 0 0.");
this->cmd_ = "detRangeCfg -1 0 0";
} else if (min2 < 0 || max2 < 0) {
this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f;
this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f;
this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 =
this->max4_ = max4 = -1;
char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null
snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15f, max1 / 0.15f);
this->cmd_ = buf;
} else if (min3 < 0 || max3 < 0) {
this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f;
this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f;
this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f;
this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f;
this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1;
char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null
snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f,
max2 / 0.15f);
this->cmd_ = buf;
} else if (min4 < 0 || max4 < 0) {
this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f;
this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f;
this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f;
this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f;
this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f;
this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f;
this->min4_ = min4 = this->max4_ = max4 = -1;
char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null
snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f,
max2 / 0.15f, min3 / 0.15f, max3 / 0.15f);
this->cmd_ = buf;
} else {
this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f;
this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f;
this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f;
this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f;
this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f;
this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f;
this->min4_ = min4 = roundf(min4 / 0.15f) * 0.15f;
this->max4_ = max4 = roundf(max4 / 0.15f) * 0.15f;
char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null
snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f,
min2 / 0.15f, max2 / 0.15f, min3 / 0.15f, max3 / 0.15f, min4 / 0.15f, max4 / 0.15f);
this->cmd_ = buf;
}
this->min1_ = min1;
this->max1_ = max1;
this->min2_ = min2;
this->max2_ = max2;
this->min3_ = min3;
this->max3_ = max3;
this->min4_ = min4;
this->max4_ = max4;
};
uint8_t DetRangeCfgCommand::on_message(std::string &message) {
if (message == "sensor is not stopped") {
ESP_LOGE(TAG, "Cannot configure range config. Sensor is not stopped!");
return 1; // Command done
} else if (message == "Done") {
ESP_LOGI(TAG,
"Updated detection area config:\n"
" Detection area 1 from %.02fm to %.02fm.",
this->min1_, this->max1_);
if (this->min2_ >= 0 && this->max2_ >= 0) {
ESP_LOGI(TAG, " Detection area 2 from %.02fm to %.02fm.", this->min2_, this->max2_);
}
if (this->min3_ >= 0 && this->max3_ >= 0) {
ESP_LOGI(TAG, " Detection area 3 from %.02fm to %.02fm.", this->min3_, this->max3_);
}
if (this->min4_ >= 0 && this->max4_ >= 0) {
ESP_LOGI(TAG, " Detection area 4 from %.02fm to %.02fm.", this->min4_, this->max4_);
}
ESP_LOGD(TAG, " Used command: %s", this->cmd_.c_str());
return 1; // Command done
}
return 0; // Command not done yet.
}
SetLatencyCommand::SetLatencyCommand(float delay_after_detection, float delay_after_disappear) {
delay_after_detection = std::round(delay_after_detection / 0.025f) * 0.025f;
delay_after_disappear = std::round(delay_after_disappear / 0.025f) * 0.025f;
this->delay_after_detection_ = clamp(delay_after_detection, 0.0f, 1638.375f);
this->delay_after_disappear_ = clamp(delay_after_disappear, 0.0f, 1638.375f);
// max 32: "setLatency "(11) + float(8) + " "(1) + float(8) + null, rounded to 32
char buf[32];
snprintf(buf, sizeof(buf), "setLatency %.03f %.03f", this->delay_after_detection_, this->delay_after_disappear_);
this->cmd_ = buf;
};
uint8_t SetLatencyCommand::on_message(std::string &message) {
if (message == "sensor is not stopped") {
ESP_LOGE(TAG, "Cannot configure output latency. Sensor is not stopped!");
return 1; // Command done
} else if (message == "Done") {
ESP_LOGI(TAG,
"Updated output latency config:\n"
" Signal that someone was detected is delayed by %.03f s.\n"
" Signal that nobody is detected anymore is delayed by %.03f s.",
this->delay_after_detection_, this->delay_after_disappear_);
ESP_LOGD(TAG, " Used command: %s", this->cmd_.c_str());
return 1; // Command done
}
return 0; // Command not done yet
}
uint8_t SensorCfgStartCommand::on_message(std::string &message) {
if (message == "sensor is not stopped") {
ESP_LOGE(TAG, "Cannot configure sensor startup behavior. Sensor is not stopped!");
return 1; // Command done
} else if (message == "Done") {
ESP_LOGI(TAG, "Updated sensor startup behavior:");
if (startup_mode_) {
this->parent_->set_start_after_boot(true);
ESP_LOGI(TAG, "Sensor will start automatically after power-on.");
} else {
this->parent_->set_start_after_boot(false);
ESP_LOGI(TAG, "Sensor needs to be started manually after power-on.");
}
ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
return 1; // Command done
}
return 0; // Command not done yet
}
uint8_t FactoryResetCommand::on_message(std::string &message) {
if (message == "sensor is not stopped") {
ESP_LOGE(TAG, "Cannot factory reset. Sensor is not stopped!");
return 1; // Command done
} else if (message == "Done") {
ESP_LOGI(TAG, "Sensor factory reset done.");
return 1; // Command done
}
return 0; // Command not done yet
}
uint8_t ResetSystemCommand::on_message(std::string &message) {
if (message == "leapMMW:/>") {
ESP_LOGI(TAG, "Restarted sensor.");
return 1; // Command done
}
return 0; // Command not done yet
}
uint8_t SaveCfgCommand::on_message(std::string &message) {
if (message == "no parameter has changed") {
ESP_LOGI(TAG, "Not saving config (no parameter changed).");
return 1; // Command done
} else if (message == "Done") {
ESP_LOGI(TAG, "Saved config. Saving a lot may damage the sensor.");
return 1; // Command done
}
return 0; // Command not done yet
}
uint8_t LedModeCommand::on_message(std::string &message) {
if (message == "sensor is not stopped") {
ESP_LOGE(TAG, "Cannot set led mode. Sensor is not stopped!");
return 1; // Command done
} else if (message == "Done") {
ESP_LOGI(TAG, "Set led mode done.");
if (this->active_) {
this->parent_->set_led_active(true);
ESP_LOGI(TAG, "Sensor LED will blink.");
} else {
this->parent_->set_led_active(false);
ESP_LOGI(TAG, "Turned off LED.");
}
ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
return 1; // Command done
}
return 0; // Command not done yet
}
uint8_t UartOutputCommand::on_message(std::string &message) {
if (message == "sensor is not stopped") {
ESP_LOGE(TAG, "Cannot set uart output mode. Sensor is not stopped!");
return 1; // Command done
} else if (message == "Done") {
ESP_LOGI(TAG, "Set uart mode done.");
if (this->active_) {
this->parent_->set_uart_presence_active(true);
ESP_LOGI(TAG, "Presence information is sent via UART and GPIO.");
} else {
this->parent_->set_uart_presence_active(false);
ESP_LOGI(TAG, "Presence information is only sent via GPIO.");
}
ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
return 1; // Command done
}
return 0; // Command not done yet
}
uint8_t SensitivityCommand::on_message(std::string &message) {
if (message == "sensor is not stopped") {
ESP_LOGE(TAG, "Cannot set sensitivity. Sensor is not stopped!");
return 1; // Command done
} else if (message == "Done") {
ESP_LOGI(TAG, "Set sensitivity done. Set to value %d.", this->sensitivity_);
ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
return 1; // Command done
}
return 0; // Command not done yet
}
} // namespace esphome::dfrobot_sen0395
@@ -1,152 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include "esphome/core/helpers.h"
namespace esphome::dfrobot_sen0395 {
class DfrobotSen0395Component;
// Use command queue and time stamps to avoid blocking.
// When component has run time, check if minimum time (1s) between
// commands has passed. After that run a command from the queue.
class Command {
public:
virtual ~Command() = default;
virtual uint8_t execute(DfrobotSen0395Component *parent);
virtual uint8_t on_message(std::string &message) = 0;
protected:
DfrobotSen0395Component *parent_{nullptr};
std::string cmd_;
bool cmd_sent_{false};
int8_t retries_left_{2};
uint32_t cmd_duration_ms_{1000};
uint32_t timeout_ms_{1500};
};
class ReadStateCommand : public Command {
public:
ReadStateCommand() { timeout_ms_ = 500; }
uint8_t execute(DfrobotSen0395Component *parent) override;
uint8_t on_message(std::string &message) override;
};
class PowerCommand : public Command {
public:
PowerCommand(bool power_on) : power_on_(power_on) {
if (power_on) {
cmd_ = "sensorStart";
} else {
cmd_ = "sensorStop";
}
};
uint8_t on_message(std::string &message) override;
protected:
bool power_on_;
};
class DetRangeCfgCommand : public Command {
public:
DetRangeCfgCommand(float min1, float max1, float min2, float max2, float min3, float max3, float min4, float max4);
uint8_t on_message(std::string &message) override;
protected:
float min1_, max1_, min2_, max2_, min3_, max3_, min4_, max4_;
// TODO: Set min max values in component, so they can be published as sensor.
};
class SetLatencyCommand : public Command {
public:
SetLatencyCommand(float delay_after_detection, float delay_after_disappear);
uint8_t on_message(std::string &message) override;
protected:
float delay_after_detection_;
float delay_after_disappear_;
};
class SensorCfgStartCommand : public Command {
public:
SensorCfgStartCommand(bool startup_mode) : startup_mode_(startup_mode) {
char tmp_cmd[20]; // "sensorCfgStart " (15) + "0/1" (1) + null = 17
buf_append_printf(tmp_cmd, sizeof(tmp_cmd), 0, "sensorCfgStart %d", startup_mode);
cmd_ = std::string(tmp_cmd);
}
uint8_t on_message(std::string &message) override;
protected:
bool startup_mode_;
};
class FactoryResetCommand : public Command {
public:
FactoryResetCommand() { cmd_ = "factoryReset 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89"; };
uint8_t on_message(std::string &message) override;
};
class ResetSystemCommand : public Command {
public:
ResetSystemCommand() { cmd_ = "resetSystem"; }
uint8_t on_message(std::string &message) override;
};
class SaveCfgCommand : public Command {
public:
SaveCfgCommand() {
cmd_ = "saveCfg 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89";
cmd_duration_ms_ = 3000;
timeout_ms_ = 3500;
}
uint8_t on_message(std::string &message) override;
};
class LedModeCommand : public Command {
public:
LedModeCommand(bool active) : active_(active) {
if (active) {
cmd_ = "setLedMode 1 0";
} else {
cmd_ = "setLedMode 1 1";
}
};
uint8_t on_message(std::string &message) override;
protected:
bool active_;
};
class UartOutputCommand : public Command {
public:
UartOutputCommand(bool active) : active_(active) {
if (active) {
cmd_ = "setUartOutput 1 1";
} else {
cmd_ = "setUartOutput 1 0";
}
};
uint8_t on_message(std::string &message) override;
protected:
bool active_;
};
class SensitivityCommand : public Command {
public:
SensitivityCommand(uint8_t sensitivity) : sensitivity_(sensitivity) {
if (sensitivity > 9)
sensitivity_ = sensitivity = 9;
char tmp_cmd[20]; // "setSensitivity " (15) + "0-9" (1) + null = 17
buf_append_printf(tmp_cmd, sizeof(tmp_cmd), 0, "setSensitivity %d", sensitivity);
cmd_ = std::string(tmp_cmd);
};
uint8_t on_message(std::string &message) override;
protected:
uint8_t sensitivity_;
};
} // namespace esphome::dfrobot_sen0395
@@ -1,13 +1,27 @@
#include "dfrobot_sen0395.h"
#include <cmath>
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::dfrobot_sen0395 {
ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395");
const char ASCII_CR = 0x0D;
const char ASCII_LF = 0x0A;
static constexpr float DET_RANGE_STEP_M = 0.15f;
static constexpr float LATENCY_STEP_S = 0.025f;
// Sent after a reset with no line end
static constexpr char PROMPT[] PROGMEM = "leapMMW:/>";
// What each CommandType cannot do while the sensor runs; empty for commands that do not need it stopped
PROGMEM_STRING_TABLE(BusyActions, "", "configure range config", "configure output latency",
"configure sensor startup behavior", "factory reset", "", "", "set led mode",
"set uart output mode", "set sensitivity");
static_assert(BusyActions::COUNT == static_cast<size_t>(CommandType::COMMAND_TYPE_SENSITIVITY) + 1,
"BusyActions needs one entry per CommandType");
void DfrobotSen0395Component::dump_config() {
ESP_LOGCONFIG(TAG, "Dfrobot Mmwave Radar:");
@@ -23,72 +37,287 @@ void DfrobotSen0395Component::dump_config() {
}
void DfrobotSen0395Component::loop() {
if (cmd_queue_.is_empty()) {
// Command queue empty. Read sensor state.
cmd_queue_.enqueue(make_unique<ReadStateCommand>());
if (this->cmd_queue_.empty()) {
this->read_state_();
return;
}
// Commands are non-blocking and need to be called repeatedly.
if (cmd_queue_.process(this)) {
// Dequeue if command is done
cmd_queue_.dequeue();
// Commands are non-blocking and need to be called repeatedly
if (this->run_command_(this->cmd_queue_.front(), App.get_loop_component_start_time())) {
this->cmd_queue_.pop();
this->cmd_sent_ = false;
this->retries_left_ = 2;
}
}
int8_t DfrobotSen0395Component::enqueue(std::unique_ptr<Command> cmd) {
return cmd_queue_.enqueue(std::move(cmd)); // Transfer ownership using std::move
bool DfrobotSen0395Component::enqueue(CommandType type, uint8_t arg) {
if (!this->cmd_queue_.push({type, arg})) {
ESP_LOGE(TAG, "Command queue is full");
return false;
}
return true;
}
uint8_t DfrobotSen0395Component::read_message_() {
bool DfrobotSen0395Component::has_queue_room(size_t count) const {
if (COMMAND_QUEUE_SIZE - this->cmd_queue_.size() >= count)
return true;
ESP_LOGE(TAG, "Command queue is full");
return false;
}
void DfrobotSen0395Component::enqueue_det_range_cfg(float min1, float max1, float min2, float max2, float min3,
float max3, float min4, float max4) {
// TODO: Print warning when values are rounded
const float values[8] = {min1, max1, min2, max2, min3, max3, min4, max4};
int16_t steps[8];
bool in_use = true;
for (size_t i = 0; i < 8; i += 2) {
in_use = in_use && values[i] >= 0 && values[i + 1] >= 0;
for (size_t j = i; j < i + 2; j++) {
steps[j] = in_use ? static_cast<int16_t>(clamp(roundf(values[j] / DET_RANGE_STEP_M), 0.0f, 32767.0f)) : -1;
}
}
if (steps[0] < 0) {
ESP_LOGW(TAG, "Invalid detection range, using 0 0");
steps[0] = steps[1] = 0;
}
if (this->enqueue(CommandType::COMMAND_TYPE_DET_RANGE_CFG))
memcpy(this->det_range_steps_, steps, sizeof(steps));
}
void DfrobotSen0395Component::enqueue_set_latency(float delay_after_detection, float delay_after_disappear) {
if (!this->enqueue(CommandType::COMMAND_TYPE_SET_LATENCY))
return;
// 1638.375 s is 65535 steps
this->latency_steps_[0] =
static_cast<uint16_t>(clamp(std::round(delay_after_detection / LATENCY_STEP_S), 0.0f, 65535.0f));
this->latency_steps_[1] =
static_cast<uint16_t>(clamp(std::round(delay_after_disappear / LATENCY_STEP_S), 0.0f, 65535.0f));
}
void DfrobotSen0395Component::enqueue_saved_setting(CommandType type, bool value) {
const bool was_active = this->active_;
if (!this->has_queue_room(was_active ? 4 : 2))
return;
if (was_active)
this->enqueue(CommandType::COMMAND_TYPE_POWER, false);
this->enqueue(type, value);
this->enqueue(CommandType::COMMAND_TYPE_SAVE_CFG);
if (was_active)
this->enqueue(CommandType::COMMAND_TYPE_POWER, true);
}
bool DfrobotSen0395Component::run_command_(const Command &cmd, uint32_t now) {
// The interval between two commands must be larger than the command duration
const uint32_t duration = cmd.type == CommandType::COMMAND_TYPE_SAVE_CFG ? 3000 : 1000;
if (!this->cmd_sent_) {
if (now - this->ts_last_cmd_sent_ > duration) {
char buf[64]; // "detRangeCfg -1" + 8 * " 32767" + null
this->format_command(cmd, buf, sizeof(buf));
ESP_LOGD(TAG, "Sending command: %s", buf);
this->write_str(buf);
this->ts_last_cmd_sent_ = now;
this->cmd_sent_ = true;
}
return false; // Command not done yet
}
if (const char *message = this->read_message_()) {
if (ESPHOME_strstr_P(message, ESPHOME_PSTR("is not recognized as a CLI command")) == nullptr) {
if (!this->handle_reply_(cmd, message))
return false; // Command not done yet
this->read_message_(); // Skip the prompt
return true;
}
ESP_LOGD(TAG, "Command not recognized properly by sensor");
} else if (now - this->ts_last_cmd_sent_ <= duration + 500) {
return false; // Command not done yet
} else {
ESP_LOGD(TAG, "Command timeout");
}
if (this->retries_left_ == 0) {
this->read_message_(); // Skip the prompt
return true; // Give up
}
this->retries_left_--;
this->cmd_sent_ = false;
ESP_LOGD(TAG, "Retrying");
return false;
}
void DfrobotSen0395Component::read_state_() {
const char *message = this->read_message_();
if (message == nullptr)
return;
if (ESPHOME_strstr_P(message, ESPHOME_PSTR("$JYBSS,0, , , *")) != nullptr) {
this->set_detected_(false);
this->set_active(true);
} else if (ESPHOME_strstr_P(message, ESPHOME_PSTR("$JYBSS,1, , , *")) != nullptr) {
this->set_detected_(true);
this->set_active(true);
}
}
static size_t append_cfg_key(char *buf, size_t size, size_t pos) {
return buf_append_str(buf, size, pos, " 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89");
}
void DfrobotSen0395Component::format_command(const Command &cmd, char *buf, size_t size) const {
buf[0] = '\0';
switch (cmd.type) {
case CommandType::COMMAND_TYPE_POWER:
if (cmd.arg) {
buf_append_str(buf, size, 0, "sensorStart");
} else {
buf_append_str(buf, size, 0, "sensorStop");
}
break;
case CommandType::COMMAND_TYPE_DET_RANGE_CFG: {
size_t pos = buf_append_str(buf, size, 0, "detRangeCfg -1");
for (size_t i = 0; i < 8 && this->det_range_steps_[i] >= 0; i += 2) {
pos = buf_append_printf(buf, size, pos, " %d %d", this->det_range_steps_[i], this->det_range_steps_[i + 1]);
}
break;
}
case CommandType::COMMAND_TYPE_SET_LATENCY:
buf_append_printf(buf, size, 0, "setLatency %.03f %.03f", this->latency_steps_[0] * LATENCY_STEP_S,
this->latency_steps_[1] * LATENCY_STEP_S);
break;
case CommandType::COMMAND_TYPE_SENSOR_CFG_START:
buf_append_printf(buf, size, 0, "sensorCfgStart %d", cmd.arg);
break;
case CommandType::COMMAND_TYPE_FACTORY_RESET:
append_cfg_key(buf, size, buf_append_str(buf, size, 0, "factoryReset"));
break;
case CommandType::COMMAND_TYPE_RESET_SYSTEM:
buf_append_str(buf, size, 0, "resetSystem");
break;
case CommandType::COMMAND_TYPE_SAVE_CFG:
append_cfg_key(buf, size, buf_append_str(buf, size, 0, "saveCfg"));
break;
case CommandType::COMMAND_TYPE_LED_MODE:
buf_append_printf(buf, size, 0, "setLedMode 1 %d", cmd.arg ? 0 : 1);
break;
case CommandType::COMMAND_TYPE_UART_OUTPUT:
buf_append_printf(buf, size, 0, "setUartOutput 1 %d", cmd.arg ? 1 : 0);
break;
case CommandType::COMMAND_TYPE_SENSITIVITY:
buf_append_printf(buf, size, 0, "setSensitivity %d", cmd.arg);
break;
}
}
bool DfrobotSen0395Component::handle_reply_(const Command &cmd, const char *message) {
auto is = [message](ESPHOME_PGM_P text) { return ESPHOME_strcmp_P(message, text) == 0; };
// Commands with their own replies
switch (cmd.type) {
case CommandType::COMMAND_TYPE_POWER:
if (is(ESPHOME_PSTR("sensor stopped already"))) {
this->set_active(false);
ESP_LOGI(TAG, "Stopped sensor (already stopped)");
} else if (is(ESPHOME_PSTR("sensor started already"))) {
this->set_active(true);
ESP_LOGI(TAG, "Started sensor (already started)");
} else if (is(ESPHOME_PSTR("new parameter isn't save, can't startSensor"))) {
this->set_active(false);
ESP_LOGE(TAG, "Can't start sensor! (Use SaveCfgCommand to save config first)");
} else if (is(ESPHOME_PSTR("Done"))) {
this->set_active(cmd.arg);
ESP_LOGI(TAG, "%s", cmd.arg ? LOG_STR_LITERAL("Started sensor") : LOG_STR_LITERAL("Stopped sensor"));
} else {
return false;
}
return true;
case CommandType::COMMAND_TYPE_RESET_SYSTEM:
if (!is(PROMPT))
return false;
ESP_LOGI(TAG, "Restarted sensor.");
return true;
case CommandType::COMMAND_TYPE_SAVE_CFG:
if (is(ESPHOME_PSTR("no parameter has changed"))) {
ESP_LOGI(TAG, "Not saving config (no parameter changed).");
} else if (is(ESPHOME_PSTR("Done"))) {
ESP_LOGI(TAG, "Saved config. Saving a lot may damage the sensor.");
} else {
return false;
}
return true;
default:
break;
}
// Configuration commands, which need the sensor stopped
if (is(ESPHOME_PSTR("sensor is not stopped"))) {
ESP_LOGE(TAG, "Cannot %s. Sensor is not stopped!",
LOG_STR_ARG(BusyActions::get_log_str(static_cast<uint8_t>(cmd.type), 0)));
return true;
}
if (!is(ESPHOME_PSTR("Done")))
return false;
switch (cmd.type) {
case CommandType::COMMAND_TYPE_DET_RANGE_CFG:
ESP_LOGI(TAG, "Updated detection area config:");
for (size_t i = 0; i < 8 && this->det_range_steps_[i] >= 0; i += 2) {
ESP_LOGI(TAG, " Detection area %u from %.02fm to %.02fm.", static_cast<unsigned>(i / 2 + 1),
this->det_range_steps_[i] * DET_RANGE_STEP_M, this->det_range_steps_[i + 1] * DET_RANGE_STEP_M);
}
break;
case CommandType::COMMAND_TYPE_SET_LATENCY:
ESP_LOGI(TAG,
"Updated output latency config:\n"
" Signal that someone was detected is delayed by %.03f s.\n"
" Signal that nobody is detected anymore is delayed by %.03f s.",
this->latency_steps_[0] * LATENCY_STEP_S, this->latency_steps_[1] * LATENCY_STEP_S);
break;
case CommandType::COMMAND_TYPE_SENSOR_CFG_START:
this->set_start_after_boot(cmd.arg);
ESP_LOGI(TAG, "Updated sensor startup behavior: %s",
cmd.arg ? LOG_STR_LITERAL("sensor will start automatically after power-on.")
: LOG_STR_LITERAL("sensor needs to be started manually after power-on."));
break;
case CommandType::COMMAND_TYPE_FACTORY_RESET:
ESP_LOGI(TAG, "Sensor factory reset done.");
break;
case CommandType::COMMAND_TYPE_LED_MODE:
this->set_led_active(cmd.arg);
ESP_LOGI(TAG, "Set led mode done. %s",
cmd.arg ? LOG_STR_LITERAL("Sensor LED will blink.") : LOG_STR_LITERAL("Turned off LED."));
break;
case CommandType::COMMAND_TYPE_UART_OUTPUT:
this->set_uart_presence_active(cmd.arg);
ESP_LOGI(TAG, "Set uart mode done. %s",
cmd.arg ? LOG_STR_LITERAL("Presence information is sent via UART and GPIO.")
: LOG_STR_LITERAL("Presence information is only sent via GPIO."));
break;
case CommandType::COMMAND_TYPE_SENSITIVITY:
ESP_LOGI(TAG, "Set sensitivity done. Set to value %d.", cmd.arg);
break;
default:
break;
}
return true;
}
const char *DfrobotSen0395Component::read_message_() {
while (this->available()) {
uint8_t byte;
this->read_byte(&byte);
if (this->read_pos_ == MMWAVE_READ_BUFFER_LENGTH)
this->read_pos_ = 0;
ESP_LOGVV(TAG, "Buffer pos: %u %d", this->read_pos_, byte);
if (byte == ASCII_CR)
continue;
if (byte >= 0x7F)
byte = '?'; // needs to be valid utf8 string for log functions.
this->read_buffer_[this->read_pos_] = byte;
if (this->read_pos_ == 9 && byte == '>')
this->read_buffer_[++this->read_pos_] = ASCII_LF;
if (this->read_buffer_[this->read_pos_] == ASCII_LF) {
this->read_buffer_[this->read_pos_] = 0;
this->read_pos_ = 0;
ESP_LOGV(TAG, "Message: %s", this->read_buffer_);
return 1; // Full message in buffer
} else {
this->read_pos_++;
const char *line = this->line_reader_.feed(byte);
if (line == nullptr && byte == '>' && this->line_reader_.length() == sizeof(PROMPT) - 1) {
line = this->line_reader_.partial();
this->line_reader_.reset();
}
}
return 0; // No full message yet
}
uint8_t DfrobotSen0395Component::find_prompt_() {
if (this->read_message_()) {
std::string message(this->read_buffer_);
if (message.rfind("leapMMW:/>") != std::string::npos) {
return 1; // Prompt found
if (line != nullptr) {
ESP_LOGV(TAG, "Message: %s", line);
return line;
}
this->line_reader_.log_if_overflowed(TAG);
}
return 0; // Not found yet
}
uint8_t DfrobotSen0395Component::send_cmd_(const char *cmd, uint32_t duration) {
// The interval between two commands must be larger than the specified duration (in ms).
if (millis() - ts_last_cmd_sent_ > duration) {
this->write_str(cmd);
ts_last_cmd_sent_ = millis();
return 1; // Command sent
}
// Could not send command yet as command duration did not fully pass yet.
return 0;
return nullptr;
}
void DfrobotSen0395Component::set_detected_(bool detected) {
@@ -99,44 +328,4 @@ void DfrobotSen0395Component::set_detected_(bool detected) {
#endif
}
int8_t CircularCommandQueue::enqueue(std::unique_ptr<Command> cmd) {
if (this->is_full()) {
ESP_LOGE(TAG, "Command queue is full");
return -1;
} else if (this->is_empty()) {
front_++;
}
rear_ = (rear_ + 1) % COMMAND_QUEUE_SIZE;
commands_[rear_] = std::move(cmd); // Transfer ownership using std::move
return 1;
}
std::unique_ptr<Command> CircularCommandQueue::dequeue() {
if (this->is_empty())
return nullptr;
std::unique_ptr<Command> dequeued_cmd = std::move(commands_[front_]);
if (front_ == rear_) {
front_ = -1;
rear_ = -1;
} else {
front_ = (front_ + 1) % COMMAND_QUEUE_SIZE;
}
return dequeued_cmd;
}
bool CircularCommandQueue::is_empty() { return front_ == -1; }
bool CircularCommandQueue::is_full() { return (rear_ + 1) % COMMAND_QUEUE_SIZE == front_; }
// Run execute method of first in line command.
// Execute is non-blocking and has to be called until it returns 1.
uint8_t CircularCommandQueue::process(DfrobotSen0395Component *parent) {
if (!is_empty()) {
return commands_[front_]->execute(parent);
} else {
return 1;
}
}
} // namespace esphome::dfrobot_sen0395
@@ -3,6 +3,8 @@
#include "esphome/components/uart/uart.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/line_reader.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
@@ -11,29 +13,30 @@
#include "esphome/components/switch/switch.h"
#endif
#include "commands.h"
namespace esphome::dfrobot_sen0395 {
const uint8_t MMWAVE_READ_BUFFER_LENGTH = 255;
static constexpr uint8_t MMWAVE_READ_BUFFER_LENGTH = 253; // largest LineReader size with a one byte position
static constexpr uint8_t COMMAND_QUEUE_SIZE = 20;
// forward declaration due to circular dependency
class DfrobotSen0395Component;
// Keep in sync with the BusyActions table in dfrobot_sen0395.cpp
enum class CommandType : uint8_t {
COMMAND_TYPE_POWER,
COMMAND_TYPE_DET_RANGE_CFG,
COMMAND_TYPE_SET_LATENCY,
COMMAND_TYPE_SENSOR_CFG_START,
COMMAND_TYPE_FACTORY_RESET,
COMMAND_TYPE_RESET_SYSTEM,
COMMAND_TYPE_SAVE_CFG,
COMMAND_TYPE_LED_MODE,
COMMAND_TYPE_UART_OUTPUT,
COMMAND_TYPE_SENSITIVITY,
};
static const uint8_t COMMAND_QUEUE_SIZE = 20;
class CircularCommandQueue {
public:
int8_t enqueue(std::unique_ptr<Command> cmd);
std::unique_ptr<Command> dequeue();
bool is_empty();
bool is_full();
uint8_t process(DfrobotSen0395Component *parent);
protected:
int front_{-1};
int rear_{-1};
std::unique_ptr<Command> commands_[COMMAND_QUEUE_SIZE];
/// A queued command. arg is the on/off flag, startup mode or sensitivity; the detection range and
/// latency values are held by the component.
struct Command {
CommandType type;
uint8_t arg;
};
class DfrobotSen0395Component final : public uart::UARTDevice, public Component {
@@ -93,9 +96,21 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component
}
#endif
int8_t enqueue(std::unique_ptr<Command> cmd);
// Commands run one at a time from a queue, at least 1 s apart, so loop() never blocks
bool enqueue(CommandType type, uint8_t arg = 0);
/// True when count more commands fit; logs when they do not, so a stop/start sequence is never queued in part
bool has_queue_room(size_t count) const;
/// Queue a detection range config; segments are used up to the first one with a negative bound
void enqueue_det_range_cfg(float min1, float max1, float min2, float max2, float min3, float max3, float min4,
float max4);
/// Queue an output latency config, both delays in seconds
void enqueue_set_latency(float delay_after_detection, float delay_after_disappear);
/// Queue a setting change, stopping the sensor around it and saving it when the sensor is running
void enqueue_saved_setting(CommandType type, bool value);
/// Queue a sensor reset
void reset_system() { this->enqueue(make_unique<ResetSystemCommand>()); }
void reset_system() { this->enqueue(CommandType::COMMAND_TYPE_RESET_SYSTEM); }
/// Write the text sent to the sensor for cmd into buf
void format_command(const Command &cmd, char *buf, size_t size) const;
protected:
#ifdef USE_BINARY_SENSOR
@@ -107,19 +122,24 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component
bool led_active_{false};
bool uart_presence_active_{false};
bool start_after_boot_{false};
char read_buffer_[MMWAVE_READ_BUFFER_LENGTH];
size_t read_pos_{0};
CircularCommandQueue cmd_queue_;
LineReader<MMWAVE_READ_BUFFER_LENGTH> line_reader_;
bool cmd_sent_{false};
uint8_t retries_left_{2};
StaticRingBuffer<Command, COMMAND_QUEUE_SIZE> cmd_queue_;
uint32_t ts_last_cmd_sent_{0};
// Range and latency of the queued commands, in sensor steps (0.15 m, 0.025 s); a later command overwrites them
int16_t det_range_steps_[8]{};
uint16_t latency_steps_[2]{};
uint8_t read_message_();
uint8_t find_prompt_();
uint8_t send_cmd_(const char *cmd, uint32_t duration);
/// The next complete line, valid until the next call, or nullptr
const char *read_message_();
void read_state_();
/// Run the command at the front of the queue; true when it is done
bool run_command_(const Command &cmd, uint32_t now);
/// Handle a reply to a sent command; true when the command is done
bool handle_reply_(const Command &cmd, const char *message);
void set_detected_(bool detected);
friend class Command;
friend class ReadStateCommand;
};
} // namespace esphome::dfrobot_sen0395
@@ -2,45 +2,18 @@
namespace esphome::dfrobot_sen0395 {
void Sen0395PowerSwitch::write_state(bool state) { this->parent_->enqueue(make_unique<PowerCommand>(state)); }
void Sen0395PowerSwitch::write_state(bool state) { this->parent_->enqueue(CommandType::COMMAND_TYPE_POWER, state); }
void Sen0395LedSwitch::write_state(bool state) {
bool was_active = false;
if (this->parent_->is_active()) {
was_active = true;
this->parent_->enqueue(make_unique<PowerCommand>(false));
}
this->parent_->enqueue(make_unique<LedModeCommand>(state));
this->parent_->enqueue(make_unique<SaveCfgCommand>());
if (was_active) {
this->parent_->enqueue(make_unique<PowerCommand>(true));
}
this->parent_->enqueue_saved_setting(CommandType::COMMAND_TYPE_LED_MODE, state);
}
void Sen0395UartPresenceSwitch::write_state(bool state) {
bool was_active = false;
if (this->parent_->is_active()) {
was_active = true;
this->parent_->enqueue(make_unique<PowerCommand>(false));
}
this->parent_->enqueue(make_unique<UartOutputCommand>(state));
this->parent_->enqueue(make_unique<SaveCfgCommand>());
if (was_active) {
this->parent_->enqueue(make_unique<PowerCommand>(true));
}
this->parent_->enqueue_saved_setting(CommandType::COMMAND_TYPE_UART_OUTPUT, state);
}
void Sen0395StartAfterBootSwitch::write_state(bool state) {
bool was_active = false;
if (this->parent_->is_active()) {
was_active = true;
this->parent_->enqueue(make_unique<PowerCommand>(false));
}
this->parent_->enqueue(make_unique<SensorCfgStartCommand>(state));
this->parent_->enqueue(make_unique<SaveCfgCommand>());
if (was_active) {
this->parent_->enqueue(make_unique<PowerCommand>(true));
}
this->parent_->enqueue_saved_setting(CommandType::COMMAND_TYPE_SENSOR_CFG_START, state);
}
} // namespace esphome::dfrobot_sen0395
@@ -5,13 +5,15 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE
from esphome.types import ConfigType
from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component
from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component, dfrobot_sen0609_ns
OutPinLevelSelect = dfrobot_sen0609_ns.class_("OutPinLevelSelect", select.Select)
CONFIG_SCHEMA = {
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component),
cv.Optional(CONF_OUT_PIN_LEVEL): select.select_schema(
select.ApplySelect,
OutPinLevelSelect,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_SCALE,
),
@@ -23,8 +25,5 @@ async def to_code(config: ConfigType) -> None:
selects = select.sub_selects(config, parent=hub)
# The level the OUT pin has while a target is present; the index is the module's value
await selects(
CONF_OUT_PIN_LEVEL,
hub.set_out_pin_level_select,
options=["low", "high"],
apply="control_out_pin_level(index)",
CONF_OUT_PIN_LEVEL, hub.set_out_pin_level_select, options=["low", "high"]
)

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