mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 13:56:10 +00:00
Merge remote-tracking branch 'origin/dev' into pr-18205
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
DOMAIN = "vl53l1x"
|
||||
@@ -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))
|
||||
@@ -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, ¢er)) {
|
||||
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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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%
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user