mirror of
https://github.com/esphome/esphome.git
synced 2026-10-26 16:02:13 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb614d2e2 | ||
|
|
35059556f0 |
No files matched your search
@@ -1,156 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,71 +0,0 @@
|
||||
// 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()
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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++;
|
||||
@@ -67,17 +67,9 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul
|
||||
automations in the config itself.
|
||||
7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines
|
||||
that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`).
|
||||
8. **Report** on the PR. Every result comment starts with this two-line GitHub alert, then a blank line:
|
||||
|
||||
```
|
||||
> [!NOTE]
|
||||
> Automated testing results
|
||||
```
|
||||
|
||||
Then one short sentence saying what was tested (builds and SHAs, board, what was driven), a results table
|
||||
(build SHA, board and framework, what was driven, counts, result, plus the `dev` control row), short bullets
|
||||
for the findings, and the setup and exact flow in a collapsed `<details>` block. Answer any question asked on
|
||||
the PR directly in the comment. No credentials, SSIDs, IP addresses, port names or other bench details.
|
||||
8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result,
|
||||
plus the `dev` control row and the exact flow in a `<details>` block. No credentials, no port names,
|
||||
no IP addresses.
|
||||
|
||||
## Flashing by chip family
|
||||
|
||||
@@ -119,11 +111,11 @@ ESP-IDF OTA image: `.esphome/build/<name>/build/firmware.ota.bin`. LibreTiny OTA
|
||||
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 with prefixed misbehaviour cases, PNG, JPEG, BMP and QOI generators, API drivers, first pass timer), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
|
||||
(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, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver, request test server, action case driver, concurrency, reboot, update check and OTA drivers, `ota_auth/` OTA request cases with basic auth), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `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, MAC suffix runner with per-variant configs for two boards, `shutdown/` shutdown message runner and matcher), `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), `uart_sim/` (API driver and flash, capture and drive runner for the simulated UART sensors), `remote_base/` (loopback runner and API log decoder per sent frame), `xiaomi_ble/` (frame and header generator with the expected counts, injecting, advertising and receiving configs, counter and runner), `ble_terminate/` (proxy and peer configs, connect and disconnect driver, peer disconnect matcher, runner), `i2c_slave/` (D1 mini slave and BK7238 master configs), `wpa3_ap/` (PMF AP with join and leave logging, BK7238 and CB3S DUTs, runner and summary), `wisp_ap/` (WISP upstream with AP buttons, WISP and ALWAYS_ON DUTs, client, runner and per phase summary), `wifi_restart/` (WPA3 mixed WISP upstream, static IP DUTs with syslog, runner and summary), `ota_timing/` (OTA upload timer with deflate, raw and TCP modes, API build check, A/B runner and median summary, `inflate_bench/` on-device inflate benchmark with stream generator, configs and syslog runner).
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `homeassistant_light/` (direct bulb driver), `usb_audio/` (URL player).
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
@@ -138,30 +130,11 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
|
||||
`bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`,
|
||||
`homeassistant-light-dut.yaml`, `deep-sleep-outgoing-dut.yaml`, `outgoing-wifi-drop-dut.yaml`,
|
||||
`globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`,
|
||||
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`,
|
||||
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
|
||||
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`,
|
||||
`online-image-s3-eth-dut.yaml`,
|
||||
and the directories `store_yaml/` (package with secrets one directory up, per-board files),
|
||||
`http_request_update/` (manifest URL setter shapes, per-board files), `modem/` (modem only and modem plus
|
||||
Wi-Fi boot checks), `ble_midi/` (fake BLE MIDI instrument and the client), `http_request_action/` (request
|
||||
action package with per-board files and an OTA flash variant) `uart_sim/` (simulator header, common package, per-build parts and run files) and `remote_base/` (common
|
||||
package, transmit script, per-board templates and the ESP8266 software sender).
|
||||
|
||||
## When the PR keeps moving
|
||||
|
||||
Record every head in the stack (`gh pr view N --json headRefOid`), build, and recheck before flashing; if anything
|
||||
moved, wait five minutes and check again so the test runs on a settled head. When a head moved only by merging dev,
|
||||
compare the PR's own diff (`git diff $(git merge-base OLD origin/dev) OLD` against the same for NEW); if it is
|
||||
identical and the dev commits do not touch the code under test, earlier results still describe it, but rebuild so the
|
||||
comment quotes the exact commits. Fetch `origin dev` before computing a merge base.
|
||||
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`.
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
- A capture stopped early or a line is missing: check the timestamps before trusting it.
|
||||
- A script that pipes into `tee` never ends although its work is done: a background command written as
|
||||
`(cd dir && server & echo $!)` backgrounds the whole `cd && server` list, so a shell stays behind holding the
|
||||
pipe open and `$!` is that shell, not the server. Write `(cd dir && exec server > out 2>&1 < /dev/null) &`.
|
||||
- The client never joins the AP: another board may still run an AP with the same SSID, or the device
|
||||
booted with saved credentials because the name was reused. One AP device at a time on the bench.
|
||||
- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse
|
||||
@@ -171,10 +144,6 @@ comment quotes the exact commits. Fetch `origin dev` before computing a merge ba
|
||||
preference and repeat it in a periodic alive line, as `scripts/libretiny_prefs/ltprefs.hpp` does.
|
||||
- Multicast from the host never arrives on some access points: test it device to device. Broadcast
|
||||
is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate.
|
||||
- The merge base comes from a freshly fetched `origin/dev`; a stale local `dev` puts dependency bumps into the
|
||||
PR diff and builds the control from the wrong commit.
|
||||
- A `.local` name can stop resolving in the middle of a run; keep the address from the boot log and drive by it.
|
||||
- `wc -l` pads its count on macOS, so `tail -n +$n0` needs `$((n0+0))` or an arithmetic expansion.
|
||||
- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for
|
||||
soak tests.
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@ 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`
|
||||
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;
|
||||
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.
|
||||
@@ -50,9 +47,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
USB replug fixes it. Fallback: `esphome logs --device <name>.local` (API log stream, which drops lines
|
||||
while Wi-Fi is off).
|
||||
- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry).
|
||||
- A board left in the ROM bootloader after an upload (BOOT held, or the USB JTAG reset landing in download mode
|
||||
again) starts its app without a replug through `esptool --port <port> --after watchdog-reset chip-id`; flash a
|
||||
minimal Wi-Fi plus API image first when recovering from a crash-looping one.
|
||||
|
||||
## RP2040 / RP2350 (Pico W, Pico 2 W)
|
||||
|
||||
@@ -86,8 +80,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`).
|
||||
Fault dumps print PC, LR and R0 to R3.
|
||||
- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`.
|
||||
- The `rtw_is_adapter_up` bus fault after `Disabling AP` happens on LibreTiny 1.13.0 and 1.14.0 alike
|
||||
(2 of 4 and 3 of 3 runs in #21531); run at least four rounds per build before blaming a PR for it.
|
||||
|
||||
## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN)
|
||||
|
||||
@@ -118,18 +110,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
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.
|
||||
- Pick the board that matches the module's flash layout. A NiceMCU XH-WB3S (BK7238) needs `board: xh-wb3s` (plain
|
||||
Beken layout, RF calibration at 0x1E0000). Built as `generic-bk7238-tuya` (calibration at 0x1E3000, download
|
||||
partition over 0x1E0000) the firmware boots and may even join a known network, but scans come back empty and its
|
||||
AP is invisible to clients, with no warning in the log. If scans are empty, check the layout first.
|
||||
- The BK7238 bootloader prints `App verify failed! Need to recovery factory firmware.` on every boot with any
|
||||
build; it is harmless. Even with the right layout an ESP32 client did not see the BK7238 fallback AP on
|
||||
LibreTiny 1.13.0 or 1.14.0, so the wifisave hammer cannot run on it.
|
||||
- Moving a BK7231N device (CB3S) that sits on its fallback AP to another network: join the host's Wi-Fi to
|
||||
`<name>-fallback`, OTA to 192.168.4.1, then rejoin the host's own network. Moving it with `/wifisave` instead can
|
||||
leave it in the captive portal's BLIND_RETRY state (no scan, `Beacon Timeout` every 46 s) until it reboots, and a
|
||||
log-only serial adapter cannot reset it. Rejoining may change the host's address; update a syslog target that
|
||||
points at the old one.
|
||||
- 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.
|
||||
|
||||
@@ -177,12 +157,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
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.
|
||||
- Thread: nRF52 `openthread:` accepts only `tlv:`; the individual keys (network_key, channel, ...) are rejected at
|
||||
validation. To list nearby networks with channel and PAN ID, call
|
||||
`otThreadDiscover(InstanceLock::acquire().get_instance(), 0, 0xFFFF, false, false, cb, nullptr)` from an
|
||||
`interval:` lambda in an included header; results arrive on the OpenThread thread and log fine.
|
||||
- A wrong network key on a real network's channel and PAN ID makes the device form its own partition as leader on
|
||||
that PAN within about 30 s; take it off Thread at once. Never try guessed datasets against a network in use.
|
||||
|
||||
## Waveshare ESP32-S3-ETH with the OV5640 camera module
|
||||
|
||||
|
||||
@@ -4,40 +4,26 @@ Each flow names the pull requests it was used on, so their result tables show wh
|
||||
|
||||
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, store_yaml recovery over Noise or plaintext
|
||||
- [`ble_midi.md`](ble_midi.md): BLE MIDI client against a fake ESP32 instrument, every packet shape and the send actions
|
||||
- [`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
|
||||
- [`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
|
||||
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check, non-blocking request actions with loop gap, queue, cancel and reboot checks, OTA request cases with basic auth and MD5 sources
|
||||
- [`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
|
||||
- [`ld2450.md`](ld2450.md): polygon zones
|
||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
|
||||
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
|
||||
- [`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, MAC suffix topics and will messages on two boards, shutdown message delivery on restart, disable and deep sleep
|
||||
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT
|
||||
- [`nrf52.md`](nrf52.md): dongle board support
|
||||
- [`online_image.md`](online_image.md): stalled and malformed downloads, PNG, JPEG, BMP and QOI decode time per loop pass with large images and small buffers
|
||||
- [`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, upload timing A/B per build and board with deflate and raw, on-device inflate benchmark
|
||||
- [`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
|
||||
- [`udp.md`](udp.md): datagrams, multicast, sockets
|
||||
- [`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, reboots against a WPA2/WPA3 PMF access point, WISP and ALWAYS_ON AP switching, AP restarts with WPA3 mixed and LIGHT power save
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP)
|
||||
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
|
||||
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||
- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind
|
||||
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant, held reports versus later calls
|
||||
- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant
|
||||
- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin
|
||||
- [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply
|
||||
- [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player
|
||||
- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback
|
||||
- [`uart_sim.md`](uart_sim.md): UART sensors simulated on a classic ESP32 with loopback and cross-wired UARTs
|
||||
- [`gpio_expander.md`](gpio_expander.md): interrupt driven expander inputs, loop idle checks, testing a device out of reach
|
||||
- [`led_strip.md`](led_strip.md): forcing a failed strip allocation, RMT symbols on the S3
|
||||
- [`remote_base.md`](remote_base.md): remote_receiver protocol decoding through a one-pin loopback on ESP32 and ESP8266, no IR hardware
|
||||
- [`xiaomi_ble.md`](xiaomi_ble.md): Xiaomi, ATC and PVVX parsing by injecting scan results into the ESP32 tracker or advertising them from a second ESP32, real AES-CCM frames, duplicate filter model
|
||||
- [`ble_terminate.md`](ble_terminate.md): proxy disconnect against a peer with peripheral latency
|
||||
- [`i2c_slave.md`](i2c_slave.md): fake I2C slave on a D1 mini, ESP32 hardware master as the control
|
||||
@@ -28,31 +28,3 @@ connected first, and the loop-time maxima. For the loop cost of polling, repeat
|
||||
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.
|
||||
|
||||
## store_yaml recovery over the native API (#16445)
|
||||
|
||||
`scripts/api/syrecover.py HOST KEY OUTDIR [ORIGDIR]` (KEY `-` for plaintext) builds the client library's
|
||||
`APINoiseFrameHelper` around a stub `APIConnection` subclass (the compiled helper type checks the connection, and
|
||||
`ConnectionParams` needs every field), sends Hello, Authentication (never answered without a password) and
|
||||
`DeviceCapabilitiesRequest`, then `GetYamlRequest` (161), collects `GetYamlResponse` (162) frames by field number
|
||||
(1 data, 2 done, 3 total_size, 4 encoding) until done, requests again, decompresses with `compression.zstd` (Python 3.14, or the `zstandard` package) and
|
||||
unpacks the envelope with the tree's `unpack_envelope` (run with `PYTHONPATH` on that tree). Configs in
|
||||
`templates/store_yaml/`: entry files in `boards/`, a package one directory above them with `!secret` and an inline
|
||||
OTA password,
|
||||
so the redaction and the `..` paths are exercised. Repetitive YAML compresses into one chunk even at 22 KB; a 6 KB
|
||||
include of random base64 lines with `include_secrets: true` gives four chunks (1390 byte packets). Rough edge at
|
||||
0bd3738430: `include_secrets: true` fails the build when a package in another directory uses `!secret` and has no
|
||||
`secrets.yaml` beside it; a copy next to the package works around it.
|
||||
Results: https://github.com/esphome/esphome/pull/16445#issuecomment-6335435397
|
||||
@@ -1,19 +0,0 @@
|
||||
# ble_midi
|
||||
|
||||
## BLE MIDI client against a fake instrument (#18508)
|
||||
|
||||
No MIDI hardware needed: `templates/ble_midi/midiperi.yaml` turns a second ESP32 (an S3 with ethernet, so the
|
||||
API stays reachable while BLE is busy) into a BLE MIDI peripheral with `esp32_ble_server`: the MIDI service
|
||||
advertised, the MIDI characteristic with read, write, write without response and notify, an `on_write` that logs
|
||||
the bytes, buttons that notify hand built packets (channel messages, two messages in one packet, running status,
|
||||
SysEx in one packet, split over two, oversize, cut short, clock, aftertouch, orphan data bytes, a 50 packet burst)
|
||||
and a restart button for the disconnect test. `midicli.yaml` is the component test config on a classic devkit with
|
||||
`wifi:`, `api:`, the peripheral's BLE address in the `peri_mac` substitution, given on the command line with
|
||||
`-s peri_mac <mac>` (the base MAC plus 2, printed by `esp32_ble` in `dump_config`), `on_sysex` and `on_message` lambdas that print the bytes, debug sensors and out of range send
|
||||
buttons. Drive both over the plaintext API with `scripts/modem/mdrive.py HOST LOGFILE press:NAME wait:S` 3 s
|
||||
apart and compare the peripheral's `TX` and `RX` lines with the client's decoded lines. Gotcha:
|
||||
`ble_server.characteristic.set_value` latches the value of its first play and re-applies it before every notify,
|
||||
so the peripheral script sets the value from a lambda (`id(midi_io).set_value(std::vector<uint8_t>(pkt));
|
||||
id(midi_io).notify();`). At VERBOSE the client logs four lines per message, so a burst shows an
|
||||
`esp32_ble took a long time` of a couple of seconds that is the serial port, not the parser.
|
||||
Results: https://github.com/esphome/esphome/pull/18508#issuecomment-6348272717
|
||||
@@ -1,17 +0,0 @@
|
||||
# BLE terminate with peripheral latency (#20232)
|
||||
|
||||
`scripts/ble_terminate`: an ESP32-S3 `bluetooth_proxy` (active) over Ethernet as the central (`btproxy.yaml`) and a
|
||||
classic devkit `esp32_ble_server` peer (`btpeer.yaml`). The peer asks for 50 ms / latency 10 / 6 s with
|
||||
`esp_ble_gap_update_conn_params` 4 s after connect; earlier requests are overwritten by the proxy's own "medium
|
||||
conn params" after service discovery. Read the proxy's `Conn params updated` line (VERBOSE, API logs) to confirm;
|
||||
the peer side never logs the event. The proxy only connects to a device its scanner has seen, so `btdrive.py`
|
||||
subscribes to raw advertisements first.
|
||||
|
||||
Measure the peer's own disconnect (`serial_capture.py --ms`) after the client's disconnect: about 0.2 to 0.8 s when
|
||||
the terminate arrived, about 6 s (the supervision timeout) when it was lost. The client side disconnect time is the
|
||||
API reply and says nothing. On dev the S3 lost 6 of 12; the PR lost 0 of 16.
|
||||
|
||||
`run.sh LABEL ROUNDS` writes `central_bda.hpp` from `PROXY_BT_MAC`, builds and flashes both boards, runs the rounds
|
||||
and `an.py`, which prints PASS only when every round completed, every peer disconnect came within 3 s and the last
|
||||
parameter update the proxy logged before each disconnect had latency=10 (a round without it did not test peripheral
|
||||
latency and counts as inconclusive).
|
||||
@@ -1,21 +0,0 @@
|
||||
# dfrobot_sen0395
|
||||
|
||||
## DFRobot SEN0395 mmWave on an ESP32 DevKit V1 (#20906, #20979)
|
||||
|
||||
Wiring: V to VIN (5 V, the module wants 3.6 to 5 V), G to GND, sensor TX to GPIO16 (RX2), sensor RX to GPIO17
|
||||
(TX2), IO2 to GPIO18 (presence output, a gpio binary sensor), I1 unconnected. `templates/dfrobot-sen0395-dut.yaml`
|
||||
has the hub, both detection binary sensors, the four switches, settings buttons and junk-line buttons.
|
||||
`scripts/dfrobot_sen0395/ctl.py HOST KEY press:NAME sw:NAME=on|off wait:S` drives it and prints binary sensor and
|
||||
switch updates; `cases.sh DEV_IP KEY CAPTURE LABEL` runs the whole flow against a running serial capture. Flash by
|
||||
USB, `serial_capture.py PORT 540 CAPTURE --reset`, then the cases; compare the `Sending command` and `Message:` lines
|
||||
and the counts of `Done`, `Error`, prompts and frames between builds. The module echoes every command, so the echoed
|
||||
lines are the bytes the component sent and can be compared between builds as a set.
|
||||
|
||||
Module facts: it answers `Done` or `Error` and then the `leapMMW:/>` prompt without a line end; a junk line gets
|
||||
`<line> is not recognized as a CLI command` (36 chars longer than the line, so 220 and 240 byte junk gives 256 and
|
||||
276 char replies); input lines of 256 bytes or more make the module assert (`..\User_code\main.c line:232`) and
|
||||
restart, after which the sensor is stopped; a factory reset restarts the module about 7 s later; `sensorStop` on a
|
||||
stopped sensor answers `sensor stopped already` then `Error`; a settings press takes 5 to 6 s (stop, change, save,
|
||||
start). Free heap on the ESP32 is Wi-Fi noise (192 to 198 kB) and says nothing about the component.
|
||||
Results: https://github.com/esphome/esphome/pull/20979#issuecomment-6279129146 and
|
||||
https://github.com/esphome/esphome/pull/20906#issuecomment-6279129604
|
||||
@@ -1,13 +0,0 @@
|
||||
# GPIO expanders
|
||||
|
||||
## Interrupt driven base class (#21469, #21468)
|
||||
|
||||
Any device with a PCF8574 input expander whose INT line goes to an ESP32 GPIO works; the inputs should change
|
||||
during the test (end switches on a gate or cover are ideal). Before flashing, check with `nm` that
|
||||
`gpio_expander::interrupt_pin_isr` lands in IRAM (0x4008xxxx on a classic ESP32). Then confirm `dump_config` lists
|
||||
the interrupt pin and the input states match reality, and that the `pcf8574` entry is absent from the runtime stats
|
||||
while idle (its loop is disabled). Drive the inputs (for a cover, `cover_command(position=1.0)` and then `0.0` over
|
||||
the API, waiting for each end state): every input edge should log, `pcf8574` should show a small count in that
|
||||
minute's stats and vanish in the next. For a device that cannot be reached physically, boot the same code on a
|
||||
bench board first and keep a merge base image built as the fallback; an ESP32 bootloader without rollback support
|
||||
leaves only `safe_mode` to recover a bad image.
|
||||
@@ -33,15 +33,3 @@ connected but not yet subscribed is dropped by the api server (`client has not s
|
||||
fallback to an off report the mirror keeps the unsent brightness; a mirror that never saw its light on since boot has
|
||||
no levels to restore. Results: https://github.com/esphome/esphome/pull/11354#issuecomment-6253630336 and
|
||||
https://github.com/esphome/esphome/pull/11354#issuecomment-6254161409
|
||||
|
||||
## Held report versus a later call (#21144)
|
||||
|
||||
A report that arrives during a transition is held and applied when the transition ends, unless a call came after
|
||||
it. `scripts/homeassistant_light/heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY]` (a key of `-` is plaintext) keeps one API connection to the mirror
|
||||
device and one to the bulb so the steps land inside a 3 s fade: press "A 50 fade 3s", 0.7 s later set the bulb to
|
||||
90% directly (the report is held), 1.4 s in press the same fade again (or "A many calls", 16 calls in one loop
|
||||
pass). Pass: the mirror and the bulb end at the second call's target. Only `same` reproduces the bug: a build
|
||||
without the fix logs `Applying the report that arrived during the transition` and the bulb stays at 90%. `many`
|
||||
ends at 100% on both builds because the last call's target differs from the held report, so it is a regression
|
||||
check only, not a reproduction. Separate button presses are too slow (about 1.5 s each) to land inside the fade.
|
||||
The mirrors are not API entities, so read their state from the "Log state" button's line. Results: https://github.com/esphome/esphome/pull/21144#issuecomment-6278257695
|
||||
@@ -1,48 +0,0 @@
|
||||
# http_request
|
||||
|
||||
## Update manifest URL changed at run time (#21373)
|
||||
|
||||
`scripts/http_request/manifest_server.py PORT [DELAY]` serves `stable.json`, `beta.json` and `slow.json` (answered
|
||||
after DELAY s) with both chip families and logs every request. `templates/http_request_update/upd-common.yaml`
|
||||
(secret `manifest_base` is the server URL) has the update entity on the stable manifest, buttons for every
|
||||
`set_source_url` shape (`std::string` local, literal, temporary, `.c_str()` of a local, the current URL back in),
|
||||
`Check Then Swap` (ESP32 task path: check the slow manifest and replace the URL in the same lambda), `Arm Swap In
|
||||
Check` plus a `logger: on_message` lambda that replaces the URL from inside the inline check (ESP8266; the log line
|
||||
from inside `on_message` is swallowed, judge by the next check's result), and a `Heap` button. `upd-base-common.yaml`
|
||||
keeps only the shapes that compile on a tree with the bare pointer setter (for the base build, copy a board file
|
||||
and point its `common` include at `../upd-base-common.yaml` with a different `name:`); `boards/upd8266.yaml` and `boards/upd32.yaml` are the
|
||||
per-board files, one directory below the package their `../` include points at. Drive with `scripts/http_request/updrive.py HOST KEY LOGFILE press:NAME wait:S`, which prints every
|
||||
update state (latest version, summary). The ESP8266 Arduino client gets `Error reading manifest: -1` on roughly a
|
||||
quarter of the fetches from this Python server on every build, so count checks against the base before blaming a
|
||||
PR. `http_request` on the ESP8266 needs `verify_ssl: false` even for plain http URLs.
|
||||
Results: https://github.com/esphome/esphome/pull/21373#issuecomment-6337544933
|
||||
|
||||
## Non-blocking request actions (#21486)
|
||||
|
||||
`scripts/http_request/hsrv.py PORT` serves `/fast`, `/slow?d=S`, `/big` and `/chunked` (`n` bytes at `rate` B/s,
|
||||
deterministic bytes `(i*7+3)&0xFF`), `/post` (echoes length and byte sum), `/redirect` and `/hang`.
|
||||
`templates/http_request_action/hr-common.yaml` (substitutions `name`, `bufsize`; per-board files `hr32.yaml` and
|
||||
`hr66.yaml`, `-s name hr32base` on the command line for the base build; `hrota32.yaml` adds the `otaflash` action
|
||||
for `otadrive.py`) has API actions `req_get`, `req_post`, `burst` (N runs of a `mode: parallel` script on one action),
|
||||
`cancel` (`mode: single` script run twice, then `script.stop`) and `reboot_during`, and logs
|
||||
`RESULT n status len sum took maxgap`, where maxgap is the longest main loop gap during the request (an
|
||||
`interval: 1ms` lambda plus the time since its last pass, so a fully blocking request counts too). Drive with
|
||||
`scripts/http_request/hrdrive.py IP http://HOST:PORT LABEL [https]` (`ONLY=case,case` and `ROUNDS=n` env; the
|
||||
`cross` case runs two actions on one component). `burstonly.py IP URL N` checks concurrency against the server's
|
||||
completion times, `reboottest.py IP URL` times `App.safe_reboot()` with requests in flight, `updcheck.py IP LABEL N`
|
||||
runs N update checks over the API (for real device configs that use `update: platform: http_request`) and
|
||||
`otadrive.py` triggers `ota.http_request.flash` (the API variable is `sum`; `md5` is a reserved variable name).
|
||||
`execute_service` must be awaited on current aioesphomeapi or nothing is sent. Keep the ESP32 `bufsize` at 32 KB or
|
||||
less: the action copies the body into a `std::string` while the buffer is still held, and 80 KB aborts with out of
|
||||
memory on dev and on the PR alike. `http_request` refuses BK72xx and RTL87xx at validation.
|
||||
|
||||
## OTA request cases with basic auth (#21556)
|
||||
|
||||
`scripts/http_request/ota_auth/`: `ho-common.yaml` (API actions for `ota.http_request.flash` with `md5_url`, `md5`
|
||||
and basic auth, `flash()` with no URL, an invalid URL, the `md5_computed()`/`md5_expected()` getters), `fwsrv.py`
|
||||
(serves a www dir, `/auth/` needs `otauser:otapass`), `hodrive.py` (11 cases, reconnects after each good flash) and
|
||||
`run.sh BUILD BOARD` (compiles from `TREE`, serial flash, serves that exact image; `TREE`, `P32` or `P66`, `SC` and `HOST_IP` in
|
||||
the environment). On the D1 mini the authenticated MD5 read fails
|
||||
with `Error reading MD5: -1` on dev too, and stored credentials carry over to the next flash on both builds.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21556#issuecomment-6381067561
|
||||
@@ -1,12 +0,0 @@
|
||||
# Fake I2C slave (#21586)
|
||||
|
||||
`scripts/i2c_slave`: a D1 mini running an Arduino `Wire` slave at 0x40 (`i2cslave.yaml` with `i2cslave.hpp`): a fake
|
||||
HDC1080 plus a 256 byte register file, and reads of 0x7E and 0x7F stretch the clock for 0.5 and 5 ms. It needs
|
||||
`libraries: [Wire]` and 160 MHz. With jumper wires only and the internal pull ups it works to about 50 kHz; it cannot
|
||||
keep up with a true 100 kHz hardware master.
|
||||
|
||||
The master (`i2cbk.yaml`, a BK7238 on P26/P24, needs the software I2C bus from that PR) writes a counter, reads it
|
||||
back with a repeated start, probes an absent address and reads both stretch registers every 2 s, logging `RW OK`
|
||||
(the read back matches, the absent address NACKs and both stretched reads succeed) or `RW BAD` with running totals.
|
||||
Control: the same blocks on a classic ESP32 devkit (SDA GPIO21, SCL GPIO22) with hardware I2C at 25 and 50 kHz got
|
||||
every repeated start read right, so failures on a bit banged master at a similar real clock point at the master.
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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.
|
||||
@@ -5,38 +5,3 @@
|
||||
To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and
|
||||
switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after.
|
||||
Writing those numbers blocks the ld2450 loop about 50 ms.
|
||||
|
||||
## Zone numbers round trip (#21146)
|
||||
|
||||
`scripts/ld2450/zonedrive.py HOST KEY(- for plaintext) set:NAME=VALUE sel:NAME=OPTION wait:S list` sets numbers and
|
||||
selects over the API and prints every LD2450 state update. After each set the hub sends the set zone command, queries
|
||||
the radar and republishes all twelve coordinates from the answer, so set each coordinate of each zone to a distinct
|
||||
value and check the readbacks; no target is needed. Readbacks can arrive late over Wi-Fi, so match them by content.
|
||||
Results: https://github.com/esphome/esphome/pull/21146#issuecomment-6278026922
|
||||
|
||||
## 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
|
||||
@@ -1,10 +0,0 @@
|
||||
# Addressable LED strips (#21447, #21445)
|
||||
|
||||
- To exercise a failed strip allocation, use a classic ESP32 without PSRAM with `num_leds: 65535` and
|
||||
`channel_colors: GRBW` (262 kB, bigger than any internal block). `num_leds` is uint16 in the drivers, larger
|
||||
values wrap. On dev such a strip boot-loops the board.
|
||||
- Never force allocation failures by hogging RAM instead: Wi-Fi then aborts in `operator new` or the loop task
|
||||
trips the task watchdog.
|
||||
- On the ESP32-S3 give each RMT strip `rmt_symbols: 48`, or the first channel takes all 192 symbols.
|
||||
- A board with a few WS2812 on a pin already defined as a light can be extended with `id: !extend <light id>` to
|
||||
add effects; restore its own config afterwards.
|
||||
@@ -1,18 +0,0 @@
|
||||
# modem
|
||||
|
||||
## PPPoS modem component with no modem attached (#6721)
|
||||
|
||||
A boot check on a classic ESP32 devkit with the modem UART pins left open. `templates/modem/modem-common.yaml` is
|
||||
the package (`api:`, debug `loop_time` and `free` sensors, uptime, buttons for `modem.enable`, `modem.disable` and
|
||||
`modem.send_at`, a 20 s alive log); `modemo.yaml` is the modem only build with `reboot_timeout: 2min` and the
|
||||
component test config's `on_boot` block at `priority: -100` (earlier, `modem.send_at` runs before the modem's
|
||||
`setup()` and logs `Modem DCE not ready`); `modemw.yaml` adds `wifi:` with `reboot_timeout: 0s` so the API stays
|
||||
reachable. Expect `Modem not responding at 115200 baud` once per pass with each pass blocking the loop about 1 s,
|
||||
the reboot at the configured timeout (`SW_CPU_RESET`), and a `modem.disable` with no modem answering to block
|
||||
the loop about 37 s (`Failed to leave data mode`, then the radio off timeout) without tripping the 30 s watchdog
|
||||
floor because the component feeds it in between. Drive the Wi-Fi build with
|
||||
`scripts/modem/mdrive.py HOST LOGFILE list press:NAME wait:S` (plaintext API; prints sensor and text sensor
|
||||
states, saves the device log). Before 8cc8df4a1f a modem only config with `api:` failed validation because a
|
||||
config dependent `AUTO_LOAD` added `network` after the dependency check; an explicit `network:` block was the
|
||||
workaround. Results: https://github.com/esphome/esphome/pull/6721#issuecomment-6347395159 and
|
||||
https://github.com/esphome/esphome/pull/6721#issuecomment-6348520416
|
||||
@@ -10,72 +10,3 @@ after bulk subscribe and unsubscribe; read the heap seconds later over several r
|
||||
leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish.
|
||||
`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a
|
||||
paho script publishes and polls the serial capture file.
|
||||
|
||||
## WebSocket transports on ESP-IDF (#16056)
|
||||
|
||||
One build per transport from `templates/mqtt-transport-dut.yaml`, with `scripts/mqtt/mosquitto-ws.conf` as the
|
||||
local broker (mosquitto 2.x from brew has `websockets` support; `log_type all` prints `New connection from <ip>:<port>
|
||||
on port <listener>`, which proves which listener each client used) and `scripts/mqtt/make_test_ca.sh BROKER_IP` for
|
||||
the TLS listener's certificate, both run in the scratch directory so keys, logs and outputs stay out of the checkout. `scripts/mqtt/cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart]` subscribes
|
||||
to `NAME/#`, sends a switch command and an `on_message` topic, presses the 3000 byte publish button over the API,
|
||||
restarts the local broker when the last argument is `restart` (the PID in `mosquitto.pid`, written when the broker
|
||||
was started with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` in the working directory next to the
|
||||
certificates), checks 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
|
||||
|
||||
## MAC suffix topics (#9315)
|
||||
|
||||
`scripts/mqtt/mac_suffix/run.sh X BUILD` compiles (from `TREE`) and flashes variant X (a defaults, b custom `topic_prefix`, c explicit topics,
|
||||
d no suffix) of BUILD (`pr` or `base`) on an ESP32 and a D1 mini, records the broker with
|
||||
`mosquitto_sub -F '@H:@M:@S r=%r %t %p'`, presses Restart through the `cmd_t` found in the discovery payloads, then
|
||||
holds both chips in the bootloader for 40 s (`esptool --after no-reset chip-id`) so the broker sends the will; a
|
||||
plain reset only gives "session taken over" and no will. The two boards' configs live in separate folders because
|
||||
variants a to c share a `name` (the MAC suffix tells them apart), and the same name in one folder shares a build
|
||||
directory. A D1 mini CH340 can garble the serial capture after an RTS reset, so read its topics from the broker.
|
||||
`esphome logs --device MQTT` on a current tree needs paho-mqtt 2.x; an older venv with 1.6.1 fails, so use a venv
|
||||
made from the tree's requirements.txt. A Restart press never delivered the shutdown message on any build tested.
|
||||
|
||||
## Shutdown message delivery (#21566)
|
||||
|
||||
`scripts/mqtt/shutdown/`: `sd-common.yaml` sets birth/will/shutdown payloads `hello`/`dead`/`bye` so the shutdown
|
||||
message and the will cannot be confused, and takes `restart`, `disable` and `sleep` on `<name>/cmd`.
|
||||
`sdrun.sh V N` (`TREE`, `P32` and `P66` in the environment) compiles and flashes the ESP32 and D1 mini, restarts each N times, disables MQTT once and deep sleeps the ESP32 three
|
||||
times (never the D1 mini: without the GPIO16 to RST wire it never wakes); `match.py OUT` counts a `bye` within 5 s of
|
||||
each command. Dev: ESP32 0/10 restarts, D1 mini 1 to 3/10. Put the broker address (`broker_ip`) in the secrets.yaml next to
|
||||
the included package, and quote `"${name}/status"` inside flow mappings.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21566#issuecomment-6381448061
|
||||
|
||||
Retest at c9cf3c2813 (shutdown message sent at QoS 1, teardown waits for the broker's ack): 10/10 restarts on both
|
||||
boards and 3/3 deep sleeps, against 1/10, 3/10 and 0/3 on base. An earlier 100 ms hold gave 3/10, 6/10 and 2/3, so
|
||||
judge a delivery fix by counts over 10 or more restarts, never one or two presses.
|
||||
Results: https://github.com/esphome/esphome/pull/21566#issuecomment-6382076613
|
||||
@@ -4,10 +4,3 @@
|
||||
|
||||
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.
|
||||
|
||||
## ItsyBitsy over UF2 when serial DFU fails (#20481)
|
||||
|
||||
Serial DFU to the ItsyBitsy nRF52840 fails about half the time ("Target is not in DFU mode"). Reliable path: a
|
||||
1200 baud touch on the app port, wait for the `ITSY840BOOT` volume, convert `zephyr/zephyr.hex` with the SDK's
|
||||
`zephyr/scripts/build/uf2conv.py -f 0xADA52840 -c` and copy the .uf2 to the volume (the copy ends in an I/O error
|
||||
when the board reboots, which is normal). macOS BLE scans give UUIDs, not MAC addresses.
|
||||
@@ -18,47 +18,3 @@ ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout
|
||||
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.
|
||||
|
||||
## PNG decode time limit (#21364, #21391)
|
||||
|
||||
The same server also serves any plain file in its www directory by name (404 for a missing file or a path
|
||||
outside it). `scripts/online_image/make_big_pngs.py <www>` (after `make_images.py`) writes a 320x240 gradient (618 B, also as `ok.png` so the misbehaviour cases carry a
|
||||
real multi-row PNG), a flat 640x480 PNG (1.6 KB for 307200 pixels), 231 KB of noise and a JPEG. The device is
|
||||
`templates/online-image-dut.yaml` with `type: BINARY` on the main image (a 640x480 RGB565 does not fit a classic
|
||||
ESP32 without PSRAM) and `debug` loop time and heap sensors at 2 s; the 256 byte buffer image stays RGB565, so do
|
||||
not point big images at it (out of memory on every build). `scripts/online_image/pngdrive.py HOST KEY LOGFILE
|
||||
BASEURL LABEL` (default case set `png`; `CASES=short` for the six case list) calls the actions, waits for the
|
||||
finished or error line and reports the case time, the highest loop time and the lowest free heap. The per pass `online_image.image took a long time` values come from
|
||||
the serial capture and only print on a new maximum, so read the first fetch of each image. The pass that decodes a
|
||||
whole buffer is the decoder cost (before the change 510 ms gradient and 1980 ms flat, after it 77 and 140 ms); for
|
||||
the noisy PNG the longest pass is the blocking 64 KB read on both builds (0.7 to 3.7 s). Alternate PR, base, PR
|
||||
for a timing claim.
|
||||
|
||||
For a replacement PR, compare against the parent PR head, not dev. `CASES=noise` fetches the noisy PNG three times
|
||||
(run it right after a boot, alternating builds); `CASES=end` runs flat and gradient as plain, `noclen_`, `chunked_`,
|
||||
`half_` and `stall_` through the prefixed server cases below.
|
||||
|
||||
## JPEG decoded as it downloads (#21342)
|
||||
|
||||
`oi_server.py` serves prefixed cases on any file: `half_<file>` (first half, matching Content-Length),
|
||||
`noclen_<file>` (close delimited), `chunked_<file>` (1 KB chunks), `stall_<file>` (a third, then the socket held
|
||||
40 s). `scripts/online_image/make_jpegs.py <www>` writes noise, gradient, 12 KB EXIF and progressive JPEGs. Device:
|
||||
`templates/online-image-dut.yaml` with `type: BINARY` on both entities and `buffer_size: 4096` on the second;
|
||||
`CASES=jpeg scripts/online_image/pngdrive.py HOST KEY LOGFILE BASEURL LABEL` runs the JPEG list; every case set
|
||||
prints case time, highest loop time and lowest free heap. Before the change a JPEG needs a contiguous block of its file size (233 KB never
|
||||
allocates on a classic ESP32, and after a few failures 69 KB does not either until a reboot); after it the 64 KB
|
||||
buffer stays 64 KB and 4 KB is enough for a baseline JPEG. The first pass still reads up to `buffer_size` in one
|
||||
blocking read, so the 64 KB entity blocks 1.7 to 4 s and the 4 KB one 272 ms at most; judge the decode budget on
|
||||
the small buffer entity. Already on dev: a JPEG without Content-Length (plain or chunked) ends with `Could not open
|
||||
image for decoding: 4` then `Invalid type`.
|
||||
|
||||
## BMP and QOI decode time limit (#21392)
|
||||
|
||||
Use a wired board (`templates/online-image-s3-eth-dut.yaml`: Waveshare ESP32-S3-ETH, W5500, PSRAM, `type: RGB565`)
|
||||
rather than a classic ESP32 on Wi-Fi: with a weak link the reads block up to the 4.5 s timeout and the per case loop
|
||||
numbers are noise. `scripts/online_image/make_bmp_qoi.py <www>` writes 1 bit BMP 640x480, 8 and 24 bit BMP 320x240,
|
||||
QOI 640x480 one colour (all runs) and checkerboard, QOI 320x240 gradient and noise. `pngdrive.py` with
|
||||
`CASES=bmpqoi` runs each as plain, `noclen_`, `chunked_`, `half_` plus stalls. The `loop_time` sensor sits at 75
|
||||
to 160 ms even for a 16x16 image, so for decode passes use `scripts/online_image/firstpass.py HOST KEY BASEURL
|
||||
LABEL IMAGE...`: it reboots the device before each fetch and reports that fetch's `took a long time` maximum. A
|
||||
`half_` file with matching Content-Length reports `download finished` with "not caching it" on dev too.
|
||||
@@ -6,46 +6,3 @@ All overlap orders (a web upload during a native OTA and the reverse) must be re
|
||||
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.
|
||||
|
||||
## OTA upload timing A/B (#21639, #21641, #21642)
|
||||
|
||||
`scripts/ota_timing/run.sh N PHASE...` times native OTA uploads per build (phase) and per board. It builds every
|
||||
board from `TREE_<PHASE>`, does one warm-up upload per board (it installs the phase's build and is left out of the
|
||||
results), checks over the API that each board reports the compile time from that build's `build_info.json`
|
||||
(`apiinfo.py`), then runs N rounds of every mode on every board. Boards are interleaved per round so they share the
|
||||
same air; put a nearby ESP32 on the same Wi-Fi in `BOARDS` as a network control. `summ.py` prints medians: never
|
||||
judge from single runs, slow Wi-Fi gave 33 to 235 s for the same upload in one night.
|
||||
|
||||
- `otatime.py` drives `espota2.run_ota()` from the tree on `PYTHONPATH` and prints one `RESULT` line with the
|
||||
`Update took` split, the compressed size, whether UDP was used and the time at each tenth of the upload.
|
||||
An upload that leaves no `RESULT` line (board gone, script crash, timeout) is recorded as `rc: -1`, and `summ.py`
|
||||
fails when a board and mode has fewer than N rows or an upload time cannot be read.
|
||||
- Boards with an `api: encryption: key` also encrypt OTA: set `API_KEY` for `otatime.py` and `apiinfo.py`. LibreTiny
|
||||
LN882H listens for OTA on 8820, not 8892; BK72xx and RTL87xx use 8892.
|
||||
- Raw mode leaves `CLIENT_FEATURE_SUPPORTS_DEFLATE` out of the client's offer. Never switch deflate off after the
|
||||
offer (`if deflate and 0:`): once the device accepted deflate it expects a deflate stream.
|
||||
- `-tcp` modes make `espota2._loss_reason` return None, so the client asks to stay on TCP. Clearing
|
||||
`CLIENT_FEATURE_SUPPORTS_UDP` instead desyncs the handshake (`receiving UDP token response: timed out`).
|
||||
- LibreTiny trimmed UF2 (#21642): the CLI sends what `esphome.components.libretiny.uf2.device_ota_uf2()` returns for
|
||||
`firmware.uf2`; that module only exists with #21642, so on older trees the full UF2 goes out. Compare the sizes
|
||||
in the `Compressed to` lines, and rely on the API compile time check to show that the trimmed image booted.
|
||||
|
||||
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409628227 (dev and LibreTiny 1.14.0 on a
|
||||
CB3S), https://github.com/esphome/esphome/pull/21642#issuecomment-6412972562 (trimmed UF2 on CB3S, BK7238 and BW15),
|
||||
https://github.com/esphome/esphome/pull/21641#issuecomment-6412976859 (new decoder on the same three).
|
||||
|
||||
## On-device inflate benchmark (#21639, #21641)
|
||||
|
||||
`scripts/ota_timing/inflate_bench`: `gen_streams.py NAME=IMAGE...` cuts a 128 KB slice from each image and writes
|
||||
`bench_streams.hpp` with raw deflate streams (level 9, 4 KB and 32 KB windows). `bench.hpp` inflates every stream with
|
||||
each ring size at least the encoder window, three times, and logs the best time plus input and output rates, then
|
||||
`BENCHDONE`. `bench.sh CONFIG DEVICE` builds and uploads `bench-esp32s3.yaml` or `bench-bk72xx.yaml`, collects one pass
|
||||
over syslog and fails when a case did not reach the end of its stream.
|
||||
|
||||
- LibreTiny defines `micros` as a macro, so the header calls `(esphome::micros)()`.
|
||||
- Keep the streams near 130 KB on a BK7231N: an image with about 330 KB of extra incompressible data failed its
|
||||
OTA with "Writing OTA data to flash memory failed". Use one image and `--windows 12` there.
|
||||
- `bench.hpp` targets the decoder interface with `dest_limit`. #21641 drops it (the output runs into the window up
|
||||
to `dest`); after that lands, remove the `dest_limit` line.
|
||||
|
||||
Results: https://github.com/esphome/esphome/issues/21639#issuecomment-6409786224
|
||||
@@ -1,30 +0,0 @@
|
||||
# remote_base / remote_receiver
|
||||
|
||||
## Loopback on one pin, no IR hardware (#21560 to #21563)
|
||||
|
||||
`templates/remote_base/`: `rb-common.yaml` (API action `tx_all`, a dumper registered from `on_boot` through
|
||||
`register_dumper()` standing in for an external component), `rb-txscript.yaml` (14 `remote_transmitter.transmit_*`
|
||||
frames with `tx <proto>` log markers), `rb32.tmpl` and `rb66.tmpl` (`@V@` is the build label).
|
||||
`scripts/remote_base/run.sh LABEL B N` (`TREE`, `P32` or `P66`, `SC` in the environment) writes the run file from the
|
||||
template, builds and flashes it from `TREE` (`esphome run --no-logs`), finds the IP and runs `rbdrive.py IP N`, which segments the API log by the
|
||||
markers and prints what each sent frame decoded as.
|
||||
|
||||
- ESP32: `remote_transmitter` and two `remote_receiver`s on the same GPIO (`allow_other_uses`), carrier 100%, no
|
||||
inversion. RMT reads its own output, every frame decodes. Second receiver `dump: [nec, raw]` shows raw runs only
|
||||
where no primary matched.
|
||||
- ESP8266: the transmitter sends with interrupts off, so a receiver on the same pin sees nothing. `softtx.hpp`
|
||||
bit-bangs frames built with each protocol's `encode()` with interrupts on, and an `interval` sets the pin back to
|
||||
OUTPUT after the receiver's setup. Bit-banged NEC decodes as NEC about 2 of 3 times (timing), on every build.
|
||||
- Dish never decodes in loopback on dev either. Pronto matches every frame, so raw on `dump: all` never prints.
|
||||
- API log lines can drop under bursts; rerun with more rounds before calling a shortfall.
|
||||
- `register_dumper()` returns nothing, so the config logs `ext dumper register called`, not whether it stuck. On dev
|
||||
the list has a fixed size and a dumper beyond it is dropped with `No dumper slot` (ERROR); judge by that line and by
|
||||
`ext dumper saw` counts in the `rbdrive.py` table, which only appear when the dumper is really registered.
|
||||
`rbdrive.py` exits 1 when the external dumper saw no frame, so a build that rejects the registration fails the
|
||||
drive; set `EXPECT_REGISTER=1` on `run.sh` to also fail the run on any `No dumper slot` line.
|
||||
|
||||
Results: https://github.com/esphome/esphome/pull/21563#issuecomment-6381304387
|
||||
|
||||
Retest of the moved stack with 5 rounds per build: https://github.com/esphome/esphome/pull/21563#issuecomment-6382075726.
|
||||
A D1 mini CH340 sometimes fails the 460800 baud upload; esphome retries at 115200 by itself, but a runner that reads
|
||||
only the last line sees "Failed to upload" first, so rerun before blaming the build.
|
||||
@@ -1,19 +0,0 @@
|
||||
# speaker
|
||||
|
||||
## Media player pipeline tasks (#21203)
|
||||
|
||||
The `speaker` media player platform (`speaker/media_player/`) creates `ann_read`, `ann_decode`, `med_read` and
|
||||
`med_decode` FreeRTOS tasks on every play and deletes them after a stop. The AUX-2 and sendspin packages use the
|
||||
`speaker_source` player instead, which has its own pipeline and never runs that code, so a test of
|
||||
`speaker/media_player/*` has to swap the player: `templates/aux-2-preprod-speaker-player-dut.yaml` removes the
|
||||
package's `external_media_player` with `id: !remove` and adds a `speaker` platform player on the package's resampler
|
||||
speakers, plus a "Task names" button whose lambda calls `xTaskGetHandle()` on the four names and logs internal free
|
||||
heap. Serve FLAC files from the host (`ffmpeg -f lavfi -i "sine=frequency=440:duration=90" -ac 2 -ar 48000 -c:a flac
|
||||
media90.flac`, a 3 s mono one for announcements, `python3 -m http.server 8765 --directory www`); `audio_http`
|
||||
refuses WAV and MP3. `scripts/speaker_media_player/cases.sh HOST KEY MEDIA_URL ANN_URL CAPTURE LABEL` runs three
|
||||
cycles through `drive.py` (`play:URL | ann:URL | stop | press:NAME | wait:S` on one API connection) with the probe
|
||||
pressed idle, during media, during the announcement, after it and after the stop. Pass: idle none, media `med_*`
|
||||
only, announcement all four, after stop none, every cycle. API commands land a second or two late over Wi-Fi, so read
|
||||
the probe against the `State changed to` lines rather than the driver's timestamps. Internal free heap after a stop
|
||||
moved by 2.5 kB between two boots of the same build, so a heap delta needs more than one boot per build. Results:
|
||||
https://github.com/esphome/esphome/pull/21203#issuecomment-6299581943
|
||||
@@ -1,19 +0,0 @@
|
||||
# Simulated UART sensors on a classic ESP32 (#21449, #21446)
|
||||
|
||||
No sensor needed. `templates/uart_sim/sim.hpp` (included with `esphome: includes:`) has frame builders for wts01,
|
||||
ds1603l, sm300d2 and ufm01, `sim::loopback(id(uart))` (ESP-IDF `uart_set_loop_back` on the component's own UART),
|
||||
`sim::send()` that logs the bytes, and `sim::probe`, a SEN0465 responder polled from a 10 ms interval. The run files
|
||||
`fra.yaml`, `frb.yaml` and `frc.yaml` set the name and include `fr-common.yaml` plus their `fr*-parts.yaml` as
|
||||
packages; copy them with a different name (`frabase` and so on) for the base build.
|
||||
|
||||
- Push-style sensors: put the component on a UART, switch it to loopback in `on_boot`, and inject frames from
|
||||
template buttons (`fra-parts.yaml`, `frb-parts.yaml`). ufm01 enters its active stream by itself when a 1 s
|
||||
interval streams frames during its 2 s startup wait.
|
||||
- Request/response sensors: cross-wire two UARTs through the GPIO matrix (UART1 tx 25 rx 26, UART2 tx 26 rx 25,
|
||||
`allow_other_uses: true` on all four pins); the second UART runs the simulated device (`frc-parts.yaml`).
|
||||
A select sets the probe's misbehaviour (tail, FF tail, silent, split, wrong gas).
|
||||
- Polled readers: run `update()` from a script and log `micros()` around it to measure blocking
|
||||
(sm300d2 on dev blocks about 104 ms on an empty buffer).
|
||||
- The logger stays on UART0, so three builds cover five components. Drive with `scripts/uart_sim/frdrive.py HOST
|
||||
LOG press:NAME sel:NAME=OPT num:NAME=V wait:S`, or `scripts/uart_sim/run.sh NAME TREE SECONDS OPS...` (build
|
||||
and flash from TREE with `esphome run --no-logs`, capture, drive; `PORT` and `SC` in the environment).
|
||||
@@ -9,18 +9,3 @@ listener. Broadcast to boards is lossy under power save, unicast is not. Flood a
|
||||
reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect`
|
||||
(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action
|
||||
rejects `%.*s`; use a lambda.
|
||||
|
||||
## udp.write payload shapes (#21263, ApplyBytes)
|
||||
|
||||
`templates/udp-write-dut.yaml` puts every payload shape behind a template button (constant 4 bytes twice, 3, 9, 0,
|
||||
128, 129 and 600 bytes, a 300 byte lambda, a simple value, a string) plus an `on_receive` that replies with the
|
||||
received bytes reversed. `scripts/udp/write_cases.sh DEVICE_IP KEY` records the device's datagrams with
|
||||
`udp_listen.py` on the test host, presses the buttons two seconds apart, sends one unicast datagram for the echo and
|
||||
compares everything byte for byte with `check.py`; a missing datagram shifts every later one, so read the first
|
||||
mismatch. Before flashing, check the generated `main.cpp`: on ESP8266 a constant payload is copied out of its
|
||||
`PROGMEM` table into `uint8_t esphome__bytes[N]` up to the stack limit and into `std::make_unique<uint8_t[]>` beyond,
|
||||
identical payloads share one table, a lambda is sent from its vector. An empty constant payload is generated as
|
||||
`send_packet(nullptr, 0)`, and the raw lwIP UDP `sendto` used on ESP8266 rejected a null buffer with `EINVAL`
|
||||
(`sendto() error 22` in the log, no datagram) until it accepted `nullptr` with a zero length; the old
|
||||
`TemplatableBytes::visit` had passed a zero length stack buffer instead. Compare sizes against the stacked PR's own
|
||||
head when the PR is stacked, not against dev. Results: https://github.com/esphome/esphome/pull/21263
|
||||
@@ -1,60 +0,0 @@
|
||||
# web_server
|
||||
|
||||
## State SSE on web_server_idf (#21109) and the HTTPD task stack (#21038)
|
||||
|
||||
`templates/web-server-sse-dut.yaml` (ESP32 classic, ESP-IDF, `web_server` version 3) publishes eight sensors every
|
||||
200 ms (small state events on the 1024 byte stack buffer) and three text sensors whose JSON carries the string twice
|
||||
(`value` and `state`): 600 chars for about 1.3 kB (the 2048 byte tail), 1800 chars for about 3.7 kB (tail grown to
|
||||
4096) and 2800 chars for about 5.7 kB, over the 5120 byte JSON room, which must log `State event does not fit`
|
||||
and never put a truncated frame on the wire. An `interval` lambda logs `uxTaskGetStackHighWaterMark()` of the loop
|
||||
task (`nullptr`) and of the HTTPD task (`xTaskGetHandle("httpd")`) every 5 s; the ESPHome loop task is `loopTask`
|
||||
on ESP32. Keep `power_save_mode: none`, and drive the device by address: mDNS names dropped out mid-run once.
|
||||
|
||||
`scripts/web_server/sse.py HOST SECONDS [--clients N] [--stall] [--churn CYCLES]` opens raw TCP connections to
|
||||
`/events`, decodes the chunked body and parses every `state` event as JSON; `--stall` adds a connection with a 2 kB
|
||||
receive buffer that never reads (the device must log `Closing stuck EventSource connection` after the 20 s stall
|
||||
timeout), `--churn` opens and resets a connection CYCLES times. `sse_cases.sh HOST KEY LABEL CAPTURE` runs the
|
||||
set: 1 client 60 s, 4 clients 60 s, a reader plus a stalled client 45 s, a reader plus 10 abrupt closes, then two
|
||||
clients with the "Burst" button (50 sensor and 50 text publishes in one loop pass). Pass: 0 invalid JSON on every
|
||||
client, the oversize sensor dropped with its warning on every publish, the stalled session closed after about
|
||||
20 s, no reset. The loop task watermark is a minimum since boot, so compare the value after the first client, after
|
||||
four clients and after the burst between the builds (the SSE split in #21109 left 1280 B more on the ESP32 classic).
|
||||
|
||||
For the HTTPD task, `web_server: auth: type: digest` (the default `auth` is Basic) and
|
||||
`scripts/web_server/httpd_cases.sh HOST USER PASS LABEL CAPTURE`: `httpmix.sh` sends a `curl --anyauth` request
|
||||
mix per round (page, `/sensor/<Name>`, `/text_sensor/<Name>`, `/switch/<Name>`, a `POST` with `Content-Length: 0`
|
||||
to `/switch/<Name>/toggle`, an unknown path, which the IDF server closes without a response, an unauthenticated
|
||||
request answered 401, and a 2 s `/events` stream), 20 rounds alone and 12 rounds next to two `ssecurl.sh` SSE
|
||||
clients. Entity URLs use the entity name, URL encoded, not an object id. The HTTPD watermark follows the deepest
|
||||
request of the boot; compare after the mix and after the SSE round, with two boots when the numbers are close.
|
||||
Results: https://github.com/esphome/esphome/pull/21109#issuecomment-6300582328 and
|
||||
https://github.com/esphome/esphome/pull/21038#issuecomment-6301310182
|
||||
|
||||
## 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.
|
||||
@@ -23,58 +23,3 @@ Only an Android phone configures: Settings, network details, Add device, Share W
|
||||
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.
|
||||
|
||||
## Reboot against a WPA2/WPA3 PMF access point (#21590)
|
||||
|
||||
- AP: `scripts/wpa3_ap/wdap.yaml` on a classic devkit. ESPHome `wifi: ap:` only, then an on_boot lambda switches it
|
||||
to `WIFI_AUTH_WPA2_WPA3_PSK`, PMF capable, and registers a WIFI_EVENT handler that logs `STA JOIN` and
|
||||
`STA LEAVE ... reason N` (the include header pulls in `esp_wifi.h`). It disables Wi-Fi after 420 s.
|
||||
- AP leave reason 3 means the station sent a deauth; reason 2 means the AP found a stale association when the
|
||||
station came back; several reason 2 before one join are rejected reconnects.
|
||||
- DUTs (`wdbk.yaml` BK7238, `wdcb.yaml` CB3S, both on `wd-common.yaml`) list the AP at priority 10 and the house
|
||||
network at 0, and reboot 30 s after joining the AP and 150 s after joining the house network. So they move to the
|
||||
AP while it is up and fall back to the house network (where they can be flashed over the air) once its window
|
||||
ends. `run.sh BUILD` flashes the AP, does OTA for the CB3S and a serial flash for the BK7238, then captures one AP
|
||||
window; `an.py` prints each station's join and leave sequence and fails on a rejected reconnect, an AP that did
|
||||
not switch to WPA2/WPA3 (`AP CONFIG FAILED`), a DUT missing from the AP events (`DUT_MACS`) or a BK7238 that never
|
||||
joined the AP.
|
||||
- Gotchas: the LibreTiny build directory has two `.uf2` files, so flash `firmware.uf2`. The BK7238 serial flash
|
||||
sometimes fails to link while the chip is in a reboot loop; retry it 3 or 4 times. OTA fails if the DUT reboots
|
||||
mid-upload; retry.
|
||||
- Finding: BDK 3.0.78 rejects about 2 reconnects per reboot when the station left without a deauth; BDK 3.0.33
|
||||
does not.
|
||||
|
||||
## WISP and ALWAYS_ON AP rig (#20077)
|
||||
|
||||
`scripts/wisp_ap`: the upstream is an ESP32-S3 with Ethernet running a WISP bridge (`wisp-upstream.yaml`) with its AP on
|
||||
10.40.0.1/24 and web buttons `ap off` and `ap on`. Its subnet must not be 192.168.4.0/24, the DUT AP's default, or
|
||||
nothing routes. The DUT is a classic devkit (`dut-wisp.yaml` or `dut-always-on.yaml`); the client is a D1 mini on
|
||||
the DUT's AP (`wispcli.yaml`) logging its DNS server, a name lookup, a GET by name, the AP itself and a LAN file by
|
||||
IP. `run.sh DUT START SECONDS T:STATE...` switches the upstream AP at the given times and `sum.py` groups the client
|
||||
results by phase. Capture the D1 mini without `--reset` (a CH340 floods garbage after an RTS reset). Clients keep
|
||||
the captive portal's DNS answer for its TTL.
|
||||
|
||||
## AP restart with WPA3 mixed and LIGHT power save (#21531)
|
||||
|
||||
`scripts/wifi_restart`: the WISP upstream (`wisp-upstream-wpa3.yaml`) switched to WPA2/WPA3 mixed by a script that
|
||||
runs again after every `ap on`, DUTs (`rebk.yaml`, `recb.yaml` on `re-common.yaml`) with a static IP on the bridge's
|
||||
subnet, a fallback AP, `power_save_mode: LIGHT` and syslog to the host through the NAT. `run.sh BUILD` switches the
|
||||
AP off and on three times; `an.py` fails on a reboot, a crash or a DUT that is not back on syslog within 120 s of an
|
||||
AP start. After 19 minutes the DUTs run `wifi.configure` to the house network; that keeps the static IP, so the next
|
||||
OTA needs the host to have an address on the bridge's subnet as well (an interface alias). The CB3S log is on UART1
|
||||
(TX1), so its configs need `logger: hardware_uart: UART1`. Runners must `pkill` only their own ports; a global pkill
|
||||
stops the other runs' captures.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Xiaomi BLE
|
||||
|
||||
## Xiaomi, ATC and PVVX parsing without the sensors (#21483)
|
||||
|
||||
No Xiaomi devices are needed, and one ESP32 cannot hear its own advertising, so the frames are injected into the
|
||||
tracker as scan results: everything after the radio runs as normal.
|
||||
|
||||
- `scripts/xiaomi_ble/gen.py` writes `frames.hpp` and `expected.json`: plain MiBeacon (0x50), encrypted v5 23 byte
|
||||
with MAC and 19 byte without, real AES-CCM (`cryptography` AESCCM, tag 4, AAD 0x11, nonce = MAC reversed + pid +
|
||||
cnt + 3 byte ext counter), ATC (13 byte, big endian) and PVVX (15 byte, little endian) 0x181A frames, a corrupted
|
||||
tag every other cycle and a counter 0 boot frame. It models the old shared duplicate counter (`base`) and the per
|
||||
device one (`pr`) and prints the expected publish counts.
|
||||
- `xbinject.yaml` (ESP-IDF devkit) includes `frames.hpp`; an `interval:` builds a `BLEScanResult` and calls
|
||||
`global_esp32_ble_tracker->gap_scan_event_handler(r)`. `count.py` counts the `Got Xiaomi` blocks (sensor
|
||||
publishes are not logged at DEBUG) and compares them with `expected.json`.
|
||||
- On ESP32 the tracker's own triggers sit in `listeners_`, which runs before `neutral_listeners_` (the
|
||||
ble_device_base devices), so an `on_ble_service_data_advertise` never sees what a Xiaomi listener did to the
|
||||
shared data. Check that directly: `xb_rewrite_probe(id(xa))` builds an `ESPBTDevice` with `parse_scan_rst()`,
|
||||
calls the component's `parse_device()` and logs the service data before and after (`[probe` lines).
|
||||
- `run.sh inject pr|base` builds, flashes, captures and prints PASS when the counts match that build's model and,
|
||||
on the PR build, the probe's service data is the same before and after `parse_device()`.
|
||||
Base dropped a second device's frame with the same counter, the frame after a bad tag and a first frame with
|
||||
counter 0, and rewrote the shared data in place; the PR fixed all of them.
|
||||
|
||||
## Over the air with a second ESP32
|
||||
|
||||
- Advertiser: an ESP32-S3 with Ethernet (OTA over Ethernet), `xbadv.yaml` with `adv_frames.hpp` from
|
||||
`gen.py --ota`. Per frame: stop advertising, `esp_ble_gap_set_rand_addr`, `config_adv_data_raw`, start
|
||||
non-connectable advertising with `own_addr_type` random, 20 ms interval, 250 ms dwell. Random static addresses
|
||||
need the top two bits set, so these use C4:C1:38 instead of A4. `esp32_ble:` alone brings up the stack. The
|
||||
web_server button starts a run: `curl -X POST -d "" http://<advertiser>/button/start/press` (no body gives 411).
|
||||
- Receiver: `xbrecv.yaml` on the devkit, passive scan with window = interval = 100 ms. `run.sh ota pr|base` (with
|
||||
`ADV` set) flashes it, resets it with a capture and presses start 18 s later, so the counter 0 boot frame is the
|
||||
first one it hears.
|
||||
- Each frame arrives about 4 times; the counts matched the injection run exactly apart from one radio loss, so the
|
||||
over the air comparison allows two missing publishes per tag.
|
||||
@@ -1,211 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Pull the store_yaml blob off a device over the native API (plaintext or Noise) and unpack it.
|
||||
|
||||
usage: syrecover.py HOST KEY OUTDIR [ORIGDIR] (KEY of - means a plaintext API)
|
||||
Prints the capability flag, chunk count, advertised and received sizes, encoding, the envelope's files, and
|
||||
when ORIGDIR is given compares each recovered file with ORIGDIR/<path>.
|
||||
Run with PYTHONPATH pointing at the esphome tree that has store_yaml (for unpack_envelope). Needs Python 3.14
|
||||
(compression.zstd) or the zstandard package.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import api_pb2
|
||||
from aioesphomeapi._frame_helper.noise import APINoiseFrameHelper
|
||||
from aioesphomeapi._frame_helper.plain_text import APIPlaintextFrameHelper
|
||||
from aioesphomeapi.connection import APIConnection, ConnectionParams
|
||||
from aioesphomeapi.zeroconf import ZeroconfManager
|
||||
|
||||
from esphome.components.store_yaml import unpack_envelope
|
||||
|
||||
try:
|
||||
from compression import zstd
|
||||
except ImportError:
|
||||
try:
|
||||
import zstandard as zstd
|
||||
except ImportError:
|
||||
sys.exit("needs Python 3.14 (compression.zstd) or the zstandard package")
|
||||
|
||||
HOST, KEY, OUT = sys.argv[1], sys.argv[2], Path(sys.argv[3])
|
||||
ORIG = Path(sys.argv[4]) if len(sys.argv) > 4 else None
|
||||
HELLO, HELLO_RESP, CONNECT, CONNECT_RESP = 1, 2, 3, 4
|
||||
DISCONNECT_REQ = 5
|
||||
FATAL = -1 # queued by report_fatal_error so a waiting expect() stops at once
|
||||
CAPS_REQ, CAPS_RESP = 149, 150
|
||||
GET_YAML_REQ, GET_YAML_RESP = 161, 162
|
||||
|
||||
|
||||
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) -> dict[int, list]:
|
||||
out: dict[int, list] = {}
|
||||
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]
|
||||
pos += 4
|
||||
else:
|
||||
raise ValueError(f"wire type {wt}")
|
||||
out.setdefault(fnum, []).append(v)
|
||||
return out
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue[tuple[int, bytes]] = asyncio.Queue()
|
||||
errors: list[Exception] = []
|
||||
|
||||
class Shim(APIConnection):
|
||||
"""Only the two callbacks the frame helper uses; the real connection logic is bypassed."""
|
||||
|
||||
def process_packet(self, msg_type_proto, data) -> None:
|
||||
queue.put_nowait((msg_type_proto, bytes(data)))
|
||||
|
||||
def report_fatal_error(self, err: Exception) -> None:
|
||||
errors.append(err)
|
||||
queue.put_nowait((FATAL, b""))
|
||||
|
||||
params = ConnectionParams(
|
||||
addresses=[HOST],
|
||||
port=6053,
|
||||
password=None,
|
||||
client_info="syrecover",
|
||||
keepalive=15,
|
||||
zeroconf_manager=ZeroconfManager(),
|
||||
noise_psk=None if KEY == "-" else KEY,
|
||||
expected_name=None,
|
||||
expected_mac=None,
|
||||
timezone=None,
|
||||
provide_time=True,
|
||||
)
|
||||
shim = Shim(params, None, False, HOST)
|
||||
if KEY == "-":
|
||||
helper = APIPlaintextFrameHelper(
|
||||
connection=shim, client_info="syrecover", log_name=HOST
|
||||
)
|
||||
else:
|
||||
helper = APINoiseFrameHelper(
|
||||
connection=shim,
|
||||
noise_psk=KEY,
|
||||
expected_name=None,
|
||||
expected_mac=None,
|
||||
client_info="syrecover",
|
||||
log_name=HOST,
|
||||
)
|
||||
await loop.create_connection(lambda: helper, HOST, 6053)
|
||||
try:
|
||||
await asyncio.wait_for(helper.ready_future, 10)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
sys.exit(f"handshake failed: {exc!r} {errors}")
|
||||
print(f"transport {'plaintext' if KEY == '-' else 'noise'}: handshake done")
|
||||
|
||||
async def expect(msg_type: int, timeout: float = 10) -> bytes:
|
||||
while True:
|
||||
t, d = await asyncio.wait_for(queue.get(), timeout)
|
||||
if t == msg_type:
|
||||
return d
|
||||
if t == FATAL:
|
||||
sys.exit(
|
||||
f"transport error while waiting for message {msg_type}: {errors}"
|
||||
)
|
||||
if t == DISCONNECT_REQ:
|
||||
sys.exit(
|
||||
f"device closed the connection while waiting for message {msg_type}"
|
||||
)
|
||||
print(f"skipping message type {t} while waiting for {msg_type}")
|
||||
|
||||
hello = api_pb2.HelloRequest(
|
||||
client_info="syrecover", api_version_major=1, api_version_minor=10
|
||||
)
|
||||
helper.write_packets([(HELLO, hello.SerializeToString())], False)
|
||||
await expect(HELLO_RESP)
|
||||
# Sent like the client library does; a device without a password never answers it
|
||||
helper.write_packets(
|
||||
[(CONNECT, api_pb2.AuthenticationRequest().SerializeToString())], False
|
||||
)
|
||||
helper.write_packets(
|
||||
[(CAPS_REQ, api_pb2.DeviceCapabilitiesRequest().SerializeToString())], False
|
||||
)
|
||||
caps = fields(await expect(CAPS_RESP))
|
||||
supported = None
|
||||
if 7 in caps:
|
||||
supported = fields(caps[7][0]).get(1) == [1]
|
||||
print(f"capabilities: store_yaml field present={7 in caps} supported={supported}")
|
||||
|
||||
t0 = time.monotonic()
|
||||
helper.write_packets([(GET_YAML_REQ, b"")], False)
|
||||
chunks: list[bytes] = []
|
||||
total = encoding = None
|
||||
done = False
|
||||
while not done:
|
||||
f = fields(await expect(GET_YAML_RESP, 20))
|
||||
if 1 in f:
|
||||
chunks.append(f[1][0])
|
||||
done = f.get(2) == [1]
|
||||
if 3 in f:
|
||||
total = f[3][0]
|
||||
if 4 in f:
|
||||
encoding = f[4][0].decode()
|
||||
dt = time.monotonic() - t0
|
||||
compressed = b"".join(chunks)
|
||||
print(f"chunks {len(chunks)} sizes {[len(c) for c in chunks]}")
|
||||
print(
|
||||
f"received {len(compressed)} bytes, advertised {total}, encoding {encoding!r}, {dt:.2f} s"
|
||||
)
|
||||
# a second request right after: the device must answer again, not stay stuck
|
||||
helper.write_packets([(GET_YAML_REQ, b"")], False)
|
||||
again = 0
|
||||
done = False
|
||||
while not done:
|
||||
f = fields(await expect(GET_YAML_RESP, 20))
|
||||
again += len(f.get(1, [b""])[0])
|
||||
done = f.get(2) == [1]
|
||||
print(f"second request: {again} bytes")
|
||||
helper.close()
|
||||
if errors:
|
||||
sys.exit(f"transport errors: {errors}")
|
||||
if encoding != "zstd":
|
||||
sys.exit(f"unexpected encoding {encoding!r}, expected 'zstd'")
|
||||
if total is not None and len(compressed) != total:
|
||||
sys.exit(f"received {len(compressed)} bytes but the device advertised {total}")
|
||||
if again != len(compressed):
|
||||
sys.exit(
|
||||
f"the second request returned {again} bytes, the first {len(compressed)}"
|
||||
)
|
||||
envelope = zstd.decompress(compressed)
|
||||
files = unpack_envelope(envelope)
|
||||
print(
|
||||
f"envelope {len(envelope)} bytes, {len(files)} files, ratio {len(compressed) / len(envelope):.2f}"
|
||||
)
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for rel, content in files.items():
|
||||
target = OUT / rel.replace("../", "up_/")
|
||||
if not target.resolve().is_relative_to(OUT.resolve()):
|
||||
print("skipping a path outside the output directory:", rel)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
note = ""
|
||||
if ORIG is not None:
|
||||
orig = (ORIG / rel).resolve()
|
||||
if orig.exists():
|
||||
note = (
|
||||
"IDENTICAL to original"
|
||||
if orig.read_bytes() == content
|
||||
else f"DIFFERS from original ({orig.stat().st_size} bytes)"
|
||||
)
|
||||
else:
|
||||
note = "no original"
|
||||
print(f" {rel}: {len(content)} bytes {note}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Press a template button on the test device over the API, or list its buttons.
|
||||
|
||||
usage: api_button.py HOST KEY list | press "Button name" [...] (KEY of - means a plaintext API)
|
||||
usage: api_button.py HOST KEY list | press "Button name" [...]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -13,7 +13,7 @@ CMD, ARGS = sys.argv[3], sys.argv[4:]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||
await c.connect(login=True)
|
||||
ents, _ = await c.list_entities_services()
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Match each proxy disconnect with the peer's own disconnect line and count the links the peer kept.
|
||||
Usage: an.py DIR LABEL [ROUNDS] reads LABEL-drive.txt, LABEL-peer.txt (serial_capture --ms) and LABEL-proxy.txt
|
||||
(esphome logs, VERBOSE). A round counts only when the last `Conn params updated` the proxy logged since the previous
|
||||
disconnect shows latency=10; otherwise it is inconclusive. Exits 1 when the peer kept a link past 3 s (the terminate
|
||||
was lost), a round was inconclusive, or fewer than ROUNDS rounds completed."""
|
||||
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
D, label = Path(sys.argv[1]), sys.argv[2]
|
||||
ROUNDS = int(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||
|
||||
|
||||
def read(name: str) -> list[str]:
|
||||
return (
|
||||
(D / f"{label}-{name}.txt")
|
||||
.read_text(encoding="utf-8", errors="replace")
|
||||
.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def ptime(line: str) -> float | None:
|
||||
"""Seconds since midnight from a leading [HH:MM:SS.mmm] stamp."""
|
||||
m = re.match(r"\[(\d+):(\d+):(\d+)\.(\d+)\]", line)
|
||||
return (
|
||||
int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 1000 if m else None
|
||||
)
|
||||
|
||||
|
||||
pdisc = [
|
||||
t
|
||||
for line in read("peer")
|
||||
if "PEER DISCONNECT" in line and (t := ptime(line)) is not None
|
||||
]
|
||||
params = [
|
||||
(t, int(m[1]))
|
||||
for line in read("proxy")
|
||||
if "Conn params updated" in line
|
||||
and (m := re.search(r"latency=(\d+)", line))
|
||||
and (t := ptime(line)) is not None
|
||||
]
|
||||
res = []
|
||||
prev = -1.0
|
||||
for line in read("drive"):
|
||||
m = re.search(
|
||||
r"R(\d+) .*DISC_CALL ([\d.]+) DISC_DONE ([\d.]+) \(\+([\d.]+)s\)", line
|
||||
)
|
||||
if not m:
|
||||
if line.startswith("R"):
|
||||
print(" ", line)
|
||||
continue
|
||||
# Both captures stamp local wall clock time; compare as seconds since midnight.
|
||||
t = dt.datetime.fromtimestamp(float(m[2])).astimezone()
|
||||
tc = t.hour * 3600 + t.minute * 60 + t.second + t.microsecond / 1e6
|
||||
after = [p - tc for p in pdisc if -1 < p - tc < 12]
|
||||
window = [lat for pt, lat in params if prev < pt <= tc]
|
||||
res.append(
|
||||
(
|
||||
int(m[1]),
|
||||
float(m[4]),
|
||||
after[0] if after else None,
|
||||
window[-1] if window else None,
|
||||
)
|
||||
)
|
||||
prev = tc
|
||||
for r, done, pd, lat in res:
|
||||
peer = (
|
||||
f"peer disconnect +{pd:.2f}s" if pd is not None else "peer disconnect not seen"
|
||||
)
|
||||
print(f" R{r:2} central done +{done:.3f}s {peer} latency at disconnect {lat}")
|
||||
late = sum(1 for _, _, pd, lat in res if lat == 10 and (pd is None or pd > 3))
|
||||
inconclusive = sum(1 for *_, lat in res if lat != 10)
|
||||
print(
|
||||
f"{label}: rounds {len(res)}, peer kept the link (>3 s or not seen) {late}, inconclusive {inconclusive}"
|
||||
)
|
||||
failed = []
|
||||
if not res:
|
||||
failed.append("no round completed")
|
||||
if ROUNDS is not None and len(res) < ROUNDS:
|
||||
failed.append(f"only {len(res)} of {ROUNDS} rounds completed")
|
||||
if late:
|
||||
failed.append(f"{late} links kept")
|
||||
if inconclusive:
|
||||
failed.append(
|
||||
f"INCONCLUSIVE: {inconclusive} rounds had no latency=10 update before the disconnect"
|
||||
)
|
||||
print(f"PASS: {label}" if not failed else f"FAIL: {label}: " + "; ".join(failed))
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Connect to the peer through the proxy, read its services, idle, disconnect; print host timestamps per round.
|
||||
Usage: btdrive.py PROXY KEY PEER_MAC ROUNDS IDLE_S"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError
|
||||
|
||||
|
||||
async def main(host: str, key: str, peer: str, rounds: int, idle: float) -> int:
|
||||
addr = int(peer.replace(":", ""), 16)
|
||||
cli = APIClient(host, 6053, None, noise_psk=key)
|
||||
await cli.connect(login=True)
|
||||
info = await cli.device_info()
|
||||
print(f"proxy {info.name} bt_mac {info.bluetooth_mac_address}", flush=True)
|
||||
seen = 0
|
||||
|
||||
def adv(msg) -> None:
|
||||
nonlocal seen
|
||||
seen += sum(a.address == addr for a in msg.advertisements)
|
||||
|
||||
# The proxy only connects to a device its scanner has seen.
|
||||
cli.subscribe_bluetooth_le_raw_advertisements(adv)
|
||||
await asyncio.sleep(5)
|
||||
print(f"peer advertisements seen in 5 s: {seen}", flush=True)
|
||||
if not seen:
|
||||
print("FAIL: the proxy does not see the peer", flush=True)
|
||||
await cli.disconnect()
|
||||
return 1
|
||||
done = 0
|
||||
for r in range(1, rounds + 1):
|
||||
state: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
def cb(
|
||||
connected: bool, mtu: int, err: int, state: asyncio.Queue = state
|
||||
) -> None:
|
||||
state.put_nowait((connected, err, time.time()))
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
cancel = await cli.bluetooth_device_connect(
|
||||
addr, cb, timeout=20, address_type=0, feature_flags=0xFF
|
||||
)
|
||||
except (APIConnectionError, TimeoutError) as e:
|
||||
print(f"R{r} CONNECT ERROR {type(e).__name__}", flush=True)
|
||||
await asyncio.sleep(9)
|
||||
continue
|
||||
try:
|
||||
c, err, t = await asyncio.wait_for(state.get(), 25)
|
||||
except TimeoutError:
|
||||
print(f"R{r} CONNECT TIMEOUT", flush=True)
|
||||
cancel()
|
||||
await asyncio.sleep(8)
|
||||
continue
|
||||
if not c:
|
||||
print(f"R{r} CONNECT FAIL err {err} after {t - t0:.2f}s", flush=True)
|
||||
cancel()
|
||||
await asyncio.sleep(8)
|
||||
continue
|
||||
try:
|
||||
n = len((await cli.bluetooth_gatt_get_services(addr)).services)
|
||||
except (APIConnectionError, TimeoutError) as e:
|
||||
n = f"err {e!r}"
|
||||
await asyncio.sleep(idle)
|
||||
td = time.time()
|
||||
await cli.bluetooth_device_disconnect(addr)
|
||||
try:
|
||||
c2, err2, t2 = await asyncio.wait_for(state.get(), 15)
|
||||
if c2 or not isinstance(n, int):
|
||||
# an unexpected connect state or a failed service read: the round did not run as intended
|
||||
print(
|
||||
f"R{r} BAD ROUND services {n} connected {c2} err {err2}", flush=True
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"R{r} services {n} DISC_CALL {td:.3f} DISC_DONE {t2:.3f} (+{t2 - td:.3f}s) err {err2}",
|
||||
flush=True,
|
||||
)
|
||||
done += 1
|
||||
except TimeoutError:
|
||||
print(
|
||||
f"R{r} services {n} DISC_CALL {td:.3f} no disconnect state", flush=True
|
||||
)
|
||||
cancel()
|
||||
await asyncio.sleep(
|
||||
9
|
||||
) # a peer that missed the terminate hits its 6 s supervision timeout in this gap
|
||||
await cli.disconnect()
|
||||
print(f"rounds completed {done} of {rounds}", flush=True)
|
||||
return 0 if done == rounds else 1
|
||||
|
||||
|
||||
sys.exit(
|
||||
asyncio.run(
|
||||
main(
|
||||
sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), float(sys.argv[5])
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
#pragma once
|
||||
#include <cstring>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include "esphome/core/log.h"
|
||||
#include "central_bda.hpp" // written by run.sh from PROXY_BT_MAC
|
||||
|
||||
// 50 ms interval, peripheral latency 10, 6 s supervision timeout
|
||||
inline void btpeer_request_latency() {
|
||||
esp_ble_conn_update_params_t p{};
|
||||
memcpy(p.bda, CENTRAL_BDA, 6);
|
||||
p.min_int = 40;
|
||||
p.max_int = 40;
|
||||
p.latency = 10;
|
||||
p.timeout = 600;
|
||||
esp_err_t err = esp_ble_gap_update_conn_params(&p);
|
||||
ESP_LOGI("peer", "requested interval 50 ms latency 10 timeout 6 s: %d", err);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
esphome:
|
||||
name: btpeer
|
||||
includes:
|
||||
- btpeer.hpp
|
||||
- central_bda.hpp
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble: VERBOSE
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
|
||||
esp32_ble:
|
||||
max_connections: 1
|
||||
|
||||
esp32_ble_server:
|
||||
manufacturer: test
|
||||
on_connect:
|
||||
- lambda: ESP_LOGI("peer", "PEER CONNECT conn %u", (unsigned) id);
|
||||
- delay: 4s
|
||||
- lambda: btpeer_request_latency();
|
||||
on_disconnect:
|
||||
- lambda: ESP_LOGI("peer", "PEER DISCONNECT conn %u", (unsigned) id);
|
||||
services:
|
||||
- uuid: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
|
||||
advertise: true
|
||||
characteristics:
|
||||
- uuid: 6e400002-b5a3-f393-e0a9-e50e24dcca9e
|
||||
read: true
|
||||
value: "peer"
|
||||
@@ -1,44 +0,0 @@
|
||||
substitutions:
|
||||
name: btproxy
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
level: VERBOSE
|
||||
logs:
|
||||
scheduler: DEBUG
|
||||
component: DEBUG
|
||||
esp32_ble_tracker: DEBUG
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
# usage: run.sh LABEL ROUNDS build both boards, then ROUNDS connect/disconnect rounds through the proxy
|
||||
# Copy this directory into a scratch directory with a secrets.yaml (api_key, base64 Noise key) and run it there.
|
||||
# env: TREE (esphome checkout to build the proxy from), PORT (peer devkit serial port), PROXY (proxy address, flashed
|
||||
# over the air; the first flash of a fresh board goes over USB), PROXY_BT_MAC (the proxy's Bluetooth MAC, printed by
|
||||
# btdrive.py as bt_mac), PEER_MAC (the peer's Bluetooth MAC, base MAC + 2 on a classic ESP32), SC (path to
|
||||
# serial_capture.py), PY (checkout python), PEER_TREE (checkout for the peer, defaults to TREE)
|
||||
D=$(cd "$(dirname "$0")" && pwd); L=${1:?label}; N=${2:-12}; T=${TREE:?set TREE}; PT=${PEER_TREE:-$T}
|
||||
P=${PORT:?set PORT}; S3=${PROXY:?set PROXY}; SC=${SC:?set SC}; PY=${PY:-$T/venv/bin/python}
|
||||
PEER=${PEER_MAC:?set PEER_MAC}; CM=${PROXY_BT_MAC:?set PROXY_BT_MAC}
|
||||
KEY=$(sed -n 's/^api_key: *"\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$D/secrets.yaml")
|
||||
[ -n "$KEY" ] || { echo "FAIL: no api_key in secrets.yaml"; exit 1; }
|
||||
cd "$D" || exit 1
|
||||
printf '#pragma once\n#include <cstdint>\nstatic const uint8_t CENTRAL_BDA[6] = {0x%s};\n' "${CM//:/, 0x}" > central_bda.hpp
|
||||
(cd "$PT" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btpeer.yaml" --device "$P" > "$D/$L-peer-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: peer build or upload failed"; exit 1; }
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 timeout 1500 $PY -m esphome run --no-logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy-upload.log" 2>&1) \
|
||||
|| { echo "FAIL: proxy build or upload failed"; exit 1; }
|
||||
SECS=$((N * 26 + 40))
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
$PY "$SC" "$P" $SECS "$D/$L-peer.txt" --ms > /dev/null 2>&1 & CAP=$!
|
||||
(cd "$T" && PYTHONUNBUFFERED=1 exec timeout $((SECS - 10)) $PY -m esphome logs "$D/btproxy.yaml" --device "$S3" > "$D/$L-proxy.txt" 2>&1) & LOGS=$!
|
||||
trap 'kill $CAP $LOGS 2>/dev/null' EXIT
|
||||
sleep 6
|
||||
$PY "$D/btdrive.py" "$S3" "$KEY" "$PEER" "$N" 10 > "$D/$L-drive.txt" 2>&1; RC=$?
|
||||
sleep 10
|
||||
kill $CAP $LOGS 2>/dev/null; wait 2>/dev/null; trap - EXIT
|
||||
[ -s "$D/$L-peer.txt" ] || { tail -3 "$D/$L-drive.txt"; echo "FAIL: peer capture got nothing"; exit 1; }
|
||||
$PY "$D/an.py" "$D" "$L" "$N"; ARC=$?
|
||||
[ "$RC" = 0 ] || { tail -1 "$D/$L-drive.txt"; echo "FAIL: driver rc $RC (not every round completed)"; exit 1; }
|
||||
exit $ARC
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/sh
|
||||
# cases.sh DEV_IP KEY CAPTURE LABEL : the SEN0395 flow (settings, switches, junk lines, factory reset, reset) against
|
||||
# templates/dfrobot-sen0395-dut.yaml, with a serial capture of the device running into CAPTURE
|
||||
D=$1; K=$2; LOG=$3; LABEL=$4; PY=${PYTHON:-python3}; CTL="$(dirname "$0")/ctl.py"
|
||||
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a 'dfrobot\|Detected\|\[W\]\|\[E\]\|sen\]' | grep -a -v 'JYBSS\|took a long time' | cut -c1-200; }
|
||||
step() { n0=$(wc -l < "$LOG"); echo "=== $LABEL: $1 ($(date +%T))"; shift; $PY "$CTL" "$D" "$K" "$@" || { rc=$?; echo "driver failed ($rc), aborting"; exit $rc; }; echo "--- device log:"; show "$n0"; }
|
||||
frames() { echo "frames: $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,1') present, $(tail -n +$(($1+1)) "$LOG" | grep -a -c 'JYBSS,0') absent"; }
|
||||
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames ($(date +%T))"; sleep 20; frames "$n0"
|
||||
step "range 0 to 3 m" "press:Range 0 to 3m" wait:12
|
||||
step "range 0 to 9 m" "press:Range 0 to 9m" wait:12
|
||||
step "latency 1 s / 2 s" "press:Latency 1s 2s" wait:12
|
||||
step "latency 0 / 0" "press:Latency 0 0" wait:12
|
||||
step "sensitivity 7" "press:Sensitivity 7" wait:12
|
||||
step "LED off then on" "sw:LED=off" wait:10 "sw:LED=on" wait:10
|
||||
step "sensor active off then on" "sw:Sensor active=off" wait:10 "sw:Sensor active=on" wait:10
|
||||
step "presence via UART off then on" "sw:Presence via UART=off" wait:10 "sw:Presence via UART=on" wait:10
|
||||
step "start after boot off then on" "sw:Start after boot=off" wait:10 "sw:Start after boot=on" wait:10
|
||||
for n in 100 160 200 220 240 256; do step "junk line of $n bytes" "press:Junk $n" wait:7; done
|
||||
step "junk 220 then a command" "press:Junk 220" wait:6 "press:Range 0 to 9m" wait:12
|
||||
step "factory reset" "press:Factory reset" wait:15
|
||||
step "reset" "press:Reset" wait:15
|
||||
n0=$(wc -l < "$LOG"); echo "=== $LABEL: 20 s of frames after ($(date +%T))"; sleep 20; frames "$n0"
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Drive the SEN0395 test device over the API and print its binary sensor and switch updates.
|
||||
usage: ctl.py HOST KEY(- for plaintext) OP [OP ...] OP: press:NAME | sw:NAME=on|off | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
BinarySensorState,
|
||||
ButtonInfo,
|
||||
SwitchInfo,
|
||||
SwitchState,
|
||||
)
|
||||
|
||||
HOST, KEY = sys.argv[1], sys.argv[2]
|
||||
OPS = sys.argv[3:]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
await c.connect(login=True)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
switches = {e.name: e for e in ents if isinstance(e, SwitchInfo)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if isinstance(s, BinarySensorState):
|
||||
print(
|
||||
f"{time.time() - T0:7.3f} BIN {e.name} = {'ON' if s.state else 'off'}"
|
||||
)
|
||||
elif isinstance(s, SwitchState):
|
||||
print(
|
||||
f"{time.time() - T0:7.3f} SW {e.name} = {'on' if s.state else 'off'}"
|
||||
)
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
await asyncio.sleep(1.0)
|
||||
for op in OPS:
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
elif kind == "press":
|
||||
if rest not in buttons:
|
||||
sys.exit(f"no button {rest!r}; have {sorted(buttons)}")
|
||||
print(f"{time.time() - T0:7.3f} >>> press {rest}")
|
||||
c.button_command(buttons[rest].key)
|
||||
elif kind == "sw":
|
||||
name, _, val = rest.partition("=")
|
||||
if name not in switches:
|
||||
sys.exit(f"no switch {name!r}; have {sorted(switches)}")
|
||||
print(f"{time.time() - T0:7.3f} >>> switch {name} {val}")
|
||||
c.switch_command(switches[name].key, val == "on")
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,56 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,37 +0,0 @@
|
||||
"""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}")
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,99 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Held-report cases for esphome#21144 with persistent API connections, so the steps land inside the 3 s fade.
|
||||
usage: heldcase.py DEV_IP KEY BULB_IP same|many [BULB_KEY] (KEY or BULB_KEY of - means plaintext)
|
||||
t0 press "A 50 fade 3s" on the mirror device (3 s fade to 50%)
|
||||
t0+0.7 set the bulb directly to 90% (Home Assistant reports it to the mirror during the fade: held)
|
||||
t0+1.4 same: press "A 50 fade 3s" again (same target as the running fade)
|
||||
many: press "A many calls" (16 calls in one loop pass, the last one 100% with a 1 s fade)
|
||||
t0+6.5 read the bulb, press Log state so the mirror prints its state
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, ButtonInfo, LightInfo, LightState
|
||||
|
||||
DEV, KEY, BULB, CASE = sys.argv[1:5]
|
||||
BULB_KEY = sys.argv[5] if len(sys.argv) > 5 else "-"
|
||||
if CASE not in ("same", "many"):
|
||||
sys.exit(f"case must be same or many, got {CASE!r}")
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:6.3f}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
dev = APIClient(DEV, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
bulb = APIClient(BULB, 6053, None, noise_psk=None if BULB_KEY == "-" else BULB_KEY)
|
||||
await dev.connect(login=True)
|
||||
await bulb.connect(login=True)
|
||||
dents, _ = await dev.list_entities_services()
|
||||
bents, _ = await bulb.list_entities_services()
|
||||
buttons = {e.name: e for e in dents if isinstance(e, ButtonInfo)}
|
||||
blight = next(e for e in bents if isinstance(e, LightInfo))
|
||||
states = {}
|
||||
|
||||
def on_bulb(s) -> None:
|
||||
if isinstance(s, LightState) and s.key == blight.key:
|
||||
states["bulb"] = s
|
||||
print(f"{stamp()} bulb state: on={s.state} bri={s.brightness:.2f}")
|
||||
|
||||
bulb.subscribe_states(on_bulb)
|
||||
await asyncio.sleep(1.0)
|
||||
print(f"{stamp()} >>> bulb to 100% (precondition)")
|
||||
bulb.light_command(blight.key, state=True, brightness=1.0)
|
||||
await asyncio.sleep(4.0)
|
||||
t = time.time()
|
||||
print(f"{stamp()} >>> t0 press A 50 fade 3s")
|
||||
dev.button_command(buttons["A 50 fade 3s"].key)
|
||||
await asyncio.sleep(0.7 - (time.time() - t))
|
||||
print(f"{stamp()} >>> bulb direct to 90%")
|
||||
bulb.light_command(blight.key, state=True, brightness=0.9)
|
||||
await asyncio.sleep(1.4 - (time.time() - t))
|
||||
name = "A 50 fade 3s" if CASE == "same" else "A many calls"
|
||||
print(f"{stamp()} >>> press {name}")
|
||||
dev.button_command(buttons[name].key)
|
||||
await asyncio.sleep(6.5 - (time.time() - t))
|
||||
print(f"{stamp()} >>> press Log state (the mirror's state is in the device log)")
|
||||
dev.button_command(buttons["Log state"].key)
|
||||
await asyncio.sleep(1.0)
|
||||
b = states.get("bulb")
|
||||
print(
|
||||
f"{stamp()} RESULT bulb "
|
||||
+ (f"bri={b.brightness:.2f}" if b is not None else "no state")
|
||||
)
|
||||
await dev.disconnect()
|
||||
await bulb.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Run the burst action once and print each queued request as it completes.
|
||||
Usage: burstonly.py IP URL N"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, URL, N = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||
|
||||
|
||||
async def main():
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
_, svcs = await c.list_entities_services()
|
||||
s = {x.name: x for x in svcs}
|
||||
t0 = time.monotonic()
|
||||
done = []
|
||||
errors = []
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if "BURSTRESP status=200 " in t:
|
||||
done.append(t)
|
||||
elif "BURSTRESP" in t or "BURSTERR" in t:
|
||||
errors.append(t)
|
||||
if "hrt" in t or "Too many" in t:
|
||||
print(f"{time.monotonic() - t0:6.2f} {t[:120]}", flush=True)
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_INFO)
|
||||
await asyncio.sleep(1)
|
||||
t0 = time.monotonic()
|
||||
await c.execute_service(s["burst"], {"url": URL, "count": N})
|
||||
await asyncio.sleep(14)
|
||||
await c.disconnect()
|
||||
print(
|
||||
f"{len(done)} of {N} burst requests answered, {len(errors)} failed", flush=True
|
||||
)
|
||||
return 0 if len(done) >= N else 1
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Drive the http_request action test device over the API and collect its hrt log lines.
|
||||
Usage: hrdrive.py DEVICE_IP SERVER_BASE LABEL [https]"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, BASE, LABEL = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
HTTPS = len(sys.argv) > 4
|
||||
CASES = [
|
||||
("get", "fast", BASE + "/fast"),
|
||||
("get", "slow2s", BASE + "/slow?d=2"),
|
||||
("get", "big70k", BASE + "/big?n=70000&rate=20000"),
|
||||
("get", "chunked70k", BASE + "/chunked?n=70000&rate=20000"),
|
||||
("post", "post500", BASE + "/post"),
|
||||
("get", "redirect", BASE + "/redirect"),
|
||||
("get", "hang", BASE + "/hang?d=20"),
|
||||
("get", "unreachable", "http://192.0.2.1:81/"),
|
||||
]
|
||||
if HTTPS:
|
||||
CASES += [
|
||||
("get", "https_get", "https://www.howsmyssl.com/a/check"),
|
||||
("post", "https_post", "https://httpbin.org/post"),
|
||||
]
|
||||
|
||||
ONLY = os.environ.get("ONLY")
|
||||
if ONLY:
|
||||
CASES = [c for c in CASES if c[1] in ONLY.split(",")]
|
||||
ROUNDS = int(os.environ.get("ROUNDS", "2"))
|
||||
# cases whose request must fail (past the 8 s timeout, or an address that never answers)
|
||||
EXPECT_ERROR = {"hang", "unreachable"}
|
||||
EXTRAS = not ONLY
|
||||
|
||||
|
||||
timeouts: list[str] = []
|
||||
mismatches: list[str] = []
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
_, services = await c.list_entities_services()
|
||||
svc = {s.name: s for s in services}
|
||||
lines = []
|
||||
|
||||
def on_log(m):
|
||||
t = (
|
||||
m.message.decode(errors="replace")
|
||||
if isinstance(m.message, bytes)
|
||||
else m.message
|
||||
)
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", t)
|
||||
if (
|
||||
"[hrt" in t
|
||||
or "http_request" in t
|
||||
or "Script" in t
|
||||
or "already running" in t
|
||||
):
|
||||
lines.append(t)
|
||||
print(f"{LABEL} {t}", flush=True)
|
||||
|
||||
def bad_burst(ln: str) -> bool:
|
||||
return "BURSTERR" in ln or ("BURSTRESP" in ln and "status=200 " not in ln)
|
||||
|
||||
def mismatch(what: str, why: str) -> None:
|
||||
print(f"{LABEL} {what} MISMATCH: {why}", flush=True)
|
||||
mismatches.append(what)
|
||||
|
||||
async def wait_until(done, secs: float, what: str) -> None:
|
||||
"""Poll done() until it holds; print a TIMEOUT marker when the deadline passes first."""
|
||||
end = time.monotonic() + secs
|
||||
while not done():
|
||||
if time.monotonic() >= end:
|
||||
print(f"{LABEL} {what} TIMEOUT", flush=True)
|
||||
timeouts.append(what)
|
||||
return
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(1)
|
||||
for rnd in range(1, ROUNDS + 1):
|
||||
for kind, name, url in CASES:
|
||||
n0 = len(lines)
|
||||
print(f"{LABEL} == {name} round {rnd}", flush=True)
|
||||
if kind == "get":
|
||||
await c.execute_service(svc["req_get"], {"url": url})
|
||||
else:
|
||||
await c.execute_service(
|
||||
svc["req_post"], {"url": url, "payload": "x" * 500}
|
||||
)
|
||||
await wait_until(
|
||||
lambda n0=n0: any("RESULT" in ln for ln in lines[n0:]),
|
||||
40,
|
||||
f"{name} round {rnd}",
|
||||
)
|
||||
result = next((ln for ln in lines[n0:] if "RESULT" in ln), None)
|
||||
if result is not None and (
|
||||
("status=200 " not in result)
|
||||
if name not in EXPECT_ERROR
|
||||
else (" error " not in result)
|
||||
):
|
||||
mismatch(f"{name} round {rnd}", result)
|
||||
await asyncio.sleep(1.5)
|
||||
if not EXTRAS:
|
||||
continue
|
||||
print(f"{LABEL} == burst round {rnd}", flush=True)
|
||||
n0 = len(lines)
|
||||
await c.execute_service(svc["burst"], {"url": BASE + "/slow?d=1", "count": 6})
|
||||
await wait_until(
|
||||
lambda n0=n0: (
|
||||
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) >= 6
|
||||
),
|
||||
30,
|
||||
f"burst round {rnd}",
|
||||
)
|
||||
if any(bad_burst(ln) for ln in lines[n0:]):
|
||||
mismatch(f"burst round {rnd}", "a burst request failed or was not 200")
|
||||
await asyncio.sleep(2)
|
||||
print(f"{LABEL} == cancel round {rnd}", flush=True)
|
||||
n0 = len(lines)
|
||||
await c.execute_service(svc["cancel"], {"url": BASE + "/slow?d=2"})
|
||||
await asyncio.sleep(9)
|
||||
if not any("CANCEL stopped running=0" in ln for ln in lines[n0:]):
|
||||
mismatch(
|
||||
f"cancel round {rnd}", "script.stop did not leave the script stopped"
|
||||
)
|
||||
print(f"{LABEL} == cross round {rnd}", flush=True)
|
||||
n0 = len(lines)
|
||||
await c.execute_service(svc["req_get"], {"url": BASE + "/slow?d=2&tag=cross"})
|
||||
await c.execute_service(
|
||||
svc["burst"], {"url": BASE + "/slow?d=1&tag=crossburst", "count": 3}
|
||||
)
|
||||
await wait_until(
|
||||
lambda n0=n0: (
|
||||
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) >= 3
|
||||
and any("RESULT" in ln for ln in lines[n0:])
|
||||
),
|
||||
30,
|
||||
f"cross round {rnd}",
|
||||
)
|
||||
if any(
|
||||
bad_burst(ln) or ("RESULT" in ln and "status=200 " not in ln)
|
||||
for ln in lines[n0:]
|
||||
):
|
||||
mismatch(f"cross round {rnd}", "a request failed")
|
||||
await asyncio.sleep(2)
|
||||
await c.disconnect()
|
||||
if timeouts:
|
||||
print(f"{LABEL} TIMEOUTS: {', '.join(timeouts)}", flush=True)
|
||||
if mismatches:
|
||||
print(f"{LABEL} MISMATCHES: {', '.join(mismatches)}", flush=True)
|
||||
return 1 if timeouts or mismatches else 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Test server for http_request actions: deterministic bodies, slow and chunked responses."""
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
def body_bytes(n):
|
||||
return bytes((i * 7 + 3) & 0xFF for i in range(n))
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *a):
|
||||
sys.stdout.write(f"{time.time():.3f} {self.client_address[0]} {fmt % a}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def _q(self):
|
||||
u = urlparse(self.path)
|
||||
return u.path, {k: v[0] for k, v in parse_qs(u.query).items()}
|
||||
|
||||
def _send(self, data, code=200, ctype="application/octet-stream"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
got = self.rfile.read(n)
|
||||
self._send(
|
||||
b"posted %d %d" % (len(got), sum(got) & 0xFFFFFFFF), ctype="text/plain"
|
||||
)
|
||||
|
||||
def do_GET(self):
|
||||
path, q = self._q()
|
||||
if path == "/fast":
|
||||
self._send(b"hello", ctype="text/plain")
|
||||
elif path == "/slow":
|
||||
time.sleep(float(q.get("d", "2")))
|
||||
self._send(b"slow ok", ctype="text/plain")
|
||||
elif path == "/redirect":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/fast")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
elif path == "/hang":
|
||||
time.sleep(float(q.get("d", "30")))
|
||||
elif path in ("/big", "/chunked"):
|
||||
n = int(q.get("n", "70000"))
|
||||
rate = float(q.get("rate", "20000"))
|
||||
data = body_bytes(n)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
if path == "/big":
|
||||
self.send_header("Content-Length", str(n))
|
||||
else:
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
step = 1000
|
||||
for i in range(0, n, step):
|
||||
part = data[i : i + step]
|
||||
if path == "/big":
|
||||
self.wfile.write(part)
|
||||
else:
|
||||
self.wfile.write(b"%x\r\n" % len(part) + part + b"\r\n")
|
||||
self.wfile.flush()
|
||||
time.sleep(len(part) / rate)
|
||||
if path == "/chunked":
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
else:
|
||||
self._send(b"nope", 404)
|
||||
|
||||
|
||||
ThreadingHTTPServer(("0.0.0.0", int(sys.argv[1])), H).serve_forever()
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Serve update manifests: stable.json, beta.json and slow.json (answered after a delay); logs every request."""
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
PORT = int(sys.argv[1])
|
||||
DELAY = float(sys.argv[2]) if len(sys.argv) > 2 else 4.0
|
||||
|
||||
|
||||
def manifest(name: str, version: str) -> bytes:
|
||||
builds = [
|
||||
{
|
||||
"chipFamily": fam,
|
||||
"ota": {
|
||||
"path": f"/{name}.bin",
|
||||
"md5": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"summary": f"{name} channel",
|
||||
},
|
||||
}
|
||||
for fam in ("ESP8266", "ESP32")
|
||||
]
|
||||
return json.dumps(
|
||||
{"name": f"Bench {name}", "version": version, "builds": builds}
|
||||
).encode()
|
||||
|
||||
|
||||
DOCS = {
|
||||
"/stable.json": manifest("stable", "1.0.0"),
|
||||
"/beta.json": manifest("beta", "2.0.0-beta1"),
|
||||
"/slow.json": manifest("slow", "3.0.0-slow"),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
body = DOCS.get(self.path)
|
||||
print(
|
||||
f"{time.strftime('%H:%M:%S')} {self.client_address[0]} GET {self.path} -> {404 if body is None else 200}",
|
||||
flush=True,
|
||||
)
|
||||
if body is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
if self.path == "/slow.json":
|
||||
time.sleep(DELAY)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args) -> None: # quiet default log
|
||||
return
|
||||
|
||||
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
@@ -1,10 +0,0 @@
|
||||
esphome:
|
||||
name: ho32base
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
@@ -1,11 +0,0 @@
|
||||
esphome:
|
||||
name: ho66base
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Serve a www dir for ota.http_request tests; /auth/ needs basic auth otauser:otapass.
|
||||
Usage: fwsrv.py WWW PORT"""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import http.server
|
||||
import sys
|
||||
|
||||
WWW, PORT = sys.argv[1], int(sys.argv[2])
|
||||
GOOD = "Basic " + base64.b64encode(b"otauser:otapass").decode()
|
||||
|
||||
|
||||
class H(http.server.SimpleHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/auth/") and self.headers.get("Authorization") != GOOD:
|
||||
self.send_response(401)
|
||||
self.send_header("WWW-Authenticate", 'Basic realm="ota"')
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(
|
||||
f"{self.address_string()} auth={'Authorization' in self.headers} {fmt % args}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
http.server.ThreadingHTTPServer(
|
||||
("0.0.0.0", PORT), functools.partial(H, directory=WWW)
|
||||
).serve_forever()
|
||||
@@ -1,60 +0,0 @@
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
actions:
|
||||
- action: fl_md5url
|
||||
variables: {url: string, md5url: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5_url: !lambda return md5url;
|
||||
- action: fl_md5
|
||||
variables: {url: string, sum: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5: !lambda return sum;
|
||||
- action: fl_auth
|
||||
variables: {url: string, md5url: string, user: string, pw: string}
|
||||
then:
|
||||
- ota.http_request.flash:
|
||||
url: !lambda return url;
|
||||
md5_url: !lambda return md5url;
|
||||
username: !lambda return user;
|
||||
password: !lambda return pw;
|
||||
- action: fl_nourl
|
||||
then:
|
||||
- lambda: id(hota).flash();
|
||||
- action: fl_badurl
|
||||
then:
|
||||
- lambda: |-
|
||||
id(hota).set_url("not a url");
|
||||
id(hota).flash();
|
||||
- action: getters
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("hot", "getters computed='%s' expected='%s'", id(hota).md5_computed().c_str(),
|
||||
id(hota).md5_expected().c_str());
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("hot", "boot getters computed='%s' expected='%s'", id(hota).md5_computed().c_str(),
|
||||
id(hota).md5_expected().c_str());
|
||||
|
||||
http_request:
|
||||
timeout: 10s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
- platform: http_request
|
||||
id: hota
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Drive the ota.http_request cases over the API.
|
||||
Usage: hodrive.py IP BASEURL MD5FILE (BASEURL serves fw.bin, fw.md5, bad.md5, auth/fw.bin, auth/fw.md5)"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(__doc__)
|
||||
IP, BASE = sys.argv[1:3]
|
||||
SUM = pathlib.Path(sys.argv[3]).read_text(encoding="utf-8").strip()
|
||||
T0 = time.monotonic()
|
||||
lines: list[str] = []
|
||||
failed: list[str] = []
|
||||
|
||||
|
||||
def out(s: str) -> None:
|
||||
print(f"{time.monotonic() - T0:7.2f} {s}", flush=True)
|
||||
|
||||
|
||||
async def connect() -> tuple[aioesphomeapi.APIClient, dict]:
|
||||
last: Exception | None = None
|
||||
for _ in range(90):
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
try:
|
||||
await c.connect(login=True)
|
||||
except (
|
||||
aioesphomeapi.InvalidAuthAPIError,
|
||||
aioesphomeapi.InvalidEncryptionKeyAPIError,
|
||||
aioesphomeapi.RequiresEncryptionAPIError,
|
||||
):
|
||||
raise # a key or password problem never fixes itself
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
i = await c.device_info()
|
||||
out(f"connected {i.name} {i.compilation_time}")
|
||||
_, svcs = await c.list_entities_services()
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if re.search(r"\[hot|http_request|ota|OTA|MD5|md5", t):
|
||||
lines.append(t)
|
||||
out(t[:170])
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
return c, {x.name: x for x in svcs}
|
||||
raise SystemExit(f"device never came back (last error {last!r})")
|
||||
|
||||
|
||||
async def case(c, s, name, args, want, timeout=60.0):
|
||||
out(f"=== {name} {args} (want {want})")
|
||||
start = len(lines)
|
||||
await c.execute_service(s[name], args)
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if any(re.search(want, x) for x in lines[start:]):
|
||||
out(f"RESULT {name} PASS")
|
||||
return True
|
||||
await asyncio.sleep(0.2)
|
||||
out(f"RESULT {name} FAIL (no '{want}')")
|
||||
failed.append(name)
|
||||
return False
|
||||
|
||||
|
||||
async def flash_ok(c, s, name, args):
|
||||
"""A good flash: wait for the device to drop and come back."""
|
||||
out(f"=== {name} {args} (want reboot)")
|
||||
start = len(lines)
|
||||
await c.execute_service(s[name], args)
|
||||
end = time.monotonic() + 120
|
||||
while (
|
||||
time.monotonic() < end
|
||||
and c._connection is not None
|
||||
and c._connection.is_connected
|
||||
): # noqa: SLF001
|
||||
await asyncio.sleep(0.5)
|
||||
# a good flash needs both the device's own success line and the connection dropping for the reboot
|
||||
dropped = c._connection is None or not c._connection.is_connected # noqa: SLF001
|
||||
logged = any(
|
||||
re.search(r"(?i)update complete|rebooting|successful", x) for x in lines[start:]
|
||||
)
|
||||
if not dropped:
|
||||
out(f"{name}: the device never dropped the connection within 120 s")
|
||||
ok = logged and dropped
|
||||
await c.disconnect()
|
||||
await asyncio.sleep(5)
|
||||
c, s = await connect()
|
||||
out(f"RESULT {name} {'PASS' if ok else 'FAIL'}")
|
||||
if not ok:
|
||||
failed.append(name)
|
||||
return c, s
|
||||
|
||||
|
||||
async def main():
|
||||
c, s = await connect()
|
||||
u, m = f"{BASE}/fw.bin", f"{BASE}/fw.md5"
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await case(c, s, "fl_nourl", {}, r"URL not set")
|
||||
await case(c, s, "fl_badurl", {}, r"URL not set")
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await case(c, s, "fl_md5", {"url": u, "sum": "0" * 32}, r"MD5 mismatch")
|
||||
await case(c, s, "getters", {}, r"getters computed=")
|
||||
await case(
|
||||
c,
|
||||
s,
|
||||
"fl_auth",
|
||||
{
|
||||
"url": f"{BASE}/auth/fw.bin",
|
||||
"md5url": f"{BASE}/auth/fw.md5",
|
||||
"user": "otauser",
|
||||
"pw": "wrong",
|
||||
},
|
||||
r"HTTP Request failed; URL: \S*/auth/fw\.md5; Code: 401",
|
||||
)
|
||||
c, s = await flash_ok(
|
||||
c,
|
||||
s,
|
||||
"fl_auth",
|
||||
{
|
||||
"url": f"{BASE}/auth/fw.bin",
|
||||
"md5url": f"{BASE}/auth/fw.md5",
|
||||
"user": "otauser",
|
||||
"pw": "otapass",
|
||||
},
|
||||
)
|
||||
c, s = await flash_ok(c, s, "fl_md5url", {"url": u, "md5url": m})
|
||||
c, s = await flash_ok(c, s, "fl_md5", {"url": u, "sum": SUM})
|
||||
await case(c, s, "getters", {}, r"computed='' expected=''")
|
||||
await c.disconnect()
|
||||
out(
|
||||
f"SUMMARY {'all cases passed' if not failed else 'FAILED: ' + ', '.join(failed)}"
|
||||
)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,10 +0,0 @@
|
||||
esphome:
|
||||
name: ho32pr
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
@@ -1,11 +0,0 @@
|
||||
esphome:
|
||||
name: ho66pr
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../../ho-common.yaml
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
# run.sh BUILD(pr|base) BOARD(32|66) : serial flash ho<BOARD><BUILD>, serve its own image, drive the cases
|
||||
# Copy this directory to a scratch directory (secrets.yaml next to ho-common.yaml) and run it there.
|
||||
# env: TREE (esphome checkout for BUILD), P32 or P66 (serial port of the board), SC (path to serial_capture.py),
|
||||
# HOST_IP (this host's address as the device reaches it), PY (checkout python)
|
||||
D=$(cd "$(dirname "$0")" && pwd); V=$1; B=$2; N=ho$B$V; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
SC=${SC:?set SC}; HOST=${HOST_IP:?set HOST_IP}
|
||||
if [ "$B" = 32 ]; then P=${P32:?set P32}; else P=${P66:?set P66}; fi
|
||||
OUT=$D/out-$V-$B; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P"
|
||||
(cd "$T" && $PY -m esphome compile "$D/$V/$B/$N.yaml" > "$OUT/compile.log" 2>&1) || { echo "compile failed"; exit 1; }
|
||||
(cd "$T" && $PY -m esphome upload "$D/$V/$B/$N.yaml" --device "$P" > "$OUT/upload.log" 2>&1) || { echo "upload failed"; exit 1; }
|
||||
# the exact image of this config, just compiled, never one found elsewhere
|
||||
if [ "$B" = 32 ]; then IMG=$D/$V/$B/.esphome/build/$N/build/firmware.ota.bin
|
||||
else IMG=$D/$V/$B/.esphome/build/$N/.pioenvs/$N/firmware.bin; fi
|
||||
[ -f "$IMG" ] || { echo "no image for $N at $IMG"; exit 1; }
|
||||
echo "image $IMG"
|
||||
W=$OUT/www; mkdir -p "$W/auth"; cp "$IMG" "$W/fw.bin"; cp "$IMG" "$W/auth/fw.bin"
|
||||
$PY -c "import hashlib,sys;print(hashlib.md5(open(sys.argv[1],'rb').read()).hexdigest())" "$IMG" > "$W/fw.md5"
|
||||
cp "$W/fw.md5" "$W/auth/fw.md5"; echo 00000000000000000000000000000000 > "$W/bad.md5"
|
||||
$PY "$SC" "$P" 400 "$OUT/serial.txt" --reset > /dev/null 2>&1 & CAP=$!
|
||||
trap 'kill $CAP $SRV 2>/dev/null' EXIT
|
||||
pkill -f "[f]wsrv.py"; $PY "$D/fwsrv.py" "$W" 8093 > "$OUT/server.txt" 2>&1 & SRV=$!
|
||||
for i in $(seq 60); do IP=$(grep -a -o 'IP Address: [0-9.]*' "$OUT/serial.txt" | tail -1 | cut -d' ' -f3); [ -z "$IP" ] && IP=$(python3 -c "import socket;print(socket.gethostbyname('$N.local'))" 2>/dev/null); [ -n "$IP" ] && break; sleep 2; done
|
||||
[ -n "$IP" ] || { echo "device not found"; exit 1; }
|
||||
echo "device up"
|
||||
$PY "$D/hodrive.py" "$IP" "http://$HOST:8093" "$W/fw.md5" > "$OUT/drive.txt" 2>&1; RC=$?
|
||||
kill $SRV; pkill -f "[s]erial_capture.py $P"
|
||||
grep -E 'RESULT|SUMMARY|never came back' "$OUT/drive.txt"; echo "server requests: $(grep -c . "$OUT/server.txt")"
|
||||
exit $RC
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Trigger ota.http_request.flash over the API, optionally during a burst of action requests.
|
||||
Usage: otadrive.py IP FWURL MD5 BASE [burst]"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, FW, SUM, BASE = sys.argv[1:5]
|
||||
BURST = len(sys.argv) > 5
|
||||
|
||||
|
||||
async def main():
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
i = await c.device_info()
|
||||
print("before:", i.name, i.compilation_time, flush=True)
|
||||
_, svcs = await c.list_entities_services()
|
||||
s = {x.name: x for x in svcs}
|
||||
t0 = time.monotonic()
|
||||
seen: list[str] = []
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
seen.append(t)
|
||||
if re.search(r"hrt|http_request|ota|OTA|Too many", t):
|
||||
print(f"{time.monotonic() - t0:7.2f} {t[:170]}", flush=True)
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_INFO)
|
||||
await asyncio.sleep(1)
|
||||
if BURST:
|
||||
await c.execute_service(s["burst"], {"url": BASE + "/slow?d=1", "count": 6})
|
||||
await asyncio.sleep(0.5)
|
||||
await c.execute_service(s["otaflash"], {"url": FW, "sum": SUM})
|
||||
end = time.monotonic() + 45
|
||||
while (
|
||||
time.monotonic() < end
|
||||
and c._connection is not None # noqa: SLF001
|
||||
and c._connection.is_connected # noqa: SLF001
|
||||
):
|
||||
await asyncio.sleep(0.5)
|
||||
dropped = c._connection is None or not c._connection.is_connected # noqa: SLF001
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect()
|
||||
for _ in range(30):
|
||||
try:
|
||||
c2 = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c2.connect(login=True)
|
||||
i = await c2.device_info()
|
||||
print("after:", i.name, i.compilation_time, flush=True)
|
||||
await c2.disconnect()
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
print("RESULT FAIL: not reachable after the flash", flush=True)
|
||||
return 1
|
||||
if not dropped:
|
||||
print("RESULT FAIL: the device never rebooted", flush=True)
|
||||
return 1
|
||||
if any("OTAFLASH returned" in t for t in seen):
|
||||
print("RESULT FAIL: the flash action returned, the update failed", flush=True)
|
||||
return 1
|
||||
if not any(re.search(r"(?i)update complete|rebooting|successful", t) for t in seen):
|
||||
print("RESULT FAIL: no update complete line before the reboot", flush=True)
|
||||
return 1
|
||||
print("RESULT PASS", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Call reboot_during (a slow request in flight, another waiting, then App.safe_reboot) and time the reboot.
|
||||
Usage: reboottest.py IP URL"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, URL = sys.argv[1], sys.argv[2]
|
||||
|
||||
|
||||
async def main():
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
_, svcs = await c.list_entities_services()
|
||||
s = {x.name: x for x in svcs}
|
||||
t0 = time.monotonic()
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
if re.search(
|
||||
r"hrt|http_request|safe_mode|shutdown|Rebooting|reboot", t, re.IGNORECASE
|
||||
):
|
||||
print(f"{time.monotonic() - t0:7.2f} {t[:150]}", flush=True)
|
||||
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(1)
|
||||
t0 = time.monotonic()
|
||||
await c.execute_service(s["reboot_during"], {"url": URL})
|
||||
dropped = False
|
||||
while time.monotonic() - t0 < 30:
|
||||
try:
|
||||
await asyncio.wait_for(c.device_info(), 1.0)
|
||||
await asyncio.sleep(0.2)
|
||||
except TimeoutError:
|
||||
continue # a slow reply is not a reboot; only a closed connection counts
|
||||
except Exception: # noqa: BLE001
|
||||
dropped = True
|
||||
print(f"{time.monotonic() - t0:7.2f} API connection gone", flush=True)
|
||||
break
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect()
|
||||
if not dropped:
|
||||
print(f"{time.monotonic() - t0:7.2f} NO REBOOT within 30 s", flush=True)
|
||||
return 1
|
||||
last: Exception | None = None
|
||||
for _ in range(60):
|
||||
try:
|
||||
c2 = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await asyncio.wait_for(c2.connect(login=True), 3)
|
||||
i = await c2.device_info()
|
||||
print(f"{time.monotonic() - t0:7.2f} back: {i.name}", flush=True)
|
||||
await c2.disconnect()
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
await asyncio.sleep(1)
|
||||
print(
|
||||
f"{time.monotonic() - t0:7.2f} NOT BACK after reboot (last error {last!r})",
|
||||
flush=True,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Run N update checks over the API and print the update state plus http_request/update/long-operation log lines.
|
||||
Usage: updcheck.py IP LABEL N"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import aioesphomeapi
|
||||
|
||||
IP, LABEL, N = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||
|
||||
|
||||
async def main():
|
||||
c = aioesphomeapi.APIClient(IP, 6053, None)
|
||||
await c.connect(login=True)
|
||||
info = await c.device_info()
|
||||
print(
|
||||
f"{LABEL} device {info.name} {info.esphome_version} {info.compilation_time}",
|
||||
flush=True,
|
||||
)
|
||||
ents, _ = await c.list_entities_services()
|
||||
upd = next(e for e in ents if isinstance(e, aioesphomeapi.UpdateInfo))
|
||||
states = []
|
||||
|
||||
def on_state(s):
|
||||
if s.key == upd.key:
|
||||
states.append(s)
|
||||
print(
|
||||
f"{LABEL} {time.monotonic() - t0:7.2f} UPDATE in_progress={s.in_progress} current={s.current_version} latest={s.latest_version} title={s.title!r}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def on_log(m):
|
||||
t = (
|
||||
m.message.decode(errors="replace")
|
||||
if isinstance(m.message, bytes)
|
||||
else m.message
|
||||
)
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", t)
|
||||
if re.search(
|
||||
r"http_request|update|took a long time|Guru|abort|heap|Free",
|
||||
t,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
print(f"{LABEL} {time.monotonic() - t0:7.2f} {t[:200]}", flush=True)
|
||||
|
||||
t0 = time.monotonic()
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=aioesphomeapi.LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(3)
|
||||
missing = []
|
||||
for i in range(N):
|
||||
print(f"{LABEL} {time.monotonic() - t0:7.2f} CHECK {i + 1}", flush=True)
|
||||
before = len(states)
|
||||
c.update_command(upd.key, aioesphomeapi.UpdateCommand.CHECK)
|
||||
await asyncio.sleep(25)
|
||||
if not any(not st.in_progress for st in states[before:]):
|
||||
print(f"{LABEL} CHECK {i + 1} got no finished update state", flush=True)
|
||||
missing.append(i + 1)
|
||||
await c.disconnect()
|
||||
print(f"{LABEL} {N - len(missing)} of {N} checks reported a state", flush=True)
|
||||
return 1 if missing else 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Drive the http_request update harness over the API: press buttons, print every update state and save the log.
|
||||
usage: updrive.py HOST KEY(- for plaintext) LOGFILE OP [OP ...]
|
||||
OP: list | press:NAME | set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
BinarySensorState,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SelectInfo,
|
||||
SelectState,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
UpdateState,
|
||||
)
|
||||
|
||||
HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
OPS = sys.argv[4:]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:7.3f}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
await c.connect(login=True)
|
||||
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)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None:
|
||||
return
|
||||
if isinstance(s, TextSensorState):
|
||||
print(
|
||||
f"{stamp()} TXT {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, UpdateState):
|
||||
print(
|
||||
f"{stamp()} UPD {e.name} latest={s.latest_version!r} title={s.title!r} summary={s.release_summary!r} in_progress={s.in_progress}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, BinarySensorState):
|
||||
print(
|
||||
f"{stamp()} BIN {e.name} = {s.state}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, NumberState):
|
||||
print(
|
||||
f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SelectState):
|
||||
print(
|
||||
f"{stamp()} SEL {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SensorState) and isinstance(e, SensorInfo):
|
||||
print(f"{stamp()} SEN {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("=")
|
||||
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
|
||||
if name not in numbers:
|
||||
sys.exit(f"no number named {name!r}")
|
||||
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(2.0)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,79 +0,0 @@
|
||||
# BK7238 bit banged master; needs the BK72xx software I2C bus from #21586 (dev rejects the pins).
|
||||
# The ESP32 control is the same i2c, sensor and interval blocks on a devkit (sda GPIO21, scl GPIO22, 25 or 50 kHz).
|
||||
substitutions:
|
||||
frequency: 100kHz
|
||||
|
||||
esphome:
|
||||
name: i2cbk
|
||||
|
||||
bk72xx:
|
||||
board: xh-wb3s
|
||||
|
||||
logger:
|
||||
hardware_uart: UART1
|
||||
level: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
i2c:
|
||||
id: bus
|
||||
sda: P26
|
||||
scl: P24
|
||||
scan: true
|
||||
frequency: ${frequency}
|
||||
timeout: 20ms # clock stretch limit; the bus default (1 ms) is shorter than the slave's 5 ms stretch
|
||||
|
||||
sensor:
|
||||
- platform: hdc1080
|
||||
address: 0x40
|
||||
update_interval: 5s
|
||||
temperature:
|
||||
name: fake temperature
|
||||
humidity:
|
||||
name: fake humidity
|
||||
|
||||
globals:
|
||||
- id: n
|
||||
type: uint8_t
|
||||
initial_value: "0"
|
||||
- id: n_ok
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: n_bad
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 2s
|
||||
then:
|
||||
- lambda: |-
|
||||
uint8_t k = ++id(n);
|
||||
uint8_t w[4] = {0x20, k, (uint8_t) ~k, 0xA5};
|
||||
uint32_t t0 = micros();
|
||||
auto e1 = id(bus)->write(0x40, w, 4);
|
||||
uint8_t reg = 0x20, r[3] = {0, 0, 0};
|
||||
auto e2 = id(bus)->write_readv(0x40, ®, 1, r, 3);
|
||||
uint32_t t1 = micros();
|
||||
uint8_t x;
|
||||
auto e3 = id(bus)->read(0x41, &x, 1);
|
||||
uint8_t s1 = 0x7E, s2 = 0x7F, rr[2];
|
||||
uint32_t t2 = micros();
|
||||
auto e4 = id(bus)->write_readv(0x40, &s1, 1, rr, 2);
|
||||
uint32_t t3 = micros();
|
||||
auto e5 = id(bus)->write_readv(0x40, &s2, 1, rr, 2);
|
||||
uint32_t t4 = micros();
|
||||
// the absent address must NACK and both clock stretched reads must succeed
|
||||
bool ok = e1 == 0 && e2 == 0 && r[0] == k && r[1] == (uint8_t) ~k && r[2] == 0xA5 && e3 != 0 && e4 == 0 &&
|
||||
e5 == 0;
|
||||
if (ok) id(n_ok)++; else id(n_bad)++;
|
||||
ESP_LOGI("t", "RW %s e1 %d e2 %d got %02X %02X %02X want %02X %02X A5 | %u us | absent e %d | stretch500 e %d %u us | stretch5ms e %d %u us | ok %u bad %u",
|
||||
ok ? "OK" : "BAD", e1, e2, r[0], r[1], r[2], k, (uint8_t) ~k, (unsigned) (t1 - t0), e3, e4,
|
||||
(unsigned) (t3 - t2), e5, (unsigned) (t4 - t3), (unsigned) id(n_ok), (unsigned) id(n_bad));
|
||||
@@ -1,50 +0,0 @@
|
||||
#pragma once
|
||||
#include <Wire.h>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Fake HDC1080 at 0x40: 0x00 temperature (26.0 C), 0x01 humidity (50 %), other registers are a
|
||||
// 256 byte file; reading 0x7E holds SCL for 500 us, reading 0x7F for 5 ms.
|
||||
static volatile uint8_t SL_REGS[256];
|
||||
static volatile uint8_t sl_ptr = 0;
|
||||
static volatile uint32_t sl_rx = 0, sl_tx = 0, sl_stretch = 0;
|
||||
|
||||
static void sl_on_receive(int n) {
|
||||
if (n < 1)
|
||||
return;
|
||||
sl_ptr = Wire.read();
|
||||
uint8_t p = sl_ptr;
|
||||
while (--n > 0 && Wire.available())
|
||||
SL_REGS[p++] = Wire.read();
|
||||
sl_rx++;
|
||||
}
|
||||
|
||||
static void sl_on_request() {
|
||||
uint8_t b[8];
|
||||
uint8_t p = sl_ptr;
|
||||
sl_tx++;
|
||||
if (p == 0x7E || p == 0x7F) {
|
||||
sl_stretch++;
|
||||
delayMicroseconds(p == 0x7E ? 500 : 5000);
|
||||
}
|
||||
for (int i = 0; i < 8; i++)
|
||||
b[i] = SL_REGS[(uint8_t) (p + i)];
|
||||
if (p == 0x00) {
|
||||
b[0] = 0x66;
|
||||
b[1] = 0x66;
|
||||
} else if (p == 0x01) {
|
||||
b[0] = 0x80;
|
||||
b[1] = 0x00;
|
||||
}
|
||||
Wire.write(b, 8);
|
||||
}
|
||||
|
||||
inline void i2cslave_begin() {
|
||||
Wire.begin(4, 5, 0x40);
|
||||
Wire.onReceive(sl_on_receive);
|
||||
Wire.onRequest(sl_on_request);
|
||||
ESP_LOGI("slave", "I2C slave at 0x40 on SDA GPIO4 SCL GPIO5");
|
||||
}
|
||||
|
||||
inline void i2cslave_report() {
|
||||
ESP_LOGI("slave", "rx %u tx %u stretch %u", (unsigned) sl_rx, (unsigned) sl_tx, (unsigned) sl_stretch);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
esphome:
|
||||
name: i2cslave
|
||||
libraries:
|
||||
- Wire
|
||||
includes:
|
||||
- i2cslave.hpp
|
||||
platformio_options:
|
||||
board_build.f_cpu: 160000000L
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: i2cslave_begin();
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
logger:
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- lambda: i2cslave_report();
|
||||
@@ -1,123 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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))
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Drive LD2450 numbers and selects over the API while printing every LD2450 state update.
|
||||
usage: zonedrive.py HOST KEY(- for plaintext) OP [OP ...]
|
||||
OP: set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS | list
|
||||
Every state update of an entity whose name contains "LD2450" is printed with a timestamp.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SelectInfo,
|
||||
SelectState,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
)
|
||||
|
||||
HOST, KEY = sys.argv[1], sys.argv[2]
|
||||
OPS = sys.argv[3:]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:7.3f}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
await c.connect(login=True)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
|
||||
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
|
||||
sensors = {e.name: e for e in ents if isinstance(e, SensorInfo)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None or "LD2450" not in e.name:
|
||||
return
|
||||
if isinstance(s, NumberState):
|
||||
print(
|
||||
f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}"
|
||||
)
|
||||
elif isinstance(s, SelectState):
|
||||
print(f"{stamp()} SEL {e.name} = {s.state}")
|
||||
elif isinstance(s, SensorState) and "Count" in e.name:
|
||||
print(f"{stamp()} CNT {e.name} = {s.state:.0f}")
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
await asyncio.sleep(1.5)
|
||||
for op in OPS:
|
||||
if op == "list":
|
||||
for n in sorted(numbers):
|
||||
print("number:", n)
|
||||
for n in sorted(selects):
|
||||
print("select:", n, selects[n].options)
|
||||
for n in sorted(sensors):
|
||||
if "Count" in n:
|
||||
print("sensor:", n)
|
||||
continue
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
continue
|
||||
name, _, value = rest.partition("=")
|
||||
if kind == "set":
|
||||
if name not in numbers:
|
||||
sys.exit(f"no number named {name!r}; have {sorted(numbers)}")
|
||||
print(f"{stamp()} >>> set {name} = {value}")
|
||||
c.number_command(numbers[name].key, float(value))
|
||||
elif kind == "sel":
|
||||
if name not in selects:
|
||||
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
|
||||
print(f"{stamp()} >>> select {name} = {value}")
|
||||
c.select_command(selects[name].key, value)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(0.3)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Drive the modem test build over the API: press buttons, print sensor states, save the device log.
|
||||
usage: mdrive.py HOST LOGFILE OP [OP ...]
|
||||
OP: list | press:NAME | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
)
|
||||
|
||||
HOST, LOGFILE = 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:
|
||||
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
c = APIClient(HOST, 6053, None)
|
||||
await c.connect(login=True)
|
||||
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)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None:
|
||||
return
|
||||
if isinstance(s, TextSensorState):
|
||||
print(
|
||||
f"{stamp()} TXT {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SensorState) and isinstance(e, SensorInfo):
|
||||
print(f"{stamp()} SEN {e.name} = {s.state:.1f}", flush=True)
|
||||
|
||||
def on_log(msg) -> None:
|
||||
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||
log.flush()
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(1.5)
|
||||
for op in OPS:
|
||||
if op == "list":
|
||||
for n in sorted(buttons):
|
||||
print("button:", n)
|
||||
for e in ents:
|
||||
if isinstance(e, (TextSensorInfo, SensorInfo)):
|
||||
print("entity:", type(e).__name__, 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)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(2.0)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/sh
|
||||
# cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart] : subscribe to NAME/# on the broker, send a
|
||||
# switch command and an on_message topic, press the 3000 byte publish button over the API (KEY, - for plaintext),
|
||||
# optionally restart the local broker (its PID from mosquitto.pid in the working directory, written when it was started
|
||||
# with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid`), and show the device log. Outputs (sub-NAME.txt, mosquitto.out) go to the working directory, which should be the scratch directory.
|
||||
N=$1; KEY=$2; B=$3; SP=$4; LOG=$5; DEV=${6:-$1.local}; RESTART=$7; HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; WORK=$PWD
|
||||
BTN=$HERE/../api_button.py
|
||||
[ -n "$KEY" ] && [ -r "$LOG" ] || { echo "usage: cases.sh NAME KEY BROKER_HOST SUB_PORT CAPTURE [DEV_HOST] [restart] (CAPTURE must be a readable file)"; exit 2; }
|
||||
show() { tail -n +$(($1+1)) "$LOG" | sed 's/\x1b\[[0-9;]*m//g' | grep -a 'mqtt\|\[probe:\|cmd payload\|Relay\|\[W\]\|\[E\]\|Guru\|abort\|Backtrace\|ESPHome version' | grep -a -v 'took a long time\|Publish.*Counter\|sensor/counter' | cut -c1-170; }
|
||||
pub() { mosquitto_pub -h "$B" -p "$SP" -t "$1" -m "$2" || { echo "mosquitto_pub to $1 failed"; exit 1; }; }
|
||||
n0=$(($(wc -l < "$LOG")+0))
|
||||
timeout 75 mosquitto_sub -h "$B" -p "$SP" -v -t "$N/#" > "$WORK/sub-$N.txt" 2> "$WORK/sub-$N.err" &
|
||||
sub_pid=$!
|
||||
sleep 20
|
||||
echo "=== $N: command to the switch and the 3000 byte publish ($(date +%T))"
|
||||
pub "$N/switch/relay/command" ON; sleep 2
|
||||
pub "$N/cmd" "hello-over-$N"; sleep 2
|
||||
$PY "$BTN" "$DEV" "$KEY" press "Publish big" || { echo "button press failed"; exit 1; }; sleep 3
|
||||
pub "$N/switch/relay/command" OFF; sleep 2
|
||||
if [ "$RESTART" = restart ]; then
|
||||
[ -f "$WORK/mosquitto-ws.conf" ] || { echo "no mosquitto-ws.conf in $WORK, cannot restart the broker"; exit 1; }
|
||||
echo "=== $N: broker restart ($(date +%T))"; n1=$(($(wc -l < "$LOG")+0))
|
||||
# Only this run's broker is signalled: its PID comes from mosquitto.pid, written when it was started
|
||||
pid=$(cat "$WORK/mosquitto.pid" 2>/dev/null); [ -n "$pid" ] || { echo "no mosquitto.pid in $WORK, cannot restart the broker"; exit 1; }
|
||||
kill "$pid" || { echo "broker PID $pid is not running, nothing to restart"; exit 1; }; sleep 8
|
||||
nc -z -w 3 "$B" 1883 && { echo "the old broker still answers on 1883 after the kill"; exit 1; }
|
||||
# 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
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/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 ]
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/bin/sh
|
||||
# flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST MODE [SUB_PORT] : flash YAML from the checkout TREE over the serial
|
||||
# PORT, capture the serial log (cap-NAME.txt in the working directory), wait for the first MQTT connect or TLS error,
|
||||
# then drive the device with cases.sh (KEY as for cases.sh, - for a plaintext API). MODE local runs cases.sh with a
|
||||
# broker restart (the working directory must hold mosquitto-ws.conf and mosquitto.pid), public runs it without a
|
||||
# restart, neg only watches for 100 s (a build that must never connect). SUB_PORT is the plain listener cases.sh
|
||||
# subscribes on (default 1883). Ends with connect, disconnect, handshake error and crash counts, the config dump's
|
||||
# TLS line, the probe heap lines and the broker listener each connection used. Exit 1 when the capture did not start,
|
||||
# the device reached no MQTT event within 90 s (local and public), it crashed, or the mode's own check failed.
|
||||
TREE=$1; YAML=$2; PORT=$3; KEY=$4; B=$5; MODE=$6; SP=${7:-1883}
|
||||
case $YAML in /*) ;; *) YAML=$PWD/$YAML;; esac
|
||||
HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; N=$(basename "$YAML" .yaml); CAP=$PWD/cap-$N.txt; ULOG=$PWD/upload-$N.log
|
||||
[ -d "$TREE/esphome" ] && [ -e "$PORT" ] && [ -n "$KEY" ] && [ -n "$B" ] || { echo "usage: flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST local|public|neg [SUB_PORT]"; exit 2; }
|
||||
case $MODE in local|public|neg) ;; *) echo "MODE must be local, public or neg"; exit 2;; esac
|
||||
pkill -f "[s]erial_capture.py $PORT "; sleep 1
|
||||
echo "=== $N: flash ($(date +%T))"
|
||||
(cd "$TREE" && PYTHONUNBUFFERED=1 timeout 300 $PY -m esphome upload "$YAML" --device "$PORT" > "$ULOG" 2>&1) || { echo "upload failed"; tail -5 "$ULOG"; exit 1; }
|
||||
SECS=200; [ "$MODE" = neg ] && SECS=100
|
||||
# a stale capture from an earlier run must not satisfy the wait below
|
||||
rm -f "$CAP"
|
||||
$PY "$HERE/../serial_capture.py" "$PORT" $SECS "$CAP" > "$PWD/cap-$N.stderr" 2>&1 &
|
||||
cap_pid=$!
|
||||
i=0; until grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected\|mbedtls_ssl_handshake returned\|Failed to verify" "$CAP" 2>/dev/null || [ $i -ge 45 ]; do sleep 2; i=$((i+1)); done
|
||||
kill -0 $cap_pid 2>/dev/null && [ -f "$CAP" ] || { echo "serial capture is not running on $PORT"; tail -3 "$PWD/cap-$N.stderr"; exit 1; }
|
||||
DEV=$(grep -a -o 'IP Address: [0-9.]*' "$CAP" | head -1 | awk '{print $3}')
|
||||
if [ $i -ge 45 ] || [ -z "$DEV" ]; then
|
||||
echo "=== $N: no MQTT event or no IP within 90 s ($(date +%T))"
|
||||
[ "$MODE" = neg ] || { pkill -f "[s]erial_capture.py $PORT "; exit 1; }
|
||||
else
|
||||
echo "=== $N: first MQTT event after $((i*2)) s, device $DEV ($(date +%T))"
|
||||
fi
|
||||
grep -a 'ESPHome version\|\]: Transport\|\]: TLS:' "$CAP" | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-120
|
||||
rc=0
|
||||
case $MODE in
|
||||
local) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV" restart; rc=$?;;
|
||||
public) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV"; rc=$?;;
|
||||
neg) sleep 70;;
|
||||
esac
|
||||
wait $cap_pid
|
||||
strip() { sed 's/\x1b\[[0-9;]*m//g'; }
|
||||
# count the backend's own events so the result does not depend on on_connect/on_disconnect log lines in the config
|
||||
connects=$(grep -a -c 'MQTT_EVENT_CONNECTED' "$CAP"); errors=$(grep -a -c 'mbedtls_ssl_handshake returned\|Failed to verify certificate' "$CAP")
|
||||
crashes=$(grep -a -c 'Guru\|abort()\|Backtrace' "$CAP")
|
||||
echo "=== $N: connects $connects, disconnects $(grep -a -c 'MQTT_EVENT_DISCONNECTED' "$CAP"), handshake errors $errors, crashes $crashes"
|
||||
grep -a 'mbedtls_ssl_handshake returned\|Failed to verify\|esp-tls' "$CAP" | strip | sed 's/^\[[^]]*\]//' | sort | uniq -c | sort -rn | head -4 | cut -c1-150
|
||||
grep -a '\[probe:' "$CAP" | strip | sed 's/.*\[probe:[0-9]*\]: //' | sed -n '1p;$p'
|
||||
[ -f "$PWD/mosquitto.log" ] && [ -n "$DEV" ] && echo "broker listeners used by $DEV: $(grep "New connection from $DEV" "$PWD/mosquitto.log" | sed 's/.*on port //' | sort | uniq -c | tr '\n' ';')"
|
||||
[ "$crashes" = 0 ] || { echo "device crashed $crashes time(s)"; exit 1; }
|
||||
if [ "$MODE" = neg ]; then
|
||||
[ "$connects" = 0 ] && [ "$errors" -gt 0 ] || { echo "negative case: expected no connect and a handshake error"; exit 1; }
|
||||
else
|
||||
[ "$rc" = 0 ] || { echo "cases.sh failed with status $rc"; exit 1; }
|
||||
fi
|
||||
@@ -1,8 +0,0 @@
|
||||
substitutions: {name: mqabase, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -1,9 +0,0 @@
|
||||
substitutions: {name: mqbbase, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
topic_prefix: custpre32
|
||||
@@ -1,18 +0,0 @@
|
||||
substitutions: {name: mqcbase, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
log_topic: explicit32/log
|
||||
birth_message:
|
||||
topic: explicit32/avail
|
||||
payload: up
|
||||
will_message:
|
||||
topic: explicit32/avail
|
||||
payload: down
|
||||
shutdown_message:
|
||||
topic: explicit32/avail
|
||||
payload: bye
|
||||
@@ -1,8 +0,0 @@
|
||||
substitutions: {name: mqd32base, suffix: "false"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -1,7 +0,0 @@
|
||||
substitutions: {name: mqabase, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -1,8 +0,0 @@
|
||||
substitutions: {name: mqbbase, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
topic_prefix: custpre66
|
||||
@@ -1,17 +0,0 @@
|
||||
substitutions: {name: mqcbase, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
log_topic: explicit66/log
|
||||
birth_message:
|
||||
topic: explicit66/avail
|
||||
payload: up
|
||||
will_message:
|
||||
topic: explicit66/avail
|
||||
payload: down
|
||||
shutdown_message:
|
||||
topic: explicit66/avail
|
||||
payload: bye
|
||||
@@ -1,7 +0,0 @@
|
||||
substitutions: {name: mqd66base, suffix: "false"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -1,24 +0,0 @@
|
||||
# Package for esphome#9315, included by the per-variant files under pr/ and base/ (substitutions name, suffix).
|
||||
esphome:
|
||||
name: ${name}
|
||||
name_add_mac_suffix: ${suffix}
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
name: Restart
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- logger.log: "alive"
|
||||
@@ -1,8 +0,0 @@
|
||||
substitutions: {name: mqapr, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -1,9 +0,0 @@
|
||||
substitutions: {name: mqbpr, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
topic_prefix: custpre32
|
||||
@@ -1,18 +0,0 @@
|
||||
substitutions: {name: mqcpr, suffix: "true"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
log_topic: explicit32/log
|
||||
birth_message:
|
||||
topic: explicit32/avail
|
||||
payload: up
|
||||
will_message:
|
||||
topic: explicit32/avail
|
||||
payload: down
|
||||
shutdown_message:
|
||||
topic: explicit32/avail
|
||||
payload: bye
|
||||
@@ -1,8 +0,0 @@
|
||||
substitutions: {name: mqd32pr, suffix: "false"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -1,7 +0,0 @@
|
||||
substitutions: {name: mqapr, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -1,8 +0,0 @@
|
||||
substitutions: {name: mqbpr, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
topic_prefix: custpre66
|
||||
@@ -1,17 +0,0 @@
|
||||
substitutions: {name: mqcpr, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
log_topic: explicit66/log
|
||||
birth_message:
|
||||
topic: explicit66/avail
|
||||
payload: up
|
||||
will_message:
|
||||
topic: explicit66/avail
|
||||
payload: down
|
||||
shutdown_message:
|
||||
topic: explicit66/avail
|
||||
payload: bye
|
||||
@@ -1,7 +0,0 @@
|
||||
substitutions: {name: mqd66pr, suffix: "false"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/bin/bash
|
||||
# run.sh X BUILD : flash variant X (a..d) of BUILD (pr|base) on both boards, record broker traffic, restart, hard reset
|
||||
# Copy this directory to a scratch directory and run it there; secrets.yaml (wifi_ssid, wifi_password, broker_ip)
|
||||
# goes next to mq-common.yaml and in every board folder.
|
||||
# env: TREE (esphome checkout for BUILD), P32 and P66 (ESP32 and ESP8266 serial ports), SC (path to
|
||||
# serial_capture.py), PY (checkout python)
|
||||
M=$(cd "$(dirname "$0")" && pwd); X=$1; V=$2; T=${TREE:?set TREE to the checkout under test}
|
||||
P32=${P32:?set P32}; P66=${P66:?set P66}; SC=${SC:?set SC}
|
||||
PY=${PY:-$T/venv/bin/python}; OUT=$M/out-$X-$V; mkdir -p $OUT
|
||||
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"; pkill -f "[m]osquitto_sub -h 127.0.0.1"
|
||||
# compile one at a time (shared tool caches), then flash both boards at once; upload alone never builds
|
||||
for b in 32 66; do
|
||||
(cd $T && $PY -m esphome compile $M/$V/$b/mq${X}$b$V.yaml > $OUT/c$b.log 2>&1) || { echo "compile$b failed, see $OUT/c$b.log"; exit 1; }
|
||||
done
|
||||
(cd $T && $PY -m esphome upload $M/$V/32/mq${X}32$V.yaml --device $P32 > $OUT/u32.log 2>&1) & U32=$!
|
||||
(cd $T && $PY -m esphome upload $M/$V/66/mq${X}66$V.yaml --device $P66 > $OUT/u66.log 2>&1) & U66=$!
|
||||
wait $U32 || { echo "upload32 failed, see $OUT/u32.log"; exit 1; }
|
||||
wait $U66 || { echo "upload66 failed, see $OUT/u66.log"; exit 1; }
|
||||
mosquitto_sub -h 127.0.0.1 -v -t '#' -F '@H:@M:@S r=%r %t %p' > $OUT/broker.txt 2>&1 &
|
||||
SUB=$!
|
||||
$PY $SC $P32 170 $OUT/s32.txt --reset > /dev/null 2>&1 &
|
||||
$PY $SC $P66 170 $OUT/s66.txt > /dev/null 2>&1 &
|
||||
sleep 60
|
||||
TOPICS=$(grep "homeassistant/button/" $OUT/broker.txt | grep "\"name\":\"mq${X}" | grep -o '"cmd_t":"[^"]*"' | cut -d'"' -f4 | sort -u)
|
||||
ERR=0
|
||||
[ "$(echo "$TOPICS" | grep -c .)" -ge 2 ] || { echo "ERROR: expected a Restart command topic per board, found: ${TOPICS:-none}"; ERR=1; }
|
||||
for t in $TOPICS; do
|
||||
mosquitto_pub -h 127.0.0.1 -t "$t" -m PRESS && echo "$(date +%T) press $t" || { echo "$(date +%T) ERROR: publish to $t failed"; ERR=1; }
|
||||
done
|
||||
sleep 45
|
||||
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"; sleep 1
|
||||
echo "$(date +%T) hold both in the bootloader for 40 s (will test)"
|
||||
$PY -m esptool --port $P32 --after no-reset chip-id > $OUT/hold32.log 2>&1 & H32=$!
|
||||
$PY -m esptool --port $P66 --after no-reset chip-id > $OUT/hold66.log 2>&1 & H66=$!
|
||||
wait $H32 || { echo "ERROR: ESP32 not held in the bootloader, no will expected (see $OUT/hold32.log)"; ERR=1; }
|
||||
wait $H66 || { echo "ERROR: ESP8266 not held in the bootloader, no will expected (see $OUT/hold66.log)"; ERR=1; }
|
||||
sleep 40
|
||||
REL=$(date +%T); echo "$REL release both"
|
||||
$PY $SC $P32 40 $OUT/s32b.txt --reset > /dev/null 2>&1 &
|
||||
$PY $SC $P66 40 $OUT/s66b.txt --reset > /dev/null 2>&1 &
|
||||
sleep 42
|
||||
kill $SUB
|
||||
for f in s32 s66; do echo "== $f"; grep -a -E "Topic Prefix|Log Topic|Availability|Client ID" $OUT/$f.txt | head -4 | sed 's/^\[[0-9:]*\]//'; done
|
||||
echo "== broker (excluding discovery and entity state)"
|
||||
grep -v -E " homeassistant/| [^ ]*/(sensor|button|binary_sensor)/| esphome/discover/" $OUT/broker.txt | awk '$3 ~ /(debug|log)$/ { if (seen[$3]++) next; print $1, $2, $3, "(log lines:", "see file)"; next } { print }' | head -40
|
||||
echo "== log line counts per topic"; awk '$3 ~ /(debug|log)$/ {c[$3]++} END {for (t in c) print t, c[t]}' $OUT/broker.txt
|
||||
# each board must publish its birth message (default "online", explicit variant "up") after the release;
|
||||
# a missing will is the measured result and does not fail the run
|
||||
BIRTHS=$(awk -v t="$REL" '$1 >= t && ($4 == "online" || $4 == "up") {print $3}' $OUT/broker.txt | sort -u | grep -c .)
|
||||
[ "$BIRTHS" -ge 2 ] || { echo "ERROR: $BIRTHS of 2 boards published a birth message after the release"; ERR=1; }
|
||||
exit $ERR
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,26 +0,0 @@
|
||||
# Copy next to the certificates in the scratch directory and start it there with
|
||||
# `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` (cases.sh restarts that PID): plain MQTT, MQTT over 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
|
||||
@@ -1,9 +0,0 @@
|
||||
substitutions: {name: sd32base}
|
||||
esphome:
|
||||
name: sd32base
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
deep_sleep:
|
||||
id: ds
|
||||
packages: {c: !include ../sd-common.yaml}
|
||||
@@ -1,8 +0,0 @@
|
||||
substitutions: {name: sd66base}
|
||||
esphome:
|
||||
name: sd66base
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
deep_sleep:
|
||||
id: ds
|
||||
packages: {c: !include ../sd-common.yaml}
|
||||
@@ -1,33 +0,0 @@
|
||||
"""match.py OUTDIR: for each event, say whether a bye/dead for that board arrived within 5 s."""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
d = pathlib.Path(sys.argv[1])
|
||||
|
||||
|
||||
def secs(stamp: str) -> int:
|
||||
h, m, s = (int(x) for x in stamp.split(":"))
|
||||
return h * 3600 + m * 60 + s
|
||||
|
||||
|
||||
ev = [ln.split() for ln in (d / "events.txt").read_text(encoding="utf-8").splitlines()]
|
||||
br = [
|
||||
f
|
||||
for f in (
|
||||
ln.split() for ln in (d / "broker.txt").read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
if len(f) >= 3 and f[2] in ("bye", "dead")
|
||||
]
|
||||
rows = {}
|
||||
for ts, kind, b in ev:
|
||||
hit = [
|
||||
p
|
||||
for s, top, p in br
|
||||
if top.startswith(f"sd{b}") and 0 <= secs(s) - secs(ts) <= 5
|
||||
]
|
||||
rows.setdefault((b, kind), []).append(
|
||||
"bye" if "bye" in hit else (hit[0] if hit else "-")
|
||||
)
|
||||
for (b, kind), r in sorted(rows.items()):
|
||||
print(f"{b} {kind:8s} {r.count('bye')}/{len(r)} bye {' '.join(r)}")
|
||||
@@ -1,9 +0,0 @@
|
||||
substitutions: {name: sd32pr}
|
||||
esphome:
|
||||
name: sd32pr
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
deep_sleep:
|
||||
id: ds
|
||||
packages: {c: !include ../sd-common.yaml}
|
||||
@@ -1,8 +0,0 @@
|
||||
substitutions: {name: sd66pr}
|
||||
esphome:
|
||||
name: sd66pr
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
deep_sleep:
|
||||
id: ds
|
||||
packages: {c: !include ../sd-common.yaml}
|
||||
@@ -1,40 +0,0 @@
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
discovery: false
|
||||
birth_message: {topic: "${name}/status", payload: hello}
|
||||
will_message: {topic: "${name}/status", payload: dead}
|
||||
shutdown_message: {topic: "${name}/status", payload: bye}
|
||||
on_message:
|
||||
- topic: ${name}/cmd
|
||||
payload: restart
|
||||
then:
|
||||
- button.press: restart_btn
|
||||
- topic: ${name}/cmd
|
||||
payload: disable
|
||||
then:
|
||||
- mqtt.disable:
|
||||
- delay: 5s
|
||||
- mqtt.enable:
|
||||
- topic: ${name}/cmd
|
||||
payload: sleep
|
||||
then:
|
||||
- deep_sleep.enter:
|
||||
id: ds
|
||||
sleep_duration: 5s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
id: restart_btn
|
||||
name: Restart
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
# sdrun.sh V(pr|base) [N] : flash both boards, restart each N times over MQTT, then disable, then deep sleep (ESP32)
|
||||
# Copy this directory to a scratch directory (secrets.yaml with wifi_ssid, wifi_password and broker_ip next to
|
||||
# sd-common.yaml) and run it there with a local mosquitto on this host.
|
||||
# env: TREE (esphome checkout for V), P32 and P66 (ESP32 and ESP8266 serial ports), PY (checkout python)
|
||||
S=$(cd "$(dirname "$0")" && pwd); V=$1; N=${2:-10}; T=${TREE:?set TREE}; PY=${PY:-$T/venv/bin/python}
|
||||
P32=${P32:?set P32}; P66=${P66:?set P66}; OUT=$S/out-$V; rm -rf "$OUT"; mkdir -p "$OUT"
|
||||
pkill -f "[s]erial_capture.py $P32"; pkill -f "[s]erial_capture.py $P66"
|
||||
# compile one at a time (shared tool caches), then flash both boards at once; upload alone never builds
|
||||
for b in 32 66; do
|
||||
(cd "$T" && $PY -m esphome compile "$S/$V/sd$b$V.yaml" > "$OUT/c$b.log" 2>&1) || { echo "compile$b failed, see $OUT/c$b.log"; exit 1; }
|
||||
done
|
||||
(cd "$T" && $PY -m esphome upload "$S/$V/sd32$V.yaml" --device "$P32" > "$OUT/u32.log" 2>&1) & U32=$!
|
||||
(cd "$T" && $PY -m esphome upload "$S/$V/sd66$V.yaml" --device "$P66" > "$OUT/u66.log" 2>&1) & U66=$!
|
||||
wait $U32 || { echo "upload32 failed"; exit 1; }; wait $U66 || { echo "upload66 failed"; exit 1; }
|
||||
mosquitto_sub -h 127.0.0.1 -R -v -t "sd32$V/status" -t "sd66$V/status" -F '@H:@M:@S %t %p' > "$OUT/broker.txt" 2>&1 & SUB=$!
|
||||
# cmd KIND BOARD: publish the command and record the event only when the publish worked
|
||||
ERR=0
|
||||
cmd() {
|
||||
local ts; ts=$(date +%T)
|
||||
if mosquitto_pub -h 127.0.0.1 -t "sd$2$V/cmd" -m "$1"; then echo "$ts $1 $2" >> "$OUT/events.txt"
|
||||
else echo "$ts ERROR: publish $1 to board $2 failed"; ERR=1; fi
|
||||
}
|
||||
sleep 40
|
||||
for b in 32 66; do
|
||||
grep -q "sd$b$V/status hello" "$OUT/broker.txt" || { echo "ERROR: sd$b$V never sent its birth message"; kill $SUB; exit 1; }
|
||||
done
|
||||
for i in $(seq "$N"); do
|
||||
for b in 32 66; do cmd restart $b; done
|
||||
sleep 25
|
||||
done
|
||||
for b in 32 66; do cmd disable $b; done
|
||||
sleep 30
|
||||
for i in 1 2 3; do cmd sleep 32; sleep 30; done
|
||||
kill -0 $SUB 2>/dev/null || { echo "ERROR: the broker subscriber died, counts are incomplete"; ERR=1; }
|
||||
kill $SUB 2>/dev/null
|
||||
for b in 32 66; do echo "sd$b$V: $(grep -c "sd$b$V/status hello" "$OUT/broker.txt") hello, $(grep -c "sd$b$V/status bye" "$OUT/broker.txt") bye, $(grep -c "sd$b$V/status dead" "$OUT/broker.txt") dead"; done
|
||||
# each restart, the disable (a reconnect) and each ESP32 deep sleep is followed by a new birth message
|
||||
for b in 32 66; do
|
||||
want=$((N + 2)); [ $b = 32 ] && want=$((want + 3))
|
||||
got=$(grep -c "sd$b$V/status hello" "$OUT/broker.txt")
|
||||
[ "$got" -ge "$want" ] || { echo "ERROR: sd$b$V sent $got birth messages, expected $want (a board did not come back)"; ERR=1; }
|
||||
done
|
||||
exit $ERR
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Reboot the DUT before each fetch and report the longest online_image loop pass of that one fetch.
|
||||
usage: firstpass.py HOST KEY BASEURL LABEL IMAGE...
|
||||
The component warning prints only on a new maximum, so a fresh boot per image gives that image's own maximum.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
LogLevel,
|
||||
RequiresEncryptionAPIError,
|
||||
UserService,
|
||||
)
|
||||
|
||||
HOST, KEY, BASE, LABEL = sys.argv[1:5]
|
||||
IMAGES = sys.argv[5:]
|
||||
TOOK = re.compile(r"online_image\.image took a long time for an operation \((\d+) ms\)")
|
||||
OUTCOME = re.compile(r"download (finished|error): img\b")
|
||||
|
||||
|
||||
async def connect() -> tuple[APIClient, dict, int]:
|
||||
last: Exception | None = None
|
||||
for _ in range(30):
|
||||
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||
try:
|
||||
await c.connect(login=True)
|
||||
break
|
||||
except (
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
RequiresEncryptionAPIError,
|
||||
):
|
||||
raise # a wrong key never fixes itself
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = exc
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
raise RuntimeError(f"device did not come back (last error {last!r})")
|
||||
ents, services = await c.list_entities_services()
|
||||
reboot = next(
|
||||
(e.key for e in ents if isinstance(e, ButtonInfo) and e.name == "Reboot"), None
|
||||
)
|
||||
if reboot is None:
|
||||
raise RuntimeError("the device has no button named Reboot")
|
||||
return c, {s.name: s for s in services if isinstance(s, UserService)}, reboot
|
||||
|
||||
|
||||
async def rebooted(c: APIClient, reboot: int) -> bool:
|
||||
"""Press Reboot and wait up to 15 s for the API connection to drop."""
|
||||
c.button_command(reboot)
|
||||
end = time.monotonic() + 15
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
await asyncio.wait_for(c.device_info(), 1.0)
|
||||
except TimeoutError:
|
||||
continue # a slow reply is not a reboot; only a closed connection counts
|
||||
except Exception: # noqa: BLE001
|
||||
return True
|
||||
await asyncio.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
failed = []
|
||||
for img in IMAGES:
|
||||
c, actions, reboot = await connect()
|
||||
ok = await rebooted(c, reboot)
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect()
|
||||
if not ok:
|
||||
print(f"{LABEL} {img:22} SKIPPED: no reboot within 15 s", flush=True)
|
||||
failed.append(img)
|
||||
continue
|
||||
await asyncio.sleep(8)
|
||||
c, actions, reboot = await connect()
|
||||
lines: list[str] = []
|
||||
c.subscribe_logs(
|
||||
lambda m, lines=lines: lines.append(m.message.decode(errors="replace")),
|
||||
log_level=LogLevel.LOG_LEVEL_DEBUG,
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
lines.clear()
|
||||
t0 = time.monotonic()
|
||||
await c.execute_service(actions["fetch"], {"url": f"{BASE}/{img}"})
|
||||
outcome = None
|
||||
while time.monotonic() - t0 < 60 and outcome is None:
|
||||
await asyncio.sleep(0.2)
|
||||
outcome = next(
|
||||
(m.group(1) for ln in lines if (m := OUTCOME.search(ln))), None
|
||||
)
|
||||
took = time.monotonic() - t0
|
||||
await asyncio.sleep(1)
|
||||
passes = [int(m.group(1)) for ln in lines if (m := TOOK.search(ln))]
|
||||
print(
|
||||
f"{LABEL} {img:22} {outcome or 'none':9} {took:5.1f}s longest pass {max(passes) if passes else '<50'} ms {passes}",
|
||||
flush=True,
|
||||
)
|
||||
if (
|
||||
outcome != "finished"
|
||||
): # a decode timing is only meaningful for a complete image
|
||||
failed.append(img)
|
||||
await c.disconnect()
|
||||
if failed:
|
||||
print(f"{LABEL} not finished: {', '.join(failed)}", flush=True)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
Loaded 100 of 2220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user