Merge remote-tracking branch 'origin/dev' into pr-18205

This commit is contained in:
J. Nick Koston
2026-10-20 18:46:12 -10:00
208 changed files with 3014 additions and 1083 deletions
+7 -2
View File
@@ -115,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player).
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).
## Templates in `$SKILL/templates`
@@ -131,7 +131,8 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
`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`.
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
`mqtt-transport-dut.yaml`.
## When something looks wrong
@@ -145,6 +146,10 @@ 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.
@@ -11,7 +11,7 @@ One file per component, named after it:
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker
- [`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
@@ -28,3 +28,5 @@ One file per component, named after it:
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls
- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin
- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply
- [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf
@@ -10,3 +10,22 @@ 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
@@ -0,0 +1,19 @@
# 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
@@ -0,0 +1,31 @@
# 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" [...]
usage: api_button.py HOST KEY list | press "Button name" [...] (KEY of - means a plaintext API)
"""
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=KEY)
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
await c.connect(login=True)
ents, _ = await c.list_entities_services()
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
+42
View File
@@ -0,0 +1,42 @@
#!/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
+13
View File
@@ -0,0 +1,13 @@
#!/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
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,12 @@
#!/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
@@ -0,0 +1,50 @@
"""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())
@@ -0,0 +1,17 @@
#!/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
+26
View File
@@ -0,0 +1,26 @@
#!/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 ]
@@ -0,0 +1,187 @@
"""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")
@@ -0,0 +1,19 @@
#!/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
+25
View File
@@ -0,0 +1,25 @@
#!/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
@@ -0,0 +1,66 @@
# 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));
@@ -0,0 +1,88 @@
# 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()"]
@@ -0,0 +1,136 @@
# 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'));
}
+1
View File
@@ -697,6 +697,7 @@ 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
+10 -18
View File
@@ -184,11 +184,9 @@ 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_);
}
@@ -227,24 +225,18 @@ void AcDimmer::dump_config() {
ESP_LOGCONFIG(TAG,
"AcDimmer:\n"
" Min Power: %.1f%%\n"
" Init with half cycle: %s",
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_));
" 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"));
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);
}
+9 -12
View File
@@ -6,19 +6,11 @@
namespace esphome::ac_dimmer {
enum DimMethod { DIM_METHOD_LEADING_PULSE = 0, DIM_METHOD_LEADING, DIM_METHOD_TRAILING };
enum DimMethod : uint8_t { 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
@@ -27,6 +19,12 @@ 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
@@ -50,17 +48,16 @@ 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) { method_ = method; }
void set_method(DimMethod method) { this->store_.method = method; }
protected:
void write_state(float state) override;
InternalGPIOPin *gate_pin_;
InternalGPIOPin *zero_cross_pin_;
gpio::InterruptType zero_cross_interrupt_type_;
AcDimmerDataStore store_;
gpio::InterruptType zero_cross_interrupt_type_;
bool init_with_half_cycle_;
DimMethod method_;
};
} // namespace esphome::ac_dimmer
+12 -14
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_.c_str(),
ESP_LOGV(TAG, "%s: updating subscription for %d virtual sensors (out=%d sensors)", this->device_id_,
num_virtual_sensors, num_sensor_settings);
}
void BME680BSECComponent::dump_config() {
ESP_LOGCONFIG(TAG, "%s via BSEC:", this->device_id_.c_str());
ESP_LOGCONFIG(TAG, "%s via BSEC:", this->device_id_);
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_.c_str());
ESP_LOGV(TAG, "%s: Performing sensor run", this->device_id_);
// 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_.c_str());
ESP_LOGV(TAG, "%s: Reading data", this->device_id_);
int64_t curr_time_ns = this->get_time_ns_();
if (this->bme680_settings_.trigger_measurement) {
@@ -365,8 +365,7 @@ 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_.c_str(), num_inputs,
num_outputs);
ESP_LOGV(TAG, "%s: after bsec_do_steps: num_inputs=%d num_outputs=%d", this->device_id_, 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)
@@ -381,7 +380,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_.c_str());
ESP_LOGV(TAG, "%s: Queuing sensor state publish actions", this->device_id_);
for (uint8_t i = 0; i < num_outputs; i++) {
float signal = outputs[i].signal;
switch (outputs[i].sensor_id) {
@@ -471,7 +470,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_.c_str(),
ESP_LOGW(TAG, "%s: Failed to fetch BSEC library state for snapshot (BSEC Error Code %d)", this->device_id_,
this->bsec_status_);
return;
}
@@ -482,7 +481,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_.c_str());
ESP_LOGV(TAG, "%s: BSEC state data NOT valid, aborting restore_state_()", this->device_id_);
return;
}
@@ -527,17 +526,16 @@ void BME680BSECComponent::load_state_() {
return;
}
ESP_LOGV(TAG, "%s: Loading BSEC library state", this->device_id_.c_str());
ESP_LOGV(TAG, "%s: Loading BSEC library state", this->device_id_);
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_.c_str(),
this->bsec_status_);
ESP_LOGW(TAG, "%s: Failed to load BSEC library state (BSEC Error Code %d)", this->device_id_, 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_.c_str());
ESP_LOGI(TAG, "%s: Loaded BSEC library state", this->device_id_);
}
void BME680BSECComponent::save_state_(uint8_t accuracy) {
@@ -552,7 +550,7 @@ void BME680BSECComponent::save_state_(uint8_t accuracy) {
if (!this->bsec_state_data_valid_)
return;
ESP_LOGV(TAG, "%s: Saving state", this->device_id_.c_str());
ESP_LOGV(TAG, "%s: Saving state", this->device_id_);
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 std::string &devid) { this->device_id_.assign(devid); }
void set_device_id(const char *devid) { this->device_id_ = 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_;
std::string device_id_;
const char *device_id_{nullptr};
float temperature_offset_{0};
IAQMode iaq_mode_{IAQ_MODE_STATIC};
SupplyVoltage supply_voltage_;
+1
View File
@@ -50,6 +50,7 @@ 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"
@@ -82,7 +82,7 @@ std::string MenuItemNumber::get_value_text() const {
result = this->value_getter_.value()(this);
} else {
char data[32];
snprintf(data, sizeof(data), this->format_.c_str(), get_number_value_());
snprintf(data, sizeof(data), this->format_, get_number_value_());
result = data;
}
@@ -118,7 +118,8 @@ class MenuItemNumber final : public MenuItemEditable {
public:
explicit MenuItemNumber() : MenuItemEditable(MENU_ITEM_NUMBER) {}
void set_number_variable(number::Number *var) { this->number_var_ = var; }
void set_format(const std::string &fmt) { this->format_ = fmt; }
// Keeps the pointer; the string must outlive the item (codegen passes a literal)
void set_format(const char *fmt) { this->format_ = fmt; }
bool has_value() const override { return true; }
std::string get_value_text() const override;
@@ -130,7 +131,7 @@ class MenuItemNumber final : public MenuItemEditable {
float get_number_value_() const;
number::Number *number_var_{nullptr};
std::string format_;
const char *format_{""};
};
#endif
@@ -139,8 +140,9 @@ class MenuItemSwitch final : public MenuItemEditable {
public:
explicit MenuItemSwitch() : MenuItemEditable(MENU_ITEM_SWITCH) {}
void set_switch_variable(switch_::Switch *var) { this->switch_var_ = var; }
void set_on_text(const std::string &t) { this->switch_on_text_ = t; }
void set_off_text(const std::string &t) { this->switch_off_text_ = t; }
// Both keep the pointer; the strings must outlive the item (codegen passes literals)
void set_on_text(const char *t) { this->switch_on_text_ = t; }
void set_off_text(const char *t) { this->switch_off_text_ = t; }
bool has_value() const override { return true; }
std::string get_value_text() const override;
@@ -153,8 +155,8 @@ class MenuItemSwitch final : public MenuItemEditable {
bool toggle_switch_();
switch_::Switch *switch_var_{nullptr};
std::string switch_on_text_;
std::string switch_off_text_;
const char *switch_on_text_{""};
const char *switch_off_text_{""};
};
#endif
+37
View File
@@ -1,2 +1,39 @@
from collections.abc import Iterable
from dataclasses import dataclass, field
from pathlib import Path
from esphome.core import CORE
CODEOWNERS = ["@esphome/core"]
DOMAIN = "epaper_spi"
# The source file that every model needs.
BASE_SOURCE_FILE = "epaper_spi.cpp"
@dataclass
class EpaperSpiData:
needed_sources: set[str] = field(default_factory=set)
def _get_data() -> EpaperSpiData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = EpaperSpiData()
return CORE.data[DOMAIN]
def register_sources(sources: Iterable[str]) -> None:
"""Record the source files that a configured model's C++ driver class needs."""
_get_data().needed_sources.update(sources)
def FILTER_SOURCE_FILES() -> list[str]: # noqa: N802
"""Exclude the driver sources that no configured model needs."""
if not (needed := _get_data().needed_sources):
return []
keep = needed | {BASE_SOURCE_FILE}
return sorted(
path.name
for path in Path(__file__).parent.glob("*.cpp")
if path.name not in keep
)
+6 -1
View File
@@ -42,7 +42,11 @@ from esphome.const import (
)
from esphome.cpp_generator import RawExpression
from . import models
from . import ( # pylint: disable=unused-import
FILTER_SOURCE_FILES, # noqa: F401
models,
register_sources,
)
AUTO_LOAD = ["split_buffer"]
DEPENDENCIES = ["spi"]
@@ -219,6 +223,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
model = MODELS[config[CONF_MODEL]]
register_sources(model.source_files)
init_sequence = config.get(CONF_INIT_SEQUENCE)
if init_sequence is None:
@@ -18,17 +18,24 @@ class EpaperModel:
# Whether the driver manages chip-select itself instead of via the SPI bus.
manages_cs: bool = False
# The .cpp files this model's driver class needs: its own and those of every parent class
# except epaper_spi.cpp, which is always built. Subclasses override this next to their class_name.
source_files: tuple[str, ...] = ()
def __init__(
self,
name: str,
class_name: str,
initsequence=(),
source_files: tuple[str, ...] | None = None,
**defaults,
):
name = name.upper()
self.name = name
self.class_name = class_name
self.initsequence = initsequence
if source_files is not None:
self.source_files = source_files
self.defaults = defaults
EpaperModel.models[name] = self
@@ -112,7 +119,13 @@ class EpaperModel:
initsequence.extend(kwargs.pop("add_init_sequence", ()))
defaults = self.defaults.copy()
defaults.update(kwargs)
return self.__class__(name, initsequence=tuple(initsequence), **defaults)
model = self.__class__(name, initsequence=tuple(initsequence), **defaults)
# Subclass constructors do not all take these, so carry them over unless overridden
if "class_name" not in kwargs:
model.class_name = self.class_name
if "source_files" not in kwargs:
model.source_files = self.source_files
return model
def check_requirements(self) -> None:
"""
@@ -4,6 +4,8 @@ from . import EpaperModel
class Inkplate2Model(EpaperModel):
source_files = ("epaper_spi_inkplate2.cpp",)
def __init__(self, name, class_name="EPaperInkplate2", **kwargs):
super().__init__(name, class_name, **kwargs)
@@ -6,6 +6,11 @@ from . import EpaperModel
class Inkplate6ColorModel(EpaperModel):
source_files = (
"epaper_spi_4bpp.cpp",
"epaper_spi_inkplate6color.cpp",
)
def __init__(self, name, class_name="EPaperInkplate6Color", **kwargs):
super().__init__(name, class_name, **kwargs)
@@ -9,6 +9,8 @@ from . import EpaperModel
class JD79660(EpaperModel):
source_files = ("epaper_spi_jd79660.cpp",)
def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs):
# Only a fast_update sequence lets the driver do anything but a full refresh
kwargs.setdefault("partial_update", fast_update is not None)
@@ -4,6 +4,11 @@ from . import EpaperModel
class SpectraE6(EpaperModel):
source_files = (
"epaper_spi_4bpp.cpp",
"epaper_spi_spectra_e6.cpp",
)
def __init__(self, name, class_name="EPaperSpectraE6", **defaults):
super().__init__(name, class_name, **defaults)
@@ -14,6 +14,11 @@ MONOCHROME = "monochrome"
class SSD1677(EpaperModel):
source_files = (
"epaper_spi_mono.cpp",
"epaper_spi_ssd1677.cpp",
)
def __init__(
self,
name: str,
@@ -116,6 +121,11 @@ seeed_sticky = ssd1677.extend(
seeed_sticky.extend(
"seeed-reterminal-sticky-gray4",
class_name="EPaperSSD1677Gray4",
source_files=(
"epaper_spi_mono.cpp",
"epaper_spi_ssd1677.cpp",
"epaper_spi_ssd1677_gray4.cpp",
),
border_waveform=0x00,
partial_update=MONOCHROME,
# each plane byte is built from two whole buffer bytes
@@ -4,6 +4,11 @@ from . import EpaperModel
class SSD1683(EpaperModel):
source_files = (
"epaper_spi_mono.cpp",
"epaper_spi_ssd1683.cpp",
)
def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults):
defaults[CONF_DATA_RATE] = data_rate
defaults.setdefault("partial_update", True)
@@ -22,6 +22,8 @@ CONF_CS1_PIN = "cs1_pin"
class T133A01Model(EpaperModel):
"""EpaperModel subclass for T133A01-based 6-color e-paper displays."""
source_files = ("epaper_spi_t133a01.cpp",)
# The driver drives CS and CS1 directly for the dual-CS protocol.
manages_cs = True
@@ -22,6 +22,8 @@ MAX_GATES = 296
class UC8151(EpaperModel):
"""EpaperModel class for black/white/red displays using the UC8151D controller."""
source_files = ("epaper_waveshare_bwr.cpp",)
def __init__(self, name: str, **defaults: Any) -> None:
super().__init__(name, "EPaperWaveshareBWR", **defaults)
@@ -24,6 +24,11 @@ from . import EpaperModel, ultrachip_resolution
class UC8179(EpaperModel):
"""EpaperModel class for monochrome displays using the UC8179 controller."""
source_files = (
"epaper_spi_ultrachip.cpp",
"epaper_spi_uc8179.cpp",
)
def __init__(
self,
name: str,
@@ -19,6 +19,11 @@ from . import EpaperModel, ultrachip_resolution
class UC8279(EpaperModel):
source_files = (
"epaper_spi_ultrachip.cpp",
"epaper_spi_uc8279.cpp",
)
def __init__(
self,
name: str,
@@ -11,6 +11,11 @@ from . import EpaperModel
class WaveshareModel(EpaperModel):
source_files = (
"epaper_spi_mono.cpp",
"epaper_waveshare.cpp",
)
def __init__(
self,
name: str,
@@ -2,6 +2,11 @@ from . import EpaperModel
class WaveshareB(EpaperModel):
source_files = (
"epaper_waveshare_b.cpp",
"epaper_weact_3c.cpp",
)
def __init__(self, name, **defaults):
super().__init__(name, "EpaperWaveshareB", **defaults)
@@ -14,6 +14,8 @@ from . import EpaperModel
class WaveshareBWR(EpaperModel):
"""EpaperModel class for Waveshare Black/White/Red displays using UC8179 controller."""
source_files = ("epaper_waveshare_bwr.cpp",)
def __init__(self, name, **defaults):
super().__init__(name, "EPaperWaveshareBWR", **defaults)
@@ -15,6 +15,8 @@ from . import EpaperModel
class WeActBWR(EpaperModel):
"""Base EpaperModel class for WeAct Black/White/Red displays using SSD1683 controller."""
source_files = ("epaper_weact_3c.cpp",)
def __init__(self, name, **defaults):
super().__init__(name, "EPaperWeAct3C", **defaults)
+40 -16
View File
@@ -612,7 +612,14 @@ class GlyphInfo:
self.height = height
def glyph_to_glyphinfo(glyph, font, size, bpp):
def glyph_to_glyphinfo(glyph, font, size, bpp, crop: bool = True):
"""Render a glyph and pack its bitmap at the given bits per pixel.
With ``crop``, blank rows and columns around the set pixels are dropped and the
offsets adjusted to match. Bitmap fonts (BDF/PCF) render every glyph as a whole
character cell, so this keeps their empty space out of flash. A blank glyph keeps
its offsets and stores no pixels.
"""
# Convert to 32 bit unicode codepoint
glyph = ord(glyph)
scale = 256 // (1 << bpp)
@@ -632,19 +639,36 @@ def glyph_to_glyphinfo(glyph, font, size, bpp):
height = font.glyph.bitmap.rows
buffer = font.glyph.bitmap.buffer
pitch = font.glyph.bitmap.pitch
glyph_data = [0] * ((height * width * bpp + 7) // 8)
src_mode = font.glyph.bitmap.pixel_mode
if src_mode == ft_pixel_mode_mono:
rows = [
[
(1 << bpp) - 1 if buffer[y * pitch + x // 8] & (1 << (7 - x % 8)) else 0
for x in range(width)
]
for y in range(height)
]
else:
rows = [
[buffer[y * pitch + x] // scale for x in range(width)]
for y in range(height)
]
left = 0
top = 0
if crop and width * height > 1:
used_rows = [y for y, row in enumerate(rows) if any(row)]
used_cols = [x for x in range(width) if any(row[x] for row in rows)]
if used_rows:
left = used_cols[0]
top = used_rows[0]
width = used_cols[-1] + 1 - left
height = used_rows[-1] + 1 - top
else:
width = height = 0
glyph_data = [0] * ((height * width * bpp + 7) // 8)
pos = 0
for y in range(height):
for x in range(width):
if src_mode == ft_pixel_mode_mono:
pixel = (
(1 << bpp) - 1
if buffer[y * pitch + x // 8] & (1 << (7 - x % 8))
else 0
)
else:
pixel = buffer[y * pitch + x] // scale
for row in rows[top : top + height]:
for pixel in row[left : left + width]:
for bit_num in range(bpp):
if pixel & (1 << (bpp - bit_num - 1)):
glyph_data[pos // 8] |= 0x80 >> (pos % 8)
@@ -663,8 +687,8 @@ def glyph_to_glyphinfo(glyph, font, size, bpp):
glyph,
glyph_data,
pt_to_px(font.glyph.metrics.horiAdvance),
font.glyph.bitmap_left,
ascender - font.glyph.bitmap_top,
font.glyph.bitmap_left + left,
ascender - font.glyph.bitmap_top + top,
width,
height,
)
@@ -733,9 +757,9 @@ async def to_code(config):
font_height = pt_to_px(base_font.size.height)
ascender = pt_to_px(base_font.size.ascender)
descender = abs(pt_to_px(base_font.size.descender))
g = glyph_to_glyphinfo("x", base_font, size, bpp)
g = glyph_to_glyphinfo("x", base_font, size, bpp, crop=False)
xheight = g.height if len(g.bitmap_data) > 1 else 0
g = glyph_to_glyphinfo("X", base_font, size, bpp)
g = glyph_to_glyphinfo("X", base_font, size, bpp, crop=False)
capheight = g.height if len(g.bitmap_data) > 1 else 0
if font_height == 0:
if not base_font.is_scalable:
+8 -8
View File
@@ -47,7 +47,7 @@ void HistoryData::take_sample(float data) {
}
void GraphTrace::init(Graph *g) {
ESP_LOGI(TAG, "Init trace for sensor %s", this->get_name().c_str());
ESP_LOGI(TAG, "Init trace for sensor %s", this->name_);
this->data_.init(g->get_width());
sensor_->add_on_state_callback([this](float state) { this->data_.take_sample(state); });
this->data_.set_update_time_ms(g->get_duration() * 1000 / g->get_width());
@@ -220,16 +220,16 @@ void GraphLegend::init(Graph *g) {
int valw = 0, valh = 0;
int lt = 0;
for (auto *trace : g->traces_) {
std::string txtstr = trace->get_name();
const char *txtstr = trace->name_;
int fw, fos, fbl, fh;
this->font_label_->measure(txtstr.c_str(), &fw, &fos, &fbl, &fh);
this->font_label_->measure(txtstr, &fw, &fos, &fbl, &fh);
if (fw > txtw)
txtw = fw;
if (fh > txth)
txth = fh;
if (trace->get_line_thickness() > lt)
lt = trace->get_line_thickness();
ESP_LOGI(TAGL, " %s %d %d", txtstr.c_str(), fw, fh);
ESP_LOGI(TAGL, " %s %d %d", txtstr, fw, fh);
if (this->values_ != VALUE_POSITION_TYPE_NONE) {
char valstr[VALUE_ACCURACY_MAX_LEN];
@@ -351,10 +351,10 @@ void Graph::draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_of
int x = x_offset + legend_->x0_;
int y = y_offset;
for (auto *trace : traces_) {
std::string txtstr = trace->get_name();
ESP_LOGV(TAG, " %s", txtstr.c_str());
const char *txtstr = trace->name_;
ESP_LOGV(TAG, " %s", txtstr);
buff->printf(x, y, legend_->font_label_, trace->get_line_color(), TextAlign::TOP_CENTER, "%s", txtstr.c_str());
buff->printf(x, y, legend_->font_label_, trace->get_line_color(), TextAlign::TOP_CENTER, "%s", txtstr);
if (legend_->lines_) {
uint16_t thick = trace->get_line_thickness();
@@ -393,7 +393,7 @@ void Graph::setup() {
void Graph::dump_config() {
for (auto *trace : traces_) {
ESP_LOGCONFIG(TAG, "Graph for sensor %s", trace->get_name().c_str());
ESP_LOGCONFIG(TAG, "Graph for sensor %s", trace->name_);
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
#pragma once
#include <cstdint>
#include <utility>
#include <vector>
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/color.h"
#include "esphome/core/string_ref.h"
#include "esphome/core/component.h"
namespace esphome {
@@ -108,7 +108,7 @@ class HistoryData {
class GraphTrace final {
public:
void init(Graph *g);
void set_name(std::string name) { name_ = std::move(name); }
void set_name(const char *name) { this->name_ = name; }
void set_sensor(sensor::Sensor *sensor) { sensor_ = sensor; }
uint8_t get_line_thickness() { return this->line_thickness_; }
void set_line_thickness(uint8_t val) { this->line_thickness_ = val; }
@@ -118,12 +118,12 @@ class GraphTrace final {
void set_line_color(Color val) { this->line_color_ = val; }
bool get_continuous() { return this->continuous_; }
void set_continuous(bool continuous) { this->continuous_ = continuous; }
std::string get_name() { return name_; }
StringRef get_name() const { return StringRef(this->name_); }
const HistoryData *get_tracedata() { return &data_; }
protected:
sensor::Sensor *sensor_{nullptr};
std::string name_;
const char *name_{""};
uint8_t line_thickness_{3};
enum LineType line_type_ { LINE_TYPE_SOLID };
Color line_color_{COLOR_ON};
@@ -4,6 +4,7 @@
#include "esphome/components/key_provider/key_provider.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
namespace esphome::key_collector {
@@ -14,12 +15,13 @@ class KeyCollector final : public Component {
void add_provider(key_provider::KeyProvider *provider);
void set_min_length(uint16_t min_length) { this->min_length_ = min_length; };
void set_max_length(uint16_t max_length) { this->max_length_ = max_length; };
void set_start_keys(std::string start_keys) { this->start_keys_ = std::move(start_keys); };
void set_end_keys(std::string end_keys) { this->end_keys_ = std::move(end_keys); };
// The key setters keep the pointer; the strings must outlive the component (codegen passes literals)
void set_start_keys(const char *start_keys) { this->start_keys_ = StringRef(start_keys); };
void set_end_keys(const char *end_keys) { this->end_keys_ = StringRef(end_keys); };
void set_end_key_required(bool end_key_required) { this->end_key_required_ = end_key_required; };
void set_back_keys(std::string back_keys) { this->back_keys_ = std::move(back_keys); };
void set_clear_keys(std::string clear_keys) { this->clear_keys_ = std::move(clear_keys); };
void set_allowed_keys(std::string allowed_keys) { this->allowed_keys_ = std::move(allowed_keys); };
void set_back_keys(const char *back_keys) { this->back_keys_ = StringRef(back_keys); };
void set_clear_keys(const char *clear_keys) { this->clear_keys_ = StringRef(clear_keys); };
void set_allowed_keys(const char *allowed_keys) { this->allowed_keys_ = StringRef(allowed_keys); };
template<typename F> void add_on_progress_callback(F &&callback) {
this->progress_callbacks_.add(std::forward<F>(callback));
}
@@ -38,12 +40,12 @@ class KeyCollector final : public Component {
protected:
uint16_t min_length_{0};
uint16_t max_length_{0};
std::string start_keys_;
std::string end_keys_;
StringRef start_keys_;
StringRef end_keys_;
bool end_key_required_{false};
std::string back_keys_;
std::string clear_keys_;
std::string allowed_keys_;
StringRef back_keys_;
StringRef clear_keys_;
StringRef allowed_keys_;
std::string result_;
uint8_t start_key_{0};
LazyCallbackManager<void(const std::string &, uint8_t)> progress_callbacks_;
@@ -77,7 +77,6 @@ void MQTTAlarmControlPanelComponent::setup() {
}
void MQTTAlarmControlPanelComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT alarm_control_panel '%s':", this->alarm_control_panel_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
ESP_LOGCONFIG(TAG,
" Supported Features: %" PRIu32 "\n"
@@ -17,10 +17,7 @@ void MQTTBinarySensorComponent::setup() {
this->binary_sensor_->add_on_state_callback([this](bool state) { this->publish_state(state); });
}
void MQTTBinarySensorComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Binary Sensor '%s':", this->binary_sensor_->get_name().c_str());
LOG_MQTT_COMPONENT(true, false);
}
void MQTTBinarySensorComponent::dump_config() { LOG_MQTT_COMPONENT(true, false); }
MQTTBinarySensorComponent::MQTTBinarySensorComponent(binary_sensor::BinarySensor *binary_sensor)
: binary_sensor_(binary_sensor) {
if (this->binary_sensor_->is_status_binary_sensor()) {
+1 -4
View File
@@ -24,10 +24,7 @@ void MQTTButtonComponent::setup() {
}
});
}
void MQTTButtonComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Button '%s': ", this->button_->get_name().c_str());
LOG_MQTT_COMPONENT(false, true);
}
void MQTTButtonComponent::dump_config() { LOG_MQTT_COMPONENT(false, true); }
void MQTTButtonComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
config.state_topic = false;
@@ -38,6 +38,7 @@ inline char *append_char(char *p, char c) {
// Function implementation of LOG_MQTT_COMPONENT macro to reduce code size
void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) {
ESP_LOGCONFIG(tag, "MQTT %s '%s':", obj->component_type(), obj->friendly_name_().c_str());
char buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
if (state_topic) {
ESP_LOGCONFIG(tag, " State Topic: '%s'", obj->get_state_topic_to_(buf).c_str());
-1
View File
@@ -63,7 +63,6 @@ void MQTTCoverComponent::setup() {
}
void MQTTCoverComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT cover '%s':", this->cover_->get_name().c_str());
auto traits = this->cover_->get_traits();
bool has_command_topic = traits.get_supports_position() || !traits.get_supports_tilt();
LOG_MQTT_COMPONENT(true, has_command_topic);
+1 -4
View File
@@ -34,10 +34,7 @@ void MQTTDateComponent::setup() {
[this]() { this->publish_state(this->date_->year, this->date_->month, this->date_->day); });
}
void MQTTDateComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Date '%s':", this->date_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
}
void MQTTDateComponent::dump_config() { LOG_MQTT_COMPONENT(true, true); }
MQTT_COMPONENT_TYPE(MQTTDateComponent, "date")
const EntityBase *MQTTDateComponent::get_entity() const { return this->date_; }
+1 -4
View File
@@ -45,10 +45,7 @@ void MQTTDateTimeComponent::setup() {
});
}
void MQTTDateTimeComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT DateTime '%s':", this->datetime_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
}
void MQTTDateTimeComponent::dump_config() { LOG_MQTT_COMPONENT(true, true); }
MQTT_COMPONENT_TYPE(MQTTDateTimeComponent, "datetime")
const EntityBase *MQTTDateTimeComponent::get_entity() const { return this->datetime_; }
+4 -5
View File
@@ -28,12 +28,11 @@ void MQTTEventComponent::setup() {
}
void MQTTEventComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Event '%s': ", this->event_->get_name().c_str());
ESP_LOGCONFIG(TAG, "Event Types: ");
for (const char *event_type : this->event_->get_event_types()) {
ESP_LOGCONFIG(TAG, "- %s", event_type);
}
LOG_MQTT_COMPONENT(true, true);
ESP_LOGCONFIG(TAG, " Event Types:");
for (const char *event_type : this->event_->get_event_types()) {
ESP_LOGCONFIG(TAG, " - %s", event_type);
}
}
bool MQTTEventComponent::publish_event_(const std::string &event_type) {
-1
View File
@@ -125,7 +125,6 @@ void MQTTFanComponent::setup() {
}
void MQTTFanComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Fan '%s': ", this->state_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
if (this->state_->get_traits().supports_direction()) {
ESP_LOGCONFIG(TAG,
+1 -4
View File
@@ -87,10 +87,7 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery
}
}
bool MQTTJSONLightComponent::send_initial_state() { return this->publish_state_(); }
void MQTTJSONLightComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Light '%s':", this->state_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
}
void MQTTJSONLightComponent::dump_config() { LOG_MQTT_COMPONENT(true, true); }
} // namespace esphome::mqtt
+1 -4
View File
@@ -31,10 +31,7 @@ void MQTTLockComponent::setup() {
this->lock_->add_on_state_callback(
[this](LockState /*state*/) { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); });
}
void MQTTLockComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
}
void MQTTLockComponent::dump_config() { LOG_MQTT_COMPONENT(true, true); }
MQTT_COMPONENT_TYPE(MQTTLockComponent, "lock")
const EntityBase *MQTTLockComponent::get_entity() const { return this->lock_; }
+1 -4
View File
@@ -32,10 +32,7 @@ void MQTTNumberComponent::setup() {
this->number_->add_on_state_callback([this](float state) { this->publish_state(state); });
}
void MQTTNumberComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Number '%s':", this->number_->get_name().c_str());
LOG_MQTT_COMPONENT(true, false);
}
void MQTTNumberComponent::dump_config() { LOG_MQTT_COMPONENT(true, false); }
MQTT_COMPONENT_TYPE(MQTTNumberComponent, "number")
const EntityBase *MQTTNumberComponent::get_entity() const { return this->number_; }
+1 -4
View File
@@ -23,10 +23,7 @@ void MQTTSelectComponent::setup() {
this->select_->add_on_state_callback([this](size_t index) { this->publish_state(this->select_->option_at(index)); });
}
void MQTTSelectComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Select '%s':", this->select_->get_name().c_str());
LOG_MQTT_COMPONENT(true, false);
}
void MQTTSelectComponent::dump_config() { LOG_MQTT_COMPONENT(true, false); }
MQTT_COMPONENT_TYPE(MQTTSelectComponent, "select")
const EntityBase *MQTTSelectComponent::get_entity() const { return this->select_; }
+1 -2
View File
@@ -25,11 +25,10 @@ void MQTTSensorComponent::setup() {
}
void MQTTSensorComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Sensor '%s':", this->sensor_->get_name().c_str());
LOG_MQTT_COMPONENT(true, false);
if (this->get_expire_after() > 0) {
ESP_LOGCONFIG(TAG, " Expire After: %" PRIu32 "s", this->get_expire_after() / 1000);
}
LOG_MQTT_COMPONENT(true, false);
}
MQTT_COMPONENT_TYPE(MQTTSensorComponent, "sensor")
+1 -4
View File
@@ -36,10 +36,7 @@ void MQTTSwitchComponent::setup() {
this->switch_->add_on_state_callback(
[this](bool enabled) { this->defer(SEND_DEFER_ID, [this, enabled]() { this->publish_state(enabled); }); });
}
void MQTTSwitchComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Switch '%s': ", this->switch_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
}
void MQTTSwitchComponent::dump_config() { LOG_MQTT_COMPONENT(true, true); }
MQTT_COMPONENT_TYPE(MQTTSwitchComponent, "switch")
const EntityBase *MQTTSwitchComponent::get_entity() const { return this->switch_; }
+1 -4
View File
@@ -28,10 +28,7 @@ void MQTTTextComponent::setup() {
this->text_->add_on_state_callback([this](const std::string &state) { this->publish_state(state); });
}
void MQTTTextComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT text '%s':", this->text_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
}
void MQTTTextComponent::dump_config() { LOG_MQTT_COMPONENT(true, true); }
MQTT_COMPONENT_TYPE(MQTTTextComponent, "text")
const EntityBase *MQTTTextComponent::get_entity() const { return this->text_; }
+1 -4
View File
@@ -20,10 +20,7 @@ void MQTTTextSensor::setup() {
this->sensor_->add_on_state_callback([this](const std::string &state) { this->publish_state(state); });
}
void MQTTTextSensor::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Text Sensor '%s':", this->sensor_->get_name().c_str());
LOG_MQTT_COMPONENT(true, false);
}
void MQTTTextSensor::dump_config() { LOG_MQTT_COMPONENT(true, false); }
bool MQTTTextSensor::publish_state(const std::string &value) {
char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
+1 -4
View File
@@ -34,10 +34,7 @@ void MQTTTimeComponent::setup() {
[this]() { this->publish_state(this->time_->hour, this->time_->minute, this->time_->second); });
}
void MQTTTimeComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Time '%s':", this->time_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
}
void MQTTTimeComponent::dump_config() { LOG_MQTT_COMPONENT(true, true); }
MQTT_COMPONENT_TYPE(MQTTTimeComponent, "time")
const EntityBase *MQTTTimeComponent::get_entity() const { return this->time_; }
+1 -4
View File
@@ -48,10 +48,7 @@ void MQTTUpdateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon
bool MQTTUpdateComponent::send_initial_state() { return this->publish_state(); }
void MQTTUpdateComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Update '%s': ", this->update_->get_name().c_str());
LOG_MQTT_COMPONENT(true, true);
}
void MQTTUpdateComponent::dump_config() { LOG_MQTT_COMPONENT(true, true); }
MQTT_COMPONENT_TYPE(MQTTUpdateComponent, "update")
const EntityBase *MQTTUpdateComponent::get_entity() const { return this->update_; }
-1
View File
@@ -51,7 +51,6 @@ void MQTTValveComponent::setup() {
}
void MQTTValveComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT valve '%s':", this->valve_->get_name().c_str());
auto traits = this->valve_->get_traits();
bool has_command_topic = traits.get_supports_position();
LOG_MQTT_COMPONENT(true, has_command_topic);
+115 -22
View File
@@ -1,13 +1,23 @@
from typing import NamedTuple
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander, i2c
from esphome.components import i2c
from esphome.components.const import CONF_DRIVE_STRENGTH
from esphome.components.gpio_expander import validate_interrupt_pin
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_DEVICE,
CONF_I2C_ID,
CONF_ID,
CONF_INPUT,
CONF_INTERRUPT_PIN,
CONF_MODE,
CONF_NUMBER,
CONF_OUTPUT,
CONF_PULLDOWN,
CONF_PULLUP,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
@@ -17,46 +27,106 @@ AUTO_LOAD = ["gpio_expander"]
DEPENDENCIES = ["i2c"]
DOMAIN = "pca9554"
MULTI_CONF = True
CONF_PIN_COUNT = "pin_count"
pca9554_ns = cg.esphome_ns.namespace("pca9554")
CONF_LATCH = "latch"
CONF_PCA9554 = "pca9554"
CONF_PIN_COUNT = "pin_count"
pca9554_ns = cg.esphome_ns.namespace("pca9554")
PCA9554Component = pca9554_ns.class_("PCA9554Component", cg.Component, i2c.I2CDevice)
PCA9554GPIOPin = pca9554_ns.class_(
"PCA9554GPIOPin", cg.GPIOPin, cg.Parented.template(PCA9554Component)
)
CONF_PCA9554 = "pca9554"
CONFIG_SCHEMA = (
class _Device(NamedTuple):
pins: int
# Lowest I2C address, with all address pins low
address: int
# Has the PCAL registers: pull resistors, input latch, drive strength, interrupt mask
pcal: bool = False
DEVICES: dict[str, _Device] = {
"PCA9536": _Device(4, 0x41),
"PCA9534": _Device(8, 0x20),
"PCA9534A": _Device(8, 0x38),
"PCA9538": _Device(8, 0x70),
"PCA9554": _Device(8, 0x20),
"PCA9554A": _Device(8, 0x38),
"TCA9554": _Device(8, 0x20),
"PCA9535": _Device(16, 0x20),
"PCA9555": _Device(16, 0x20),
"TCA9535": _Device(16, 0x20),
"TCA9555": _Device(16, 0x20),
"PCAL9538": _Device(8, 0x70, True),
"PCAL9555": _Device(16, 0x20, True),
}
# The device a legacy `pin_count:` stands for
_LEGACY_DEVICES = {4: "PCA9536", 8: "PCA9554", 16: "PCA9555"}
# Datasheet drive levels and their register codes
DRIVE_STRENGTHS = {0.25: 0, 0.5: 1, 0.75: 2, 1.0: 3}
def _validate_hub(config: ConfigType) -> ConfigType:
count = config.get(CONF_PIN_COUNT)
if (device := config.get(CONF_DEVICE)) is None:
device = config[CONF_DEVICE] = _LEGACY_DEVICES[8 if count is None else count]
elif count is not None and count != DEVICES[device].pins:
raise cv.Invalid(
f"'{CONF_PIN_COUNT}' ({count}) does not match the {DEVICES[device].pins} "
f"pins of device {device}; remove '{CONF_PIN_COUNT}'"
)
config.setdefault(CONF_ADDRESS, DEVICES[device].address)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(PCA9554Component),
cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16),
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
cv.GenerateID(CONF_I2C_ID): cv.use_id(i2c.I2CBus),
# The default depends on the device, so _validate_hub fills it in
cv.Optional(CONF_ADDRESS): cv.i2c_address,
cv.Optional(CONF_DEVICE): cv.one_of(*DEVICES, upper=True),
cv.Optional(CONF_PIN_COUNT): cv.one_of(4, 8, 16),
cv.Optional(CONF_INTERRUPT_PIN): validate_interrupt_pin,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(
i2c.i2c_device_schema(0x20)
) # Note: 0x20 for the non-A part. The PCA9554A parts start at addess 0x38
).extend(cv.COMPONENT_SCHEMA),
_validate_hub,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_pin_count(config[CONF_PIN_COUNT]))
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
device = DEVICES[config[CONF_DEVICE]]
var = await i2c.new_i2c_component(config, device.pins)
if device.pcal:
# Only a build with a PCAL part carries the code for its extra registers
cg.add_define("USE_PCA9554_PCAL")
cg.add(var.set_pcal())
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
def validate_mode(value: ConfigType) -> ConfigType:
"""Reject contradictory modes; whether the device supports a mode is checked in final validation."""
pins.gpio_validate_input_xor_output(value)
if value[CONF_PULLDOWN] and value[CONF_PULLUP]:
raise cv.Invalid("Pull-up and pull-down are mutually exclusive")
return value
PCA9554_PIN_SCHEMA = pins.gpio_base_schema(
PCA9554GPIOPin,
cv.int_range(min=0, max=15),
modes=[CONF_INPUT, CONF_OUTPUT],
mode_validator=pins.gpio_validate_input_xor_output,
modes=[CONF_INPUT, CONF_OUTPUT, CONF_PULLUP, CONF_PULLDOWN],
mode_validator=validate_mode,
).extend(
{
cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component),
cv.Optional(CONF_LATCH, default=False): cv.boolean,
cv.Optional(CONF_DRIVE_STRENGTH): cv.All(
cv.percentage, cv.one_of(*DRIVE_STRENGTHS)
),
}
)
@@ -64,13 +134,36 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema(
def pca9554_pin_final_validate(
pin_config: ConfigType, parent_config: ConfigType
) -> None:
count = parent_config[CONF_PIN_COUNT]
if pin_config[CONF_NUMBER] >= count:
raise cv.Invalid(f"Pin number must be in range 0-{count - 1}")
device_name = parent_config[CONF_DEVICE]
device = DEVICES[device_name]
if pin_config[CONF_NUMBER] >= device.pins:
raise cv.Invalid(f"Pin number must be in range 0-{device.pins - 1}")
mode = pin_config[CONF_MODE]
if pin_config[CONF_LATCH] and mode[CONF_OUTPUT]:
raise cv.Invalid(f"'{CONF_LATCH}' is not valid for an output pin")
if CONF_DRIVE_STRENGTH in pin_config and mode[CONF_INPUT]:
raise cv.Invalid(f"'{CONF_DRIVE_STRENGTH}' is not valid for an input pin")
if device.pcal:
return
for name in (CONF_PULLUP, CONF_PULLDOWN):
if mode[name]:
raise cv.Invalid(f"Pin mode '{name}' is not valid for device {device_name}")
for key in (CONF_LATCH, CONF_DRIVE_STRENGTH):
if pin_config.get(key):
raise cv.Invalid(f"'{key}' is not supported by device {device_name}")
@pins.PIN_SCHEMA_REGISTRY.register(
CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate
)
async def pca9554_pin_to_code(config: ConfigType) -> MockObj:
return await pins.new_parented_pin(config, CONF_PCA9554)
var = await pins.new_parented_pin(config, CONF_PCA9554)
parent = await cg.get_variable(config[CONF_PCA9554])
num = config[CONF_NUMBER]
# Final validation only lets these through on a PCAL hub
if config[CONF_LATCH]:
cg.add(parent.set_latch(num))
if (drive_strength := config.get(CONF_DRIVE_STRENGTH)) is not None:
cg.add(parent.set_drive_strength(num, DRIVE_STRENGTHS[drive_strength]))
return var
+88 -14
View File
@@ -4,15 +4,25 @@
namespace esphome::pca9554 {
// for 16 bit expanders, these addresses will be doubled.
const uint8_t INPUT_REG = 0;
const uint8_t OUTPUT_REG = 1;
const uint8_t INVERT_REG = 2;
const uint8_t CONFIG_REG = 3;
static constexpr uint8_t INPUT_REG = 0;
static constexpr uint8_t OUTPUT_REG = 1;
static constexpr uint8_t INVERT_REG = 2;
static constexpr uint8_t CONFIG_REG = 3;
#ifdef USE_PCA9554_PCAL
// PCAL registers, as offsets from PCAL_BASE spaced by the register width like the ones above.
static constexpr uint8_t PCAL_BASE = 0x40;
static constexpr uint8_t OUTPUT_DRIVE_0 = 0;
static constexpr uint8_t OUTPUT_DRIVE_1 = 1;
static constexpr uint8_t INPUT_LATCH = 2;
static constexpr uint8_t PUPD_ENABLE = 3;
static constexpr uint8_t PUPD_SEL = 4;
static constexpr uint8_t INTERRUPT_MASK = 5;
#endif
ESPHOME_LOG_TAG(TAG, "pca9554");
void PCA9554Component::setup() {
this->reg_width_ = (this->pin_count_ + 7) / 8;
// Test to see if device exists
if (!this->read_inputs_()) {
ESP_LOGE(TAG, "PCA95xx not detected at 0x%02X", this->address_);
@@ -29,6 +39,13 @@ void PCA9554Component::setup() {
// All outputs low
this->output_mask_ = 0;
this->write_register_(OUTPUT_REG, this->output_mask_);
#ifdef USE_PCA9554_PCAL
// The chip keeps these across an MCU reboot
if (this->pcal_ && !this->write_pcal_shadows_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
#endif
// Read the inputs
this->read_inputs_();
ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(),
@@ -45,7 +62,9 @@ void PCA9554Component::setup() {
// For polling mode, loop is re-enabled when pin_mode() registers an input pin
this->disable_loop();
}
void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_loop_soon_any_context(); }
void PCA9554Component::loop() {
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
this->reset_pin_cache_();
@@ -62,6 +81,9 @@ void PCA9554Component::dump_config() {
"PCA9554:\n"
" I/O Pins: %d",
this->pin_count_);
#ifdef USE_PCA9554_PCAL
ESP_LOGCONFIG(TAG, " PCAL registers: %s", YESNO(this->pcal_));
#endif
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_I2C_DEVICE(this)
}
@@ -82,11 +104,16 @@ void PCA9554Component::digital_write_hw(uint8_t pin, bool value) {
} else {
this->output_mask_ &= ~(1 << pin);
}
#ifdef USE_PCA9554_PCAL
if (this->write_register_(OUTPUT_REG, this->output_mask_))
this->retry_pcal_shadows_(); // an output-only hub never reads, so it retries here
#else
this->write_register_(OUTPUT_REG, this->output_mask_);
#endif
}
void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) {
if (flags == gpio::FLAG_INPUT) {
if (flags & gpio::FLAG_INPUT) {
// Clear mode mask bit
this->config_mask_ &= ~(1 << pin);
// Enable polling loop for input pins (not needed for interrupt-driven mode
@@ -94,11 +121,24 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) {
if (this->interrupt_pin_ == nullptr) {
this->enable_loop();
}
} else if (flags == gpio::FLAG_OUTPUT) {
} else if (flags & gpio::FLAG_OUTPUT) {
// Set mode mask bit
this->config_mask_ |= 1 << pin;
}
this->write_register_(CONFIG_REG, ~this->config_mask_);
#ifdef USE_PCA9554_PCAL
if (this->pcal_) {
const uint16_t bit = 1U << pin;
// A masked pin never asserts INT, so only inputs are unmasked
this->update_pcal_register_(INTERRUPT_MASK, this->interrupt_mask_, bit, this->config_mask_ & bit);
const bool pull = flags & (gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN);
// The select bit means nothing while the pull is disabled
if (pull)
this->update_pcal_register_(PUPD_SEL, this->pupd_select_, bit, (flags & gpio::FLAG_PULLUP) ? bit : 0);
this->update_pcal_register_(PUPD_ENABLE, this->pupd_enable_, bit, pull ? bit : 0);
}
#endif
}
bool PCA9554Component::read_inputs_() {
@@ -109,30 +149,64 @@ bool PCA9554Component::read_inputs_() {
return false;
}
this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_);
if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) {
ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_);
const i2c::ErrorCode err = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_);
if (!this->status_comm_result(err == i2c::ERROR_OK)) {
ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) err);
return false;
}
this->input_mask_ = inputs[0];
if (this->reg_width_ == 2) {
this->input_mask_ |= inputs[1] << 8;
}
#ifdef USE_PCA9554_PCAL
// The bus answers again, so retry the settings a failed write left off the chip
this->retry_pcal_shadows_();
#endif
return true;
}
bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) {
bool PCA9554Component::write_address_(uint8_t address, uint16_t value) {
uint8_t outputs[2];
outputs[0] = (uint8_t) value;
outputs[1] = (uint8_t) (value >> 8);
this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_);
if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) {
ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_);
const i2c::ErrorCode err = this->write_register(address, outputs, this->reg_width_);
if (!this->status_comm_result(err == i2c::ERROR_OK)) {
ESP_LOGE(TAG, "write_register(): I2C I/O error: %d", (int) err);
return false;
}
return true;
}
#ifdef USE_PCA9554_PCAL
bool PCA9554Component::write_pcal_register_(uint8_t reg, uint16_t value) {
if (this->write_address_(PCAL_BASE + reg * this->reg_width_, value))
return true;
this->pcal_dirty_ = true;
return false;
}
bool PCA9554Component::write_pcal_shadows_() {
this->pcal_dirty_ = false;
// Every write runs so one failure does not hide the others; each failure sets pcal_dirty_ again
bool ok = this->write_pcal_register_(PUPD_ENABLE, this->pupd_enable_);
ok &= this->write_pcal_register_(PUPD_SEL, this->pupd_select_);
ok &= this->write_pcal_register_(INPUT_LATCH, this->latch_);
ok &= this->write_pcal_register_(INTERRUPT_MASK, this->interrupt_mask_);
ok &= this->write_pcal_register_(OUTPUT_DRIVE_0, static_cast<uint16_t>(this->drive_));
ok &= this->write_pcal_register_(OUTPUT_DRIVE_1, static_cast<uint16_t>(this->drive_ >> (8 * this->reg_width_)));
return ok;
}
void PCA9554Component::update_pcal_register_(uint8_t reg, uint16_t &shadow, uint16_t mask, uint16_t value) {
const uint16_t updated = (shadow & ~mask) | (value & mask);
// The shadows hold the wanted state; an unchanged value is already on the chip unless a write failed
if (updated == shadow && !this->pcal_dirty_)
return;
shadow = updated;
this->write_pcal_register_(reg, shadow);
}
#endif
float PCA9554Component::get_setup_priority() const { return setup_priority::IO; }
} // namespace esphome::pca9554
+48 -11
View File
@@ -12,7 +12,7 @@ class PCA9554Component final : public Component,
public i2c::I2CDevice,
public gpio_expander::CachedGpioExpander<uint16_t, 16> {
public:
PCA9554Component() = default;
explicit PCA9554Component(uint8_t pin_count) : pin_count_(pin_count), reg_width_((pin_count + 7) / 8) {}
/// Check i2c availability and setup masks
void setup() override;
@@ -24,33 +24,70 @@ class PCA9554Component final : public Component,
void dump_config() override;
void set_pin_count(size_t pin_count) { this->pin_count_ = pin_count; }
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
#ifdef USE_PCA9554_PCAL
/// The PCAL parts add pull resistors, input latches, an interrupt mask and drive strength.
void set_pcal() { this->pcal_ = true; }
/// Only updates the shadow, which setup() writes.
void set_latch(uint8_t pin) { this->latch_ |= 1U << pin; }
/// Only updates the shadow, which setup() writes. 0 is a quarter of the full drive strength, 3 the full strength.
void set_drive_strength(uint8_t pin, uint8_t strength) {
const uint8_t shift = 2 * pin;
this->drive_ = (this->drive_ & ~(3UL << shift)) | (uint32_t(strength) << shift);
}
#endif
protected:
static void IRAM_ATTR gpio_intr(PCA9554Component *arg);
bool read_inputs_();
bool write_register_(uint8_t reg, uint16_t value);
bool write_register_(uint8_t reg, uint16_t value) { return this->write_address_(reg * this->reg_width_, value); }
bool write_address_(uint8_t address, uint16_t value);
#ifdef USE_PCA9554_PCAL
bool write_pcal_register_(uint8_t reg, uint16_t value);
/// Write every PCAL shadow to the chip; false when any write failed
bool write_pcal_shadows_();
/// Retry the shadows when an earlier PCAL write failed
void retry_pcal_shadows_() {
if (this->pcal_dirty_)
this->write_pcal_shadows_();
}
/// Changes the masked bits of a shadow and writes the register only when it changed.
void update_pcal_register_(uint8_t reg, uint16_t &shadow, uint16_t mask, uint16_t value);
#endif
// Virtual methods from CachedGpioExpander
bool digital_read_hw(uint8_t pin) override;
bool digital_read_cache(uint8_t pin) override;
void digital_write_hw(uint8_t pin, bool value) override;
/// number of bits the expander has
size_t pin_count_{8};
/// width of registers
size_t reg_width_{1};
InternalGPIOPin *interrupt_pin_{nullptr};
#ifdef USE_PCA9554_PCAL
/// Drive strength shadow, two bits per pin; the low reg_width_ bytes go to OUTPUT_DRIVE_0
uint32_t drive_{0xFFFFFFFF};
#endif
/// Mask for the pin config - 1 means OUTPUT, 0 means INPUT
uint16_t config_mask_{0x00};
/// The mask to write as output state - 1 means HIGH, 0 means LOW
uint16_t output_mask_{0x00};
/// The state of the actual input pin states - 1 means HIGH, 0 means LOW
/// The state of the actual input pins - 1 means HIGH, 0 means LOW
uint16_t input_mask_{0x00};
/// Storage for last I2C error seen
esphome::i2c::ErrorCode last_error_;
InternalGPIOPin *interrupt_pin_{nullptr};
#ifdef USE_PCA9554_PCAL
// Shadows of the PCAL registers. setup() writes them, so every part starts with the pulls disconnected
// whatever its power-on state is (the PCAL9555 powers up with pull-ups enabled).
uint16_t pupd_enable_{0x0000};
uint16_t pupd_select_{0xFFFF};
uint16_t latch_{0x0000};
uint16_t interrupt_mask_{0xFFFF};
#endif
/// number of bits the expander has
const uint8_t pin_count_;
/// width of registers
const uint8_t reg_width_;
#ifdef USE_PCA9554_PCAL
bool pcal_{false};
bool pcal_dirty_{false}; // a PCAL write failed; retried after the next good read or output write
#endif
};
using PCA9554GPIOPin = gpio_expander::ExpanderGPIOPin<PCA9554Component>;
+6 -2
View File
@@ -3,7 +3,12 @@ from typing import Any
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET, CONF_RST_PIN
from esphome.components.const import (
CONF_CRC_ENABLE,
CONF_MODULATION,
CONF_ON_PACKET,
CONF_RST_PIN,
)
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID
from esphome.core import ID, TimePeriod
@@ -28,7 +33,6 @@ CONF_CRC_INITIAL = "crc_initial"
CONF_DEVIATION = "deviation"
CONF_DIO1_PIN = "dio1_pin"
CONF_HW_VERSION = "hw_version"
CONF_MODULATION = "modulation"
CONF_PA_POWER = "pa_power"
CONF_PA_RAMP = "pa_ramp"
CONF_PAYLOAD_LENGTH = "payload_length"
+6 -2
View File
@@ -3,7 +3,12 @@ from typing import Any
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET, CONF_RST_PIN
from esphome.components.const import (
CONF_CRC_ENABLE,
CONF_MODULATION,
CONF_ON_PACKET,
CONF_RST_PIN,
)
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID
from esphome.core import ID
@@ -24,7 +29,6 @@ CONF_BITSYNC = "bitsync"
CONF_CODING_RATE = "coding_rate"
CONF_DEVIATION = "deviation"
CONF_DIO0_PIN = "dio0_pin"
CONF_MODULATION = "modulation"
CONF_PA_PIN = "pa_pin"
CONF_PA_POWER = "pa_power"
CONF_PA_RAMP = "pa_ramp"
+36
View File
@@ -0,0 +1,36 @@
The register sequence and the configuration, distance mode and timing budget tables in this
component come from the VL53L1X Ultra Lite Driver (STSW-IMG009) published by STMicroelectronics.
Please see the top-level LICENSE.txt for information about ESPHome's license. ST's license for
the Ultra Lite Driver is included below.
=================================================================
Copyright (c) 2017, STMicroelectronics - All Rights Reserved
This file is part of VL53L1 Core and is dual licensed, either 'STMicroelectronics Proprietary
license' or 'BSD 3-clause "New" or "Revised" License', at your option.
BSD 3-clause "New" or "Revised" License
Redistribution and use in source and binary forms, with or without modification, are permitted
provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of
conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of
conditions and the following disclaimer in the documentation and/or other materials provided
with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to
endorse or promote products derived from this software without specific prior written
permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+1
View File
@@ -0,0 +1 @@
DOMAIN = "vl53l1x"
+170
View File
@@ -0,0 +1,170 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.i2c import CONF_I2C_ID
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_ENABLE_PIN,
CONF_OFFSET,
CONF_PLATFORM,
ICON_ARROW_EXPAND_VERTICAL,
STATE_CLASS_MEASUREMENT,
UNIT_METER,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import DOMAIN
CODEOWNERS = ["@will-tm"]
DEPENDENCIES = ["i2c"]
vl53l1x_ns = cg.esphome_ns.namespace("vl53l1x")
VL53L1XSensor = vl53l1x_ns.class_(
"VL53L1XSensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice
)
DistanceMode = vl53l1x_ns.enum("DistanceMode")
DISTANCE_MODE_OPTIONS = DistanceMode.options(
"short", "medium", "long", prefix="DISTANCE_MODE_"
)
CONF_DISTANCE_MODE = "distance_mode"
CONF_TIMING_BUDGET = "timing_budget"
CONF_SIGNAL_RATE_LIMIT = "signal_rate_limit"
CONF_ROI_WIDTH = "roi_width"
CONF_ROI_HEIGHT = "roi_height"
CONF_ROI_CENTER = "roi_center"
DEFAULT_ADDRESS = 0x29
# ROI centre the ULD uses for a ROI wider or taller than 10 SPADs
ROI_CENTER_LARGE = 199
# Timing budgets in ms supported by the ST Ultra Lite Driver tables, per distance mode
TIMING_BUDGETS = {
"short": (15, 20, 33, 50, 100, 200, 500),
"medium": (20, 33, 50, 100, 200, 500),
"long": (20, 33, 50, 100, 200, 500),
}
def validate_address(config: ConfigType) -> ConfigType:
if config[CONF_ADDRESS] != DEFAULT_ADDRESS and CONF_ENABLE_PIN not in config:
raise cv.Invalid(
"Address other than 0x29 requires enable_pin definition to allow sensor "
"re-addressing. If you have multiple VL53L1X sensors on the same I2C bus, "
"each sensor must have an enable_pin defined."
)
return config
def validate_timing_budget(config: ConfigType) -> ConfigType:
mode = config[CONF_DISTANCE_MODE]
budgets = TIMING_BUDGETS[mode]
if config[CONF_TIMING_BUDGET].total_milliseconds not in budgets:
raise cv.Invalid(
f"Timing budget for {mode} distance mode must be one of "
f"{', '.join(f'{b}ms' for b in budgets)}",
path=[CONF_TIMING_BUDGET],
)
return config
def validate_roi(config: ConfigType) -> ConfigType:
if (
(center := config.get(CONF_ROI_CENTER)) is not None
and center != ROI_CENTER_LARGE
and (config[CONF_ROI_WIDTH] > 10 or config[CONF_ROI_HEIGHT] > 10)
):
raise cv.Invalid(
f"roi_center must be {ROI_CENTER_LARGE} when roi_width or roi_height "
"is larger than 10",
path=[CONF_ROI_CENTER],
)
return config
def _final_validate(config: ConfigType) -> ConfigType:
# Every sensor boots at 0x29. With several on one bus each needs its own enable_pin so it
# can be held in reset while the others are re-addressed, and none may stay at 0x29, or a
# later sibling boots at the same address and the re-address write reaches both chips.
full_config = fv.full_config.get()
siblings = [
conf
for conf in full_config.get(sensor.DOMAIN, [])
if conf.get(CONF_PLATFORM) == DOMAIN
and conf[CONF_I2C_ID] == config[CONF_I2C_ID]
]
if len(siblings) < 2:
return config
if CONF_ENABLE_PIN not in config:
raise cv.Invalid(
"Every VL53L1X sensor on a shared I2C bus needs an enable_pin",
path=[CONF_ENABLE_PIN],
)
address = config[CONF_ADDRESS]
if address == DEFAULT_ADDRESS:
raise cv.Invalid(
f"VL53L1X sensors sharing an I2C bus must all be re-addressed away from "
f"0x{DEFAULT_ADDRESS:02X}",
path=[CONF_ADDRESS],
)
if sum(conf[CONF_ADDRESS] == address for conf in siblings) > 1:
raise cv.Invalid(
f"Address 0x{address:02X} is used by more than one VL53L1X sensor on this bus",
path=[CONF_ADDRESS],
)
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
VL53L1XSensor,
unit_of_measurement=UNIT_METER,
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
icon=ICON_ARROW_EXPAND_VERTICAL,
)
.extend(
{
cv.Optional(CONF_DISTANCE_MODE, default="long"): cv.enum(
DISTANCE_MODE_OPTIONS, lower=True
),
cv.Optional(
CONF_TIMING_BUDGET, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema,
# Minimum return signal rate in MCPS (mega counts per second)
cv.Optional(CONF_SIGNAL_RATE_LIMIT, default=1.0): cv.float_range(
min=0.0, max=512.0, min_included=False, max_included=False
),
cv.Optional(CONF_ROI_WIDTH, default=16): cv.int_range(min=4, max=16),
cv.Optional(CONF_ROI_HEIGHT, default=16): cv.int_range(min=4, max=16),
# Defaults to the chip's optical centre, or 199 for a ROI larger than 10x10
cv.Optional(CONF_ROI_CENTER): cv.int_range(min=1, max=255),
cv.Optional(CONF_OFFSET, default=0): cv.int_range(min=-1024, max=1023),
}
)
.extend(cv.polling_component_schema("1s"))
.extend(i2c.i2c_device_schema(DEFAULT_ADDRESS)),
validate_address,
validate_timing_budget,
validate_roi,
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
var = await i2c.new_i2c_component(config)
await sensor.register_sensor(var, config)
cg.add(var.set_distance_mode(config[CONF_DISTANCE_MODE]))
cg.add(var.set_timing_budget(config[CONF_TIMING_BUDGET].total_milliseconds))
cg.add(var.set_signal_rate_limit(config[CONF_SIGNAL_RATE_LIMIT]))
cg.add(var.set_roi(config[CONF_ROI_WIDTH], config[CONF_ROI_HEIGHT]))
if (roi_center := config.get(CONF_ROI_CENTER)) is not None:
cg.add(var.set_roi_center(roi_center))
cg.add(var.set_offset(config[CONF_OFFSET]))
await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN))
+436
View File
@@ -0,0 +1,436 @@
// VL53L1X time-of-flight distance sensor, ported from ST's VL53L1X Ultra Lite Driver (ULD).
#include "vl53l1x.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <iterator>
namespace esphome::vl53l1x {
ESPHOME_LOG_TAG(TAG, "vl53l1x");
static constexpr uint8_t DEFAULT_ADDRESS = 0x29;
static constexpr uint16_t MODEL_ID = 0xEACC;
static constexpr uint16_t I2C_DEVICE_ADDRESS = 0x0001;
static constexpr uint16_t VHV_CONFIG_TIMEOUT_MACROP_LOOP_BOUND = 0x0008;
static constexpr uint16_t VHV_CONFIG_INIT = 0x000B;
static constexpr uint16_t ALGO_PART_TO_PART_RANGE_OFFSET_MM = 0x001E;
static constexpr uint16_t MM_CONFIG_INNER_OFFSET_MM = 0x0020;
static constexpr uint16_t MM_CONFIG_OUTER_OFFSET_MM = 0x0022;
static constexpr uint16_t DEFAULT_CONFIG_START = 0x002D;
static constexpr uint16_t GPIO_TIO_HV_STATUS = 0x0031;
static constexpr uint16_t PHASECAL_CONFIG_TIMEOUT_MACROP = 0x004B;
static constexpr uint16_t RANGE_CONFIG_TIMEOUT_MACROP_A_HI = 0x005E;
static constexpr uint16_t RANGE_CONFIG_VCSEL_PERIOD_A = 0x0060;
static constexpr uint16_t RANGE_CONFIG_TIMEOUT_MACROP_B_HI = 0x0061;
static constexpr uint16_t RANGE_CONFIG_VCSEL_PERIOD_B = 0x0063;
static constexpr uint16_t RANGE_CONFIG_MIN_COUNT_RATE_RTN_LIMIT_MCPS = 0x0066;
static constexpr uint16_t RANGE_CONFIG_VALID_PHASE_HIGH = 0x0069;
static constexpr uint16_t SD_CONFIG_WOI_SD0 = 0x0078;
static constexpr uint16_t SD_CONFIG_INITIAL_PHASE_SD0 = 0x007A;
static constexpr uint16_t ROI_CONFIG_USER_ROI_CENTRE_SPAD = 0x007F;
static constexpr uint16_t ROI_CONFIG_USER_ROI_REQUESTED_GLOBAL_XY_SIZE = 0x0080;
static constexpr uint16_t SYSTEM_INTERRUPT_CLEAR = 0x0086;
static constexpr uint16_t SYSTEM_MODE_START = 0x0087;
static constexpr uint16_t RESULT_RANGE_STATUS = 0x0089;
static constexpr uint16_t FIRMWARE_SYSTEM_STATUS = 0x00E5;
static constexpr uint16_t IDENTIFICATION_MODEL_ID = 0x010F;
static constexpr uint16_t ROI_CONFIG_MODE_ROI_CENTRE_SPAD = 0x013E;
static constexpr uint8_t MODE_START_SINGLE_SHOT = 0x10;
// ROI centre for a ROI wider or taller than 10 SPADs (ULD SetROI)
static constexpr uint8_t ROI_CENTER_LARGE = 199;
// RESULT_RANGE_STATUS through RESULT_FINAL_CROSSTALK_CORRECTED_RANGE_MM_SD0 (0x0096)
static constexpr size_t RESULT_LENGTH = 15;
// Raw range status that the ULD reports as a valid range
static constexpr uint8_t RANGE_STATUS_VALID = 9;
// ULD VL51L1X_DEFAULT_CONFIGURATION, registers 0x2D to 0x87
static const uint8_t DEFAULT_CONFIG[] PROGMEM = {
0x00, // 0x2D: reserved
0x01, // 0x2E: I2C pads, bit 0 set for pull-up at AVDD
0x01, // 0x2F: GPIO pads, bit 0 set for pull-up at AVDD
0x01, // 0x30: GPIO_HV_MUX_CTRL (bit 4 clear: data ready reads high)
0x02, // 0x31: reserved
0x00, // 0x32: reserved
0x02, // 0x33: reserved
0x08, // 0x34: reserved
0x00, // 0x35: reserved
0x08, // 0x36: reserved
0x10, // 0x37: reserved
0x01, // 0x38: reserved
0x01, // 0x39: reserved
0x00, // 0x3A: reserved
0x00, // 0x3B: reserved
0x00, // 0x3C: reserved
0x00, // 0x3D: reserved
0xFF, // 0x3E: reserved
0x00, // 0x3F: reserved
0x0F, // 0x40: reserved
0x00, // 0x41: reserved
0x00, // 0x42: reserved
0x00, // 0x43: reserved
0x00, // 0x44: reserved
0x00, // 0x45: reserved
0x20, // 0x46: CAL_CONFIG_SPAD_DC_TARGET_COUNT
0x0B, // 0x47: CAL_CONFIG_VCSEL_START
0x00, // 0x48: reserved
0x00, // 0x49: reserved
0x02, // 0x4A: reserved
0x14, // 0x4B: PHASECAL_CONFIG_TIMEOUT_MACROP
0x21, // 0x4C: reserved
0x00, // 0x4D: PHASECAL_CONFIG_OVERRIDE
0x00, // 0x4E: reserved
0x05, // 0x4F: DSS_CONFIG_ROI_MODE_CONTROL
0x00, // 0x50: SYSTEM_THRESH_RATE_HIGH
0x00, // 0x51: SYSTEM_THRESH_RATE_HIGH
0x00, // 0x52: SYSTEM_THRESH_RATE_LOW
0x00, // 0x53: SYSTEM_THRESH_RATE_LOW
0xC8, // 0x54: DSS_CONFIG_MANUAL_EFFECTIVE_SPADS_SELECT (200 << 8)
0x00, // 0x55: DSS_CONFIG_MANUAL_EFFECTIVE_SPADS_SELECT
0x00, // 0x56: reserved
0x38, // 0x57: DSS_CONFIG_APERTURE_ATTENUATION
0xFF, // 0x58: reserved
0x01, // 0x59: reserved
0x00, // 0x5A: reserved
0x08, // 0x5B: reserved
0x00, // 0x5C: reserved
0x00, // 0x5D: reserved
0x01, // 0x5E: RANGE_CONFIG_TIMEOUT_MACROP_A_HI
0xCC, // 0x5F: RANGE_CONFIG_TIMEOUT_MACROP_A_LO
0x0F, // 0x60: RANGE_CONFIG_VCSEL_PERIOD_A
0x01, // 0x61: RANGE_CONFIG_TIMEOUT_MACROP_B_HI
0xF1, // 0x62: RANGE_CONFIG_TIMEOUT_MACROP_B_LO
0x0D, // 0x63: RANGE_CONFIG_VCSEL_PERIOD_B
0x01, // 0x64: RANGE_CONFIG_SIGMA_THRESH
0x68, // 0x65: RANGE_CONFIG_SIGMA_THRESH (360)
0x00, // 0x66: RANGE_CONFIG_MIN_COUNT_RATE_RTN_LIMIT_MCPS
0x80, // 0x67: RANGE_CONFIG_MIN_COUNT_RATE_RTN_LIMIT_MCPS (1.0 MCPS)
0x08, // 0x68: reserved
0xB8, // 0x69: RANGE_CONFIG_VALID_PHASE_HIGH
0x00, // 0x6A: reserved
0x00, // 0x6B: reserved
0x00, // 0x6C: SYSTEM_INTERMEASUREMENT_PERIOD
0x00, // 0x6D: SYSTEM_INTERMEASUREMENT_PERIOD
0x0F, // 0x6E: SYSTEM_INTERMEASUREMENT_PERIOD
0x89, // 0x6F: SYSTEM_INTERMEASUREMENT_PERIOD
0x00, // 0x70: reserved
0x00, // 0x71: SYSTEM_THRESH_HIGH
0x00, // 0x72: SYSTEM_THRESH_HIGH
0x00, // 0x73: SYSTEM_THRESH_LOW
0x00, // 0x74: SYSTEM_THRESH_LOW
0x00, // 0x75: reserved
0x00, // 0x76: reserved
0x01, // 0x77: reserved
0x0F, // 0x78: SD_CONFIG_WOI_SD0
0x0D, // 0x79: SD_CONFIG_WOI_SD1
0x0E, // 0x7A: SD_CONFIG_INITIAL_PHASE_SD0
0x0E, // 0x7B: SD_CONFIG_INITIAL_PHASE_SD1
0x00, // 0x7C: reserved
0x00, // 0x7D: reserved
0x02, // 0x7E: reserved
0xC7, // 0x7F: ROI_CONFIG_USER_ROI_CENTRE_SPAD
0xFF, // 0x80: ROI_CONFIG_USER_ROI_REQUESTED_GLOBAL_XY_SIZE
0x9B, // 0x81: SYSTEM_SEQUENCE_CONFIG
0x00, // 0x82: reserved
0x00, // 0x83: reserved
0x00, // 0x84: reserved
0x01, // 0x85: reserved
0x00, // 0x86: SYSTEM_INTERRUPT_CLEAR
0x00, // 0x87: SYSTEM_MODE_START
};
// ULD SetDistanceMode values, indexed by DistanceMode. The ULD only knows short and long; the
// medium row is the standard ranging preset of ST's full VL53L1 API.
struct DistanceModeConfig {
uint8_t phasecal_timeout;
uint8_t vcsel_period_a;
uint8_t vcsel_period_b;
uint8_t valid_phase_high;
uint16_t woi_sd0;
uint16_t initial_phase_sd0;
};
static const DistanceModeConfig DISTANCE_MODE_CONFIGS[] PROGMEM = {
{0x14, 0x07, 0x05, 0x38, 0x0705, 0x0606}, // short
{0x0B, 0x0B, 0x09, 0x78, 0x0B09, 0x0A0A}, // medium
{0x0A, 0x0F, 0x0D, 0xB8, 0x0F0D, 0x0E0E}, // long
};
// ULD SetTimingBudgetInMs values
struct TimingBudgetConfig {
uint16_t budget_ms;
uint16_t macrop_a;
uint16_t macrop_b;
};
static const TimingBudgetConfig SHORT_TIMING_BUDGETS[] PROGMEM = {
{15, 0x001D, 0x0027}, {20, 0x0051, 0x006E}, {33, 0x00D6, 0x006E}, {50, 0x01AE, 0x01E8},
{100, 0x02E1, 0x0388}, {200, 0x03E1, 0x0496}, {500, 0x0591, 0x05C1},
};
static const TimingBudgetConfig LONG_TIMING_BUDGETS[] PROGMEM = {
{20, 0x001E, 0x0022}, {33, 0x0060, 0x006E}, {50, 0x00AD, 0x00C6},
{100, 0x01CC, 0x01EA}, {200, 0x02D9, 0x02F8}, {500, 0x048F, 0x04A4},
};
// The ULD has no medium table: these are the long values scaled by the VCSEL period ratio
// (16/12 for A, 14/10 for B), since the macro period grows with the period and the timeouts
// count macro periods. The data-ready poll tolerates the small error this leaves.
static const TimingBudgetConfig MEDIUM_TIMING_BUDGETS[] PROGMEM = {
{20, 0x0028, 0x0030}, {33, 0x0080, 0x009A}, {50, 0x00E7, 0x018B},
{100, 0x0288, 0x02A4}, {200, 0x0390, 0x03AD}, {500, 0x04BE, 0x04E5},
};
// Copies the entry for budget_ms out of flash; false when the mode has no such budget.
static bool find_timing_budget(DistanceMode mode, uint16_t budget_ms, TimingBudgetConfig *out) {
const TimingBudgetConfig *table = LONG_TIMING_BUDGETS;
size_t count = std::size(LONG_TIMING_BUDGETS);
if (mode == DISTANCE_MODE_SHORT) {
table = SHORT_TIMING_BUDGETS;
count = std::size(SHORT_TIMING_BUDGETS);
} else if (mode == DISTANCE_MODE_MEDIUM) {
table = MEDIUM_TIMING_BUDGETS;
count = std::size(MEDIUM_TIMING_BUDGETS);
}
for (size_t i = 0; i < count; i++) {
progmem_memcpy(out, &table[i], sizeof(TimingBudgetConfig));
if (out->budget_ms == budget_ms)
return true;
}
return false;
}
VL53L1XSensor *VL53L1XSensor::first_sensor = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
VL53L1XSensor::VL53L1XSensor() {
this->next_ = VL53L1XSensor::first_sensor;
VL53L1XSensor::first_sensor = this;
}
void VL53L1XSensor::setup() {
// The first sensor to set up holds every sensor with an enable pin in reset, so each one
// comes back at the default address only when its own setup() releases it.
if (VL53L1XSensor::first_sensor != nullptr) {
for (auto *other = VL53L1XSensor::first_sensor; other != nullptr; other = other->next_) {
if (other->enable_pin_ != nullptr) {
other->enable_pin_->setup();
other->enable_pin_->digital_write(false);
}
}
VL53L1XSensor::first_sensor = nullptr;
}
const uint8_t final_address = this->address_;
if (this->enable_pin_ != nullptr) {
this->enable_pin_->digital_write(true);
delay(2);
}
this->set_i2c_address(DEFAULT_ADDRESS);
if (!this->wait_for_boot_()) {
this->fail_at_default_address_(LOG_STR("Boot timeout"));
return;
}
uint16_t model_id;
if (!this->read_word16_(IDENTIFICATION_MODEL_ID, &model_id)) {
this->fail_at_default_address_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (model_id != MODEL_ID) {
ESP_LOGE(TAG, "Wrong model ID 0x%04X", model_id);
this->fail_at_default_address_(LOG_STR("Wrong model ID"));
return;
}
if (final_address != DEFAULT_ADDRESS) {
if (!this->write_byte16_(I2C_DEVICE_ADDRESS, final_address & 0x7F)) {
this->fail_at_default_address_(LOG_STR("Setting address failed"));
return;
}
this->set_i2c_address(final_address);
}
if (!this->configure_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// The ULD ranges once during init to calibrate; loop() finishes that from the first result.
if (!this->start_ranging_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->measurement_start_ = millis();
}
// A sensor that failed before it was re-addressed is still answering at 0x29, which would
// collide with the next sibling's bring-up, so hold it in reset.
void VL53L1XSensor::fail_at_default_address_(const LogString *message) {
if (this->enable_pin_ != nullptr)
this->enable_pin_->digital_write(false);
this->mark_failed(message);
}
bool VL53L1XSensor::wait_for_boot_() {
for (uint8_t i = 0; i < 100; i++) {
uint8_t status;
if (this->read_byte16_(FIRMWARE_SYSTEM_STATUS, &status) && (status & 0x01))
return true;
delay(1);
}
return false;
}
bool VL53L1XSensor::configure_() {
TimingBudgetConfig budget;
if (!find_timing_budget(this->distance_mode_, this->budget_ms_, &budget)) {
ESP_LOGE(TAG, "Unsupported timing budget %u ms", this->budget_ms_);
return false;
}
DistanceModeConfig mode;
progmem_memcpy(&mode, &DISTANCE_MODE_CONFIGS[this->distance_mode_], sizeof(mode));
uint8_t config[sizeof(DEFAULT_CONFIG)];
progmem_memcpy(config, DEFAULT_CONFIG, sizeof(config));
// ULD SetROI: a ROI of at most 10x10 is centred on the part's calibrated optical centre.
uint8_t center = this->roi_center_;
if (center == 0) {
center = ROI_CENTER_LARGE;
if (this->roi_width_ <= 10 && this->roi_height_ <= 10 &&
!this->read_byte16_(ROI_CONFIG_MODE_ROI_CENTRE_SPAD, &center)) {
return false;
}
}
// The register address auto-increments, so the whole table goes in one write.
return this->write_register16(DEFAULT_CONFIG_START, config, sizeof(config)) == i2c::ERROR_OK &&
// SetDistanceMode
this->write_byte16_(PHASECAL_CONFIG_TIMEOUT_MACROP, mode.phasecal_timeout) &&
this->write_byte16_(RANGE_CONFIG_VCSEL_PERIOD_A, mode.vcsel_period_a) &&
this->write_byte16_(RANGE_CONFIG_VCSEL_PERIOD_B, mode.vcsel_period_b) &&
this->write_byte16_(RANGE_CONFIG_VALID_PHASE_HIGH, mode.valid_phase_high) &&
this->write_word16_(SD_CONFIG_WOI_SD0, mode.woi_sd0) &&
this->write_word16_(SD_CONFIG_INITIAL_PHASE_SD0, mode.initial_phase_sd0) &&
// SetTimingBudgetInMs
this->write_word16_(RANGE_CONFIG_TIMEOUT_MACROP_A_HI, budget.macrop_a) &&
this->write_word16_(RANGE_CONFIG_TIMEOUT_MACROP_B_HI, budget.macrop_b) &&
// SetOffset
this->write_word16_(ALGO_PART_TO_PART_RANGE_OFFSET_MM, static_cast<uint16_t>(this->offset_mm_ * 4)) &&
this->write_word16_(MM_CONFIG_INNER_OFFSET_MM, 0) && this->write_word16_(MM_CONFIG_OUTER_OFFSET_MM, 0) &&
// SetROI and SetROICenter
this->write_byte16_(ROI_CONFIG_USER_ROI_CENTRE_SPAD, center) &&
this->write_byte16_(ROI_CONFIG_USER_ROI_REQUESTED_GLOBAL_XY_SIZE,
static_cast<uint8_t>(((this->roi_height_ - 1) << 4) | (this->roi_width_ - 1))) &&
// SetSignalThreshold
this->write_word16_(RANGE_CONFIG_MIN_COUNT_RATE_RTN_LIMIT_MCPS, this->signal_rate_limit_);
}
bool VL53L1XSensor::start_ranging_() { return this->write_byte16_(SYSTEM_MODE_START, MODE_START_SINGLE_SHOT); }
void VL53L1XSensor::update() {
if (this->is_in_loop_state()) {
ESP_LOGD(TAG, "Measurement still running");
return;
}
if (!this->start_ranging_()) {
ESP_LOGW(TAG, "Starting measurement failed");
this->status_set_warning();
this->publish_state(NAN);
return;
}
this->measurement_start_ = App.get_loop_component_start_time();
this->enable_loop();
}
void VL53L1XSensor::loop() {
const uint32_t elapsed = App.get_loop_component_start_time() - this->measurement_start_;
if (elapsed < this->budget_ms_)
return;
uint8_t gpio_status;
if (!this->read_byte16_(GPIO_TIO_HV_STATUS, &gpio_status) || (gpio_status & 0x01) == 0) {
if (elapsed > static_cast<uint32_t>(this->budget_ms_) * 2 + 100) {
ESP_LOGW(TAG, "Measurement timed out");
this->status_set_warning();
this->publish_state(NAN);
this->disable_loop();
}
return;
}
uint8_t result[RESULT_LENGTH];
if (this->read_register16(RESULT_RANGE_STATUS, result, sizeof(result)) != i2c::ERROR_OK ||
!this->write_byte16_(SYSTEM_INTERRUPT_CLEAR, 0x01)) {
ESP_LOGW(TAG, "Reading result failed");
this->status_set_warning();
this->publish_state(NAN);
this->disable_loop();
return;
}
this->disable_loop();
if (this->first_result_) {
// ULD SensorInit: after the first ranging, start VHV calibration from the previous temperature.
if (!this->write_byte16_(VHV_CONFIG_TIMEOUT_MACROP_LOOP_BOUND, 0x09) || !this->write_byte16_(VHV_CONFIG_INIT, 0)) {
this->status_set_warning();
return;
}
this->first_result_ = false;
return;
}
this->status_clear_warning();
const uint8_t range_status = result[0] & 0x1F;
if (range_status != RANGE_STATUS_VALID) {
ESP_LOGD(TAG, "Range status %u", range_status);
this->publish_state(NAN);
return;
}
const uint16_t distance_mm = encode_uint16(result[13], result[14]);
this->publish_state(distance_mm / 1000.0f);
}
// Indexed by DistanceMode
PROGMEM_STRING_TABLE(DistanceModeStrings, "short", "medium", "long");
void VL53L1XSensor::dump_config() {
LOG_SENSOR("", "VL53L1X", this);
LOG_UPDATE_INTERVAL(this);
LOG_I2C_DEVICE(this);
LOG_PIN(" Enable Pin: ", this->enable_pin_);
ESP_LOGCONFIG(TAG,
" Distance Mode: %s\n"
" Timing Budget: %u ms\n"
" ROI: %ux%u\n"
" Offset: %d mm",
LOG_STR_ARG(DistanceModeStrings::get_log_str(this->distance_mode_, DistanceModeStrings::LAST_INDEX)),
this->budget_ms_, this->roi_width_, this->roi_height_, this->offset_mm_);
if (this->roi_center_ != 0) {
ESP_LOGCONFIG(TAG, " ROI Center: %u", this->roi_center_);
}
}
bool VL53L1XSensor::write_byte16_(uint16_t reg, uint8_t value) {
return this->write_register16(reg, &value, 1) == i2c::ERROR_OK;
}
bool VL53L1XSensor::write_word16_(uint16_t reg, uint16_t value) {
const uint8_t data[2] = {static_cast<uint8_t>(value >> 8), static_cast<uint8_t>(value)};
return this->write_register16(reg, data, 2) == i2c::ERROR_OK;
}
bool VL53L1XSensor::read_byte16_(uint16_t reg, uint8_t *value) {
return this->read_register16(reg, value, 1) == i2c::ERROR_OK;
}
bool VL53L1XSensor::read_word16_(uint16_t reg, uint16_t *value) {
uint8_t data[2];
if (this->read_register16(reg, data, 2) != i2c::ERROR_OK)
return false;
*value = encode_uint16(data[0], data[1]);
return true;
}
} // namespace esphome::vl53l1x
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
#include "esphome/core/gpio.h"
namespace esphome::vl53l1x {
// Values index the distance mode and timing budget tables in the .cpp.
enum DistanceMode : uint8_t {
DISTANCE_MODE_SHORT = 0,
DISTANCE_MODE_MEDIUM = 1,
DISTANCE_MODE_LONG = 2,
};
class VL53L1XSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice {
public:
VL53L1XSensor();
void setup() override;
void loop() override;
void update() override;
void dump_config() override;
void set_enable_pin(GPIOPin *pin) { this->enable_pin_ = pin; }
void set_distance_mode(DistanceMode mode) { this->distance_mode_ = mode; }
void set_timing_budget(uint16_t budget_ms) { this->budget_ms_ = budget_ms; }
// Rate in MCPS, stored as the register's 9.7 fixed point value.
void set_signal_rate_limit(float limit_mcps) { this->signal_rate_limit_ = static_cast<uint16_t>(limit_mcps * 128); }
void set_offset(int16_t offset_mm) { this->offset_mm_ = offset_mm; }
void set_roi(uint8_t width, uint8_t height) {
this->roi_width_ = width;
this->roi_height_ = height;
}
void set_roi_center(uint8_t center) { this->roi_center_ = center; }
protected:
void fail_at_default_address_(const LogString *message);
bool wait_for_boot_();
bool configure_();
bool start_ranging_();
bool write_byte16_(uint16_t reg, uint8_t value);
bool write_word16_(uint16_t reg, uint16_t value);
bool read_byte16_(uint16_t reg, uint8_t *value);
bool read_word16_(uint16_t reg, uint16_t *value);
// Every instance, so the first setup() can hold all sensors with an enable pin in reset.
static VL53L1XSensor *first_sensor; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
VL53L1XSensor *next_{nullptr};
GPIOPin *enable_pin_{nullptr};
uint32_t measurement_start_{0};
uint16_t budget_ms_{50};
uint16_t signal_rate_limit_{128};
int16_t offset_mm_{0};
uint8_t roi_width_{16};
uint8_t roi_height_{16};
uint8_t roi_center_{0}; // 0 selects the centre the way the ULD does
DistanceMode distance_mode_{DISTANCE_MODE_LONG};
bool first_result_{true};
};
} // namespace esphome::vl53l1x
+1
View File
@@ -257,6 +257,7 @@
#define USE_OTA_STATE_LISTENER
#define USE_OUTPUT
#define USE_OUTPUT_FLOAT_POWER_SCALING
#define USE_PCA9554_PCAL
#define USE_POWER_SUPPLY
// Only defined by key-lookup preference backends; the slot-based platforms
// (esp8266, rp2040) never set it in generated builds, and their preferences
@@ -0,0 +1,199 @@
"""Tests for the epaper_spi source file filtering."""
from collections.abc import Callable
from pathlib import Path
import re
from typing import Any
import pytest
from esphome.components import epaper_spi
from esphome.components.epaper_spi import (
BASE_SOURCE_FILE,
DOMAIN,
FILTER_SOURCE_FILES,
register_sources,
)
from esphome.components.epaper_spi.models import EpaperModel
from esphome.config import iter_components
from esphome.core import CORE
COMPONENT_DIR = Path(epaper_spi.__file__).parent
TWO_MODELS_YAML = """
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
psram:
mode: octal
speed: 80MHz
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
display:
- platform: epaper_spi
id: display_a
model: seeed-reterminal-sticky-gray4
- platform: epaper_spi
id: display_b
model: seeed-reterminal-e1001
"""
def _resource_names() -> set[str]:
"""Names of the epaper_spi sources the build would copy, merged over all manifests."""
return {
res.resource
for _, component in iter_components(CORE.config)
for res in component.resources
if res.package == "esphome.components.epaper_spi"
}
def _cpp_names(names: set[str]) -> set[str]:
return {name for name in names if name.endswith(".cpp")}
FIX_HINT = (
"Set source_files on the model in esphome/components/epaper_spi/models/<...> to include "
"its class's .cpp and every parent class's .cpp, excluding epaper_spi.cpp."
)
CLASS_RE = re.compile(r"^class\s+(\w+)\s+(?:final\s+)?:\s*public\s+(\w+)", re.MULTILINE)
def _class_table() -> dict[str, tuple[str, str]]:
"""Map each driver class to its header stem and parent class, read from the headers."""
table: dict[str, tuple[str, str]] = {}
for header in COMPONENT_DIR.glob("*.h"):
for name, parent in CLASS_RE.findall(header.read_text()):
table[name] = (header.stem, parent)
return table
def _expected_sources(class_name: str, table: dict[str, tuple[str, str]]) -> set[str]:
"""The .cpp files of a class and its parents, up to but excluding epaper_spi.cpp."""
sources: set[str] = set()
current = class_name
while current != "EPaperBase":
assert current in table, (
f"No 'class {current} : public <Parent>' declaration found in the headers "
f"in {COMPONENT_DIR}. {FIX_HINT}"
)
stem, current = table[current]
sources.add(f"{stem}.cpp")
return sources - {BASE_SOURCE_FILE}
@pytest.mark.parametrize("model_name", sorted(EpaperModel.models))
def test_model_source_files_match_headers(model_name: str) -> None:
"""A model must list exactly the sources its class hierarchy needs."""
model = EpaperModel.models[model_name]
expected = _expected_sources(model.class_name, _class_table())
assert expected == set(model.source_files), (
f"Model {model_name} ({model.class_name}) needs {sorted(expected)} but lists "
f"{sorted(model.source_files)}. {FIX_HINT}"
)
def test_every_source_file_is_claimed_by_a_model() -> None:
on_disk = {p.name for p in COMPONENT_DIR.glob("*.cpp")} - {BASE_SOURCE_FILE}
claimed = {f for m in EpaperModel.models.values() for f in m.source_files}
assert not on_disk - claimed, (
f"Source files no model lists: {sorted(on_disk - claimed)}. {FIX_HINT}"
)
def test_listed_source_files_exist() -> None:
on_disk = {p.name for p in COMPONENT_DIR.glob("*.cpp")}
listed = {f for m in EpaperModel.models.values() for f in m.source_files}
assert not listed - on_disk, (
f"Listed source files that do not exist: {sorted(listed - on_disk)}. {FIX_HINT}"
)
def test_missing_class_declaration_is_reported() -> None:
with pytest.raises(AssertionError, match="No 'class EPaperGone"):
_expected_sources("EPaperGone", _class_table())
def test_extend_keeps_class_name_and_source_files() -> None:
class _Model(EpaperModel):
def __init__(self, name: str, **kwargs: Any) -> None:
super().__init__(name, "EPaperX", **kwargs)
base = _Model("test-extend-base", source_files=("x.cpp",))
try:
assert base.extend("test-extend-child").source_files == ("x.cpp",)
assert EpaperModel.models["TEST-EXTEND-CHILD"].class_name == "EPaperX"
override = base.extend("test-extend-over", source_files=("y.cpp",))
assert override.source_files == ("y.cpp",)
finally:
for key in ("TEST-EXTEND-BASE", "TEST-EXTEND-CHILD", "TEST-EXTEND-OVER"):
EpaperModel.models.pop(key, None)
def test_filter_excludes_nothing_when_no_sources_recorded() -> None:
assert FILTER_SOURCE_FILES() == []
def test_filter_excludes_unrecorded_sources() -> None:
register_sources(["epaper_spi_jd79660.cpp"])
excluded = FILTER_SOURCE_FILES()
assert excluded == sorted(excluded)
assert "epaper_spi_jd79660.cpp" not in excluded
assert BASE_SOURCE_FILE not in excluded
assert "epaper_spi_mono.cpp" in excluded
def test_filter_data_lives_in_core_data() -> None:
register_sources(["epaper_spi_mono.cpp"])
assert CORE.data[DOMAIN].needed_sources == {"epaper_spi_mono.cpp"}
def test_sources_filtered_via_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The real to_code records the model, and every manifest honours the filter."""
generate_main(component_config_path("ssd1677_gray4_test.yaml"))
assert _cpp_names(_resource_names()) == {
BASE_SOURCE_FILE,
"epaper_spi_ssd1677_gray4.cpp",
"epaper_spi_ssd1677.cpp",
"epaper_spi_mono.cpp",
}
def test_sources_filtered_for_two_models(
generate_main: Callable[[str | Path], str],
tmp_path: Path,
) -> None:
config_path = tmp_path / "two_models.yaml"
config_path.write_text(TWO_MODELS_YAML)
generate_main(config_path)
assert _cpp_names(_resource_names()) == {
BASE_SOURCE_FILE,
"epaper_spi_ssd1677_gray4.cpp",
"epaper_spi_ssd1677.cpp",
"epaper_spi_mono.cpp",
"epaper_spi_ultrachip.cpp",
"epaper_spi_uc8179.cpp",
}
+72
View File
@@ -0,0 +1,72 @@
STARTFONT 2.1
FONT -Test-Cell-Medium-R-Normal--8-80-75-75-C-60-ISO10646-1
SIZE 8 75 75
FONTBOUNDINGBOX 6 8 0 -2
STARTPROPERTIES 4
PIXEL_SIZE 8
FONT_ASCENT 6
FONT_DESCENT 2
SPACING "C"
ENDPROPERTIES
CHARS 4
STARTCHAR space
ENCODING 32
SWIDTH 720 0
DWIDTH 6 0
BBX 6 8 0 -2
BITMAP
00
00
00
00
00
00
00
00
ENDCHAR
STARTCHAR X
ENCODING 88
SWIDTH 720 0
DWIDTH 6 0
BBX 6 8 0 -2
BITMAP
84
00
00
30
00
00
00
84
ENDCHAR
STARTCHAR x
ENCODING 120
SWIDTH 720 0
DWIDTH 6 0
BBX 6 8 0 -2
BITMAP
00
00
50
20
50
00
00
00
ENDCHAR
STARTCHAR bar
ENCODING 124
SWIDTH 720 0
DWIDTH 6 0
BBX 6 8 0 -2
BITMAP
20
20
20
20
20
20
20
20
ENDCHAR
ENDFONT
@@ -0,0 +1,14 @@
esphome:
name: test
host:
font:
- file: ../cell6x8.bdf
id: cell
size: 8
glyphs: " Xx|"
- file: ../NotoSans-Regular.ttf
id: noto
size: 12
glyphs: " AB"
+84
View File
@@ -10,6 +10,7 @@ from pathlib import Path
import re
from unittest.mock import MagicMock, patch
from freetype import Face
import pytest
from esphome.components.font import (
@@ -19,8 +20,10 @@ from esphome.components.font import (
CONF_IGNORE_MISSING_GLYPHS,
CONF_RAW_GLYPH_ID,
FONT_CACHE,
GlyphInfo,
flatten,
glyph_comparator,
glyph_to_glyphinfo,
to_code,
validate_font_config,
)
@@ -345,3 +348,84 @@ def test_glyph_table_is_progmem(
assert re.search(r"static constexpr font::Glyph \w+\[\] PROGMEM = ", main_cpp)
assert "static const font::Glyph" not in main_cpp
# ---------- glyph cropping ----------
BDF_PATH = FONT_DIR / "cell6x8.bdf"
def _glyph_table(main_cpp: str, glyph_table_id: str) -> dict[int, tuple[int, ...]]:
"""Map each codepoint in a generated glyph table to (advance, offset_x, offset_y, width, height)."""
table = re.search(
rf"font::Glyph {glyph_table_id}\[\] PROGMEM = \{{(.*)\}};", main_cpp
).group(1)
return {
int(code): tuple(int(x) for x in rest.split(", "))
for code, rest in re.findall(r"\{(\d+), \([^)]*\), ([-\d, ]+)\}", table)
}
def test_bitmap_font_glyphs_are_cropped(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Bitmap font glyphs drop blank rows and columns; outline glyphs are unchanged."""
main_cpp = generate_main(component_config_path("bitmap_crop.yaml"))
# Each 6x8 cell is reduced to its set pixels, and the offsets move to match
assert _glyph_table(main_cpp, "font_glyph_id") == {
32: (6, 0, 0, 0, 0), # blank: no pixels stored
88: (6, 0, 0, 6, 8), # touches every edge of the cell: no crop
120: (6, 1, 2, 3, 3), # blank border all round
124: (6, 2, 0, 1, 8), # full height column
}
# 6 bytes for "X", 2 for the cropped "x", 1 for "|"
assert (
"uint8_t_id[] PROGMEM = "
"{0x84, 0x00, 0x0C, 0x00, 0x00, 0x21, 0xAA, 0x80, 0xFF};" in main_cpp
)
# xheight and capheight still measure the uncropped cell
assert "font::Font(font_glyph_id, 4, 6, 8, 2, 8, 8, 1);" in main_cpp
# FreeType already crops outline glyphs, and a blank one stays 1x1
assert _glyph_table(main_cpp, "font_glyph_id_2") == {
32: (3, 0, 12, 1, 1),
65: (8, 0, 4, 8, 9),
66: (8, 1, 4, 6, 9),
}
def _render(glyph: GlyphInfo, bpp: int) -> dict[tuple[int, int], int]:
"""Decode a packed glyph bitmap into its non-zero pixels at absolute positions."""
pixels = {}
pos = 0
for y in range(glyph.height):
for x in range(glyph.width):
value = 0
for _ in range(bpp):
bit = glyph.bitmap_data[pos // 8] >> (7 - pos % 8) & 1
value = value << 1 | bit
pos += 1
if value:
pixels[(x + glyph.offset_x, y + glyph.offset_y)] = value
return pixels
@pytest.mark.parametrize("bpp", [1, 2, 4, 8])
@pytest.mark.parametrize(
("path", "size"),
[(BDF_PATH, 8), (FONT_PATH, 12)],
ids=["bitmap", "outline"],
)
def test_cropped_glyphs_render_identically(path: Path, size: int, bpp: int) -> None:
"""Cropping only drops blank pixels, so every glyph renders the same as before."""
face = Face(str(path))
for char in " Xx|AB":
if not face.get_char_index(char):
continue
full = glyph_to_glyphinfo(char, face, size, bpp, crop=False)
cropped = glyph_to_glyphinfo(char, face, size, bpp)
assert _render(cropped, bpp) == _render(full, bpp)
assert cropped.advance == full.advance
assert len(cropped.bitmap_data) <= len(full.bitmap_data)
@@ -0,0 +1,68 @@
esphome:
name: test
esp32:
board: esp32dev
i2c:
id: i2c_bus
sda: GPIO21
scl: GPIO22
pca9554:
- id: plain_hub
address: 0x20
device: PCA9554
- id: pcal_hub
address: 0x70
device: PCAL9555
- id: pca9538_hub
device: PCA9538
binary_sensor:
- platform: gpio
id: plain_input
name: Plain input
pin:
pca9554: plain_hub
number: 1
mode: INPUT
- platform: gpio
id: pcal_input
name: PCAL input
pin:
pca9554: pcal_hub
number: 9
mode:
input: true
pullup: true
latch: true
switch:
- platform: gpio
id: pcal_weak_output
pin:
pca9554: pcal_hub
number: 10
mode: OUTPUT
drive_strength: 25%
- platform: gpio
id: pcal_full_output
pin:
pca9554: pcal_hub
number: 11
mode: OUTPUT
drive_strength: 100%
- platform: gpio
id: pcal_mid_output
pin:
pca9554: pcal_hub
number: 12
mode: OUTPUT
drive_strength: 75%
- platform: gpio
id: pca9538_output
pin:
pca9554: pca9538_hub
number: 7
mode: OUTPUT
@@ -0,0 +1,126 @@
"""Tests for the PCA9554 device table and its pin validation."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.pca9554 import (
CONFIG_SCHEMA,
PCA9554_PIN_SCHEMA,
pca9554_pin_final_validate,
)
from esphome.types import ConfigType
def _hub(**overrides: object) -> ConfigType:
return CONFIG_SCHEMA({"id": "hub", **overrides})
def _pin(number: int, mode: dict, **overrides: object) -> ConfigType:
return PCA9554_PIN_SCHEMA(
{"pca9554": "hub", "number": number, "mode": mode, **overrides}
)
def test_pin_count_must_match_the_device() -> None:
"""A stale pin_count next to a device would address every register wrongly."""
with pytest.raises(
cv.Invalid, match="does not match the 8 pins of device PCAL9538"
):
_hub(device="PCAL9538", pin_count=16)
@pytest.mark.parametrize(
("overrides", "device"),
[
({}, "PCA9554"),
({"pin_count": 4}, "PCA9536"),
({"pin_count": 8}, "PCA9554"),
({"pin_count": 16}, "PCA9555"),
],
)
def test_legacy_pin_count_maps_to_a_device(overrides: dict, device: str) -> None:
assert _hub(**overrides)["device"] == device
@pytest.mark.parametrize(
("device", "address"),
[
("PCA9554", 0x20),
("PCA9554A", 0x38),
("PCA9536", 0x41),
("PCA9538", 0x70),
("PCAL9538", 0x70),
],
)
def test_address_defaults_to_the_device_base(device: str, address: int) -> None:
assert _hub(device=device)["address"] == address
assert _hub(device=device, address=0x27)["address"] == 0x27
def test_pin_number_follows_the_device() -> None:
with pytest.raises(cv.Invalid, match="range 0-3"):
pca9554_pin_final_validate(_pin(4, {"input": True}), _hub(device="PCA9536"))
pca9554_pin_final_validate(_pin(15, {"input": True}), _hub(device="PCAL9555"))
@pytest.mark.parametrize(
("mode", "overrides", "message"),
[
({"input": True, "pullup": True}, {}, "Pin mode 'pullup' is not valid"),
({"input": True, "pulldown": True}, {}, "Pin mode 'pulldown' is not valid"),
({"input": True}, {"latch": True}, "'latch' is not supported"),
({"output": True}, {"drive_strength": "100%"}, "'drive_strength' is not"),
],
)
def test_pcal_only_settings_are_rejected_on_a_plain_part(
mode: dict, overrides: dict, message: str
) -> None:
pin = _pin(1, mode, **overrides)
with pytest.raises(cv.Invalid, match=f"{message}.*device PCA9554"):
pca9554_pin_final_validate(pin, _hub(device="PCA9554"))
pca9554_pin_final_validate(pin, _hub(device="PCAL9538"))
def test_outputs_cannot_be_latched() -> None:
with pytest.raises(cv.Invalid, match="'latch' is not valid for an output pin"):
pca9554_pin_final_validate(
_pin(1, {"output": True}, latch=True), _hub(device="PCAL9538")
)
def test_inputs_take_no_drive_strength() -> None:
with pytest.raises(cv.Invalid, match="'drive_strength' is not valid for an input"):
pca9554_pin_final_validate(
_pin(1, {"input": True}, drive_strength="50%"), _hub(device="PCAL9538")
)
@pytest.mark.parametrize("value", ["30%", "0%", 2])
def test_drive_strength_must_be_a_datasheet_level(value: object) -> None:
with pytest.raises(cv.Invalid):
_pin(1, {"output": True}, drive_strength=value)
def test_pcal_code_is_only_generated_for_pcal_hubs(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The define and the hub setters exist only where a PCAL part is configured."""
main_cpp = generate_main(component_config_path("pca9554.yaml"))
for call in (
"pcal_hub->set_pcal();",
"pcal_hub->set_latch(9);",
"pcal_hub->set_drive_strength(12, 2);",
"pcal_hub->set_drive_strength(10, 0);",
"pcal_hub->set_drive_strength(11, 3);",
):
assert main_cpp.count(call) == 1
for call in ("set_pcal(", "set_latch(", "set_drive_strength("):
assert f"plain_hub->{call}" not in main_cpp
assert f"pca9538_hub->{call}" not in main_cpp
# 0x70, the PCA9538 default
assert "pca9538_hub->set_i2c_address(112);" in main_cpp
+25
View File
@@ -8,6 +8,14 @@ pca9554:
pin_count: 8
address: 0x3E
interrupt_pin: ${interrupt_pin}
- id: pcal9538
i2c_id: i2c_bus
address: 0x70
device: PCAL9538
- id: pcal9555
i2c_id: i2c_bus
address: 0x71
device: PCAL9555
binary_sensor:
- platform: gpio
@@ -25,3 +33,20 @@ binary_sensor:
number: 0
mode: OUTPUT
inverted: false
- platform: gpio
id: pcal9538_input
pin:
pca9554: pcal9538
number: 3
mode:
input: true
pullup: true
latch: true
# A pin on the second port of a 16 bit PCAL part, with a non-default drive strength
- platform: gpio
id: pcal9555_output
pin:
pca9554: pcal9555
number: 12
mode: OUTPUT
drive_strength: 50%
+17
View File
@@ -0,0 +1,17 @@
sensor:
- platform: vl53l1x
i2c_id: i2c_bus
name: VL53L1X Distance
address: 0x31
enable_pin: GPIO12
- platform: vl53l1x
i2c_id: i2c_bus
name: VL53L1X Short Range
address: 0x30
enable_pin: GPIO13
distance_mode: medium
timing_budget: 33ms
roi_width: 8
roi_height: 8
offset: 5
signal_rate_limit: 2.0
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
vl53l1x: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
vl53l1x: !include common.yaml
+3 -1
View File
@@ -181,7 +181,9 @@ def require_entity[T: EntityInfo](
else f"entity with '{object_id_substring}' in object_id"
)
type_info = f" of type {entity_type.__name__}" if entity_type else ""
raise AssertionError(f"{desc}{type_info} not found in entities")
raise AssertionError(
f"{desc}{type_info} not found; available: {[e.object_id for e in entities]}"
)
return entity
@@ -8,6 +8,7 @@ import re
import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -66,9 +67,7 @@ async def test_action_concurrent_reentry(
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "action-concurrent-reentry"
await assert_device_name(client, "action-concurrent-reentry")
# Wait for tests to complete with timeout
await wait_or_fail(test_complete, 8.0, "test timed out")
+8 -29
View File
@@ -9,9 +9,10 @@ import asyncio
import json
import re
from aioesphomeapi import SupportsResponseType, UserService, UserServiceArgType
from aioesphomeapi import SupportsResponseType, UserServiceArgType
import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -69,9 +70,7 @@ async def test_api_action_responses(
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "api-action-responses-test"
await assert_device_name(client, "api-action-responses-test")
# List services
_, services = await client.list_entities_services()
@@ -80,31 +79,11 @@ async def test_api_action_responses(
assert len(services) == 5, f"Expected 5 services, found {len(services)}"
# Find our services
action_no_response: UserService | None = None
action_status_response: UserService | None = None
action_optional_response: UserService | None = None
action_only_response: UserService | None = None
action_nested_json: UserService | None = None
for service in services:
if service.name == "action_no_response":
action_no_response = service
elif service.name == "action_status_response":
action_status_response = service
elif service.name == "action_optional_response":
action_optional_response = service
elif service.name == "action_only_response":
action_only_response = service
elif service.name == "action_nested_json":
action_nested_json = service
assert action_no_response is not None, "action_no_response not found"
assert action_status_response is not None, "action_status_response not found"
assert action_optional_response is not None, (
"action_optional_response not found"
)
assert action_only_response is not None, "action_only_response not found"
assert action_nested_json is not None, "action_nested_json not found"
action_no_response = require_service(services, "action_no_response")
action_status_response = require_service(services, "action_status_response")
action_optional_response = require_service(services, "action_optional_response")
action_only_response = require_service(services, "action_only_response")
action_nested_json = require_service(services, "action_nested_json")
# Verify supports_response modes
assert action_no_response.supports_response is None or (
+5 -19
View File
@@ -10,9 +10,9 @@ import asyncio
import contextlib
import re
from aioesphomeapi import UserService
import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -79,9 +79,7 @@ async def test_api_action_timeout(
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "api-action-timeout-test"
await assert_device_name(client, "api-action-timeout-test")
# List services
_, services = await client.list_entities_services()
@@ -90,21 +88,9 @@ async def test_api_action_timeout(
assert len(services) == 3, f"Expected 3 services, found {len(services)}"
# Find our services
action_immediate: UserService | None = None
action_short_delay: UserService | None = None
action_long_delay: UserService | None = None
for service in services:
if service.name == "action_immediate":
action_immediate = service
elif service.name == "action_short_delay":
action_short_delay = service
elif service.name == "action_long_delay":
action_long_delay = service
assert action_immediate is not None, "action_immediate not found"
assert action_short_delay is not None, "action_short_delay not found"
assert action_long_delay is not None, "action_long_delay not found"
action_immediate = require_service(services, "action_immediate")
action_short_delay = require_service(services, "action_short_delay")
action_long_delay = require_service(services, "action_long_delay")
# Test 1: Immediate response should work
response = await client.execute_service(
@@ -5,9 +5,10 @@ from __future__ import annotations
import asyncio
import re
from aioesphomeapi import UserService, UserServiceArgType
from aioesphomeapi import UserServiceArgType
import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -53,9 +54,7 @@ async def test_api_conditional_memory(
async with run_compiled(yaml_config, line_callback=check_output):
async with api_client_connected() as client:
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "api-conditional-memory-test"
await assert_device_name(client, "api-conditional-memory-test")
# Wait for connection log
await asyncio.wait_for(connected_future, timeout=5.0)
@@ -67,17 +66,8 @@ async def test_api_conditional_memory(
assert len(services) == 2, f"Expected 2 services, found {len(services)}"
# Find our services
simple_service: UserService | None = None
service_with_args: UserService | None = None
for service in services:
if service.name == "test_simple_service":
simple_service = service
elif service.name == "test_service_with_args":
service_with_args = service
assert simple_service is not None, "test_simple_service not found"
assert service_with_args is not None, "test_service_with_args not found"
simple_service = require_service(services, "test_simple_service")
service_with_args = require_service(services, "test_service_with_args")
# Verify service arguments
assert len(service_with_args.args) == 4, (
+10 -32
View File
@@ -5,9 +5,10 @@ from __future__ import annotations
import asyncio
import re
from aioesphomeapi import UserService, UserServiceArgType
from aioesphomeapi import UserServiceArgType
import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -67,9 +68,7 @@ async def test_api_custom_services(
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "api-custom-services-test"
await assert_device_name(client, "api-custom-services-test")
# List services
_, services = await client.list_entities_services()
@@ -78,35 +77,14 @@ async def test_api_custom_services(
assert len(services) == 6, f"Expected 6 services, found {len(services)}"
# Find our services
yaml_service: UserService | None = None
yaml_args_service: UserService | None = None
yaml_many_args_service: UserService | None = None
custom_service: UserService | None = None
custom_args_service: UserService | None = None
custom_arrays_service: UserService | None = None
for service in services:
if service.name == "test_yaml_service":
yaml_service = service
elif service.name == "test_yaml_service_with_args":
yaml_args_service = service
elif service.name == "test_yaml_service_many_args":
yaml_many_args_service = service
elif service.name == "custom_test_service":
custom_service = service
elif service.name == "custom_service_with_args":
custom_args_service = service
elif service.name == "custom_service_with_arrays":
custom_arrays_service = service
assert yaml_service is not None, "test_yaml_service not found"
assert yaml_args_service is not None, "test_yaml_service_with_args not found"
assert yaml_many_args_service is not None, (
"test_yaml_service_many_args not found"
yaml_service = require_service(services, "test_yaml_service")
yaml_args_service = require_service(services, "test_yaml_service_with_args")
yaml_many_args_service = require_service(
services, "test_yaml_service_many_args"
)
assert custom_service is not None, "custom_test_service not found"
assert custom_args_service is not None, "custom_service_with_args not found"
assert custom_arrays_service is not None, "custom_service_with_arrays not found"
custom_service = require_service(services, "custom_test_service")
custom_args_service = require_service(services, "custom_service_with_args")
custom_arrays_service = require_service(services, "custom_service_with_arrays")
# Test YAML service
await client.execute_service(yaml_service, {})
+3 -7
View File
@@ -16,6 +16,7 @@ import re
from aioesphomeapi import HomeassistantServiceCall
import pytest
from .state_utils import assert_device_name, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -184,9 +185,7 @@ async def test_api_homeassistant(
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "test-ha-api"
await assert_device_name(client, "test-ha-api")
# Store client reference for use in service call callback
client_ref.append(client)
@@ -228,10 +227,7 @@ async def test_api_homeassistant(
_, services = await client.list_entities_services()
# Find the trigger service
trigger_service = next(
(s for s in services if s.name == "trigger_all_tests"), None
)
assert trigger_service is not None, "trigger_all_tests service not found"
trigger_service = require_service(services, "trigger_all_tests")
# Execute all tests
await client.execute_service(trigger_service, {})
@@ -13,6 +13,7 @@ import asyncio
from aioesphomeapi import ButtonInfo, HomeassistantServiceCall
import pytest
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -59,8 +60,7 @@ async def test_api_homeassistant_action_no_subscriber(
await asyncio.wait_for(boot_warning_future, timeout=10.0)
async with api_client_connected() as client:
device_info = await client.device_info()
assert device_info.name == "test-ha-action-no-subscriber"
await assert_device_name(client, "test-ha-action-no-subscriber")
# on_client_connected fired at authentication, before this client
# subscribed to Home Assistant actions.
@@ -9,6 +9,7 @@ from aioesphomeapi import APIClient, HomeassistantServiceCall, UserService
import pytest
from .log_utils import LineWaiter
from .state_utils import require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -73,7 +74,7 @@ async def test_api_homeassistant_entities(
):
entities, services = await client.list_entities_services()
keys = {entity.name: entity.key for entity in entities}
dump_select = next(s for s in services if s.name == "dump_select")
dump_select = require_service(services, "dump_select")
client.subscribe_service_calls(calls.put_nowait)
client.subscribe_home_assistant_states(on_state_sub)
await wait_or_fail(
@@ -21,7 +21,7 @@ from google.protobuf import message
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .state_utils import require_entity, require_service
from .types import APIClientConnectedFactory, RunCompiledFunction
ENTITY = "light.kitchen"
@@ -160,7 +160,7 @@ async def test_api_homeassistant_light(
async with light_entity(
yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgb']"
) as ha:
set_force_off = next(s for s in ha.services if s.name == "set_force_off")
set_force_off = require_service(ha.services, "set_force_off")
# Normal while Home Assistant starts, so no warning
ha.report(None, "unavailable")
ha.report(None, "unknown")

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