mirror of
https://github.com/esphome/esphome.git
synced 2026-10-24 06:55:37 +00:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b1ee34b0 | ||
|
|
fd450bb6a7 | ||
|
|
acb89a8676 | ||
|
|
08d231991b | ||
|
|
253db1e0d5 | ||
|
|
bb07d04c90 | ||
|
|
3c1e53628f | ||
|
|
d515e8b2e3 | ||
|
|
49d4b83dd2 | ||
|
|
e04dbc1881 | ||
|
|
4c62713eb2 | ||
|
|
b3633fe53c | ||
|
|
b6fdccfc65 | ||
|
|
1a11424475 | ||
|
|
f35395b584 | ||
|
|
6d110fd981 | ||
|
|
7c69d1b63f | ||
|
|
1546ff022c | ||
|
|
c2911a51bd | ||
|
|
db16d86ee2 | ||
|
|
b401a8bf17 | ||
|
|
051978212c | ||
|
|
971035d6d1 | ||
|
|
b90e1e43a3 | ||
|
|
fc6accb8d9 | ||
|
|
ade01b2c71 | ||
|
|
b415f86847 | ||
|
|
8fa9932033 | ||
|
|
c464ef0cc4 | ||
|
|
6f091e4cf2 | ||
|
|
4a9409e299 | ||
|
|
53357c92f8 | ||
|
|
ed882741bc | ||
|
|
f994fbde04 | ||
|
|
a72adc7573 | ||
|
|
55a62993c8 | ||
|
|
31a43f13d7 | ||
|
|
3c06d41c6a | ||
|
|
3be29eab35 | ||
|
|
202087f919 | ||
|
|
9ed59c29e2 | ||
|
|
d9548d142c | ||
|
|
34ef64186e | ||
|
|
e8a8d6cf95 | ||
|
|
1babcbf371 | ||
|
|
f9ca09ab30 | ||
|
|
ff56667929 | ||
|
|
ebdd9fe07e | ||
|
|
5a4025ee12 | ||
|
|
2b024114cf | ||
|
|
a1316e1bfa | ||
|
|
9beb41e2ff | ||
|
|
b9f973ba7f | ||
|
|
3d42180dcb | ||
|
|
8262e336cc | ||
|
|
616100803f | ||
|
|
8fb9dba023 | ||
|
|
c57a2b9f73 | ||
|
|
9f2be8f195 | ||
|
|
aa6c06ac03 | ||
|
|
2caf6eb6d0 | ||
|
|
5f90fb501b | ||
|
|
dfe87daa40 | ||
|
|
29cb1e9039 | ||
|
|
831f10fdeb | ||
|
|
8a2358d0e6 | ||
|
|
f416e1edd4 | ||
|
|
8156de9613 | ||
|
|
89c2cdb592 | ||
|
|
cb57148178 |
No files matched your search
@@ -111,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, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/`
|
||||
(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/`
|
||||
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
|
||||
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
|
||||
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
|
||||
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), `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), `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).
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
@@ -132,7 +132,12 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
|
||||
`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`.
|
||||
`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 `uart_sim/` (simulator header, common package and per-build parts).
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ 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)
|
||||
|
||||
@@ -83,6 +86,8 @@ 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)
|
||||
|
||||
@@ -113,6 +118,13 @@ 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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -4,17 +4,20 @@ 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
|
||||
- [`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
|
||||
- [`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
|
||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
||||
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
|
||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
|
||||
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
|
||||
- [`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
|
||||
- [`nrf52.md`](nrf52.md): dongle board support
|
||||
- [`online_image.md`](online_image.md): stalled and malformed downloads
|
||||
- [`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
|
||||
- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP
|
||||
- [`ota.md`](ota.md): overlap and lock, timeouts, deflate
|
||||
- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH
|
||||
@@ -31,3 +34,6 @@ One file per component, named after it:
|
||||
- [`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
|
||||
@@ -40,3 +40,19 @@ length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSIN
|
||||
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
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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`) 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.
|
||||
@@ -0,0 +1,10 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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
|
||||
@@ -51,3 +51,15 @@ context rejects a CA that lacks a `keyUsage` extension (`CA cert does not includ
|
||||
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` 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.
|
||||
@@ -18,3 +18,47 @@ 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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...` (flash,
|
||||
capture, drive; `PORT` and `SC` in the environment).
|
||||
@@ -0,0 +1,221 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,31 @@
|
||||
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()
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
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()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
"""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"))
|
||||
EXTRAS = not ONLY
|
||||
|
||||
|
||||
async def main():
|
||||
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)
|
||||
|
||||
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}
|
||||
)
|
||||
end = time.monotonic() + 40
|
||||
while time.monotonic() < end and not any(
|
||||
"RESULT" in ln for ln in lines[n0:]
|
||||
):
|
||||
await asyncio.sleep(0.2)
|
||||
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})
|
||||
end = time.monotonic() + 30
|
||||
while (
|
||||
time.monotonic() < end
|
||||
and sum(("BURSTRESP" in ln or "BURSTERR" in ln) for ln in lines[n0:]) < 6
|
||||
):
|
||||
await asyncio.sleep(0.2)
|
||||
await asyncio.sleep(2)
|
||||
print(f"{LABEL} == cancel round {rnd}", flush=True)
|
||||
await c.execute_service(svc["cancel"], {"url": BASE + "/slow?d=2"})
|
||||
await asyncio.sleep(9)
|
||||
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}
|
||||
)
|
||||
end = time.monotonic() + 30
|
||||
while time.monotonic() < end and (
|
||||
sum("BURSTRESP" in ln or "BURSTERR" in ln for ln in lines[n0:]) < 3
|
||||
or not any("RESULT" in ln for ln in lines[n0:])
|
||||
):
|
||||
await asyncio.sleep(0.2)
|
||||
await asyncio.sleep(2)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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()
|
||||
|
||||
def on_log(m):
|
||||
t = re.sub(r"\x1b\[[0-9;]*m", "", m.message.decode(errors="replace"))
|
||||
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})
|
||||
await asyncio.sleep(45)
|
||||
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()
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
await asyncio.sleep(2)
|
||||
print("after: not reachable")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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 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})
|
||||
while time.monotonic() - t0 < 30:
|
||||
try:
|
||||
await asyncio.wait_for(c.device_info(), 1.0)
|
||||
await asyncio.sleep(0.2)
|
||||
except Exception: # noqa: BLE001
|
||||
print(f"{time.monotonic() - t0:7.2f} API connection gone", flush=True)
|
||||
break
|
||||
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
|
||||
except Exception: # noqa: BLE001
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
"""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)
|
||||
for i in range(N):
|
||||
print(f"{LABEL} {time.monotonic() - t0:7.2f} CHECK {i + 1}", flush=True)
|
||||
c.update_command(upd.key, aioesphomeapi.UpdateCommand.CHECK)
|
||||
await asyncio.sleep(25)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,9 @@
|
||||
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
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions: {name: mqabase, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions: {name: mqd66base, suffix: "false"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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"
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,9 @@
|
||||
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
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions: {name: mqapr, suffix: "true"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions: {name: mqd66pr, suffix: "false"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
packages: {mq: !include ../../mq-common.yaml}
|
||||
mqtt:
|
||||
broker: !secret broker_ip
|
||||
reboot_timeout: 0s
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/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"
|
||||
(cd $T && $PY -m esphome upload $M/$V/32/mq${X}32$V.yaml --device $P32 > $OUT/u32.log 2>&1) &
|
||||
(cd $T && $PY -m esphome upload $M/$V/66/mq${X}66$V.yaml --device $P66 > $OUT/u66.log 2>&1) &
|
||||
wait; echo "upload32: $(tail -1 $OUT/u32.log)"; echo "upload66: $(tail -1 $OUT/u66.log)"
|
||||
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
|
||||
for t in $(grep "homeassistant/button/" $OUT/broker.txt | grep "\"name\":\"mq${X}" | grep -o '"cmd_t":"[^"]*"' | cut -d'"' -f4 | sort -u); do
|
||||
echo "$(date +%T) press $t"; mosquitto_pub -h 127.0.0.1 -t "$t" -m PRESS
|
||||
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 &
|
||||
$PY -m esptool --port $P66 --after no-reset chip-id > $OUT/hold66.log 2>&1 &
|
||||
wait $(jobs -p | grep -v "^$SUB$") 2>/dev/null; sleep 40
|
||||
echo "$(date +%T) 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
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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 re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, ButtonInfo, LogLevel, 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]:
|
||||
for _ in range(30):
|
||||
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||
try:
|
||||
await c.connect(login=True)
|
||||
ents, services = await c.list_entities_services()
|
||||
reboot = next(
|
||||
e.key for e in ents if isinstance(e, ButtonInfo) and e.name == "Reboot"
|
||||
)
|
||||
return (
|
||||
c,
|
||||
{s.name: s for s in services if isinstance(s, UserService)},
|
||||
reboot,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
await asyncio.sleep(2)
|
||||
raise RuntimeError("device did not come back")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
for img in IMAGES:
|
||||
c, actions, reboot = await connect()
|
||||
c.button_command(reboot)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
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,
|
||||
)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Build the large PNGs for the decode time test: a compressible gradient (also saved as ok.png so every server case
|
||||
carries a multi-row PNG), a flat 640x480 image whose few KB hold 307200 pixels, incompressible noise, and a JPEG."""
|
||||
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
www = Path(sys.argv[1])
|
||||
im = Image.new("RGB", (320, 240))
|
||||
px = im.load()
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
px[x, y] = (x * 255 // 319, y * 255 // 239, 128)
|
||||
im.save(www / "ok.png", "PNG", optimize=True)
|
||||
im.save(www / "gradient.png", "PNG", optimize=True)
|
||||
im.save(www / "gradient.jpg", "JPEG", quality=85)
|
||||
Image.new("RGB", (640, 480), (40, 90, 200)).save(www / "flat.png", "PNG", optimize=True)
|
||||
random.seed(1)
|
||||
noise = Image.new("RGB", (320, 240))
|
||||
npx = noise.load()
|
||||
for y in range(240):
|
||||
for x in range(320):
|
||||
npx[x, y] = (
|
||||
random.randrange(256),
|
||||
random.randrange(256),
|
||||
random.randrange(256),
|
||||
)
|
||||
noise.save(www / "noise.png", "PNG")
|
||||
for p in sorted(www.iterdir()):
|
||||
print(p.name, p.stat().st_size)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Write big BMP (1, 8, 24 bit) and QOI test images into a www directory."""
|
||||
|
||||
from pathlib import Path
|
||||
import random
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
www = Path(sys.argv[1])
|
||||
|
||||
|
||||
def gradient(w, h):
|
||||
im = Image.new("RGB", (w, h))
|
||||
im.putdata(
|
||||
[
|
||||
((x * 255) // w, (y * 255) // h, ((x + y) * 255) // (w + h))
|
||||
for y in range(h)
|
||||
for x in range(w)
|
||||
]
|
||||
)
|
||||
return im
|
||||
|
||||
|
||||
def checker(w, h):
|
||||
im = Image.new("1", (w, h))
|
||||
im.putdata(
|
||||
[255 if ((x // 16) + (y // 16)) % 2 else 0 for y in range(h) for x in range(w)]
|
||||
)
|
||||
return im
|
||||
|
||||
|
||||
checker(640, 480).save(www / "bmp1_640.bmp")
|
||||
|
||||
gradient(320, 240).convert("P", palette=Image.ADAPTIVE).save(www / "bmp8_320.bmp")
|
||||
gradient(320, 240).save(www / "bmp24_320.bmp")
|
||||
|
||||
|
||||
def qoi(im, path):
|
||||
im = im.convert("RGBA")
|
||||
w, h = im.size
|
||||
out = bytearray(b"qoif" + struct.pack(">IIBB", w, h, 4, 0))
|
||||
idx = [(0, 0, 0, 0)] * 64
|
||||
prev = (0, 0, 0, 255)
|
||||
run = 0
|
||||
px = list(im.getdata())
|
||||
for i, p in enumerate(px):
|
||||
if p == prev:
|
||||
run += 1
|
||||
if run == 62 or i == len(px) - 1:
|
||||
out.append(0xC0 | (run - 1))
|
||||
run = 0
|
||||
continue
|
||||
if run:
|
||||
out.append(0xC0 | (run - 1))
|
||||
run = 0
|
||||
h_ = (p[0] * 3 + p[1] * 5 + p[2] * 7 + p[3] * 11) % 64
|
||||
if idx[h_] == p:
|
||||
out.append(h_)
|
||||
else:
|
||||
idx[h_] = p
|
||||
if p[3] == prev[3]:
|
||||
dr, dg, db = (
|
||||
((p[0] - prev[0] + 128) % 256) - 128,
|
||||
((p[1] - prev[1] + 128) % 256) - 128,
|
||||
((p[2] - prev[2] + 128) % 256) - 128,
|
||||
)
|
||||
drg, dbg = dr - dg, db - dg
|
||||
if -2 <= dr <= 1 and -2 <= dg <= 1 and -2 <= db <= 1:
|
||||
out.append(0x40 | (dr + 2) << 4 | (dg + 2) << 2 | (db + 2))
|
||||
elif -32 <= dg <= 31 and -8 <= drg <= 7 and -8 <= dbg <= 7:
|
||||
out += bytes([0x80 | (dg + 32), (drg + 8) << 4 | (dbg + 8)])
|
||||
else:
|
||||
out += bytes([0xFE, p[0], p[1], p[2]])
|
||||
else:
|
||||
out += bytes([0xFF, *p])
|
||||
prev = p
|
||||
out += b"\x00" * 7 + b"\x01"
|
||||
path.write_bytes(out)
|
||||
|
||||
|
||||
qoi(
|
||||
Image.new("RGB", (640, 480), (0, 0, 0)).copy(), www / "qoi_flat.qoi"
|
||||
) # all runs, 62 pixels a byte
|
||||
qoi(checker(640, 480).convert("RGB"), www / "qoi_check.qoi")
|
||||
qoi(gradient(320, 240), www / "qoi_grad.qoi")
|
||||
random.seed(1)
|
||||
noise = Image.frombytes(
|
||||
"RGB", (320, 240), bytes(random.getrandbits(8) for _ in range(320 * 240 * 3))
|
||||
)
|
||||
qoi(noise, www / "qoi_noise.qoi")
|
||||
for f in sorted(www.glob("*_*.*")):
|
||||
print(f.name, f.stat().st_size)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Build the JPEGs for the streaming decode test: noise 320x240 (also ok.jpg) and 640x480, a 640x480 gradient, the
|
||||
320x240 noise with a 12 KB EXIF segment in front of the frame header, and a progressive one."""
|
||||
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
www = Path(sys.argv[1])
|
||||
random.seed(2)
|
||||
|
||||
|
||||
def noise(w: int, h: int) -> Image.Image:
|
||||
im = Image.new("RGB", (w, h))
|
||||
px = im.load()
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
px[x, y] = (
|
||||
random.randrange(256),
|
||||
random.randrange(256),
|
||||
random.randrange(256),
|
||||
)
|
||||
return im
|
||||
|
||||
|
||||
n320 = noise(320, 240)
|
||||
n320.save(www / "noise320.jpg", "JPEG", quality=90)
|
||||
n320.save(www / "ok.jpg", "JPEG", quality=90)
|
||||
noise(640, 480).save(www / "noise640.jpg", "JPEG", quality=85)
|
||||
g = Image.new("RGB", (640, 480))
|
||||
px = g.load()
|
||||
for y in range(480):
|
||||
for x in range(640):
|
||||
px[x, y] = (x * 255 // 639, y * 255 // 479, 128)
|
||||
g.save(www / "gradient640.jpg", "JPEG", quality=85)
|
||||
n320.save(
|
||||
www / "bigexif320.jpg", "JPEG", quality=90, exif=b"Exif\x00\x00" + bytes(12000)
|
||||
)
|
||||
n320.save(www / "progressive320.jpg", "JPEG", quality=90, progressive=True)
|
||||
for p in sorted(www.iterdir()):
|
||||
print(p.name, p.stat().st_size)
|
||||
@@ -5,7 +5,16 @@ from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
WWW = Path(sys.argv[1])
|
||||
WWW = Path(sys.argv[1]).resolve()
|
||||
NOT_FOUND = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
|
||||
|
||||
def www_file(rel: str) -> Path | None:
|
||||
"""The file under WWW named by a request path, or None if missing or outside WWW."""
|
||||
file = (WWW / rel).resolve()
|
||||
return file if file.is_relative_to(WWW) and file.is_file() else None
|
||||
|
||||
|
||||
PORT = int(sys.argv[2])
|
||||
LOG = Path(sys.argv[3]).open("a", encoding="utf-8", buffering=1) # noqa: SIM115
|
||||
|
||||
@@ -108,8 +117,34 @@ async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str:
|
||||
if name == "huge": # claims 20 MB so the JPEG buffer resize fails
|
||||
w.write(headers(ctype, [b"Content-Length: 20000000"]) + body)
|
||||
return f"claims 20000000, sent {len(body)} bytes"
|
||||
w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||
return "404"
|
||||
prefix, _, rest = name.partition("_")
|
||||
if prefix in ("half", "noclen", "chunked", "stall") and rest:
|
||||
# 404 when the base file is missing, so a typo never passes as the misbehaviour under test
|
||||
file = www_file(rest + "." + ext)
|
||||
else:
|
||||
prefix, file = "", www_file(path.lstrip("/"))
|
||||
if file is None:
|
||||
w.write(NOT_FOUND)
|
||||
return "404"
|
||||
data = file.read_bytes()
|
||||
if prefix == "half": # the first half of a file, Content-Length matching
|
||||
cut = data[: len(data) // 2]
|
||||
w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut)
|
||||
return f"half {len(cut)} of {len(data)} bytes"
|
||||
if prefix == "noclen": # a file without Content-Length, delimited by close
|
||||
w.write(headers(ctype, []) + data)
|
||||
return f"no content-length, full {len(data)} bytes"
|
||||
if prefix == "chunked": # a file in 1 KB chunks
|
||||
w.write(headers(ctype, [b"Transfer-Encoding: chunked"]) + chunked(data, 1024))
|
||||
return f"chunked full {len(data)} bytes"
|
||||
if prefix == "stall": # claims the full length, sends a third, holds the socket
|
||||
part = data[: len(data) // 3]
|
||||
w.write(headers(ctype, [b"Content-Length: %d" % len(data)]) + part)
|
||||
await w.drain()
|
||||
await asyncio.sleep(40)
|
||||
return f"stalled after {len(part)} of {len(data)} bytes"
|
||||
w.write(headers(ctype, [b"Content-Length: %d" % len(data), *tag]) + data)
|
||||
return f"file {file.name} {len(data)} bytes"
|
||||
|
||||
|
||||
async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Fetch images through the online_image API actions and report outcome, time, loop time maximum and heap minimum per case.
|
||||
usage: [CASES=png|noise|end|bmpqoi|short|jpeg] pngdrive.py HOST KEY LOGFILE BASEURL LABEL
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import APIClient, LogLevel, SensorInfo, SensorState, UserService
|
||||
|
||||
HOST, KEY, LOGFILE, BASE, LABEL = sys.argv[1:6]
|
||||
CAP_S = 60
|
||||
OUTCOME = re.compile(r"download (finished|error): (\w+)")
|
||||
INTEREST = re.compile(
|
||||
r"took a long time|Error|error|Download ended|No data received|Waiting for more|Decoded|cut off|already being|headers do not fit|buffer_size|Downloading image|fully downloaded|allocat|not caching"
|
||||
)
|
||||
|
||||
PNG = [
|
||||
("gradient 320x240 png", "fetch", "gradient.png", "finished"),
|
||||
("gradient again", "fetch", "gradient.png", "finished"),
|
||||
("gradient again", "fetch", "gradient.png", "finished"),
|
||||
("flat 640x480 png", "fetch", "flat.png", "finished"),
|
||||
("flat again", "fetch", "flat.png", "finished"),
|
||||
("flat again", "fetch", "flat.png", "finished"),
|
||||
("gradient jpeg", "fetch", "gradient.jpg", "finished"),
|
||||
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
|
||||
("ok png 16x16 body = gradient", "fetch", "ok.png", "finished"),
|
||||
("chunked png", "fetch", "chunked.png", "finished"),
|
||||
("chunked png cut in half", "fetch", "chunked_cut.png", "any"),
|
||||
("closed early png", "fetch", "closed_early.png", "error"),
|
||||
("stalled png", "fetch", "stall.png", "error"),
|
||||
("slow png", "fetch", "slow.png", "finished"),
|
||||
("empty png", "fetch", "empty.png", "error"),
|
||||
("no content-length png", "fetch", "noclen.png", "any"),
|
||||
("truncated bmp", "fetch", "truncated.bmp", "error"),
|
||||
("gradient after errors", "fetch", "gradient.png", "finished"),
|
||||
("gradient refetch 304", "refetch", "", "finished"),
|
||||
("noise 320x240 png", "fetch", "noise.png", "finished"),
|
||||
("noise again", "fetch", "noise.png", "finished"),
|
||||
]
|
||||
|
||||
NOISE = [("noise 320x240 png", "fetch", "noise.png", "finished")] * 3
|
||||
|
||||
END = [
|
||||
(f"{pre}{img}", "fetch", f"{pre}{img}", exp)
|
||||
for img in ("flat.png", "gradient.png")
|
||||
for pre, exp in (
|
||||
("", "finished"),
|
||||
("noclen_", "finished"),
|
||||
("chunked_", "finished"),
|
||||
("half_", "any"),
|
||||
("stall_", "error"),
|
||||
)
|
||||
] + [("gradient after", "fetch", "gradient.png", "finished")]
|
||||
|
||||
BMPQOI = [
|
||||
(f"{pre}{img}", "fetch", f"{pre}{img}", exp)
|
||||
for img in (
|
||||
"bmp1_640.bmp",
|
||||
"bmp8_320.bmp",
|
||||
"bmp24_320.bmp",
|
||||
"qoi_flat.qoi",
|
||||
"qoi_check.qoi",
|
||||
"qoi_grad.qoi",
|
||||
"qoi_noise.qoi",
|
||||
)
|
||||
for pre, exp in (
|
||||
("", "finished"),
|
||||
("", "finished"),
|
||||
("noclen_", "any"),
|
||||
("chunked_", "any"),
|
||||
("half_", "error"),
|
||||
)
|
||||
] + [
|
||||
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
|
||||
("ok qoi 16x16", "fetch", "ok.qoi", "finished"),
|
||||
("truncated bmp", "fetch", "truncated.bmp", "error"),
|
||||
("stall_bmp1_640.bmp", "fetch", "stall_bmp1_640.bmp", "error"),
|
||||
("stall_qoi_flat.qoi", "fetch", "stall_qoi_flat.qoi", "error"),
|
||||
("qoi_flat after", "fetch", "qoi_flat.qoi", "finished"),
|
||||
]
|
||||
|
||||
SHORT = [
|
||||
("gradient 320x240 png", "fetch", "gradient.png", "finished"),
|
||||
("gradient again", "fetch", "gradient.png", "finished"),
|
||||
("noise 320x240 png", "fetch", "noise.png", "finished"),
|
||||
("noise again", "fetch", "noise.png", "finished"),
|
||||
("noise again", "fetch", "noise.png", "finished"),
|
||||
("gradient after noise", "fetch", "gradient.png", "finished"),
|
||||
]
|
||||
|
||||
JPEG = [
|
||||
("noise 320x240 jpeg 69 KB", "fetch", "noise320.jpg", "finished"),
|
||||
("noise 320x240 again", "fetch", "noise320.jpg", "finished"),
|
||||
("noise 320x240 again", "fetch", "noise320.jpg", "finished"),
|
||||
("noise 640x480 jpeg 233 KB", "fetch", "noise640.jpg", "finished"),
|
||||
("noise 640x480 again", "fetch", "noise640.jpg", "finished"),
|
||||
("gradient 640x480 jpeg 14 KB", "fetch", "gradient640.jpg", "finished"),
|
||||
("4 KB buffer: noise 320x240", "fetch_small", "noise320.jpg", "finished"),
|
||||
("4 KB buffer: noise 640x480", "fetch_small", "noise640.jpg", "finished"),
|
||||
("4 KB buffer: 12 KB exif", "fetch_small", "bigexif320.jpg", "any"),
|
||||
("64 KB buffer: 12 KB exif", "fetch", "bigexif320.jpg", "finished"),
|
||||
("progressive jpeg", "fetch", "progressive320.jpg", "any"),
|
||||
("no content-length", "fetch", "noclen_noise320.jpg", "finished"),
|
||||
("chunked", "fetch", "chunked_noise320.jpg", "finished"),
|
||||
("half with matching length", "fetch", "half_noise320.jpg", "error"),
|
||||
("closed early", "fetch", "closed_early.jpg", "error"),
|
||||
("stalled after a third", "fetch", "stall_noise320.jpg", "error"),
|
||||
("empty", "fetch", "empty.jpg", "error"),
|
||||
("noise 320x240 after errors", "fetch", "noise320.jpg", "finished"),
|
||||
("refetch 304", "refetch", "", "finished"),
|
||||
("ok png 16x16", "fetch", "ok.png", "finished"),
|
||||
("ok bmp 16x16", "fetch", "ok.bmp", "finished"),
|
||||
("4 KB buffer: noise 640x480 again", "fetch_small", "noise640.jpg", "finished"),
|
||||
("4 KB buffer: half", "fetch_small", "half_noise320.jpg", "error"),
|
||||
("4 KB buffer: stalled", "fetch_small", "stall_noise320.jpg", "error"),
|
||||
(
|
||||
"4 KB buffer: noise 320x240 after errors",
|
||||
"fetch_small",
|
||||
"noise320.jpg",
|
||||
"finished",
|
||||
),
|
||||
]
|
||||
|
||||
CASE_SETS = {
|
||||
"png": PNG,
|
||||
"noise": NOISE,
|
||||
"end": END,
|
||||
"bmpqoi": BMPQOI,
|
||||
"short": SHORT,
|
||||
"jpeg": JPEG,
|
||||
}
|
||||
CASES = CASE_SETS[os.environ.get("CASES", "png")]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=KEY)
|
||||
await c.connect(login=True)
|
||||
ents, services = await c.list_entities_services()
|
||||
actions = {s.name: s for s in services if isinstance(s, UserService)}
|
||||
loop_key = next(
|
||||
e.key for e in ents if isinstance(e, SensorInfo) and e.name == "Loop Time"
|
||||
)
|
||||
heap_key = next(
|
||||
e.key for e in ents if isinstance(e, SensorInfo) and e.name == "Heap Free"
|
||||
)
|
||||
state = {"loop": [], "heap": None, "heap_min": None, "lines": []}
|
||||
|
||||
def on_state(s) -> None:
|
||||
if isinstance(s, SensorState) and s.key == loop_key:
|
||||
state["loop"].append(s.state)
|
||||
elif isinstance(s, SensorState) and s.key == heap_key:
|
||||
state["heap"] = s.state
|
||||
if state["heap_min"] is None or s.state < state["heap_min"]:
|
||||
state["heap_min"] = s.state
|
||||
|
||||
def on_log(msg) -> None:
|
||||
line = msg.message.decode(errors="replace")
|
||||
log.write(f"{time.strftime('%H:%M:%S')} {line}\n")
|
||||
state["lines"].append(line)
|
||||
|
||||
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(3)
|
||||
print(f"{LABEL}: heap at start {state['heap']}")
|
||||
rows = []
|
||||
for label, action, path, expect in CASES:
|
||||
img = "img_small" if action.endswith("_small") else "img"
|
||||
state["loop"].clear()
|
||||
state["lines"].clear()
|
||||
state["heap_min"] = None
|
||||
args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"}
|
||||
t0 = time.monotonic()
|
||||
log.write(f">>> {label} {path}\n")
|
||||
try:
|
||||
await c.execute_service(actions[action], args)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" api call failed ({exc!r}); reconnecting", flush=True)
|
||||
with contextlib.suppress(Exception):
|
||||
await c.disconnect()
|
||||
await asyncio.sleep(10)
|
||||
await c.connect(login=True)
|
||||
ents, services = await c.list_entities_services()
|
||||
actions = {s.name: s for s in services if isinstance(s, UserService)}
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(2)
|
||||
state["lines"].clear()
|
||||
t0 = time.monotonic()
|
||||
await c.execute_service(actions[action], args)
|
||||
outcome = "no outcome"
|
||||
while time.monotonic() - t0 < CAP_S:
|
||||
await asyncio.sleep(0.25)
|
||||
for line in state["lines"]:
|
||||
m = OUTCOME.search(line)
|
||||
if m and m.group(2) == img:
|
||||
outcome = m.group(1)
|
||||
if outcome != "no outcome":
|
||||
break
|
||||
took = time.monotonic() - t0
|
||||
await asyncio.sleep(4.5) # two loop time reports after the case
|
||||
notes = [
|
||||
ln for ln in state["lines"] if INTEREST.search(ln) and "Loop" not in ln
|
||||
]
|
||||
loop_max = max(state["loop"]) if state["loop"] else float("nan")
|
||||
ok = outcome == expect or (expect == "any" and outcome != "no outcome")
|
||||
rows.append((label, path, expect, outcome, took, loop_max, ok))
|
||||
print(
|
||||
f"{'PASS' if ok else 'FAIL'} {label:30s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s loop max {loop_max:7.0f} ms heap min {state['heap_min']}",
|
||||
flush=True,
|
||||
)
|
||||
for n in notes[:8]:
|
||||
print(f" {n.strip()}", flush=True)
|
||||
await c.disconnect()
|
||||
print(
|
||||
"\nSUMMARY",
|
||||
"all expectations met"
|
||||
if all(r[6] for r in rows)
|
||||
else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[6]),
|
||||
)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Drive the FrameReader simulation builds over the API: press buttons, set selects/numbers, print states.
|
||||
usage: frdrive.py HOST LOGFILE OP [OP ...]
|
||||
OP: list | press:NAME | sel:NAME=OPTION | num:NAME=VALUE | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
BinarySensorState,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
NumberInfo,
|
||||
SelectInfo,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
)
|
||||
|
||||
HOST, LOGFILE = sys.argv[1], sys.argv[2]
|
||||
OPS = sys.argv[3:]
|
||||
T0 = time.time()
|
||||
SKIP = {"Heap Free", "Loop 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} compiled {info.compilation_time}", flush=True)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
|
||||
def find(kind: type, name: str):
|
||||
for e in ents:
|
||||
if isinstance(e, kind) and e.name == name:
|
||||
return e
|
||||
sys.exit(f"no {kind.__name__} named {name!r}")
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None or e.name in SKIP:
|
||||
return
|
||||
if isinstance(s, SensorState) and isinstance(e, SensorInfo):
|
||||
v = "nan" if math.isnan(s.state) else f"{s.state:.2f}"
|
||||
line = f"{stamp()} SEN {e.name} = {v}"
|
||||
elif isinstance(s, BinarySensorState):
|
||||
line = f"{stamp()} BIN {e.name} = {s.state}"
|
||||
else:
|
||||
return
|
||||
print(line, flush=True)
|
||||
log.write(line + "\n")
|
||||
|
||||
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_VERBOSE)
|
||||
await asyncio.sleep(2)
|
||||
for op in OPS:
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "list":
|
||||
for e in ents:
|
||||
print(type(e).__name__, e.name)
|
||||
continue
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
continue
|
||||
line = f"{stamp()} >>> {op}"
|
||||
print(line, flush=True)
|
||||
log.write(line + "\n")
|
||||
if kind == "press":
|
||||
c.button_command(find(ButtonInfo, rest).key)
|
||||
elif kind == "sel":
|
||||
name, _, value = rest.partition("=")
|
||||
c.select_command(find(SelectInfo, name).key, value)
|
||||
elif kind == "num":
|
||||
name, _, value = rest.partition("=")
|
||||
c.number_command(find(NumberInfo, name).key, float(value))
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(2.5)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/zsh
|
||||
# usage: run.sh NAME TREE SECONDS OP... flash over serial, capture serial, drive over the API
|
||||
# Copy this, frdrive.py and the templates/uart_sim files into one scratch directory and run it there.
|
||||
# env: PORT (the devkit serial port), SC (path to serial_capture.py); TREE is the esphome checkout to build from
|
||||
D=${0:A:h}; N=$1; T=$2; S=$3; shift 3; P=${PORT:?set PORT}; PY=$T/venv/bin/python
|
||||
(cd $T && PYTHONUNBUFFERED=1 timeout 300 venv/bin/python -m esphome upload $D/$N.yaml --device $P > $D/$N-upload.log 2>&1) || { echo "upload failed"; exit 1; }
|
||||
$PY ${SC:?set SC} $P $S $D/$N-cap.txt > $D/$N-cap.err 2>&1 &
|
||||
CAP=$!
|
||||
for i in $(seq 1 40); do $PY -c "import socket;socket.create_connection(('$N.local',6053),2)" 2>/dev/null && break; sleep 2; done
|
||||
cd $D && PYTHONUNBUFFERED=1 timeout $((S - 5)) $PY frdrive.py $N.local $N-api.log "$@" > $N-drive.txt 2>&1; echo "drive rc=$?"
|
||||
wait $CAP
|
||||
grep -acE 'Guru|abort\(\)|rst:0x' $D/$N-cap.txt
|
||||
@@ -0,0 +1,150 @@
|
||||
substitutions:
|
||||
# the fake instrument's BLE address; pass it with `-s peri_mac AA:BB:CC:DD:EE:FF`
|
||||
peri_mac: "00:00:00:00:00:00"
|
||||
|
||||
esphome:
|
||||
name: midicli
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble_tracker: DEBUG
|
||||
ble_client: DEBUG
|
||||
esp32_ble: DEBUG
|
||||
api: DEBUG
|
||||
scheduler: DEBUG
|
||||
sensor: DEBUG
|
||||
wifi: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
ble_client:
|
||||
- mac_address: ${peri_mac}
|
||||
id: midi_client
|
||||
|
||||
ble_midi:
|
||||
id: test_midi
|
||||
ble_client_id: midi_client
|
||||
max_sysex_size: 64
|
||||
pair: false
|
||||
on_connect:
|
||||
- logger.log: "MIDI device connected"
|
||||
- ble_midi.send_program_change:
|
||||
id: test_midi
|
||||
program: 0
|
||||
on_disconnect:
|
||||
- logger.log: "MIDI device disconnected"
|
||||
on_note_on:
|
||||
- logger.log:
|
||||
format: "Note on %u velocity %u channel %u"
|
||||
args: [note, velocity, channel]
|
||||
on_note_off:
|
||||
- logger.log:
|
||||
format: "Note off %u channel %u"
|
||||
args: [note, channel]
|
||||
on_control_change:
|
||||
- logger.log:
|
||||
format: "CC %u = %u on channel %u"
|
||||
args: [control_number, value, channel]
|
||||
on_program_change:
|
||||
- logger.log:
|
||||
format: "Program %u on channel %u"
|
||||
args: [program, channel]
|
||||
on_pitch_bend:
|
||||
- logger.log:
|
||||
format: "Pitch bend %d on channel %u"
|
||||
args: [value, channel]
|
||||
on_sysex:
|
||||
- lambda: |-
|
||||
ESP_LOGI("midi", "SysEx of %u bytes: %s", (unsigned) data.size(), format_hex_pretty(data.data(), data.size()).c_str());
|
||||
on_message:
|
||||
- lambda: |-
|
||||
ESP_LOGI("midi", "Raw message of %u bytes: %s", (unsigned) data.size(), format_hex_pretty(data.data(), data.size()).c_str());
|
||||
|
||||
sensor:
|
||||
- platform: debug
|
||||
free:
|
||||
name: Heap Free
|
||||
loop_time:
|
||||
name: Loop Time
|
||||
|
||||
debug:
|
||||
update_interval: 30s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "MIDI send tests"
|
||||
on_press:
|
||||
- ble_midi.send_note_on:
|
||||
id: test_midi
|
||||
note: 60
|
||||
- ble_midi.send_note_on:
|
||||
id: test_midi
|
||||
note: !lambda "return 60;"
|
||||
velocity: !lambda "return 100;"
|
||||
channel: !lambda "return 1;"
|
||||
- ble_midi.send_note_off:
|
||||
id: test_midi
|
||||
note: 60
|
||||
velocity: 0
|
||||
channel: 1
|
||||
- ble_midi.send_control_change:
|
||||
id: test_midi
|
||||
control_number: 7
|
||||
value: 64
|
||||
- ble_midi.send_pitch_bend:
|
||||
id: test_midi
|
||||
value: -8192
|
||||
- ble_midi.send_sysex:
|
||||
id: test_midi
|
||||
payload: [0x7E, 0x7F, 0x06, 0x01]
|
||||
- ble_midi.send_raw:
|
||||
id: test_midi
|
||||
data: !lambda "return std::vector<uint8_t>{0x90, 0x3C, 0x40};"
|
||||
- if:
|
||||
condition:
|
||||
ble_midi.connected:
|
||||
id: test_midi
|
||||
then:
|
||||
- logger.log: "Still connected"
|
||||
else:
|
||||
- logger.log: "Not connected"
|
||||
- platform: template
|
||||
name: "MIDI send out of range"
|
||||
on_press:
|
||||
- ble_midi.send_note_on:
|
||||
id: test_midi
|
||||
note: !lambda "return 200;"
|
||||
- ble_midi.send_control_change:
|
||||
id: test_midi
|
||||
control_number: 1
|
||||
value: 1
|
||||
channel: !lambda "return 16;"
|
||||
- ble_midi.send_pitch_bend:
|
||||
id: test_midi
|
||||
value: 8191
|
||||
- ble_midi.send_sysex:
|
||||
id: test_midi
|
||||
payload: [0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7]
|
||||
- ble_midi.send_raw:
|
||||
id: test_midi
|
||||
data: !lambda "return std::vector<uint8_t>(140, 0x00);"
|
||||
- platform: template
|
||||
name: "MIDI send long sysex"
|
||||
on_press:
|
||||
- ble_midi.send_sysex:
|
||||
id: test_midi
|
||||
payload: !lambda "return std::vector<uint8_t>(100, 0x11);"
|
||||
@@ -0,0 +1,177 @@
|
||||
esphome:
|
||||
name: midiperi
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
esp32_ble_server:
|
||||
id: ble_server
|
||||
manufacturer: FakeMIDI
|
||||
on_connect:
|
||||
- lambda: ESP_LOGI("peri", "central connected %d", id);
|
||||
on_disconnect:
|
||||
- lambda: ESP_LOGI("peri", "central disconnected %d", id);
|
||||
services:
|
||||
- uuid: 03B80E5A-EDE8-4B33-A751-6CE34EC4C700
|
||||
advertise: true
|
||||
characteristics:
|
||||
- id: midi_io
|
||||
uuid: 7772E5DB-3868-4112-A1A9-F2669D106BF3
|
||||
read: true
|
||||
write: true
|
||||
write_no_response: true
|
||||
notify: true
|
||||
on_write:
|
||||
- lambda: |-
|
||||
ESP_LOGI("peri", "RX %u bytes: %s", (unsigned) x.size(), format_hex_pretty(x.data(), x.size()).c_str());
|
||||
|
||||
script:
|
||||
- id: emit
|
||||
parameters:
|
||||
pkt: std::vector<uint8_t>
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("peri", "TX %u bytes: %s", (unsigned) pkt.size(), format_hex_pretty(pkt.data(), pkt.size()).c_str());
|
||||
id(midi_io).set_value(std::vector<uint8_t>(pkt));
|
||||
id(midi_io).notify();
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
name: P Restart
|
||||
- platform: template
|
||||
name: P Note On
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x3C, 0x64};'
|
||||
- platform: template
|
||||
name: P Note Off
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x80, 0x3C, 0x00};'
|
||||
- platform: template
|
||||
name: P Control Change
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xB0, 0x07, 0x40};'
|
||||
- platform: template
|
||||
name: P Program Change
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xC5, 0x05};'
|
||||
- platform: template
|
||||
name: P Pitch Bend
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xE0, 0x7F, 0x7F};'
|
||||
- platform: template
|
||||
name: P Two Notes One Packet
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x3C, 0x64, 0x81, 0x91, 0x3E, 0x50};'
|
||||
- platform: template
|
||||
name: P Running Status
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x40, 0x64, 0x41, 0x64};'
|
||||
- platform: template
|
||||
name: P Sysex One Packet
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x7E, 0x7F, 0x06, 0x01, 0x80, 0xF7};'
|
||||
- platform: template
|
||||
name: P Sysex Split
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x41, 0x10, 0x42};'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x12, 0x00, 0x40, 0x80, 0xF7};'
|
||||
- platform: template
|
||||
name: P Sysex Oversize
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'std::vector<uint8_t> v{0x80, 0x80, 0xF0}; for (int i = 0; i < 17; i++) v.push_back(i); return v;'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x20 + i); return v;'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x40 + i); return v;'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x60 + i); return v;'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF7};'
|
||||
- platform: template
|
||||
name: P Sysex Cut Short
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x41, 0x10, 0x80, 0x90, 0x3C, 0x64};'
|
||||
- platform: template
|
||||
name: P Clock
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF8};'
|
||||
- platform: template
|
||||
name: P Aftertouch
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xD3, 0x45};'
|
||||
- platform: template
|
||||
name: P Orphan Data Byte
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x3C, 0x64};'
|
||||
- platform: template
|
||||
name: P Burst
|
||||
on_press:
|
||||
- repeat:
|
||||
count: 50
|
||||
then:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x30, 0x40, 0x80, 0x80, 0x30, 0x00};'
|
||||
- delay: 20ms
|
||||
@@ -0,0 +1,159 @@
|
||||
# Package for the http_request action test (esphome#21486). Needs substitutions: name, bufsize.
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
actions:
|
||||
- action: req_get
|
||||
variables:
|
||||
url: string
|
||||
then:
|
||||
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); id(tag)++; ESP_LOGI("hrt", "START %d %s %s", id(tag), "GET", url.c_str());'
|
||||
- http_request.get:
|
||||
url: !lambda return url;
|
||||
capture_response: true
|
||||
max_response_buffer_size: ${bufsize}
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
uint32_t sum = 0;
|
||||
for (char c : body) sum += (uint8_t) c;
|
||||
ESP_LOGI("hrt", "RESULT %d status=%d len=%u sum=%u took=%u maxgap=%u head=%.20s", id(tag),
|
||||
response->status_code, (unsigned) body.size(), (unsigned) sum,
|
||||
(unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)), body.c_str());
|
||||
on_error:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "RESULT %d error took=%u maxgap=%u", id(tag), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
|
||||
- lambda: 'ESP_LOGI("hrt", "CHAIN %d after send", id(tag));'
|
||||
- action: req_post
|
||||
variables:
|
||||
url: string
|
||||
payload: string
|
||||
then:
|
||||
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); id(tag)++; ESP_LOGI("hrt", "START %d %s %s", id(tag), "POST", url.c_str());'
|
||||
- http_request.post:
|
||||
url: !lambda return url;
|
||||
body: !lambda return payload;
|
||||
capture_response: true
|
||||
max_response_buffer_size: ${bufsize}
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
uint32_t sum = 0;
|
||||
for (char c : body) sum += (uint8_t) c;
|
||||
ESP_LOGI("hrt", "RESULT %d status=%d len=%u sum=%u took=%u maxgap=%u head=%.20s", id(tag),
|
||||
response->status_code, (unsigned) body.size(), (unsigned) sum,
|
||||
(unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)), body.c_str());
|
||||
on_error:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "RESULT %d error took=%u maxgap=%u", id(tag), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
|
||||
- lambda: 'ESP_LOGI("hrt", "CHAIN %d after send", id(tag));'
|
||||
- action: burst
|
||||
variables:
|
||||
url: string
|
||||
count: int
|
||||
then:
|
||||
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); ESP_LOGI("hrt", "BURST %d", count);'
|
||||
- repeat:
|
||||
count: !lambda return count;
|
||||
then:
|
||||
- script.execute:
|
||||
id: burst_script
|
||||
url: !lambda return url;
|
||||
- action: cancel
|
||||
variables:
|
||||
url: string
|
||||
then:
|
||||
- lambda: 'id(max_gap) = 0; id(t_start) = millis(); ESP_LOGI("hrt", "CANCEL start");'
|
||||
- script.execute:
|
||||
id: single_script
|
||||
url: !lambda return url;
|
||||
- delay: 300ms
|
||||
- script.execute:
|
||||
id: single_script
|
||||
url: !lambda return url;
|
||||
- delay: 300ms
|
||||
- script.stop: single_script
|
||||
- lambda: 'ESP_LOGI("hrt", "CANCEL stopped running=%d", id(single_script).is_running());'
|
||||
- delay: 4s
|
||||
- lambda: 'ESP_LOGI("hrt", "CANCEL done maxgap=%u", (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
|
||||
|
||||
- action: reboot_during
|
||||
variables:
|
||||
url: string
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "REBOOT_DURING start");'
|
||||
- script.execute:
|
||||
id: single_script
|
||||
url: !lambda return url;
|
||||
- script.execute:
|
||||
id: burst_script
|
||||
url: !lambda return url;
|
||||
- delay: 500ms
|
||||
- lambda: 'ESP_LOGI("hrt", "REBOOT_DURING rebooting"); App.safe_reboot();'
|
||||
|
||||
script:
|
||||
- id: burst_script
|
||||
mode: parallel
|
||||
max_runs: 10
|
||||
parameters:
|
||||
url: string
|
||||
then:
|
||||
- http_request.get:
|
||||
url: !lambda return url;
|
||||
capture_response: true
|
||||
max_response_buffer_size: 256
|
||||
on_response:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "BURSTRESP status=%d len=%u took=%u maxgap=%u", response->status_code, (unsigned) body.size(), (unsigned) (millis() - id(t_start)), (unsigned) std::max<uint32_t>(id(max_gap), millis() - id(last_tick)));'
|
||||
on_error:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "BURSTERR took=%u", (unsigned) (millis() - id(t_start)));'
|
||||
- id: single_script
|
||||
parameters:
|
||||
url: string
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "SINGLE begin");'
|
||||
- http_request.get:
|
||||
url: !lambda return url;
|
||||
capture_response: true
|
||||
max_response_buffer_size: 256
|
||||
on_response:
|
||||
then:
|
||||
- lambda: 'ESP_LOGI("hrt", "SINGLERESP status=%d took=%u", response->status_code, (unsigned) (millis() - id(t_start)));'
|
||||
- lambda: 'ESP_LOGI("hrt", "SINGLE end");'
|
||||
|
||||
http_request:
|
||||
timeout: 8s
|
||||
|
||||
globals:
|
||||
- id: max_gap
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: last_tick
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: t_start
|
||||
type: uint32_t
|
||||
initial_value: "0"
|
||||
- id: tag
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
interval:
|
||||
- interval: 1ms
|
||||
then:
|
||||
- lambda: |-
|
||||
const uint32_t now = millis();
|
||||
if (id(last_tick) != 0 && now - id(last_tick) > id(max_gap))
|
||||
id(max_gap) = now - id(last_tick);
|
||||
id(last_tick) = now;
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -0,0 +1,6 @@
|
||||
substitutions: {name: hr32pr, bufsize: "32768"}
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework: {type: esp-idf}
|
||||
logger:
|
||||
packages: {hr: !include hr-common.yaml}
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions: {name: hr66pr, bufsize: "8192"}
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
logger:
|
||||
packages: {hr: !include hr-common.yaml}
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: upd32
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../upd-common.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
esphome:
|
||||
name: upd8266
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../upd-common.yaml
|
||||
@@ -0,0 +1,52 @@
|
||||
substitutions:
|
||||
base: !secret manifest_base
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
timeout: 10s
|
||||
|
||||
ota:
|
||||
- platform: http_request
|
||||
id: ota_http
|
||||
|
||||
update:
|
||||
- platform: http_request
|
||||
id: upd
|
||||
name: Firmware
|
||||
source: ${base}/stable.json
|
||||
update_interval: 1h
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Check
|
||||
on_press:
|
||||
- lambda: id(upd).check();
|
||||
- platform: template
|
||||
name: URL Slow Cstr
|
||||
on_press:
|
||||
- lambda: |-
|
||||
std::string url = "${base}/slow.json";
|
||||
id(upd).set_source_url(url.c_str());
|
||||
- platform: template
|
||||
name: URL Stable Literal
|
||||
on_press:
|
||||
- lambda: id(upd).set_source_url("${base}/stable.json");
|
||||
- platform: template
|
||||
name: Heap
|
||||
on_press:
|
||||
- lambda: |-
|
||||
#ifdef USE_ESP8266
|
||||
ESP_LOGI("harness", "heap free %u", ESP.getFreeHeap());
|
||||
#else
|
||||
ESP_LOGI("harness", "heap free %u", (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||
#endif
|
||||
@@ -0,0 +1,95 @@
|
||||
substitutions:
|
||||
base: !secret manifest_base
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
on_message:
|
||||
- level: DEBUG
|
||||
then:
|
||||
- lambda: |-
|
||||
// Inline check path: replace the URL from inside the check (ESP8266 runs the check on the main loop)
|
||||
if (id(swap_in_check) && strstr(message, "Content-Length") != nullptr) {
|
||||
id(swap_in_check) = false;
|
||||
std::string url = "${base}/beta.json";
|
||||
id(upd).set_source_url(url);
|
||||
ESP_LOGI("harness", "URL replaced from inside the check");
|
||||
}
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
timeout: 10s
|
||||
|
||||
ota:
|
||||
- platform: http_request
|
||||
id: ota_http
|
||||
|
||||
update:
|
||||
- platform: http_request
|
||||
id: upd
|
||||
name: Firmware
|
||||
source: ${base}/stable.json
|
||||
update_interval: 1h
|
||||
|
||||
globals:
|
||||
- id: swap_in_check
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Check
|
||||
on_press:
|
||||
- lambda: id(upd).check();
|
||||
- platform: template
|
||||
name: URL Beta String
|
||||
on_press:
|
||||
- lambda: |-
|
||||
std::string url = "${base}/beta.json";
|
||||
id(upd).set_source_url(url);
|
||||
- platform: template
|
||||
name: URL Slow Cstr
|
||||
on_press:
|
||||
- lambda: |-
|
||||
std::string url = "${base}/slow.json";
|
||||
id(upd).set_source_url(url.c_str());
|
||||
- platform: template
|
||||
name: URL Stable Literal
|
||||
on_press:
|
||||
- lambda: id(upd).set_source_url("${base}/stable.json");
|
||||
- platform: template
|
||||
name: URL Temporary
|
||||
on_press:
|
||||
- lambda: id(upd).set_source_url(std::string("${base}/beta.json"));
|
||||
- platform: template
|
||||
name: Same URL Back
|
||||
on_press:
|
||||
- lambda: id(upd).set_source_url(id(upd).get_source_url());
|
||||
- platform: template
|
||||
name: Check Then Swap
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// ESP32: the check task reads slow.json while the URL is replaced under it
|
||||
id(upd).check();
|
||||
std::string url = "${base}/beta.json";
|
||||
id(upd).set_source_url(url);
|
||||
- platform: template
|
||||
name: Arm Swap In Check
|
||||
on_press:
|
||||
- lambda: id(swap_in_check) = true;
|
||||
- platform: template
|
||||
name: Heap
|
||||
on_press:
|
||||
- lambda: |-
|
||||
#ifdef USE_ESP8266
|
||||
ESP_LOGI("harness", "heap free %u", ESP.getFreeHeap());
|
||||
#else
|
||||
ESP_LOGI("harness", "heap free %u", (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||
#endif
|
||||
@@ -0,0 +1,65 @@
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
watchdog_timeout: 30s
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
modem:
|
||||
id: atmodem
|
||||
rx_pin: GPIO26
|
||||
tx_pin: GPIO27
|
||||
baud_rate: 115200
|
||||
model: SIM7600
|
||||
apn: internet
|
||||
pin_code: "0000"
|
||||
init_at:
|
||||
- ATI
|
||||
urc:
|
||||
name: Modem URC
|
||||
reboot_timeout: ${modem_reboot_timeout}
|
||||
on_connect:
|
||||
logger.log: "modem connected"
|
||||
on_disconnect:
|
||||
logger.log: "modem disconnected"
|
||||
on_sync:
|
||||
logger.log: "modem synced"
|
||||
|
||||
sensor:
|
||||
- platform: uptime
|
||||
name: Uptime
|
||||
update_interval: 30s
|
||||
- platform: debug
|
||||
free:
|
||||
name: Heap Free
|
||||
loop_time:
|
||||
name: Loop Time
|
||||
|
||||
debug:
|
||||
update_interval: 30s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Modem Enable
|
||||
on_press:
|
||||
- modem.enable
|
||||
- platform: template
|
||||
name: Modem Disable
|
||||
on_press:
|
||||
- modem.disable
|
||||
- platform: template
|
||||
name: Modem Send AT
|
||||
on_press:
|
||||
- modem.send_at: "AT+CGSN"
|
||||
|
||||
interval:
|
||||
- interval: 20s
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("alive", "uptime %u s enabled=%d connected=%d", (unsigned) (millis() / 1000),
|
||||
id(atmodem).is_enabled(), id(atmodem).is_connected());
|
||||
@@ -0,0 +1,26 @@
|
||||
substitutions:
|
||||
modem_reboot_timeout: 2min
|
||||
|
||||
esphome:
|
||||
name: modemo
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- modem.disable
|
||||
- modem.enable
|
||||
- if:
|
||||
condition:
|
||||
modem.enabled:
|
||||
then:
|
||||
modem.send_at: "AT+CGSN"
|
||||
else:
|
||||
modem.enable:
|
||||
- if:
|
||||
condition:
|
||||
not:
|
||||
modem.connected:
|
||||
then:
|
||||
logger.log: modem not connected at boot
|
||||
|
||||
packages:
|
||||
common: !include modem-common.yaml
|
||||
@@ -0,0 +1,12 @@
|
||||
substitutions:
|
||||
modem_reboot_timeout: 0s
|
||||
|
||||
esphome:
|
||||
name: modemw
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
packages:
|
||||
common: !include modem-common.yaml
|
||||
@@ -0,0 +1,105 @@
|
||||
esphome:
|
||||
name: oi-s3eth
|
||||
friendly_name: oi-s3eth
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
speed: 80MHz
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
|
||||
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
|
||||
actions:
|
||||
- action: fetch
|
||||
variables:
|
||||
url: string
|
||||
then:
|
||||
- online_image.set_url:
|
||||
id: img
|
||||
url: !lambda 'return url;'
|
||||
update: false
|
||||
- component.update: img
|
||||
- action: refetch
|
||||
then:
|
||||
- component.update: img
|
||||
- action: fetch_small
|
||||
variables:
|
||||
url: string
|
||||
then:
|
||||
- online_image.set_url:
|
||||
id: img_small
|
||||
url: !lambda 'return url;'
|
||||
update: false
|
||||
- component.update: img_small
|
||||
- action: refetch_small
|
||||
then:
|
||||
- component.update: img_small
|
||||
|
||||
http_request:
|
||||
timeout: 4.5s
|
||||
verify_ssl: false
|
||||
|
||||
display:
|
||||
|
||||
image:
|
||||
- platform: online_image
|
||||
id: img
|
||||
url: http://127.0.0.1/none.bmp
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
update_interval: never
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished: img cached=%d"
|
||||
args: [cached]
|
||||
on_error:
|
||||
logger.log: "download error: img"
|
||||
- platform: online_image
|
||||
id: img_small
|
||||
url: http://127.0.0.1/none.bmp
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
buffer_size: 256
|
||||
update_interval: never
|
||||
on_download_finished:
|
||||
logger.log:
|
||||
format: "download finished: img_small cached=%d"
|
||||
args: [cached]
|
||||
on_error:
|
||||
logger.log: "download error: img_small"
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
id: reboot_btn
|
||||
name: Reboot
|
||||
|
||||
debug:
|
||||
update_interval: 2s
|
||||
|
||||
sensor:
|
||||
- platform: debug
|
||||
loop_time:
|
||||
name: Loop Time
|
||||
free:
|
||||
name: Heap Free
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: sy8266
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
packages:
|
||||
common: !include ../sy-common.yaml
|
||||
|
||||
store_yaml:
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: sy8266s
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
packages:
|
||||
common: !include ../sy-common.yaml
|
||||
|
||||
store_yaml:
|
||||
include_secrets: true
|
||||
@@ -0,0 +1,24 @@
|
||||
# shared package for the store_yaml hardware test
|
||||
substitutions:
|
||||
greeting: "hello from the package"
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
password: recoverme123
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: Package Sensor
|
||||
lambda: return 42.0;
|
||||
update_interval: 60s
|
||||
|
||||
packages:
|
||||
extra: !include sy-extra.yaml
|
||||
@@ -0,0 +1,6 @@
|
||||
# second level include
|
||||
text_sensor:
|
||||
- platform: template
|
||||
name: Greeting
|
||||
lambda: return std::string("${greeting}");
|
||||
update_interval: 60s
|
||||
@@ -0,0 +1,35 @@
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
esphome:
|
||||
includes:
|
||||
- sim.hpp
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
component: DEBUG
|
||||
api.service: DEBUG
|
||||
api.connection: DEBUG
|
||||
scheduler: DEBUG
|
||||
sensor: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
debug:
|
||||
update_interval: 10s
|
||||
|
||||
sensor:
|
||||
- platform: debug
|
||||
free:
|
||||
name: Heap Free
|
||||
loop_time:
|
||||
name: Loop Time
|
||||
@@ -0,0 +1,86 @@
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
sim::loopback(id(u_w));
|
||||
sim::loopback(id(u_d));
|
||||
|
||||
uart:
|
||||
- id: u_w
|
||||
tx_pin: GPIO25
|
||||
rx_pin: GPIO26
|
||||
baud_rate: 9600
|
||||
- id: u_d
|
||||
tx_pin: GPIO32
|
||||
rx_pin: GPIO33
|
||||
baud_rate: 9600
|
||||
|
||||
sensor:
|
||||
- platform: wts01
|
||||
id: wts
|
||||
name: WTS01 Temperature
|
||||
uart_id: u_w
|
||||
accuracy_decimals: 2
|
||||
filters: []
|
||||
- platform: ds1603l
|
||||
name: DS1603L Distance
|
||||
uart_id: u_d
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: W Good
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_w), "W Good 22.18", sim::wts01(22, 18));
|
||||
- platform: template
|
||||
name: W Negative
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_w), "W Negative -5.50", sim::wts01(0x85, 50));
|
||||
- platform: template
|
||||
name: W Extra 55
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_w), "W Extra 55 then 23.25", sim::cat({0x55}, sim::wts01(23, 25)));
|
||||
- platform: template
|
||||
name: W 55 Breaks Header
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_w), "W 55 01 01 then 26.00", sim::cat({0x55, 0x01, 0x01}, sim::wts01(26, 0)));
|
||||
- platform: template
|
||||
name: W Bad Checksum
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_w), "W bad checksum 30.00", sim::wts01(30, 0, true));
|
||||
- platform: template
|
||||
name: W Garbage Then Frame
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_w), "W garbage then 24.50", sim::cat({0x01, 0x02, 0x55, 0x01, 0x99}, sim::wts01(24, 50)));
|
||||
- platform: template
|
||||
name: W Split
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_w), "W split head 27.75", sim::part(sim::wts01(27, 75), 0, 5));
|
||||
- delay: 300ms
|
||||
- lambda: sim::send(id(u_w), "W split rest", sim::part(sim::wts01(27, 75), 5, 9));
|
||||
- platform: template
|
||||
name: W Two Frames
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_w), "W 20.01 then 20.02", sim::cat(sim::wts01(20, 1), sim::wts01(20, 2)));
|
||||
- platform: template
|
||||
name: D Good
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_d), "D Good 1000", sim::ds1603l(1000));
|
||||
- platform: template
|
||||
name: D Misaligned
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_d), "D FF 01 then 1234", sim::cat({0xFF, 0x01}, sim::ds1603l(1234)));
|
||||
- platform: template
|
||||
name: D Bad Checksum
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_d), "D bad checksum 2000", sim::ds1603l(2000, true));
|
||||
- platform: template
|
||||
name: D Garbage Then Frame
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_d), "D garbage then 500", sim::cat({0x12, 0x34}, sim::ds1603l(500)));
|
||||
- platform: template
|
||||
name: D Split
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_d), "D split head 777", sim::part(sim::ds1603l(777), 0, 2));
|
||||
- delay: 300ms
|
||||
- lambda: sim::send(id(u_d), "D split rest", sim::part(sim::ds1603l(777), 2, 4));
|
||||
@@ -0,0 +1,6 @@
|
||||
# Run file for build a; copy it as frabase.yaml with name frabase for the base build.
|
||||
esphome:
|
||||
name: fra
|
||||
packages:
|
||||
common: !include fr-common.yaml
|
||||
parts: !include fra-parts.yaml
|
||||
@@ -0,0 +1,104 @@
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: 200
|
||||
then:
|
||||
- lambda: |-
|
||||
sim::loopback(id(u_s));
|
||||
sim::loopback(id(u_f));
|
||||
|
||||
uart:
|
||||
- id: u_s
|
||||
tx_pin: GPIO25
|
||||
rx_pin: GPIO26
|
||||
baud_rate: 9600
|
||||
- id: u_f
|
||||
tx_pin: GPIO32
|
||||
rx_pin: GPIO33
|
||||
baud_rate: 2400
|
||||
parity: EVEN
|
||||
|
||||
sensor:
|
||||
- platform: sm300d2
|
||||
id: sm
|
||||
uart_id: u_s
|
||||
update_interval: never
|
||||
co2:
|
||||
name: SM CO2
|
||||
temperature:
|
||||
name: SM Temperature
|
||||
- platform: ufm01
|
||||
temperature:
|
||||
name: UFM Temperature
|
||||
accumulated_flow:
|
||||
name: UFM Accumulated Flow
|
||||
|
||||
ufm01:
|
||||
id: ufm
|
||||
uart_id: u_f
|
||||
|
||||
interval:
|
||||
- interval: 1s
|
||||
then:
|
||||
- lambda: id(u_f).write_array(sim::ufm01(0x21, 0x50).data(), 32);
|
||||
|
||||
script:
|
||||
- id: sm_update
|
||||
then:
|
||||
- delay: 100ms
|
||||
- lambda: |-
|
||||
uint32_t t0 = micros();
|
||||
id(sm).update();
|
||||
ESP_LOGI("sim", "sm300d2 update() took %u us", (unsigned) (micros() - t0));
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: S Good
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_s), "S Good co2 600 23.4", sim::sm300d2(600, 23, 4));
|
||||
- script.execute: sm_update
|
||||
- platform: template
|
||||
name: S Empty Update
|
||||
on_press:
|
||||
- script.execute: sm_update
|
||||
- platform: template
|
||||
name: S Wrong Second Header
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_s), "S 3C 05 then co2 700", sim::cat({0x3C, 0x05}, sim::sm300d2(700, 24, 0)));
|
||||
- script.execute: sm_update
|
||||
- platform: template
|
||||
name: S Garbage Then Frame
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_s), "S garbage then co2 800", sim::cat({0x01, 0x3C, 0x99}, sim::sm300d2(800, 25, 0)));
|
||||
- script.execute: sm_update
|
||||
- platform: template
|
||||
name: S Cut Frame
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_s), "S first 10 bytes of co2 900", sim::part(sim::sm300d2(900, 26, 0), 0, 10));
|
||||
- script.execute: sm_update
|
||||
- platform: template
|
||||
name: S Two Frames
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_s), "S co2 1001 then 1002", sim::cat(sim::sm300d2(1001, 27, 0), sim::sm300d2(1002, 28, 0)));
|
||||
- script.execute: sm_update
|
||||
- delay: 1s
|
||||
- script.execute: sm_update
|
||||
- platform: template
|
||||
name: S Bad Checksum
|
||||
on_press:
|
||||
- lambda: |-
|
||||
auto f = sim::sm300d2(1100, 29, 0);
|
||||
f[16] += 0x11;
|
||||
sim::send(id(u_s), "S bad checksum co2 1100", f);
|
||||
- script.execute: sm_update
|
||||
- platform: template
|
||||
name: F Cut Then Frame
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_f), "F 3C 32 00 00 then 33.33", sim::cat({0x3C, 0x32, 0x00, 0x00}, sim::ufm01(0x33, 0x33)));
|
||||
- platform: template
|
||||
name: F Bad Checksum Then Frame
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_f), "F bad checksum 66.66 then 44.44", sim::cat(sim::ufm01(0x66, 0x66, true), sim::ufm01(0x44, 0x44)));
|
||||
- platform: template
|
||||
name: F Garbage Then Frame
|
||||
on_press:
|
||||
- lambda: sim::send(id(u_f), "F garbage then 55.55", sim::cat({0x16, 0x3C, 0x00}, sim::ufm01(0x55, 0x55)));
|
||||
@@ -0,0 +1,6 @@
|
||||
# Run file for build b; copy it as frbbase.yaml with name frbbase for the base build.
|
||||
esphome:
|
||||
name: frb
|
||||
packages:
|
||||
common: !include fr-common.yaml
|
||||
parts: !include frb-parts.yaml
|
||||
@@ -0,0 +1,64 @@
|
||||
uart:
|
||||
- id: u_a
|
||||
tx_pin:
|
||||
number: GPIO25
|
||||
allow_other_uses: true
|
||||
rx_pin:
|
||||
number: GPIO26
|
||||
allow_other_uses: true
|
||||
baud_rate: 9600
|
||||
- id: u_b
|
||||
tx_pin:
|
||||
number: GPIO26
|
||||
allow_other_uses: true
|
||||
rx_pin:
|
||||
number: GPIO25
|
||||
allow_other_uses: true
|
||||
baud_rate: 9600
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
priority: 1000
|
||||
then:
|
||||
- lambda: sim::probe.u = id(u_b);
|
||||
|
||||
interval:
|
||||
- interval: 10ms
|
||||
then:
|
||||
- lambda: sim::probe.poll();
|
||||
|
||||
sensor:
|
||||
- platform: dfrobot_sen0465_uart
|
||||
id: gas
|
||||
uart_id: u_a
|
||||
model: SEN0466
|
||||
alarm:
|
||||
threshold: 35
|
||||
type: high
|
||||
update_interval: 5s
|
||||
concentration:
|
||||
name: CO Concentration
|
||||
temperature:
|
||||
name: CO Temperature
|
||||
|
||||
select:
|
||||
- platform: template
|
||||
name: Probe Mode
|
||||
optimistic: true
|
||||
options: ["0 normal", "1 tail", "2 FF tail", "3 silent", "4 split", "5 wrong gas"]
|
||||
initial_option: "0 normal"
|
||||
set_action:
|
||||
- lambda: |-
|
||||
sim::probe.mode = x.c_str()[0] - '0';
|
||||
ESP_LOGI("sim", "probe mode %d", sim::probe.mode);
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
name: Probe Conc
|
||||
optimistic: true
|
||||
min_value: 0
|
||||
max_value: 1000
|
||||
step: 1
|
||||
initial_value: 25
|
||||
set_action:
|
||||
- lambda: sim::probe.conc = (uint16_t) x;
|
||||
@@ -0,0 +1,6 @@
|
||||
# Run file for build c; copy it as frcbase.yaml with name frcbase for the base build.
|
||||
esphome:
|
||||
name: frc
|
||||
packages:
|
||||
common: !include fr-common.yaml
|
||||
parts: !include frc-parts.yaml
|
||||
@@ -0,0 +1,155 @@
|
||||
#pragma once
|
||||
// Simulated UART sensors for the FrameReader hardware test: frame builders, UART loopback and a SEN0465 responder.
|
||||
#include <driver/uart.h>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include "esphome/components/uart/uart_component_esp_idf.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace sim {
|
||||
using esphome::uart::IDFUARTComponent;
|
||||
static const char *const TAG = "sim";
|
||||
|
||||
inline void loopback(IDFUARTComponent *u) {
|
||||
esp_err_t err = uart_set_loop_back((uart_port_t) u->get_hw_serial_number(), true);
|
||||
ESP_LOGI(TAG, "UART%u loopback %s", u->get_hw_serial_number(),
|
||||
err == ESP_OK ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("FAILED"));
|
||||
}
|
||||
|
||||
inline void send(IDFUARTComponent *u, const char *name, std::vector<uint8_t> bytes) {
|
||||
char hex[200] = {0};
|
||||
size_t n = 0;
|
||||
for (uint8_t b : bytes) {
|
||||
if (n + 4 > sizeof(hex))
|
||||
break;
|
||||
n += snprintf(hex + n, sizeof(hex) - n, "%02X ", b);
|
||||
}
|
||||
ESP_LOGI(TAG, ">>> %s: %s", name, hex);
|
||||
u->write_array(bytes.data(), bytes.size());
|
||||
}
|
||||
|
||||
inline uint8_t sum(const std::vector<uint8_t> &v, size_t from, size_t to) {
|
||||
uint8_t s = 0;
|
||||
for (size_t i = from; i < to; i++)
|
||||
s += v[i];
|
||||
return s;
|
||||
}
|
||||
|
||||
// wts01: 55 01 01 04 01 11 T Td Ck
|
||||
inline std::vector<uint8_t> wts01(uint8_t t, uint8_t td, bool bad_ck = false) {
|
||||
std::vector<uint8_t> f = {0x55, 0x01, 0x01, 0x04, 0x01, 0x11, t, td, 0};
|
||||
f[8] = sum(f, 0, 8) + (bad_ck ? 1 : 0);
|
||||
return f;
|
||||
}
|
||||
// ds1603l: FF H L Ck
|
||||
inline std::vector<uint8_t> ds1603l(uint16_t mm, bool bad_ck = false) {
|
||||
std::vector<uint8_t> f = {0xFF, (uint8_t) (mm >> 8), (uint8_t) mm, 0};
|
||||
f[3] = sum(f, 0, 3) + (bad_ck ? 1 : 0);
|
||||
return f;
|
||||
}
|
||||
// sm300d2: 3C 02 co2 hcho tvoc pm25 pm10 (16 bit each) temp temp.dec hum hum.dec Ck
|
||||
inline std::vector<uint8_t> sm300d2(uint16_t co2, uint8_t temp, uint8_t temp_dec) {
|
||||
std::vector<uint8_t> f = {
|
||||
0x3C, 0x02, (uint8_t) (co2 >> 8), (uint8_t) co2, 0, 10, 0, 20, 0, 5, 0, 8, temp, temp_dec, 45, 6, 0};
|
||||
f[16] = sum(f, 0, 16);
|
||||
return f;
|
||||
}
|
||||
// ufm01 active frame, temperature t.tt as BCD (t_int, t_frac)
|
||||
inline std::vector<uint8_t> ufm01(uint8_t t_int_bcd, uint8_t t_frac_bcd, bool bad_ck = false) {
|
||||
std::vector<uint8_t> f(32, 0);
|
||||
f[0] = 0x3C;
|
||||
f[1] = 0x32;
|
||||
f[2] = 0x01;
|
||||
f[4] = 0x14;
|
||||
f[5] = 0x07;
|
||||
f[6] = 0x23;
|
||||
f[7] = 0x01;
|
||||
f[9] = 0x56; // accumulated flow 0.056
|
||||
f[15] = 0x0B;
|
||||
f[16] = 0x50; // flow
|
||||
f[21] = 0x0C;
|
||||
f[24] = 0x0D;
|
||||
f[25] = t_frac_bcd;
|
||||
f[26] = t_int_bcd;
|
||||
f[31] = 0x16;
|
||||
f[30] = sum(f, 0, 30) + (bad_ck ? 1 : 0);
|
||||
return f;
|
||||
}
|
||||
inline std::vector<uint8_t> cat(std::vector<uint8_t> a, const std::vector<uint8_t> &b) {
|
||||
a.insert(a.end(), b.begin(), b.end());
|
||||
return a;
|
||||
}
|
||||
inline std::vector<uint8_t> part(const std::vector<uint8_t> &v, size_t from, size_t to) {
|
||||
return std::vector<uint8_t>(v.begin() + from, v.begin() + to);
|
||||
}
|
||||
|
||||
// SEN0465 probe on the other end of a cross-wired UART pair. Modes: 0 normal, 1 tail before the answer,
|
||||
// 2 tail with an FF before the answer, 3 silent, 4 answer split in two 300 ms apart, 5 wrong gas type.
|
||||
struct Probe {
|
||||
IDFUARTComponent *u{nullptr};
|
||||
int mode{0};
|
||||
uint8_t req[9];
|
||||
uint8_t n{0};
|
||||
std::vector<uint8_t> rest;
|
||||
uint32_t rest_at{0};
|
||||
uint16_t conc{25};
|
||||
|
||||
static uint8_t ck(const std::vector<uint8_t> &f) { return (uint8_t) (~sum(f, 1, 7) + 1); }
|
||||
|
||||
void poll() {
|
||||
if (!this->rest.empty() && millis() - this->rest_at >= 300) {
|
||||
send(this->u, "probe answer rest", this->rest);
|
||||
this->rest.clear();
|
||||
}
|
||||
uint8_t c;
|
||||
while (this->u->available() && this->u->read_byte(&c)) {
|
||||
if (this->n == 0 && c != 0xFF)
|
||||
continue;
|
||||
this->req[this->n++] = c;
|
||||
if (this->n < 9)
|
||||
continue;
|
||||
this->n = 0;
|
||||
this->answer_(this->req[2]);
|
||||
}
|
||||
}
|
||||
|
||||
void answer_(uint8_t cmd) {
|
||||
ESP_LOGI(TAG, "probe got request 0x%02X (mode %d)", cmd, this->mode);
|
||||
std::vector<uint8_t> f;
|
||||
if (cmd == 0x88) {
|
||||
f = {0xFF,
|
||||
0x88,
|
||||
(uint8_t) (this->conc >> 8),
|
||||
(uint8_t) this->conc,
|
||||
(uint8_t) (this->mode == 5 ? 0x05 : 0x04),
|
||||
0x00,
|
||||
0x02,
|
||||
0x00,
|
||||
0};
|
||||
} else {
|
||||
f = {0xFF, cmd, 0x01, 0, 0, 0, 0, 0, 0};
|
||||
}
|
||||
f[8] = ck(f);
|
||||
switch (this->mode) {
|
||||
case 1:
|
||||
send(this->u, "probe tail+answer", cat({0x12, 0x34, 0x56}, f));
|
||||
break;
|
||||
case 2:
|
||||
send(this->u, "probe FF tail+answer", cat({0x12, 0xFF, 0x34}, f));
|
||||
break;
|
||||
case 3:
|
||||
ESP_LOGI(TAG, "probe silent");
|
||||
break;
|
||||
case 4:
|
||||
send(this->u, "probe answer head", part(f, 0, 5));
|
||||
this->rest = part(f, 5, 9);
|
||||
this->rest_at = millis();
|
||||
break;
|
||||
default:
|
||||
send(this->u, "probe answer", f);
|
||||
}
|
||||
}
|
||||
};
|
||||
inline Probe probe;
|
||||
} // namespace sim
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@af4d4fcb6922b8e6f65c0426947125fcc38794ce # v3.0.2
|
||||
uses: j178/prek-action@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0
|
||||
with:
|
||||
prek-version: ${{ steps.prek.outputs.version }}
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
|
||||
@@ -209,7 +209,8 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
registered with `automation.ApplyBytes` (see Actions below), which keeps a constant payload in flash;
|
||||
`TEMPLATABLE_BYTES` is for a `play()` that does more with the bytes.
|
||||
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
|
||||
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
|
||||
logged with `get_log_str` and `LOG_STR_ARG`, published on a text sensor with
|
||||
`publish_state(Table::get_progmem_str(idx, fallback))`, which copies it out of flash on ESP8266.
|
||||
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
|
||||
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`,
|
||||
`reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend<BITS>`, `bcd_to_uint8`, `assign_bits`); pick
|
||||
@@ -649,7 +650,13 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`, `cv.only_with_esp_idf`.
|
||||
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
|
||||
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
|
||||
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
|
||||
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
|
||||
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
|
||||
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
|
||||
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
|
||||
* **Schema Extensions:**
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.Schema({ ... })
|
||||
|
||||
+12
-1
@@ -158,6 +158,7 @@ esphome/components/dashboard_import/* @esphome/core
|
||||
esphome/components/datetime/* @jesserockz @rfdarter
|
||||
esphome/components/debug/* @esphome/core
|
||||
esphome/components/delonghi/* @grob6000
|
||||
esphome/components/delta_wallbox/* @epicRE
|
||||
esphome/components/dew_point/* @CFlix
|
||||
esphome/components/dfplayer/* @glmnet
|
||||
esphome/components/dfrobot_sen0395/* @niklasweber
|
||||
@@ -175,6 +176,11 @@ esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/ds3231/* @linkedupbits
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duco/* @kokx
|
||||
esphome/components/duco/number/* @kokx
|
||||
esphome/components/duco/select/* @kokx
|
||||
esphome/components/duco/sensor/* @kokx
|
||||
esphome/components/duco/text_sensor/* @kokx
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ecocomfort2/* @gledian
|
||||
esphome/components/ee895/* @Stock-M
|
||||
@@ -374,7 +380,8 @@ esphome/components/m5stack_8angle/* @rnauber
|
||||
esphome/components/mapping/* @clydebarrow
|
||||
esphome/components/matrix_keypad/* @ssieb
|
||||
esphome/components/max17043/* @blacknell
|
||||
esphome/components/max17048/* @B48D81EFCC
|
||||
esphome/components/max17048/* @B48D81EFCC @ryansch
|
||||
esphome/components/max170xx/* @ryansch
|
||||
esphome/components/max31865/* @DAVe3283
|
||||
esphome/components/max31888/* @JiriPrchal
|
||||
esphome/components/max44009/* @berfenger
|
||||
@@ -429,6 +436,7 @@ esphome/components/modbus_controller/sensor/* @martgras
|
||||
esphome/components/modbus_controller/switch/* @martgras
|
||||
esphome/components/modbus_controller/text_sensor/* @martgras
|
||||
esphome/components/modbus_server/* @exciton
|
||||
esphome/components/modem/* @oarcher
|
||||
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
|
||||
esphome/components/mopeka_pro_check/* @spbrogan
|
||||
esphome/components/mopeka_std_check/* @Fabian-Schmidt
|
||||
@@ -462,6 +470,8 @@ esphome/components/openthread/* @mrene
|
||||
esphome/components/opt3001/* @ccutrer
|
||||
esphome/components/ota/* @esphome/core
|
||||
esphome/components/output/* @esphome/core
|
||||
esphome/components/packet_interface/* @clydebarrow
|
||||
esphome/components/packet_interface/packet_transport/* @clydebarrow
|
||||
esphome/components/packet_transport/* @clydebarrow
|
||||
esphome/components/pca6416a/* @Mat931
|
||||
esphome/components/pca9505/* @ajfriesen @ssieb
|
||||
@@ -682,6 +692,7 @@ esphome/components/tuya/water_heater/* @iago-veiga
|
||||
esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
esphome/components/uart/packet_interface/* @clydebarrow
|
||||
esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
|
||||
@@ -84,3 +84,5 @@ FROM base-${BUILD_TYPE} AS final
|
||||
# Copy esphome and install
|
||||
COPY . /esphome
|
||||
RUN uv pip install --no-cache-dir -e /esphome
|
||||
# The tree has no .git; lets SBOM manifests describe it as the release it was built from
|
||||
ENV ESPHOME_IS_DOCKER_IMAGE=true
|
||||
@@ -44,7 +44,6 @@ from esphome.platformio.library import (
|
||||
normalize_dependencies,
|
||||
parse_library_json,
|
||||
parse_library_properties,
|
||||
warn_properties_depends,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -283,7 +282,6 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
|
||||
"resolved automatically; add them with add_library() if needed",
|
||||
name,
|
||||
)
|
||||
warn_properties_depends(name, data)
|
||||
build = data.get("build")
|
||||
if isinstance(build, dict) and build.get("extraScript"):
|
||||
# Scripts only run on the converted path; building without
|
||||
|
||||
@@ -49,6 +49,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
static_const_array,
|
||||
static_function,
|
||||
templatable,
|
||||
unique_global_id,
|
||||
variable,
|
||||
with_local_variable,
|
||||
)
|
||||
|
||||
@@ -91,17 +91,19 @@ def alarm_control_panel_schema(
|
||||
|
||||
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA = maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(AlarmControlPanel),
|
||||
}
|
||||
)
|
||||
|
||||
ALARM_CONTROL_PANEL_CODE_ACTION_SCHEMA = maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(AlarmControlPanel),
|
||||
cv.Optional(CONF_CODE): cv.templatable(cv.string),
|
||||
}
|
||||
)
|
||||
|
||||
ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(AlarmControlPanel),
|
||||
}
|
||||
)
|
||||
ALARM_CONTROL_PANEL_CONDITION_SCHEMA = ALARM_CONTROL_PANEL_ACTION_SCHEMA
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
@@ -194,7 +196,7 @@ for _name, _arm in (
|
||||
):
|
||||
automation.register_apply_action(
|
||||
_name,
|
||||
ALARM_CONTROL_PANEL_ACTION_SCHEMA,
|
||||
ALARM_CONTROL_PANEL_CODE_ACTION_SCHEMA,
|
||||
automation.ApplyCall(_arm),
|
||||
automation.ApplyField(CONF_CODE, "set_code", cg.std_string),
|
||||
call="make_call",
|
||||
|
||||
@@ -293,14 +293,9 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin
|
||||
}
|
||||
|
||||
void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
|
||||
// protect from refreshing too often
|
||||
uint32_t now = micros();
|
||||
if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 &&
|
||||
(now - this->last_refresh_) < *this->max_refresh_rate_) {
|
||||
// try again next loop iteration, so that this change won't get lost
|
||||
this->schedule_show();
|
||||
if (this->throttle_show_(now, this->last_refresh_, this->max_refresh_rate_))
|
||||
return;
|
||||
}
|
||||
this->last_refresh_ = now;
|
||||
this->mark_shown_();
|
||||
|
||||
@@ -369,17 +364,6 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
|
||||
this->status_clear_warning();
|
||||
}
|
||||
|
||||
light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const {
|
||||
const light::ChannelColors &colors = this->channel_colors_;
|
||||
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
|
||||
return {led + colors.r,
|
||||
led + colors.g,
|
||||
led + colors.b,
|
||||
colors.has_white() ? led + colors.w : nullptr,
|
||||
&this->effect_data_[index],
|
||||
&this->correction_};
|
||||
}
|
||||
|
||||
void BekenSPILEDStripLightOutput::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Beken SPI LED Strip:\n"
|
||||
@@ -390,7 +374,7 @@ void BekenSPILEDStripLightOutput::dump_config() {
|
||||
" Channel colors: %s\n"
|
||||
" Max refresh rate: %" PRIu32 "\n"
|
||||
" Number of LEDs: %u",
|
||||
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_, this->num_leds_);
|
||||
}
|
||||
|
||||
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
@@ -18,15 +18,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
float get_setup_priority() const override;
|
||||
|
||||
int32_t size() const override { return this->num_leds_; }
|
||||
light::LightTraits get_traits() override {
|
||||
auto traits = light::LightTraits();
|
||||
if (this->channel_colors_.has_white()) {
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
|
||||
} else {
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB});
|
||||
}
|
||||
return traits;
|
||||
}
|
||||
light::LightTraits get_traits() override { return this->channel_colors_.traits(); }
|
||||
|
||||
void set_pin(uint8_t pin) { this->pin_ = pin; }
|
||||
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
|
||||
@@ -38,14 +30,16 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency);
|
||||
|
||||
void clear_effect_data() override {
|
||||
for (int i = 0; i < this->size(); i++)
|
||||
this->effect_data_[i] = 0;
|
||||
if (this->effect_data_ != nullptr)
|
||||
memset(this->effect_data_, 0, this->num_leds_);
|
||||
}
|
||||
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
light::ESPColorView get_view_internal(int32_t index) const override;
|
||||
light::ESPColorView get_view_internal(int32_t index) const override {
|
||||
return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_);
|
||||
}
|
||||
|
||||
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
|
||||
|
||||
@@ -62,7 +56,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
|
||||
|
||||
uint32_t last_refresh_{0};
|
||||
optional<uint32_t> max_refresh_rate_{};
|
||||
uint32_t max_refresh_rate_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::beken_spi_led_strip
|
||||
|
||||
@@ -3,7 +3,6 @@ from logging import getLogger
|
||||
from esphome import automation, core
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
from esphome.components.const import CONF_ON_STATE_CHANGE
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
@@ -29,7 +28,6 @@ from esphome.const import (
|
||||
CONF_STATE,
|
||||
CONF_TIMING,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_WEB_SERVER,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_BATTERY_CHARGING,
|
||||
DEVICE_CLASS_CARBON_MONOXIDE,
|
||||
@@ -61,10 +59,12 @@ from esphome.const import (
|
||||
DEVICE_CLASS_VIBRATION,
|
||||
DEVICE_CLASS_WINDOW,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority, entity_helpers
|
||||
from esphome.core.entity_helpers import (
|
||||
SubEntities,
|
||||
entity_duplicate_validator,
|
||||
lazy_load_validator,
|
||||
mqtt_component_class,
|
||||
queue_entity_register,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
@@ -433,14 +433,14 @@ def validate_publish_initial_state(value):
|
||||
|
||||
|
||||
_BINARY_SENSOR_SCHEMA = (
|
||||
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
|
||||
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
|
||||
.extend(cv.MQTT_COMPONENT_SCHEMA)
|
||||
.extend(zigbee.BINARY_SENSOR_SCHEMA)
|
||||
.extend(entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BinarySensor),
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
|
||||
mqtt.MQTTBinarySensorComponent
|
||||
mqtt_component_class("MQTTBinarySensorComponent")
|
||||
),
|
||||
cv.Exclusive(
|
||||
CONF_PUBLISH_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
|
||||
@@ -508,7 +508,7 @@ _BINARY_SENSOR_SCHEMA = (
|
||||
|
||||
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor"))
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(zigbee.validate_binary_sensor)
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_binary_sensor"))
|
||||
|
||||
|
||||
def binary_sensor_schema(
|
||||
@@ -619,14 +619,12 @@ async def setup_binary_sensor_core_(var, config):
|
||||
|
||||
CORE.add_job(_build_binary_sensor_automations, var, config)
|
||||
|
||||
if mqtt_id := config.get(CONF_MQTT_ID):
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
await mqtt.register_mqtt_component(mqtt_, config)
|
||||
await entity_helpers.setup_entity_integrations(var, config)
|
||||
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
if "zigbee" in CORE.loaded_integrations:
|
||||
from esphome.components import zigbee
|
||||
|
||||
await zigbee.setup_binary_sensor(var, config)
|
||||
await zigbee.setup_binary_sensor(var, config)
|
||||
|
||||
|
||||
async def register_binary_sensor(var, config):
|
||||
|
||||
@@ -20,6 +20,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
platform="bp1658cj", hub_key=CONF_BP1658CJ_ID
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await output.register_output(var, config)
|
||||
|
||||
@@ -21,6 +21,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
platform="bp5758d", hub_key=CONF_BP5758D_ID
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await output.register_output(var, config)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ch422g.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ch422g {
|
||||
@@ -59,11 +60,7 @@ bool CH422GComponent::digital_read(uint8_t pin) {
|
||||
}
|
||||
|
||||
void CH422GComponent::digital_write(uint8_t pin, bool value) {
|
||||
if (value) {
|
||||
this->output_bits_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_bits_ &= ~(1 << pin);
|
||||
}
|
||||
assign_bits(this->output_bits_, 1 << pin, value);
|
||||
this->write_outputs_();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ch423.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
@@ -62,11 +63,7 @@ bool CH423Component::digital_read(uint8_t pin) {
|
||||
}
|
||||
|
||||
void CH423Component::digital_write(uint8_t pin, bool value) {
|
||||
if (value) {
|
||||
this->output_bits_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_bits_ &= ~(1 << pin);
|
||||
}
|
||||
assign_bits(this->output_bits_, 1 << pin, value);
|
||||
this->write_outputs_();
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ climate::ClimateTraits ClimateIR::traits() {
|
||||
return traits;
|
||||
}
|
||||
|
||||
void ClimateIR::setup() {
|
||||
void ClimateIR::setup() { this->setup_(this->restore_state_()); }
|
||||
|
||||
void ClimateIR::setup_(optional<climate::ClimateDeviceRestoreState> restore) {
|
||||
if (this->sensor_) {
|
||||
this->sensor_->add_on_state_callback([this](float state) {
|
||||
this->current_temperature = state;
|
||||
@@ -43,7 +45,6 @@ void ClimateIR::setup() {
|
||||
}
|
||||
|
||||
// restore set points
|
||||
auto restore = this->restore_state_();
|
||||
if (restore.has_value()) {
|
||||
restore->apply(this);
|
||||
} else {
|
||||
|
||||
@@ -60,6 +60,10 @@ class ClimateIR : public Component,
|
||||
/// Transmit via IR the state of this climate controller.
|
||||
virtual void transmit_state() = 0;
|
||||
|
||||
/// setup() with the restored state already loaded, for subclasses that read it first. Load it only once:
|
||||
/// every restore_state_() call takes a new preference slot on some platforms.
|
||||
void setup_(optional<climate::ClimateDeviceRestoreState> restore);
|
||||
|
||||
// Dummy implement on_receive so implementation is optional for inheritors
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override { return false; };
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
platform="dac7678", hub_key=CONF_DAC7678_ID
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> MockObj:
|
||||
paren = await cg.get_variable(config[CONF_DAC7678_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_client, time
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_PASSWORD, CONF_PIN, CONF_TIME_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@epicRE"]
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
DOMAIN = "delta_wallbox"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_DELTA_WALLBOX_ID = "delta_wallbox_id"
|
||||
CONF_USER = "user"
|
||||
|
||||
delta_wallbox_ns = cg.esphome_ns.namespace("delta_wallbox")
|
||||
DeltaWallbox = delta_wallbox_ns.class_(
|
||||
"DeltaWallbox", ble_client.BLEClientNode, cg.PollingComponent
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.COMPONENT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DeltaWallbox),
|
||||
cv.Required(CONF_PASSWORD): cv.All(cv.string_strict, cv.Length(max=22)),
|
||||
cv.Optional(CONF_PIN): cv.int_range(min=0, max=999999),
|
||||
cv.Optional(CONF_USER, default="admin"): cv.All(
|
||||
cv.string_strict, cv.Length(max=6)
|
||||
),
|
||||
cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
|
||||
}
|
||||
)
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||
.extend(cv.polling_component_schema("15s"))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await ble_client.new_ble_node_component(
|
||||
config, config[CONF_USER], config[CONF_PASSWORD]
|
||||
)
|
||||
cg.add_optional(var.set_pin, config.get(CONF_PIN))
|
||||
await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID))
|
||||
@@ -0,0 +1,30 @@
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||
|
||||
DEPENDENCIES = ["delta_wallbox"]
|
||||
|
||||
CONF_START_CHARGING = "start_charging"
|
||||
CONF_STOP_CHARGING = "stop_charging"
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||
cv.Optional(CONF_START_CHARGING): button.button_schema(
|
||||
button.ApplyButton,
|
||||
icon="mdi:play-circle-outline",
|
||||
),
|
||||
cv.Optional(CONF_STOP_CHARGING): button.button_schema(
|
||||
button.ApplyButton,
|
||||
icon="mdi:stop-circle-outline",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
buttons = button.sub_buttons(config, parent=config[CONF_DELTA_WALLBOX_ID])
|
||||
await buttons(CONF_START_CHARGING, None, apply="start_charging()")
|
||||
await buttons(CONF_STOP_CHARGING, None, apply="stop_charging()")
|
||||
@@ -0,0 +1,691 @@
|
||||
#include "delta_wallbox.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::delta_wallbox {
|
||||
ESPHOME_LOG_TAG(TAG, "delta_wallbox");
|
||||
// Modbus function codes
|
||||
static constexpr uint8_t FC_READ_HOLDING_REGISTERS = 0x03;
|
||||
static constexpr uint8_t FC_WRITE_SINGLE_REGISTER = 0x06;
|
||||
static constexpr uint8_t FC_WRITE_MULTIPLE_REGISTERS = 0x10;
|
||||
static constexpr uint8_t FC_EXCEPTION_FLAG = 0x80;
|
||||
|
||||
// Response lengths in bytes, CRC included
|
||||
static constexpr size_t READ_RESPONSE_OVERHEAD = 5;
|
||||
static constexpr size_t WRITE_RESPONSE_LEN = 8;
|
||||
static constexpr size_t EXCEPTION_RESPONSE_LEN = 5;
|
||||
|
||||
// Frame bodies without CRC
|
||||
static constexpr size_t LOGIN_BODY_LEN = 35;
|
||||
static constexpr size_t LOGIN_PASS_LEN = 22;
|
||||
static constexpr size_t LOGIN_USER_LEN = 6;
|
||||
static constexpr size_t TIME_SYNC_BODY_LEN = 15;
|
||||
|
||||
static constexpr uint8_t REGISTER_BYTE_COUNT = 2;
|
||||
static constexpr uint8_t TWO_REGISTER_BYTE_COUNT = 4;
|
||||
static constexpr uint8_t CHARGING_INFO_BYTE_COUNT = 18;
|
||||
static constexpr uint8_t SERIAL_TEXT_BYTE_COUNT = 14;
|
||||
static constexpr uint32_t RETRANSMIT_TIMEOUT_MS = 4000;
|
||||
static constexpr uint32_t RETRANSMIT_TIMEOUT_ID = 0;
|
||||
static constexpr uint8_t MAX_PAIRING_ATTEMPTS = 5;
|
||||
static constexpr uint16_t PAIRING_STATUS_READY = 0x0007;
|
||||
static constexpr uint16_t PAIRING_STATUS_WAITING = 0x0008;
|
||||
static constexpr uint8_t LOGIN_ACCEPTED = 0x01;
|
||||
|
||||
static constexpr uint8_t CMD_POLL_CHARGING[] = {0x01, 0x03, 0x01, 0x1B, 0x00, 0x09};
|
||||
static constexpr uint8_t CMD_GET_MODEL[] = {0x01, 0x03, 0x01, 0x85, 0x00, 0x0E};
|
||||
static constexpr uint8_t CMD_GET_BT_FW_VERSION[] = {0x02, 0x03, 0x01, 0x00, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_CHECK_PAIRING[] = {0x02, 0x03, 0x01, 0x00, 0x00, 0x03};
|
||||
static constexpr uint8_t CMD_GET_LOGIN_USER[] = {0x01, 0x03, 0x01, 0x7D, 0x00, 0x03};
|
||||
static constexpr uint8_t CMD_READ_LOGIN_STATUS[] = {0x01, 0x03, 0x01, 0x80, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_GET_LOGIN_ROLE[] = {0x01, 0x03, 0x01, 0x81, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_GET_CHARGER_FW_VERSION[] = {0x01, 0x03, 0x01, 0x10, 0x00, 0x02};
|
||||
static constexpr uint8_t CMD_GET_MAX_CURRENT[] = {0x01, 0x03, 0x01, 0x12, 0x00, 0x02};
|
||||
static constexpr uint8_t CMD_GET_SERIAL_NUMBER[] = {0x01, 0x03, 0x01, 0x09, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_GET_ERROR_CODE[] = {0x01, 0x03, 0x01, 0xB5, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_START_CHARGING[] = {0x01, 0x06, 0x01, 0x02, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_STOP_CHARGING[] = {0x01, 0x06, 0x01, 0x03, 0x00, 0x01};
|
||||
|
||||
// Indexed by DeltaState
|
||||
PROGMEM_STRING_TABLE(DeltaStateStrings, "CHECK_PAIRING", "CONNECTED", "GET_CHARGER_FW_VERSION", "GET_BT_FW_VERSION",
|
||||
"GET_LOGIN_RESULT", "GET_LOGIN_RESULT0", "GET_LOGIN_ROLE", "GET_LOGIN_USER", "GET_MODEL",
|
||||
"GET_SERIAL_NUMBER", "HALTED", "IDLE", "SEND_LOGIN_CREDENTIALS", "TIME_SYNC");
|
||||
static_assert(DeltaStateStrings::LAST_INDEX == static_cast<uint8_t>(DeltaState::DELTA_STATE_TIME_SYNC),
|
||||
"DeltaStateStrings must list every DeltaState");
|
||||
static const LogString *delta_state_to_string(DeltaState state) {
|
||||
return DeltaStateStrings::get_log_str(static_cast<uint8_t>(state), DeltaStateStrings::LAST_INDEX);
|
||||
}
|
||||
|
||||
// The function code a handshake step waits for; 0 where any frame is handled
|
||||
static uint8_t expected_function_code(DeltaState state) {
|
||||
switch (state) {
|
||||
case DeltaState::DELTA_STATE_IDLE:
|
||||
case DeltaState::DELTA_STATE_HALTED:
|
||||
case DeltaState::DELTA_STATE_CONNECTED:
|
||||
return 0;
|
||||
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS:
|
||||
case DeltaState::DELTA_STATE_TIME_SYNC:
|
||||
return FC_WRITE_MULTIPLE_REGISTERS;
|
||||
default:
|
||||
return FC_READ_HOLDING_REGISTERS;
|
||||
}
|
||||
}
|
||||
|
||||
// UUID references
|
||||
static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID =
|
||||
esp32_ble_tracker::ESPBTUUID::from_raw("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
|
||||
static const esp32_ble_tracker::ESPBTUUID TX_UUID =
|
||||
esp32_ble_tracker::ESPBTUUID::from_raw("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
|
||||
static const esp32_ble_tracker::ESPBTUUID RX_UUID =
|
||||
esp32_ble_tracker::ESPBTUUID::from_raw("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
|
||||
|
||||
// Appends the little-endian Modbus CRC-16 to the len bytes in frame and returns the new length.
|
||||
static size_t append_crc(uint8_t *frame, size_t len) {
|
||||
const uint16_t crc = crc16(frame, static_cast<uint16_t>(len));
|
||||
frame[len] = static_cast<uint8_t>(crc & 0xFF);
|
||||
frame[len + 1] = static_cast<uint8_t>(crc >> 8);
|
||||
return len + 2;
|
||||
}
|
||||
|
||||
static bool verify_crc(const uint8_t *data, size_t len) {
|
||||
if (len < EXCEPTION_RESPONSE_LEN)
|
||||
return false;
|
||||
const uint16_t calculated = crc16(data, static_cast<uint16_t>(len - 2));
|
||||
const uint16_t received = data[len - 2] | (data[len - 1] << 8);
|
||||
return calculated == received;
|
||||
}
|
||||
|
||||
// Login write body: 22-byte password and 6-byte user, both left-padded with zeros
|
||||
static std::array<uint8_t, LOGIN_BODY_LEN> build_login_body(StringRef user, StringRef password) {
|
||||
std::array<uint8_t, LOGIN_BODY_LEN> frame = {
|
||||
0x01, 0x10, 0x01, 0x72, 0x00, 0x0E, 0x1C, // Unit, FC16, register 0x0172, 14 registers, 28 bytes
|
||||
};
|
||||
|
||||
const size_t pass_len = std::min(password.size(), LOGIN_PASS_LEN);
|
||||
std::copy_n(password.c_str(), pass_len, frame.begin() + 7 + (LOGIN_PASS_LEN - pass_len));
|
||||
|
||||
uint8_t *user_chunk = frame.data() + 7 + LOGIN_PASS_LEN;
|
||||
bool is_digit = !user.empty();
|
||||
for (size_t i = 0; i < user.size() && is_digit; i++) {
|
||||
is_digit = isdigit(static_cast<unsigned char>(user.c_str()[i]));
|
||||
}
|
||||
if (is_digit) {
|
||||
const uint64_t val = strtoull(user.c_str(), nullptr, 10);
|
||||
for (size_t i = 0; i < LOGIN_USER_LEN; i++) {
|
||||
user_chunk[LOGIN_USER_LEN - 1 - i] = static_cast<uint8_t>((val >> (8 * i)) & 0xFF);
|
||||
}
|
||||
} else {
|
||||
const size_t user_len = std::min(user.size(), LOGIN_USER_LEN);
|
||||
std::copy_n(user.c_str(), user_len, user_chunk + LOGIN_USER_LEN - user_len);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
static std::array<uint8_t, TIME_SYNC_BODY_LEN> build_time_sync_body(int year, int month, int day, int hour, int minute,
|
||||
int second, int weekday) {
|
||||
const uint8_t weekday_bitmap = static_cast<uint8_t>(1 << weekday); // Sunday=bit0, Mon=bit1...
|
||||
return {
|
||||
0x01,
|
||||
0x10,
|
||||
0x01,
|
||||
0x14,
|
||||
0x00,
|
||||
0x04,
|
||||
0x08, // Unit, FC16, register 0x0114, 4 registers, 8 bytes
|
||||
static_cast<uint8_t>(year % 100),
|
||||
static_cast<uint8_t>(month),
|
||||
weekday_bitmap,
|
||||
static_cast<uint8_t>(day),
|
||||
static_cast<uint8_t>(hour),
|
||||
static_cast<uint8_t>(minute),
|
||||
static_cast<uint8_t>(second),
|
||||
0x00,
|
||||
};
|
||||
}
|
||||
|
||||
void DeltaWallbox::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Delta Wallbox:");
|
||||
ESP_LOGCONFIG(TAG, " User: %s", this->user_.c_str());
|
||||
ESP_LOGCONFIG(TAG, " BLE PIN configured: %s", this->has_pin_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"));
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
}
|
||||
|
||||
void DeltaWallbox::update() {
|
||||
if (this->is_connected()) {
|
||||
// Start poll flow sequence
|
||||
ESP_LOGD(TAG, "Polling charging info...");
|
||||
this->send_frame_(CMD_POLL_CHARGING, sizeof(CMD_POLL_CHARGING));
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::write_frame_(const uint8_t *frame, size_t len) {
|
||||
if (this->tx_char_handle_ == 0) {
|
||||
ESP_LOGW(TAG, "TX characteristic handle is not ready while in state %s. Dropping %u-byte frame.",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), (unsigned int) len);
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Sending %u-byte frame in state %s (conn_id=%u, tx_handle=0x%04X).", (unsigned int) len,
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), this->parent_->get_conn_id(), this->tx_char_handle_);
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->tx_char_handle_, len,
|
||||
const_cast<uint8_t *>(frame), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "esp_ble_gattc_write_char failed, status=%d", status);
|
||||
}
|
||||
}
|
||||
|
||||
// Copies body into a stack buffer, appends the CRC and writes the frame.
|
||||
void DeltaWallbox::send_frame_(const uint8_t *body, size_t len) {
|
||||
std::array<uint8_t, MAX_FRAME_LEN> frame{};
|
||||
std::copy_n(body, len, frame.begin());
|
||||
this->write_frame_(frame.data(), append_crc(frame.data(), len));
|
||||
}
|
||||
|
||||
void DeltaWallbox::drop_rx_bytes_(size_t count) {
|
||||
count = std::min(count, this->rx_len_);
|
||||
std::memmove(this->rx_buffer_.data(), this->rx_buffer_.data() + count, this->rx_len_ - count);
|
||||
this->rx_len_ -= count;
|
||||
}
|
||||
|
||||
void DeltaWallbox::send_next_state_command_() {
|
||||
ESP_LOGD(TAG, "Preparing command for state %s.", LOG_STR_ARG(delta_state_to_string(this->state_)));
|
||||
switch (this->state_) {
|
||||
case DeltaState::DELTA_STATE_GET_MODEL:
|
||||
ESP_LOGI(TAG, "Requesting charger model name...");
|
||||
this->send_frame_(CMD_GET_MODEL, sizeof(CMD_GET_MODEL));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_BT_FW_VERSION:
|
||||
ESP_LOGI(TAG, "Requesting BT firmware version...");
|
||||
this->send_frame_(CMD_GET_BT_FW_VERSION, sizeof(CMD_GET_BT_FW_VERSION));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_CHECK_PAIRING:
|
||||
ESP_LOGI(TAG, "Checking pairing status...");
|
||||
this->send_frame_(CMD_CHECK_PAIRING, sizeof(CMD_CHECK_PAIRING));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_USER:
|
||||
ESP_LOGI(TAG, "Querying user profile...");
|
||||
this->send_frame_(CMD_GET_LOGIN_USER, sizeof(CMD_GET_LOGIN_USER));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT0:
|
||||
ESP_LOGI(TAG, "Checking pre-login status register...");
|
||||
this->send_frame_(CMD_READ_LOGIN_STATUS, sizeof(CMD_READ_LOGIN_STATUS));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS: {
|
||||
ESP_LOGI(TAG, "Transmitting login credentials...");
|
||||
const auto body = build_login_body(this->user_, this->password_);
|
||||
this->send_frame_(body.data(), body.size());
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT:
|
||||
ESP_LOGI(TAG, "Verifying login result...");
|
||||
this->send_frame_(CMD_READ_LOGIN_STATUS, sizeof(CMD_READ_LOGIN_STATUS));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_ROLE:
|
||||
ESP_LOGI(TAG, "Checking role privileges...");
|
||||
this->send_frame_(CMD_GET_LOGIN_ROLE, sizeof(CMD_GET_LOGIN_ROLE));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_TIME_SYNC: {
|
||||
#ifdef USE_TIME
|
||||
// Only a valid time is written; the charger keeps its own clock otherwise
|
||||
if (this->time_id_ != nullptr) {
|
||||
auto tm = this->time_id_->now();
|
||||
if (tm.is_valid()) {
|
||||
ESP_LOGI(TAG, "Synchronizing wallbox clock...");
|
||||
// ESPTime day_of_week is 1 (Sunday) to 7 (Saturday); the charger counts from 0
|
||||
const auto body = build_time_sync_body(tm.year, tm.month, tm.day_of_month, tm.hour, tm.minute, tm.second,
|
||||
tm.day_of_week - 1);
|
||||
this->send_frame_(body.data(), body.size());
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION:
|
||||
ESP_LOGI(TAG, "Requesting charger main firmware version...");
|
||||
this->send_frame_(CMD_GET_CHARGER_FW_VERSION, sizeof(CMD_GET_CHARGER_FW_VERSION));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_SERIAL_NUMBER:
|
||||
ESP_LOGI(TAG, "Requesting wallbox serial number...");
|
||||
this->send_frame_(CMD_GET_SERIAL_NUMBER, sizeof(CMD_GET_SERIAL_NUMBER));
|
||||
break;
|
||||
default:
|
||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||
return;
|
||||
}
|
||||
this->set_timeout(RETRANSMIT_TIMEOUT_ID, RETRANSMIT_TIMEOUT_MS, [this]() { this->retransmit_(); });
|
||||
}
|
||||
|
||||
void DeltaWallbox::retransmit_() {
|
||||
ESP_LOGW(TAG, "No answer in login state %s within %u ms. Sending the command again...",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), (unsigned int) RETRANSMIT_TIMEOUT_MS);
|
||||
this->send_next_state_command_();
|
||||
}
|
||||
|
||||
void DeltaWallbox::advance_to_(DeltaState next) {
|
||||
ESP_LOGD(TAG, "State transition: %s -> %s", LOG_STR_ARG(delta_state_to_string(this->state_)),
|
||||
LOG_STR_ARG(delta_state_to_string(next)));
|
||||
this->state_ = next;
|
||||
this->send_next_state_command_();
|
||||
}
|
||||
|
||||
void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
||||
ESP_LOGD(TAG, "Received valid frame in state %s: func=0x%02X len=%u",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), frame[1], (unsigned int) len);
|
||||
if (frame[1] & FC_EXCEPTION_FLAG) {
|
||||
ESP_LOGE(TAG, "Modbus exception received in state %s! Error code: 0x%02X",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), frame[2]);
|
||||
if (this->state_ == DeltaState::DELTA_STATE_TIME_SYNC) {
|
||||
// The clock write is optional, so a charger that refuses it is used without it
|
||||
ESP_LOGW(TAG, "Charger refused the clock sync; continuing without it");
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
|
||||
} else if (expected_function_code(this->state_) != 0) {
|
||||
// The charger refused a handshake step; sending the same request again would not change that
|
||||
this->halt_(LOG_STR("Modbus exception during login"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const uint8_t expected = expected_function_code(this->state_);
|
||||
if (expected != 0 && frame[1] != expected) {
|
||||
// A late answer to a retransmitted request; the pending one is answered or sent again by the timer
|
||||
ESP_LOGD(TAG, "Ignoring func=0x%02X reply while waiting for 0x%02X", frame[1], expected);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this->state_) {
|
||||
case DeltaState::DELTA_STATE_IDLE:
|
||||
break; // No command is pending; ignore the frame.
|
||||
case DeltaState::DELTA_STATE_GET_MODEL: {
|
||||
char model[MAX_FRAME_LEN];
|
||||
const size_t data_len = std::min<size_t>(frame[2], len - READ_RESPONSE_OVERHEAD);
|
||||
size_t model_len = 0;
|
||||
for (size_t i = 0; i < data_len; i++) {
|
||||
if (frame[3 + i] != '\0')
|
||||
model[model_len++] = static_cast<char>(frame[3 + i]);
|
||||
}
|
||||
model[model_len] = '\0';
|
||||
ESP_LOGI(TAG, "Discovered Wallbox Model Name: %s", model);
|
||||
if (this->model_name_text_sensor_ != nullptr) {
|
||||
this->model_name_text_sensor_->publish_state(model);
|
||||
}
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_BT_FW_VERSION);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_BT_FW_VERSION: {
|
||||
if (frame[2] >= REGISTER_BYTE_COUNT) {
|
||||
uint8_t major = frame[3] % 16;
|
||||
uint8_t minor = frame[4];
|
||||
char ver[16];
|
||||
snprintf(ver, sizeof(ver), "%d.%d", major, minor);
|
||||
ESP_LOGI(TAG, "Discovered Wallbox BT FW: %s", ver);
|
||||
if (this->bt_fw_version_text_sensor_ != nullptr) {
|
||||
this->bt_fw_version_text_sensor_->publish_state(ver);
|
||||
}
|
||||
}
|
||||
this->advance_to_(DeltaState::DELTA_STATE_CHECK_PAIRING);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_CHECK_PAIRING: {
|
||||
if (frame[2] >= REGISTER_BYTE_COUNT) {
|
||||
uint16_t status = (frame[3] << 8) | frame[4];
|
||||
ESP_LOGI(TAG, "Pairing Status Register: 0x%04X", status);
|
||||
if (status == PAIRING_STATUS_READY) {
|
||||
this->pairing_in_progress_ = false;
|
||||
this->pairing_attempts_ = 0;
|
||||
ESP_LOGI(TAG, "Pairing ready and confirmed!");
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_USER);
|
||||
} else if (status == PAIRING_STATUS_WAITING) {
|
||||
ESP_LOGW(TAG, "Wallbox is waiting to be paired by the system controller (attempt %u, in_progress=%s).",
|
||||
this->pairing_attempts_ + 1,
|
||||
this->pairing_in_progress_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"));
|
||||
this->pairing_attempts_++;
|
||||
if (!this->pairing_in_progress_) {
|
||||
ESP_LOGI(TAG, "Initiating BLE pairing with wallbox.");
|
||||
auto err = this->parent_->pair();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to initiate BLE pairing, err=%d", err);
|
||||
} else {
|
||||
this->pairing_in_progress_ = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Unexpected pairing status 0x%04X", status);
|
||||
this->pairing_attempts_++;
|
||||
}
|
||||
if (this->pairing_attempts_ >= MAX_PAIRING_ATTEMPTS) {
|
||||
this->halt_(LOG_STR("Too many failed/waiting pairing checks"));
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Pairing status response too short: byte_count=%u frame_len=%u", frame[2], (unsigned int) len);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_USER: {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_RESULT0);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT0: {
|
||||
if (frame[2] == REGISTER_BYTE_COUNT) {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS: {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_RESULT);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT: {
|
||||
// Only the one-register status answer counts; a late reply to another read has a different length
|
||||
if (frame[2] == REGISTER_BYTE_COUNT) {
|
||||
uint8_t login_val = frame[4]; // respondent data_lo
|
||||
if (login_val == LOGIN_ACCEPTED) {
|
||||
ESP_LOGI(TAG, "Credentials accepted! Logged in as %s.", this->user_.c_str());
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_ROLE);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Login validation failed (0x%02X). Check password.", login_val);
|
||||
this->halt_(LOG_STR("Bad credentials"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_HALTED: {
|
||||
break; // Authentication is halted; ignore any late replies.
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_ROLE: {
|
||||
if (frame[2] == REGISTER_BYTE_COUNT) {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_TIME_SYNC);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_TIME_SYNC: {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION: {
|
||||
if (frame[2] >= TWO_REGISTER_BYTE_COUNT) {
|
||||
char ver[16];
|
||||
snprintf(ver, sizeof(ver), "%d.%d", frame[3], frame[4]);
|
||||
ESP_LOGI(TAG, "Discovered Charger Main FW: %s", ver);
|
||||
if (this->fw_version_text_sensor_ != nullptr) {
|
||||
this->fw_version_text_sensor_->publish_state(ver);
|
||||
}
|
||||
}
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_SERIAL_NUMBER);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_SERIAL_NUMBER: {
|
||||
// Serial read (0x0109) returns 14 ASCII bytes instead of one register
|
||||
if (frame[2] >= SERIAL_TEXT_BYTE_COUNT) {
|
||||
char serial[SERIAL_TEXT_BYTE_COUNT + 1];
|
||||
size_t serial_len = 0;
|
||||
for (size_t i = 0; i < SERIAL_TEXT_BYTE_COUNT; i++) {
|
||||
if (frame[3 + i] != '\0')
|
||||
serial[serial_len++] = static_cast<char>(frame[3 + i]);
|
||||
}
|
||||
serial[serial_len] = '\0';
|
||||
ESP_LOGI(TAG, "Wallbox serial text: %s", serial);
|
||||
if (this->serial_number_text_sensor_ != nullptr)
|
||||
this->serial_number_text_sensor_->publish_state(serial);
|
||||
}
|
||||
this->advance_to_(DeltaState::DELTA_STATE_CONNECTED);
|
||||
ESP_LOGI(TAG, "Authentication complete. Core parameters loaded.");
|
||||
this->status_clear_warning();
|
||||
this->update(); // perform first poll update immediately
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_CONNECTED: {
|
||||
// Connected polling responses
|
||||
if (frame[1] == FC_READ_HOLDING_REGISTERS) {
|
||||
uint8_t byte_count = frame[2];
|
||||
if (byte_count == CHARGING_INFO_BYTE_COUNT) {
|
||||
// Parsing 0x011B charging info (9 registers)
|
||||
uint32_t power_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
||||
uint32_t energy_raw = (frame[7] << 24) | (frame[8] << 16) | (frame[9] << 8) | frame[10];
|
||||
uint8_t minutes = frame[15];
|
||||
uint8_t hours = frame[16];
|
||||
uint8_t state_byte = frame[17];
|
||||
|
||||
float power_kw = power_raw / 1000.0f;
|
||||
float energy_kwh = energy_raw / 1000.0f;
|
||||
float charging_minutes = hours * 60 + minutes;
|
||||
|
||||
if (this->power_sensor_ != nullptr)
|
||||
this->power_sensor_->publish_state(power_kw);
|
||||
if (this->energy_sensor_ != nullptr)
|
||||
this->energy_sensor_->publish_state(energy_kwh);
|
||||
if (this->charging_time_sensor_ != nullptr)
|
||||
this->charging_time_sensor_->publish_state(charging_minutes);
|
||||
if (this->charger_state_sensor_ != nullptr)
|
||||
this->charger_state_sensor_->publish_state(state_byte);
|
||||
|
||||
// Retrieve maximum current output setting next
|
||||
this->send_frame_(CMD_GET_MAX_CURRENT, sizeof(CMD_GET_MAX_CURRENT));
|
||||
} else if (byte_count == TWO_REGISTER_BYTE_COUNT) {
|
||||
// Parsing 0x0112 max output setting (2 registers)
|
||||
uint32_t current_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
||||
float amps = current_raw / 10.0f;
|
||||
if (this->max_current_sensor_ != nullptr) {
|
||||
this->max_current_sensor_->publish_state(amps);
|
||||
}
|
||||
|
||||
// Poll the error code register every cycle
|
||||
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
|
||||
} else if (byte_count == REGISTER_BYTE_COUNT) {
|
||||
// Error status register (0x01B5)
|
||||
uint16_t reg_val = (frame[3] << 8) | frame[4];
|
||||
char hex_str[16];
|
||||
snprintf(hex_str, sizeof(hex_str), "%04X", reg_val);
|
||||
if (this->error_code_text_sensor_ != nullptr)
|
||||
this->error_code_text_sensor_->publish_state(hex_str);
|
||||
}
|
||||
} else if (frame[1] == FC_WRITE_SINGLE_REGISTER) {
|
||||
// Confirmation frame of Start/Stop write actions
|
||||
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::reset_state_() {
|
||||
ESP_LOGW(TAG, "Resetting component state from %s. pairing_attempts=%u, pairing_in_progress=%s, rx_buffer=%u bytes",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), this->pairing_attempts_,
|
||||
this->pairing_in_progress_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"), (unsigned int) this->rx_len_);
|
||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||
this->rx_len_ = 0;
|
||||
this->state_ = DeltaState::DELTA_STATE_IDLE;
|
||||
this->pairing_attempts_ = 0;
|
||||
this->pairing_in_progress_ = false;
|
||||
this->status_set_warning();
|
||||
}
|
||||
|
||||
void DeltaWallbox::halt_(const LogString *reason) {
|
||||
ESP_LOGE(TAG, "Halting authentication: %s. A reconnect is required to retry.", LOG_STR_ARG(reason));
|
||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||
this->rx_len_ = 0;
|
||||
this->state_ = DeltaState::DELTA_STATE_HALTED;
|
||||
this->pairing_attempts_ = 0;
|
||||
this->pairing_in_progress_ = false;
|
||||
this->status_set_warning(reason);
|
||||
}
|
||||
|
||||
void DeltaWallbox::set_charging_state(bool state) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGW(TAG, "Cannot send charging command while wallbox is not fully connected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state) {
|
||||
ESP_LOGI(TAG, "Sending remote START charging command...");
|
||||
this->send_frame_(CMD_START_CHARGING, sizeof(CMD_START_CHARGING));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Sending remote STOP charging command...");
|
||||
this->send_frame_(CMD_STOP_CHARGING, sizeof(CMD_STOP_CHARGING));
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::start_charging() { this->set_charging_state(true); }
|
||||
|
||||
void DeltaWallbox::stop_charging() { this->set_charging_state(false); }
|
||||
|
||||
void DeltaWallbox::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GATTC_DISCONNECT_EVT: {
|
||||
ESP_LOGW(TAG, "Disconnected from Bluetooth wallbox client (conn_id=%u, reason=%d). Resetting state.",
|
||||
param->disconnect.conn_id, param->disconnect.reason);
|
||||
this->reset_state_();
|
||||
this->node_state = esp32_ble_tracker::ClientState::IDLE;
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_OPEN_EVT: {
|
||||
ESP_LOGI(TAG, "GATT open event received: status=%d conn_id=%u", param->open.status, param->open.conn_id);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
ESP_LOGI(TAG, "GATT service discovery completed: status=%d", param->search_cmpl.status);
|
||||
auto *tx_char = this->parent_->get_characteristic(SERVICE_UUID, TX_UUID);
|
||||
auto *rx_char = this->parent_->get_characteristic(SERVICE_UUID, RX_UUID);
|
||||
if (tx_char == nullptr || rx_char == nullptr) {
|
||||
ESP_LOGE(TAG, "Could not discover matching NUS TX/RX service on this BLE device.");
|
||||
this->reset_state_();
|
||||
break;
|
||||
}
|
||||
this->tx_char_handle_ = tx_char->handle;
|
||||
this->rx_char_handle_ = rx_char->handle;
|
||||
|
||||
esp_err_t err = this->parent_->register_for_notify(this->rx_char_handle_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register_for_notify failed, status=%d", err);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
|
||||
ESP_LOGI(TAG, "Register-for-notify event: status=%d handle=0x%04X", param->reg_for_notify.status,
|
||||
param->reg_for_notify.handle);
|
||||
if (param->reg_for_notify.handle == this->rx_char_handle_) {
|
||||
ESP_LOGI(TAG, "Subscribed to RX notification handle 0x%04X successfully.", this->rx_char_handle_);
|
||||
this->node_state = esp32_ble_tracker::ClientState::ESTABLISHED;
|
||||
if (this->state_ == DeltaState::DELTA_STATE_IDLE) {
|
||||
ESP_LOGI(TAG, "GATT connection established. Starting authentication state machine.");
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_MODEL);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
if (param->notify.handle != this->rx_char_handle_)
|
||||
break;
|
||||
|
||||
const size_t value_len = param->notify.value_len;
|
||||
ESP_LOGD(TAG, "Notification received on handle 0x%04X with %u bytes. Buffer before append=%u bytes.",
|
||||
param->notify.handle, param->notify.value_len, (unsigned int) this->rx_len_);
|
||||
if (value_len > MAX_FRAME_LEN - this->rx_len_) {
|
||||
ESP_LOGW(TAG, "Notification overflows the %u-byte RX buffer. Discarding buffered data.",
|
||||
(unsigned int) MAX_FRAME_LEN);
|
||||
this->rx_len_ = 0;
|
||||
if (value_len > MAX_FRAME_LEN)
|
||||
break;
|
||||
}
|
||||
std::memcpy(this->rx_buffer_.data() + this->rx_len_, param->notify.value, value_len);
|
||||
this->rx_len_ += value_len;
|
||||
|
||||
bool progress = true;
|
||||
while (progress && this->rx_len_ >= 3) {
|
||||
progress = false;
|
||||
uint8_t func_code = this->rx_buffer_[1];
|
||||
size_t expected_len = 0;
|
||||
|
||||
if (func_code == FC_READ_HOLDING_REGISTERS) {
|
||||
expected_len = this->rx_buffer_[2] + READ_RESPONSE_OVERHEAD;
|
||||
} else if (func_code == FC_WRITE_MULTIPLE_REGISTERS || func_code == FC_WRITE_SINGLE_REGISTER) {
|
||||
expected_len = WRITE_RESPONSE_LEN;
|
||||
} else if (func_code == (FC_EXCEPTION_FLAG | FC_READ_HOLDING_REGISTERS) ||
|
||||
func_code == (FC_EXCEPTION_FLAG | FC_WRITE_MULTIPLE_REGISTERS) ||
|
||||
func_code == (FC_EXCEPTION_FLAG | FC_WRITE_SINGLE_REGISTER)) {
|
||||
expected_len = EXCEPTION_RESPONSE_LEN;
|
||||
}
|
||||
|
||||
if (expected_len > 0) {
|
||||
if (this->rx_len_ >= expected_len) {
|
||||
if (verify_crc(this->rx_buffer_.data(), expected_len)) {
|
||||
// The frame stays in rx_buffer_ during handle_frame_(); it is dropped afterwards.
|
||||
this->handle_frame_(this->rx_buffer_.data(), expected_len);
|
||||
this->drop_rx_bytes_(expected_len);
|
||||
progress = true;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "CRC mismatch while parsing notification data in state %s. Dropping one byte from buffer.",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)));
|
||||
// Shift by one byte to realign with the next possible frame start
|
||||
this->drop_rx_bytes_(1);
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG,
|
||||
"Unsupported function code 0x%02X while parsing notification data in state %s. Advancing buffer.",
|
||||
func_code, LOG_STR_ARG(delta_state_to_string(this->state_)));
|
||||
this->drop_rx_bytes_(1);
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GAP_BLE_PASSKEY_REQ_EVT: {
|
||||
if (!this->parent_->check_addr(param->ble_security.ble_req.bd_addr))
|
||||
return;
|
||||
|
||||
if (!this->has_pin_) {
|
||||
ESP_LOGW(TAG, "Wallbox requested a BLE passkey, but no pin is configured. Set pin: under delta_wallbox:.");
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Wallbox requested a BLE passkey. Replying with configured YAML pin.");
|
||||
esp_err_t err = esp_ble_passkey_reply(param->ble_security.ble_req.bd_addr, true, this->pin_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to send BLE passkey reply: %s", esp_err_to_name(err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GAP_BLE_AUTH_CMPL_EVT:
|
||||
if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr))
|
||||
return;
|
||||
|
||||
this->pairing_in_progress_ = false;
|
||||
if (param->ble_security.auth_cmpl.success) {
|
||||
ESP_LOGI(TAG,
|
||||
"BLE pairing completed successfully. auth_mode=%d addr_type=%d. Re-checking wallbox pairing status.",
|
||||
param->ble_security.auth_cmpl.auth_mode, param->ble_security.auth_cmpl.addr_type);
|
||||
this->pairing_attempts_ = 0;
|
||||
if (this->node_state == esp32_ble_tracker::ClientState::ESTABLISHED &&
|
||||
this->state_ == DeltaState::DELTA_STATE_CHECK_PAIRING) {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_CHECK_PAIRING);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "BLE pairing failed, reason=%d", param->ble_security.auth_cmpl.fail_reason);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::delta_wallbox
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,103 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <array>
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#ifdef USE_TIME
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::delta_wallbox {
|
||||
// Largest FC03 response: unit, function, byte count, 255 data bytes, CRC.
|
||||
static constexpr size_t MAX_FRAME_LEN = 260;
|
||||
enum class DeltaState : uint8_t {
|
||||
DELTA_STATE_CHECK_PAIRING,
|
||||
DELTA_STATE_CONNECTED,
|
||||
DELTA_STATE_GET_CHARGER_FW_VERSION,
|
||||
DELTA_STATE_GET_BT_FW_VERSION,
|
||||
DELTA_STATE_GET_LOGIN_RESULT,
|
||||
DELTA_STATE_GET_LOGIN_RESULT0,
|
||||
DELTA_STATE_GET_LOGIN_ROLE,
|
||||
DELTA_STATE_GET_LOGIN_USER,
|
||||
DELTA_STATE_GET_MODEL,
|
||||
DELTA_STATE_GET_SERIAL_NUMBER,
|
||||
DELTA_STATE_HALTED,
|
||||
DELTA_STATE_IDLE,
|
||||
DELTA_STATE_SEND_LOGIN_CREDENTIALS,
|
||||
DELTA_STATE_TIME_SYNC,
|
||||
};
|
||||
|
||||
class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingComponent {
|
||||
public:
|
||||
DeltaWallbox(const char *user, const char *password) : user_(user), password_(password) {}
|
||||
void update() override;
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override;
|
||||
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_pin(uint32_t pin) {
|
||||
this->pin_ = pin;
|
||||
this->has_pin_ = true;
|
||||
}
|
||||
#ifdef USE_TIME
|
||||
void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; }
|
||||
#endif
|
||||
|
||||
SUB_SENSOR(power)
|
||||
SUB_SENSOR(energy)
|
||||
SUB_SENSOR(charging_time)
|
||||
SUB_SENSOR(charger_state)
|
||||
SUB_SENSOR(max_current)
|
||||
SUB_TEXT_SENSOR(serial_number)
|
||||
SUB_TEXT_SENSOR(fw_version)
|
||||
SUB_TEXT_SENSOR(bt_fw_version)
|
||||
SUB_TEXT_SENSOR(model_name)
|
||||
SUB_TEXT_SENSOR(error_code)
|
||||
void set_charging_state(bool state);
|
||||
void start_charging();
|
||||
void stop_charging();
|
||||
|
||||
bool is_connected() {
|
||||
return this->node_state == esp32_ble_tracker::ClientState::ESTABLISHED &&
|
||||
this->state_ == DeltaState::DELTA_STATE_CONNECTED;
|
||||
}
|
||||
|
||||
protected:
|
||||
void write_frame_(const uint8_t *frame, size_t len);
|
||||
void send_frame_(const uint8_t *body, size_t len);
|
||||
void send_next_state_command_();
|
||||
void retransmit_();
|
||||
void advance_to_(DeltaState next);
|
||||
void handle_frame_(const uint8_t *frame, size_t len);
|
||||
void drop_rx_bytes_(size_t count);
|
||||
void reset_state_();
|
||||
// Stops the handshake until the next BLE disconnect; used for failures a retry can't fix.
|
||||
void halt_(const LogString *reason);
|
||||
|
||||
// Members ordered by size so nothing is padded
|
||||
StringRef user_;
|
||||
StringRef password_;
|
||||
uint32_t pin_{0};
|
||||
#ifdef USE_TIME
|
||||
time::RealTimeClock *time_id_{nullptr};
|
||||
#endif
|
||||
size_t rx_len_{0};
|
||||
|
||||
uint16_t rx_char_handle_{0};
|
||||
uint16_t tx_char_handle_{0};
|
||||
DeltaState state_{DeltaState::DELTA_STATE_IDLE};
|
||||
uint8_t pairing_attempts_{0};
|
||||
bool has_pin_{false};
|
||||
bool pairing_in_progress_{false};
|
||||
std::array<uint8_t, MAX_FRAME_LEN> rx_buffer_{};
|
||||
};
|
||||
} // namespace esphome::delta_wallbox
|
||||
#endif
|
||||
@@ -0,0 +1,66 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ENERGY,
|
||||
CONF_MAX_CURRENT,
|
||||
CONF_POWER,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_ENERGY,
|
||||
DEVICE_CLASS_POWER,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_AMPERE,
|
||||
UNIT_KILOWATT,
|
||||
UNIT_KILOWATT_HOURS,
|
||||
UNIT_MINUTE,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||
|
||||
DEPENDENCIES = ["delta_wallbox"]
|
||||
|
||||
CONF_CHARGING_TIME = "charging_time"
|
||||
CONF_CHARGER_STATE = "charger_state"
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||
cv.Optional(CONF_CHARGER_STATE): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
),
|
||||
cv.Optional(CONF_MAX_CURRENT): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_AMPERE,
|
||||
accuracy_decimals=1,
|
||||
),
|
||||
cv.Optional(CONF_POWER): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_ENERGY): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional(CONF_CHARGING_TIME): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MINUTE,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_CHARGER_STATE, hub.set_charger_state_sensor)
|
||||
await sensors(CONF_MAX_CURRENT, hub.set_max_current_sensor)
|
||||
await sensors(CONF_POWER, hub.set_power_sensor)
|
||||
await sensors(CONF_ENERGY, hub.set_energy_sensor)
|
||||
await sensors(CONF_CHARGING_TIME, hub.set_charging_time_sensor)
|
||||
@@ -0,0 +1,34 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text_sensor
|
||||
from esphome.components.const import CONF_ERROR_CODE, CONF_SERIAL_NUMBER
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||
|
||||
DEPENDENCIES = ["delta_wallbox"]
|
||||
|
||||
CONF_FW_VERSION = "fw_version"
|
||||
CONF_BT_FW_VERSION = "bt_fw_version"
|
||||
CONF_MODEL_NAME = "model_name"
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||
cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(),
|
||||
cv.Optional(CONF_FW_VERSION): text_sensor.text_sensor_schema(),
|
||||
cv.Optional(CONF_BT_FW_VERSION): text_sensor.text_sensor_schema(),
|
||||
cv.Optional(CONF_MODEL_NAME): text_sensor.text_sensor_schema(),
|
||||
cv.Optional(CONF_ERROR_CODE): text_sensor.text_sensor_schema(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
|
||||
text_sensors = text_sensor.sub_text_sensors(config)
|
||||
await text_sensors(CONF_SERIAL_NUMBER, hub.set_serial_number_text_sensor)
|
||||
await text_sensors(CONF_FW_VERSION, hub.set_fw_version_text_sensor)
|
||||
await text_sensors(CONF_BT_FW_VERSION, hub.set_bt_fw_version_text_sensor)
|
||||
await text_sensors(CONF_MODEL_NAME, hub.set_model_name_text_sensor)
|
||||
await text_sensors(CONF_ERROR_CODE, hub.set_error_code_text_sensor)
|
||||
@@ -0,0 +1,87 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import time, uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DISCOVERY, CONF_ID, CONF_TIME, CONF_TIME_ID
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kokx"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
DOMAIN = "duco"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_DISABLE_CRC = "disable_crc"
|
||||
CONF_DUCO_ID = "duco_id"
|
||||
|
||||
duco_ns = cg.esphome_ns.namespace("duco")
|
||||
Duco = duco_ns.class_("Duco", cg.Component, uart.UARTDevice)
|
||||
# Base of every class that sends requests through the hub
|
||||
DucoDevice = duco_ns.class_(
|
||||
"DucoDevice", cg.PollingComponent, cg.Parented.template(Duco)
|
||||
)
|
||||
DucoDiscovery = duco_ns.class_("DucoDiscovery", DucoDevice)
|
||||
DucoTime = duco_ns.class_("DucoTime", cg.PollingComponent, cg.Parented.template(Duco))
|
||||
|
||||
# Entities of the platforms take the hub with this
|
||||
DUCO_COMPONENT_SCHEMA = cv.Schema({cv.GenerateID(CONF_DUCO_ID): cv.use_id(Duco)})
|
||||
|
||||
DISCOVERY_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(DucoDiscovery)}).extend(
|
||||
cv.COMPONENT_SCHEMA
|
||||
)
|
||||
|
||||
TIME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DucoTime),
|
||||
cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
|
||||
}
|
||||
).extend(cv.polling_component_schema("15min"))
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Duco),
|
||||
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA,
|
||||
cv.Optional(CONF_TIME): TIME_SCHEMA,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
DOMAIN,
|
||||
baud_rate=57600,
|
||||
require_tx=True,
|
||||
require_rx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
_request_device_slot = cg.slot_counter("DUCO_DEVICE_COUNT")
|
||||
|
||||
|
||||
async def register_duco_device(var: cg.MockObj, config: ConfigType, hub_id: ID) -> None:
|
||||
"""Register a device that sends requests, and give it an open request slot on its hub."""
|
||||
await cg.register_component(var, config)
|
||||
hub = await cg.get_variable(hub_id)
|
||||
await cg.register_parented(var, hub)
|
||||
_request_device_slot(str(hub_id))
|
||||
cg.add(hub.register_device(var))
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await uart.new_uart_component(config)
|
||||
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
|
||||
|
||||
if (discovery := config.get(CONF_DISCOVERY)) is not None:
|
||||
discovery_var = cg.new_Pvariable(discovery[CONF_ID])
|
||||
await register_duco_device(discovery_var, discovery, config[CONF_ID])
|
||||
|
||||
if (time_config := config.get(CONF_TIME)) is not None:
|
||||
clock = await cg.get_variable(time_config[CONF_TIME_ID])
|
||||
time_var = cg.new_Pvariable(time_config[CONF_ID], clock)
|
||||
await cg.register_component(time_var, time_config)
|
||||
await cg.register_parented(time_var, var)
|
||||
@@ -0,0 +1,273 @@
|
||||
#include "duco.h"
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "duco");
|
||||
|
||||
static constexpr uint8_t HEADER_START = 0xAA;
|
||||
static constexpr uint8_t HEADER_END = 0x55;
|
||||
static constexpr uint8_t STUFFING = 0x01; // follows an 0xAA inside a message, not part of it
|
||||
static constexpr uint32_t FRAME_GAP_MS = 50;
|
||||
|
||||
static constexpr uint32_t DISCOVERY_START_ID = 0;
|
||||
static constexpr uint32_t DISCOVERY_STEP_ID = 1;
|
||||
static constexpr uint32_t DISCOVERY_START_MS = 16000;
|
||||
static constexpr uint32_t DISCOVERY_STEP_MS = 1600;
|
||||
static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 3;
|
||||
|
||||
void Duco::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
// Read first: after a slow loop pass the rest of a message is already waiting, which is not silence
|
||||
this->read_available([this, now](uint8_t byte) {
|
||||
if (this->parse_byte_(byte)) {
|
||||
this->last_byte_ = now;
|
||||
} else {
|
||||
this->reset_frame_();
|
||||
}
|
||||
});
|
||||
|
||||
// The last message of a burst has no header after it, so it ends on silence
|
||||
if (this->rx_size_ != 0 && now - this->last_byte_ > FRAME_GAP_MS) {
|
||||
this->finalize_message_();
|
||||
this->reset_frame_();
|
||||
}
|
||||
}
|
||||
|
||||
void Duco::reset_frame_() {
|
||||
this->rx_size_ = 0;
|
||||
this->escaped_ = false;
|
||||
}
|
||||
|
||||
void Duco::close_request(DucoDevice *device) {
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.device == device) {
|
||||
pending.function = 0;
|
||||
pending.missed = false;
|
||||
this->update_warning_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Duco::parse_byte_(uint8_t byte) {
|
||||
const size_t at = this->rx_size_;
|
||||
if (at >= MAX_FRAME_SIZE) {
|
||||
ESP_LOGV(TAG, "Message longer than %u bytes dropped", static_cast<unsigned>(MAX_FRAME_SIZE));
|
||||
return false;
|
||||
}
|
||||
const uint8_t prev = at > 0 ? this->rx_buffer_[at - 1] : 0;
|
||||
|
||||
if (at == 1 && prev == HEADER_START && byte == HEADER_END)
|
||||
return false; // header of the next message
|
||||
|
||||
if (at >= 2) {
|
||||
const bool escaped = this->escaped_;
|
||||
this->escaped_ = false;
|
||||
if (prev == HEADER_START && !escaped) {
|
||||
if (byte == STUFFING) {
|
||||
this->escaped_ = true;
|
||||
return true;
|
||||
}
|
||||
if (at >= 3 && byte == HEADER_END) {
|
||||
this->rx_size_--; // drop the 0xAA of the header
|
||||
this->finalize_message_();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this->rx_buffer_[this->rx_size_++] = byte;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Duco::finalize_message_() {
|
||||
const uint8_t *raw = this->rx_buffer_.data();
|
||||
// length (function, id and data), function, id, data, CRC low, CRC high
|
||||
if (this->rx_size_ < 5 || raw[0] < 2 || this->rx_size_ < raw[0] + 3u) {
|
||||
ESP_LOGV(TAG, "Malformed message dropped: %u bytes, length byte %u", this->rx_size_, raw[0]);
|
||||
return;
|
||||
}
|
||||
const uint8_t length = raw[0];
|
||||
|
||||
const uint16_t computed_crc = crc16(raw, length + 1);
|
||||
const uint16_t remote_crc = encode_uint16(raw[length + 2], raw[length + 1]);
|
||||
if (computed_crc != remote_crc) {
|
||||
if (!this->disable_crc_) {
|
||||
ESP_LOGV(TAG, "CRC check failed: %04X != %04X", computed_crc, remote_crc);
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "CRC check failed, ignored: %04X != %04X", computed_crc, remote_crc);
|
||||
}
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
// The box first acknowledges a request with function + 1, then answers with function + 2
|
||||
const DucoMessage message{raw[2], raw + 3, static_cast<uint8_t>(length - 2)};
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.function != 0 && pending.id == message.id && pending.function + 2 == raw[1]) {
|
||||
pending.function = 0;
|
||||
pending.missed = false;
|
||||
this->update_warning_();
|
||||
pending.device->receive_response(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Acknowledgements (odd function codes) are expected and never match
|
||||
if ((raw[1] & 1) == 0) {
|
||||
ESP_LOGV(TAG, "Answer %02X with id %u matches no open request", raw[1], message.id);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void Duco::update_warning_() {
|
||||
for (const auto &pending : this->pending_) {
|
||||
if (pending.missed) {
|
||||
this->status_set_warning(LOG_STR("No answer from the box"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->status_clear_warning();
|
||||
}
|
||||
#endif
|
||||
|
||||
void Duco::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Duco:\n"
|
||||
" CRC Disabled: %s",
|
||||
YESNO(this->disable_crc_));
|
||||
}
|
||||
|
||||
uint8_t Duco::next_id_() {
|
||||
do {
|
||||
this->last_id_++;
|
||||
} while (this->last_id_ == HEADER_START || this->last_id_ == HEADER_END);
|
||||
return this->last_id_;
|
||||
}
|
||||
|
||||
void Duco::send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data) {
|
||||
if (data.size() > MAX_REQUEST_DATA) {
|
||||
ESP_LOGE(TAG, "Request of %u bytes is too long", static_cast<unsigned>(data.size()));
|
||||
return;
|
||||
}
|
||||
// Not byte stuffed, as in the author's original and no capture shows a request with 0xAA. An 0xAA followed by any
|
||||
// byte but 0x01 or 0x55 reads the same with or without unstuffing on the box, so only those rare pairs are
|
||||
// unverified.
|
||||
const size_t size = data.size();
|
||||
const uint8_t id = this->next_id_();
|
||||
uint8_t frame[5 + MAX_REQUEST_DATA + 2] = {HEADER_START, HEADER_END, static_cast<uint8_t>(size + 2), function, id};
|
||||
std::copy_n(data.begin(), size, frame + 5);
|
||||
const uint16_t crc = crc16(frame + 2, size + 3);
|
||||
frame[size + 5] = crc & 0xFF;
|
||||
frame[size + 6] = crc >> 8;
|
||||
this->write_array(frame, size + 7);
|
||||
this->flush();
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.device == device) {
|
||||
pending.missed = pending.function != 0;
|
||||
if (pending.missed) {
|
||||
ESP_LOGV(TAG, "No answer to request %02X with id %u", pending.function, pending.id);
|
||||
this->update_warning_();
|
||||
}
|
||||
pending.id = id;
|
||||
pending.function = function;
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
ESP_LOGV_HEX(TAG, frame + 2, size + 5, sizeof(frame), "Sent %s");
|
||||
}
|
||||
|
||||
static const LogString *node_type_name(uint8_t type) {
|
||||
switch (type) {
|
||||
case 8:
|
||||
return LOG_STR("UCBAT");
|
||||
case 9:
|
||||
return LOG_STR("UC");
|
||||
case 10:
|
||||
return LOG_STR("UCRH");
|
||||
case 12:
|
||||
return LOG_STR("UCCO2");
|
||||
case 13:
|
||||
return LOG_STR("VLV");
|
||||
case 17:
|
||||
return LOG_STR("BOX");
|
||||
case 18:
|
||||
return LOG_STR("SWITCH");
|
||||
default:
|
||||
return LOG_STR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
void DucoDiscovery::setup() {
|
||||
// Give the box time to start before walking its node addresses
|
||||
this->set_timeout(DISCOVERY_START_ID, DISCOVERY_START_MS, [this]() {
|
||||
this->set_interval(DISCOVERY_STEP_ID, DISCOVERY_STEP_MS, [this]() { this->update(); });
|
||||
});
|
||||
}
|
||||
|
||||
void DucoDiscovery::update() {
|
||||
if (this->attempts_ == DISCOVERY_MAX_ATTEMPTS) {
|
||||
ESP_LOGV(TAG, "No answer for node %u", this->next_node_);
|
||||
this->next_node_++;
|
||||
this->attempts_ = 0;
|
||||
}
|
||||
if (this->next_node_ >= NODE_COUNT) {
|
||||
this->cancel_interval(DISCOVERY_STEP_ID);
|
||||
this->parent_->close_request(this);
|
||||
return;
|
||||
}
|
||||
// Without an answer by the next step the same node is asked again; a retry is not a missed answer
|
||||
if (this->attempts_ > 0)
|
||||
this->parent_->close_request(this);
|
||||
ESP_LOGV(TAG, "Discovering node %u", this->next_node_);
|
||||
this->attempts_++;
|
||||
this->parent_->send(this, FUNCTION_NODE, {0x01, this->next_node_});
|
||||
}
|
||||
|
||||
void DucoDiscovery::receive_response(const DucoMessage &message) {
|
||||
// Only the latest attempt is open, so a late answer to an earlier one never arrives here
|
||||
if (message.size >= 1 && this->next_node_ < NODE_COUNT) {
|
||||
if (message.data[0] != 0) {
|
||||
ESP_LOGV(TAG, "Found node %u: type %u", this->next_node_, message.data[0]);
|
||||
}
|
||||
this->node_types_[this->next_node_++] = message.data[0];
|
||||
this->attempts_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void DucoDiscovery::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Discovered nodes:");
|
||||
for (uint8_t node = 0; node < NODE_COUNT; node++) {
|
||||
const uint8_t type = this->node_types_[node];
|
||||
if (type != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Node %u: type %u (%s)", node, type, LOG_STR_ARG(node_type_name(type)));
|
||||
}
|
||||
}
|
||||
if (this->next_node_ < NODE_COUNT) {
|
||||
ESP_LOGCONFIG(TAG, " Still discovering, at node %u", this->next_node_);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_TIME
|
||||
void DucoTime::update() {
|
||||
const ESPTime now = this->clock_->now();
|
||||
if (!now.is_valid()) {
|
||||
ESP_LOGV(TAG, "Time is not valid yet");
|
||||
return;
|
||||
}
|
||||
const uint32_t ts = now.timestamp;
|
||||
this->parent_->send(nullptr, FUNCTION_PARAMETER,
|
||||
{0x05, static_cast<uint8_t>(ts), static_cast<uint8_t>(ts >> 8), static_cast<uint8_t>(ts >> 16),
|
||||
static_cast<uint8_t>(ts >> 24), 0x00, 0x00, 0x00, 0x00});
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <initializer_list>
|
||||
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_TIME
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
// Function codes of the requests; the box answers a request with function + 2
|
||||
static constexpr uint8_t FUNCTION_NODE = 0x0c; // discovery, status and mode of a node
|
||||
static constexpr uint8_t FUNCTION_NODE_DATA = 0x10; // a block of a node's data (sensors, serial number)
|
||||
static constexpr uint8_t FUNCTION_PARAMETER = 0x24; // read or write a box parameter, set the clock
|
||||
|
||||
/// A received message; data points into the receive buffer and is valid only during the callback.
|
||||
struct DucoMessage {
|
||||
uint8_t id;
|
||||
const uint8_t *data;
|
||||
uint8_t size;
|
||||
};
|
||||
|
||||
class DucoDevice;
|
||||
|
||||
/// The open request of one device: the answer to it is function + 2 with the same message id.
|
||||
struct DucoPendingRequest {
|
||||
DucoDevice *device;
|
||||
uint8_t id;
|
||||
uint8_t function; // 0 while nothing is open
|
||||
bool missed; // the request before this one got no answer; sits in padding
|
||||
};
|
||||
|
||||
class Duco : public uart::UARTDevice, public Component {
|
||||
public:
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void register_device(DucoDevice *device) { this->pending_.push_back({device, 0, 0, false}); }
|
||||
#endif
|
||||
|
||||
/// Forget device's open request, for a device that will not send again.
|
||||
void close_request(DucoDevice *device);
|
||||
|
||||
/// Send a request; its answer goes to device, or nowhere if nullptr. A device's new request replaces its
|
||||
/// open one, so an answer is never lost to another device's traffic.
|
||||
void send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data);
|
||||
|
||||
protected:
|
||||
// The longest answer to a request sent here is 25 bytes (node status), the longest request 9
|
||||
static constexpr size_t MAX_FRAME_SIZE = 32;
|
||||
static constexpr size_t MAX_REQUEST_DATA = 9;
|
||||
|
||||
bool parse_byte_(uint8_t byte);
|
||||
void reset_frame_();
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void update_warning_(); // warn while any device's previous request went unanswered
|
||||
#endif
|
||||
void finalize_message_();
|
||||
uint8_t next_id_();
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
StaticVector<DucoPendingRequest, DUCO_DEVICE_COUNT> pending_;
|
||||
#endif
|
||||
std::array<uint8_t, MAX_FRAME_SIZE> rx_buffer_{};
|
||||
uint32_t last_byte_{0};
|
||||
uint8_t rx_size_{0};
|
||||
uint8_t last_id_{10};
|
||||
bool escaped_{false}; // the last 0xAA was followed by a stuffing byte, so it starts no header
|
||||
bool disable_crc_{false};
|
||||
};
|
||||
|
||||
/// Every entity polls the box; deriving from PollingComponent shares its vtable.
|
||||
class DucoDevice : public PollingComponent, public Parented<Duco> {
|
||||
public:
|
||||
virtual void receive_response(const DucoMessage &message) = 0;
|
||||
};
|
||||
|
||||
/// Walks the node addresses once after boot; the nodes found are listed in the config dump.
|
||||
class DucoDiscovery : public DucoDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override; // one discovery step, run by the step interval
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
static constexpr uint8_t NODE_COUNT = 0x45;
|
||||
|
||||
std::array<uint8_t, NODE_COUNT> node_types_{}; // 0 when no node answered at that address
|
||||
uint8_t next_node_{0};
|
||||
uint8_t attempts_{0};
|
||||
};
|
||||
|
||||
#ifdef USE_TIME
|
||||
class DucoTime : public PollingComponent, public Parented<Duco> {
|
||||
public:
|
||||
explicit DucoTime(time::RealTimeClock *clock) : clock_(clock) {}
|
||||
void update() override;
|
||||
|
||||
protected:
|
||||
time::RealTimeClock *clock_;
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace esphome::duco
|
||||
Loaded 100 of 623 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user