mirror of
https://github.com/esphome/esphome.git
synced 2026-10-28 00:42:11 +00:00
Compare commits
446
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b5dd52ef | ||
|
|
bf36f7532d | ||
|
|
69f6429728 | ||
|
|
bab99748f0 | ||
|
|
69785f56f6 | ||
|
|
61b52c0a34 | ||
|
|
54c4cc81c5 | ||
|
|
02ea1b2696 | ||
|
|
16023df4fd | ||
|
|
0fd72f74f1 | ||
|
|
4254d76e5c | ||
|
|
7605958db5 | ||
|
|
35ea89859b | ||
|
|
fd7a0d3679 | ||
|
|
dc37e06143 | ||
|
|
9226e03496 | ||
|
|
a4fff7738b | ||
|
|
4242bcb084 | ||
|
|
8a88e97497 | ||
|
|
d586e20a31 | ||
|
|
e6161e64b1 | ||
|
|
431843c27e | ||
|
|
a1da3d6b90 | ||
|
|
930429061a | ||
|
|
afceb91d90 | ||
|
|
7d741361a3 | ||
|
|
9c70ffd826 | ||
|
|
dac6a9c8cc | ||
|
|
a3a4144e4f | ||
|
|
29e9ae008d | ||
|
|
c92c4d7f99 | ||
|
|
5bcc3f42e9 | ||
|
|
d6aa5ba14c | ||
|
|
59c7178910 | ||
|
|
e55741a26b | ||
|
|
0bdc14154b | ||
|
|
1d6f839ee7 | ||
|
|
2544518aca | ||
|
|
70e56df630 | ||
|
|
7d62aae996 | ||
|
|
c12baaa337 | ||
|
|
cb9eda3681 | ||
|
|
4e27957c74 | ||
|
|
0856211b9b | ||
|
|
ff4ff5c5f5 | ||
|
|
980b336668 | ||
|
|
a3cb3732ee | ||
|
|
ab50b3830a | ||
|
|
3131cf28a3 | ||
|
|
13683fa163 | ||
|
|
01d29001bb | ||
|
|
9a5fcbce89 | ||
|
|
ce83a625c1 | ||
|
|
43732e382e | ||
|
|
6d1f68af8d | ||
|
|
adeea9da98 | ||
|
|
946de4223f | ||
|
|
0ccac0c291 | ||
|
|
fc1e4bf816 | ||
|
|
5df921b4e0 | ||
|
|
fb27e003f3 | ||
|
|
f277476de0 | ||
|
|
9673e73893 | ||
|
|
b722812bd8 | ||
|
|
8fde15e789 | ||
|
|
c48d219dc0 | ||
|
|
3bed3f3d4f | ||
|
|
4220cbe63e | ||
|
|
d7bc501095 | ||
|
|
a04032b345 | ||
|
|
611d8cfb4d | ||
|
|
7dace44d61 | ||
|
|
491f98f982 | ||
|
|
619283d74a | ||
|
|
18348b8794 | ||
|
|
f7dc2ecb51 | ||
|
|
ddcc5874ed | ||
|
|
28013a60e1 | ||
|
|
966193cb09 | ||
|
|
e1077748be | ||
|
|
cc89a011e2 | ||
|
|
c76e9e1d62 | ||
|
|
7a3aac407f | ||
|
|
b0a1054e8d | ||
|
|
a8a8acb918 | ||
|
|
3ae5e1dfad | ||
|
|
ef79d48b6f | ||
|
|
94804cc741 | ||
|
|
879c5e5e14 | ||
|
|
edda565237 | ||
|
|
3616052a7c | ||
|
|
fbc4bc9e10 | ||
|
|
01b29dbf53 | ||
|
|
e15797c18b | ||
|
|
3c6b1e66e7 | ||
|
|
179f9ed395 | ||
|
|
2f7105fa54 | ||
|
|
e3590bdac7 | ||
|
|
54440a3559 | ||
|
|
45c7fd5214 | ||
|
|
bfcefbb896 | ||
|
|
40a58e1d3c | ||
|
|
07f24708b4 | ||
|
|
c26967f623 | ||
|
|
9ff76f4d79 | ||
|
|
e17e386dea | ||
|
|
7133d02d4c | ||
|
|
e2d261e744 | ||
|
|
6e054555ac | ||
|
|
4fd3e41ee7 | ||
|
|
4280ac9659 | ||
|
|
211dcfa325 | ||
|
|
c74a9da996 | ||
|
|
b67ac5d0be | ||
|
|
9c4edc767b | ||
|
|
04432d3fd7 | ||
|
|
63b610bf17 | ||
|
|
62c9e05541 | ||
|
|
671ac3e0bc | ||
|
|
6671ded245 | ||
|
|
9010c4bb2e | ||
|
|
e0a48ff683 | ||
|
|
e3d7b957a7 | ||
|
|
9ab230f98d | ||
|
|
35883270fe | ||
|
|
da37e89b9d | ||
|
|
32bbf55f2a | ||
|
|
16759c848f | ||
|
|
244e419f82 | ||
|
|
04f53f0ab4 | ||
|
|
9eef601f99 | ||
|
|
454dbe59db | ||
|
|
7c0fa49b00 | ||
|
|
c9fe7451a6 | ||
|
|
92320ed14e | ||
|
|
601dcce0e4 | ||
|
|
980f415c64 | ||
|
|
054071171c | ||
|
|
a8ab7adf0d | ||
|
|
3bf98fbc98 | ||
|
|
8906a3e1bf | ||
|
|
0dd02fd68a | ||
|
|
1fa828bf9e | ||
|
|
55a658dca6 | ||
|
|
436d756396 | ||
|
|
8225fd95e7 | ||
|
|
3cd3d776e7 | ||
|
|
f3c025c6e3 | ||
|
|
7784210ec4 | ||
|
|
e4dd6d452d | ||
|
|
3f29f1a5f1 | ||
|
|
80fedf020f | ||
|
|
3a15ffa8df | ||
|
|
704551a6c9 | ||
|
|
f30e70acfa | ||
|
|
594a691d9f | ||
|
|
ab41553e8d | ||
|
|
82a0c5b2f5 | ||
|
|
659eab1958 | ||
|
|
fb2ed65d1b | ||
|
|
6c90b5c549 | ||
|
|
940de19d3b | ||
|
|
c8df23a9cb | ||
|
|
4fdce9d96b | ||
|
|
9d296a5748 | ||
|
|
f2e013d71a | ||
|
|
ded94c6698 | ||
|
|
e415c099e0 | ||
|
|
d25a925a9b | ||
|
|
41c3bbff25 | ||
|
|
3b8b6c6429 | ||
|
|
7c58a9698b | ||
|
|
f47dca7e4e | ||
|
|
701ba8eaa5 | ||
|
|
7af7b6f1d6 | ||
|
|
7fe4352786 | ||
|
|
e70b0ace13 | ||
|
|
915458cfcf | ||
|
|
4eaad21f08 | ||
|
|
6e4e5ae830 | ||
|
|
a66faec424 | ||
|
|
d24cadbc8f | ||
|
|
f9373bd7b3 | ||
|
|
eb32f80d53 | ||
|
|
cef13c8697 | ||
|
|
876537186c | ||
|
|
6bb51c8cd9 | ||
|
|
5d9ca0e45b | ||
|
|
f3f056ca41 | ||
|
|
bf36e7c33a | ||
|
|
bac4d2aa29 | ||
|
|
992860637b | ||
|
|
b0d3899483 | ||
|
|
f46ebdd345 | ||
|
|
fbe4699ad4 | ||
|
|
6423af2978 | ||
|
|
ebc9619e3b | ||
|
|
c0cbf9cdfd | ||
|
|
89eb58edce | ||
|
|
442bd4975e | ||
|
|
808075bf93 | ||
|
|
a2fbedeac3 | ||
|
|
8b951fa620 | ||
|
|
b18a5b09e7 | ||
|
|
6c4a42c069 | ||
|
|
18120cc4a8 | ||
|
|
7719179dcd | ||
|
|
74e1057268 | ||
|
|
6304411341 | ||
|
|
fd902a646a | ||
|
|
09b580e4ea | ||
|
|
89536632dc | ||
|
|
3da581233a | ||
|
|
ff1c448ecc | ||
|
|
1430428f70 | ||
|
|
91f7b0d147 | ||
|
|
5bccdab01d | ||
|
|
68df9248ed | ||
|
|
0a501b7ef5 | ||
|
|
b65395174c | ||
|
|
da0fed0517 | ||
|
|
984ecc9f3c | ||
|
|
7a1ed1ce34 | ||
|
|
c6eeeac7e8 | ||
|
|
01524a2ffd | ||
|
|
65ba64b9a6 | ||
|
|
1b6a43a8a1 | ||
|
|
7a88c73fac | ||
|
|
e3a7437bdd | ||
|
|
ee53f8ab1c | ||
|
|
bc8bebfaba | ||
|
|
0ab57126d8 | ||
|
|
6d8e36cc97 | ||
|
|
0b70f297c2 | ||
|
|
89a222b95a | ||
|
|
b537611bfa | ||
|
|
a3d3dd4a51 | ||
|
|
a5fbd34a1e | ||
|
|
fd3c1fad45 | ||
|
|
f250fa73d0 | ||
|
|
85dd0f71b3 | ||
|
|
7cf72c287d | ||
|
|
dc56b27f33 | ||
|
|
384c2ec26c | ||
|
|
6868011336 | ||
|
|
8ffdbfd4ce | ||
|
|
1cd5641900 | ||
|
|
15b988be9c | ||
|
|
2d0e3faa3d | ||
|
|
2087d28684 | ||
|
|
b919b692f7 | ||
|
|
4498ad2c77 | ||
|
|
2addbaaa7f | ||
|
|
1dc4006bfc | ||
|
|
4d4ba5a1bc | ||
|
|
0e8e45c528 | ||
|
|
28e43235fa | ||
|
|
ded5ad6cb1 | ||
|
|
ab656acbf1 | ||
|
|
52390ebbe4 | ||
|
|
8d6b40f735 | ||
|
|
257a636099 | ||
|
|
b53b3968b4 | ||
|
|
73ba726838 | ||
|
|
490d4723ca | ||
|
|
a6dee800d1 | ||
|
|
2001dd2be8 | ||
|
|
c793ab30a7 | ||
|
|
1753c45e53 | ||
|
|
1767e4b0be | ||
|
|
5575dd8928 | ||
|
|
704dd4b445 | ||
|
|
e6ad995c8e | ||
|
|
84ce39e493 | ||
|
|
d9242520e3 | ||
|
|
03bf2de7df | ||
|
|
a70d75e469 | ||
|
|
2dcb97774d | ||
|
|
be9741c6de | ||
|
|
a6a4b49e75 | ||
|
|
649f2c91f5 | ||
|
|
ffa20abe96 | ||
|
|
f979ddcf0d | ||
|
|
26064f1415 | ||
|
|
b1c138c612 | ||
|
|
9526233821 | ||
|
|
c50ee215f2 | ||
|
|
0fbba03522 | ||
|
|
7ccaedfb9e | ||
|
|
0173127239 | ||
|
|
235c614e68 | ||
|
|
79823c89ab | ||
|
|
e09cb64756 | ||
|
|
bd5b3fa641 | ||
|
|
f464994f85 | ||
|
|
4552dbad7a | ||
|
|
ad8cd0dd3e | ||
|
|
dd2582bdfd | ||
|
|
ba575600dc | ||
|
|
cd6c0dd402 | ||
|
|
4645d34e1a | ||
|
|
dff1422e36 | ||
|
|
512e43793b | ||
|
|
603f16623f | ||
|
|
218e696654 | ||
|
|
3e68e0f5e1 | ||
|
|
dd456ea0b2 | ||
|
|
e54d66e620 | ||
|
|
a3bb17d2c6 | ||
|
|
71c0f46e35 | ||
|
|
a153fcc8ee | ||
|
|
85724892cf | ||
|
|
deea006b39 | ||
|
|
619728b686 | ||
|
|
c35d98b622 | ||
|
|
b2d6c78499 | ||
|
|
a801e414a0 | ||
|
|
22a36de4ae | ||
|
|
eca95348ac | ||
|
|
ed9616d512 | ||
|
|
0cd1ae01f1 | ||
|
|
2a416fbce6 | ||
|
|
270d989481 | ||
|
|
3665bcc4e3 | ||
|
|
0922df9bb3 | ||
|
|
54a28b3e3b | ||
|
|
032a2b7200 | ||
|
|
0bb78186f2 | ||
|
|
2fe1333cba | ||
|
|
265754c467 | ||
|
|
6580f4f314 | ||
|
|
5ad63e3bb6 | ||
|
|
a8ab035b59 | ||
|
|
968d57c56b | ||
|
|
1307faef06 | ||
|
|
ee117886b9 | ||
|
|
1dbad7b951 | ||
|
|
5df6c65cc2 | ||
|
|
af5efabdc6 | ||
|
|
a2754f655c | ||
|
|
34e6d3ec9c | ||
|
|
21362f8c92 | ||
|
|
626d84bc64 | ||
|
|
68fa7b9847 | ||
|
|
32dc3b8535 | ||
|
|
74bcec34ad | ||
|
|
c1687aba4c | ||
|
|
ec642f928f | ||
|
|
ec9a207f42 | ||
|
|
3b7847d951 | ||
|
|
1197f22f00 | ||
|
|
e034662dc6 | ||
|
|
42e849967a | ||
|
|
e6f1ed8d51 | ||
|
|
bb9067acd7 | ||
|
|
928cf7d696 | ||
|
|
89afb111ad | ||
|
|
d1ec52e95a | ||
|
|
818d43f742 | ||
|
|
1a28a9220b | ||
|
|
022733c46b | ||
|
|
0f6fc503bd | ||
|
|
ae62960193 | ||
|
|
325c747dcd | ||
|
|
5759c6b8e6 | ||
|
|
c9ba6c355c | ||
|
|
d02da3c8de | ||
|
|
a576092f25 | ||
|
|
792d650fb2 | ||
|
|
f72857151a | ||
|
|
5f8f075e76 | ||
|
|
c2c8cb5eb3 | ||
|
|
5c2b05d0eb | ||
|
|
e110613ef1 | ||
|
|
4324b7d084 | ||
|
|
37d6140193 | ||
|
|
4f192130f8 | ||
|
|
4ef17cebdb | ||
|
|
35276a38bc | ||
|
|
635df9c6ad | ||
|
|
d16d8c111e | ||
|
|
1ead61f11f | ||
|
|
bd06d37d1a | ||
|
|
7c6fac91ff | ||
|
|
052a6096e6 | ||
|
|
25b684f762 | ||
|
|
62f8cc3436 | ||
|
|
34f9152d61 | ||
|
|
73af6b0500 | ||
|
|
a384aae5b1 | ||
|
|
905267a9c3 | ||
|
|
d08c845c65 | ||
|
|
91dfb92c60 | ||
|
|
cef9418a0f | ||
|
|
c0a693dfce | ||
|
|
1b4721d445 | ||
|
|
f8d31c3e54 | ||
|
|
eafcca8274 | ||
|
|
06b585d373 | ||
|
|
885f2fe5d8 | ||
|
|
4e27b2fc12 | ||
|
|
375900bfcd | ||
|
|
6d664d3fef | ||
|
|
4dc2b5911b | ||
|
|
687466b6cd | ||
|
|
dcdc042a4b | ||
|
|
d2a1f7ee31 | ||
|
|
a5a883fd19 | ||
|
|
7671d4410c | ||
|
|
298bd5ec72 | ||
|
|
b8afcdfb3c | ||
|
|
9031df757b | ||
|
|
107bebebad | ||
|
|
1147b024d6 | ||
|
|
36ba2f6106 | ||
|
|
bb0eabf241 | ||
|
|
d9969dd533 | ||
|
|
51ae3bb642 | ||
|
|
b3ef5cf2f3 | ||
|
|
b3f5789dc6 | ||
|
|
1ddacb4931 | ||
|
|
10d3ced897 | ||
|
|
6fa7b87e6a | ||
|
|
c0b7c5e590 | ||
|
|
57dd09018e | ||
|
|
04aa59563d | ||
|
|
9440012413 | ||
|
|
38db8c1554 | ||
|
|
ab0c32c2aa | ||
|
|
cd6a34401b | ||
|
|
5ab92ed363 | ||
|
|
1368ce4342 | ||
|
|
dc0d79635b | ||
|
|
1ff6b29b0d | ||
|
|
49caeae780 | ||
|
|
e1a6c615b1 | ||
|
|
e95e56f100 | ||
|
|
126dc8b0b2 | ||
|
|
3298988f5d | ||
|
|
802c2633ed | ||
|
|
51b1fdcc8b | ||
|
|
6555da2906 | ||
|
|
6dbd15b903 | ||
|
|
0399379c0d | ||
|
|
6817960365 | ||
|
|
cfdaf958e9 |
No files matched your search
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
name: display-test
|
||||||
|
description: Test an ESPHome display driver on a real panel with a camera. Use when a task asks to verify a display (LCD, e-paper, OLED, RLCD) on hardware, check orientation/rotation, partial updates, colours, contrast, or that text and shapes render correctly. Covers finding the USB port, flashing a test pattern, capturing the panel with any camera, and reading the photo.
|
||||||
|
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
||||||
|
---
|
||||||
|
|
||||||
|
# Display testing with a camera
|
||||||
|
|
||||||
|
Point a camera at the panel to get visual evidence that a driver renders correctly. Any camera
|
||||||
|
and capture tool works; this skill covers the common ones. `$SKILL` below is this directory
|
||||||
|
(`.agents/skills/display-test`).
|
||||||
|
|
||||||
|
## Ask before using a camera
|
||||||
|
|
||||||
|
A camera can show people and rooms, not only the panel. Before the first capture, ask the user
|
||||||
|
for permission and for which camera to use. List the cameras the host has (the commands below
|
||||||
|
show how), name the one you intend to use, and wait for the user to confirm it. Do not pick a
|
||||||
|
camera yourself, and do not fall back to another camera if the chosen one fails; ask again.
|
||||||
|
|
||||||
|
The permission covers this session and the camera the user named. Ask again for a different
|
||||||
|
camera or in a new session. If the user declines, stop and report that the display was not
|
||||||
|
checked visually.
|
||||||
|
|
||||||
|
## Capturing a frame
|
||||||
|
|
||||||
|
Grab a still image, skipping the first few frames so auto-exposure and white balance settle.
|
||||||
|
Read the JPEG with the Read tool; the image is shown visually.
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
The bundled Swift tool uses AVFoundation and avoids the frame-grabbing problems some USB
|
||||||
|
cameras have with other tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/camsnap /tmp/display-test
|
||||||
|
swiftc -O "$SKILL/scripts/camsnap.swift" -o /tmp/camsnap/camsnap
|
||||||
|
/tmp/camsnap/camsnap <camera-name-substring> /tmp/display-test/after.jpg 30
|
||||||
|
```
|
||||||
|
|
||||||
|
The camera argument is a case-insensitive substring of the name AVFoundation reports. If it does
|
||||||
|
not match, the tool lists the cameras it found, so a run with a name that matches nothing lists
|
||||||
|
the cameras without capturing. Use a substring that matches only the camera the user approved.
|
||||||
|
It creates the output directory if needed and reports encoding or write errors on stderr. Other
|
||||||
|
options include `ffmpeg` with `avfoundation` input, `imagesnap`, or Photo Booth's still capture.
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Use `ffmpeg` with the v4l2 input, `fswebcam`, or `v4l2-ctl`. With `ffmpeg`, `-ss 2` after the
|
||||||
|
input discards the first two seconds of frames while exposure settles:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/display-test
|
||||||
|
ffmpeg -f v4l2 -i /dev/video0 -ss 2 -frames:v 1 -y /tmp/display-test/after.jpg
|
||||||
|
# or
|
||||||
|
fswebcam -d /dev/video0 --skip 10 /tmp/display-test/after.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
List devices with `v4l2-ctl --list-devices`.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
Use `ffmpeg` with the DirectShow input (`-ss 2` skips the first frames, as on Linux), or any
|
||||||
|
camera application that can save a still image:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
mkdir C:\temp -Force | Out-Null
|
||||||
|
ffmpeg -f dshow -list_devices true -i dummy # find the device name
|
||||||
|
ffmpeg -f dshow -i video="<device name>" -ss 2 -frames:v 1 -y C:\temp\after.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Camera caveats
|
||||||
|
|
||||||
|
- The board may sit at an angle. Do not trust absolute pixel positions or aspect ratios from the
|
||||||
|
photo; judge orientation, ordering and approximate spacing, using the corner markers as
|
||||||
|
references.
|
||||||
|
- Reflections or a glossy cover can brighten one side. Capture again if a region is unclear.
|
||||||
|
- A moving pattern is caught at an arbitrary phase. Take several captures, or use a static marker
|
||||||
|
per position.
|
||||||
|
|
||||||
|
## Board and port
|
||||||
|
|
||||||
|
Find the port; names change on every replug:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 "$SKILL/../hardware-test/scripts/ports.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
Native-USB ESP32-S3/C3/C6/H2 boards show up as `USB JTAG/serial debug unit` and need
|
||||||
|
`logger: hardware_uart: USB_SERIAL_JTAG`.
|
||||||
|
|
||||||
|
## Test pattern
|
||||||
|
|
||||||
|
Write a scratch config outside the checkout. Use one `name:` per board and include:
|
||||||
|
|
||||||
|
- a white background, a black border and a filled square at the centre, drawn in the first
|
||||||
|
update (full-window check and scale);
|
||||||
|
- a 60x20 bar near each corner, one bar per later update with `auto_clear_enabled: false`, so
|
||||||
|
each bar is written by its own partial window. The bars are wider than tall, which shows a
|
||||||
|
90 or 270 degree rotation; the corners exercise the window alignment;
|
||||||
|
- a 20x20 square under the left end of the top-left bar, drawn in the same update, which marks
|
||||||
|
the top-left corner and shows a 180 degree rotation or a mirrored image.
|
||||||
|
|
||||||
|
Use `Color::BLACK` and `Color::WHITE` in lambdas. Do not use the old `COLOR_OFF`/`COLOR_ON`
|
||||||
|
constants; black must mean black.
|
||||||
|
|
||||||
|
A ready template is in `$SKILL/templates/display-pattern.yaml`. Substitute the display platform,
|
||||||
|
pins and (for a driver with fonts) a font. The marker pattern needs no font.
|
||||||
|
|
||||||
|
## Build, flash, capture
|
||||||
|
|
||||||
|
Run from the worktree under test so `python -m esphome` imports the checkout, not an installed
|
||||||
|
copy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <worktree>
|
||||||
|
python3 -c "import esphome; print(esphome.__file__)" # must print the worktree path
|
||||||
|
PYTHONUNBUFFERED=1 python3 -m esphome compile /tmp/display-test/display-pattern.yaml
|
||||||
|
PYTHONUNBUFFERED=1 python3 -m esphome upload /tmp/display-test/display-pattern.yaml \
|
||||||
|
--device /dev/cu.usbmodemXXXX
|
||||||
|
sleep 6 # boot plus a few update intervals
|
||||||
|
<capture command> /tmp/display-test/after.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
The device path differs by OS and host; use the path printed by `ports.py`. If the USB console
|
||||||
|
wedges during flashing (esptool `No serial data received`), replug the board and retry. A board
|
||||||
|
powered only by a battery may need the USB cable for flashing; the panel keeps its image when
|
||||||
|
power is removed, which is useful for checking that an image is bistable.
|
||||||
|
|
||||||
|
## What to check
|
||||||
|
|
||||||
|
1. **Orientation**: the extra square sits under the bar in the expected top-left corner. If it is
|
||||||
|
in another corner, the image is rotated or mirrored. Test `rotation:` values with the same
|
||||||
|
pattern. The logical width and height swap at 90 and
|
||||||
|
270 degrees, so the 60x20 corner bars should then appear taller than wide on the physical
|
||||||
|
panel.
|
||||||
|
2. **Coverage**: the border touches all four edges, with no dead rows or columns at the edges.
|
||||||
|
3. **Partial updates**: with `auto_clear_enabled: false`, draw one marker per update. Each should
|
||||||
|
appear exactly at its requested position with no smearing, offset or mirrored placement.
|
||||||
|
Include all four corners.
|
||||||
|
4. **Text and contrast**: sharp glyphs, correct polarity (black text on white, not inverted), no
|
||||||
|
ghosting from the previous frame.
|
||||||
|
5. **Speed**: if the driver logs transfer sizes, check that a full refresh and a partial window
|
||||||
|
take the expected times.
|
||||||
|
|
||||||
|
Record the build commit, board, config, and the captured image path when reporting results.
|
||||||
|
|
||||||
|
## When a partial update looks wrong
|
||||||
|
|
||||||
|
Take a marker-only capture before assuming the mapping is wrong: the camera's perspective makes
|
||||||
|
absolute positions deceptive. If markers land in mirrored positions, the controller's address
|
||||||
|
order (`MADCTL` MX/MY/MV) or the RAM block mapping is inverted for the window; verify the
|
||||||
|
full-frame path separately, then fix the window computation.
|
||||||
|
|
||||||
|
## Add findings
|
||||||
|
|
||||||
|
Add new capture tools, panel quirks, or recovery steps here so the next session starts ahead.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// camsnap <camera name substring> <output.jpg> [frames to skip]
|
||||||
|
// macOS only: grabs one frame from an AVFoundation camera after letting exposure settle.
|
||||||
|
// See SKILL.md for capture tools on other systems.
|
||||||
|
import AVFoundation
|
||||||
|
import AppKit
|
||||||
|
import CoreImage
|
||||||
|
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
guard args.count >= 3 else {
|
||||||
|
FileHandle.standardError.write("usage: camsnap <camera name> <out.jpg> [skip]\n".data(using: .utf8)!)
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
let wanted = args[1].lowercased()
|
||||||
|
let outPath = args[2]
|
||||||
|
let skip = args.count > 3 ? Int(args[3]) ?? 30 : 30
|
||||||
|
|
||||||
|
let discovery = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: [.external, .builtInWideAngleCamera], mediaType: .video, position: .unspecified)
|
||||||
|
guard let device = discovery.devices.first(where: { $0.localizedName.lowercased().contains(wanted) }) else {
|
||||||
|
let names = discovery.devices.map { $0.localizedName }.joined(separator: ", ")
|
||||||
|
FileHandle.standardError.write("no camera matching '\(wanted)'; found: \(names)\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Grabber: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
var seen = 0
|
||||||
|
let skip: Int
|
||||||
|
let out: String
|
||||||
|
init(skip: Int, out: String) { self.skip = skip; self.out = out }
|
||||||
|
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
|
||||||
|
from connection: AVCaptureConnection) {
|
||||||
|
seen += 1
|
||||||
|
guard seen > skip, let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||||
|
let image = CIImage(cvPixelBuffer: pixels)
|
||||||
|
let rep = NSBitmapImageRep(ciImage: image)
|
||||||
|
guard let jpeg = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else {
|
||||||
|
FileHandle.standardError.write("failed to encode \(out) as JPEG\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
let url = URL(fileURLWithPath: out)
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
do {
|
||||||
|
try jpeg.write(to: url)
|
||||||
|
} catch {
|
||||||
|
FileHandle.standardError.write("failed to write \(out): \(error)\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
print("saved \(out) after \(seen) frames")
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
session.sessionPreset = .high
|
||||||
|
guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else {
|
||||||
|
FileHandle.standardError.write("cannot open \(device.localizedName)\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
session.addInput(input)
|
||||||
|
let output = AVCaptureVideoDataOutput()
|
||||||
|
output.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
|
||||||
|
let grabber = Grabber(skip: skip, out: outPath)
|
||||||
|
output.setSampleBufferDelegate(grabber, queue: DispatchQueue(label: "camsnap"))
|
||||||
|
session.addOutput(output)
|
||||||
|
session.startRunning()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 15) {
|
||||||
|
FileHandle.standardError.write("timed out waiting for frames\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
RunLoop.main.run()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Display test pattern: corner markers, centre reference and partial-update markers.
|
||||||
|
# Replace the display platform and pins with the driver under test.
|
||||||
|
esphome:
|
||||||
|
name: display-pattern
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32-s3-devkitc-1
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
|
||||||
|
logger:
|
||||||
|
hardware_uart: USB_SERIAL_JTAG
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
spi:
|
||||||
|
clk_pin: GPIO11
|
||||||
|
mosi_pin: GPIO12
|
||||||
|
|
||||||
|
display:
|
||||||
|
- platform: st7305
|
||||||
|
id: test_display
|
||||||
|
cs_pin: GPIO40
|
||||||
|
dc_pin: GPIO5
|
||||||
|
reset_pin: GPIO41
|
||||||
|
update_interval: 1s
|
||||||
|
auto_clear_enabled: false
|
||||||
|
lambda: |-
|
||||||
|
static int frame = 0;
|
||||||
|
if (frame == 0) {
|
||||||
|
it.fill(Color::WHITE);
|
||||||
|
it.rectangle(0, 0, it.get_width(), it.get_height(), Color::BLACK);
|
||||||
|
it.filled_rectangle(it.get_width() / 2 - 10, it.get_height() / 2 - 10, 20, 20, Color::BLACK);
|
||||||
|
} else if (frame == 1) {
|
||||||
|
it.filled_rectangle(20, 20, 60, 20, Color::BLACK);
|
||||||
|
it.filled_rectangle(20, 50, 20, 20, Color::BLACK);
|
||||||
|
} else if (frame == 2) {
|
||||||
|
it.filled_rectangle(20, it.get_height() - 40, 60, 20, Color::BLACK);
|
||||||
|
} else if (frame == 3) {
|
||||||
|
it.filled_rectangle(it.get_width() - 80, 20, 60, 20, Color::BLACK);
|
||||||
|
} else if (frame == 4) {
|
||||||
|
it.filled_rectangle(it.get_width() - 80, it.get_height() - 40, 60, 20, Color::BLACK);
|
||||||
|
}
|
||||||
|
frame++;
|
||||||
@@ -15,19 +15,26 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
|||||||
1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that
|
1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that
|
||||||
port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py <port> "`,
|
port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py <port> "`,
|
||||||
naming the port so a capture on another board survives; the bracket keeps the pattern from matching
|
naming the port so a capture on another board survives; the bracket keeps the pattern from matching
|
||||||
the shell that runs it. Never run `esphome logs` in a
|
the shell that runs it. When more than one agent session shares the bench, run a copy of the script
|
||||||
`while true` loop: the loop respawns it and holds the port.
|
under a session-specific name (`cap<topic>.py`) so another session's `pkill` cannot take your capture
|
||||||
|
down. Never run `esphome logs` in a `while true` loop: the loop respawns it and holds the port.
|
||||||
2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with
|
2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with
|
||||||
`!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs.
|
`!secret`. A copy of a device's own config keeps using the secrets file next to that config: two
|
||||||
|
checkouts can hold different networks, and the wrong one leaves the device unreachable after the OTA. Never paste a config with credentials into a PR, never commit scratch configs or logs.
|
||||||
3. **Build from the tree under test.** `python -m esphome` puts the current directory first on
|
3. **Build from the tree under test.** `python -m esphome` puts the current directory first on
|
||||||
`sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by
|
`sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by
|
||||||
its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the
|
its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the
|
||||||
checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep
|
checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep
|
||||||
the build's `src/` for the change before trusting any number.
|
the build's `src/` for the change before trusting any number. Tool calls sent together share one
|
||||||
|
shell, so a `cd` in one can land another's compile in the wrong tree: pass `PYTHONPATH=<worktree>` on
|
||||||
|
every compile and upload command and print `esphome.__file__` into the compile log.
|
||||||
4. **PR build and baseline build get different `name:` values.** Builds share
|
4. **PR build and baseline build get different `name:` values.** Builds share
|
||||||
`.esphome/build/<name>`, so the same name means `esphome upload` can flash the wrong binary. Saved
|
`.esphome/build/<name>`, so the same name means `esphome upload` can flash the wrong binary. Saved
|
||||||
Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh
|
Wi-Fi credentials are keyed by the config hash when the config has `networks:`, so a test that must
|
||||||
name every run.
|
start unprovisioned gets a fresh name every run. A config with `ap:` only and no `networks:` uses one
|
||||||
|
fixed key shared by every such config on the board, so a new name does not help there: wipe once at
|
||||||
|
boot with an `on_boot` lambda that resets the preferences and saves a marker key
|
||||||
|
(`templates/dut-ap-only-wipe-once-bk72xx.yaml`).
|
||||||
5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD`
|
5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD`
|
||||||
checked out in a throwaway worktree), with the baseline commit named in the table. When a delta
|
checked out in a throwaway worktree), with the baseline commit named in the table. When a delta
|
||||||
looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it.
|
looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it.
|
||||||
@@ -73,7 +80,7 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
|||||||
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
|
| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device <name>.local` |
|
||||||
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
|
| Pico W / Pico 2 W | `esphome upload --device <port>` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture |
|
||||||
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
|
| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d <port> <build>/.pioenvs/<name>/*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` |
|
||||||
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test |
|
| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d <port> <uf2>` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner |
|
||||||
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
|
| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash |
|
||||||
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
|
| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second |
|
||||||
| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port |
|
| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port |
|
||||||
@@ -86,12 +93,15 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
|
|||||||
- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`,
|
- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`,
|
||||||
`--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and
|
`--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and
|
||||||
`--serial-of PORT` (the serial number behind a path).
|
`--serial-of PORT` (the serial number behind a path).
|
||||||
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped
|
- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append] [--ms]`: bounded timestamped
|
||||||
capture, ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
|
capture (`--ms` adds milliseconds for timing work), ANSI stripped, `LOG_SECRET` text written as `<secret>`, reopens a vanished port, exits at the
|
||||||
deadline. OUT is truncated unless `--append` is given, so counts over it cover one run.
|
deadline. OUT is truncated unless `--append` is given, so counts over it cover one run.
|
||||||
- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot
|
- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot
|
||||||
strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not.
|
strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not.
|
||||||
- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN.
|
- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now
|
||||||
|
and then; retry up to three times.
|
||||||
|
- `api_button.py HOST KEY list|press NAME [...]`: list a device's template buttons or press them over the API; the
|
||||||
|
recipes drive their test configs with it.
|
||||||
- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`:
|
- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`:
|
||||||
the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the
|
the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the
|
||||||
templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0),
|
templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0),
|
||||||
@@ -100,6 +110,13 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
|
|||||||
(exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run)
|
(exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run)
|
||||||
or exits 1 when a build, flash or port step failed.
|
or exits 1 when a build, flash or port step failed.
|
||||||
|
|
||||||
|
Per-topic scripts, each described in the matching `reference/recipes/<component>.md`: `online_image/`
|
||||||
|
(server, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
|
||||||
|
(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, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
|
||||||
|
|
||||||
## Templates in `$SKILL/templates`
|
## Templates in `$SKILL/templates`
|
||||||
|
|
||||||
- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and
|
- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and
|
||||||
@@ -107,17 +124,37 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
|
|||||||
- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real
|
- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real
|
||||||
credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a
|
credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a
|
||||||
plain provisioning check.
|
plain provisioning check.
|
||||||
|
- Per-topic device configs, each described in `reference/recipes/<component>.md`: `online-image-dut.yaml`,
|
||||||
|
`usb-cdc-acm-dongle-dut.yaml`, `nrf52-preferences-dut.yaml`, `rtsp-waveshare-s3-eth-dut.yaml`,
|
||||||
|
`waveshare-s3-eth-camdiag.yaml`, `s31-cse7766-dut.yaml`, `nrf52-coredump-crash-dut.yaml`,
|
||||||
|
`bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`,
|
||||||
|
`homeassistant-light-dut.yaml`, `deep-sleep-outgoing-dut.yaml`, `outgoing-wifi-drop-dut.yaml`,
|
||||||
|
`globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`,
|
||||||
|
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`,
|
||||||
|
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
|
||||||
|
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`.
|
||||||
|
|
||||||
## When something looks wrong
|
## When something looks wrong
|
||||||
|
|
||||||
- A capture stopped early or a line is missing: check the timestamps before trusting it.
|
- A capture stopped early or a line is missing: check the timestamps before trusting it.
|
||||||
|
- A script that pipes into `tee` never ends although its work is done: a background command written as
|
||||||
|
`(cd dir && server & echo $!)` backgrounds the whole `cd && server` list, so a shell stays behind holding the
|
||||||
|
pipe open and `$!` is that shell, not the server. Write `(cd dir && exec server > out 2>&1 < /dev/null) &`.
|
||||||
- The client never joins the AP: another board may still run an AP with the same SSID, or the device
|
- The client never joins the AP: another board may still run an AP with the same SSID, or the device
|
||||||
booted with saved credentials because the name was reused. One AP device at a time on the bench.
|
booted with saved credentials because the name was reused. One AP device at a time on the bench.
|
||||||
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3).
|
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse
|
||||||
|
too: two binaries that differ only in the build timestamp and in `__LINE__` values of log calls below
|
||||||
|
the changed code are the same build; map the offsets with `objdump -h` and `addr2line` first.
|
||||||
|
- A boot the host cannot capture (a power cut, an OTA-only board): keep the boot report in a flash
|
||||||
|
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
|
- 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.
|
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
|
- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for
|
||||||
soak tests.
|
soak tests.
|
||||||
|
|
||||||
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven
|
`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes/` has the proven
|
||||||
flows. Add new findings there.
|
flows. Add new findings there.
|
||||||
@@ -7,6 +7,9 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
|||||||
|
|
||||||
- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset`
|
- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset`
|
||||||
pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`).
|
pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`).
|
||||||
|
- UART0 can be looped back onto itself (`USC0(0) |= (1 << UCLBE)` in an `on_boot` lambda) so a UART
|
||||||
|
component is testable without its device: frames written with `uart.write` arrive on RX. Log over the
|
||||||
|
API with `logger: baud_rate: 0`, since the serial port carries the component's bytes.
|
||||||
- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset;
|
- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset;
|
||||||
a plain pyserial capture at 115200 is reliable.
|
a plain pyserial capture at 115200 is reliable.
|
||||||
- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug.
|
- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug.
|
||||||
@@ -88,8 +91,28 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
|||||||
pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset
|
pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset
|
||||||
reaches the downloader; no software reboot does.
|
reaches the downloader; no software reboot does.
|
||||||
- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter.
|
- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter.
|
||||||
- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one
|
- Opening the port may reset the chip (bootloader banner on open) or may not, depending on the adapter and
|
||||||
capture open for the whole test and never reopen mid-phase.
|
what pyserial does with DTR and RTS; `serial_capture.py --reset` pulses RTS on CEN and always does, and
|
||||||
|
`lt_get_reboot_reason()` reports that as Power-On. Check for the banner at the start of a capture before
|
||||||
|
assuming either.
|
||||||
|
- `flash_bk72xx.py` misses the link now and then (`Timed out attempting to link with chip`); retry up to 3
|
||||||
|
times before suspecting the board.
|
||||||
|
- Crashes: `__builtin_trap()` and a call into unmapped memory both end as an undefined-instruction trap
|
||||||
|
(`bk_misc_get_start_type()` 6, `lt_get_reboot_reason()` Crash); reads, unaligned loads and stores to
|
||||||
|
unmapped addresses never fault on the ARM968. Without Wi-Fi the SDK trap handler spins with interrupts off
|
||||||
|
for 10 s and the hardware watchdog resets the chip. With Wi-Fi connected a dev build hangs forever after a
|
||||||
|
crash, and never resets from a plain `portDISABLE_INTERRUPTS(); while (1) {}` either: the boot-time
|
||||||
|
watchdog is dead once Wi-Fi is up, for any lockup. The crash handler fix (#21012) re-arms the watchdog
|
||||||
|
from its wrapper, after which the reboot comes within a second (BK7238 and BK7231N).
|
||||||
|
- The SDK prints nothing on UART1 under `sdk_silent: all`; the bootloader then prints the banked
|
||||||
|
`cpsr/spsr/r13/r14` of SVC, IRQ, FIQ and SYS plus `ST:` (the retention register) before `go os_addr`.
|
||||||
|
A CEN or power reset wipes ITCM (reads `0xaaaaaaaa`), so only a watchdog reset keeps a record there. What
|
||||||
|
survives that reboot on the BK7238: ITCM (`0x3F0000..0x3F4000`) and the top 96 KB of SRAM
|
||||||
|
(`0x428000..0x440000`, the heap); the bootloader zeroes SRAM up to about `0x420000` and rewrites
|
||||||
|
`0x400024..0x4000FF` with its own register dump every boot, so neither a `.noinit` after `.bss` nor the
|
||||||
|
SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception
|
||||||
|
handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit`
|
||||||
|
placed there after `.itcm.code` works for a crash record.
|
||||||
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
|
- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms;
|
||||||
radio enable 160 to 278 ms, disable about 200 ms.
|
radio enable 160 to 278 ms, disable about 200 ms.
|
||||||
|
|
||||||
@@ -123,6 +146,38 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
|||||||
- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume,
|
- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume,
|
||||||
adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24),
|
adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24),
|
||||||
button P0.18.
|
button P0.18.
|
||||||
|
- Adafruit ItsyBitsy nRF52840 Express (UF2 bootloader with S140, bootloader port `239a:0051` plus an
|
||||||
|
`ITSY840BOOT` volume): the board DTS already has `cdc_acm_uart0`, so the logger port is the first CDC
|
||||||
|
port and a `usb_cdc_acm` interface 1 the second. A `dfu: true` build enters the bootloader on the
|
||||||
|
1200 bps touch (an app without it needs a double tap on RESET); `adafruit-nrfutil` over the bootloader
|
||||||
|
serial port can fail, copying `.pioenvs/<name>/zephyr/zephyr.uf2` to the volume works (the copy reports
|
||||||
|
an I/O error as the board reboots, which is normal). Reboot a running `dfu: true` app with a 2001 bps touch.
|
||||||
|
- The PCA10059 board DTS already defines a `cdc_acm_uart` node, so every ESPHome CDC port is one USB port
|
||||||
|
later than its number (log on `cdc_acm_uart0` is the second port). A third CDC instance needs
|
||||||
|
`CONFIG_USB_MAX_NUM_TRANSFERS` above 4 or it never moves data (see `recipes/usb_cdc_acm.md`).
|
||||||
|
- USB CDC boot output: the Zephyr legacy stack's `cdc_acm` TX ring is 1 KB and `uart_poll_out` drops when it
|
||||||
|
is full, so anything printed in one burst beyond about 1 KB (a coredump, a long table) is lost even with
|
||||||
|
the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead.
|
||||||
|
- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs
|
||||||
|
a never-saved value.
|
||||||
|
|
||||||
|
## Waveshare ESP32-S3-ETH with the OV5640 camera module
|
||||||
|
|
||||||
|
- Native USB console (`303a:1001`), ESP32-S3R8, 16 MB flash, 8 MB octal PSRAM, W5500 on SPI (MOSI 11,
|
||||||
|
MISO 12, SCLK 13, CS 14, INT 10, RST 9), TF card on 4..7, WS2812 on 21.
|
||||||
|
- Camera header (vendor schematic): XCLK 3, SIOD 48, SIOC 47, VSYNC 1, HREF 2, PCLK 39, D0..D7 = 41, 45,
|
||||||
|
46, 42, 40, 38, 15, 18. PWDN is tied to GND and RESET is an RC to 3.3 V on the board, so no reset or
|
||||||
|
power-down GPIO exists.
|
||||||
|
- GPIO8 switches the camera 3.3 V rail through a P-channel MOSFET: LOW = camera powered, gate pulled up so
|
||||||
|
the camera is off by default. `power_down_pin: GPIO8` only pulls it low 10 ms before the probe, which is
|
||||||
|
not enough; use `switch: gpio GPIO8 restore_mode: ALWAYS_OFF setup_priority: 800` plus `on_boot:
|
||||||
|
priority: 700` with `delay(1200)` and `esp32_camera: setup_priority: 200`
|
||||||
|
(`templates/rtsp-waveshare-s3-eth-dut.yaml`). `esphome config` rejects a config that uses GPIO8 for both the switch
|
||||||
|
and `power_down_pin` (pin reuse), so it is one or the other.
|
||||||
|
- The sensor at SCCB 0x3C answers only with XCLK running, so the ESPHome i2c scan at boot (before XCLK)
|
||||||
|
reporting no devices on 48/47 is expected. If the SCCB ID register (0x3100 = 0x78) reads fine but every
|
||||||
|
0x30xx register reads 0x00 (`Detected camera not supported`, 0x106), reseat the ribbon before blaming
|
||||||
|
software; `templates/waveshare-s3-eth-camdiag.yaml` runs that probe with LEDC XCLK and raw SCCB reads.
|
||||||
|
|
||||||
## ESP Thread Border Router board v1.2 (S3 host + H2 RCP)
|
## ESP Thread Border Router board v1.2 (S3 host + H2 RCP)
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
# Proven test flows
|
|
||||||
|
|
||||||
Each flow names the pull requests it was used on, so their result tables show what to post.
|
|
||||||
|
|
||||||
## Captive portal `/wifisave` hammer (#20802, #20784)
|
|
||||||
|
|
||||||
Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board.
|
|
||||||
Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`,
|
|
||||||
`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the
|
|
||||||
device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown
|
|
||||||
is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on
|
|
||||||
ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md).
|
|
||||||
|
|
||||||
Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash
|
|
||||||
markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`.
|
|
||||||
A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters
|
|
||||||
the adapter restart), no panic, and the real credentials connect the device within about 10 s.
|
|
||||||
|
|
||||||
Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in
|
|
||||||
`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag
|
|
||||||
(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive
|
|
||||||
portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection
|
|
||||||
refused proves the portal's server is gone.
|
|
||||||
|
|
||||||
## `reboot_timeout` with a fallback AP (#9958)
|
|
||||||
|
|
||||||
Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network,
|
|
||||||
`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and
|
|
||||||
ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes:
|
|
||||||
`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles
|
|
||||||
are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot
|
|
||||||
with no station joined, no reboot for minutes while one is, then a reboot within a second of the last
|
|
||||||
disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way.
|
|
||||||
|
|
||||||
## MQTT (#20433, #17935, #20762, #20767, #20768)
|
|
||||||
|
|
||||||
A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives
|
|
||||||
each subscription's QoS, the device address (`New client connected from <ip> as <name>-<mac>`) and exact
|
|
||||||
subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription.
|
|
||||||
Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right
|
|
||||||
after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a
|
|
||||||
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.
|
|
||||||
|
|
||||||
## UDP, multicast, sockets (#20556 to #20559, #20585, #20590)
|
|
||||||
|
|
||||||
A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device
|
|
||||||
multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock
|
|
||||||
listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate
|
|
||||||
(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi
|
|
||||||
reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect`
|
|
||||||
(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action
|
|
||||||
rejects `%.*s`; use a lambda.
|
|
||||||
|
|
||||||
## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037)
|
|
||||||
|
|
||||||
All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release
|
|
||||||
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
|
|
||||||
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
|
|
||||||
`pkill -f "a\|b"` does not alternate; use two pkills.
|
|
||||||
|
|
||||||
## DHCP lease cache (#17767)
|
|
||||||
|
|
||||||
Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the
|
|
||||||
`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep
|
|
||||||
sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use
|
|
||||||
`upload` then `logs`.
|
|
||||||
|
|
||||||
## HDMI-CEC two-board wire (#9189)
|
|
||||||
|
|
||||||
Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:`
|
|
||||||
filter on the reply handler, template buttons for each target plus lambdas calling
|
|
||||||
`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms,
|
|
||||||
round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep
|
|
||||||
lags the press).
|
|
||||||
|
|
||||||
## LD2450 polygon zones (#20154)
|
|
||||||
|
|
||||||
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
|
|
||||||
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
|
|
||||||
Writing those numbers blocks the ld2450 loop about 50 ms.
|
|
||||||
|
|
||||||
## Scheduler and loop wake A/B (#20356)
|
|
||||||
|
|
||||||
An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266;
|
|
||||||
the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout
|
|
||||||
logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus
|
|
||||||
`button_command`). Build the same yaml twice with different names, one per tree.
|
|
||||||
|
|
||||||
## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233)
|
|
||||||
|
|
||||||
Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over
|
|
||||||
two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with
|
|
||||||
`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml.
|
|
||||||
|
|
||||||
## ESP-NOW on the ESP8266 (#14407)
|
|
||||||
|
|
||||||
The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is
|
|
||||||
up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only
|
|
||||||
mode the device transmits from the soft-AP MAC.
|
|
||||||
|
|
||||||
## Wi-Fi Easy Connect (DPP) enrollee (#17934)
|
|
||||||
|
|
||||||
Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's
|
|
||||||
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
|
|
||||||
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
|
|
||||||
supplicant state machine.
|
|
||||||
|
|
||||||
## Thread border router (#18104)
|
|
||||||
|
|
||||||
See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR.
|
|
||||||
|
|
||||||
## nRF52840 dongle boards (#19009, #20814)
|
|
||||||
|
|
||||||
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
|
|
||||||
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Proven test flows
|
||||||
|
|
||||||
|
Each flow names the pull requests it was used on, so their result tables show what to post.
|
||||||
|
|
||||||
|
One file per component, named after it:
|
||||||
|
|
||||||
|
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client
|
||||||
|
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
|
||||||
|
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
|
||||||
|
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
|
||||||
|
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
|
||||||
|
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
||||||
|
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
|
||||||
|
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
|
||||||
|
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
|
||||||
|
- [`nrf52.md`](nrf52.md): dongle board support
|
||||||
|
- [`online_image.md`](online_image.md): stalled and malformed downloads
|
||||||
|
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
|
||||||
|
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
|
||||||
|
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
|
||||||
|
- [`scheduler.md`](scheduler.md): loop wake A/B
|
||||||
|
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
|
||||||
|
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
|
||||||
|
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream
|
||||||
|
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
|
||||||
|
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||||
|
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||||
|
- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind
|
||||||
|
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls
|
||||||
|
- [`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, entity icon and device class readback
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# api
|
||||||
|
|
||||||
|
## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233)
|
||||||
|
|
||||||
|
Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over
|
||||||
|
two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with
|
||||||
|
`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml.
|
||||||
|
|
||||||
|
## Outgoing connection wake timing on a deep sleep build (#21077)
|
||||||
|
|
||||||
|
`templates/deep-sleep-outgoing-dut.yaml`: classic ESP32 ESP-IDF, `wifi: fast_connect`, `api: encryption` with a bare
|
||||||
|
`outgoing_connection:`, `deep_sleep` 15 s run and 15 s sleep, a `homeassistant` text sensor, and `millis()` logged
|
||||||
|
from `wifi: on_connect`, `api: on_client_connected` and the sensor's first `on_value`. Home Assistant's dial-in
|
||||||
|
listener is on 6054; reuse a device name it already has. Flash by USB, `serial_capture.py PORT 360 OUT --reset` for
|
||||||
|
12 cycles, then `scripts/api_outgoing/cycles.py OUT LABEL` prints per cycle the wifi, client and state millis with
|
||||||
|
the client-wifi and state-client gaps. Wi-Fi takes 4 to 8 s after a wake, so compare the client-wifi gap, and run
|
||||||
|
the PR twice around one base run because the spread is a few hundred ms.
|
||||||
|
Results: https://github.com/esphome/esphome/pull/21077#issuecomment-6254098014
|
||||||
|
|
||||||
|
## Outgoing connection on a mains build after a Wi-Fi drop (#21085)
|
||||||
|
|
||||||
|
`templates/outgoing-wifi-drop-dut.yaml`: classic ESP32, `outgoing_connection: delay: 2s`, a "Wifi cycle" button doing
|
||||||
|
`wifi.disable`, 4 s, `wifi.enable`, a `debug` loop-time sensor, millis from the wifi and api triggers. Flash by USB,
|
||||||
|
`serial_capture.py PORT SECONDS OUT --ms`, press the button N times 40 s apart with `api_button.py DEV_IP KEY press
|
||||||
|
"Wifi cycle"`, then `wificycles.py CAPTURE LABEL` prints per drop the Wi-Fi-to-dial gap, dials and dials closed, who
|
||||||
|
connected first, and the loop-time maxima. For the loop cost of polling, repeat with an unreachable
|
||||||
|
`host: 192.0.2.1`. Home Assistant refuses a dial-in while its own session is READY (`Refusing dial-in adoption`),
|
||||||
|
so after a drop every dial is closed and HA's incoming reconnect wins 11 to 33 s later; the measurable effect is the
|
||||||
|
first dial landing in the loop the network flag flips.
|
||||||
|
Results: https://github.com/esphome/esphome/pull/21085#issuecomment-6254742791
|
||||||
|
|
||||||
|
## User defined action listing and execution without a client library (#18882)
|
||||||
|
|
||||||
|
`scripts/api/rawactions.py HOST list` speaks the plaintext native API over a raw socket and decodes every
|
||||||
|
`ListEntitiesServicesResponse` by field number (argument fields 1 name, 2 type, 3 description, 4 example, 5 optional,
|
||||||
|
6 default_value), so new wire fields show up without waiting for the client library. `rawactions.py HOST exec ACTION
|
||||||
|
k=v ...` sends `ExecuteServiceRequest` with `sint32` ints (field 5, not the legacy int32 field 2), fixed32 floats,
|
||||||
|
length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSING=1` leaves arguments out on purpose
|
||||||
|
(the device then drops the call). `templates/api-actions-dut.yaml` (D1 mini, three actions, optional variables with
|
||||||
|
defaults) and `templates/api-actions-strings-dut.yaml` (required strings) log what arrived. String arguments are
|
||||||
|
`StringRef`s that are not null terminated: copy them with `snprintf("%.*s", size, c_str())` into a buffer before
|
||||||
|
logging, and do not pass `%.*s` to `logger.log`, whose validator counts it as one pattern.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# captive_portal
|
||||||
|
|
||||||
|
## Captive portal `/wifisave` hammer (#20802, #20784)
|
||||||
|
|
||||||
|
Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board.
|
||||||
|
Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`,
|
||||||
|
`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the
|
||||||
|
device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown
|
||||||
|
is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on
|
||||||
|
ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md).
|
||||||
|
|
||||||
|
Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash
|
||||||
|
markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`.
|
||||||
|
A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters
|
||||||
|
the adapter restart), no panic, and the real credentials connect the device within about 10 s.
|
||||||
|
|
||||||
|
Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in
|
||||||
|
`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag
|
||||||
|
(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive
|
||||||
|
portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection
|
||||||
|
refused proves the portal's server is gone.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# cse7766
|
||||||
|
|
||||||
|
## cse7766 on a Sonoff S31 (#20948)
|
||||||
|
|
||||||
|
`templates/s31-cse7766-dut.yaml`: `logger: baud_rate: 0` because the CSE7766 owns the UART, VERBOSE logger
|
||||||
|
with everything but cse7766 quieted, 2 s throttles, and a `globals` frame counter fed by the voltage sensor's
|
||||||
|
`on_raw_value` and logged every 10 s (about 96 frames per 10 s). OTA only, every reflash reboots the relay.
|
||||||
|
`scripts/cse7766/run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]` subscribes to states and logs over the
|
||||||
|
API with a concurrent ping and prints the frame counter, API gaps and warnings. API state gaps of 10 to 20 s
|
||||||
|
can appear on a weak Wi-Fi path while the device counter stays flat, so judge a UART parser by the
|
||||||
|
device-side counter, not by the API cadence. With nothing plugged in, current and power read 0.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# dfrobot_sen0395
|
||||||
|
|
||||||
|
## DFRobot SEN0395 mmWave on an ESP32 DevKit V1 (#20906, #20979)
|
||||||
|
|
||||||
|
Wiring: V to VIN (5 V, the module wants 3.6 to 5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17
|
||||||
|
(TX2), IO2 to GPIO18 (presence output, a gpio binary sensor), I1 unconnected. `templates/dfrobot-sen0395-dut.yaml`
|
||||||
|
has the hub, both detection binary sensors, the four switches, settings buttons and junk-line buttons.
|
||||||
|
`scripts/dfrobot_sen0395/ctl.py HOST KEY press:NAME sw:NAME=on|off wait:S` drives it and prints binary sensor and
|
||||||
|
switch updates; `cases.sh DEV_IP KEY CAPTURE LABEL` runs the whole flow against a running serial capture. Flash by
|
||||||
|
USB, `serial_capture.py PORT 540 CAPTURE --reset`, then the cases; compare the `Sending command` and `Message:` lines
|
||||||
|
and the counts of `Done`, `Error`, prompts and frames between builds. The module echoes every command, so the echoed
|
||||||
|
lines are the bytes the component sent and can be compared between builds as a set.
|
||||||
|
|
||||||
|
Module facts: it answers `Done` or `Error` and then the `leapMMW:/>` prompt without a line end; a junk line gets
|
||||||
|
`<line> is not recognized as a CLI command` (36 chars longer than the line, so 220 and 240 byte junk gives 256 and
|
||||||
|
276 char replies); input lines of 256 bytes or more make the module assert (`..\User_code\main.c line:232`) and
|
||||||
|
restart, after which the sensor is stopped; a factory reset restarts the module about 7 s later; `sensorStop` on a
|
||||||
|
stopped sensor answers `sensor stopped already` then `Error`; a settings press takes 5 to 6 s (stop, change, save,
|
||||||
|
start). Free heap on the ESP32 is Wi-Fi noise (192 to 198 kB) and says nothing about the component.
|
||||||
|
Results: https://github.com/esphome/esphome/pull/20979#issuecomment-6279129146 and
|
||||||
|
https://github.com/esphome/esphome/pull/20906#issuecomment-6279129604
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# espnow
|
||||||
|
|
||||||
|
## ESP-NOW on the ESP8266 (#14407)
|
||||||
|
|
||||||
|
The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is
|
||||||
|
up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only
|
||||||
|
mode the device transmits from the soft-AP MAC.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# globals
|
||||||
|
|
||||||
|
## Restoring globals with storage: rtc and flash (#13954)
|
||||||
|
|
||||||
|
`templates/globals-storage-dut.yaml` is the shared package: five restoring globals (rtc int, flash int, default int,
|
||||||
|
rtc `std::string`, rtc `uint8_t[2]`), bumped and logged with the reset reason on every boot, saved and synced after
|
||||||
|
2 s, odd boots `App.safe_reboot()`. Platform files add the board (`packages: dut: !include globals-storage-dut.yaml`), the `debug` reset-reason text sensor and, on the
|
||||||
|
ESP32, `deep_sleep: run_duration: 12s, sleep_duration: 8s` (even boots sleep). No network needed: flash by USB,
|
||||||
|
`serial_capture.py PORT 110 OUT --reset` gives the reset pulse plus a run of soft restarts and sleeps; the ESP32's
|
||||||
|
EN pulse is a `POWERON_RESET` that empties RTC memory, the D1 mini's RST pulse (`rst cause:2`) keeps it, so the
|
||||||
|
ESP8266 needs a real power cut by hand; arm long `--released` captures on both ports first (the capture
|
||||||
|
reopens the port when it comes back, the very first boot lines are still lost, read the next boot's restored
|
||||||
|
values). Unchanged-build check: the config with every `storage:` removed, built on the PR and on the dev merge
|
||||||
|
commit under the PR's own commits (not the merge base, which is older dev), `cmp -l` plus an `nm -S` symbol diff;
|
||||||
|
only the template names differ. Results: https://github.com/esphome/esphome/pull/13954#issuecomment-6254794712
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# hdmi_cec
|
||||||
|
|
||||||
|
## HDMI-CEC two-board wire (#9189)
|
||||||
|
|
||||||
|
Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:`
|
||||||
|
filter on the reply handler, template buttons for each target plus lambdas calling
|
||||||
|
`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms,
|
||||||
|
round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep
|
||||||
|
lags the press).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# homeassistant
|
||||||
|
|
||||||
|
## homeassistant light platform against a local Home Assistant (#11354)
|
||||||
|
|
||||||
|
A local Home Assistant dev install (`hass -c config`, https://127.0.0.1:8123 with a self-signed cert); its ESPHome
|
||||||
|
entries and entity ids are in `config/.storage/`. Two ESPHome RGBCW bulbs with a plaintext API are the lights, so an
|
||||||
|
"HA side" change is made directly on a bulb with `scripts/homeassistant_light/bulb.py BULB_IP - on|off|state|restart`.
|
||||||
|
Add the test devices to HA by hand with the shared `api_key` and "Allow the device to perform Home Assistant actions".
|
||||||
|
|
||||||
|
`templates/homeassistant-light-dut.yaml` is a package: two mirrors, buttons for each change, a "Log state" button, a
|
||||||
|
30 s alive line, an `on_boot` `light.turn_on` for the change-before-first-write path, and VERBOSE logging with the
|
||||||
|
noisy tags at DEBUG (a tag cannot exceed the global level). A platform file sets the board and includes it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
esphome:
|
||||||
|
name: ha-light-dut
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
packages:
|
||||||
|
dut: !include homeassistant-light-dut.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Flash by USB, start a bounded `esphome logs` capture per device, then press buttons with
|
||||||
|
`scripts/api_button.py DEV_IP KEY press "Button"` and read the bulb with `bulb.py`. Cases: each
|
||||||
|
button against the bulb and the device log; a direct bulb change that both mirrors follow; restart the bulb, send a
|
||||||
|
change, expect the 5 s `did not report the change back` and the fallback (with an off and with an on last report);
|
||||||
|
OTA reboot with a serial capture running to see the boot-time change (the API log client reconnects too late).
|
||||||
|
|
||||||
|
Lessons: the light core clamps mireds to the range HA reported; a flash sends nothing; a change sent while HA is
|
||||||
|
connected but not yet subscribed is dropped by the api server (`client has not subscribed to actions (yet)`); after a
|
||||||
|
fallback to an off report the mirror keeps the unsent brightness; a mirror that never saw its light on since boot has
|
||||||
|
no levels to restore. Results: https://github.com/esphome/esphome/pull/11354#issuecomment-6253630336 and
|
||||||
|
https://github.com/esphome/esphome/pull/11354#issuecomment-6254161409
|
||||||
|
|
||||||
|
## Held report versus a later call (#21144)
|
||||||
|
|
||||||
|
A report that arrives during a transition is held and applied when the transition ends, unless a call came after
|
||||||
|
it. `scripts/homeassistant_light/heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY]` (a key of `-` is plaintext) keeps one API connection to the mirror
|
||||||
|
device and one to the bulb so the steps land inside a 3 s fade: press "A 50 fade 3s", 0.7 s later set the bulb to
|
||||||
|
90% directly (the report is held), 1.4 s in press the same fade again (or "A many calls", 16 calls in one loop
|
||||||
|
pass). Pass: the mirror and the bulb end at the second call's target. Only `same` reproduces the bug: a build
|
||||||
|
without the fix logs `Applying the report that arrived during the transition` and the bulb stays at 90%. `many`
|
||||||
|
ends at 100% on both builds because the last call's target differs from the held report, so it is a regression
|
||||||
|
check only, not a reproduction. Separate button presses are too slow (about 1.5 s each) to land inside the fade.
|
||||||
|
The mirrors are not API entities, so read their state from the "Log state" button's line. Results: https://github.com/esphome/esphome/pull/21144#issuecomment-6278257695
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ld2412
|
||||||
|
|
||||||
|
## Output pin level and the basic configuration command (#21353)
|
||||||
|
|
||||||
|
`templates/ld2412-select-only-dut.yaml` (ESP32-S3, the module on GPIO43 and GPIO44 at 115200, only the output pin
|
||||||
|
level select, the module's OUT pin on GPIO38 as a binary sensor plus the presence sensor) with `uart: debug:
|
||||||
|
direction: BOTH`. Drive the select with `scripts/ld2450/lddrive.py HOST KEY LOGFILE sel:NAME=OPTION` and read the TX
|
||||||
|
dump for command `02` carrying the level byte, then the OUT pin binary sensor against presence: with level high the
|
||||||
|
pin follows presence, with level low it is inverted. A module that never answers the boot query (no `<<<` lines)
|
||||||
|
only shows the held back path: `Command 02 held back until the module reports its settings`, a query `12`, and after
|
||||||
|
2 s `Command 02 not sent, the module did not report its settings` with the select republished to the last known
|
||||||
|
level. With the full set of numbers configured a select change also needs every number to have a state, so wait
|
||||||
|
for the boot query answer before judging.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ld2450
|
||||||
|
|
||||||
|
## LD2450 polygon zones (#20154)
|
||||||
|
|
||||||
|
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
|
||||||
|
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
|
||||||
|
Writing those numbers blocks the ld2450 loop about 50 ms.
|
||||||
|
|
||||||
|
## Zone numbers round trip (#21146)
|
||||||
|
|
||||||
|
`scripts/ld2450/zonedrive.py HOST KEY(- for plaintext) set:NAME=VALUE sel:NAME=OPTION wait:S list` sets numbers and
|
||||||
|
selects over the API and prints every LD2450 state update. After each set the hub sends the set zone command, queries
|
||||||
|
the radar and republishes all twelve coordinates from the answer, so set each coordinate of each zone to a distinct
|
||||||
|
value and check the readbacks; no target is needed. Readbacks can arrive late over Wi-Fi, so match them by content.
|
||||||
|
Results: https://github.com/esphome/esphome/pull/21146#issuecomment-6278026922
|
||||||
|
|
||||||
|
## ESP8266 loopback harness, no radar needed (#21349)
|
||||||
|
|
||||||
|
`templates/ld2450-loopback.yaml` (package; substitutions `name` and `baud`) runs the hub on UART0 (GPIO1 and GPIO3)
|
||||||
|
with `logger: baud_rate: 0` and sets the UART0 loopback bit in `on_boot` (`USC0(0) |= (1 << UCLBE)`), so everything
|
||||||
|
the hub or a `uart.write` action sends comes back on RX. Template buttons inject periodic frames (all four direction
|
||||||
|
strings, targets 2 and 3), query zone acks (zone type 0, 1 and 2 for the select readback) and call the name based
|
||||||
|
setters with a valid and a bogus name. `uart: debug: direction: TX` delimited on the command footer dumps every
|
||||||
|
command the hub sends. Drive it with `scripts/ld2450/lddrive.py HOST KEY LOGFILE press:NAME sel:NAME=OPTION wait:S`
|
||||||
|
(logs over the API at VERBOSE, 2 s between steps because the API log stream drops lines while `send_command_`
|
||||||
|
blocks), then `txsum.py LOGFILE` lists the TX frames per step; diff the summaries of the two builds. The hub reads its
|
||||||
|
own commands back and logs `Invalid status` for each, which is harmless. The boot readback of the baud rate select
|
||||||
|
comes from the UART setting, so a second build at `baud: "9600"` checks the flash table lookup.
|
||||||
|
Results: https://github.com/esphome/esphome/pull/21349#issuecomment-6332320026
|
||||||
|
|
||||||
|
## Frame splitter collisions (#21357)
|
||||||
|
|
||||||
|
The loopback template also carries the zone 1 numbers and seven collision buttons: a periodic frame with the
|
||||||
|
command footer inside target 1, zone acks with `55 CC` and with `04 03 02 01` inside the coordinates, a periodic
|
||||||
|
frame cut after 12 bytes followed by a zone ack, a frame missing its last byte followed by a complete one, a bogus
|
||||||
|
255 byte length then a frame, and garbage then a frame. Judge by the state updates plus the `Handling Periodic` and
|
||||||
|
`Handling Ack` lines. Through a real radar the set zone ack is sometimes corrupted by the radar itself (its tail
|
||||||
|
replaced by the periodic frame it was sending, for example `04 03 03 03 00 ... 55 CC`), so count readbacks over at
|
||||||
|
least 24 selects per build and read the raw bytes with `uart: debug: direction: RX` (`!extend` the package's uart
|
||||||
|
id) before blaming the parser; the API log stream drops lines under that dump volume, so some misses stay
|
||||||
|
unclassified.
|
||||||
|
Results: https://github.com/esphome/esphome/pull/21357#issuecomment-6333244886
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# libretiny
|
||||||
|
|
||||||
|
## LibreTiny rtc preference emulation (#21007)
|
||||||
|
|
||||||
|
The PR keeps `storage: rtc` preferences in FlashDB and deletes them on the first load or save after a boot
|
||||||
|
whose `lt_get_reboot_reason()` is not software, watchdog, crash or sleep. Only the families listed in
|
||||||
|
`_libretiny_rtc_supported()` accept the config keys; on others force the backend in with
|
||||||
|
`platformio_options: build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]` after confirming the rejection.
|
||||||
|
|
||||||
|
- Harness: `scripts/libretiny_prefs/ltprefs.hpp` plus `templates/bk72xx-rtc-prefs-dut.yaml`. `ltprefs_boot()`
|
||||||
|
loads one rtc and one flash preference, a second pair that shares one key as rtc and as flash, and a third
|
||||||
|
rtc entry (so a hard reset drops three entries in one pass), logs the reason and both results, saves `100 + step`, syncs; after
|
||||||
|
a templated delay `ltprefs_exit()` bumps the flash-backed step and runs the step's action (`lt_reboot()`,
|
||||||
|
`__builtin_trap()`, `lt_reboot_wdt()` or idle). The last 8 boot reports are kept in a flash preference and
|
||||||
|
repeated in the 10 s alive line, so a boot missed by the capture (a power cut, an OTA-only board) is still
|
||||||
|
readable. Bump `run:` for fresh keys.
|
||||||
|
- A store to an unmapped address does not fault on the ARM968, the board carries on: use `__builtin_trap()`
|
||||||
|
for the crash row. The SDK crash handler reboots about 10 s later. Works on BK7238 and BK7231N.
|
||||||
|
- Hard resets: `serial_capture.py ... --reset` pulses RTS on CEN (reported as Power-On). A real power cut
|
||||||
|
needs a hand or a smart plug; the history in the alive line covers the boot the host cannot see. On the
|
||||||
|
Beken SDK a chip-enable reset and a power cut both report Power-On, there is no separate reset-pin source.
|
||||||
|
- Step 0 and step 8 wait 30 s before acting so the host can open the port (and pulse a reset) after a flash
|
||||||
|
without losing the first boot; the flash tool's own reset starts the sequence immediately otherwise.
|
||||||
|
- OTA-only board: keep the device's wifi, api and ota blocks verbatim, keep safe_mode, read logs with
|
||||||
|
`esphome logs <yaml> --device <name>.local` (the API attaches late, hence the history), and restore the
|
||||||
|
device's own config afterwards.
|
||||||
|
- Plain-build comparison: `raw_firmware.bin` differs by the timestamp and by `__LINE__` in log calls of
|
||||||
|
functions below the added code; map offsets through `objdump -h` load addresses and `addr2line` before
|
||||||
|
calling a byte difference a code change.
|
||||||
|
|
||||||
|
## BK72xx crash report on the next boot (#21012)
|
||||||
|
|
||||||
|
Harness: `scripts/libretiny_crash/bkprobe.hpp` plus `templates/bk72xx-crash-probe-dut.yaml`. A flash
|
||||||
|
preference holds the step; `on_boot` (priority -100) logs the reset reason, the SDK start type, two canaries
|
||||||
|
and hex dumps of the low SRAM, a set of marker words and the record area after `.bss`, waits 30 s on step 0
|
||||||
|
(so a capture can be opened after the flash) or 5 s, saves `step + 1`, syncs, then acts: 0 `__builtin_trap()`,
|
||||||
|
1 call into unmapped memory, 2 unmapped and unaligned reads and a store (no fault on the ARM968), 3
|
||||||
|
`lt_reboot()`. A `globals: restore_value` step counter does not work here: it is only saved from the global's
|
||||||
|
loop, which a crash 50 ms later never reaches; save an `ESPPreferenceObject` and `global_preferences->sync()`
|
||||||
|
first. Bump `run:` for fresh keys. Keep the `wifi:` block: the bug the fix addresses only shows with Wi-Fi up.
|
||||||
|
|
||||||
|
Flow: `flash_bk72xx.py` (retry up to three times), one `serial_capture.py ... --reset` capture of 150 to 230 s,
|
||||||
|
then `grep -a "libretiny.crash\|probe"`. Decode with
|
||||||
|
`arm-none-eabi-addr2line -pfiaC -e .pioenvs/<name>/raw_firmware.elf <addr...>` (the ELF is `raw_firmware.elf`
|
||||||
|
on LibreTiny), or run `scripts/libretiny_crash/decode_capture.py <yaml> <capture>` from the config directory
|
||||||
|
to feed the capture through the CLI's `LogLineProcessor`.
|
||||||
|
|
||||||
|
Expected: `*** CRASH DETECTED ON PREVIOUS BOOT ***`, `Reason: Undefined instruction`, PC on the `udf`,
|
||||||
|
LR = PC + 1, BT0..BT7 walking the lambda, `StatelessLambdaAction`, `Action::play_complex`, `DelayAction`,
|
||||||
|
`Scheduler::call`, `Application::loop`. The wild call reports where the core ended up (it executes zeros as
|
||||||
|
Thumb NOPs until it wraps into real code), not the called address. Without the fix a dev build with Wi-Fi
|
||||||
|
connected never comes back from the crash; see the BK72xx section in `boards.md`.
|
||||||
|
|
||||||
|
OTA-only board: copy the device's own config, add the probe include and the step machine (step 0 crashes
|
||||||
|
45 s after boot, later steps idle), keep its wifi, api, ota and safe_mode blocks and use the secrets file
|
||||||
|
next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the
|
||||||
|
upload in a short timeout. Read the replayed report with `esphome logs ... --device <name>.local` (an API
|
||||||
|
client gets it on subscribe) or on the board's serial log port if it has one.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# mqtt
|
||||||
|
|
||||||
|
## MQTT (#20433, #17935, #20762, #20767, #20768)
|
||||||
|
|
||||||
|
A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives
|
||||||
|
each subscription's QoS, the device address (`New client connected from <ip> as <name>-<mac>`) and exact
|
||||||
|
subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription.
|
||||||
|
Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right
|
||||||
|
after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a
|
||||||
|
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 every listener in the broker config answers again, and prints the broker's message counts with the device log. `mosquitto_sub` cannot speak
|
||||||
|
WebSocket, so it subscribes on the plain listener of the same broker. Cases: `tcp` on 1883, `ws` on 9001, `wss` on
|
||||||
|
9002 with `certificate_authority` (the certificate names an address, so `skip_cert_cn_check: true`), `wss` to
|
||||||
|
test.mosquitto.org on 8081 with no CA for the certificate bundle, and two negatives that must never connect: a
|
||||||
|
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
|
||||||
|
|
||||||
|
## TLS trust sources on ESP-IDF (#14318)
|
||||||
|
|
||||||
|
One build per trust source from `templates/mqtt-tls-dut.yaml`: broker only (port must resolve to 1883),
|
||||||
|
`certificate_authority` with no port (must resolve to 8883), `verify_ssl: false` with no CA, the CA plus
|
||||||
|
`client_certificate` and `client_certificate_key` on the 8884 listener that requires one, `transport: wss` with the CA,
|
||||||
|
`tls: true` to test.mosquitto.org on 8886 for the certificate bundle, and `tls: true` to the local self-signed broker as
|
||||||
|
the negative. `scripts/mqtt/mosquitto-ws.conf` now has the 8883 and 8884 listeners and `scripts/mqtt/make_test_ca.sh`
|
||||||
|
also writes `client.crt`/`client.key`. Before flashing, check each build's `src/esphome/core/defines.h` for
|
||||||
|
`USE_MQTT_TLS`, `USE_MQTT_CERT_BUNDLE` and `USE_MQTT_TLS_INSECURE` and its `sdkconfig.<name>` for
|
||||||
|
`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE` and `CONFIG_ESP_TLS_INSECURE`: only the bundle builds may carry the bundle and only
|
||||||
|
the unverified build the insecure flags. `scripts/mqtt/flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST
|
||||||
|
local|public|neg` runs one build end to end (flash, bounded capture, `cases.sh` with a restart for the local broker,
|
||||||
|
counts and the broker's per-listener connection lines) and prints the config dump's `TLS:` line (`CA certificate`,
|
||||||
|
`certificate bundle` or `no verification`). The CLI side (`esphome/mqtt.py`) is covered without a board by
|
||||||
|
`scripts/mqtt/cli_tls_check.sh TREE [neg] YAML...`, which runs `esphome clean-mqtt` under a timeout: status 124
|
||||||
|
after `Connected to MQTT broker!` is a pass, and with `neg` each config must instead fail with
|
||||||
|
`CERTIFICATE_VERIFY_FAILED` and never connect. Python's default SSL
|
||||||
|
context rejects a CA that lacks a `keyUsage` extension (`CA cert does not include key usage extension`) while mbedTLS
|
||||||
|
on the device accepts it, which is why the test CA carries `basicConstraints` and `keyUsage`. A broker kill shows as one
|
||||||
|
`Last esp-tls error: 0x8008` on every build, TLS or not. Results:
|
||||||
|
https://github.com/esphome/esphome/pull/14318#issuecomment-6311863827
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# nrf52
|
||||||
|
|
||||||
|
## nRF52840 dongle boards (#19009, #20814)
|
||||||
|
|
||||||
|
Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a
|
||||||
|
10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# online_image
|
||||||
|
|
||||||
|
## online_image stalled downloads (#20594)
|
||||||
|
|
||||||
|
One classic ESP32, both frameworks (the ESP32 Arduino build also uses `http_request_idf.cpp`). The host
|
||||||
|
serves the images: `scripts/online_image/make_images.py <www>` writes 16x16 BMP/PNG/JPEG/QOI plus an 8 bit
|
||||||
|
palette BMP with a 1078 byte header; `scripts/online_image/oi_server.py <www> 8765 <log>` answers one
|
||||||
|
misbehaviour per path name (`ok`, `truncated`, `large_header`, `empty`, `stall` holds the socket 40 s,
|
||||||
|
`closed_early`, `slow` pauses 2.5 s, `chunked`, `chunked_cut`, `noclen`, `huge` claims 20 MB) with an ETag
|
||||||
|
and a 304 on If-None-Match. `templates/online-image-dut.yaml` has two AUTO-format images (default and
|
||||||
|
256 byte buffer), API actions `fetch`/`fetch_small` (set_url with `update: false`, then component.update)
|
||||||
|
and `refetch`/`refetch_small` (component.update only, keeps the cache headers), and a restart button.
|
||||||
|
`scripts/online_image/drive.py <device ip> <api key> <capture> http://<host ip>:8765 pr|base` runs the
|
||||||
|
case list over aioesphomeapi and judges each from the serial capture. ESP-IDF facts: a stalled server
|
||||||
|
ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout, an early close with
|
||||||
|
`-1`, both before and after the PR; a body without Content-Length and without chunking reads as complete
|
||||||
|
(Size: 0) and finishes. A build without the fix hangs on the truncated, oversized header, empty, chunked PNG
|
||||||
|
and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs
|
||||||
|
a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list.
|
||||||
|
A case expected as `any` passes on `finished` or `error`, never on no outcome.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# openthread
|
||||||
|
|
||||||
|
## Thread border router (#18104)
|
||||||
|
|
||||||
|
See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# ota
|
||||||
|
|
||||||
|
## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037)
|
||||||
|
|
||||||
|
All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release
|
||||||
|
after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A
|
||||||
|
refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot.
|
||||||
|
`pkill -f "a\|b"` does not alternate; use two pkills.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# rtsp_server
|
||||||
|
|
||||||
|
## RTSP camera streaming on the Waveshare ESP32-S3-ETH (#17990)
|
||||||
|
|
||||||
|
`templates/rtsp-waveshare-s3-eth-dut.yaml` (OV5640 pins, GPIO8 camera power switch with the 1.2 s boot delay,
|
||||||
|
`rtsp_server`, logger VERBOSE for `rtsp_server`); swap the `wifi:` block for the W5500 `ethernet:` block when
|
||||||
|
the cable is in, and add `network: enable_high_performance: true` for the big TCP buffers. Flash over native
|
||||||
|
USB, capture the log with `serial_capture.py`, take the IP from the last `IP Address` line that is not
|
||||||
|
0.0.0.0 (or `dns-sd -Q <name>.local`). `scripts/rtsp_server/rtsp_client.py IP [cases]` covers play, udp,
|
||||||
|
pipeline, badreq, replay, getparam, teardown, third, stall, timeout; `rawrate2.py IP quiet|chatty|rtcp|bigbuf`
|
||||||
|
measures the raw receive rate; `measure.sh IP LABEL LOGFILE [OUTDIR]` runs the fixed set (ping, ffmpeg 12 s
|
||||||
|
twice, two ffmpeg clients, raw reader) and appends to `results.txt`; `cam_api.py IP` pulls one image over the
|
||||||
|
API; `ffmpeg -rtsp_transport tcp -i rtsp://IP:554/ -frames:v 30 -f image2 f%03d.jpg` plus Pillow checks
|
||||||
|
decoding. The 5760 byte default TCP send buffer makes the stream stall over Wi-Fi (about 1 fps, dozens of
|
||||||
|
stall drops per run); `enable_high_performance` gives several fps on Wi-Fi and removes the stalls on Ethernet.
|
||||||
|
Frame counts vary a lot between runs (auto exposure changes the JPEG size), so always measure twice. Home
|
||||||
|
Assistant's stream pipeline does not accept MJPEG over RTSP; its ffmpeg camera opens one RTSP session per
|
||||||
|
snapshot, so `max_connections` needs headroom, and go2rtc transcoding to H.264 is the way to a live view.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# scheduler
|
||||||
|
|
||||||
|
## Scheduler and loop wake A/B (#20356)
|
||||||
|
|
||||||
|
An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266;
|
||||||
|
the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout
|
||||||
|
logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus
|
||||||
|
`button_command`). Build the same yaml twice with different names, one per tree.
|
||||||
@@ -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,26 @@
|
|||||||
|
# udp
|
||||||
|
|
||||||
|
## UDP, multicast, sockets (#20556 to #20559, #20585, #20590)
|
||||||
|
|
||||||
|
A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device
|
||||||
|
multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock
|
||||||
|
listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate
|
||||||
|
(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi
|
||||||
|
reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect`
|
||||||
|
(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action
|
||||||
|
rejects `%.*s`; use a lambda.
|
||||||
|
|
||||||
|
## udp.write payload shapes (#21263, ApplyBytes)
|
||||||
|
|
||||||
|
`templates/udp-write-dut.yaml` puts every payload shape behind a template button (constant 4 bytes twice, 3, 9, 0,
|
||||||
|
128, 129 and 600 bytes, a 300 byte lambda, a simple value, a string) plus an `on_receive` that replies with the
|
||||||
|
received bytes reversed. `scripts/udp/write_cases.sh DEVICE_IP KEY` records the device's datagrams with
|
||||||
|
`udp_listen.py` on the test host, presses the buttons two seconds apart, sends one unicast datagram for the echo and
|
||||||
|
compares everything byte for byte with `check.py`; a missing datagram shifts every later one, so read the first
|
||||||
|
mismatch. Before flashing, check the generated `main.cpp`: on ESP8266 a constant payload is copied out of its
|
||||||
|
`PROGMEM` table into `uint8_t esphome__bytes[N]` up to the stack limit and into `std::make_unique<uint8_t[]>` beyond,
|
||||||
|
identical payloads share one table, a lambda is sent from its vector. An empty constant payload is generated as
|
||||||
|
`send_packet(nullptr, 0)`, and the raw lwIP UDP `sendto` used on ESP8266 rejected a null buffer with `EINVAL`
|
||||||
|
(`sendto() error 22` in the log, no datagram) until it accepted `nullptr` with a zero length; the old
|
||||||
|
`TemplatableBytes::visit` had passed a zero length stack buffer instead. Compare sizes against the stacked PR's own
|
||||||
|
head when the PR is stacked, not against dev. Results: https://github.com/esphome/esphome/pull/21263
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# usb_audio
|
||||||
|
|
||||||
|
## USB audio on the Waveshare ESP32-S3-ETH (#21081, #11728)
|
||||||
|
|
||||||
|
`templates/usb-audio-waveshare-s3-eth-dut.yaml`: ESP-IDF, PoE, logs over the API on Ethernet (the native USB port is
|
||||||
|
the host port, so there is no console), `usb_audio` hub, a 48 kHz 16-bit microphone, a 48 kHz 24-bit speaker (many
|
||||||
|
speakerphones offer only 24-bit; a wrong format logs the device's alternates), buttons for capture start and stop,
|
||||||
|
a non-blocking beep fed from an 8 ms interval with `play(buf, len, 0)` (a blocking `play()` on the main loop trips
|
||||||
|
the task watchdog), and a status interval with mic bytes, speaker state and heap. Drive the buttons with
|
||||||
|
`scripts/api_button.py IP KEY press "Beep"`. Expect 192 kB/s microphone bytes and the FIFO split
|
||||||
|
RX 80 / NPTX 16 / PTX 104 in the build's `defines.h`; with the balanced split the 294-byte OUT pipe is refused
|
||||||
|
(`EP MPS (294) exceeds supported limit (128)`). A device whose OUT endpoint is larger than 600 bytes cannot be
|
||||||
|
claimed on an S3 at all.
|
||||||
|
|
||||||
|
## Music Assistant through sendspin
|
||||||
|
|
||||||
|
`templates/usb-audio-sendspin-s3-eth-dut.yaml` overlays the template above with the Apollo CAST-1 wiring: resampler
|
||||||
|
speakers into a mixer whose inputs never time out, `speaker_source` media player with the sendspin source, and
|
||||||
|
`sendspin: unpaired_access: true`. A `media_player: platform: speaker` is not a media-source consumer and gives
|
||||||
|
"Player over its buffer", no audio. Music Assistant finds the device by mDNS within 15 s of boot.
|
||||||
|
|
||||||
|
Read the result from the device, not only by ear: sendspin's sync task logs `Lost sync (N us off)` (DEBUG, tag
|
||||||
|
`SsSync`) for every correction over 5 ms, a positive N inserts that much silence, and each one is an audible click.
|
||||||
|
For the chunk lead histogram, edit the managed library in the build directory,
|
||||||
|
`.esphome/build/<name>/managed_components/sendspin__sendspin-cpp/src/sync_task.cpp` (an `SS_LOGD` before the
|
||||||
|
late-chunk skip in `decode_chunk()` printing the chunk's lead against `new_audio_client_playtime`), update only that
|
||||||
|
file's `size` and sha256 `hash` in the component's `CHECKSUMS.json`, and compile again; deleting `.component_hash` or
|
||||||
|
recomputing it makes the manager refuse or refetch. Check the edit is in the image with `strings firmware.ota.bin`.
|
||||||
|
A lead below -5 ms makes the library skip the whole chunk; an I2S speaker keeps it within 0.1 ms of zero, which is
|
||||||
|
the figure a USB speaker has to match.
|
||||||
|
|
||||||
|
Gotchas: wait two minutes after a reboot before the measured stream (safe mode's boot counter write stalls the cores
|
||||||
|
for about 140 ms once, 60 to 75 s after boot); with the serial console on, a DEBUG log line per report saturates
|
||||||
|
UART0 and trips the task watchdog, so use `logger: baud_rate: 0` and the API log; two overlapping `esphome logs`
|
||||||
|
captures double every count; and every audio test must include a pause and resume cycle, since a stream end that
|
||||||
|
leaves less than one transfer parked in the driver was what hung the speaker after #11728 merged.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
Apollo CAST PRO-1 (`templates/cast-pro-1-sendspin-control.yaml`): `Core.yaml` next to it is
|
||||||
|
`Integrations/ESPHome/Core.yaml` from ApolloAutomation/CAST_PRO-1, which also defines the `version` substitution.
|
||||||
|
Keep its framework log level at ERROR (DEBUG floods driver output and trips the watchdog); the package reboots itself
|
||||||
|
into Ethernet-only once it sees a link and appends the MAC suffix to the name. `wifi: enable_on_boot: false` keeps
|
||||||
|
sendspin off Wi-Fi.
|
||||||
|
|
||||||
|
Nabu Casa Connect AUX-2 (a pre-production unit): build from `packages/aux-2-pre-prod.yaml` at NabuCasa/aux-2 commit
|
||||||
|
283f2e64 (dropped in bde41e01), not the public `aux-2.yaml`, whose W5500, codec and I2S pins differ (the public one
|
||||||
|
reads W5500 chip ID 0x00 and fails the codec's I2C writes). Point its local `external_components` source at
|
||||||
|
`github://NabuCasa/aux-2@main`. Templates `aux-2-preprod-stock-dut.yaml` (I2S codec) and
|
||||||
|
`aux-2-preprod-usb-audio-dut.yaml` (the USB speaker as the mixer output via `!extend mixing_speaker`). The console is
|
||||||
|
the CH343 (1a86:55d3); first flash over it, OTA after. Forcing the Wi-Fi flag off with `restore_value: no` boot-loops
|
||||||
|
when Ethernet fails, so let the package switch itself.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# usb_cdc_acm
|
||||||
|
|
||||||
|
## usb_cdc_acm on the nRF52840 dongle (#11634)
|
||||||
|
|
||||||
|
`templates/usb-cdc-acm-dongle-dut.yaml`: logger on USB_CDC port 0, `usb_cdc_acm` interface 0 disabled,
|
||||||
|
interface 1 echoing from a 20 ms lambda, 0x01-prefixed commands for burst, stream, flush, peek, line coding
|
||||||
|
and counters. Build and flash with `dfu: true` through the app port; the dongle then shows three CDC ports:
|
||||||
|
the board DTS node `cdc_acm_uart` (unused), `cdc_acm_uart0` (log) and `cdc_acm_uart1` (data), in that order.
|
||||||
|
Capture the log port with `serial_capture.py`, drive the data port with `scripts/usb_cdc_acm/drive.py
|
||||||
|
DATA_PORT` (11 cases, prints PASS/FAIL and a summary) and grep the log for `Opened`, `Closed`, `buffer full`,
|
||||||
|
`flush result`, `line coding`. Boot lines are held in the driver's 1 KB ring until the host opens the port, so
|
||||||
|
a capture started later still shows the first 1 KB of the boot and then a truncated line.
|
||||||
|
|
||||||
|
With three CDC instances the Zephyr legacy stack's `CONFIG_USB_MAX_NUM_TRANSFERS=4` starves the last instance
|
||||||
|
(each instance holds an OUT read and an IN ZLP), so interface 1 shows DTR changes but moves no data.
|
||||||
|
`scripts/usb_cdc_acm/cdc_probe.hpp` (include it, call `cdc_probe()` from an interval) reads the Zephyr driver
|
||||||
|
directly to tell a dead endpoint from a component bug. Raising the transfer pool to 8, or disabling the
|
||||||
|
board's `cdc_acm_uart` node, fixes it. Design limit of the driver: `fifo_fill` holds `tx_ready` until the
|
||||||
|
write callback and `write_array` drops when the ring is full, so a 64 KiB full-duplex echo loses one 64 byte
|
||||||
|
tail on every buffer size.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
## Entity icon and device class readback (#21111)
|
||||||
|
|
||||||
|
Covers every path that reads an entity's icon or device class: the native API listing, MQTT discovery, the web_server
|
||||||
|
`/events` listing and REST endpoints, and `dump_config`. `templates/entity-icon-dut.yaml` (ESP8266 board with wifi,
|
||||||
|
encrypted api, ota, web_server and mqtt against a local broker) carries a sensor with a 63 character icon, one with no
|
||||||
|
icon or device class, an internal sensor, and binary_sensor, switch, button, text_sensor, number, select, event, cover
|
||||||
|
and text entities with icons and device classes. The per-run file supplies `name` and `broker_ip` through
|
||||||
|
substitutions. Flash the PR and base builds under different names, then from the scratch directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/entity_icons/readback.sh DEVICE_IP KEY NAME BROKER_HOST cap-NAME.txt # once per build
|
||||||
|
scripts/entity_icons/compare.sh ic-base ic-pr # api, web, mqttn, dump all identical
|
||||||
|
```
|
||||||
|
|
||||||
|
`readback.sh` takes about 25 s once the device is on the network: 12 s of `mosquitto_sub` on `homeassistant/#` and
|
||||||
|
`NAME/#`, the API listing (`api_list.py`, one JSON line per entity with icon, device class, unit, options and event
|
||||||
|
types) and `web_dump.py` (6 s on `/events`, keeping only the listing rows that carry `domain`, then a GET of each
|
||||||
|
listed id with `?detail=all` and one retry, because the ESP8266 async server occasionally leaves a request unanswered
|
||||||
|
within 5 s). The web ids are `domain/Name`, not object ids, and `/event/<name>` has no GET handler (404 on every
|
||||||
|
build). `mqtt_norm.py` drops the `dev` block (compile time) and the `NAME/debug` topic (heap probe), so a clean run
|
||||||
|
differs in nothing. `dump_config` prints icons and device classes only for some domains; button, event and
|
||||||
|
binary_sensor icons never appear there.
|
||||||
|
|
||||||
|
Found with this flow on 2026-10-21: since #19577 every ESP8266 `web_server` build that lists a cover, climate, lock,
|
||||||
|
valve, update, alarm_control_panel or water_heater entity reboots with `Exception (3) LoadStoreErr` in
|
||||||
|
`json::linked(const char *)` (the ROM `strlen` on a flash address), fixed by #21316. The cover in the template keeps
|
||||||
|
that path covered; before #21316 is merged, apply its one line change to both trees or the web_server comparison
|
||||||
|
cannot run.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# wifi
|
||||||
|
|
||||||
|
## `reboot_timeout` with a fallback AP (#9958)
|
||||||
|
|
||||||
|
Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network,
|
||||||
|
`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and
|
||||||
|
ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes:
|
||||||
|
`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles
|
||||||
|
are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot
|
||||||
|
with no station joined, no reboot for minutes while one is, then a reboot within a second of the last
|
||||||
|
disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way.
|
||||||
|
|
||||||
|
## DHCP lease cache (#17767)
|
||||||
|
|
||||||
|
Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the
|
||||||
|
`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep
|
||||||
|
sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use
|
||||||
|
`upload` then `logs`.
|
||||||
|
|
||||||
|
## Wi-Fi Easy Connect (DPP) enrollee (#17934)
|
||||||
|
|
||||||
|
Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's
|
||||||
|
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
|
||||||
|
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
|
||||||
|
supplicant state machine.
|
||||||
|
|
||||||
|
## WISP bridge with an ethernet upstream (#7147)
|
||||||
|
|
||||||
|
`templates/wisp-ethernet-dut.yaml` (ESP32-S3 with a W5500, `wifi: ap:` with `ap_mode: WISP` and no station) and
|
||||||
|
`templates/wisp-client.yaml` (D1 mini joining the bridge, `http_request` to a web server on the wired LAN and to
|
||||||
|
`http://example.com/` every 15 s, `time: sntp`). Secrets: `wisp_psk` and `lan_http` (serve a text file on the host
|
||||||
|
with `python -m http.server 8787 --bind 0.0.0.0` from a scratch directory). Judge by the client's `GET status 200`
|
||||||
|
lines, the web server log showing the device's ethernet address as the source (NAT), the client's DHCP info (DNS1 is
|
||||||
|
the LAN resolver) and `SNTP synced`. The device logs `Starting AP` and `DHCP DNS server: <resolver>` on its USB
|
||||||
|
console; flashing over the air reboots it for repeat boots. Before the AP address and DHCP server moved ahead of the
|
||||||
|
AP start (the fix in that PR), the first AP start failed on every boot with `esp_netif_dhcps_option failed: 20484`
|
||||||
|
(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the
|
||||||
|
retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix
|
||||||
|
the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# zephyr
|
||||||
|
|
||||||
|
## Zephyr preferences on the nRF52840 dongle (#20810)
|
||||||
|
|
||||||
|
`templates/nrf52-preferences-dut.yaml`: logger on USB_CDC at VERBOSE, `flash_write_interval: 5s`, one
|
||||||
|
`globals` with `restore_value` incremented every 15 s. Give each build its own global id so each first boot
|
||||||
|
sees a never-saved key (settings survive a reflash). Flow per build: flash through the log port, 40 s capture
|
||||||
|
of the first boot, then start a second capture and reboot with a 2001 baud touch on the dongle's other CDC
|
||||||
|
port (the dfu rate callback is global, and the capture holds the log port); `serial_capture.py` reopens the
|
||||||
|
log port after the USB re-enumeration and gets the buffered boot lines (`load setting`, `Loaded N settings`).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# zephyr_coredump
|
||||||
|
|
||||||
|
## zephyr_coredump crash and decode on nRF52 boards (#13122)
|
||||||
|
|
||||||
|
`templates/nrf52-coredump-crash-dut.yaml` (dongle; change `board:` to `adafruit_itsybitsy_nrf52840` for the
|
||||||
|
ItsyBitsy): `zephyr_coredump:`, `dfu: true`, a `restore_value` global keyed by the `run` substitution
|
||||||
|
(settings survive a reflash, so bump `run` for every new crash), and an `on_boot` sequence that sets the
|
||||||
|
flag, yields 3 s so the globals loop saves it (a blocking lambda never gets there and the board crash-loops),
|
||||||
|
syncs preferences, then writes to 0xDEADBEEF. Flash, keep every port closed for 80 s, then
|
||||||
|
`scripts/zephyr_coredump/flow.sh LABEL PORT ELF [OUTDIR]` does the three opens (3 s cut, 100 s full print,
|
||||||
|
20 s reopen) and calls `decode.sh`, which keeps the first complete `#CD:BEGIN#..END#` block, runs Zephyr's
|
||||||
|
`coredump_serial_log_parser.py` and `coredump_gdbserver.py` from the sdk-nrf ESPHome installed and prints
|
||||||
|
`bt` and `info threads` with its `arm-zephyr-eabi-gdb`. The ELF is `.pioenvs/<name>/zephyr/zephyr/zephyr.elf`.
|
||||||
|
Expect about 9 KB dumps, 140 to 150 `#CD:` lines, a 673 ms loop warning for the erase, and `Has stored
|
||||||
|
dump: 0` on the reopen. Check `compiled on` in every capture: another session flashing the same board
|
||||||
|
mid-test looks exactly like a failed test otherwise. Boards without a network cannot exercise the API and
|
||||||
|
MQTT delivery paths of the component; say so in the results.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# zwave_proxy
|
||||||
|
|
||||||
|
## zwave_proxy frame checksums on a live proxy (#20922)
|
||||||
|
|
||||||
|
A Z-Wave proxy (ESP32-S3 with the module on `usb_uart`) is a live device: flash it over OTA and read logs
|
||||||
|
over the API, there is no serial console because the native USB is the host port for the module, so the
|
||||||
|
boot-time `GET_NETWORK_IDS` exchange is never captured. Home Assistant's Z-Wave JS holds the single API
|
||||||
|
subscription (it polls `0x3B` every 30 s and resubscribes after every reboot), so do not subscribe from a
|
||||||
|
script, and keep the device `name:` so it stays on Z-Wave JS; give each build its own scratch directory
|
||||||
|
instead.
|
||||||
|
|
||||||
|
1. `logger: level: VERY_VERBOSE` with `logs: zwave_proxy: VERY_VERBOSE` and DEBUG for the rest: the
|
||||||
|
`Sending:`, `Received frame:` and `CHECKSUM Received ... Calculated` lines are `ESP_LOGVV`, which needs
|
||||||
|
the compile-time level.
|
||||||
|
2. `md5` the two `firmware.ota.bin` files to be sure the A and B builds differ, then `esphome upload
|
||||||
|
--device <name>.local` and a `timeout 330 esphome logs --device <name>.local` capture started right after
|
||||||
|
the OTA to catch the resubscribe traffic.
|
||||||
|
3. `scripts/zwave_proxy/verify_checksums.py <captures>` recomputes the XOR (init 0xFF, LENGTH..payload) over
|
||||||
|
every logged frame and counts bad checksums and ACK/NAK sent and received; `scripts/zwave_proxy/devinfo.py
|
||||||
|
<name>.local` prints the firmware version, compile time and the Home ID from the API (a Home ID proves the
|
||||||
|
device-built `GET_NETWORK_IDS` frame was accepted at boot).
|
||||||
|
4. Pre-existing: one `zwave_proxy took a long time for an operation` warning on the first frame after the
|
||||||
|
log client connects, caused by VERY_VERBOSE logging; it appears on dev too.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Plaintext native API client that lists user actions with their raw argument fields and executes one.
|
||||||
|
|
||||||
|
usage: rawactions.py HOST list
|
||||||
|
rawactions.py HOST exec ACTION key=value [...] (every argument must be given; types from the listing)
|
||||||
|
Fields of ListEntitiesServicesArgument: 1 name, 2 type, 3 description, 4 example, 5 optional, 6 default_value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HOST = sys.argv[1]
|
||||||
|
CMD = sys.argv[2]
|
||||||
|
ARGS = sys.argv[3:]
|
||||||
|
TYPE_NAMES = {
|
||||||
|
0: "bool",
|
||||||
|
1: "int",
|
||||||
|
2: "float",
|
||||||
|
3: "string",
|
||||||
|
4: "bool[]",
|
||||||
|
5: "int[]",
|
||||||
|
6: "float[]",
|
||||||
|
7: "string[]",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def varint(v: int) -> bytes:
|
||||||
|
out = bytearray()
|
||||||
|
while True:
|
||||||
|
b = v & 0x7F
|
||||||
|
v >>= 7
|
||||||
|
if v:
|
||||||
|
out.append(b | 0x80)
|
||||||
|
else:
|
||||||
|
out.append(b)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def svarint(v: int) -> bytes:
|
||||||
|
return varint((v << 1) ^ (v >> 63))
|
||||||
|
|
||||||
|
|
||||||
|
def decode_varint(buf: bytes, pos: int) -> tuple[int, int]:
|
||||||
|
value = shift = 0
|
||||||
|
while True:
|
||||||
|
b = buf[pos]
|
||||||
|
pos += 1
|
||||||
|
value |= (b & 0x7F) << shift
|
||||||
|
if not b & 0x80:
|
||||||
|
return value, pos
|
||||||
|
shift += 7
|
||||||
|
|
||||||
|
|
||||||
|
def fields(payload: bytes) -> list[tuple[int, int, object]]:
|
||||||
|
out = []
|
||||||
|
pos = 0
|
||||||
|
while pos < len(payload):
|
||||||
|
key, pos = decode_varint(payload, pos)
|
||||||
|
fnum, wt = key >> 3, key & 7
|
||||||
|
if wt == 0:
|
||||||
|
v, pos = decode_varint(payload, pos)
|
||||||
|
elif wt == 2:
|
||||||
|
ln, pos = decode_varint(payload, pos)
|
||||||
|
v = payload[pos : pos + ln]
|
||||||
|
pos += ln
|
||||||
|
elif wt == 5:
|
||||||
|
v = payload[pos : pos + 4] # fixed32, caller unpacks
|
||||||
|
pos += 4
|
||||||
|
else:
|
||||||
|
raise ValueError(f"wire type {wt}")
|
||||||
|
out.append((fnum, wt, v))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def frame(mtype: int, payload: bytes) -> bytes:
|
||||||
|
return b"\x00" + varint(len(payload)) + varint(mtype) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def field_bytes(fnum: int, data: bytes) -> bytes:
|
||||||
|
return varint((fnum << 3) | 2) + varint(len(data)) + data
|
||||||
|
|
||||||
|
|
||||||
|
def field_varint(fnum: int, v: int) -> bytes:
|
||||||
|
return varint((fnum << 3) | 0) + varint(v)
|
||||||
|
|
||||||
|
|
||||||
|
def recv_frames(
|
||||||
|
sock: socket.socket, until: set[int], limit: float = 5.0
|
||||||
|
) -> list[tuple[int, bytes]]:
|
||||||
|
sock.settimeout(limit)
|
||||||
|
buf = bytearray()
|
||||||
|
frames = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
except TimeoutError:
|
||||||
|
return frames
|
||||||
|
if not chunk:
|
||||||
|
return frames
|
||||||
|
buf += chunk
|
||||||
|
while True:
|
||||||
|
if len(buf) < 3:
|
||||||
|
break
|
||||||
|
if buf[0] != 0:
|
||||||
|
raise ValueError("not plaintext")
|
||||||
|
try:
|
||||||
|
ln, p = decode_varint(buf, 1)
|
||||||
|
mt, p = decode_varint(buf, p)
|
||||||
|
except IndexError:
|
||||||
|
break
|
||||||
|
if len(buf) < p + ln:
|
||||||
|
break
|
||||||
|
frames.append((mt, bytes(buf[p : p + ln])))
|
||||||
|
del buf[: p + ln]
|
||||||
|
if mt in until:
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
sock = socket.create_connection((HOST, 6053), timeout=5)
|
||||||
|
sock.sendall(
|
||||||
|
frame(
|
||||||
|
1, field_bytes(1, b"rawactions") + field_varint(2, 1) + field_varint(3, 10)
|
||||||
|
)
|
||||||
|
) # HelloRequest
|
||||||
|
recv_frames(sock, {2})
|
||||||
|
sock.sendall(frame(3, b"")) # ConnectRequest, no password
|
||||||
|
recv_frames(sock, {4})
|
||||||
|
sock.sendall(frame(11, b"")) # ListEntitiesRequest
|
||||||
|
frames = recv_frames(sock, {19})
|
||||||
|
services = {}
|
||||||
|
for mt, payload in frames:
|
||||||
|
if mt != 41:
|
||||||
|
continue
|
||||||
|
name = key = None
|
||||||
|
args = []
|
||||||
|
for fnum, _, v in fields(payload):
|
||||||
|
if fnum == 1:
|
||||||
|
name = v.decode()
|
||||||
|
elif fnum == 2:
|
||||||
|
key = struct.unpack("<I", v)[0]
|
||||||
|
elif fnum == 3:
|
||||||
|
a = {
|
||||||
|
"name": None,
|
||||||
|
"type": 0,
|
||||||
|
"description": None,
|
||||||
|
"example": None,
|
||||||
|
"optional": None,
|
||||||
|
"default": None,
|
||||||
|
}
|
||||||
|
for af, _, av in fields(v):
|
||||||
|
if af == 1:
|
||||||
|
a["name"] = av.decode()
|
||||||
|
elif af == 2:
|
||||||
|
a["type"] = av
|
||||||
|
elif af == 3:
|
||||||
|
a["description"] = av.decode()
|
||||||
|
elif af == 4:
|
||||||
|
a["example"] = av.decode()
|
||||||
|
elif af == 5:
|
||||||
|
a["optional"] = bool(av)
|
||||||
|
elif af == 6:
|
||||||
|
a["default"] = av.decode()
|
||||||
|
else:
|
||||||
|
a[f"field{af}"] = av
|
||||||
|
args.append(a)
|
||||||
|
services[name] = (key, args)
|
||||||
|
if CMD == "list":
|
||||||
|
for name, (key, args) in services.items():
|
||||||
|
print(f"action {name} key={key}")
|
||||||
|
for a in args:
|
||||||
|
extra = {
|
||||||
|
k: v
|
||||||
|
for k, v in a.items()
|
||||||
|
if k not in ("name", "type") and v is not None
|
||||||
|
}
|
||||||
|
print(f" {a['name']}: {TYPE_NAMES.get(a['type'], a['type'])} {extra}")
|
||||||
|
return
|
||||||
|
name = ARGS[0]
|
||||||
|
key, args = services[name]
|
||||||
|
payload = varint((1 << 3) | 5) + struct.pack("<I", key)
|
||||||
|
given = dict(kv.split("=", 1) for kv in ARGS[1:])
|
||||||
|
for a in args:
|
||||||
|
if a["name"] not in given:
|
||||||
|
if os.environ.get("ALLOW_MISSING"):
|
||||||
|
continue # leave the argument out of the request on purpose
|
||||||
|
sys.exit(f"missing {a['name']}")
|
||||||
|
raw = given[a["name"]]
|
||||||
|
t = a["type"]
|
||||||
|
if t == 0:
|
||||||
|
av = field_varint(1, 1 if raw.lower() == "true" else 0)
|
||||||
|
elif t == 1:
|
||||||
|
av = varint((5 << 3) | 0) + svarint(int(raw)) # sint32 int_
|
||||||
|
elif t == 2:
|
||||||
|
av = varint((3 << 3) | 5) + struct.pack("<f", float(raw))
|
||||||
|
elif t == 3:
|
||||||
|
av = field_bytes(4, raw.encode())
|
||||||
|
elif t == 5:
|
||||||
|
av = b"".join(
|
||||||
|
varint((7 << 3) | 0) + svarint(int(x)) for x in raw.split(",") if x
|
||||||
|
) # repeated sint32 int_array
|
||||||
|
else:
|
||||||
|
sys.exit(f"type {t} not supported here")
|
||||||
|
payload += field_bytes(2, av)
|
||||||
|
sock.sendall(frame(42, payload)) # ExecuteServiceRequest
|
||||||
|
print(f"sent {name} with {given}")
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Press a template button on the test device over the API, or list its buttons.
|
||||||
|
|
||||||
|
usage: api_button.py HOST KEY list | press "Button name" [...] (KEY of - means a plaintext API)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient, ButtonInfo
|
||||||
|
|
||||||
|
HOST, KEY = sys.argv[1], sys.argv[2]
|
||||||
|
CMD, ARGS = sys.argv[3], sys.argv[4:]
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||||
|
await c.connect(login=True)
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||||
|
if CMD == "list":
|
||||||
|
print("\n".join(sorted(buttons)))
|
||||||
|
else:
|
||||||
|
for name in ARGS:
|
||||||
|
if name not in buttons:
|
||||||
|
sys.exit(f"no button named {name!r}; have {sorted(buttons)}")
|
||||||
|
c.button_command(buttons[name].key)
|
||||||
|
print("pressed", name)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Per wake cycle timings from a serial capture of the deep sleep timing config.
|
||||||
|
|
||||||
|
usage: cycles.py CAPTURE [LABEL]
|
||||||
|
A cycle starts at the boot banner; the numbers are the device's millis() from the timing lines, and the
|
||||||
|
capture clock gap from the first line of the cycle to the "Dialing" line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
|
||||||
|
cap = (
|
||||||
|
pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
)
|
||||||
|
label = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
|
TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]")
|
||||||
|
|
||||||
|
|
||||||
|
def secs(line):
|
||||||
|
m = TS.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
h, mi, s, f = m.groups()
|
||||||
|
return int(h) * 3600 + int(mi) * 60 + int(s) + (int(f) / 10 ** len(f) if f else 0)
|
||||||
|
|
||||||
|
|
||||||
|
cycles = []
|
||||||
|
cur = None
|
||||||
|
for line in cap:
|
||||||
|
if "rst:0x" in line or (cur is None and "Log initialized" in line):
|
||||||
|
cur = {"start": secs(line), "reset": line.split("]", 1)[-1].strip()[:60]}
|
||||||
|
cycles.append(cur)
|
||||||
|
continue
|
||||||
|
if cur is None:
|
||||||
|
continue
|
||||||
|
if "Dialing" in line:
|
||||||
|
cur.setdefault("dial_cap", secs(line))
|
||||||
|
m = re.search(r"wifi connected at (\d+) ms", line)
|
||||||
|
if m:
|
||||||
|
cur.setdefault("wifi", int(m.group(1)))
|
||||||
|
m = re.search(r"client connected at (\d+) ms \((.*?)\)", line)
|
||||||
|
if m and "client" not in cur:
|
||||||
|
cur["client"] = int(m.group(1))
|
||||||
|
cur["who"] = m.group(2)
|
||||||
|
m = re.search(r"first HA state '(.*?)' at (\d+) ms", line)
|
||||||
|
if m and "state" not in cur:
|
||||||
|
cur["state"] = int(m.group(2))
|
||||||
|
|
||||||
|
print(f"{label} cycles: {len(cycles)}")
|
||||||
|
print(
|
||||||
|
f"{'n':>2} {'wifi ms':>8} {'dial +ms':>9} {'client ms':>10} {'state ms':>9} {'client-wifi':>11} {'state-client':>12} client reset"
|
||||||
|
)
|
||||||
|
for i, c in enumerate(cycles, 1):
|
||||||
|
dial = ""
|
||||||
|
if c.get("dial_cap") is not None and c.get("start") is not None:
|
||||||
|
dial = f"{(c['dial_cap'] - c['start']) * 1000:.0f}"
|
||||||
|
cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else ""
|
||||||
|
sc = c["state"] - c["client"] if "state" in c and "client" in c else ""
|
||||||
|
print(
|
||||||
|
f"{i:>2} {c.get('wifi', ''):>8} {dial:>9} {c.get('client', ''):>10} {c.get('state', ''):>9} {cw:>11} {sc:>12} {c.get('who', '')} {c.get('reset', '')[:28]}"
|
||||||
|
)
|
||||||
|
for c in cycles:
|
||||||
|
if "client" in c and "wifi" in c:
|
||||||
|
c["client-wifi"] = c["client"] - c["wifi"]
|
||||||
|
if "state" in c and "client" in c:
|
||||||
|
c["state-client"] = c["state"] - c["client"]
|
||||||
|
for key in ("wifi", "client", "state", "client-wifi", "state-client"):
|
||||||
|
vals = [c[key] for c in cycles if key in c]
|
||||||
|
if vals:
|
||||||
|
print(
|
||||||
|
f"{key}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Per Wi-Fi drop timings from a serial capture of the outgoing connection timing config.
|
||||||
|
|
||||||
|
usage: wificycles.py CAPTURE [LABEL]
|
||||||
|
A cycle starts at "wifi cycle disabling"; the numbers are the device's millis() from the timing lines.
|
||||||
|
"dial" says whether the client that connected first came from the device's own dial ("Dialing" seen after the
|
||||||
|
reconnect and before the hello) or from an incoming connection ("Accept" seen first).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
|
||||||
|
cap = (
|
||||||
|
pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
)
|
||||||
|
label = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
|
TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]")
|
||||||
|
|
||||||
|
|
||||||
|
def secs(line):
|
||||||
|
m = TS.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
h, mi, se, f = m.groups()
|
||||||
|
return int(h) * 3600 + int(mi) * 60 + int(se) + (int(f) / 10 ** len(f) if f else 0)
|
||||||
|
|
||||||
|
|
||||||
|
cycles = []
|
||||||
|
cur = None
|
||||||
|
loop_max = []
|
||||||
|
for line in cap:
|
||||||
|
m = re.search(r"loop time max (\d+) ms", line)
|
||||||
|
if m:
|
||||||
|
loop_max.append(int(m.group(1)))
|
||||||
|
if "wifi cycle disabling" in line:
|
||||||
|
cur = {"order": []}
|
||||||
|
cycles.append(cur)
|
||||||
|
continue
|
||||||
|
if cur is None:
|
||||||
|
continue
|
||||||
|
m = re.search(r"wifi connected at (\d+) ms", line)
|
||||||
|
if m and "wifi" not in cur:
|
||||||
|
cur["wifi"] = int(m.group(1))
|
||||||
|
cur["wifi_cap"] = secs(line)
|
||||||
|
if "Dialing" in line:
|
||||||
|
cur["order"].append("dial")
|
||||||
|
cur["dials"] = cur.get("dials", 0) + 1
|
||||||
|
if "dial_cap" not in cur and "wifi_cap" in cur:
|
||||||
|
cur["dial_cap"] = secs(line)
|
||||||
|
elif "dial_cap" not in cur and "dial_before_wifi_cap" not in cur:
|
||||||
|
cur["dial_before_wifi_cap"] = secs(line)
|
||||||
|
if (
|
||||||
|
re.search(r"client disconnected at \d+ ms \(\d+\.\d+\.\d+\.\d+\)", line)
|
||||||
|
and "wifi" in cur
|
||||||
|
and "client" not in cur
|
||||||
|
):
|
||||||
|
cur["dial_closed"] = cur.get("dial_closed", 0) + 1
|
||||||
|
if "Accept " in line and "wifi" in cur:
|
||||||
|
cur["order"].append("accept")
|
||||||
|
m = re.search(r"client connected at (\d+) ms \((.*?)\)", line)
|
||||||
|
if m and "wifi" in cur and "client" not in cur:
|
||||||
|
cur["client"] = int(m.group(1))
|
||||||
|
cur["who"] = m.group(2).split(" ")[0]
|
||||||
|
cur["via"] = cur["order"][0] if cur["order"] else "?"
|
||||||
|
m = re.search(r"Connect (failed|timeout)", line)
|
||||||
|
if m:
|
||||||
|
cur.setdefault("connect_errors", 0)
|
||||||
|
cur["connect_errors"] += 1
|
||||||
|
|
||||||
|
print(f"{label} cycles: {len(cycles)}")
|
||||||
|
print(
|
||||||
|
f"{'n':>2} {'wifi ms':>8} {'dial-wifi cap ms':>16} {'client ms':>10} {'client-wifi':>11} via dials closed client"
|
||||||
|
)
|
||||||
|
for i, c in enumerate(cycles, 1):
|
||||||
|
cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else ""
|
||||||
|
c["client-wifi"] = cw
|
||||||
|
dw = ""
|
||||||
|
if "wifi_cap" in c and c.get("wifi_cap") is not None:
|
||||||
|
# the first dial of the cycle, whether it was logged before or after the Wi-Fi line
|
||||||
|
if c.get("dial_before_wifi_cap") is not None:
|
||||||
|
dw = f"{(c['dial_before_wifi_cap'] - c['wifi_cap']) * 1000:.0f}"
|
||||||
|
elif "dial_cap" in c and c["dial_cap"] is not None:
|
||||||
|
dw = f"{(c['dial_cap'] - c['wifi_cap']) * 1000:.0f}"
|
||||||
|
c["dial-wifi"] = dw
|
||||||
|
print(
|
||||||
|
f"{i:>2} {c.get('wifi', ''):>8} {dw:>16} {c.get('client', ''):>10} {cw:>11} {c.get('via', ''):7s} {c.get('dials', 0):>5} {c.get('dial_closed', 0):>6} {c.get('who', '')}"
|
||||||
|
)
|
||||||
|
for via in ("dial", "accept"):
|
||||||
|
vals = [
|
||||||
|
c["client-wifi"]
|
||||||
|
for c in cycles
|
||||||
|
if c.get("via") == via and c["client-wifi"] != ""
|
||||||
|
]
|
||||||
|
if vals:
|
||||||
|
print(
|
||||||
|
f"client-wifi via {via}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}"
|
||||||
|
)
|
||||||
|
dws = [float(c["dial-wifi"]) for c in cycles if c.get("dial-wifi") not in ("", None)]
|
||||||
|
if dws:
|
||||||
|
print(
|
||||||
|
f"dial after wifi connected (capture clock): n={len(dws)} min={min(dws):.0f} median={statistics.median(dws):.0f} max={max(dws):.0f}"
|
||||||
|
)
|
||||||
|
if loop_max:
|
||||||
|
print(
|
||||||
|
f"loop time max per 5 s: n={len(loop_max)} min={min(loop_max)} median={statistics.median(loop_max):.0f} max={max(loop_max)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Subscribe to states and logs of a device over the API for N seconds and summarize its float sensors.
|
||||||
|
|
||||||
|
usage: observe.py IP SECONDS OUTFILE [NAME_FILTER]
|
||||||
|
|
||||||
|
Every float sensor state whose entity name contains NAME_FILTER (default: all) is appended to OUTFILE
|
||||||
|
with a relative timestamp, log lines are appended with a LOG prefix, and a one-line summary per sensor
|
||||||
|
(count, min, max, last) plus the warning and error count is printed at the end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient, LogLevel
|
||||||
|
|
||||||
|
IP, SECS, OUT = sys.argv[1], float(sys.argv[2]), sys.argv[3]
|
||||||
|
FILTER = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||||
|
# ESPHome LOG_SECRET span; one cut off before its end marker is secret to the end of the line
|
||||||
|
SECRET = re.compile(r"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL)
|
||||||
|
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
c = APIClient(IP, 6053, None)
|
||||||
|
await c.connect(login=True)
|
||||||
|
di = await c.device_info()
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
names = {e.key: e.name for e in ents}
|
||||||
|
readings: dict[str, list[float]] = {}
|
||||||
|
warnings: list[str] = []
|
||||||
|
t0 = time.monotonic()
|
||||||
|
with pathlib.Path(OUT).open("w", encoding="utf-8") as out:
|
||||||
|
out.write(
|
||||||
|
f"device {di.name} version {di.esphome_version} compiled {di.compilation_time}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_state(st) -> None:
|
||||||
|
n = names.get(st.key, str(st.key))
|
||||||
|
if FILTER in n and hasattr(st, "state") and isinstance(st.state, float):
|
||||||
|
readings.setdefault(n, []).append(st.state)
|
||||||
|
out.write(f"[{time.monotonic() - t0:6.1f}] {n}: {st.state}\n")
|
||||||
|
out.flush()
|
||||||
|
|
||||||
|
def on_log(msg) -> None:
|
||||||
|
line = (
|
||||||
|
msg.message.decode(errors="replace")
|
||||||
|
if isinstance(msg.message, bytes)
|
||||||
|
else msg.message
|
||||||
|
)
|
||||||
|
line = ANSI.sub("", SECRET.sub("<secret>", line))
|
||||||
|
out.write(f"[{time.monotonic() - t0:6.1f}] LOG {line}\n")
|
||||||
|
out.flush()
|
||||||
|
if "[W]" in line or "[E]" in line or "Invalid" in line:
|
||||||
|
warnings.append(line)
|
||||||
|
|
||||||
|
c.subscribe_states(on_state)
|
||||||
|
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
|
||||||
|
await asyncio.sleep(SECS)
|
||||||
|
await c.disconnect()
|
||||||
|
summary = "; ".join(
|
||||||
|
f"{k.split()[-1]} n={len(v)} min={min(v):.3f} max={max(v):.3f} last={v[-1]:.3f}"
|
||||||
|
for k, v in sorted(readings.items())
|
||||||
|
)
|
||||||
|
print(f"{di.compilation_time}: {summary}; warnings/errors {len(warnings)}")
|
||||||
|
for w in warnings[:5]:
|
||||||
|
print(" ", w)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# usage: run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]
|
||||||
|
# Observes states and logs over the API with a concurrent ping and prints a summary: the device-side
|
||||||
|
# "frames parsed" counter per 10 s, gaps over 5 s between voltage updates seen by the API, and warnings.
|
||||||
|
# Needs a python with aioesphomeapi (PYTHON, default python3).
|
||||||
|
D=${0:A:h}
|
||||||
|
IP=$1; SECS=$2; L=$3; S=${4:-.}; F=${5:-}
|
||||||
|
PY=${PYTHON:-python3}
|
||||||
|
setopt pipefail
|
||||||
|
ping -i 1 -c $SECS $IP > $S/ping-$L.txt 2>&1 &
|
||||||
|
P=$!
|
||||||
|
if ! timeout $((SECS + 40)) $PY $D/observe.py $IP $SECS $S/obs-$L.txt "$F" 2>&1 | sed "s/$IP/<ip>/g"; then
|
||||||
|
echo "observe.py failed or timed out, no summary"; wait $P; exit 1
|
||||||
|
fi
|
||||||
|
wait $P
|
||||||
|
echo "ping: $(tail -2 $S/ping-$L.txt | head -1 | sed "s/$IP/<ip>/g") $(tail -1 $S/ping-$L.txt)"
|
||||||
|
python3 - "$S/obs-$L.txt" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
txt = open(sys.argv[1]).read()
|
||||||
|
fr = [int(m.group(1)) for m in re.finditer(r"frames parsed: (\d+) in 10 s", txt)]
|
||||||
|
print(f"device frame counter per 10 s: n={len(fr)} min={min(fr) if fr else None} max={max(fr) if fr else None} mean={sum(fr)/len(fr) if fr else 0:.1f}")
|
||||||
|
ts = [float(m.group(1)) for m in re.finditer(r"^\[\s*([0-9.]+)\] .*Voltage: ", txt, re.M)]
|
||||||
|
gaps = [round(b - a, 1) for a, b in zip(ts, ts[1:]) if b - a > 5]
|
||||||
|
print(f"API voltage updates {len(ts)}, gaps over 5 s: {gaps}")
|
||||||
|
warn = [l for l in txt.splitlines() if "LOG" in l and ("[W]" in l or "[E]" in l or "Invalid" in l)]
|
||||||
|
print(f"warnings/errors in log: {len(warn)}", *warn[:3], sep="\n ")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# cases.sh DEV_IP KEY CAPTURE LABEL : the SEN0395 flow (settings, switches, junk lines, factory reset, reset) against
|
||||||
|
# templates/dfrobot-sen0395-dut.yaml, with a serial capture of the device running into CAPTURE
|
||||||
|
D=$1; K=$2; LOG=$3; LABEL=$4; PY=${PYTHON:-python3}; CTL="$(dirname "$0")/ctl.py"
|
||||||
|
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a 'dfrobot\|Detected\|\[W\]\|\[E\]\|sen\]' | grep -a -v 'JYBSS\|took a long time' | cut -c1-200; }
|
||||||
|
step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; $PY "$CTL" "$D" "$K" "$@" || { rc=$?; echo "driver failed ($rc), aborting"; exit $rc; }; echo "--- device log:"; show "$n0"; }
|
||||||
|
frames() { echo "frames: $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,1') present, $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,0') absent"; }
|
||||||
|
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames ($(date +%T))"; sleep 20; frames "$n0"
|
||||||
|
step "range 0 to 3 m" "press:Range 0 to 3m" wait:12
|
||||||
|
step "range 0 to 9 m" "press:Range 0 to 9m" wait:12
|
||||||
|
step "latency 1 s / 2 s" "press:Latency 1s 2s" wait:12
|
||||||
|
step "latency 0 / 0" "press:Latency 0 0" wait:12
|
||||||
|
step "sensitivity 7" "press:Sensitivity 7" wait:12
|
||||||
|
step "LED off then on" "sw:LED=off" wait:10 "sw:LED=on" wait:10
|
||||||
|
step "sensor active off then on" "sw:Sensor active=off" wait:10 "sw:Sensor active=on" wait:10
|
||||||
|
step "presence via UART off then on" "sw:Presence via UART=off" wait:10 "sw:Presence via UART=on" wait:10
|
||||||
|
step "start after boot off then on" "sw:Start after boot=off" wait:10 "sw:Start after boot=on" wait:10
|
||||||
|
for n in 100 160 200 220 240 256; do step "junk line of $n bytes" "press:Junk $n" wait:7; done
|
||||||
|
step "junk 220 then a command" "press:Junk 220" wait:6 "press:Range 0 to 9m" wait:12
|
||||||
|
step "factory reset" "press:Factory reset" wait:15
|
||||||
|
step "reset" "press:Reset" wait:15
|
||||||
|
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames after ($(date +%T))"; sleep 20; frames "$n0"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Drive the SEN0395 test device over the API and print its binary sensor and switch updates.
|
||||||
|
usage: ctl.py HOST KEY(- for plaintext) OP [OP ...] OP: press:NAME | sw:NAME=on|off | wait:SECONDS
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aioesphomeapi import (
|
||||||
|
APIClient,
|
||||||
|
BinarySensorState,
|
||||||
|
ButtonInfo,
|
||||||
|
SwitchInfo,
|
||||||
|
SwitchState,
|
||||||
|
)
|
||||||
|
|
||||||
|
HOST, KEY = sys.argv[1], sys.argv[2]
|
||||||
|
OPS = sys.argv[3:]
|
||||||
|
T0 = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||||
|
await c.connect(login=True)
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
by_key = {e.key: e for e in ents}
|
||||||
|
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||||
|
switches = {e.name: e for e in ents if isinstance(e, SwitchInfo)}
|
||||||
|
|
||||||
|
def on_state(s) -> None:
|
||||||
|
e = by_key.get(s.key)
|
||||||
|
if isinstance(s, BinarySensorState):
|
||||||
|
print(
|
||||||
|
f"{time.time() - T0:7.3f} BIN {e.name} = {'ON' if s.state else 'off'}"
|
||||||
|
)
|
||||||
|
elif isinstance(s, SwitchState):
|
||||||
|
print(
|
||||||
|
f"{time.time() - T0:7.3f} SW {e.name} = {'on' if s.state else 'off'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
c.subscribe_states(on_state)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
for op in OPS:
|
||||||
|
kind, _, rest = op.partition(":")
|
||||||
|
if kind == "wait":
|
||||||
|
await asyncio.sleep(float(rest))
|
||||||
|
elif kind == "press":
|
||||||
|
if rest not in buttons:
|
||||||
|
sys.exit(f"no button {rest!r}; have {sorted(buttons)}")
|
||||||
|
print(f"{time.time() - T0:7.3f} >>> press {rest}")
|
||||||
|
c.button_command(buttons[rest].key)
|
||||||
|
elif kind == "sw":
|
||||||
|
name, _, val = rest.partition("=")
|
||||||
|
if name not in switches:
|
||||||
|
sys.exit(f"no switch {name!r}; have {sorted(switches)}")
|
||||||
|
print(f"{time.time() - T0:7.3f} >>> switch {name} {val}")
|
||||||
|
c.switch_command(switches[name].key, val == "on")
|
||||||
|
else:
|
||||||
|
sys.exit(f"unknown op {op!r}")
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""List every entity over the native API with its icon, device class and unit: one sorted JSON line each.
|
||||||
|
|
||||||
|
usage: api_list.py HOST KEY OUT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient
|
||||||
|
|
||||||
|
HOST, KEY, OUT = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||||
|
await c.connect(login=True)
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
rows = []
|
||||||
|
for e in ents:
|
||||||
|
row = {
|
||||||
|
"type": type(e).__name__,
|
||||||
|
"name": e.name,
|
||||||
|
"object_id": e.object_id,
|
||||||
|
"icon": e.icon,
|
||||||
|
}
|
||||||
|
for k in (
|
||||||
|
"device_class",
|
||||||
|
"unit_of_measurement",
|
||||||
|
"event_types",
|
||||||
|
"options",
|
||||||
|
"mode",
|
||||||
|
):
|
||||||
|
if hasattr(e, k):
|
||||||
|
v = getattr(e, k)
|
||||||
|
row[k] = (
|
||||||
|
list(v)
|
||||||
|
if isinstance(v, (list, tuple))
|
||||||
|
else (v.name if hasattr(v, "name") else v)
|
||||||
|
)
|
||||||
|
row["entity_category"] = (
|
||||||
|
e.entity_category.name
|
||||||
|
if hasattr(e.entity_category, "name")
|
||||||
|
else e.entity_category
|
||||||
|
)
|
||||||
|
row["disabled_by_default"] = e.disabled_by_default
|
||||||
|
rows.append(row)
|
||||||
|
rows.sort(key=lambda r: (r["type"], r["name"]))
|
||||||
|
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
|
||||||
|
f.writelines(json.dumps(r, sort_keys=True) + "\n" for r in rows)
|
||||||
|
print(f"{len(rows)} entities listed")
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# compare.sh NAME_A NAME_B : diff the readback.sh outputs of two device names (api, web, mqttn and, when present, dump)
|
||||||
|
# after replacing each name with DEV. Exit 1 when any pair differs; the diffs stay in cmp-KIND.diff.
|
||||||
|
A=$1; B=$2; rc=0
|
||||||
|
[ -n "$A" ] && [ -n "$B" ] || { echo "usage: compare.sh NAME_A NAME_B"; exit 2; }
|
||||||
|
for k in api web mqttn dump; do
|
||||||
|
ext=jsonl; [ $k = dump ] && ext=txt
|
||||||
|
[ -f "$PWD/$k-$A.$ext" ] && [ -f "$PWD/$k-$B.$ext" ] || { echo "$k: missing"; [ $k = dump ] || rc=1; continue; }
|
||||||
|
sed "s/$A/DEV/g; s/$(echo "$A" | tr - _)/DEV/g" "$PWD/$k-$A.$ext" > "$PWD/cmp-$k-a.txt"
|
||||||
|
sed "s/$B/DEV/g; s/$(echo "$B" | tr - _)/DEV/g" "$PWD/$k-$B.$ext" > "$PWD/cmp-$k-b.txt"
|
||||||
|
if diff -u "$PWD/cmp-$k-a.txt" "$PWD/cmp-$k-b.txt" > "$PWD/cmp-$k.diff"; then echo "$k: identical ($(wc -l < "$PWD/cmp-$k-b.txt" | tr -d ' ') lines)"; else echo "$k: DIFFERS"; head -20 "$PWD/cmp-$k.diff"; rc=1; fi
|
||||||
|
done
|
||||||
|
exit $rc
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Normalise mosquitto_sub output (topic TAB payload) for one device: drop other devices, replace the name, drop dev block.
|
||||||
|
|
||||||
|
usage: mqtt_norm.py IN NAME OUT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
IN, NAME, OUT = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
rows = {}
|
||||||
|
with pathlib.Path(IN).open(encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
topic, _, payload = line.rstrip("\n").partition("\t")
|
||||||
|
if (
|
||||||
|
f"/{NAME}/" not in topic and not topic.startswith(f"{NAME}/")
|
||||||
|
) or topic == f"{NAME}/debug":
|
||||||
|
continue
|
||||||
|
topic = topic.replace(NAME, "DEV")
|
||||||
|
try:
|
||||||
|
obj = json.loads(payload)
|
||||||
|
except ValueError:
|
||||||
|
rows[topic] = payload.replace(NAME, "DEV")
|
||||||
|
continue
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
obj.pop("dev", None)
|
||||||
|
obj = {
|
||||||
|
k: (v.replace(NAME, "DEV") if isinstance(v, str) else v)
|
||||||
|
for k, v in obj.items()
|
||||||
|
}
|
||||||
|
rows[topic] = obj
|
||||||
|
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
|
||||||
|
f.writelines(
|
||||||
|
json.dumps({"topic": t, "payload": rows[t]}, sort_keys=True) + "\n"
|
||||||
|
for t in sorted(rows)
|
||||||
|
)
|
||||||
|
print(f"{len(rows)} topics for {NAME}")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# readback.sh DEVICE_IP KEY NAME BROKER_HOST [CAPTURE] : record how a device built from templates/entity-icon-dut.yaml
|
||||||
|
# reports its entity icons and device classes: the native API listing (api-NAME.jsonl), the retained MQTT discovery
|
||||||
|
# payloads and states (mqtt-NAME.txt, normalised to mqttn-NAME.jsonl with the device name replaced and the debug topic
|
||||||
|
# dropped), the web_server /events listing plus every REST endpoint with ?detail=all (web-NAME.jsonl) and, when a
|
||||||
|
# serial CAPTURE is given, the dump_config Icon and Device Class lines (dump-NAME.txt, timestamps and line numbers
|
||||||
|
# removed). KEY as for api_button.py. Outputs go to the working directory; compare two names with compare.sh.
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; DEV=$1; KEY=$2; N=$3; B=$4; CAP=$5; rc=0; TAB=$(printf '\t')
|
||||||
|
[ -n "$DEV" ] && [ -n "$KEY" ] && [ -n "$N" ] && [ -n "$B" ] || { echo "usage: readback.sh DEVICE_IP KEY NAME BROKER_HOST [CAPTURE]"; exit 2; }
|
||||||
|
timeout 12 mosquitto_sub -h "$B" -v -t 'homeassistant/#' -t "$N/#" -F "%t$TAB%p" > "$PWD/mqtt-$N.txt" 2> "$PWD/mqtt-$N.err"
|
||||||
|
$PY "$HERE/mqtt_norm.py" "$PWD/mqtt-$N.txt" "$N" "$PWD/mqttn-$N.jsonl" || rc=1
|
||||||
|
$PY "$HERE/api_list.py" "$DEV" "$KEY" "$PWD/api-$N.jsonl" || { echo "API listing failed"; rc=1; }
|
||||||
|
$PY "$HERE/web_dump.py" "$DEV" "$PWD/web-$N.jsonl" || { echo "web_server dump failed"; rc=1; }
|
||||||
|
if [ -n "$CAP" ]; then
|
||||||
|
LC_ALL=C grep -a '\]: Icon: \|\]: Device Class: ' "$CAP" | LC_ALL=C sed 's/\x1b\[[0-9;]*m//g; s/^\[[^]]*\]//; s/:[0-9]*\]:/]:/' | sort > "$PWD/dump-$N.txt"
|
||||||
|
echo "dump_config icon lines $(grep -c 'Icon:' "$PWD/dump-$N.txt"), device class lines $(grep -c 'Device Class:' "$PWD/dump-$N.txt")"
|
||||||
|
fi
|
||||||
|
exit $rc
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Dump the web_server view of every entity: the /events listing (detail all) and each REST endpoint with ?detail=all.
|
||||||
|
|
||||||
|
usage: web_dump.py HOST OUT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
HOST, OUT = sys.argv[1], sys.argv[2]
|
||||||
|
|
||||||
|
|
||||||
|
def sse_listing(seconds: float) -> list[dict]:
|
||||||
|
s = socket.create_connection((HOST, 80), timeout=5)
|
||||||
|
s.sendall(
|
||||||
|
b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n"
|
||||||
|
% HOST.encode()
|
||||||
|
)
|
||||||
|
buf = b""
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
s.settimeout(1.0)
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
data = s.recv(65536)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buf += data
|
||||||
|
s.close()
|
||||||
|
head, _, body = buf.partition(b"\r\n\r\n")
|
||||||
|
if b"chunked" in head.lower():
|
||||||
|
out = b""
|
||||||
|
while True:
|
||||||
|
i = body.find(b"\r\n")
|
||||||
|
if i < 0:
|
||||||
|
break
|
||||||
|
size = int(body[:i].split(b";")[0], 16)
|
||||||
|
out += body[i + 2 : i + 2 + size]
|
||||||
|
body = body[i + 2 + size + 2 :]
|
||||||
|
if size == 0:
|
||||||
|
break
|
||||||
|
body = out
|
||||||
|
rows = []
|
||||||
|
for block in body.decode(errors="replace").split("\n\n"):
|
||||||
|
ev, data = None, None
|
||||||
|
for line in block.split("\n"):
|
||||||
|
line = line.rstrip("\r")
|
||||||
|
if line.startswith("event:"):
|
||||||
|
ev = line[6:].strip()
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
data = line[5:].strip()
|
||||||
|
if ev in ("state", "state_detail_all") and data:
|
||||||
|
try:
|
||||||
|
obj = json.loads(data)
|
||||||
|
except ValueError:
|
||||||
|
rows.append({"event": ev, "invalid": data})
|
||||||
|
continue
|
||||||
|
# bare state updates that land inside the window carry no domain; only the listing rows are kept
|
||||||
|
if "domain" in obj:
|
||||||
|
rows.append({"event": ev, **obj})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def get_json(url: str) -> dict:
|
||||||
|
"""One retry: the ESP8266 async server occasionally leaves a request unanswered."""
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except TimeoutError:
|
||||||
|
if attempt == 2:
|
||||||
|
raise
|
||||||
|
print(f"retrying {url}", flush=True)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
rows = sse_listing(6)
|
||||||
|
ids = sorted({r["id"] for r in rows if "id" in r})
|
||||||
|
rest = []
|
||||||
|
for ident in ids:
|
||||||
|
domain, _, obj = ident.partition("/")
|
||||||
|
url = f"http://{HOST}/{domain}/{urllib.parse.quote(obj)}?detail=all"
|
||||||
|
try:
|
||||||
|
rest.append({"url": url.replace(HOST, "HOST"), **get_json(url)})
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
rest.append({"url": url.replace(HOST, "HOST"), "error": str(e)})
|
||||||
|
time.sleep(0.3)
|
||||||
|
with pathlib.Path(OUT).open("w", encoding="utf-8") as f:
|
||||||
|
f.writelines(
|
||||||
|
json.dumps(r, sort_keys=True) + "\n"
|
||||||
|
for r in sorted(rows, key=lambda r: (r.get("id", ""), r["event"]))
|
||||||
|
)
|
||||||
|
f.writelines(json.dumps(r, sort_keys=True) + "\n" for r in rest)
|
||||||
|
print(f"{len(rows)} sse rows, {len(ids)} ids, {len(rest)} rest rows")
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Read or drive a bulb directly over its own ESPHome API (an HA-side change, independent of the mirror).
|
||||||
|
|
||||||
|
usage: bulb.py HOST KEY state | on [brightness 0-1] [ct_mireds|rgb r,g,b] | off | restart
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient, ButtonInfo, LightColorCapability, LightInfo
|
||||||
|
|
||||||
|
HOST, KEY, CMD = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
ARGS = sys.argv[4:]
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||||
|
await c.connect(login=True)
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
light = next((e for e in ents if isinstance(e, LightInfo)), None)
|
||||||
|
if light is None:
|
||||||
|
sys.exit("no light entity on this device; is HOST a bulb?")
|
||||||
|
if CMD == "state":
|
||||||
|
fut = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
def on_state(st):
|
||||||
|
if getattr(st, "key", None) == light.key and not fut.done():
|
||||||
|
fut.set_result(st)
|
||||||
|
|
||||||
|
c.subscribe_states(on_state)
|
||||||
|
st = await asyncio.wait_for(fut, 5)
|
||||||
|
print(
|
||||||
|
f"{light.name}: on={st.state} mode={st.color_mode} bri={st.brightness:.2f} "
|
||||||
|
f"ct={st.color_temperature:.1f} rgb={st.red:.2f}/{st.green:.2f}/{st.blue:.2f} "
|
||||||
|
f"white={st.white:.2f} cold={st.cold_white:.2f} warm={st.warm_white:.2f}"
|
||||||
|
)
|
||||||
|
elif CMD == "on":
|
||||||
|
kw = {"state": True}
|
||||||
|
if ARGS:
|
||||||
|
kw["brightness"] = float(ARGS[0])
|
||||||
|
if len(ARGS) > 1:
|
||||||
|
if "," in ARGS[1]:
|
||||||
|
r, g, b = (float(x) for x in ARGS[1].split(","))
|
||||||
|
kw["rgb"] = (r, g, b)
|
||||||
|
kw["color_mode"] = (
|
||||||
|
LightColorCapability.RGB
|
||||||
|
| LightColorCapability.ON_OFF
|
||||||
|
| LightColorCapability.BRIGHTNESS
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kw["color_temperature"] = float(ARGS[1])
|
||||||
|
kw["color_mode"] = (
|
||||||
|
LightColorCapability.COLOR_TEMPERATURE
|
||||||
|
| LightColorCapability.ON_OFF
|
||||||
|
| LightColorCapability.BRIGHTNESS
|
||||||
|
)
|
||||||
|
c.light_command(light.key, **kw)
|
||||||
|
print("bulb on", kw)
|
||||||
|
elif CMD == "off":
|
||||||
|
c.light_command(light.key, state=False)
|
||||||
|
print("bulb off")
|
||||||
|
elif CMD == "restart":
|
||||||
|
btn = next(
|
||||||
|
(
|
||||||
|
e
|
||||||
|
for e in ents
|
||||||
|
if isinstance(e, ButtonInfo) and "restart" in e.name.lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if btn is None:
|
||||||
|
sys.exit("no restart button on this device")
|
||||||
|
c.button_command(btn.key)
|
||||||
|
print("bulb restart pressed")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Held-report cases for esphome#21144 with persistent API connections, so the steps land inside the 3 s fade.
|
||||||
|
usage: heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY] (KEY or BULB_KEY of - means plaintext)
|
||||||
|
t0 press "A 50 fade 3s" on the mirror device (3 s fade to 50%)
|
||||||
|
t0+0.7 set the bulb directly to 90% (Home Assistant reports it to the mirror during the fade: held)
|
||||||
|
t0+1.4 same: press "A 50 fade 3s" again (same target as the running fade)
|
||||||
|
many: press "A many calls" (16 calls in one loop pass, the last one 100% with a 1 s fade)
|
||||||
|
t0+6.5 read the bulb, press Log state so the mirror prints its state
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient, ButtonInfo, LightInfo, LightState
|
||||||
|
|
||||||
|
DEV, KEY, BULB, CASE = sys.argv[1:5]
|
||||||
|
BULB_KEY = sys.argv[5] if len(sys.argv) > 5 else "-"
|
||||||
|
if CASE not in ("same", "many"):
|
||||||
|
sys.exit(f"case must be same or many, got {CASE!r}")
|
||||||
|
T0 = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def stamp() -> str:
|
||||||
|
return f"{time.time() - T0:6.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
dev = APIClient(DEV, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||||
|
bulb = APIClient(BULB, 6053, None, noise_psk=None if BULB_KEY == "-" else BULB_KEY)
|
||||||
|
await dev.connect(login=True)
|
||||||
|
await bulb.connect(login=True)
|
||||||
|
dents, _ = await dev.list_entities_services()
|
||||||
|
bents, _ = await bulb.list_entities_services()
|
||||||
|
buttons = {e.name: e for e in dents if isinstance(e, ButtonInfo)}
|
||||||
|
blight = next(e for e in bents if isinstance(e, LightInfo))
|
||||||
|
states = {}
|
||||||
|
|
||||||
|
def on_bulb(s) -> None:
|
||||||
|
if isinstance(s, LightState) and s.key == blight.key:
|
||||||
|
states["bulb"] = s
|
||||||
|
print(f"{stamp()} bulb state: on={s.state} bri={s.brightness:.2f}")
|
||||||
|
|
||||||
|
bulb.subscribe_states(on_bulb)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
print(f"{stamp()} >>> bulb to 100% (precondition)")
|
||||||
|
bulb.light_command(blight.key, state=True, brightness=1.0)
|
||||||
|
await asyncio.sleep(4.0)
|
||||||
|
t = time.time()
|
||||||
|
print(f"{stamp()} >>> t0 press A 50 fade 3s")
|
||||||
|
dev.button_command(buttons["A 50 fade 3s"].key)
|
||||||
|
await asyncio.sleep(0.7 - (time.time() - t))
|
||||||
|
print(f"{stamp()} >>> bulb direct to 90%")
|
||||||
|
bulb.light_command(blight.key, state=True, brightness=0.9)
|
||||||
|
await asyncio.sleep(1.4 - (time.time() - t))
|
||||||
|
name = "A 50 fade 3s" if CASE == "same" else "A many calls"
|
||||||
|
print(f"{stamp()} >>> press {name}")
|
||||||
|
dev.button_command(buttons[name].key)
|
||||||
|
await asyncio.sleep(6.5 - (time.time() - t))
|
||||||
|
print(f"{stamp()} >>> press Log state (the mirror's state is in the device log)")
|
||||||
|
dev.button_command(buttons["Log state"].key)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
b = states.get("bulb")
|
||||||
|
print(
|
||||||
|
f"{stamp()} RESULT bulb "
|
||||||
|
+ (f"bri={b.brightness:.2f}" if b is not None else "no state")
|
||||||
|
)
|
||||||
|
await dev.disconnect()
|
||||||
|
await bulb.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Drive an LD24xx harness over the API: press buttons, set selects and numbers, print state updates, save the log.
|
||||||
|
|
||||||
|
usage: lddrive.py HOST KEY LOGFILE OP [OP ...] (KEY of - means a plaintext API)
|
||||||
|
OP: list | press:NAME | sel:NAME=OPTION | set:NAME=VALUE | wait:SECONDS
|
||||||
|
Steps are 2 s apart: the API log stream drops lines while the hub blocks in send_command_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aioesphomeapi import (
|
||||||
|
APIClient,
|
||||||
|
BinarySensorState,
|
||||||
|
ButtonInfo,
|
||||||
|
LogLevel,
|
||||||
|
NumberInfo,
|
||||||
|
NumberState,
|
||||||
|
SelectInfo,
|
||||||
|
SelectState,
|
||||||
|
SensorInfo,
|
||||||
|
SensorState,
|
||||||
|
TextSensorInfo,
|
||||||
|
TextSensorState,
|
||||||
|
)
|
||||||
|
|
||||||
|
HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
OPS = sys.argv[4:]
|
||||||
|
T0 = time.time()
|
||||||
|
STEP_GAP_S = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def stamp() -> str:
|
||||||
|
return f"{time.time() - T0:7.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
async def run(c: APIClient, log) -> None:
|
||||||
|
info = await c.device_info()
|
||||||
|
print(
|
||||||
|
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
|
||||||
|
)
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
by_key = {e.key: e for e in ents}
|
||||||
|
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||||
|
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
|
||||||
|
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
|
||||||
|
|
||||||
|
def on_state(s) -> None:
|
||||||
|
e = by_key.get(s.key)
|
||||||
|
if e is None:
|
||||||
|
return
|
||||||
|
missing = " (missing)" if getattr(s, "missing_state", False) else ""
|
||||||
|
if isinstance(s, TextSensorState):
|
||||||
|
print(f"{stamp()} TXT {e.name} = {s.state!r}{missing}", flush=True)
|
||||||
|
elif isinstance(s, BinarySensorState):
|
||||||
|
print(f"{stamp()} BIN {e.name} = {s.state}{missing}", flush=True)
|
||||||
|
elif isinstance(s, NumberState):
|
||||||
|
print(f"{stamp()} NUM {e.name} = {s.state:.0f}{missing}", flush=True)
|
||||||
|
elif isinstance(s, SelectState):
|
||||||
|
print(f"{stamp()} SEL {e.name} = {s.state!r}{missing}", flush=True)
|
||||||
|
elif (
|
||||||
|
isinstance(s, SensorState)
|
||||||
|
and isinstance(e, SensorInfo)
|
||||||
|
and "Count" in e.name
|
||||||
|
):
|
||||||
|
print(f"{stamp()} CNT {e.name} = {s.state:.0f}", flush=True)
|
||||||
|
|
||||||
|
def on_log(msg) -> None:
|
||||||
|
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||||
|
|
||||||
|
c.subscribe_states(on_state)
|
||||||
|
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
for op in OPS:
|
||||||
|
if op == "list":
|
||||||
|
for n in sorted(buttons):
|
||||||
|
print("button:", n)
|
||||||
|
for n in sorted(selects):
|
||||||
|
print("select:", n, selects[n].options)
|
||||||
|
for e in ents:
|
||||||
|
if isinstance(e, TextSensorInfo):
|
||||||
|
print("text_sensor:", e.name)
|
||||||
|
continue
|
||||||
|
kind, _, rest = op.partition(":")
|
||||||
|
if kind == "wait":
|
||||||
|
await asyncio.sleep(float(rest))
|
||||||
|
continue
|
||||||
|
if kind == "press":
|
||||||
|
if rest not in buttons:
|
||||||
|
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
|
||||||
|
print(f"{stamp()} >>> press {rest}", flush=True)
|
||||||
|
log.write(f"{stamp()} >>> press {rest}\n")
|
||||||
|
c.button_command(buttons[rest].key)
|
||||||
|
elif kind == "set":
|
||||||
|
name, _, value = rest.partition("=")
|
||||||
|
if name not in numbers:
|
||||||
|
sys.exit(f"no number named {name!r}; have {sorted(numbers)}")
|
||||||
|
print(f"{stamp()} >>> set {name} = {value}", flush=True)
|
||||||
|
log.write(f"{stamp()} >>> set {name} = {value}\n")
|
||||||
|
c.number_command(numbers[name].key, float(value))
|
||||||
|
elif kind == "sel":
|
||||||
|
name, _, value = rest.partition("=")
|
||||||
|
if name not in selects:
|
||||||
|
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
|
||||||
|
print(f"{stamp()} >>> select {name} = {value}", flush=True)
|
||||||
|
log.write(f"{stamp()} >>> select {name} = {value}\n")
|
||||||
|
c.select_command(selects[name].key, value)
|
||||||
|
else:
|
||||||
|
sys.exit(f"unknown op {op!r}")
|
||||||
|
await asyncio.sleep(STEP_GAP_S)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||||
|
await c.connect(login=True)
|
||||||
|
with Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||||
|
await run(c, log)
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Summarize an lddrive.py log: for each step, the LD2450 TX frames, commands and publishes.
|
||||||
|
|
||||||
|
usage: txsum.py LOGFILE (diff the output of two builds)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
out: list[str] = []
|
||||||
|
with Path(sys.argv[1]).open(encoding="utf-8", errors="replace") as log:
|
||||||
|
for raw in log:
|
||||||
|
line = ANSI.sub("", raw.rstrip("\n"))
|
||||||
|
m = re.match(r"\s*[\d.]+ (.*)", line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
text = m.group(1)
|
||||||
|
if text.startswith(">>>"):
|
||||||
|
out.append(f"\n# {text[4:]}")
|
||||||
|
elif "[uart_debug" in text:
|
||||||
|
out.append(f" TX {text.split('>>> ')[1]}")
|
||||||
|
elif "[ld2450" in text and (
|
||||||
|
"Sending COMMAND" in text
|
||||||
|
or "zone type" in text.lower()
|
||||||
|
or "Query zone" in text
|
||||||
|
):
|
||||||
|
out.append(f" LOG {text.split(']: ', 1)[1]}")
|
||||||
|
elif "[select:" in text and ">>" in text:
|
||||||
|
out.append(f" SEL {text.split(']: ', 1)[1]}")
|
||||||
|
elif "[text_sensor:098]" in text:
|
||||||
|
out.append(f" TXT {text.split(']: ', 1)[1]}")
|
||||||
|
print("\n".join(out))
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Drive LD2450 numbers and selects over the API while printing every LD2450 state update.
|
||||||
|
usage: zonedrive.py HOST KEY(- for plaintext) OP [OP ...]
|
||||||
|
OP: set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS | list
|
||||||
|
Every state update of an entity whose name contains "LD2450" is printed with a timestamp.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aioesphomeapi import (
|
||||||
|
APIClient,
|
||||||
|
NumberInfo,
|
||||||
|
NumberState,
|
||||||
|
SelectInfo,
|
||||||
|
SelectState,
|
||||||
|
SensorInfo,
|
||||||
|
SensorState,
|
||||||
|
)
|
||||||
|
|
||||||
|
HOST, KEY = sys.argv[1], sys.argv[2]
|
||||||
|
OPS = sys.argv[3:]
|
||||||
|
T0 = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def stamp() -> str:
|
||||||
|
return f"{time.time() - T0:7.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||||
|
await c.connect(login=True)
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
by_key = {e.key: e for e in ents}
|
||||||
|
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
|
||||||
|
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
|
||||||
|
sensors = {e.name: e for e in ents if isinstance(e, SensorInfo)}
|
||||||
|
|
||||||
|
def on_state(s) -> None:
|
||||||
|
e = by_key.get(s.key)
|
||||||
|
if e is None or "LD2450" not in e.name:
|
||||||
|
return
|
||||||
|
if isinstance(s, NumberState):
|
||||||
|
print(
|
||||||
|
f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}"
|
||||||
|
)
|
||||||
|
elif isinstance(s, SelectState):
|
||||||
|
print(f"{stamp()} SEL {e.name} = {s.state}")
|
||||||
|
elif isinstance(s, SensorState) and "Count" in e.name:
|
||||||
|
print(f"{stamp()} CNT {e.name} = {s.state:.0f}")
|
||||||
|
|
||||||
|
c.subscribe_states(on_state)
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
for op in OPS:
|
||||||
|
if op == "list":
|
||||||
|
for n in sorted(numbers):
|
||||||
|
print("number:", n)
|
||||||
|
for n in sorted(selects):
|
||||||
|
print("select:", n, selects[n].options)
|
||||||
|
for n in sorted(sensors):
|
||||||
|
if "Count" in n:
|
||||||
|
print("sensor:", n)
|
||||||
|
continue
|
||||||
|
kind, _, rest = op.partition(":")
|
||||||
|
if kind == "wait":
|
||||||
|
await asyncio.sleep(float(rest))
|
||||||
|
continue
|
||||||
|
name, _, value = rest.partition("=")
|
||||||
|
if kind == "set":
|
||||||
|
if name not in numbers:
|
||||||
|
sys.exit(f"no number named {name!r}; have {sorted(numbers)}")
|
||||||
|
print(f"{stamp()} >>> set {name} = {value}")
|
||||||
|
c.number_command(numbers[name].key, float(value))
|
||||||
|
elif kind == "sel":
|
||||||
|
if name not in selects:
|
||||||
|
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
|
||||||
|
print(f"{stamp()} >>> select {name} = {value}")
|
||||||
|
c.select_command(selects[name].key, value)
|
||||||
|
else:
|
||||||
|
sys.exit(f"unknown op {op!r}")
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// BK72xx crash probe: forces one crash per step and shows what the SDK and the bootloader leave behind.
|
||||||
|
// Steps: 0 undefined instruction, 1 call into unmapped memory, 2 unmapped and unaligned accesses (no fault),
|
||||||
|
// 3 lt_reboot(). The step lives in a flash preference saved and synced before the crash.
|
||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <FreeRTOS.h>
|
||||||
|
#include <task.h>
|
||||||
|
#include <cstdint>
|
||||||
|
#include "esphome/core/hal.h"
|
||||||
|
#include "esphome/core/log.h"
|
||||||
|
#include "esphome/core/preferences.h"
|
||||||
|
|
||||||
|
extern "C" unsigned bk_misc_get_start_type(void);
|
||||||
|
extern "C" char _bss_end[];
|
||||||
|
|
||||||
|
static constexpr uint32_t PROBE_CANARY_LOW = 0x004000F8; // free word in the SDK-reserved low SRAM
|
||||||
|
static constexpr uint32_t PROBE_CANARY_ITCM = 0x003F3FF0; // top of ITCM
|
||||||
|
static char bkprobe_report[112];
|
||||||
|
static constexpr uint32_t PROBE_MARKS[] = {0x40A1B0, 0x40B000, 0x40C000, 0x40E000, 0x410000, 0x414000, 0x418000,
|
||||||
|
0x41C000, 0x420000, 0x428000, 0x430000, 0x438000, 0x43C000, 0x43E000,
|
||||||
|
0x43F000, 0x43F800, 0x43FC00, 0x43FE00, 0x43FFF0};
|
||||||
|
static constexpr size_t PROBE_MARK_COUNT = sizeof(PROBE_MARKS) / sizeof(PROBE_MARKS[0]);
|
||||||
|
|
||||||
|
static inline uint32_t rd(uint32_t a) { return *(volatile uint32_t *) a; }
|
||||||
|
|
||||||
|
inline esphome::ESPPreferenceObject bkprobe_pref(uint32_t run) {
|
||||||
|
return esphome::global_preferences->make_preference<int>(0xBC000000u + run);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int bkprobe_boot(uint32_t run) {
|
||||||
|
using namespace esphome;
|
||||||
|
int step = 0;
|
||||||
|
bkprobe_pref(run).load(&step);
|
||||||
|
unsigned st = bk_misc_get_start_type();
|
||||||
|
snprintf(bkprobe_report, sizeof(bkprobe_report), "step=%d reason=%s(%d) start_type=%u low=%08lx itcm=%08lx top=%08lx",
|
||||||
|
step, lt_get_reboot_reason_name(lt_get_reboot_reason()), (int) lt_get_reboot_reason(), st,
|
||||||
|
(unsigned long) rd(PROBE_CANARY_LOW), (unsigned long) rd(PROBE_CANARY_ITCM),
|
||||||
|
(unsigned long) rd(0x0043FFF0u));
|
||||||
|
ESP_LOGI("probe", "%s", bkprobe_report);
|
||||||
|
for (uint32_t a = 0x400000; a < 0x400100; a += 32) {
|
||||||
|
ESP_LOGI("probe", "%06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a,
|
||||||
|
(unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12),
|
||||||
|
(unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24),
|
||||||
|
(unsigned long) rd(a + 28));
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < PROBE_MARK_COUNT; i += 7) {
|
||||||
|
char line[120];
|
||||||
|
int n = 0;
|
||||||
|
for (size_t j = i; j < i + 7 && j < PROBE_MARK_COUNT; j++)
|
||||||
|
n += snprintf(line + n, sizeof(line) - n, " %06lx=%08lx", (unsigned long) PROBE_MARKS[j],
|
||||||
|
(unsigned long) rd(PROBE_MARKS[j]));
|
||||||
|
ESP_LOGI("probe", "marks%s", line);
|
||||||
|
}
|
||||||
|
uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u;
|
||||||
|
for (uint32_t a = rec; a < rec + 64; a += 32) {
|
||||||
|
ESP_LOGI("probe", "rec %06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a,
|
||||||
|
(unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12),
|
||||||
|
(unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24),
|
||||||
|
(unsigned long) rd(a + 28));
|
||||||
|
}
|
||||||
|
*(volatile uint32_t *) PROBE_CANARY_LOW = 0xC0DE0000u | (uint32_t) step;
|
||||||
|
*(volatile uint32_t *) PROBE_CANARY_ITCM = 0x17C40000u | (uint32_t) step;
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
__attribute__((noinline)) void bkprobe_data_abort() {
|
||||||
|
using namespace esphome;
|
||||||
|
volatile uint32_t v = rd(0xFFFFFFF0);
|
||||||
|
ESP_LOGI("probe", "read 0xFFFFFFF0 -> %08lx (no fault)", (unsigned long) v);
|
||||||
|
uint32_t r;
|
||||||
|
__asm__ volatile("ldr %0, [%1]" : "=r"(r) : "r"(0x00400101) : "memory");
|
||||||
|
ESP_LOGI("probe", "unaligned ldr 0x400101 -> %08lx (no fault)", (unsigned long) r);
|
||||||
|
*(volatile uint32_t *) 0xFFFFFFF0 = 1;
|
||||||
|
ESP_LOGI("probe", "store 0xFFFFFFF0 (no fault)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// act is the step number before it was bumped
|
||||||
|
inline void bkprobe_act(uint32_t run, int act) {
|
||||||
|
using namespace esphome;
|
||||||
|
int next = act + 1;
|
||||||
|
bkprobe_pref(run).save(&next);
|
||||||
|
global_preferences->sync();
|
||||||
|
ESP_LOGI("probe", "acting on step %d (saved %d)", act, next);
|
||||||
|
// mark candidate storage areas so the next boot shows what survives
|
||||||
|
uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u;
|
||||||
|
for (uint32_t i = 0; i < 8; i++)
|
||||||
|
*(volatile uint32_t *) (rec + 32 + i * 4) = 0xA5A50000u | i; // second half of the record area
|
||||||
|
for (size_t i = 0; i < PROBE_MARK_COUNT; i++)
|
||||||
|
*(volatile uint32_t *) PROBE_MARKS[i] = 0x70700000u | (uint32_t) i;
|
||||||
|
ESP_LOGI("probe", "ramtop before: %08lx", (unsigned long) rd(0x0043FFF0u));
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
|
switch (act) {
|
||||||
|
case 0:
|
||||||
|
__builtin_trap(); // undefined instruction
|
||||||
|
break;
|
||||||
|
case 1: {
|
||||||
|
void (*fn)() = (void (*)()) 0xFFFFFFF1; // prefetch abort (unmapped, Thumb)
|
||||||
|
fn();
|
||||||
|
ESP_LOGI("probe", "call 0xFFFFFFF1 returned (no fault)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
bkprobe_data_abort();
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
lt_reboot();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void bkprobe_alive(int step) {
|
||||||
|
using namespace esphome;
|
||||||
|
ESP_LOGI("probe", "alive step=%d uptime=%lus boot: %s", step, (unsigned long) (millis() / 1000), bkprobe_report);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Feed a serial capture through the CLI's stacktrace decoder for a build.
|
||||||
|
|
||||||
|
usage: decode_capture.py CONFIG_YAML CAPTURE
|
||||||
|
|
||||||
|
Run from the directory that holds the config (its .esphome build is used), with the esphome checkout that
|
||||||
|
built it on PYTHONPATH. Lines of the crash report are echoed, decoded frames follow them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from esphome.config import read_config
|
||||||
|
from esphome.core import CORE
|
||||||
|
from esphome.stacktrace import LogLineProcessor
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
|
yaml, capture = Path(sys.argv[1]), Path(sys.argv[2])
|
||||||
|
CORE.config_path = yaml
|
||||||
|
config = read_config({})
|
||||||
|
proc = LogLineProcessor(config, CORE.target_platform)
|
||||||
|
for line in capture.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
if "libretiny.crash" in line:
|
||||||
|
print(line[:120])
|
||||||
|
proc.process_line(line)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#pragma once
|
||||||
|
// BK7238 bench harness for esphome#21007: one rtc and one flash preference, a step counter in flash,
|
||||||
|
// and an exit action per step so the board drives its own soft reboot, crash and watchdog reboot.
|
||||||
|
#include <libretiny.h>
|
||||||
|
#include <cstring>
|
||||||
|
#include <FreeRTOS.h>
|
||||||
|
#include <task.h>
|
||||||
|
#include "esphome/core/hal.h"
|
||||||
|
#include "esphome/core/log.h"
|
||||||
|
#include "esphome/core/preferences.h"
|
||||||
|
|
||||||
|
using namespace esphome;
|
||||||
|
|
||||||
|
// Boot report kept for the alive line, so a boot the host could not capture is still visible
|
||||||
|
static char ltprefs_report[128];
|
||||||
|
// Last boots' reports, kept in a flash preference so boots the host never saw stay readable
|
||||||
|
struct LtprefsHistory {
|
||||||
|
char line[8][128];
|
||||||
|
};
|
||||||
|
static LtprefsHistory ltprefs_hist;
|
||||||
|
|
||||||
|
inline ESPPreferenceObject ltprefs_step_pref(uint32_t run) {
|
||||||
|
return global_preferences->make_preference<int>(0x3B210070u ^ run, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the step and both preferences, logs them, saves this step's value.
|
||||||
|
// Returns the exit action: 0 idle, 1 lt_reboot, 2 crash, 3 watchdog.
|
||||||
|
inline int ltprefs_boot(int &step, uint32_t run) {
|
||||||
|
auto reason = lt_get_reboot_reason();
|
||||||
|
step = 0;
|
||||||
|
ltprefs_step_pref(run).load(&step);
|
||||||
|
auto rtcp = global_preferences->make_preference<uint32_t>(0x1B210070u ^ run, false);
|
||||||
|
auto flp = global_preferences->make_preference<uint32_t>(0x2B210070u ^ run, true);
|
||||||
|
uint32_t rv = 0, fv = 0;
|
||||||
|
// a second pair sharing one key as rtc and as flash, and a third rtc entry, so a hard reset drops three
|
||||||
|
auto rtcp2 = global_preferences->make_preference<uint32_t>(0x5B210070u ^ run, false);
|
||||||
|
auto flp2 = global_preferences->make_preference<uint32_t>(0x5B210070u ^ run, true);
|
||||||
|
auto rtcp3 = global_preferences->make_preference<uint32_t>(0x6B210070u ^ run, false);
|
||||||
|
uint32_t rv2 = 0, fv2 = 0, rv3 = 0;
|
||||||
|
bool rok = rtcp.load(&rv);
|
||||||
|
bool fok = flp.load(&fv);
|
||||||
|
bool rok2 = rtcp2.load(&rv2);
|
||||||
|
bool fok2 = flp2.load(&fv2);
|
||||||
|
bool rok3 = rtcp3.load(&rv3);
|
||||||
|
snprintf(ltprefs_report, sizeof(ltprefs_report),
|
||||||
|
"step=%d reason=%s(%d) rtc=%s%u flash=%s%u sharedkey rtc=%s%u flash=%s%u rtc3=%s%u", step,
|
||||||
|
lt_get_reboot_reason_name(reason), (int) reason, rok ? "" : "none ", rv, fok ? "" : "none ", fv,
|
||||||
|
rok2 ? "" : "none ", rv2, fok2 ? "" : "none ", fv2, rok3 ? "" : "none ", rv3);
|
||||||
|
ESP_LOGI("lttest", "%s", ltprefs_report);
|
||||||
|
auto histp = global_preferences->make_preference<LtprefsHistory>(0x4B210070u ^ run, true);
|
||||||
|
if (!histp.load(<prefs_hist))
|
||||||
|
memset(<prefs_hist, 0, sizeof(ltprefs_hist));
|
||||||
|
memmove(<prefs_hist.line[1], <prefs_hist.line[0], sizeof(ltprefs_hist.line) - sizeof(ltprefs_hist.line[0]));
|
||||||
|
strncpy(ltprefs_hist.line[0], ltprefs_report, sizeof(ltprefs_hist.line[0]) - 1);
|
||||||
|
ltprefs_hist.line[0][sizeof(ltprefs_hist.line[0]) - 1] = 0;
|
||||||
|
if (!histp.save(<prefs_hist)) {
|
||||||
|
ESP_LOGW("lttest", "history save failed");
|
||||||
|
}
|
||||||
|
int act = 0;
|
||||||
|
bool save = true;
|
||||||
|
switch (step) {
|
||||||
|
case 0:
|
||||||
|
act = 1;
|
||||||
|
break; // soft reboot
|
||||||
|
case 1:
|
||||||
|
act = 2;
|
||||||
|
break; // crash
|
||||||
|
case 2:
|
||||||
|
act = 3;
|
||||||
|
break; // watchdog
|
||||||
|
case 3:
|
||||||
|
act = 0;
|
||||||
|
break; // idle: chip-enable reset from the host
|
||||||
|
case 4:
|
||||||
|
act = 1;
|
||||||
|
break; // soft reboot after the hard reset
|
||||||
|
case 5:
|
||||||
|
act = 0;
|
||||||
|
break; // idle: chip-enable reset from the host
|
||||||
|
case 6:
|
||||||
|
act = 1;
|
||||||
|
save = false;
|
||||||
|
break; // no save, soft reboot: deleted, not skipped?
|
||||||
|
case 7:
|
||||||
|
act = 0;
|
||||||
|
break; // idle
|
||||||
|
case 8:
|
||||||
|
act = 2;
|
||||||
|
break; // crash
|
||||||
|
case 9:
|
||||||
|
act = 0;
|
||||||
|
break; // idle
|
||||||
|
case 10:
|
||||||
|
act = 0;
|
||||||
|
break; // idle: power cut by the user
|
||||||
|
default:
|
||||||
|
act = 0;
|
||||||
|
save = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (save) {
|
||||||
|
uint32_t v = 100 + step, v2r = 200 + step, v2f = 300 + step, v3 = 400 + step;
|
||||||
|
bool srtc = rtcp.save(&v);
|
||||||
|
bool sfl = flp.save(&v);
|
||||||
|
bool srtc2 = rtcp2.save(&v2r);
|
||||||
|
bool sfl2 = flp2.save(&v2f);
|
||||||
|
bool srtc3 = rtcp3.save(&v3);
|
||||||
|
bool synced = global_preferences->sync();
|
||||||
|
ESP_LOGI("lttest", "save %u/%u/%u/%u: rtc=%d flash=%d sharedkey rtc=%d flash=%d rtc3=%d sync=%d", v, v2r, v2f, v3,
|
||||||
|
srtc, sfl, srtc2, sfl2, srtc3, synced);
|
||||||
|
}
|
||||||
|
ESP_LOGI("lttest", "exit action=%d", act);
|
||||||
|
return act;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void ltprefs_exit(int &step, uint32_t run, int act) {
|
||||||
|
step++;
|
||||||
|
bool saved = ltprefs_step_pref(run).save(&step);
|
||||||
|
bool synced = global_preferences->sync();
|
||||||
|
ESP_LOGI("lttest", "step %d save=%d sync=%d", step, saved, synced);
|
||||||
|
if (!saved || !synced) {
|
||||||
|
ESP_LOGE("lttest", "step not stored, staying idle instead of repeating the step");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(200));
|
||||||
|
switch (act) {
|
||||||
|
case 1:
|
||||||
|
ESP_LOGW("lttest", "soft reboot now");
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
|
lt_reboot();
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
ESP_LOGW("lttest", "crashing now");
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
|
__builtin_trap(); // undefined instruction: a plain bad store does not fault on the ARM968
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
ESP_LOGW("lttest", "watchdog reboot now");
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
|
lt_reboot_wdt();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ESP_LOGW("lttest", "idle, waiting for an external reset");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void ltprefs_alive(int step, int act) {
|
||||||
|
ESP_LOGI("lttest", "alive step=%d act=%d boot: %s", step, act, ltprefs_report);
|
||||||
|
for (int i = 7; i >= 0; i--) {
|
||||||
|
if (ltprefs_hist.line[i][0] != 0) {
|
||||||
|
ESP_LOGI("lttest", " history[-%d]: %s", i, ltprefs_hist.line[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/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; }
|
||||||
|
# exec so the PID is the broker's own and no shell stays behind holding the caller's output pipe open
|
||||||
|
(cd "$WORK" && exec mosquitto -c mosquitto-ws.conf > mosquitto.out 2>&1 < /dev/null) &
|
||||||
|
echo $! > "$WORK/mosquitto.pid"; sleep 25
|
||||||
|
for port in $(awk '/^listener/ {print $2}' "$WORK/mosquitto-ws.conf"); do nc -z -w 3 "$B" "$port" || { echo "broker port $port not listening after the restart"; exit 1; }; done
|
||||||
|
tail -n +$((n1+1)) "$LOG" | grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected" || { echo "the device did not reconnect after the broker restart"; exit 1; }
|
||||||
|
else
|
||||||
|
echo "=== $N: no broker restart (pass 'restart' as the 7th argument for a local broker)"
|
||||||
|
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,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# cli_tls_check.sh TREE [neg] YAML... : run `esphome clean-mqtt` from the checkout TREE for each config, which goes
|
||||||
|
# through the same TLS setup in esphome/mqtt.py as `esphome logs` over MQTT, without needing a board. clean-mqtt never
|
||||||
|
# exits on its own, so it runs under a 25 s timeout: status 124 after "Connected to MQTT broker!" means the CLI
|
||||||
|
# connected. With neg every config must instead fail with CERTIFICATE_VERIFY_FAILED and never connect, so a negative
|
||||||
|
# that fails for another reason (bad YAML, broker down) is reported as a failure too. A relative YAML path is taken
|
||||||
|
# from the working directory, where the cleanmqtt-NAME.log outputs also go. Exit 1 when any config missed its result.
|
||||||
|
TREE=$1; shift; PY=${PYTHON:-python3}; NEG=0
|
||||||
|
[ "$1" = neg ] && { NEG=1; shift; }
|
||||||
|
[ -d "$TREE/esphome" ] && [ -n "$1" ] || { echo "usage: cli_tls_check.sh TREE [neg] YAML..."; exit 2; }
|
||||||
|
bad=0
|
||||||
|
for y in "$@"; do
|
||||||
|
case $y in /*) ;; *) y=$PWD/$y;; esac
|
||||||
|
n=$(basename "$y" .yaml); log=$PWD/cleanmqtt-$n.log
|
||||||
|
(cd "$TREE" && PYTHONUNBUFFERED=1 timeout 25 $PY -m esphome clean-mqtt "$y" > "$log" 2>&1); rc=$?
|
||||||
|
if [ "$NEG" = 0 ] && [ "$rc" = 124 ] && grep -q "Connected to MQTT broker" "$log"; then
|
||||||
|
echo "$n: connected"
|
||||||
|
elif [ "$NEG" = 1 ] && grep -q "CERTIFICATE_VERIFY_FAILED" "$log" && ! grep -q "Connected to MQTT broker" "$log"; then
|
||||||
|
echo "$n: rejected (CERTIFICATE_VERIFY_FAILED)"
|
||||||
|
else
|
||||||
|
echo "$n: FAILED (status $rc): $(grep -a -m1 -i 'cannot connect\|error' "$log" | cut -c1-160)"; bad=$((bad+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$bad" = 0 ]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST MODE [SUB_PORT] : flash YAML from the checkout TREE over the serial
|
||||||
|
# PORT, capture the serial log (cap-NAME.txt in the working directory), wait for the first MQTT connect or TLS error,
|
||||||
|
# then drive the device with cases.sh (KEY as for cases.sh, - for a plaintext API). MODE local runs cases.sh with a
|
||||||
|
# broker restart (the working directory must hold mosquitto-ws.conf and mosquitto.pid), public runs it without a
|
||||||
|
# restart, neg only watches for 100 s (a build that must never connect). SUB_PORT is the plain listener cases.sh
|
||||||
|
# subscribes on (default 1883). Ends with connect, disconnect, handshake error and crash counts, the config dump's
|
||||||
|
# TLS line, the probe heap lines and the broker listener each connection used. Exit 1 when the capture did not start,
|
||||||
|
# the device reached no MQTT event within 90 s (local and public), it crashed, or the mode's own check failed.
|
||||||
|
TREE=$1; YAML=$2; PORT=$3; KEY=$4; B=$5; MODE=$6; SP=${7:-1883}
|
||||||
|
case $YAML in /*) ;; *) YAML=$PWD/$YAML;; esac
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; N=$(basename "$YAML" .yaml); CAP=$PWD/cap-$N.txt; ULOG=$PWD/upload-$N.log
|
||||||
|
[ -d "$TREE/esphome" ] && [ -e "$PORT" ] && [ -n "$KEY" ] && [ -n "$B" ] || { echo "usage: flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST local|public|neg [SUB_PORT]"; exit 2; }
|
||||||
|
case $MODE in local|public|neg) ;; *) echo "MODE must be local, public or neg"; exit 2;; esac
|
||||||
|
pkill -f "[s]erial_capture.py $PORT "; sleep 1
|
||||||
|
echo "=== $N: flash ($(date +%T))"
|
||||||
|
(cd "$TREE" && PYTHONUNBUFFERED=1 timeout 300 $PY -m esphome upload "$YAML" --device "$PORT" > "$ULOG" 2>&1) || { echo "upload failed"; tail -5 "$ULOG"; exit 1; }
|
||||||
|
SECS=200; [ "$MODE" = neg ] && SECS=100
|
||||||
|
# a stale capture from an earlier run must not satisfy the wait below
|
||||||
|
rm -f "$CAP"
|
||||||
|
$PY "$HERE/../serial_capture.py" "$PORT" $SECS "$CAP" > "$PWD/cap-$N.stderr" 2>&1 &
|
||||||
|
cap_pid=$!
|
||||||
|
i=0; until grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected\|mbedtls_ssl_handshake returned\|Failed to verify" "$CAP" 2>/dev/null || [ $i -ge 45 ]; do sleep 2; i=$((i+1)); done
|
||||||
|
kill -0 $cap_pid 2>/dev/null && [ -f "$CAP" ] || { echo "serial capture is not running on $PORT"; tail -3 "$PWD/cap-$N.stderr"; exit 1; }
|
||||||
|
DEV=$(grep -a -o 'IP Address: [0-9.]*' "$CAP" | head -1 | awk '{print $3}')
|
||||||
|
if [ $i -ge 45 ] || [ -z "$DEV" ]; then
|
||||||
|
echo "=== $N: no MQTT event or no IP within 90 s ($(date +%T))"
|
||||||
|
[ "$MODE" = neg ] || { pkill -f "[s]erial_capture.py $PORT "; exit 1; }
|
||||||
|
else
|
||||||
|
echo "=== $N: first MQTT event after $((i*2)) s, device $DEV ($(date +%T))"
|
||||||
|
fi
|
||||||
|
grep -a 'ESPHome version\|\]: Transport\|\]: TLS:' "$CAP" | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-120
|
||||||
|
rc=0
|
||||||
|
case $MODE in
|
||||||
|
local) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV" restart; rc=$?;;
|
||||||
|
public) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV"; rc=$?;;
|
||||||
|
neg) sleep 70;;
|
||||||
|
esac
|
||||||
|
wait $cap_pid
|
||||||
|
strip() { sed 's/\x1b\[[0-9;]*m//g'; }
|
||||||
|
# count the backend's own events so the result does not depend on on_connect/on_disconnect log lines in the config
|
||||||
|
connects=$(grep -a -c 'MQTT_EVENT_CONNECTED' "$CAP"); errors=$(grep -a -c 'mbedtls_ssl_handshake returned\|Failed to verify certificate' "$CAP")
|
||||||
|
crashes=$(grep -a -c 'Guru\|abort()\|Backtrace' "$CAP")
|
||||||
|
echo "=== $N: connects $connects, disconnects $(grep -a -c 'MQTT_EVENT_DISCONNECTED' "$CAP"), handshake errors $errors, crashes $crashes"
|
||||||
|
grep -a 'mbedtls_ssl_handshake returned\|Failed to verify\|esp-tls' "$CAP" | strip | sed 's/^\[[^]]*\]//' | sort | uniq -c | sort -rn | head -4 | cut -c1-150
|
||||||
|
grep -a '\[probe:' "$CAP" | strip | sed 's/.*\[probe:[0-9]*\]: //' | sed -n '1p;$p'
|
||||||
|
[ -f "$PWD/mosquitto.log" ] && [ -n "$DEV" ] && echo "broker listeners used by $DEV: $(grep "New connection from $DEV" "$PWD/mosquitto.log" | sed 's/.*on port //' | sort | uniq -c | tr '\n' ';')"
|
||||||
|
[ "$crashes" = 0 ] || { echo "device crashed $crashes time(s)"; exit 1; }
|
||||||
|
if [ "$MODE" = neg ]; then
|
||||||
|
[ "$connects" = 0 ] && [ "$errors" -gt 0 ] || { echo "negative case: expected no connect and a handshake error"; exit 1; }
|
||||||
|
else
|
||||||
|
[ "$rc" = 0 ] || { echo "cases.sh failed with status $rc"; exit 1; }
|
||||||
|
fi
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# make_test_ca.sh BROKER_IP : a throwaway CA, a server certificate for the broker address and a client certificate
|
||||||
|
# (30 days), written into the working directory; run it in the scratch directory where a copy of mosquitto-ws.conf
|
||||||
|
# lives. The device config takes ca.crt as certificate_authority (skip_cert_cn_check: true, the name check does not
|
||||||
|
# match an address) and client.crt/client.key as client_certificate/client_certificate_key for the listener that
|
||||||
|
# requires one; other-ca.crt is a second CA for the negative case. The CA carries basicConstraints and keyUsage
|
||||||
|
# because Python's default SSL context (esphome logs, esphome clean-mqtt) rejects a CA without them.
|
||||||
|
set -e
|
||||||
|
IP=$1
|
||||||
|
[ -n "$IP" ] || { echo "usage: make_test_ca.sh BROKER_IP"; exit 2; }
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 30 -subj "/CN=hwtest-ca" \
|
||||||
|
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||||
|
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=hwtest-broker"
|
||||||
|
printf "subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n" "$IP" > san.ext
|
||||||
|
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 30 -extfile san.ext
|
||||||
|
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=hwtest-client"
|
||||||
|
printf "extendedKeyUsage=clientAuth\n" > client.ext
|
||||||
|
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 30 -extfile client.ext
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -keyout other-ca.key -out other-ca.crt -days 30 -subj "/CN=other-ca" \
|
||||||
|
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||||
|
openssl verify -CAfile ca.crt server.crt client.crt
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 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 TLS,
|
||||||
|
# MQTT over TLS that requires a client certificate, MQTT over WebSocket, and MQTT over WebSocket with TLS
|
||||||
|
# (server.crt, server.key, ca.crt from make_test_ca.sh in the same directory). log_type all gives the listener port
|
||||||
|
# per connection.
|
||||||
|
listener 1883
|
||||||
|
protocol mqtt
|
||||||
|
listener 8883
|
||||||
|
protocol mqtt
|
||||||
|
certfile server.crt
|
||||||
|
keyfile server.key
|
||||||
|
listener 8884
|
||||||
|
protocol mqtt
|
||||||
|
certfile server.crt
|
||||||
|
keyfile server.key
|
||||||
|
cafile ca.crt
|
||||||
|
require_certificate true
|
||||||
|
listener 9001
|
||||||
|
protocol websockets
|
||||||
|
listener 9002
|
||||||
|
protocol websockets
|
||||||
|
certfile server.crt
|
||||||
|
keyfile server.key
|
||||||
|
allow_anonymous true
|
||||||
|
log_type all
|
||||||
|
log_dest file mosquitto.log
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Drive the online_image cases over the native API and judge each from the serial capture."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aioesphomeapi import (
|
||||||
|
APIClient,
|
||||||
|
APIConnectionError,
|
||||||
|
InvalidEncryptionKeyAPIError,
|
||||||
|
RequiresEncryptionAPIError,
|
||||||
|
)
|
||||||
|
|
||||||
|
HOST, KEY, CAPTURE, BASE, MODE = sys.argv[1:6]
|
||||||
|
CAP_S = 45
|
||||||
|
|
||||||
|
CASES = [
|
||||||
|
# (label, action, path, expected outcome)
|
||||||
|
("ok bmp", "fetch", "ok.bmp", "finished"),
|
||||||
|
("ok png", "fetch", "ok.png", "finished"),
|
||||||
|
("ok jpg", "fetch", "ok.jpg", "finished"),
|
||||||
|
("ok qoi", "fetch", "ok.qoi", "finished"),
|
||||||
|
("truncated bmp", "fetch", "truncated.bmp", "error"),
|
||||||
|
("ok bmp after error", "fetch", "ok.bmp", "finished"),
|
||||||
|
("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"),
|
||||||
|
("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"),
|
||||||
|
("empty body", "fetch", "empty.bmp", "error"),
|
||||||
|
("stalled connection", "fetch", "stall.bmp", "error"),
|
||||||
|
("closed early", "fetch", "closed_early.bmp", "error"),
|
||||||
|
("slow but alive", "fetch", "slow.bmp", "finished"),
|
||||||
|
("chunked bmp", "fetch", "chunked.bmp", "finished"),
|
||||||
|
("chunked png", "fetch", "chunked.png", "finished"),
|
||||||
|
("chunked qoi", "fetch", "chunked.qoi", "finished"),
|
||||||
|
("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"),
|
||||||
|
("chunked png cut", "fetch", "chunked_cut.png", "any"),
|
||||||
|
("no content-length", "fetch", "noclen.bmp", "any"),
|
||||||
|
("huge jpeg claim", "fetch", "huge.jpg", "error"),
|
||||||
|
("ok jpg after huge", "fetch", "ok.jpg", "finished"),
|
||||||
|
("ok bmp again", "fetch", "ok.bmp", "finished"),
|
||||||
|
("ok bmp refetch: 304", "refetch", "", "finished"),
|
||||||
|
("ok png", "fetch", "ok.png", "finished"),
|
||||||
|
("ok png refetch: 304", "refetch", "", "finished"),
|
||||||
|
("chunked bmp", "fetch", "chunked.bmp", "finished"),
|
||||||
|
("chunked bmp refetch: 304", "refetch", "", "finished"),
|
||||||
|
("chunked png", "fetch", "chunked.png", "finished"),
|
||||||
|
("chunked png refetch: not cached", "refetch", "", "finished"),
|
||||||
|
("chunked png cut", "fetch", "chunked_cut.png", "any"),
|
||||||
|
("chunked png cut refetch: not cached", "refetch", "", "any"),
|
||||||
|
]
|
||||||
|
if MODE == "base":
|
||||||
|
# Each stuck case is followed by a fetch that shows the symptom, then a reboot.
|
||||||
|
CASES = [
|
||||||
|
("ok bmp", "fetch", "ok.bmp", "finished"),
|
||||||
|
("truncated bmp", "fetch", "truncated.bmp", "error"),
|
||||||
|
("ok bmp after truncated", "fetch", "ok.bmp", "finished"),
|
||||||
|
("REBOOT", "", "", ""),
|
||||||
|
("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"),
|
||||||
|
("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"),
|
||||||
|
("REBOOT", "", "", ""),
|
||||||
|
("empty body", "fetch", "empty.bmp", "error"),
|
||||||
|
("ok bmp after empty", "fetch", "ok.bmp", "finished"),
|
||||||
|
("REBOOT", "", "", ""),
|
||||||
|
("stalled connection", "fetch", "stall.bmp", "error"),
|
||||||
|
("ok bmp after stall", "fetch", "ok.bmp", "finished"),
|
||||||
|
("REBOOT", "", "", ""),
|
||||||
|
("closed early", "fetch", "closed_early.bmp", "error"),
|
||||||
|
("ok bmp after closed early", "fetch", "ok.bmp", "finished"),
|
||||||
|
("REBOOT", "", "", ""),
|
||||||
|
("chunked png", "fetch", "chunked.png", "finished"),
|
||||||
|
("ok bmp after chunked png", "fetch", "ok.bmp", "finished"),
|
||||||
|
("REBOOT", "", "", ""),
|
||||||
|
("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"),
|
||||||
|
("ok bmp after chunked cut", "fetch", "ok.bmp", "finished"),
|
||||||
|
("REBOOT", "", "", ""),
|
||||||
|
("huge jpeg claim", "fetch", "huge.jpg", "error"),
|
||||||
|
("ok jpg after huge", "fetch", "ok.jpg", "finished"),
|
||||||
|
]
|
||||||
|
|
||||||
|
OUTCOME = re.compile(r"download (finished|error): (img_small|img)")
|
||||||
|
INTEREST = re.compile(
|
||||||
|
r"\[(E|W)\]\[online_image|Image already being updated|download (finished|error)|Downloading image|Updating image|Image fully downloaded|HTTP 304|allocat|Panic|abort\(\)|Guru|Backtrace|rst:0x",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tail(path: str, offset: int) -> tuple[list[str], int]:
|
||||||
|
with pathlib.Path(path).open("rb") as f:
|
||||||
|
f.seek(offset)
|
||||||
|
data = f.read()
|
||||||
|
return data.decode("utf-8", "replace").splitlines(), offset + len(data)
|
||||||
|
|
||||||
|
|
||||||
|
async def connect() -> tuple[APIClient, dict, int]:
|
||||||
|
for _ in range(60):
|
||||||
|
try:
|
||||||
|
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||||
|
await c.connect(login=True)
|
||||||
|
ents, services = await c.list_entities_services()
|
||||||
|
except (InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError) as exc:
|
||||||
|
raise SystemExit(f"API key problem, not retrying: {exc}") from exc
|
||||||
|
except (APIConnectionError, OSError) as exc:
|
||||||
|
# only connection trouble is retried; a wrong key or a missing button must fail at once
|
||||||
|
print(f"connect: {exc}", flush=True)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
continue
|
||||||
|
btn = next(
|
||||||
|
(e.key for e in ents if getattr(e, "object_id", "") == "reboot"), None
|
||||||
|
)
|
||||||
|
if btn is None:
|
||||||
|
raise SystemExit("the config has no reboot button")
|
||||||
|
return c, {s.name: s for s in services}, btn
|
||||||
|
raise SystemExit("API never connected")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
client, actions, btn = await connect()
|
||||||
|
with pathlib.Path(CAPTURE).open("rb") as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
offset = f.tell()
|
||||||
|
rows = []
|
||||||
|
for label, action, path, expect in CASES:
|
||||||
|
if label == "REBOOT":
|
||||||
|
client.button_command(btn)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await client.disconnect()
|
||||||
|
await asyncio.sleep(8)
|
||||||
|
client, actions, btn = await connect()
|
||||||
|
_, offset = tail(CAPTURE, offset)
|
||||||
|
print("--- rebooted", flush=True)
|
||||||
|
continue
|
||||||
|
img = "img_small" if action.endswith("_small") else "img"
|
||||||
|
t0 = time.monotonic()
|
||||||
|
args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"}
|
||||||
|
await client.execute_service(actions[action], args)
|
||||||
|
outcome, notes = "no outcome", []
|
||||||
|
while time.monotonic() - t0 < CAP_S:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
lines, offset = tail(CAPTURE, offset)
|
||||||
|
done = False
|
||||||
|
for line in lines:
|
||||||
|
if INTEREST.search(line):
|
||||||
|
notes.append(line.strip())
|
||||||
|
m = OUTCOME.search(line)
|
||||||
|
if m and m.group(2) == img:
|
||||||
|
outcome = m.group(1)
|
||||||
|
done = True
|
||||||
|
if done:
|
||||||
|
break
|
||||||
|
took = time.monotonic() - t0
|
||||||
|
if outcome == "no outcome":
|
||||||
|
took = CAP_S
|
||||||
|
# drain a little more so the "already being updated" line lands
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
lines, offset = tail(CAPTURE, offset)
|
||||||
|
notes += [line.strip() for line in lines if INTEREST.search(line)]
|
||||||
|
ok = outcome == expect or (expect == "any" and outcome != "no outcome")
|
||||||
|
rows.append((label, path, expect, outcome, took, ok, notes))
|
||||||
|
print(
|
||||||
|
f"{'PASS' if ok else 'FAIL'} {label:32s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
for n in notes:
|
||||||
|
print(f" {n}", flush=True)
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
await client.disconnect()
|
||||||
|
print(
|
||||||
|
"\nSUMMARY",
|
||||||
|
"all expectations met"
|
||||||
|
if all(r[5] for r in rows)
|
||||||
|
else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[5]),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if not all(r[5] for r in rows):
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Build the small test images the HTTP server serves."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
out = Path(sys.argv[1])
|
||||||
|
out.mkdir(exist_ok=True)
|
||||||
|
w = h = 16
|
||||||
|
im = Image.new("RGB", (w, h))
|
||||||
|
px = im.load()
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
px[x, y] = (x * 16, y * 16, (x + y) * 8)
|
||||||
|
im.save(out / "ok.bmp", "BMP")
|
||||||
|
im.save(out / "ok.png", "PNG")
|
||||||
|
im.save(out / "ok.jpg", "JPEG", quality=90)
|
||||||
|
|
||||||
|
# QOI with only QOI_OP_RGB codes: valid and trivially encoded.
|
||||||
|
qoi = b"qoif" + struct.pack(">IIBB", w, h, 3, 0)
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
qoi += bytes([0xFE, *px[x, y]])
|
||||||
|
qoi += bytes(7) + b"\x01"
|
||||||
|
(out / "ok.qoi").write_bytes(qoi)
|
||||||
|
|
||||||
|
# 8x8 8-bit palette BMP: 1078 byte header, pixels start beyond a 256 byte buffer.
|
||||||
|
pw = ph = 8
|
||||||
|
data_offset = 14 + 40 + 256 * 4
|
||||||
|
file_size = data_offset + pw * ph
|
||||||
|
hdr = b"BM" + struct.pack("<IHHI", file_size, 0, 0, data_offset)
|
||||||
|
dib = struct.pack("<IiiHHIIiiII", 40, pw, ph, 1, 8, 0, pw * ph, 0, 0, 256, 0)
|
||||||
|
(out / "large_header.bmp").write_bytes(hdr + dib + bytes(256 * 4) + bytes(pw * ph))
|
||||||
|
|
||||||
|
for p in sorted(out.iterdir()):
|
||||||
|
print(p.name, p.stat().st_size)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""HTTP server with one misbehaviour per path, for the online_image stalled-download test."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
WWW = Path(sys.argv[1])
|
||||||
|
PORT = int(sys.argv[2])
|
||||||
|
LOG = Path(sys.argv[3]).open("a", encoding="utf-8", buffering=1) # noqa: SIM115
|
||||||
|
|
||||||
|
MIME = {"bmp": "image/bmp", "png": "image/png", "jpg": "image/jpeg", "qoi": "image/qoi"}
|
||||||
|
IMG = {n: (WWW / f"ok.{n}").read_bytes() for n in MIME}
|
||||||
|
BMP = IMG["bmp"]
|
||||||
|
BMP_HDR = 54 # 16x16 24-bit: pixel rows follow directly
|
||||||
|
LARGE_HEADER = (WWW / "large_header.bmp").read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
LOG.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def headers(ctype: str, extra: list[bytes]) -> bytes:
|
||||||
|
lines = [
|
||||||
|
b"HTTP/1.1 200 OK",
|
||||||
|
b"Content-Type: " + ctype.encode(),
|
||||||
|
b"Connection: close",
|
||||||
|
*extra,
|
||||||
|
b"",
|
||||||
|
b"",
|
||||||
|
]
|
||||||
|
return b"\r\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def etag(path: str) -> bytes:
|
||||||
|
return b'"' + path.strip("/").encode() + b'-v1"'
|
||||||
|
|
||||||
|
|
||||||
|
def chunked(body: bytes, size: int) -> bytes:
|
||||||
|
out = b""
|
||||||
|
for i in range(0, len(body), size):
|
||||||
|
part = body[i : i + size]
|
||||||
|
out += b"%x\r\n" % len(part) + part + b"\r\n"
|
||||||
|
return out + b"0\r\n\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str:
|
||||||
|
name, _, ext = path.lstrip("/").rpartition(".")
|
||||||
|
ctype = MIME.get(ext, "application/octet-stream")
|
||||||
|
body = IMG.get(ext, BMP)
|
||||||
|
tag = [b"ETag: " + etag(path)]
|
||||||
|
if b"if-none-match: " + etag(path).lower() in req.lower():
|
||||||
|
w.write(
|
||||||
|
b"HTTP/1.1 304 Not Modified\r\nETag: "
|
||||||
|
+ etag(path)
|
||||||
|
+ b"\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
return "304 not modified"
|
||||||
|
if name == "ok":
|
||||||
|
w.write(headers(ctype, [b"Content-Length: %d" % len(body), *tag]) + body)
|
||||||
|
return f"full {len(body)} bytes"
|
||||||
|
if name == "truncated": # cut one byte into the sixth pixel, Content-Length matches
|
||||||
|
cut = BMP[: BMP_HDR + 5 * 3 + 1]
|
||||||
|
w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut)
|
||||||
|
return f"truncated {len(cut)} bytes"
|
||||||
|
if name == "large_header":
|
||||||
|
w.write(
|
||||||
|
headers(ctype, [b"Content-Length: %d" % len(LARGE_HEADER)]) + LARGE_HEADER
|
||||||
|
)
|
||||||
|
return f"large header {len(LARGE_HEADER)} bytes"
|
||||||
|
if name == "empty":
|
||||||
|
w.write(headers(ctype, [b"Content-Length: 0"]))
|
||||||
|
return "empty body"
|
||||||
|
if (
|
||||||
|
name == "stall"
|
||||||
|
): # claims the full length, sends part, then holds the socket open
|
||||||
|
part = body[: BMP_HDR + 15]
|
||||||
|
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part)
|
||||||
|
await w.drain()
|
||||||
|
await asyncio.sleep(40)
|
||||||
|
return f"stalled after {len(part)} bytes, closed after 40 s"
|
||||||
|
if name == "closed_early": # claims the full length, sends part, closes at once
|
||||||
|
part = body[: BMP_HDR + 15]
|
||||||
|
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part)
|
||||||
|
return f"closed after {len(part)} of {len(body)} bytes"
|
||||||
|
if name == "slow": # pauses under the timeout, then completes
|
||||||
|
w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + body[:BMP_HDR])
|
||||||
|
await w.drain()
|
||||||
|
await asyncio.sleep(2.5)
|
||||||
|
w.write(body[BMP_HDR:])
|
||||||
|
return f"full {len(body)} bytes with a 2.5 s pause"
|
||||||
|
if name == "chunked":
|
||||||
|
w.write(
|
||||||
|
headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(body, 64)
|
||||||
|
)
|
||||||
|
return f"chunked full {len(body)} bytes"
|
||||||
|
if (
|
||||||
|
name == "chunked_cut"
|
||||||
|
): # chunked, ends cleanly at a pixel boundary (or half a png)
|
||||||
|
cut = body[: BMP_HDR + 5 * 3] if ext == "bmp" else body[: len(body) // 2]
|
||||||
|
w.write(
|
||||||
|
headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(cut, 64)
|
||||||
|
)
|
||||||
|
return f"chunked cut {len(cut)} of {len(body)} bytes"
|
||||||
|
if name == "noclen": # body delimited by connection close
|
||||||
|
w.write(headers(ctype, []) + body)
|
||||||
|
return f"no content-length, full {len(body)} bytes"
|
||||||
|
if name == "huge": # claims 20 MB so the JPEG buffer resize fails
|
||||||
|
w.write(headers(ctype, [b"Content-Length: 20000000"]) + body)
|
||||||
|
return f"claims 20000000, sent {len(body)} bytes"
|
||||||
|
w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||||
|
return "404"
|
||||||
|
|
||||||
|
|
||||||
|
async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
|
||||||
|
peer = w.get_extra_info("peername")
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
line = await r.readuntil(b"\r\n")
|
||||||
|
req = await r.readuntil(b"\r\n\r\n")
|
||||||
|
path = line.split(b" ")[1].decode()
|
||||||
|
log(
|
||||||
|
f"GET {path} from {peer}"
|
||||||
|
+ (" (If-None-Match)" if b"if-none-match" in req.lower() else "")
|
||||||
|
)
|
||||||
|
what = await respond(path, req, w)
|
||||||
|
await w.drain()
|
||||||
|
log(f" -> {path}: {what}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log(f" !! {peer}: {exc!r}")
|
||||||
|
finally:
|
||||||
|
w.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
server = await asyncio.start_server(handle, "0.0.0.0", PORT)
|
||||||
|
log(f"listening on {PORT}")
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Request one camera image over the native API and report its size (direct esp32_camera check)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
c = APIClient(sys.argv[1], 6053, None)
|
||||||
|
await c.connect(login=True)
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
cams = [e for e in ents if type(e).__name__ == "CameraInfo"]
|
||||||
|
print("camera entities:", [e.name for e in cams])
|
||||||
|
got = asyncio.get_event_loop().create_future()
|
||||||
|
|
||||||
|
def on_state(state):
|
||||||
|
if type(state).__name__ == "CameraState" and not got.done():
|
||||||
|
got.set_result(len(state.data))
|
||||||
|
|
||||||
|
c.subscribe_states(on_state)
|
||||||
|
c.request_single_image()
|
||||||
|
try:
|
||||||
|
n = await asyncio.wait_for(got, 10)
|
||||||
|
print("single image bytes:", n)
|
||||||
|
except TimeoutError:
|
||||||
|
print("no image within 10 s")
|
||||||
|
await c.disconnect()
|
||||||
|
sys.exit(1)
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# usage: measure.sh IP LABEL LOGFILE [OUTDIR]
|
||||||
|
# Runs the fixed RTSP measurement set against one build and appends a result line to OUTDIR/results.txt:
|
||||||
|
# ping RTT, two 12 s ffmpeg runs, two concurrent ffmpeg clients, and the raw receive rate with the default
|
||||||
|
# and a 1 MiB socket buffer. LOGFILE is the device capture, used to count "stalled" drops during the run.
|
||||||
|
# A measurement that produced nothing is written as ERR and the script exits 1, so a broken run never looks
|
||||||
|
# like a measured one. Needs ffmpeg and a python with the rtsp scripts' dependencies (PYTHON, default python3).
|
||||||
|
D=${0:A:h}
|
||||||
|
IP=$1; LABEL=$2; LOG=$3; S=${4:-.}
|
||||||
|
PY=${PYTHON:-python3}
|
||||||
|
[ -f "$LOG" ] || { echo "device capture not found: $LOG"; exit 1; }
|
||||||
|
rtt=$(ping -c 10 -i 0.2 $IP 2>&1 | tail -1 | awk -F/ '{print $5}')
|
||||||
|
stall0=$(grep -a -c "stalled" $LOG)
|
||||||
|
frames() { # OUTFILE RC: the frame count of a run that finished on its own, nothing for a failed or killed one
|
||||||
|
[ "$2" = 0 ] && grep -o 'frame= *[0-9]*' $1 | tail -1 | tr -dc 0-9
|
||||||
|
}
|
||||||
|
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f1.txt 2>&1; f1=$(frames $S/m-f1.txt $?); sleep 2
|
||||||
|
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f2.txt 2>&1; f2=$(frames $S/m-f2.txt $?); sleep 2
|
||||||
|
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoA.txt 2>&1 & A=$!; sleep 1
|
||||||
|
timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoB.txt 2>&1 & B=$!
|
||||||
|
wait $A; ra=$?; wait $B; rb=$?
|
||||||
|
fa=$(frames $S/m-twoA.txt $ra); fb=$(frames $S/m-twoB.txt $rb); sleep 2
|
||||||
|
timeout 40 $PY $D/rawrate2.py $IP quiet > $S/m-raw.txt 2>&1 && raw=$(grep -o "[0-9.]* KiB/s" $S/m-raw.txt | head -1); sleep 2
|
||||||
|
timeout 40 $PY $D/rawrate2.py $IP bigbuf > $S/m-raw2.txt 2>&1 && raw2=$(grep -o "[0-9.]* KiB/s" $S/m-raw2.txt | head -1); sleep 1
|
||||||
|
stall1=$(grep -a -c "stalled" $LOG)
|
||||||
|
bad=0
|
||||||
|
for v in rtt f1 f2 fa fb raw raw2; do
|
||||||
|
if [ -z "${(P)v}" ]; then eval "$v=ERR"; bad=1; fi
|
||||||
|
done
|
||||||
|
line="$LABEL | rtt avg ${rtt} ms | ffmpeg single 12 s: ${f1}, ${f2} frames | two clients: ${fa} + ${fb} | python raw: ${raw} (1 MiB rcvbuf ${raw2}) | stalls logged: $((stall1 - stall0))"
|
||||||
|
echo "$line" | tee -a $S/results.txt
|
||||||
|
if [ $bad = 1 ]; then echo "one or more measurements failed (ERR above)"; exit 1; fi
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import contextlib
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
IP = sys.argv[1]
|
||||||
|
mode = sys.argv[2]
|
||||||
|
secs = 8
|
||||||
|
s = socket.create_connection((IP, 554), timeout=5)
|
||||||
|
if mode == "bigbuf":
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
|
||||||
|
cseq = 0
|
||||||
|
|
||||||
|
|
||||||
|
buf = b""
|
||||||
|
|
||||||
|
|
||||||
|
def read_response() -> bytes:
|
||||||
|
"""One complete RTSP response: headers through the blank line plus a Content-Length body."""
|
||||||
|
global buf # noqa: PLW0603
|
||||||
|
while (end := buf.find(b"\r\n\r\n")) < 0:
|
||||||
|
chunk = s.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("closed")
|
||||||
|
buf += chunk
|
||||||
|
head = buf[:end].decode(errors="replace")
|
||||||
|
body_len = 0
|
||||||
|
for line in head.split("\r\n"):
|
||||||
|
if line.lower().startswith("content-length:"):
|
||||||
|
body_len = int(line.split(":", 1)[1])
|
||||||
|
while len(buf) < end + 4 + body_len:
|
||||||
|
chunk = s.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("closed")
|
||||||
|
buf += chunk
|
||||||
|
resp, buf = buf[: end + 4 + body_len], buf[end + 4 + body_len :]
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def req(m, extra="", wait=True):
|
||||||
|
global cseq # noqa: PLW0603
|
||||||
|
cseq += 1
|
||||||
|
url = f"rtsp://{IP}:554/trackID=0" if m == "SETUP" else f"rtsp://{IP}:554/"
|
||||||
|
s.sendall(f"{m} {url} RTSP/1.0\r\nCSeq: {cseq}\r\n{extra}\r\n".encode())
|
||||||
|
return read_response() if wait else b""
|
||||||
|
|
||||||
|
|
||||||
|
req("OPTIONS")
|
||||||
|
req("DESCRIBE", "Accept: application/sdp\r\n")
|
||||||
|
r = req("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")
|
||||||
|
sess = (
|
||||||
|
[x for x in r.decode(errors="replace").split("\r\n") if x.startswith("Session")][0]
|
||||||
|
.split()[1]
|
||||||
|
.split(";")[0]
|
||||||
|
)
|
||||||
|
req("PLAY", f"Session: {sess}\r\n")
|
||||||
|
t0 = time.monotonic()
|
||||||
|
total = 0
|
||||||
|
chunks = 0
|
||||||
|
last_chat = t0
|
||||||
|
s.settimeout(2)
|
||||||
|
while time.monotonic() - t0 < secs:
|
||||||
|
now = time.monotonic()
|
||||||
|
if mode == "chatty" and now - last_chat > 0.2:
|
||||||
|
req("GET_PARAMETER", f"Session: {sess}\r\n", wait=False)
|
||||||
|
last_chat = now
|
||||||
|
if mode == "rtcp" and now - last_chat > 0.2:
|
||||||
|
s.sendall(b"$\x01\x00\x08" + b"\x80\xc9\x00\x01\x00\x00\x00\x01")
|
||||||
|
last_chat = now
|
||||||
|
try:
|
||||||
|
d = s.recv(65536)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
total += len(d)
|
||||||
|
chunks += 1
|
||||||
|
print(
|
||||||
|
f"{mode:7s}: {total / 1024 / secs:5.1f} KiB/s, {chunks} recvs, avg {total // max(chunks, 1)} B/recv"
|
||||||
|
)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
req("TEARDOWN", f"Session: {sess}\r\n")
|
||||||
|
s.close()
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Raw RTSP/TCP client for the esphome rtsp_server hardware test.
|
||||||
|
|
||||||
|
usage: rtsp_client.py HOST [case ...]
|
||||||
|
cases: play, udp, pipeline, third, stall, teardown, getparam, badreq, replay, timeout
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
HOST = sys.argv[1]
|
||||||
|
PORT = 554
|
||||||
|
cases = sys.argv[2:] or [
|
||||||
|
"play",
|
||||||
|
"udp",
|
||||||
|
"pipeline",
|
||||||
|
"badreq",
|
||||||
|
"replay",
|
||||||
|
"getparam",
|
||||||
|
"teardown",
|
||||||
|
"third",
|
||||||
|
"stall",
|
||||||
|
]
|
||||||
|
results = []
|
||||||
|
|
||||||
|
|
||||||
|
def say(case, ok, detail):
|
||||||
|
results.append((case, ok, detail))
|
||||||
|
print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, timeout=5):
|
||||||
|
self.s = socket.create_connection((HOST, PORT), timeout=timeout)
|
||||||
|
self.cseq = 0
|
||||||
|
self.session = None
|
||||||
|
self.buf = b""
|
||||||
|
|
||||||
|
def send(self, method, extra="", url=None):
|
||||||
|
self.cseq += 1
|
||||||
|
url = (
|
||||||
|
url or f"rtsp://{HOST}:{PORT}/trackID=0"
|
||||||
|
if method == "SETUP"
|
||||||
|
else (url or f"rtsp://{HOST}:{PORT}/")
|
||||||
|
)
|
||||||
|
req = f"{method} {url} RTSP/1.0\r\nCSeq: {self.cseq}\r\n"
|
||||||
|
if self.session:
|
||||||
|
req += f"Session: {self.session}\r\n"
|
||||||
|
req += extra + "\r\n"
|
||||||
|
self.s.sendall(req.encode())
|
||||||
|
return self.cseq
|
||||||
|
|
||||||
|
def _recv(self):
|
||||||
|
chunk = self.s.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("closed")
|
||||||
|
self.buf += chunk
|
||||||
|
|
||||||
|
def read_response(self):
|
||||||
|
# Read until a full RTSP response (headers + optional body); skip any interleaved data in between
|
||||||
|
while True:
|
||||||
|
if self.buf.startswith(b"$"):
|
||||||
|
if len(self.buf) < 4:
|
||||||
|
self._recv()
|
||||||
|
continue
|
||||||
|
ln = struct.unpack(">H", self.buf[2:4])[0]
|
||||||
|
if len(self.buf) < 4 + ln:
|
||||||
|
self._recv()
|
||||||
|
continue
|
||||||
|
self.buf = self.buf[4 + ln :]
|
||||||
|
continue
|
||||||
|
end = self.buf.find(b"\r\n\r\n")
|
||||||
|
if end < 0:
|
||||||
|
self._recv()
|
||||||
|
continue
|
||||||
|
head = self.buf[:end].decode(errors="replace")
|
||||||
|
body_len = 0
|
||||||
|
for line in head.split("\r\n"):
|
||||||
|
if line.lower().startswith("content-length:"):
|
||||||
|
body_len = int(line.split(":", 1)[1])
|
||||||
|
while len(self.buf) < end + 4 + body_len:
|
||||||
|
self._recv()
|
||||||
|
body = self.buf[end + 4 : end + 4 + body_len]
|
||||||
|
self.buf = self.buf[end + 4 + body_len :]
|
||||||
|
status = int(head.split(" ", 2)[1])
|
||||||
|
for line in head.split("\r\n"):
|
||||||
|
if line.lower().startswith("session:"):
|
||||||
|
self.session = line.split(":", 1)[1].strip().split(";")[0]
|
||||||
|
return status, head, body
|
||||||
|
|
||||||
|
def read_frames(self, seconds):
|
||||||
|
"""Collect interleaved RTP packets on channel 0 for `seconds`; return (frames, packets, seq_gaps, sizes)."""
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
frames = 0
|
||||||
|
packets = 0
|
||||||
|
gaps = 0
|
||||||
|
last_seq = None
|
||||||
|
cur = 0
|
||||||
|
sizes = []
|
||||||
|
self.s.settimeout(1.0)
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
chunk = self.s.recv(65536)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self.buf += chunk
|
||||||
|
while True:
|
||||||
|
if len(self.buf) < 4 or self.buf[0:1] != b"$":
|
||||||
|
if self.buf and self.buf[0:1] != b"$":
|
||||||
|
end = self.buf.find(b"\r\n\r\n")
|
||||||
|
if end < 0:
|
||||||
|
break
|
||||||
|
self.buf = self.buf[end + 4 :]
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
ch = self.buf[1]
|
||||||
|
ln = struct.unpack(">H", self.buf[2:4])[0]
|
||||||
|
if len(self.buf) < 4 + ln:
|
||||||
|
break
|
||||||
|
pkt = self.buf[4 : 4 + ln]
|
||||||
|
self.buf = self.buf[4 + ln :]
|
||||||
|
if ch != 0 or len(pkt) < 12:
|
||||||
|
continue
|
||||||
|
packets += 1
|
||||||
|
seq = struct.unpack(">H", pkt[2:4])[0]
|
||||||
|
marker = pkt[1] & 0x80
|
||||||
|
if last_seq is not None and seq != ((last_seq + 1) & 0xFFFF):
|
||||||
|
gaps += 1
|
||||||
|
last_seq = seq
|
||||||
|
cur += len(pkt) - 12
|
||||||
|
if marker:
|
||||||
|
frames += 1
|
||||||
|
sizes.append(cur)
|
||||||
|
cur = 0
|
||||||
|
return frames, packets, gaps, sizes
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_play(c):
|
||||||
|
c.send("OPTIONS")
|
||||||
|
st, head, _ = c.read_response()
|
||||||
|
assert st == 200, head
|
||||||
|
c.send("DESCRIBE", "Accept: application/sdp\r\n")
|
||||||
|
st, head, body = c.read_response()
|
||||||
|
assert st == 200, head
|
||||||
|
c.send("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")
|
||||||
|
st, head, _ = c.read_response()
|
||||||
|
assert st == 200, head
|
||||||
|
c.send("PLAY")
|
||||||
|
st, head, _ = c.read_response()
|
||||||
|
assert st == 200, head
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
try:
|
||||||
|
if case == "play":
|
||||||
|
c = Client()
|
||||||
|
sdp = setup_play(c)
|
||||||
|
frames, packets, gaps, sizes = c.read_frames(10)
|
||||||
|
c.send("TEARDOWN")
|
||||||
|
c.read_response()
|
||||||
|
c.close()
|
||||||
|
ok = frames >= 50 and gaps == 0
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
ok,
|
||||||
|
f"{frames} frames in 10 s ({frames / 10:.1f} fps), {packets} RTP packets, {gaps} seq gaps, frame size min {min(sizes) if sizes else 0} max {max(sizes) if sizes else 0}; sdp has m=video {'m=video' in sdp.decode()}",
|
||||||
|
)
|
||||||
|
elif case == "udp":
|
||||||
|
c = Client()
|
||||||
|
c.send("OPTIONS")
|
||||||
|
c.read_response()
|
||||||
|
c.send("SETUP", "Transport: RTP/AVP;unicast;client_port=5000-5001\r\n")
|
||||||
|
st, head, _ = c.read_response()
|
||||||
|
c.close()
|
||||||
|
say(case, st == 461, f"UDP SETUP answered {st}")
|
||||||
|
elif case == "pipeline":
|
||||||
|
c = Client()
|
||||||
|
c.s.sendall(
|
||||||
|
f"OPTIONS rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 1\r\n\r\nDESCRIBE rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n".encode()
|
||||||
|
)
|
||||||
|
c.cseq = 2
|
||||||
|
st1, h1, _ = c.read_response()
|
||||||
|
st2, h2, b2 = c.read_response()
|
||||||
|
c.close()
|
||||||
|
cs = [x for x in (h1 + h2).split("\r\n") if x.startswith("CSeq")]
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
st1 == 200 and st2 == 200 and cs == ["CSeq: 1", "CSeq: 2"],
|
||||||
|
f"two pipelined requests answered {st1}, {st2} with {cs}",
|
||||||
|
)
|
||||||
|
elif case == "badreq":
|
||||||
|
c = Client()
|
||||||
|
c.s.sendall(b"BOGUS rtsp://x/ RTSP/1.0\r\nCSeq: 7\r\n\r\n")
|
||||||
|
st, head, _ = c.read_response()
|
||||||
|
c.close()
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
st in (400, 405, 501) and "CSeq: 7" in head,
|
||||||
|
f"unknown method answered {st}, CSeq echoed {'CSeq: 7' in head}",
|
||||||
|
)
|
||||||
|
elif case == "replay":
|
||||||
|
c = Client()
|
||||||
|
setup_play(c)
|
||||||
|
f1, _, _, _ = c.read_frames(3)
|
||||||
|
c.send("PLAY")
|
||||||
|
st, head, _ = c.read_response()
|
||||||
|
f2, _, gaps, _ = c.read_frames(3)
|
||||||
|
c.send("TEARDOWN")
|
||||||
|
c.read_response()
|
||||||
|
c.close()
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
st == 200 and f2 >= 10 and gaps == 0,
|
||||||
|
f"second PLAY answered {st}, {f1} then {f2} frames, {gaps} seq gaps after repeat PLAY",
|
||||||
|
)
|
||||||
|
elif case == "getparam":
|
||||||
|
c = Client()
|
||||||
|
setup_play(c)
|
||||||
|
c.read_frames(1)
|
||||||
|
c.send("GET_PARAMETER")
|
||||||
|
st, head, _ = c.read_response()
|
||||||
|
f, _, _, _ = c.read_frames(2)
|
||||||
|
c.send("TEARDOWN")
|
||||||
|
c.read_response()
|
||||||
|
c.close()
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
st == 200 and f > 5,
|
||||||
|
f"GET_PARAMETER while playing answered {st}, stream continued with {f} frames in 2 s",
|
||||||
|
)
|
||||||
|
elif case == "teardown":
|
||||||
|
c = Client()
|
||||||
|
setup_play(c)
|
||||||
|
c.read_frames(2)
|
||||||
|
c.send("TEARDOWN")
|
||||||
|
st, head, _ = c.read_response()
|
||||||
|
c.s.settimeout(3)
|
||||||
|
time.sleep(1.5)
|
||||||
|
try:
|
||||||
|
rest = c.s.recv(65536)
|
||||||
|
closed = rest == b""
|
||||||
|
except TimeoutError:
|
||||||
|
rest = b"x"
|
||||||
|
closed = False
|
||||||
|
except ConnectionError:
|
||||||
|
closed = True
|
||||||
|
c.close()
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
st == 200,
|
||||||
|
f"TEARDOWN answered {st}; connection closed by server afterwards: {closed}",
|
||||||
|
)
|
||||||
|
elif case == "third":
|
||||||
|
a = Client()
|
||||||
|
setup_play(a)
|
||||||
|
b = Client()
|
||||||
|
setup_play(b)
|
||||||
|
fa, _, ga, _ = a.read_frames(4)
|
||||||
|
fb, _, gb, _ = b.read_frames(4)
|
||||||
|
try:
|
||||||
|
d = Client(timeout=3)
|
||||||
|
d.send("OPTIONS")
|
||||||
|
st, _, _ = d.read_response()
|
||||||
|
third = f"third got {st}"
|
||||||
|
d.close()
|
||||||
|
except ConnectionError as e:
|
||||||
|
# refused, reset or closed by the server; a timeout or a bug propagates and fails the case
|
||||||
|
third = f"third rejected ({type(e).__name__})"
|
||||||
|
a.send("TEARDOWN")
|
||||||
|
a.read_response()
|
||||||
|
a.close()
|
||||||
|
b.send("TEARDOWN")
|
||||||
|
b.read_response()
|
||||||
|
b.close()
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
fa > 10 and fb > 10 and ga == 0 and gb == 0 and "rejected" in third,
|
||||||
|
f"two clients: {fa} and {fb} frames in 4 s each, gaps {ga}/{gb}; {third}",
|
||||||
|
)
|
||||||
|
elif case == "stall":
|
||||||
|
a = Client()
|
||||||
|
setup_play(a)
|
||||||
|
b = Client()
|
||||||
|
setup_play(b)
|
||||||
|
# stop reading on a for 4 s while b keeps playing
|
||||||
|
a.s.settimeout(None)
|
||||||
|
fb1, _, gb1, _ = b.read_frames(4)
|
||||||
|
a.s.settimeout(1.0)
|
||||||
|
fa, _, ga, _ = a.read_frames(4)
|
||||||
|
fb2, _, gb2, _ = b.read_frames(3)
|
||||||
|
a.send("TEARDOWN")
|
||||||
|
a.read_response()
|
||||||
|
a.close()
|
||||||
|
b.send("TEARDOWN")
|
||||||
|
b.read_response()
|
||||||
|
b.close()
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
fb1 > 10 and fb2 > 10 and fa > 5,
|
||||||
|
f"b kept {fb1} then {fb2} frames while a stalled 4 s; a recovered {fa} frames after resuming (gaps {ga}); b gaps {gb1}/{gb2}; device log must show stalled/dropping frame",
|
||||||
|
)
|
||||||
|
elif case == "timeout":
|
||||||
|
c = Client(timeout=70)
|
||||||
|
setup_play(c)
|
||||||
|
c.s.settimeout(
|
||||||
|
2
|
||||||
|
) # a server that stops sending but never closes must not hang the run
|
||||||
|
t0 = time.monotonic()
|
||||||
|
dead = False
|
||||||
|
try:
|
||||||
|
while time.monotonic() - t0 < 68:
|
||||||
|
try:
|
||||||
|
d = c.s.recv(65536)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
if not d:
|
||||||
|
dead = True
|
||||||
|
break
|
||||||
|
except ConnectionError:
|
||||||
|
dead = True
|
||||||
|
elapsed = time.monotonic() - t0
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
dead and 50 <= elapsed <= 68,
|
||||||
|
f"connection closed by server after idle: {dead} at {elapsed:.0f} s (expect the 60 s session timeout when the client sends nothing)",
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
say(case, False, f"exception {type(e).__name__}: {e}")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"SUMMARY",
|
||||||
|
sum(1 for r in results if r[1]),
|
||||||
|
"pass",
|
||||||
|
sum(1 for r in results if not r[1]),
|
||||||
|
"fail",
|
||||||
|
)
|
||||||
|
sys.exit(1 if any(not r[1] for r in results) else 0)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Bounded, timestamped serial capture that always exits and closes the port.
|
"""Bounded, timestamped serial capture that always exits and closes the port.
|
||||||
|
|
||||||
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append]
|
serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] [--ms]
|
||||||
|
|
||||||
default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico.
|
default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico.
|
||||||
--released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin
|
--released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin
|
||||||
@@ -76,14 +76,21 @@ def open_port(args: argparse.Namespace, path: str) -> serial.Serial:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def write_line(out, tag: bytes, data: bytes) -> None:
|
def write_line(out, tag: bytes, data: bytes, ms: bool = False) -> None:
|
||||||
stamp = time.strftime("[%H:%M:%S]").encode()
|
if ms:
|
||||||
|
now = time.time()
|
||||||
|
stamp = (
|
||||||
|
time.strftime("[%H:%M:%S", time.localtime(now))
|
||||||
|
+ f".{int(now * 1000) % 1000:03d}]"
|
||||||
|
).encode()
|
||||||
|
else:
|
||||||
|
stamp = time.strftime("[%H:%M:%S]").encode()
|
||||||
out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"<secret>", data)) + b"\n")
|
out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"<secret>", data)) + b"\n")
|
||||||
out.flush()
|
out.flush()
|
||||||
|
|
||||||
|
|
||||||
def note(out, text: str) -> None:
|
def note(out, text: str, ms: bool = False) -> None:
|
||||||
write_line(out, b"[capture] ", text.encode())
|
write_line(out, b"[capture] ", text.encode(), ms)
|
||||||
|
|
||||||
|
|
||||||
def capture(args: argparse.Namespace, out) -> int:
|
def capture(args: argparse.Namespace, out) -> int:
|
||||||
@@ -102,18 +109,18 @@ def capture(args: argparse.Namespace, out) -> int:
|
|||||||
if serial_number is None:
|
if serial_number is None:
|
||||||
serial_number, index = identify(path)
|
serial_number, index = identify(path)
|
||||||
if path != args.port:
|
if path != args.port:
|
||||||
note(out, f"reopened as {path}")
|
note(out, f"reopened as {path}", args.ms)
|
||||||
except PORT_ERRORS as err:
|
except PORT_ERRORS as err:
|
||||||
note(out, f"open failed: {err}")
|
note(out, f"open failed: {err}", args.ms)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
data = s.read(4096)
|
data = s.read(4096)
|
||||||
except PORT_ERRORS as err:
|
except PORT_ERRORS as err:
|
||||||
if buf: # keep the cut-off line apart from the next boot's first line
|
if buf: # keep the cut-off line apart from the next boot's first line
|
||||||
write_line(out, b"[partial]", buf)
|
write_line(out, b"[partial]", buf, args.ms)
|
||||||
buf = b""
|
buf = b""
|
||||||
note(out, f"port lost: {err}")
|
note(out, f"port lost: {err}", args.ms)
|
||||||
with contextlib.suppress(*PORT_ERRORS):
|
with contextlib.suppress(*PORT_ERRORS):
|
||||||
s.close()
|
s.close()
|
||||||
s = None
|
s = None
|
||||||
@@ -125,9 +132,9 @@ def capture(args: argparse.Namespace, out) -> int:
|
|||||||
buf += data
|
buf += data
|
||||||
while b"\n" in buf:
|
while b"\n" in buf:
|
||||||
line, buf = buf.split(b"\n", 1)
|
line, buf = buf.split(b"\n", 1)
|
||||||
write_line(out, b"", line.rstrip(b"\r"))
|
write_line(out, b"", line.rstrip(b"\r"), args.ms)
|
||||||
if buf:
|
if buf:
|
||||||
write_line(out, b"[partial]", buf)
|
write_line(out, b"[partial]", buf, args.ms)
|
||||||
if s is not None:
|
if s is not None:
|
||||||
s.close()
|
s.close()
|
||||||
return received
|
return received
|
||||||
@@ -144,6 +151,7 @@ def main() -> int:
|
|||||||
ap.add_argument("--released", action="store_true")
|
ap.add_argument("--released", action="store_true")
|
||||||
ap.add_argument("--reset", action="store_true")
|
ap.add_argument("--reset", action="store_true")
|
||||||
ap.add_argument("--append", action="store_true")
|
ap.add_argument("--append", action="store_true")
|
||||||
|
ap.add_argument("--ms", action="store_true", help="millisecond timestamps")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out:
|
with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out:
|
||||||
received = capture(args, out)
|
received = capture(args, out)
|
||||||
|
|||||||
@@ -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,40 @@
|
|||||||
|
"""Compare the listener log (JSON lines from udp_listen.py) against the udp-write-dut.yaml payload sequence.
|
||||||
|
|
||||||
|
usage: check.py LISTEN_JSONL ECHO_HEX (ECHO_HEX is the datagram sent to the device for the echo case)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with pathlib.Path(sys.argv[1]).open(encoding="utf-8") as f:
|
||||||
|
log = [json.loads(line) for line in f if line.strip()]
|
||||||
|
echo_in = bytes.fromhex(sys.argv[2])
|
||||||
|
small = bytes([0xAA, 0x55, 0x01, 0x02])
|
||||||
|
expected = [
|
||||||
|
("small 1/2", small),
|
||||||
|
("small 2/2", small),
|
||||||
|
("simple", bytes([1, 2, 3])),
|
||||||
|
("text", b"hello udp"),
|
||||||
|
("empty", b""),
|
||||||
|
("stack128", bytes((0x10 + i) & 0xFF for i in range(128))),
|
||||||
|
("heap129", bytes((0x20 + i) & 0xFF for i in range(129))),
|
||||||
|
("heap600", bytes((0x30 + i) & 0xFF for i in range(600))),
|
||||||
|
("lambda300", bytes((i * 7) & 0xFF for i in range(300))),
|
||||||
|
("echo", echo_in[::-1]),
|
||||||
|
]
|
||||||
|
got = [bytes.fromhex(e["hex"]) for e in log]
|
||||||
|
ok = 0
|
||||||
|
for i, (name, want) in enumerate(expected):
|
||||||
|
have = got[i] if i < len(got) else None
|
||||||
|
res = (
|
||||||
|
"OK"
|
||||||
|
if have == want
|
||||||
|
else f"MISMATCH got {None if have is None else have[:16].hex() + '... len ' + str(len(have))}"
|
||||||
|
)
|
||||||
|
ok += res == "OK"
|
||||||
|
print(f"{name:10s} {len(want):4d} B {res}")
|
||||||
|
print(
|
||||||
|
f"{ok}/{len(expected)} payloads byte-exact, {len(got)} datagrams received, sources {sorted({e['src'] for e in log})}"
|
||||||
|
)
|
||||||
|
sys.exit(0 if ok == len(expected) and len(got) == len(expected) else 1)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Log every datagram on PORT for SECONDS as one JSON line: time, source, length, hex payload."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
port, secs, out = int(sys.argv[1]), float(sys.argv[2]), sys.argv[3]
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind(("", port))
|
||||||
|
s.settimeout(1.0)
|
||||||
|
end = time.monotonic() + secs
|
||||||
|
with pathlib.Path(out).open("w", encoding="utf-8") as f:
|
||||||
|
while time.monotonic() < end:
|
||||||
|
try:
|
||||||
|
data, (ip, sport) = s.recvfrom(65535)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"t": time.strftime("%H:%M:%S"),
|
||||||
|
"src": ip,
|
||||||
|
"len": len(data),
|
||||||
|
"hex": data.hex(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
f.flush()
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# write_cases.sh DEVICE_IP KEY [PORT] : drive a device built from templates/udp-write-dut.yaml. Records every datagram
|
||||||
|
# the device sends to PORT (the udp component's port, default 18511) with udp_listen.py in the working directory,
|
||||||
|
# presses the eight payload buttons over the API two seconds apart (KEY as for api_button.py, - for a plaintext API),
|
||||||
|
# sends one 16 byte unicast datagram to the device for the echo case, then compares the recorded datagrams with
|
||||||
|
# check.py. The recorder runs until the driver stops it. Exit 1 when the recorder did not start or a payload is
|
||||||
|
# missing or differs; listen.jsonl stays in the working directory.
|
||||||
|
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; DEV=$1; KEY=$2; PORT=${3:-18511}
|
||||||
|
[ -n "$DEV" ] && [ -n "$KEY" ] || { echo "usage: write_cases.sh DEVICE_IP KEY [PORT]"; exit 2; }
|
||||||
|
ECHO=0102030405060708090a0b0c0d0e0f10
|
||||||
|
rm -f "$PWD/listen.jsonl"
|
||||||
|
$PY "$HERE/udp_listen.py" "$PORT" 600 "$PWD/listen.jsonl" &
|
||||||
|
lst=$!
|
||||||
|
sleep 2
|
||||||
|
kill -0 $lst 2>/dev/null || { echo "udp_listen.py is not running on port $PORT"; exit 1; }
|
||||||
|
for b in Small Simple Text Empty Stack128 Heap129 Heap600 Lambda300; do
|
||||||
|
$PY "$HERE/../api_button.py" "$DEV" "$KEY" press "$b" > /dev/null || echo "press $b failed"; sleep 2
|
||||||
|
done
|
||||||
|
$PY -c "import socket; socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(bytes.fromhex('$ECHO'), ('$DEV', $PORT))"
|
||||||
|
sleep 5
|
||||||
|
kill $lst 2>/dev/null; wait $lst 2>/dev/null
|
||||||
|
$PY "$HERE/check.py" "$PWD/listen.jsonl" "$ECHO"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Play a URL on the speaker_source media player over the API (no sync task involved).
|
||||||
|
|
||||||
|
usage: playurl.py HOST KEY URL [PLAYER_NAME]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient, MediaPlayerInfo
|
||||||
|
|
||||||
|
HOST, KEY, URL = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
PLAYER = sys.argv[4] if len(sys.argv) > 4 else "Player"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||||
|
await c.connect(login=True)
|
||||||
|
ents, _ = await c.list_entities_services()
|
||||||
|
players = {e.name: e for e in ents if isinstance(e, MediaPlayerInfo)}
|
||||||
|
print("media players:", sorted(players))
|
||||||
|
if PLAYER not in players:
|
||||||
|
sys.exit(f"no media player named {PLAYER!r}; have {sorted(players)}")
|
||||||
|
p = players[PLAYER]
|
||||||
|
c.media_player_command(p.key, media_url=URL)
|
||||||
|
print("sent play_media to", PLAYER)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <zephyr/device.h>
|
||||||
|
#include <zephyr/drivers/uart.h>
|
||||||
|
#include "esphome/core/log.h"
|
||||||
|
|
||||||
|
// Direct look at the Zephyr cdc_acm_uart1 driver state, bypassing the usb_cdc_acm component
|
||||||
|
inline void cdc_probe() {
|
||||||
|
const device *d = DEVICE_DT_GET(DT_NODELABEL(cdc_acm_uart1));
|
||||||
|
uint8_t tmp[32];
|
||||||
|
int rr = uart_irq_rx_ready(d);
|
||||||
|
int n = uart_fifo_read(d, tmp, sizeof(tmp));
|
||||||
|
ESP_LOGI("probe", "driver rx_ready=%d fifo_read=%d first=0x%02X", rr, n, n > 0 ? tmp[0] : 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Drive the usb_cdc_acm echo firmware on the nRF52840 dongle from the Mac.
|
||||||
|
|
||||||
|
usage: drive.py DATA_PORT [case ...]
|
||||||
|
Cases: short, bulk1k, bulk16k, bulk64k, burst, flush, peek, linecoding, dtr, stream, counters
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import serial
|
||||||
|
|
||||||
|
port = sys.argv[1]
|
||||||
|
cases = sys.argv[2:] or [
|
||||||
|
"short",
|
||||||
|
"bulk1k",
|
||||||
|
"bulk16k",
|
||||||
|
"bulk64k",
|
||||||
|
"burst",
|
||||||
|
"flush",
|
||||||
|
"peek",
|
||||||
|
"linecoding",
|
||||||
|
"dtr",
|
||||||
|
"stream",
|
||||||
|
"counters",
|
||||||
|
]
|
||||||
|
results = []
|
||||||
|
|
||||||
|
|
||||||
|
def say(case, ok, detail):
|
||||||
|
results.append((case, ok, detail))
|
||||||
|
print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def info(case, detail):
|
||||||
|
"""A case whose result lives in the device log; printed, not counted."""
|
||||||
|
print(f"INFO {case}: {detail}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def open_port(baud=115200):
|
||||||
|
s = serial.Serial(port, baudrate=baud, timeout=1)
|
||||||
|
time.sleep(0.3) # let the 100 ms line-state poll see DTR and the echo loop settle
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def read_exact(s, n, timeout):
|
||||||
|
buf = bytearray()
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while len(buf) < n and time.monotonic() < deadline:
|
||||||
|
chunk = s.read(min(4096, n - len(buf)))
|
||||||
|
if chunk:
|
||||||
|
buf += chunk
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
def payload(n):
|
||||||
|
# No 0x01 anywhere: the device treats a chunk starting with 0x01 as a command
|
||||||
|
rnd = random.Random(n)
|
||||||
|
return bytes(rnd.choice(range(2, 256)) for _ in range(n))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd(s, c, extra=b""):
|
||||||
|
s.write(b"\x01" + c + extra)
|
||||||
|
s.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def echo_case(name, n, chunk):
|
||||||
|
s = open_port()
|
||||||
|
s.reset_input_buffer()
|
||||||
|
data = payload(n)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
for i in range(0, n, chunk):
|
||||||
|
s.write(data[i : i + chunk])
|
||||||
|
got = read_exact(s, n, timeout=max(5, n / 2000))
|
||||||
|
dt = time.monotonic() - t0
|
||||||
|
s.close()
|
||||||
|
ok = got == data
|
||||||
|
first_bad = next((i for i in range(min(len(got), n)) if got[i] != data[i]), None)
|
||||||
|
say(
|
||||||
|
name,
|
||||||
|
ok,
|
||||||
|
f"{len(got)}/{n} bytes echoed in {dt:.2f}s ({n / dt / 1024:.1f} KiB/s), first mismatch {first_bad}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
if case == "short":
|
||||||
|
s = open_port()
|
||||||
|
s.reset_input_buffer()
|
||||||
|
ok = True
|
||||||
|
lat = []
|
||||||
|
for i in range(20):
|
||||||
|
msg = f"hello {i}\n".encode()
|
||||||
|
t0 = time.monotonic()
|
||||||
|
s.write(msg)
|
||||||
|
got = read_exact(s, len(msg), 2)
|
||||||
|
lat.append(time.monotonic() - t0)
|
||||||
|
ok = ok and got == msg
|
||||||
|
s.close()
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
ok,
|
||||||
|
f"20 lines, latency min {min(lat) * 1000:.0f} ms max {max(lat) * 1000:.0f} ms",
|
||||||
|
)
|
||||||
|
elif case == "bulk1k":
|
||||||
|
echo_case(case, 1024, 1024)
|
||||||
|
elif case == "bulk16k":
|
||||||
|
echo_case(case, 16 * 1024, 4096)
|
||||||
|
elif case == "bulk64k":
|
||||||
|
echo_case(case, 64 * 1024, 16384)
|
||||||
|
elif case == "burst":
|
||||||
|
s = open_port()
|
||||||
|
s.reset_input_buffer()
|
||||||
|
cmd(s, b"B")
|
||||||
|
got = read_exact(s, 1000, 3)
|
||||||
|
s.close()
|
||||||
|
expect = bytes((ord("A") + (i % 26)) for i in range(1000))
|
||||||
|
prefix_ok = got == expect[: len(got)]
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
prefix_ok and 0 < len(got) < 1000,
|
||||||
|
f"{len(got)} of 1000 bytes arrived (256 byte TX ring, rest must be dropped and warned), prefix intact {prefix_ok}",
|
||||||
|
)
|
||||||
|
elif case == "flush":
|
||||||
|
s = open_port()
|
||||||
|
s.reset_input_buffer()
|
||||||
|
cmd(s, b"F")
|
||||||
|
got = read_exact(s, 200, 3)
|
||||||
|
s.close()
|
||||||
|
expect = bytes((ord("a") + (i % 26)) for i in range(200))
|
||||||
|
say(case, got == expect, f"{len(got)}/200 bytes, flush result in device log")
|
||||||
|
time.sleep(0.5)
|
||||||
|
elif case == "peek":
|
||||||
|
s = open_port()
|
||||||
|
s.reset_input_buffer()
|
||||||
|
cmd(s, b"K", b"Z")
|
||||||
|
time.sleep(0.5)
|
||||||
|
s.close()
|
||||||
|
info(
|
||||||
|
case,
|
||||||
|
"peek/read result is in the device log: expect `peek ok=1 0x5A read ok=1 0x5A`",
|
||||||
|
)
|
||||||
|
elif case == "linecoding":
|
||||||
|
for baud in (9600, 115200, 921600):
|
||||||
|
s = open_port(baud)
|
||||||
|
s.reset_input_buffer()
|
||||||
|
cmd(s, b"L")
|
||||||
|
time.sleep(0.4)
|
||||||
|
s.close()
|
||||||
|
time.sleep(0.3)
|
||||||
|
info(case, "opened at 9600, 115200, 921600; the device log must show each baud")
|
||||||
|
elif case == "dtr":
|
||||||
|
for _i in range(5):
|
||||||
|
s = open_port()
|
||||||
|
time.sleep(0.3)
|
||||||
|
s.close()
|
||||||
|
time.sleep(0.3)
|
||||||
|
info(
|
||||||
|
case, "5 open/close cycles; the device log must show 5 Opened and 5 Closed"
|
||||||
|
)
|
||||||
|
elif case == "stream":
|
||||||
|
# device streams 100 x 100 byte lines over 10 s; close the port for 3 s in the middle
|
||||||
|
s = open_port()
|
||||||
|
s.reset_input_buffer()
|
||||||
|
cmd(s, b"S")
|
||||||
|
part1 = bytearray()
|
||||||
|
t0 = time.monotonic()
|
||||||
|
while time.monotonic() - t0 < 2.5:
|
||||||
|
part1 += s.read(4096)
|
||||||
|
s.close()
|
||||||
|
time.sleep(3.0)
|
||||||
|
s = open_port()
|
||||||
|
part2 = bytearray()
|
||||||
|
t0 = time.monotonic()
|
||||||
|
while time.monotonic() - t0 < 6.0:
|
||||||
|
part2 += s.read(4096)
|
||||||
|
s.close()
|
||||||
|
lines1 = [x for x in bytes(part1).split(b"\n") if x.startswith(b"STREAM")]
|
||||||
|
lines2 = [x for x in bytes(part2).split(b"\n") if x.startswith(b"STREAM")]
|
||||||
|
n1 = [int(x[7:10]) for x in lines1]
|
||||||
|
n2 = [int(x[7:10]) for x in lines2]
|
||||||
|
resumed = bool(n2) and max(n2) >= 90
|
||||||
|
say(
|
||||||
|
case,
|
||||||
|
resumed,
|
||||||
|
f"before close: lines {min(n1) if n1 else None}-{max(n1) if n1 else None} ({len(n1)}), after reopen: {min(n2) if n2 else None}-{max(n2) if n2 else None} ({len(n2)}); streaming resumed after reopen {resumed}",
|
||||||
|
)
|
||||||
|
elif case == "counters":
|
||||||
|
s = open_port()
|
||||||
|
cmd(s, b"C")
|
||||||
|
time.sleep(0.3)
|
||||||
|
s.close()
|
||||||
|
info(case, "counters are in the device log")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"SUMMARY",
|
||||||
|
sum(1 for r in results if r[1]),
|
||||||
|
"pass",
|
||||||
|
sum(1 for r in results if not r[1]),
|
||||||
|
"fail",
|
||||||
|
)
|
||||||
|
sys.exit(1 if any(not r[1] for r in results) else 0)
|
||||||
@@ -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,39 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# usage: decode.sh CAPTURE ELF OUTPREFIX
|
||||||
|
# Parses the #CD: lines of a serial capture with Zephyr's coredump tools from the sdk-nrf that ESPHome
|
||||||
|
# installed (SDK_NRF, default: the sdk-nrf directory under the ESPHome cache), serves the dump to GDB on a
|
||||||
|
# random port and prints the backtrace and the thread list. The ELF is .pioenvs/<name>/zephyr/zephyr/zephyr.elf.
|
||||||
|
# Every step is checked: a missing tool, a capture without a complete dump, an empty .bin or a gdbserver
|
||||||
|
# that died all stop the script with exit 1, and the exit status is GDB's otherwise.
|
||||||
|
CAP=$1; ELF=$2; OUT=$3
|
||||||
|
SDK_NRF=${SDK_NRF:-$(ls -d ~/Library/Caches/esphome/sdk-nrf ~/.cache/esphome/sdk-nrf 2>/dev/null | head -1)}
|
||||||
|
[ -n "$SDK_NRF" ] || { echo "sdk-nrf not found, set SDK_NRF"; exit 1; }
|
||||||
|
F=$(ls -d $SDK_NRF/frameworks/v*/zephyr/scripts/coredump 2>/dev/null | tail -1)
|
||||||
|
PY=${PYTHON:-$(ls $SDK_NRF/penvs/*/bin/python 2>/dev/null | head -1)}
|
||||||
|
# SDK 0.x keeps the tools under arm-zephyr-eabi/, SDK 1.0+ under gnu/arm-zephyr-eabi/
|
||||||
|
GDB=$(ls $SDK_NRF/toolchains/*/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb $SDK_NRF/toolchains/*/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb 2>/dev/null | tail -1)
|
||||||
|
for v in F PY GDB; do
|
||||||
|
[ -n "${(P)v}" ] || { echo "$v not found under $SDK_NRF"; exit 1; }
|
||||||
|
done
|
||||||
|
[ -f "$ELF" ] || { echo "ELF not found: $ELF"; exit 1; }
|
||||||
|
# strip capture timestamps and ANSI so the parser sees the raw log lines
|
||||||
|
sed -E 's/^\[[0-9:.]+\]//; s/\x1b\[[0-9;]*m//g' "$CAP" > $OUT-all.log
|
||||||
|
# keep only the first complete #CD:BEGIN# .. #CD:END# block
|
||||||
|
awk '/#CD:BEGIN#/{if(!done){p=1;buf=""}} p{buf=buf $0 "\n"} /#CD:END#/{if(p){printf "%s", buf; done=1; p=0}}' $OUT-all.log > $OUT-raw.log
|
||||||
|
echo "CD lines in capture: $(grep -c '#CD:' $OUT-all.log) (BEGIN $(grep -c '#CD:BEGIN#' $OUT-all.log), END $(grep -c '#CD:END#' $OUT-all.log)); first complete block: $(grep -c '#CD:' $OUT-raw.log) lines"
|
||||||
|
[ -s $OUT-raw.log ] || { echo "no complete #CD:BEGIN# .. #CD:END# block in the capture"; exit 1; }
|
||||||
|
if ! $PY $F/coredump_serial_log_parser.py $OUT-raw.log $OUT.bin > $OUT-parser.log 2>&1; then
|
||||||
|
echo "parser failed:"; tail -5 $OUT-parser.log; exit 1
|
||||||
|
fi
|
||||||
|
tail -3 $OUT-parser.log
|
||||||
|
[ -s $OUT.bin ] || { echo "parser wrote an empty $OUT.bin"; exit 1; }
|
||||||
|
ls -la $OUT.bin | awk '{print "bin bytes", $5}'
|
||||||
|
PORT=$((20000 + RANDOM % 10000))
|
||||||
|
$PY $F/coredump_gdbserver.py --port $PORT "$ELF" $OUT.bin > $OUT-gdbserver.log 2>&1 &
|
||||||
|
G=$!
|
||||||
|
sleep 3
|
||||||
|
if ! kill -0 $G 2>/dev/null; then echo "gdbserver exited:"; tail -5 $OUT-gdbserver.log; exit 1; fi
|
||||||
|
$GDB -q -batch -ex "target remote :$PORT" -ex "bt" -ex "info threads" "$ELF" 2>&1 | grep -v "^warning\|Reading symbols\|^$" | head -40
|
||||||
|
rc=$pipestatus[1]
|
||||||
|
kill $G 2>/dev/null
|
||||||
|
exit $rc
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# usage: flow.sh LABEL PORT ELF [OUTDIR]
|
||||||
|
# Run at least 60 s after the board rebooted from its crash, with the port closed. Proves the stored
|
||||||
|
# coredump waits for a reader and survives a reader that goes away:
|
||||||
|
# 1) open the port for 3 s and close it (a reader that leaves mid-print)
|
||||||
|
# 2) open it for 100 s: the whole dump must print again, then be erased
|
||||||
|
# 3) open it again for 20 s: dump_config must report no stored dump
|
||||||
|
# then decode.sh parses the first complete dump and prints the GDB backtrace.
|
||||||
|
# Needs a python with pyserial (PYTHON, default python3).
|
||||||
|
D=${0:A:h}
|
||||||
|
L=$1; P=$2; ELF=$3; S=${4:-.}
|
||||||
|
PY=${PYTHON:-python3}
|
||||||
|
CAP=$D/../serial_capture.py
|
||||||
|
capture() { # SECONDS OUTFILE: stop the flow when the capture fails (busy or missing port)
|
||||||
|
if ! $PY $CAP $P $1 $2 > $2.err 2>&1; then
|
||||||
|
echo "capture of $2 failed:"; cat $2.err; exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
capture 3 $S/$L-brief.txt
|
||||||
|
echo "$L brief 3 s open: $(wc -l < $S/$L-brief.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-brief.txt), END $(grep -a -c '#CD:END#' $S/$L-brief.txt)"
|
||||||
|
sleep 5
|
||||||
|
capture 100 $S/$L-full.txt
|
||||||
|
echo "$L full 100 s open: $(wc -l < $S/$L-full.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-full.txt), BEGIN $(grep -a -c '#CD:BEGIN#' $S/$L-full.txt), END $(grep -a -c '#CD:END#' $S/$L-full.txt)"
|
||||||
|
grep -a "crashtest\|zephyr_coredump\|Reset reason\|Has stored\| Size:\| Error:\|took a long\|compiled on" $S/$L-full.txt | grep -a -v "#CD:[0-9A-F]" | head -16
|
||||||
|
sleep 3
|
||||||
|
capture 20 $S/$L-after.txt
|
||||||
|
echo "$L reopen 20 s: CD lines $(grep -a -c '#CD:' $S/$L-after.txt); $(grep -a 'Has stored\| Size:' $S/$L-after.txt | sed -E 's/^\[[^]]*\]//' | tr '\n' ' ')"
|
||||||
|
echo "== decode"
|
||||||
|
$D/decode.sh $S/$L-full.txt $ELF $S/$L
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aioesphomeapi import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
c = APIClient(sys.argv[1], 6053, None)
|
||||||
|
await c.connect(login=True)
|
||||||
|
di = await c.device_info()
|
||||||
|
print(
|
||||||
|
"version",
|
||||||
|
di.esphome_version,
|
||||||
|
"compiled",
|
||||||
|
di.compilation_time,
|
||||||
|
"home_id",
|
||||||
|
hex(di.zwave_home_id),
|
||||||
|
"flags",
|
||||||
|
di.zwave_proxy_feature_flags,
|
||||||
|
)
|
||||||
|
await c.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Recompute the Z-Wave checksum (XOR of LENGTH..payload, initial 0xFF) over every frame the proxy logged."""
|
||||||
|
|
||||||
|
from functools import reduce
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def zw_checksum(frame: bytes) -> int:
|
||||||
|
return reduce(lambda a, b: a ^ b, frame[1:-1], 0xFF)
|
||||||
|
|
||||||
|
|
||||||
|
def complete(frame: bytes) -> bool:
|
||||||
|
"""A data frame is SOF, LENGTH, then LENGTH bytes; the proxy logs at most 168 bytes, so longer ones are cut."""
|
||||||
|
return len(frame) >= 3 and frame[0] == 0x01 and len(frame) == frame[1] + 2
|
||||||
|
|
||||||
|
|
||||||
|
for path in sys.argv[1:]:
|
||||||
|
text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
|
||||||
|
sent = re.findall(r"Sending: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text)
|
||||||
|
recv = re.findall(r"Received frame: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text)
|
||||||
|
calc = re.findall(
|
||||||
|
r"CHECKSUM Received: 0x([0-9A-F]{2}) - Calculated: 0x([0-9A-F]{2})", text
|
||||||
|
)
|
||||||
|
bad = len(re.findall(r"Bad checksum", text))
|
||||||
|
acks_sent = len(re.findall(r"Sending ACK", text))
|
||||||
|
naks_sent = len(re.findall(r"Sending NAK/CAN", text))
|
||||||
|
acks_recv = len(re.findall(r"Received ACK", text))
|
||||||
|
naks_recv = len(re.findall(r"Received NAK", text))
|
||||||
|
reboots = len(re.findall(r"Boot count|Reset reason|rst:0x", text))
|
||||||
|
long_ops = len(re.findall(r"zwave_proxy took a long time", text))
|
||||||
|
rx_frames = [bytes.fromhex(f.replace(":", "")) for f in recv]
|
||||||
|
tx_frames = [bytes.fromhex(f.replace(":", "")) for f in sent]
|
||||||
|
rx_cut = sum(1 for b in rx_frames if not complete(b))
|
||||||
|
tx_cut = sum(1 for b in tx_frames if len(b) > 1 and not complete(b))
|
||||||
|
rx_ok = sum(1 for b in rx_frames if complete(b) and zw_checksum(b) == b[-1])
|
||||||
|
tx_ok = sum(1 for b in tx_frames if complete(b) and zw_checksum(b) == b[-1])
|
||||||
|
calc_ok = sum(1 for r, c in calc if r == c)
|
||||||
|
cmds = sorted({f.split(":")[3] for f in recv})
|
||||||
|
print(
|
||||||
|
f"{path.rsplit('/', 1)[-1]}: tx frames {len(sent)} (checksum ok {tx_ok}, truncated in log {tx_cut}), "
|
||||||
|
f"rx frames {len(recv)} (recomputed ok {rx_ok}, truncated in log {rx_cut}), "
|
||||||
|
f"device calc==received {calc_ok}/{len(calc)}, bad checksum warnings {bad}, ACK sent {acks_sent}, NAK sent {naks_sent}, "
|
||||||
|
f"ACK received {acks_recv}, NAK received {naks_recv}, long ops {long_ops}, rx cmd ids {cmds}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
|
||||||
|
esp8266:
|
||||||
|
board: d1_mini
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
power_save_mode: none
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
api:
|
||||||
|
reboot_timeout: 0s
|
||||||
|
actions:
|
||||||
|
- action: play_buzzer
|
||||||
|
description: Play a song or build one from parts
|
||||||
|
variables:
|
||||||
|
song_str:
|
||||||
|
type: string
|
||||||
|
description: Full RTTTL string
|
||||||
|
example: "scale:d=4,o=5,b=100:c,d,e"
|
||||||
|
required: false
|
||||||
|
notes:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
octave:
|
||||||
|
type: int
|
||||||
|
default: 5
|
||||||
|
bpm:
|
||||||
|
type: int
|
||||||
|
default: 100
|
||||||
|
tempo:
|
||||||
|
type: float
|
||||||
|
default: 1.5
|
||||||
|
loud:
|
||||||
|
type: bool
|
||||||
|
default: true
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
char s[40], n[40], l[40];
|
||||||
|
snprintf(s, sizeof(s), "%.*s", (int) song_str.size(), song_str.c_str());
|
||||||
|
snprintf(n, sizeof(n), "%.*s", (int) notes.size(), notes.c_str());
|
||||||
|
snprintf(l, sizeof(l), "%.*s", (int) label.size(), label.c_str());
|
||||||
|
ESP_LOGD("main", "play_buzzer song=%s(%d) notes=%s(%d) octave=%d bpm=%d tempo=%.1f loud=%d label=%s(%d)", s,
|
||||||
|
(int) song_str.size(), n, (int) notes.size(), octave, bpm, tempo, loud, l, (int) label.size());
|
||||||
|
- action: plain_action
|
||||||
|
variables:
|
||||||
|
value: int
|
||||||
|
then:
|
||||||
|
- logger.log:
|
||||||
|
format: "plain_action value=%d"
|
||||||
|
args: ["value"]
|
||||||
|
- action: list_action
|
||||||
|
variables:
|
||||||
|
values: int[]
|
||||||
|
then:
|
||||||
|
- logger.log:
|
||||||
|
format: "list_action n=%d"
|
||||||
|
args: ["values.size()"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
|
||||||
|
esp8266:
|
||||||
|
board: d1_mini
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
power_save_mode: none
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
api:
|
||||||
|
reboot_timeout: 0s
|
||||||
|
actions:
|
||||||
|
- action: str_action
|
||||||
|
variables:
|
||||||
|
song_str: string
|
||||||
|
notes: string
|
||||||
|
octave: int
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
char s[40], n[40];
|
||||||
|
snprintf(s, sizeof(s), "%.*s", (int) song_str.size(), song_str.c_str());
|
||||||
|
snprintf(n, sizeof(n), "%.*s", (int) notes.size(), notes.c_str());
|
||||||
|
ESP_LOGD("main", "str_action song=%s(%d) notes=%s(%d) octave=%d", s, (int) song_str.size(), n, (int) notes.size(), octave);
|
||||||
@@ -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,23 @@
|
|||||||
|
# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local
|
||||||
|
# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md).
|
||||||
|
# Nabu Casa Connect AUX-2 on the usb_audio PR tree: stock package (I2S PCM5122 output) as a control.
|
||||||
|
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
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local
|
||||||
|
# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md).
|
||||||
|
# Nabu Casa Connect AUX-2 on the usb_audio PR tree: the Anker S330 on the USB host port as the mixer output.
|
||||||
|
packages:
|
||||||
|
core: !include aux-2-pre-prod.yaml
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: aux-2-usb-dut
|
||||||
|
name_add_mac_suffix: false
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_key
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
baud_rate: 0
|
||||||
|
|
||||||
|
sendspin:
|
||||||
|
unpaired_access: true
|
||||||
|
|
||||||
|
usb_audio:
|
||||||
|
id: usb_audio_id
|
||||||
|
|
||||||
|
speaker:
|
||||||
|
- platform: usb_audio
|
||||||
|
id: usb_speaker
|
||||||
|
usb_audio_id: usb_audio_id
|
||||||
|
sample_rate: 48000
|
||||||
|
bits_per_sample: 24bit
|
||||||
|
num_channels: 2
|
||||||
|
buffer_size: 28800
|
||||||
|
write_timeout: 50ms
|
||||||
|
- id: !extend mixing_speaker
|
||||||
|
output_speaker: usb_speaker
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
substitutions:
|
||||||
|
run: "1"
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: bk72xx-crash-probe
|
||||||
|
includes: [../scripts/libretiny_crash/bkprobe.hpp]
|
||||||
|
# step 0 waits 30 s so a capture can be opened (and a reset pulsed) after the flash; later steps 5 s
|
||||||
|
on_boot:
|
||||||
|
priority: -100
|
||||||
|
then:
|
||||||
|
- lambda: id(step) = bkprobe_boot(${run});
|
||||||
|
- delay: !lambda 'return id(step) == 0 ? 30000 : 5000;'
|
||||||
|
- lambda: bkprobe_act(${run}, id(step));
|
||||||
|
|
||||||
|
bk72xx:
|
||||||
|
board: generic-bk7238-tuya
|
||||||
|
|
||||||
|
logger:
|
||||||
|
hardware_uart: UART1
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
preferences:
|
||||||
|
flash_write_interval: 1s
|
||||||
|
|
||||||
|
globals:
|
||||||
|
- id: step
|
||||||
|
type: int
|
||||||
|
initial_value: "0"
|
||||||
|
|
||||||
|
interval:
|
||||||
|
- interval: 10s
|
||||||
|
then:
|
||||||
|
- lambda: bkprobe_alive(id(step));
|
||||||
|
|
||||||
|
# Wi-Fi matters: a crashed BK72xx with Wi-Fi up never reboots on its own without the crash handler fix
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
|
||||||
|
api:
|
||||||
|
reboot_timeout: 0s
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
substitutions:
|
||||||
|
run: "1"
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: lt21007e
|
||||||
|
includes: [../scripts/libretiny_prefs/ltprefs.hpp]
|
||||||
|
platformio_options:
|
||||||
|
build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]
|
||||||
|
on_boot:
|
||||||
|
priority: -100
|
||||||
|
then:
|
||||||
|
- lambda: id(act) = ltprefs_boot(id(step), ${run});
|
||||||
|
# the first boot waits so the host can open the port and pulse a reset before the sequence starts
|
||||||
|
- delay: !lambda 'return (id(step) == 0 || id(step) == 8) ? 30000 : 3000;'
|
||||||
|
- lambda: ltprefs_exit(id(step), ${run}, id(act));
|
||||||
|
|
||||||
|
bk72xx:
|
||||||
|
board: generic-bk7238-tuya
|
||||||
|
|
||||||
|
logger:
|
||||||
|
hardware_uart: UART1
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
preferences:
|
||||||
|
flash_write_interval: 1s
|
||||||
|
|
||||||
|
globals:
|
||||||
|
- id: step
|
||||||
|
type: int
|
||||||
|
initial_value: "0"
|
||||||
|
- id: act
|
||||||
|
type: int
|
||||||
|
initial_value: "0"
|
||||||
|
|
||||||
|
interval:
|
||||||
|
- interval: 10s
|
||||||
|
then:
|
||||||
|
- lambda: ltprefs_alive(id(step), id(act));
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# CAST PRO-1 control for the sendspin sync comparison: Apollo's current package, DEBUG logging as on the S3.
|
||||||
|
esphome:
|
||||||
|
name: "cast-pro-1-dut"
|
||||||
|
friendly_name: CAST PRO-1 test
|
||||||
|
project:
|
||||||
|
name: "ApolloAutomation.CAST_PRO-1"
|
||||||
|
version: ${version}
|
||||||
|
|
||||||
|
packages:
|
||||||
|
core: !include Core.yaml
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
enable_on_boot: false
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_key
|
||||||
|
|
||||||
|
sendspin:
|
||||||
|
manufacturer: Apollo
|
||||||
|
model: CAST PRO-1 control
|
||||||
|
unpaired_access: true
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Deep sleep wake timing for the outgoing API connection: boot -> dial -> client hello -> first HA state.
|
||||||
|
esphome:
|
||||||
|
name: deep-sleep-dut
|
||||||
|
on_boot:
|
||||||
|
priority: -100
|
||||||
|
then:
|
||||||
|
- lambda: ESP_LOGI("timing", "setup done at %u ms", (unsigned) millis());
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
fast_connect: true
|
||||||
|
on_connect:
|
||||||
|
- lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis());
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_key
|
||||||
|
outgoing_connection:
|
||||||
|
on_client_connected:
|
||||||
|
- lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str());
|
||||||
|
on_client_disconnected:
|
||||||
|
- lambda: ESP_LOGI("timing", "client disconnected at %u ms", (unsigned) millis());
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
|
||||||
|
text_sensor:
|
||||||
|
- platform: homeassistant
|
||||||
|
id: ha_state
|
||||||
|
entity_id: light.ENTITY
|
||||||
|
on_value:
|
||||||
|
- lambda: ESP_LOGI("timing", "first HA state '%s' at %u ms", x.c_str(), (unsigned) millis());
|
||||||
|
|
||||||
|
deep_sleep:
|
||||||
|
id: ds
|
||||||
|
run_duration: 15s
|
||||||
|
sleep_duration: 15s
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# DFRobot SEN0395 on an ESP32 DevKit V1: V to VIN (5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17 (TX2),
|
||||||
|
# IO2 to GPIO18, I1 unconnected. Settings buttons, the four switches and junk-line buttons whose echo and
|
||||||
|
# "is not recognized" reply come back as lines of known length (220 and 240 bytes give replies over 252 chars).
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: VERBOSE
|
||||||
|
logs:
|
||||||
|
api: DEBUG
|
||||||
|
api.connection: DEBUG
|
||||||
|
api.service: DEBUG
|
||||||
|
wifi: DEBUG
|
||||||
|
mdns: DEBUG
|
||||||
|
scheduler: DEBUG
|
||||||
|
component: DEBUG
|
||||||
|
esp32: DEBUG
|
||||||
|
uart: DEBUG
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_key
|
||||||
|
reboot_timeout: 0s
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
|
||||||
|
uart:
|
||||||
|
id: mmwave_uart
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
baud_rate: 115200
|
||||||
|
|
||||||
|
dfrobot_sen0395:
|
||||||
|
id: mmwave
|
||||||
|
uart_id: mmwave_uart
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
- platform: dfrobot_sen0395
|
||||||
|
dfrobot_sen0395_id: mmwave
|
||||||
|
name: Detected via UART
|
||||||
|
id: uart_detected
|
||||||
|
- platform: gpio
|
||||||
|
name: Detected via GPIO
|
||||||
|
device_class: motion
|
||||||
|
pin:
|
||||||
|
number: GPIO18
|
||||||
|
mode: INPUT_PULLDOWN
|
||||||
|
|
||||||
|
switch:
|
||||||
|
- platform: dfrobot_sen0395
|
||||||
|
dfrobot_sen0395_id: mmwave
|
||||||
|
type: sensor_active
|
||||||
|
name: Sensor active
|
||||||
|
- platform: dfrobot_sen0395
|
||||||
|
dfrobot_sen0395_id: mmwave
|
||||||
|
type: presence_via_uart
|
||||||
|
name: Presence via UART
|
||||||
|
- platform: dfrobot_sen0395
|
||||||
|
dfrobot_sen0395_id: mmwave
|
||||||
|
type: turn_on_led
|
||||||
|
name: LED
|
||||||
|
- platform: dfrobot_sen0395
|
||||||
|
dfrobot_sen0395_id: mmwave
|
||||||
|
type: start_after_boot
|
||||||
|
name: Start after boot
|
||||||
|
|
||||||
|
button:
|
||||||
|
- platform: template
|
||||||
|
name: Range 0 to 3m
|
||||||
|
on_press:
|
||||||
|
- dfrobot_sen0395.settings:
|
||||||
|
id: mmwave
|
||||||
|
detection_segments:
|
||||||
|
- [0cm, 3m]
|
||||||
|
- platform: template
|
||||||
|
name: Range 0 to 9m
|
||||||
|
on_press:
|
||||||
|
- dfrobot_sen0395.settings:
|
||||||
|
id: mmwave
|
||||||
|
detection_segments:
|
||||||
|
- [0cm, 9m]
|
||||||
|
- platform: template
|
||||||
|
name: Latency 1s 2s
|
||||||
|
on_press:
|
||||||
|
- dfrobot_sen0395.settings:
|
||||||
|
id: mmwave
|
||||||
|
output_latency:
|
||||||
|
delay_after_detect: 1s
|
||||||
|
delay_after_disappear: 2s
|
||||||
|
- platform: template
|
||||||
|
name: Latency 0 0
|
||||||
|
on_press:
|
||||||
|
- dfrobot_sen0395.settings:
|
||||||
|
id: mmwave
|
||||||
|
output_latency:
|
||||||
|
delay_after_detect: 0s
|
||||||
|
delay_after_disappear: 0s
|
||||||
|
- platform: template
|
||||||
|
name: Sensitivity 7
|
||||||
|
on_press:
|
||||||
|
- dfrobot_sen0395.settings:
|
||||||
|
id: mmwave
|
||||||
|
sensitivity: 7
|
||||||
|
- platform: template
|
||||||
|
name: Factory reset
|
||||||
|
on_press:
|
||||||
|
- dfrobot_sen0395.settings:
|
||||||
|
id: mmwave
|
||||||
|
factory_reset: true
|
||||||
|
- platform: template
|
||||||
|
name: Reset
|
||||||
|
on_press:
|
||||||
|
- dfrobot_sen0395.reset:
|
||||||
|
id: mmwave
|
||||||
|
- platform: template
|
||||||
|
name: Junk 100
|
||||||
|
on_press:
|
||||||
|
- uart.write:
|
||||||
|
id: mmwave_uart
|
||||||
|
data: !lambda |-
|
||||||
|
std::vector<uint8_t> v(100, 'x');
|
||||||
|
v.push_back('\r');
|
||||||
|
v.push_back('\n');
|
||||||
|
return v;
|
||||||
|
- platform: template
|
||||||
|
name: Junk 160
|
||||||
|
on_press:
|
||||||
|
- uart.write:
|
||||||
|
id: mmwave_uart
|
||||||
|
data: !lambda |-
|
||||||
|
std::vector<uint8_t> v(160, 'x');
|
||||||
|
v.push_back('\r');
|
||||||
|
v.push_back('\n');
|
||||||
|
return v;
|
||||||
|
- platform: template
|
||||||
|
name: Junk 200
|
||||||
|
on_press:
|
||||||
|
- uart.write:
|
||||||
|
id: mmwave_uart
|
||||||
|
data: !lambda |-
|
||||||
|
std::vector<uint8_t> v(200, 'x');
|
||||||
|
v.push_back('\r');
|
||||||
|
v.push_back('\n');
|
||||||
|
return v;
|
||||||
|
- platform: template
|
||||||
|
name: Junk 220
|
||||||
|
on_press:
|
||||||
|
- uart.write:
|
||||||
|
id: mmwave_uart
|
||||||
|
data: !lambda |-
|
||||||
|
std::vector<uint8_t> v(220, 'x');
|
||||||
|
v.push_back('\r');
|
||||||
|
v.push_back('\n');
|
||||||
|
return v;
|
||||||
|
- platform: template
|
||||||
|
name: Junk 240
|
||||||
|
on_press:
|
||||||
|
- uart.write:
|
||||||
|
id: mmwave_uart
|
||||||
|
data: !lambda |-
|
||||||
|
std::vector<uint8_t> v(240, 'x');
|
||||||
|
v.push_back('\r');
|
||||||
|
v.push_back('\n');
|
||||||
|
return v;
|
||||||
|
- platform: template
|
||||||
|
name: Junk 256
|
||||||
|
on_press:
|
||||||
|
- uart.write:
|
||||||
|
id: mmwave_uart
|
||||||
|
data: !lambda |-
|
||||||
|
std::vector<uint8_t> v(256, 'x');
|
||||||
|
v.push_back('\r');
|
||||||
|
v.push_back('\n');
|
||||||
|
return v;
|
||||||
|
|
||||||
|
interval:
|
||||||
|
- interval: 30s
|
||||||
|
then:
|
||||||
|
- lambda: 'ESP_LOGI("sen", "alive uart_detected=%d heap=%u", id(uart_detected).state, (unsigned) esp_get_free_heap_size());'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
esphome:
|
||||||
|
name: i19430-dut3
|
||||||
|
on_boot:
|
||||||
|
priority: 800
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
auto marker = global_preferences->make_preference<uint32_t>(0x19430001, true);
|
||||||
|
uint32_t v = 0;
|
||||||
|
if (!marker.load(&v)) {
|
||||||
|
ESP_LOGW("test", "first boot: wiping preferences");
|
||||||
|
global_preferences->reset();
|
||||||
|
static_cast<esphome::libretiny::LibreTinyPreferences *>(global_preferences)->open();
|
||||||
|
v = 1;
|
||||||
|
marker.save(&v);
|
||||||
|
global_preferences->sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
bk72xx:
|
||||||
|
board: generic-bk7238
|
||||||
|
|
||||||
|
logger:
|
||||||
|
hardware_uart: UART1
|
||||||
|
level: VERBOSE
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ap:
|
||||||
|
ssid: i19430-ap
|
||||||
|
|
||||||
|
captive_portal:
|
||||||
|
|
||||||
|
interval:
|
||||||
|
- interval: 5s
|
||||||
|
then:
|
||||||
|
- lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000));
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Entity icon and device class readback (esphome#21111): the per-run file supplies name and broker_ip through substitutions and includes this file as a package. Every readback path is covered: API listing, MQTT discovery, web_server JSON and dump_config. See reference/recipes/web_server.md.
|
||||||
|
esp8266:
|
||||||
|
board: d1_mini
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
power_save_mode: none
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_key
|
||||||
|
reboot_timeout: 0s
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
|
||||||
|
web_server:
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
broker: ${broker_ip}
|
||||||
|
reboot_timeout: 0s
|
||||||
|
|
||||||
|
interval:
|
||||||
|
- interval: 10s
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
ESP_LOGI("probe", "free heap %u, max block %u", (unsigned) ESP.getFreeHeap(), (unsigned) ESP.getMaxFreeBlockSize());
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- platform: template
|
||||||
|
name: Temp
|
||||||
|
id: temp
|
||||||
|
device_class: temperature
|
||||||
|
unit_of_measurement: "°C"
|
||||||
|
icon: mdi:thermometer
|
||||||
|
accuracy_decimals: 1
|
||||||
|
lambda: return 21.5;
|
||||||
|
update_interval: 30s
|
||||||
|
- platform: template
|
||||||
|
name: Long Icon
|
||||||
|
id: long_icon
|
||||||
|
icon: mdi:this-icon-name-is-exactly-sixty-three-characters-long-abcde
|
||||||
|
device_class: humidity
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
lambda: return 40.0;
|
||||||
|
update_interval: 30s
|
||||||
|
- platform: template
|
||||||
|
name: Plain
|
||||||
|
id: plain
|
||||||
|
lambda: return 1.0;
|
||||||
|
update_interval: 30s
|
||||||
|
- platform: template
|
||||||
|
name: Hidden
|
||||||
|
id: hidden
|
||||||
|
internal: true
|
||||||
|
icon: mdi:eye-off
|
||||||
|
device_class: power
|
||||||
|
lambda: return 2.0;
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
- platform: template
|
||||||
|
name: Motion
|
||||||
|
id: motion
|
||||||
|
device_class: motion
|
||||||
|
icon: mdi:motion-sensor
|
||||||
|
lambda: return false;
|
||||||
|
|
||||||
|
switch:
|
||||||
|
- platform: template
|
||||||
|
name: Relay
|
||||||
|
id: relay
|
||||||
|
device_class: outlet
|
||||||
|
icon: mdi:power-plug
|
||||||
|
optimistic: true
|
||||||
|
|
||||||
|
button:
|
||||||
|
- platform: template
|
||||||
|
name: Reboot
|
||||||
|
id: reboot_btn
|
||||||
|
device_class: restart
|
||||||
|
icon: mdi:restart
|
||||||
|
on_press:
|
||||||
|
- logger.log: "Reboot pressed"
|
||||||
|
|
||||||
|
text_sensor:
|
||||||
|
- platform: template
|
||||||
|
name: Version
|
||||||
|
id: version
|
||||||
|
device_class: timestamp
|
||||||
|
icon: mdi:tag
|
||||||
|
lambda: return {"2026-10-21T00:00:00+00:00"};
|
||||||
|
update_interval: 30s
|
||||||
|
|
||||||
|
number:
|
||||||
|
- platform: template
|
||||||
|
name: Level
|
||||||
|
id: level
|
||||||
|
device_class: battery
|
||||||
|
icon: mdi:knob
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
min_value: 0
|
||||||
|
max_value: 100
|
||||||
|
step: 1
|
||||||
|
optimistic: true
|
||||||
|
initial_value: 50
|
||||||
|
|
||||||
|
select:
|
||||||
|
- platform: template
|
||||||
|
name: Mode
|
||||||
|
id: mode
|
||||||
|
icon: mdi:format-list-bulleted
|
||||||
|
options: [a, b, c]
|
||||||
|
optimistic: true
|
||||||
|
initial_option: a
|
||||||
|
|
||||||
|
event:
|
||||||
|
- platform: template
|
||||||
|
name: Doorbell
|
||||||
|
id: doorbell
|
||||||
|
device_class: doorbell
|
||||||
|
icon: mdi:doorbell
|
||||||
|
event_types: [ring, knock]
|
||||||
|
|
||||||
|
cover:
|
||||||
|
- platform: template
|
||||||
|
name: Garage
|
||||||
|
id: garage
|
||||||
|
device_class: garage
|
||||||
|
icon: mdi:garage
|
||||||
|
optimistic: true
|
||||||
|
|
||||||
|
text:
|
||||||
|
- platform: template
|
||||||
|
name: Secret Note
|
||||||
|
id: note
|
||||||
|
mode: password
|
||||||
|
icon: mdi:form-textbox-password
|
||||||
|
optimistic: true
|
||||||
|
initial_value: "x"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Restoring globals with every storage choice; each boot bumps the counters, logs them with the reset reason, saves
|
||||||
|
# and syncs; odd boots soft restart, even boots deep sleep (ESP32) or idle (D1 mini). The host's reset pulse and a
|
||||||
|
# power cut give the other reset kinds.
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
preferences:
|
||||||
|
flash_write_interval: 1s
|
||||||
|
|
||||||
|
globals:
|
||||||
|
- id: cnt_rtc
|
||||||
|
type: int
|
||||||
|
restore_value: true
|
||||||
|
storage: rtc
|
||||||
|
initial_value: "0"
|
||||||
|
- id: cnt_flash
|
||||||
|
type: int
|
||||||
|
restore_value: true
|
||||||
|
storage: flash
|
||||||
|
initial_value: "0"
|
||||||
|
- id: cnt_default
|
||||||
|
type: int
|
||||||
|
restore_value: true
|
||||||
|
initial_value: "0"
|
||||||
|
- id: str_rtc
|
||||||
|
type: std::string
|
||||||
|
restore_value: true
|
||||||
|
storage: rtc
|
||||||
|
max_restore_data_length: 24
|
||||||
|
initial_value: '"fresh"'
|
||||||
|
- id: arr_rtc
|
||||||
|
type: uint8_t[2]
|
||||||
|
restore_value: true
|
||||||
|
storage: rtc
|
||||||
|
initial_value: "{0, 0}"
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
on_boot:
|
||||||
|
priority: -100
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
id(cnt_rtc) += 1;
|
||||||
|
id(cnt_flash) += 1;
|
||||||
|
id(cnt_default) += 1;
|
||||||
|
id(arr_rtc)[0] += 1;
|
||||||
|
id(arr_rtc)[1] += 2;
|
||||||
|
char buf[24];
|
||||||
|
snprintf(buf, sizeof(buf), "boot%d", id(cnt_rtc));
|
||||||
|
ESP_LOGI("gl", "restored rtc=%d flash=%d default=%d str='%s' arr=%u/%u (values before this boot's bump: one less)",
|
||||||
|
id(cnt_rtc) - 1, id(cnt_flash) - 1, id(cnt_default) - 1, id(str_rtc).c_str(), id(arr_rtc)[0] - 1, id(arr_rtc)[1] - 2);
|
||||||
|
id(str_rtc) = buf;
|
||||||
|
- delay: 2s
|
||||||
|
- lambda: |-
|
||||||
|
global_preferences->sync();
|
||||||
|
ESP_LOGI("gl", "saved rtc=%d flash=%d default=%d str='%s' arr=%u/%u", id(cnt_rtc), id(cnt_flash), id(cnt_default),
|
||||||
|
id(str_rtc).c_str(), id(arr_rtc)[0], id(arr_rtc)[1]);
|
||||||
|
- if:
|
||||||
|
condition:
|
||||||
|
lambda: 'return id(cnt_rtc) % 2 == 1;'
|
||||||
|
then:
|
||||||
|
- delay: 3s
|
||||||
|
- lambda: 'ESP_LOGI("gl", "odd boot, soft restart now"); App.safe_reboot();'
|
||||||
|
|
||||||
|
button:
|
||||||
|
- platform: restart
|
||||||
|
name: Restart
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# shared part: two Home Assistant lights mirrored on the device plus buttons that drive them
|
||||||
|
esphome:
|
||||||
|
# A local change made before the light core's first write (b19a776 sends it instead of merging it).
|
||||||
|
on_boot:
|
||||||
|
priority: -100
|
||||||
|
then:
|
||||||
|
- lambda: 'ESP_LOGI("halight", "on_boot turning B on at 35 percent before the first write");'
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_b
|
||||||
|
brightness: 35%
|
||||||
|
logger:
|
||||||
|
level: VERBOSE
|
||||||
|
logs:
|
||||||
|
api: DEBUG
|
||||||
|
api.connection: DEBUG
|
||||||
|
api.service: DEBUG
|
||||||
|
wifi: DEBUG
|
||||||
|
scheduler: DEBUG
|
||||||
|
component: DEBUG
|
||||||
|
sensor: DEBUG
|
||||||
|
mdns: DEBUG
|
||||||
|
ota: DEBUG
|
||||||
|
socket: DEBUG
|
||||||
|
esp-idf: DEBUG
|
||||||
|
preferences: DEBUG
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_key
|
||||||
|
reboot_timeout: 0s
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
|
||||||
|
light:
|
||||||
|
- platform: homeassistant
|
||||||
|
id: bulb_a
|
||||||
|
name: Mirror A
|
||||||
|
entity_id: light.ENTITY_A
|
||||||
|
report_timeout: 5s
|
||||||
|
on_turn_on:
|
||||||
|
- logger.log: "bulb_a on_turn_on"
|
||||||
|
on_turn_off:
|
||||||
|
- logger.log: "bulb_a on_turn_off"
|
||||||
|
- platform: homeassistant
|
||||||
|
id: bulb_b
|
||||||
|
name: Mirror B
|
||||||
|
entity_id: light.ENTITY_B
|
||||||
|
report_timeout: 5s
|
||||||
|
|
||||||
|
button:
|
||||||
|
- platform: template
|
||||||
|
name: A warm 40
|
||||||
|
id: a_warm_40
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_a
|
||||||
|
brightness: 40%
|
||||||
|
color_temperature: 370 mireds
|
||||||
|
- platform: template
|
||||||
|
name: A cool 100
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_a
|
||||||
|
brightness: 100%
|
||||||
|
color_temperature: 153 mireds
|
||||||
|
- platform: template
|
||||||
|
name: A red
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_a
|
||||||
|
brightness: 80%
|
||||||
|
red: 100%
|
||||||
|
green: 0%
|
||||||
|
blue: 0%
|
||||||
|
- platform: template
|
||||||
|
name: A green dim
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_a
|
||||||
|
brightness: 20%
|
||||||
|
red: 0%
|
||||||
|
green: 100%
|
||||||
|
blue: 0%
|
||||||
|
- platform: template
|
||||||
|
name: A brightness only 60
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_a
|
||||||
|
brightness: 60%
|
||||||
|
- platform: template
|
||||||
|
name: A transition 100
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_a
|
||||||
|
brightness: 100%
|
||||||
|
transition_length: 3s
|
||||||
|
- platform: template
|
||||||
|
name: A flash
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_a
|
||||||
|
flash_length: 1s
|
||||||
|
- platform: template
|
||||||
|
name: A off
|
||||||
|
on_press:
|
||||||
|
- light.turn_off: bulb_a
|
||||||
|
- platform: template
|
||||||
|
name: A toggle
|
||||||
|
on_press:
|
||||||
|
- light.toggle: bulb_a
|
||||||
|
- platform: template
|
||||||
|
name: B warm 50
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_b
|
||||||
|
brightness: 50%
|
||||||
|
color_temperature: 300 mireds
|
||||||
|
- platform: template
|
||||||
|
name: B off
|
||||||
|
on_press:
|
||||||
|
- light.turn_off: bulb_b
|
||||||
|
- platform: template
|
||||||
|
name: A 50 fade 3s
|
||||||
|
on_press:
|
||||||
|
- light.turn_on:
|
||||||
|
id: bulb_a
|
||||||
|
brightness: 50%
|
||||||
|
transition_length: 3s
|
||||||
|
- platform: template
|
||||||
|
name: A many calls
|
||||||
|
on_press:
|
||||||
|
- lambda: |-
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
id(bulb_a).make_call().set_brightness(i == 15 ? 1.0f : 0.6f).set_transition_length(1000).perform();
|
||||||
|
}
|
||||||
|
- platform: template
|
||||||
|
name: Log state
|
||||||
|
on_press:
|
||||||
|
- lambda: |-
|
||||||
|
auto v = id(bulb_a).current_values;
|
||||||
|
ESP_LOGI("halight", "A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f",
|
||||||
|
v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(),
|
||||||
|
v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(),
|
||||||
|
id(bulb_b).current_values.get_brightness());
|
||||||
|
|
||||||
|
interval:
|
||||||
|
- interval: 30s
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
auto v = id(bulb_a).current_values;
|
||||||
|
ESP_LOGI("halight", "alive A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f",
|
||||||
|
v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(),
|
||||||
|
v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(),
|
||||||
|
id(bulb_b).current_values.get_brightness());
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
substitutions:
|
||||||
|
name: ld2412sel
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
variant: esp32s3
|
||||||
|
board: esp32-s3-devkitc-1
|
||||||
|
flash_size: 8MB
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: VERBOSE
|
||||||
|
|
||||||
|
api:
|
||||||
|
reboot_timeout: 0s
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
power_save_mode: none
|
||||||
|
|
||||||
|
uart:
|
||||||
|
- id: uart_ld2412
|
||||||
|
tx_pin: GPIO43
|
||||||
|
rx_pin: GPIO44
|
||||||
|
baud_rate: 115200
|
||||||
|
debug:
|
||||||
|
direction: BOTH
|
||||||
|
dummy_receiver: false
|
||||||
|
after:
|
||||||
|
timeout: 10ms
|
||||||
|
bytes: 120
|
||||||
|
|
||||||
|
ld2412:
|
||||||
|
id: ld2412_radar
|
||||||
|
uart_id: uart_ld2412
|
||||||
|
|
||||||
|
select:
|
||||||
|
- platform: ld2412
|
||||||
|
ld2412_id: ld2412_radar
|
||||||
|
out_pin_level:
|
||||||
|
name: "LD2412 Hardware output pin level"
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
- platform: gpio
|
||||||
|
name: "LD2412 Out Pin"
|
||||||
|
pin: GPIO38
|
||||||
|
- platform: ld2412
|
||||||
|
ld2412_id: ld2412_radar
|
||||||
|
has_target:
|
||||||
|
name: "LD2412 Presence"
|
||||||
Loaded 100 of 2783 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user