diff --git a/.agents/skills/display-test/SKILL.md b/.agents/skills/display-test/SKILL.md new file mode 100644 index 00000000000..4e6a379fd64 --- /dev/null +++ b/.agents/skills/display-test/SKILL.md @@ -0,0 +1,156 @@ +--- +name: display-test +description: Test an ESPHome display driver on a real panel with a camera. Use when a task asks to verify a display (LCD, e-paper, OLED, RLCD) on hardware, check orientation/rotation, partial updates, colours, contrast, or that text and shapes render correctly. Covers finding the USB port, flashing a test pattern, capturing the panel with any camera, and reading the photo. +allowed-tools: Read, Write, Edit, Bash, Glob, Grep +--- + +# Display testing with a camera + +Point a camera at the panel to get visual evidence that a driver renders correctly. Any camera +and capture tool works; this skill covers the common ones. `$SKILL` below is this directory +(`.agents/skills/display-test`). + +## Ask before using a camera + +A camera can show people and rooms, not only the panel. Before the first capture, ask the user +for permission and for which camera to use. List the cameras the host has (the commands below +show how), name the one you intend to use, and wait for the user to confirm it. Do not pick a +camera yourself, and do not fall back to another camera if the chosen one fails; ask again. + +The permission covers this session and the camera the user named. Ask again for a different +camera or in a new session. If the user declines, stop and report that the display was not +checked visually. + +## Capturing a frame + +Grab a still image, skipping the first few frames so auto-exposure and white balance settle. +Read the JPEG with the Read tool; the image is shown visually. + +### macOS + +The bundled Swift tool uses AVFoundation and avoids the frame-grabbing problems some USB +cameras have with other tools: + +```bash +mkdir -p /tmp/camsnap /tmp/display-test +swiftc -O "$SKILL/scripts/camsnap.swift" -o /tmp/camsnap/camsnap +/tmp/camsnap/camsnap /tmp/display-test/after.jpg 30 +``` + +The camera argument is a case-insensitive substring of the name AVFoundation reports. If it does +not match, the tool lists the cameras it found, so a run with a name that matches nothing lists +the cameras without capturing. Use a substring that matches only the camera the user approved. +It creates the output directory if needed and reports encoding or write errors on stderr. Other +options include `ffmpeg` with `avfoundation` input, `imagesnap`, or Photo Booth's still capture. + +### Linux + +Use `ffmpeg` with the v4l2 input, `fswebcam`, or `v4l2-ctl`. With `ffmpeg`, `-ss 2` after the +input discards the first two seconds of frames while exposure settles: + +```bash +mkdir -p /tmp/display-test +ffmpeg -f v4l2 -i /dev/video0 -ss 2 -frames:v 1 -y /tmp/display-test/after.jpg +# or +fswebcam -d /dev/video0 --skip 10 /tmp/display-test/after.jpg +``` + +List devices with `v4l2-ctl --list-devices`. + +### Windows + +Use `ffmpeg` with the DirectShow input (`-ss 2` skips the first frames, as on Linux), or any +camera application that can save a still image: + +```powershell +mkdir C:\temp -Force | Out-Null +ffmpeg -f dshow -list_devices true -i dummy # find the device name +ffmpeg -f dshow -i video="" -ss 2 -frames:v 1 -y C:\temp\after.jpg +``` + +### Camera caveats + +- The board may sit at an angle. Do not trust absolute pixel positions or aspect ratios from the + photo; judge orientation, ordering and approximate spacing, using the corner markers as + references. +- Reflections or a glossy cover can brighten one side. Capture again if a region is unclear. +- A moving pattern is caught at an arbitrary phase. Take several captures, or use a static marker + per position. + +## Board and port + +Find the port; names change on every replug: + +```bash +python3 "$SKILL/../hardware-test/scripts/ports.py" +``` + +Native-USB ESP32-S3/C3/C6/H2 boards show up as `USB JTAG/serial debug unit` and need +`logger: hardware_uart: USB_SERIAL_JTAG`. + +## Test pattern + +Write a scratch config outside the checkout. Use one `name:` per board and include: + +- a white background, a black border and a filled square at the centre, drawn in the first + update (full-window check and scale); +- a 60x20 bar near each corner, one bar per later update with `auto_clear_enabled: false`, so + each bar is written by its own partial window. The bars are wider than tall, which shows a + 90 or 270 degree rotation; the corners exercise the window alignment; +- a 20x20 square under the left end of the top-left bar, drawn in the same update, which marks + the top-left corner and shows a 180 degree rotation or a mirrored image. + +Use `Color::BLACK` and `Color::WHITE` in lambdas. Do not use the old `COLOR_OFF`/`COLOR_ON` +constants; black must mean black. + +A ready template is in `$SKILL/templates/display-pattern.yaml`. Substitute the display platform, +pins and (for a driver with fonts) a font. The marker pattern needs no font. + +## Build, flash, capture + +Run from the worktree under test so `python -m esphome` imports the checkout, not an installed +copy: + +```bash +cd +python3 -c "import esphome; print(esphome.__file__)" # must print the worktree path +PYTHONUNBUFFERED=1 python3 -m esphome compile /tmp/display-test/display-pattern.yaml +PYTHONUNBUFFERED=1 python3 -m esphome upload /tmp/display-test/display-pattern.yaml \ + --device /dev/cu.usbmodemXXXX +sleep 6 # boot plus a few update intervals + /tmp/display-test/after.jpg +``` + +The device path differs by OS and host; use the path printed by `ports.py`. If the USB console +wedges during flashing (esptool `No serial data received`), replug the board and retry. A board +powered only by a battery may need the USB cable for flashing; the panel keeps its image when +power is removed, which is useful for checking that an image is bistable. + +## What to check + +1. **Orientation**: the extra square sits under the bar in the expected top-left corner. If it is + in another corner, the image is rotated or mirrored. Test `rotation:` values with the same + pattern. The logical width and height swap at 90 and + 270 degrees, so the 60x20 corner bars should then appear taller than wide on the physical + panel. +2. **Coverage**: the border touches all four edges, with no dead rows or columns at the edges. +3. **Partial updates**: with `auto_clear_enabled: false`, draw one marker per update. Each should + appear exactly at its requested position with no smearing, offset or mirrored placement. + Include all four corners. +4. **Text and contrast**: sharp glyphs, correct polarity (black text on white, not inverted), no + ghosting from the previous frame. +5. **Speed**: if the driver logs transfer sizes, check that a full refresh and a partial window + take the expected times. + +Record the build commit, board, config, and the captured image path when reporting results. + +## When a partial update looks wrong + +Take a marker-only capture before assuming the mapping is wrong: the camera's perspective makes +absolute positions deceptive. If markers land in mirrored positions, the controller's address +order (`MADCTL` MX/MY/MV) or the RAM block mapping is inverted for the window; verify the +full-frame path separately, then fix the window computation. + +## Add findings + +Add new capture tools, panel quirks, or recovery steps here so the next session starts ahead. diff --git a/.agents/skills/display-test/scripts/camsnap.swift b/.agents/skills/display-test/scripts/camsnap.swift new file mode 100644 index 00000000000..9464d9db241 --- /dev/null +++ b/.agents/skills/display-test/scripts/camsnap.swift @@ -0,0 +1,71 @@ +// camsnap [frames to skip] +// macOS only: grabs one frame from an AVFoundation camera after letting exposure settle. +// See SKILL.md for capture tools on other systems. +import AVFoundation +import AppKit +import CoreImage + +let args = CommandLine.arguments +guard args.count >= 3 else { + FileHandle.standardError.write("usage: camsnap [skip]\n".data(using: .utf8)!) + exit(2) +} +let wanted = args[1].lowercased() +let outPath = args[2] +let skip = args.count > 3 ? Int(args[3]) ?? 30 : 30 + +let discovery = AVCaptureDevice.DiscoverySession( + deviceTypes: [.external, .builtInWideAngleCamera], mediaType: .video, position: .unspecified) +guard let device = discovery.devices.first(where: { $0.localizedName.lowercased().contains(wanted) }) else { + let names = discovery.devices.map { $0.localizedName }.joined(separator: ", ") + FileHandle.standardError.write("no camera matching '\(wanted)'; found: \(names)\n".data(using: .utf8)!) + exit(1) +} + +final class Grabber: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { + var seen = 0 + let skip: Int + let out: String + init(skip: Int, out: String) { self.skip = skip; self.out = out } + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection) { + seen += 1 + guard seen > skip, let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + let image = CIImage(cvPixelBuffer: pixels) + let rep = NSBitmapImageRep(ciImage: image) + guard let jpeg = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else { + FileHandle.standardError.write("failed to encode \(out) as JPEG\n".data(using: .utf8)!) + exit(1) + } + let url = URL(fileURLWithPath: out) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + do { + try jpeg.write(to: url) + } catch { + FileHandle.standardError.write("failed to write \(out): \(error)\n".data(using: .utf8)!) + exit(1) + } + print("saved \(out) after \(seen) frames") + exit(0) + } +} + +let session = AVCaptureSession() +session.sessionPreset = .high +guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { + FileHandle.standardError.write("cannot open \(device.localizedName)\n".data(using: .utf8)!) + exit(1) +} +session.addInput(input) +let output = AVCaptureVideoDataOutput() +output.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] +let grabber = Grabber(skip: skip, out: outPath) +output.setSampleBufferDelegate(grabber, queue: DispatchQueue(label: "camsnap")) +session.addOutput(output) +session.startRunning() +DispatchQueue.main.asyncAfter(deadline: .now() + 15) { + FileHandle.standardError.write("timed out waiting for frames\n".data(using: .utf8)!) + exit(1) +} +RunLoop.main.run() diff --git a/.agents/skills/display-test/templates/display-pattern.yaml b/.agents/skills/display-test/templates/display-pattern.yaml new file mode 100644 index 00000000000..3a505921c71 --- /dev/null +++ b/.agents/skills/display-test/templates/display-pattern.yaml @@ -0,0 +1,43 @@ +# Display test pattern: corner markers, centre reference and partial-update markers. +# Replace the display platform and pins with the driver under test. +esphome: + name: display-pattern + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +logger: + hardware_uart: USB_SERIAL_JTAG + level: DEBUG + +spi: + clk_pin: GPIO11 + mosi_pin: GPIO12 + +display: + - platform: st7305 + id: test_display + cs_pin: GPIO40 + dc_pin: GPIO5 + reset_pin: GPIO41 + update_interval: 1s + auto_clear_enabled: false + lambda: |- + static int frame = 0; + if (frame == 0) { + it.fill(Color::WHITE); + it.rectangle(0, 0, it.get_width(), it.get_height(), Color::BLACK); + it.filled_rectangle(it.get_width() / 2 - 10, it.get_height() / 2 - 10, 20, 20, Color::BLACK); + } else if (frame == 1) { + it.filled_rectangle(20, 20, 60, 20, Color::BLACK); + it.filled_rectangle(20, 50, 20, 20, Color::BLACK); + } else if (frame == 2) { + it.filled_rectangle(20, it.get_height() - 40, 60, 20, Color::BLACK); + } else if (frame == 3) { + it.filled_rectangle(it.get_width() - 80, 20, 60, 20, Color::BLACK); + } else if (frame == 4) { + it.filled_rectangle(it.get_width() - 80, it.get_height() - 40, 60, 20, Color::BLACK); + } + frame++; diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md index 41f75f1a4d3..8127cdd580a 100644 --- a/.agents/skills/hardware-test/SKILL.md +++ b/.agents/skills/hardware-test/SKILL.md @@ -115,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/ (host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set, API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and -capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with WebSocket listeners, test CA, transport case runner). +capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer). ## Templates in `$SKILL/templates` @@ -132,11 +132,14 @@ 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-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`. ## When something looks wrong - A capture stopped early or a line is missing: check the timestamps before trusting it. +- A script that pipes into `tee` never ends although its work is done: a background command written as + `(cd dir && server & echo $!)` backgrounds the whole `cd && server` list, so a shell stays behind holding the + pipe open and `$!` is that shell, not the server. Write `(cd dir && exec server > out 2>&1 < /dev/null) &`. - The client never joins the AP: another board may still run an AP with the same SSID, or the device booted with saved credentials because the name was reused. One AP device at a time on the bench. - Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse diff --git a/.agents/skills/hardware-test/reference/boards.md b/.agents/skills/hardware-test/reference/boards.md index 805643b59f1..7bf2ba959c5 100644 --- a/.agents/skills/hardware-test/reference/boards.md +++ b/.agents/skills/hardware-test/reference/boards.md @@ -7,6 +7,9 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever - Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset` pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`). +- UART0 can be looped back onto itself (`USC0(0) |= (1 << UCLBE)` in an `on_boot` lambda) so a UART + component is testable without its device: frames written with `uart.write` arrive on RX. Log over the + API with `logger: baud_rate: 0`, since the serial port carries the component's bytes. - `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset; a plain pyserial capture at 115200 is reliable. - A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug. diff --git a/.agents/skills/hardware-test/reference/recipes/README.md b/.agents/skills/hardware-test/reference/recipes/README.md index 89be9429397..217d7c2b0e0 100644 --- a/.agents/skills/hardware-test/reference/recipes/README.md +++ b/.agents/skills/hardware-test/reference/recipes/README.md @@ -4,23 +4,24 @@ 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 +- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client - [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer - [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API - [`espnow.md`](espnow.md): ESP8266 broadcast receive - [`hdmi_cec.md`](hdmi_cec.md): two-board wire -- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback +- [`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 +- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check - [`nrf52.md`](nrf52.md): dongle board support - [`online_image.md`](online_image.md): stalled and malformed downloads - [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP - [`ota.md`](ota.md): overlap and lock, timeouts, deflate - [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH - [`scheduler.md`](scheduler.md): loop wake A/B -- [`udp.md`](udp.md): datagrams, multicast, sockets +- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes - [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver -- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP) +- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP), WISP bridge with an ethernet upstream - [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle - [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards - [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy @@ -29,4 +30,4 @@ One file per component, named after it: - [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin - [`dfrobot_sen0395.md`](dfrobot_sen0395.md): SEN0395 mmWave command flow, module quirks and overlong lines through its error reply - [`speaker.md`](speaker.md): media player pipeline tasks on the Connect AUX-2 with the `speaker` platform player -- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf +- [`web_server.md`](web_server.md): state SSE stress and the HTTPD task stack on web_server_idf, entity icon and device class readback diff --git a/.agents/skills/hardware-test/reference/recipes/api.md b/.agents/skills/hardware-test/reference/recipes/api.md index 4776fe72dc4..2f79744b898 100644 --- a/.agents/skills/hardware-test/reference/recipes/api.md +++ b/.agents/skills/hardware-test/reference/recipes/api.md @@ -28,3 +28,15 @@ connected first, and the loop-time maxima. For the loop cost of polling, repeat so after a drop every dial is closed and HA's incoming reconnect wins 11 to 33 s later; the measurable effect is the first dial landing in the loop the network flag flips. Results: https://github.com/esphome/esphome/pull/21085#issuecomment-6254742791 + +## User defined action listing and execution without a client library (#18882) + +`scripts/api/rawactions.py HOST list` speaks the plaintext native API over a raw socket and decodes every +`ListEntitiesServicesResponse` by field number (argument fields 1 name, 2 type, 3 description, 4 example, 5 optional, +6 default_value), so new wire fields show up without waiting for the client library. `rawactions.py HOST exec ACTION +k=v ...` sends `ExecuteServiceRequest` with `sint32` ints (field 5, not the legacy int32 field 2), fixed32 floats, +length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSING=1` leaves arguments out on purpose +(the device then drops the call). `templates/api-actions-dut.yaml` (D1 mini, three actions, optional variables with +defaults) and `templates/api-actions-strings-dut.yaml` (required strings) log what arrived. String arguments are +`StringRef`s that are not null terminated: copy them with `snprintf("%.*s", size, c_str())` into a buffer before +logging, and do not pass `%.*s` to `logger.log`, whose validator counts it as one pattern. diff --git a/.agents/skills/hardware-test/reference/recipes/ld2412.md b/.agents/skills/hardware-test/reference/recipes/ld2412.md new file mode 100644 index 00000000000..1cbe584d4e2 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/ld2412.md @@ -0,0 +1,13 @@ +# ld2412 + +## Output pin level and the basic configuration command (#21353) + +`templates/ld2412-select-only-dut.yaml` (ESP32-S3, the module on GPIO43 and GPIO44 at 115200, only the output pin +level select, the module's OUT pin on GPIO38 as a binary sensor plus the presence sensor) with `uart: debug: +direction: BOTH`. Drive the select with `scripts/ld2450/lddrive.py HOST KEY LOGFILE sel:NAME=OPTION` and read the TX +dump for command `02` carrying the level byte, then the OUT pin binary sensor against presence: with level high the +pin follows presence, with level low it is inverted. A module that never answers the boot query (no `<<<` lines) +only shows the held back path: `Command 02 held back until the module reports its settings`, a query `12`, and after +2 s `Command 02 not sent, the module did not report its settings` with the select republished to the last known +level. With the full set of numbers configured a select change also needs every number to have a state, so wait +for the boot query answer before judging. diff --git a/.agents/skills/hardware-test/reference/recipes/ld2450.md b/.agents/skills/hardware-test/reference/recipes/ld2450.md index 94ea253faa3..27d7ce487fa 100644 --- a/.agents/skills/hardware-test/reference/recipes/ld2450.md +++ b/.agents/skills/hardware-test/reference/recipes/ld2450.md @@ -13,3 +13,30 @@ selects over the API and prints every LD2450 state update. After each set the hu the radar and republishes all twelve coordinates from the answer, so set each coordinate of each zone to a distinct value and check the readbacks; no target is needed. Readbacks can arrive late over Wi-Fi, so match them by content. Results: https://github.com/esphome/esphome/pull/21146#issuecomment-6278026922 + +## ESP8266 loopback harness, no radar needed (#21349) + +`templates/ld2450-loopback.yaml` (package; substitutions `name` and `baud`) runs the hub on UART0 (GPIO1 and GPIO3) +with `logger: baud_rate: 0` and sets the UART0 loopback bit in `on_boot` (`USC0(0) |= (1 << UCLBE)`), so everything +the hub or a `uart.write` action sends comes back on RX. Template buttons inject periodic frames (all four direction +strings, targets 2 and 3), query zone acks (zone type 0, 1 and 2 for the select readback) and call the name based +setters with a valid and a bogus name. `uart: debug: direction: TX` delimited on the command footer dumps every +command the hub sends. Drive it with `scripts/ld2450/lddrive.py HOST KEY LOGFILE press:NAME sel:NAME=OPTION wait:S` +(logs over the API at VERBOSE, 2 s between steps because the API log stream drops lines while `send_command_` +blocks), then `txsum.py LOGFILE` lists the TX frames per step; diff the summaries of the two builds. The hub reads its +own commands back and logs `Invalid status` for each, which is harmless. The boot readback of the baud rate select +comes from the UART setting, so a second build at `baud: "9600"` checks the flash table lookup. +Results: https://github.com/esphome/esphome/pull/21349#issuecomment-6332320026 + +## Frame splitter collisions (#21357) + +The loopback template also carries the zone 1 numbers and seven collision buttons: a periodic frame with the +command footer inside target 1, zone acks with `55 CC` and with `04 03 02 01` inside the coordinates, a periodic +frame cut after 12 bytes followed by a zone ack, a frame missing its last byte followed by a complete one, a bogus +255 byte length then a frame, and garbage then a frame. Judge by the state updates plus the `Handling Periodic` and +`Handling Ack` lines. Through a real radar the set zone ack is sometimes corrupted by the radar itself (its tail +replaced by the periodic frame it was sending, for example `04 03 03 03 00 ... 55 CC`), so count readbacks over at +least 24 selects per build and read the raw bytes with `uart: debug: direction: RX` (`!extend` the package's uart +id) before blaming the parser; the API log stream drops lines under that dump volume, so some misses stay +unclassified. +Results: https://github.com/esphome/esphome/pull/21357#issuecomment-6333244886 diff --git a/.agents/skills/hardware-test/reference/recipes/mqtt.md b/.agents/skills/hardware-test/reference/recipes/mqtt.md index 4283a58198b..17e1b22f603 100644 --- a/.agents/skills/hardware-test/reference/recipes/mqtt.md +++ b/.agents/skills/hardware-test/reference/recipes/mqtt.md @@ -20,7 +20,7 @@ the TLS listener's certificate, both run in the scratch directory so keys, logs to `NAME/#`, sends a switch command and an `on_message` topic, presses the 3000 byte publish button over the API, restarts the local broker when the last argument is `restart` (the PID in `mosquitto.pid`, written when the broker was started with `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` in the working directory next to the -certificates), checks its three ports answer again, and prints the broker's message counts with the device log. `mosquitto_sub` cannot speak +certificates), checks every listener in the broker config answers again, and prints the broker's message counts with the device log. `mosquitto_sub` cannot speak WebSocket, so it subscribes on the plain listener of the same broker. Cases: `tcp` on 1883, `ws` on 9001, `wss` on 9002 with `certificate_authority` (the certificate names an address, so `skip_cert_cn_check: true`), `wss` to test.mosquitto.org on 8081 with no CA for the certificate bundle, and two negatives that must never connect: a @@ -29,3 +29,25 @@ different CA (`mbedtls_ssl_handshake returned -0x2700`) and the self-signed serv default in the generated sdkconfig. The TLS session costs about 29 kB of internal heap while connected; the public broker carries other devices' `esphome/discover` traffic, which is noise. Results: https://github.com/esphome/esphome/pull/16056#issuecomment-6301034019 + +## TLS trust sources on ESP-IDF (#14318) + +One build per trust source from `templates/mqtt-tls-dut.yaml`: broker only (port must resolve to 1883), +`certificate_authority` with no port (must resolve to 8883), `verify_ssl: false` with no CA, the CA plus +`client_certificate` and `client_certificate_key` on the 8884 listener that requires one, `transport: wss` with the CA, +`tls: true` to test.mosquitto.org on 8886 for the certificate bundle, and `tls: true` to the local self-signed broker as +the negative. `scripts/mqtt/mosquitto-ws.conf` now has the 8883 and 8884 listeners and `scripts/mqtt/make_test_ca.sh` +also writes `client.crt`/`client.key`. Before flashing, check each build's `src/esphome/core/defines.h` for +`USE_MQTT_TLS`, `USE_MQTT_CERT_BUNDLE` and `USE_MQTT_TLS_INSECURE` and its `sdkconfig.` for +`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE` and `CONFIG_ESP_TLS_INSECURE`: only the bundle builds may carry the bundle and only +the unverified build the insecure flags. `scripts/mqtt/flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST +local|public|neg` runs one build end to end (flash, bounded capture, `cases.sh` with a restart for the local broker, +counts and the broker's per-listener connection lines) and prints the config dump's `TLS:` line (`CA certificate`, +`certificate bundle` or `no verification`). The CLI side (`esphome/mqtt.py`) is covered without a board by +`scripts/mqtt/cli_tls_check.sh TREE [neg] YAML...`, which runs `esphome clean-mqtt` under a timeout: status 124 +after `Connected to MQTT broker!` is a pass, and with `neg` each config must instead fail with +`CERTIFICATE_VERIFY_FAILED` and never connect. Python's default SSL +context rejects a CA that lacks a `keyUsage` extension (`CA cert does not include key usage extension`) while mbedTLS +on the device accepts it, which is why the test CA carries `basicConstraints` and `keyUsage`. A broker kill shows as one +`Last esp-tls error: 0x8008` on every build, TLS or not. Results: +https://github.com/esphome/esphome/pull/14318#issuecomment-6311863827 diff --git a/.agents/skills/hardware-test/reference/recipes/udp.md b/.agents/skills/hardware-test/reference/recipes/udp.md index b029e42ff27..31035480086 100644 --- a/.agents/skills/hardware-test/reference/recipes/udp.md +++ b/.agents/skills/hardware-test/reference/recipes/udp.md @@ -9,3 +9,18 @@ listener. Broadcast to boards is lossy under power save, unicast is not. Flood a reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect` (`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action rejects `%.*s`; use a lambda. + +## udp.write payload shapes (#21263, ApplyBytes) + +`templates/udp-write-dut.yaml` puts every payload shape behind a template button (constant 4 bytes twice, 3, 9, 0, +128, 129 and 600 bytes, a 300 byte lambda, a simple value, a string) plus an `on_receive` that replies with the +received bytes reversed. `scripts/udp/write_cases.sh DEVICE_IP KEY` records the device's datagrams with +`udp_listen.py` on the test host, presses the buttons two seconds apart, sends one unicast datagram for the echo and +compares everything byte for byte with `check.py`; a missing datagram shifts every later one, so read the first +mismatch. Before flashing, check the generated `main.cpp`: on ESP8266 a constant payload is copied out of its +`PROGMEM` table into `uint8_t esphome__bytes[N]` up to the stack limit and into `std::make_unique` beyond, +identical payloads share one table, a lambda is sent from its vector. An empty constant payload is generated as +`send_packet(nullptr, 0)`, and the raw lwIP UDP `sendto` used on ESP8266 rejected a null buffer with `EINVAL` +(`sendto() error 22` in the log, no datagram) until it accepted `nullptr` with a zero length; the old +`TemplatableBytes::visit` had passed a zero length stack buffer instead. Compare sizes against the stacked PR's own +head when the PR is stacked, not against dev. Results: https://github.com/esphome/esphome/pull/21263 diff --git a/.agents/skills/hardware-test/reference/recipes/web_server.md b/.agents/skills/hardware-test/reference/recipes/web_server.md index e021cb6e253..57eba800e05 100644 --- a/.agents/skills/hardware-test/reference/recipes/web_server.md +++ b/.agents/skills/hardware-test/reference/recipes/web_server.md @@ -29,3 +29,32 @@ clients. Entity URLs use the entity name, URL encoded, not an object id. The HTT request of the boot; compare after the mix and after the SSE round, with two boots when the numbers are close. Results: https://github.com/esphome/esphome/pull/21109#issuecomment-6300582328 and https://github.com/esphome/esphome/pull/21038#issuecomment-6301310182 + +## Entity icon and device class readback (#21111) + +Covers every path that reads an entity's icon or device class: the native API listing, MQTT discovery, the web_server +`/events` listing and REST endpoints, and `dump_config`. `templates/entity-icon-dut.yaml` (ESP8266 board with wifi, +encrypted api, ota, web_server and mqtt against a local broker) carries a sensor with a 63 character icon, one with no +icon or device class, an internal sensor, and binary_sensor, switch, button, text_sensor, number, select, event, cover +and text entities with icons and device classes. The per-run file supplies `name` and `broker_ip` through +substitutions. Flash the PR and base builds under different names, then from the scratch directory: + +```sh +scripts/entity_icons/readback.sh DEVICE_IP KEY NAME BROKER_HOST cap-NAME.txt # once per build +scripts/entity_icons/compare.sh ic-base ic-pr # api, web, mqttn, dump all identical +``` + +`readback.sh` takes about 25 s once the device is on the network: 12 s of `mosquitto_sub` on `homeassistant/#` and +`NAME/#`, the API listing (`api_list.py`, one JSON line per entity with icon, device class, unit, options and event +types) and `web_dump.py` (6 s on `/events`, keeping only the listing rows that carry `domain`, then a GET of each +listed id with `?detail=all` and one retry, because the ESP8266 async server occasionally leaves a request unanswered +within 5 s). The web ids are `domain/Name`, not object ids, and `/event/` has no GET handler (404 on every +build). `mqtt_norm.py` drops the `dev` block (compile time) and the `NAME/debug` topic (heap probe), so a clean run +differs in nothing. `dump_config` prints icons and device classes only for some domains; button, event and +binary_sensor icons never appear there. + +Found with this flow on 2026-10-21: since #19577 every ESP8266 `web_server` build that lists a cover, climate, lock, +valve, update, alarm_control_panel or water_heater entity reboots with `Exception (3) LoadStoreErr` in +`json::linked(const char *)` (the ROM `strlen` on a flash address), fixed by #21316. The cover in the template keeps +that path covered; before #21316 is merged, apply its one line change to both trees or the web_server comparison +cannot run. diff --git a/.agents/skills/hardware-test/reference/recipes/wifi.md b/.agents/skills/hardware-test/reference/recipes/wifi.md index 055fe267084..721de7da433 100644 --- a/.agents/skills/hardware-test/reference/recipes/wifi.md +++ b/.agents/skills/hardware-test/reference/recipes/wifi.md @@ -23,3 +23,17 @@ Only an Android phone configures: Settings, network details, Add device, Share W QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG. `esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the supplicant state machine. + +## WISP bridge with an ethernet upstream (#7147) + +`templates/wisp-ethernet-dut.yaml` (ESP32-S3 with a W5500, `wifi: ap:` with `ap_mode: WISP` and no station) and +`templates/wisp-client.yaml` (D1 mini joining the bridge, `http_request` to a web server on the wired LAN and to +`http://example.com/` every 15 s, `time: sntp`). Secrets: `wisp_psk` and `lan_http` (serve a text file on the host +with `python -m http.server 8787 --bind 0.0.0.0` from a scratch directory). Judge by the client's `GET status 200` +lines, the web server log showing the device's ethernet address as the source (NAT), the client's DHCP info (DNS1 is +the LAN resolver) and `SNTP synced`. The device logs `Starting AP` and `DHCP DNS server: ` on its USB +console; flashing over the air reboots it for repeat boots. Before the AP address and DHCP server moved ahead of the +AP start (the fix in that PR), the first AP start failed on every boot with `esp_netif_dhcps_option failed: 20484` +(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the +retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix +the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer. diff --git a/.agents/skills/hardware-test/scripts/api/rawactions.py b/.agents/skills/hardware-test/scripts/api/rawactions.py new file mode 100644 index 00000000000..96af80ff69b --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api/rawactions.py @@ -0,0 +1,211 @@ +"""Plaintext native API client that lists user actions with their raw argument fields and executes one. + +usage: rawactions.py HOST list + rawactions.py HOST exec ACTION key=value [...] (every argument must be given; types from the listing) +Fields of ListEntitiesServicesArgument: 1 name, 2 type, 3 description, 4 example, 5 optional, 6 default_value. +""" + +import os +import socket +import struct +import sys + +HOST = sys.argv[1] +CMD = sys.argv[2] +ARGS = sys.argv[3:] +TYPE_NAMES = { + 0: "bool", + 1: "int", + 2: "float", + 3: "string", + 4: "bool[]", + 5: "int[]", + 6: "float[]", + 7: "string[]", +} + + +def varint(v: int) -> bytes: + out = bytearray() + while True: + b = v & 0x7F + v >>= 7 + if v: + out.append(b | 0x80) + else: + out.append(b) + return bytes(out) + + +def svarint(v: int) -> bytes: + return varint((v << 1) ^ (v >> 63)) + + +def decode_varint(buf: bytes, pos: int) -> tuple[int, int]: + value = shift = 0 + while True: + b = buf[pos] + pos += 1 + value |= (b & 0x7F) << shift + if not b & 0x80: + return value, pos + shift += 7 + + +def fields(payload: bytes) -> list[tuple[int, int, object]]: + out = [] + pos = 0 + while pos < len(payload): + key, pos = decode_varint(payload, pos) + fnum, wt = key >> 3, key & 7 + if wt == 0: + v, pos = decode_varint(payload, pos) + elif wt == 2: + ln, pos = decode_varint(payload, pos) + v = payload[pos : pos + ln] + pos += ln + elif wt == 5: + v = payload[pos : pos + 4] # fixed32, caller unpacks + pos += 4 + else: + raise ValueError(f"wire type {wt}") + out.append((fnum, wt, v)) + return out + + +def frame(mtype: int, payload: bytes) -> bytes: + return b"\x00" + varint(len(payload)) + varint(mtype) + payload + + +def field_bytes(fnum: int, data: bytes) -> bytes: + return varint((fnum << 3) | 2) + varint(len(data)) + data + + +def field_varint(fnum: int, v: int) -> bytes: + return varint((fnum << 3) | 0) + varint(v) + + +def recv_frames( + sock: socket.socket, until: set[int], limit: float = 5.0 +) -> list[tuple[int, bytes]]: + sock.settimeout(limit) + buf = bytearray() + frames = [] + while True: + try: + chunk = sock.recv(4096) + except TimeoutError: + return frames + if not chunk: + return frames + buf += chunk + while True: + if len(buf) < 3: + break + if buf[0] != 0: + raise ValueError("not plaintext") + try: + ln, p = decode_varint(buf, 1) + mt, p = decode_varint(buf, p) + except IndexError: + break + if len(buf) < p + ln: + break + frames.append((mt, bytes(buf[p : p + ln]))) + del buf[: p + ln] + if mt in until: + return frames + + +def main() -> None: + sock = socket.create_connection((HOST, 6053), timeout=5) + sock.sendall( + frame( + 1, field_bytes(1, b"rawactions") + field_varint(2, 1) + field_varint(3, 10) + ) + ) # HelloRequest + recv_frames(sock, {2}) + sock.sendall(frame(3, b"")) # ConnectRequest, no password + recv_frames(sock, {4}) + sock.sendall(frame(11, b"")) # ListEntitiesRequest + frames = recv_frames(sock, {19}) + services = {} + for mt, payload in frames: + if mt != 41: + continue + name = key = None + args = [] + for fnum, _, v in fields(payload): + if fnum == 1: + name = v.decode() + elif fnum == 2: + key = struct.unpack(" None: + c = APIClient(HOST, 6053, None, noise_psk=KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + rows = [] + for e in ents: + row = { + "type": type(e).__name__, + "name": e.name, + "object_id": e.object_id, + "icon": e.icon, + } + for k in ( + "device_class", + "unit_of_measurement", + "event_types", + "options", + "mode", + ): + if hasattr(e, k): + v = getattr(e, k) + row[k] = ( + list(v) + if isinstance(v, (list, tuple)) + else (v.name if hasattr(v, "name") else v) + ) + row["entity_category"] = ( + e.entity_category.name + if hasattr(e.entity_category, "name") + else e.entity_category + ) + row["disabled_by_default"] = e.disabled_by_default + rows.append(row) + rows.sort(key=lambda r: (r["type"], r["name"])) + with pathlib.Path(OUT).open("w", encoding="utf-8") as f: + f.writelines(json.dumps(r, sort_keys=True) + "\n" for r in rows) + print(f"{len(rows)} entities listed") + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/entity_icons/compare.sh b/.agents/skills/hardware-test/scripts/entity_icons/compare.sh new file mode 100644 index 00000000000..3b91a1c8968 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/entity_icons/compare.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# compare.sh NAME_A NAME_B : diff the readback.sh outputs of two device names (api, web, mqttn and, when present, dump) +# after replacing each name with DEV. Exit 1 when any pair differs; the diffs stay in cmp-KIND.diff. +A=$1; B=$2; rc=0 +[ -n "$A" ] && [ -n "$B" ] || { echo "usage: compare.sh NAME_A NAME_B"; exit 2; } +for k in api web mqttn dump; do + ext=jsonl; [ $k = dump ] && ext=txt + [ -f "$PWD/$k-$A.$ext" ] && [ -f "$PWD/$k-$B.$ext" ] || { echo "$k: missing"; [ $k = dump ] || rc=1; continue; } + sed "s/$A/DEV/g; s/$(echo "$A" | tr - _)/DEV/g" "$PWD/$k-$A.$ext" > "$PWD/cmp-$k-a.txt" + sed "s/$B/DEV/g; s/$(echo "$B" | tr - _)/DEV/g" "$PWD/$k-$B.$ext" > "$PWD/cmp-$k-b.txt" + if diff -u "$PWD/cmp-$k-a.txt" "$PWD/cmp-$k-b.txt" > "$PWD/cmp-$k.diff"; then echo "$k: identical ($(wc -l < "$PWD/cmp-$k-b.txt" | tr -d ' ') lines)"; else echo "$k: DIFFERS"; head -20 "$PWD/cmp-$k.diff"; rc=1; fi +done +exit $rc diff --git a/.agents/skills/hardware-test/scripts/entity_icons/mqtt_norm.py b/.agents/skills/hardware-test/scripts/entity_icons/mqtt_norm.py new file mode 100644 index 00000000000..a980212a4a2 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/entity_icons/mqtt_norm.py @@ -0,0 +1,37 @@ +"""Normalise mosquitto_sub output (topic TAB payload) for one device: drop other devices, replace the name, drop dev block. + +usage: mqtt_norm.py IN NAME OUT +""" + +import json +import pathlib +import sys + +IN, NAME, OUT = sys.argv[1], sys.argv[2], sys.argv[3] +rows = {} +with pathlib.Path(IN).open(encoding="utf-8") as f: + for line in f: + topic, _, payload = line.rstrip("\n").partition("\t") + if ( + f"/{NAME}/" not in topic and not topic.startswith(f"{NAME}/") + ) or topic == f"{NAME}/debug": + continue + topic = topic.replace(NAME, "DEV") + try: + obj = json.loads(payload) + except ValueError: + rows[topic] = payload.replace(NAME, "DEV") + continue + if isinstance(obj, dict): + obj.pop("dev", None) + obj = { + k: (v.replace(NAME, "DEV") if isinstance(v, str) else v) + for k, v in obj.items() + } + rows[topic] = obj +with pathlib.Path(OUT).open("w", encoding="utf-8") as f: + f.writelines( + json.dumps({"topic": t, "payload": rows[t]}, sort_keys=True) + "\n" + for t in sorted(rows) + ) +print(f"{len(rows)} topics for {NAME}") diff --git a/.agents/skills/hardware-test/scripts/entity_icons/readback.sh b/.agents/skills/hardware-test/scripts/entity_icons/readback.sh new file mode 100644 index 00000000000..1f0f536ee6a --- /dev/null +++ b/.agents/skills/hardware-test/scripts/entity_icons/readback.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# readback.sh DEVICE_IP KEY NAME BROKER_HOST [CAPTURE] : record how a device built from templates/entity-icon-dut.yaml +# reports its entity icons and device classes: the native API listing (api-NAME.jsonl), the retained MQTT discovery +# payloads and states (mqtt-NAME.txt, normalised to mqttn-NAME.jsonl with the device name replaced and the debug topic +# dropped), the web_server /events listing plus every REST endpoint with ?detail=all (web-NAME.jsonl) and, when a +# serial CAPTURE is given, the dump_config Icon and Device Class lines (dump-NAME.txt, timestamps and line numbers +# removed). KEY as for api_button.py. Outputs go to the working directory; compare two names with compare.sh. +HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; DEV=$1; KEY=$2; N=$3; B=$4; CAP=$5; rc=0; TAB=$(printf '\t') +[ -n "$DEV" ] && [ -n "$KEY" ] && [ -n "$N" ] && [ -n "$B" ] || { echo "usage: readback.sh DEVICE_IP KEY NAME BROKER_HOST [CAPTURE]"; exit 2; } +timeout 12 mosquitto_sub -h "$B" -v -t 'homeassistant/#' -t "$N/#" -F "%t$TAB%p" > "$PWD/mqtt-$N.txt" 2> "$PWD/mqtt-$N.err" +$PY "$HERE/mqtt_norm.py" "$PWD/mqtt-$N.txt" "$N" "$PWD/mqttn-$N.jsonl" || rc=1 +$PY "$HERE/api_list.py" "$DEV" "$KEY" "$PWD/api-$N.jsonl" || { echo "API listing failed"; rc=1; } +$PY "$HERE/web_dump.py" "$DEV" "$PWD/web-$N.jsonl" || { echo "web_server dump failed"; rc=1; } +if [ -n "$CAP" ]; then + LC_ALL=C grep -a '\]: Icon: \|\]: Device Class: ' "$CAP" | LC_ALL=C sed 's/\x1b\[[0-9;]*m//g; s/^\[[^]]*\]//; s/:[0-9]*\]:/]:/' | sort > "$PWD/dump-$N.txt" + echo "dump_config icon lines $(grep -c 'Icon:' "$PWD/dump-$N.txt"), device class lines $(grep -c 'Device Class:' "$PWD/dump-$N.txt")" +fi +exit $rc diff --git a/.agents/skills/hardware-test/scripts/entity_icons/web_dump.py b/.agents/skills/hardware-test/scripts/entity_icons/web_dump.py new file mode 100644 index 00000000000..a248464d412 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/entity_icons/web_dump.py @@ -0,0 +1,99 @@ +"""Dump the web_server view of every entity: the /events listing (detail all) and each REST endpoint with ?detail=all. + +usage: web_dump.py HOST OUT +""" + +import json +import pathlib +import socket +import sys +import time +import urllib.parse +import urllib.request + +HOST, OUT = sys.argv[1], sys.argv[2] + + +def sse_listing(seconds: float) -> list[dict]: + s = socket.create_connection((HOST, 80), timeout=5) + s.sendall( + b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n" + % HOST.encode() + ) + buf = b"" + deadline = time.monotonic() + seconds + s.settimeout(1.0) + while time.monotonic() < deadline: + try: + data = s.recv(65536) + except TimeoutError: + continue + if not data: + break + buf += data + s.close() + head, _, body = buf.partition(b"\r\n\r\n") + if b"chunked" in head.lower(): + out = b"" + while True: + i = body.find(b"\r\n") + if i < 0: + break + size = int(body[:i].split(b";")[0], 16) + out += body[i + 2 : i + 2 + size] + body = body[i + 2 + size + 2 :] + if size == 0: + break + body = out + rows = [] + for block in body.decode(errors="replace").split("\n\n"): + ev, data = None, None + for line in block.split("\n"): + line = line.rstrip("\r") + if line.startswith("event:"): + ev = line[6:].strip() + elif line.startswith("data:"): + data = line[5:].strip() + if ev in ("state", "state_detail_all") and data: + try: + obj = json.loads(data) + except ValueError: + rows.append({"event": ev, "invalid": data}) + continue + # bare state updates that land inside the window carry no domain; only the listing rows are kept + if "domain" in obj: + rows.append({"event": ev, **obj}) + return rows + + +def get_json(url: str) -> dict: + """One retry: the ESP8266 async server occasionally leaves a request unanswered.""" + for attempt in (1, 2): + try: + with urllib.request.urlopen(url, timeout=5) as resp: + return json.loads(resp.read()) + except TimeoutError: + if attempt == 2: + raise + print(f"retrying {url}", flush=True) + return {} + + +rows = sse_listing(6) +ids = sorted({r["id"] for r in rows if "id" in r}) +rest = [] +for ident in ids: + domain, _, obj = ident.partition("/") + url = f"http://{HOST}/{domain}/{urllib.parse.quote(obj)}?detail=all" + try: + rest.append({"url": url.replace(HOST, "HOST"), **get_json(url)}) + except Exception as e: # noqa: BLE001 + rest.append({"url": url.replace(HOST, "HOST"), "error": str(e)}) + time.sleep(0.3) +with pathlib.Path(OUT).open("w", encoding="utf-8") as f: + f.writelines( + json.dumps(r, sort_keys=True) + "\n" + for r in sorted(rows, key=lambda r: (r.get("id", ""), r["event"])) + ) + f.writelines(json.dumps(r, sort_keys=True) + "\n" for r in rest) +print(f"{len(rows)} sse rows, {len(ids)} ids, {len(rest)} rest rows") diff --git a/.agents/skills/hardware-test/scripts/ld2450/lddrive.py b/.agents/skills/hardware-test/scripts/ld2450/lddrive.py new file mode 100644 index 00000000000..f2274d3510f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/ld2450/lddrive.py @@ -0,0 +1,123 @@ +"""Drive an LD24xx harness over the API: press buttons, set selects and numbers, print state updates, save the log. + +usage: lddrive.py HOST KEY LOGFILE OP [OP ...] (KEY of - means a plaintext API) + OP: list | press:NAME | sel:NAME=OPTION | set:NAME=VALUE | wait:SECONDS +Steps are 2 s apart: the API log stream drops lines while the hub blocks in send_command_. +""" + +import asyncio +from pathlib import Path +import sys +import time + +from aioesphomeapi import ( + APIClient, + BinarySensorState, + ButtonInfo, + LogLevel, + NumberInfo, + NumberState, + SelectInfo, + SelectState, + SensorInfo, + SensorState, + TextSensorInfo, + TextSensorState, +) + +HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3] +OPS = sys.argv[4:] +T0 = time.time() +STEP_GAP_S = 2.0 + + +def stamp() -> str: + return f"{time.time() - T0:7.3f}" + + +async def run(c: APIClient, log) -> None: + info = await c.device_info() + print( + f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}" + ) + ents, _ = await c.list_entities_services() + by_key = {e.key: e for e in ents} + buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)} + selects = {e.name: e for e in ents if isinstance(e, SelectInfo)} + numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)} + + def on_state(s) -> None: + e = by_key.get(s.key) + if e is None: + return + missing = " (missing)" if getattr(s, "missing_state", False) else "" + if isinstance(s, TextSensorState): + print(f"{stamp()} TXT {e.name} = {s.state!r}{missing}", flush=True) + elif isinstance(s, BinarySensorState): + print(f"{stamp()} BIN {e.name} = {s.state}{missing}", flush=True) + elif isinstance(s, NumberState): + print(f"{stamp()} NUM {e.name} = {s.state:.0f}{missing}", flush=True) + elif isinstance(s, SelectState): + print(f"{stamp()} SEL {e.name} = {s.state!r}{missing}", flush=True) + elif ( + isinstance(s, SensorState) + and isinstance(e, SensorInfo) + and "Count" in e.name + ): + print(f"{stamp()} CNT {e.name} = {s.state:.0f}", flush=True) + + def on_log(msg) -> None: + log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n") + + c.subscribe_states(on_state) + c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE) + await asyncio.sleep(1.5) + for op in OPS: + if op == "list": + for n in sorted(buttons): + print("button:", n) + for n in sorted(selects): + print("select:", n, selects[n].options) + for e in ents: + if isinstance(e, TextSensorInfo): + print("text_sensor:", e.name) + continue + kind, _, rest = op.partition(":") + if kind == "wait": + await asyncio.sleep(float(rest)) + continue + if kind == "press": + if rest not in buttons: + sys.exit(f"no button named {rest!r}; have {sorted(buttons)}") + print(f"{stamp()} >>> press {rest}", flush=True) + log.write(f"{stamp()} >>> press {rest}\n") + c.button_command(buttons[rest].key) + elif kind == "set": + name, _, value = rest.partition("=") + if name not in numbers: + sys.exit(f"no number named {name!r}; have {sorted(numbers)}") + print(f"{stamp()} >>> set {name} = {value}", flush=True) + log.write(f"{stamp()} >>> set {name} = {value}\n") + c.number_command(numbers[name].key, float(value)) + elif kind == "sel": + name, _, value = rest.partition("=") + if name not in selects: + sys.exit(f"no select named {name!r}; have {sorted(selects)}") + print(f"{stamp()} >>> select {name} = {value}", flush=True) + log.write(f"{stamp()} >>> select {name} = {value}\n") + c.select_command(selects[name].key, value) + else: + sys.exit(f"unknown op {op!r}") + await asyncio.sleep(STEP_GAP_S) + await asyncio.sleep(1) + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY) + await c.connect(login=True) + with Path(LOGFILE).open("w", encoding="utf-8") as log: + await run(c, log) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/ld2450/txsum.py b/.agents/skills/hardware-test/scripts/ld2450/txsum.py new file mode 100644 index 00000000000..d70723390d1 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/ld2450/txsum.py @@ -0,0 +1,33 @@ +"""Summarize an lddrive.py log: for each step, the LD2450 TX frames, commands and publishes. + +usage: txsum.py LOGFILE (diff the output of two builds) +""" + +from pathlib import Path +import re +import sys + +ANSI = re.compile(r"\x1b\[[0-9;]*m") +out: list[str] = [] +with Path(sys.argv[1]).open(encoding="utf-8", errors="replace") as log: + for raw in log: + line = ANSI.sub("", raw.rstrip("\n")) + m = re.match(r"\s*[\d.]+ (.*)", line) + if not m: + continue + text = m.group(1) + if text.startswith(">>>"): + out.append(f"\n# {text[4:]}") + elif "[uart_debug" in text: + out.append(f" TX {text.split('>>> ')[1]}") + elif "[ld2450" in text and ( + "Sending COMMAND" in text + or "zone type" in text.lower() + or "Query zone" in text + ): + out.append(f" LOG {text.split(']: ', 1)[1]}") + elif "[select:" in text and ">>" in text: + out.append(f" SEL {text.split(']: ', 1)[1]}") + elif "[text_sensor:098]" in text: + out.append(f" TXT {text.split(']: ', 1)[1]}") +print("\n".join(out)) diff --git a/.agents/skills/hardware-test/scripts/mqtt/cases.sh b/.agents/skills/hardware-test/scripts/mqtt/cases.sh index a455dade6e4..a919667cd85 100755 --- a/.agents/skills/hardware-test/scripts/mqtt/cases.sh +++ b/.agents/skills/hardware-test/scripts/mqtt/cases.sh @@ -24,8 +24,10 @@ if [ "$RESTART" = restart ]; then pid=$(cat "$WORK/mosquitto.pid" 2>/dev/null); [ -n "$pid" ] || { echo "no mosquitto.pid in $WORK, cannot restart the broker"; exit 1; } kill "$pid" || { echo "broker PID $pid is not running, nothing to restart"; exit 1; }; sleep 8 nc -z -w 3 "$B" 1883 && { echo "the old broker still answers on 1883 after the kill"; exit 1; } - (cd "$WORK" && mosquitto -c mosquitto-ws.conf > mosquitto.out 2>&1 & echo $! > "$WORK/mosquitto.pid"); sleep 25 - for port in 1883 9001 9002; do nc -z -w 3 "$B" "$port" || { echo "broker port $port not listening after the restart"; exit 1; }; done + # exec so the PID is the broker's own and no shell stays behind holding the caller's output pipe open + (cd "$WORK" && exec mosquitto -c mosquitto-ws.conf > mosquitto.out 2>&1 < /dev/null) & + echo $! > "$WORK/mosquitto.pid"; sleep 25 + for port in $(awk '/^listener/ {print $2}' "$WORK/mosquitto-ws.conf"); do nc -z -w 3 "$B" "$port" || { echo "broker port $port not listening after the restart"; exit 1; }; done tail -n +$((n1+1)) "$LOG" | grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected" || { echo "the device did not reconnect after the broker restart"; exit 1; } else echo "=== $N: no broker restart (pass 'restart' as the 7th argument for a local broker)" diff --git a/.agents/skills/hardware-test/scripts/mqtt/cli_tls_check.sh b/.agents/skills/hardware-test/scripts/mqtt/cli_tls_check.sh new file mode 100755 index 00000000000..5aaff63301f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/mqtt/cli_tls_check.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# cli_tls_check.sh TREE [neg] YAML... : run `esphome clean-mqtt` from the checkout TREE for each config, which goes +# through the same TLS setup in esphome/mqtt.py as `esphome logs` over MQTT, without needing a board. clean-mqtt never +# exits on its own, so it runs under a 25 s timeout: status 124 after "Connected to MQTT broker!" means the CLI +# connected. With neg every config must instead fail with CERTIFICATE_VERIFY_FAILED and never connect, so a negative +# that fails for another reason (bad YAML, broker down) is reported as a failure too. A relative YAML path is taken +# from the working directory, where the cleanmqtt-NAME.log outputs also go. Exit 1 when any config missed its result. +TREE=$1; shift; PY=${PYTHON:-python3}; NEG=0 +[ "$1" = neg ] && { NEG=1; shift; } +[ -d "$TREE/esphome" ] && [ -n "$1" ] || { echo "usage: cli_tls_check.sh TREE [neg] YAML..."; exit 2; } +bad=0 +for y in "$@"; do + case $y in /*) ;; *) y=$PWD/$y;; esac + n=$(basename "$y" .yaml); log=$PWD/cleanmqtt-$n.log + (cd "$TREE" && PYTHONUNBUFFERED=1 timeout 25 $PY -m esphome clean-mqtt "$y" > "$log" 2>&1); rc=$? + if [ "$NEG" = 0 ] && [ "$rc" = 124 ] && grep -q "Connected to MQTT broker" "$log"; then + echo "$n: connected" + elif [ "$NEG" = 1 ] && grep -q "CERTIFICATE_VERIFY_FAILED" "$log" && ! grep -q "Connected to MQTT broker" "$log"; then + echo "$n: rejected (CERTIFICATE_VERIFY_FAILED)" + else + echo "$n: FAILED (status $rc): $(grep -a -m1 -i 'cannot connect\|error' "$log" | cut -c1-160)"; bad=$((bad+1)) + fi +done +[ "$bad" = 0 ] diff --git a/.agents/skills/hardware-test/scripts/mqtt/flash_and_drive.sh b/.agents/skills/hardware-test/scripts/mqtt/flash_and_drive.sh new file mode 100755 index 00000000000..840492cc3d5 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/mqtt/flash_and_drive.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST MODE [SUB_PORT] : flash YAML from the checkout TREE over the serial +# PORT, capture the serial log (cap-NAME.txt in the working directory), wait for the first MQTT connect or TLS error, +# then drive the device with cases.sh (KEY as for cases.sh, - for a plaintext API). MODE local runs cases.sh with a +# broker restart (the working directory must hold mosquitto-ws.conf and mosquitto.pid), public runs it without a +# restart, neg only watches for 100 s (a build that must never connect). SUB_PORT is the plain listener cases.sh +# subscribes on (default 1883). Ends with connect, disconnect, handshake error and crash counts, the config dump's +# TLS line, the probe heap lines and the broker listener each connection used. Exit 1 when the capture did not start, +# the device reached no MQTT event within 90 s (local and public), it crashed, or the mode's own check failed. +TREE=$1; YAML=$2; PORT=$3; KEY=$4; B=$5; MODE=$6; SP=${7:-1883} +case $YAML in /*) ;; *) YAML=$PWD/$YAML;; esac +HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; N=$(basename "$YAML" .yaml); CAP=$PWD/cap-$N.txt; ULOG=$PWD/upload-$N.log +[ -d "$TREE/esphome" ] && [ -e "$PORT" ] && [ -n "$KEY" ] && [ -n "$B" ] || { echo "usage: flash_and_drive.sh TREE YAML PORT KEY BROKER_HOST local|public|neg [SUB_PORT]"; exit 2; } +case $MODE in local|public|neg) ;; *) echo "MODE must be local, public or neg"; exit 2;; esac +pkill -f "[s]erial_capture.py $PORT "; sleep 1 +echo "=== $N: flash ($(date +%T))" +(cd "$TREE" && PYTHONUNBUFFERED=1 timeout 300 $PY -m esphome upload "$YAML" --device "$PORT" > "$ULOG" 2>&1) || { echo "upload failed"; tail -5 "$ULOG"; exit 1; } +SECS=200; [ "$MODE" = neg ] && SECS=100 +# a stale capture from an earlier run must not satisfy the wait below +rm -f "$CAP" +$PY "$HERE/../serial_capture.py" "$PORT" $SECS "$CAP" > "$PWD/cap-$N.stderr" 2>&1 & +cap_pid=$! +i=0; until grep -a -q "MQTT_EVENT_CONNECTED\|\[mqtt:[0-9]*\]: Connected\|mbedtls_ssl_handshake returned\|Failed to verify" "$CAP" 2>/dev/null || [ $i -ge 45 ]; do sleep 2; i=$((i+1)); done +kill -0 $cap_pid 2>/dev/null && [ -f "$CAP" ] || { echo "serial capture is not running on $PORT"; tail -3 "$PWD/cap-$N.stderr"; exit 1; } +DEV=$(grep -a -o 'IP Address: [0-9.]*' "$CAP" | head -1 | awk '{print $3}') +if [ $i -ge 45 ] || [ -z "$DEV" ]; then + echo "=== $N: no MQTT event or no IP within 90 s ($(date +%T))" + [ "$MODE" = neg ] || { pkill -f "[s]erial_capture.py $PORT "; exit 1; } +else + echo "=== $N: first MQTT event after $((i*2)) s, device $DEV ($(date +%T))" +fi +grep -a 'ESPHome version\|\]: Transport\|\]: TLS:' "$CAP" | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-120 +rc=0 +case $MODE in + local) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV" restart; rc=$?;; + public) PYTHON=$PY sh "$HERE/cases.sh" "$N" "$KEY" "$B" "$SP" "$CAP" "$DEV"; rc=$?;; + neg) sleep 70;; +esac +wait $cap_pid +strip() { sed 's/\x1b\[[0-9;]*m//g'; } +# count the backend's own events so the result does not depend on on_connect/on_disconnect log lines in the config +connects=$(grep -a -c 'MQTT_EVENT_CONNECTED' "$CAP"); errors=$(grep -a -c 'mbedtls_ssl_handshake returned\|Failed to verify certificate' "$CAP") +crashes=$(grep -a -c 'Guru\|abort()\|Backtrace' "$CAP") +echo "=== $N: connects $connects, disconnects $(grep -a -c 'MQTT_EVENT_DISCONNECTED' "$CAP"), handshake errors $errors, crashes $crashes" +grep -a 'mbedtls_ssl_handshake returned\|Failed to verify\|esp-tls' "$CAP" | strip | sed 's/^\[[^]]*\]//' | sort | uniq -c | sort -rn | head -4 | cut -c1-150 +grep -a '\[probe:' "$CAP" | strip | sed 's/.*\[probe:[0-9]*\]: //' | sed -n '1p;$p' +[ -f "$PWD/mosquitto.log" ] && [ -n "$DEV" ] && echo "broker listeners used by $DEV: $(grep "New connection from $DEV" "$PWD/mosquitto.log" | sed 's/.*on port //' | sort | uniq -c | tr '\n' ';')" +[ "$crashes" = 0 ] || { echo "device crashed $crashes time(s)"; exit 1; } +if [ "$MODE" = neg ]; then + [ "$connects" = 0 ] && [ "$errors" -gt 0 ] || { echo "negative case: expected no connect and a handshake error"; exit 1; } +else + [ "$rc" = 0 ] || { echo "cases.sh failed with status $rc"; exit 1; } +fi diff --git a/.agents/skills/hardware-test/scripts/mqtt/make_test_ca.sh b/.agents/skills/hardware-test/scripts/mqtt/make_test_ca.sh index 99e34d5b322..f918cda967a 100755 --- a/.agents/skills/hardware-test/scripts/mqtt/make_test_ca.sh +++ b/.agents/skills/hardware-test/scripts/mqtt/make_test_ca.sh @@ -1,13 +1,21 @@ #!/bin/sh -# make_test_ca.sh BROKER_IP : a throwaway CA and a server certificate for the broker address (30 days), written into -# the working directory; run it in the scratch directory where a copy of mosquitto-ws.conf lives. The device -# config takes ca.crt as certificate_authority (skip_cert_cn_check: true, the name check does not match an address); -# other-ca.crt is a second CA for the negative case. +# make_test_ca.sh BROKER_IP : a throwaway CA, a server certificate for the broker address and a client certificate +# (30 days), written into the working directory; run it in the scratch directory where a copy of mosquitto-ws.conf +# lives. The device config takes ca.crt as certificate_authority (skip_cert_cn_check: true, the name check does not +# match an address) and client.crt/client.key as client_certificate/client_certificate_key for the listener that +# requires one; other-ca.crt is a second CA for the negative case. The CA carries basicConstraints and keyUsage +# because Python's default SSL context (esphome logs, esphome clean-mqtt) rejects a CA without them. set -e IP=$1 -openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 30 -subj "/CN=hwtest-ca" +[ -n "$IP" ] || { echo "usage: make_test_ca.sh BROKER_IP"; exit 2; } +openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 30 -subj "/CN=hwtest-ca" \ + -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=hwtest-broker" printf "subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n" "$IP" > san.ext openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 30 -extfile san.ext -openssl req -x509 -newkey rsa:2048 -nodes -keyout other-ca.key -out other-ca.crt -days 30 -subj "/CN=other-ca" -openssl verify -CAfile ca.crt server.crt +openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=hwtest-client" +printf "extendedKeyUsage=clientAuth\n" > client.ext +openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 30 -extfile client.ext +openssl req -x509 -newkey rsa:2048 -nodes -keyout other-ca.key -out other-ca.crt -days 30 -subj "/CN=other-ca" \ + -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" +openssl verify -CAfile ca.crt server.crt client.crt diff --git a/.agents/skills/hardware-test/scripts/mqtt/mosquitto-ws.conf b/.agents/skills/hardware-test/scripts/mqtt/mosquitto-ws.conf index d0c9f32e2ce..165ea1d41b2 100644 --- a/.agents/skills/hardware-test/scripts/mqtt/mosquitto-ws.conf +++ b/.agents/skills/hardware-test/scripts/mqtt/mosquitto-ws.conf @@ -1,8 +1,20 @@ # Copy next to the certificates in the scratch directory and start it there with -# `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` (cases.sh restarts that PID): plain MQTT, MQTT over WebSocket, and MQTT over WebSocket with TLS (server.crt and -# server.key from make_test_ca.sh in the same directory). log_type all gives the listener port per connection. +# `mosquitto -c mosquitto-ws.conf & echo $! > mosquitto.pid` (cases.sh restarts that PID): plain MQTT, MQTT over TLS, +# MQTT over TLS that requires a client certificate, MQTT over WebSocket, and MQTT over WebSocket with TLS +# (server.crt, server.key, ca.crt from make_test_ca.sh in the same directory). log_type all gives the listener port +# per connection. listener 1883 protocol mqtt +listener 8883 +protocol mqtt +certfile server.crt +keyfile server.key +listener 8884 +protocol mqtt +certfile server.crt +keyfile server.key +cafile ca.crt +require_certificate true listener 9001 protocol websockets listener 9002 diff --git a/.agents/skills/hardware-test/scripts/udp/check.py b/.agents/skills/hardware-test/scripts/udp/check.py new file mode 100644 index 00000000000..52087447d44 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/udp/check.py @@ -0,0 +1,40 @@ +"""Compare the listener log (JSON lines from udp_listen.py) against the udp-write-dut.yaml payload sequence. + +usage: check.py LISTEN_JSONL ECHO_HEX (ECHO_HEX is the datagram sent to the device for the echo case) +""" + +import json +import pathlib +import sys + +with pathlib.Path(sys.argv[1]).open(encoding="utf-8") as f: + log = [json.loads(line) for line in f if line.strip()] +echo_in = bytes.fromhex(sys.argv[2]) +small = bytes([0xAA, 0x55, 0x01, 0x02]) +expected = [ + ("small 1/2", small), + ("small 2/2", small), + ("simple", bytes([1, 2, 3])), + ("text", b"hello udp"), + ("empty", b""), + ("stack128", bytes((0x10 + i) & 0xFF for i in range(128))), + ("heap129", bytes((0x20 + i) & 0xFF for i in range(129))), + ("heap600", bytes((0x30 + i) & 0xFF for i in range(600))), + ("lambda300", bytes((i * 7) & 0xFF for i in range(300))), + ("echo", echo_in[::-1]), +] +got = [bytes.fromhex(e["hex"]) for e in log] +ok = 0 +for i, (name, want) in enumerate(expected): + have = got[i] if i < len(got) else None + res = ( + "OK" + if have == want + else f"MISMATCH got {None if have is None else have[:16].hex() + '... len ' + str(len(have))}" + ) + ok += res == "OK" + print(f"{name:10s} {len(want):4d} B {res}") +print( + f"{ok}/{len(expected)} payloads byte-exact, {len(got)} datagrams received, sources {sorted({e['src'] for e in log})}" +) +sys.exit(0 if ok == len(expected) and len(got) == len(expected) else 1) diff --git a/.agents/skills/hardware-test/scripts/udp/udp_listen.py b/.agents/skills/hardware-test/scripts/udp/udp_listen.py new file mode 100644 index 00000000000..fc8e0c84b86 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/udp/udp_listen.py @@ -0,0 +1,32 @@ +"""Log every datagram on PORT for SECONDS as one JSON line: time, source, length, hex payload.""" + +import json +import pathlib +import socket +import sys +import time + +port, secs, out = int(sys.argv[1]), float(sys.argv[2]), sys.argv[3] +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("", port)) +s.settimeout(1.0) +end = time.monotonic() + secs +with pathlib.Path(out).open("w", encoding="utf-8") as f: + while time.monotonic() < end: + try: + data, (ip, sport) = s.recvfrom(65535) + except TimeoutError: + continue + f.write( + json.dumps( + { + "t": time.strftime("%H:%M:%S"), + "src": ip, + "len": len(data), + "hex": data.hex(), + } + ) + + "\n" + ) + f.flush() diff --git a/.agents/skills/hardware-test/scripts/udp/write_cases.sh b/.agents/skills/hardware-test/scripts/udp/write_cases.sh new file mode 100755 index 00000000000..96e1c52cf6a --- /dev/null +++ b/.agents/skills/hardware-test/scripts/udp/write_cases.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# write_cases.sh DEVICE_IP KEY [PORT] : drive a device built from templates/udp-write-dut.yaml. Records every datagram +# the device sends to PORT (the udp component's port, default 18511) with udp_listen.py in the working directory, +# presses the eight payload buttons over the API two seconds apart (KEY as for api_button.py, - for a plaintext API), +# sends one 16 byte unicast datagram to the device for the echo case, then compares the recorded datagrams with +# check.py. The recorder runs until the driver stops it. Exit 1 when the recorder did not start or a payload is +# missing or differs; listen.jsonl stays in the working directory. +HERE=$(cd "$(dirname "$0")" && pwd); PY=${PYTHON:-python3}; DEV=$1; KEY=$2; PORT=${3:-18511} +[ -n "$DEV" ] && [ -n "$KEY" ] || { echo "usage: write_cases.sh DEVICE_IP KEY [PORT]"; exit 2; } +ECHO=0102030405060708090a0b0c0d0e0f10 +rm -f "$PWD/listen.jsonl" +$PY "$HERE/udp_listen.py" "$PORT" 600 "$PWD/listen.jsonl" & +lst=$! +sleep 2 +kill -0 $lst 2>/dev/null || { echo "udp_listen.py is not running on port $PORT"; exit 1; } +for b in Small Simple Text Empty Stack128 Heap129 Heap600 Lambda300; do + $PY "$HERE/../api_button.py" "$DEV" "$KEY" press "$b" > /dev/null || echo "press $b failed"; sleep 2 +done +$PY -c "import socket; socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(bytes.fromhex('$ECHO'), ('$DEV', $PORT))" +sleep 5 +kill $lst 2>/dev/null; wait $lst 2>/dev/null +$PY "$HERE/check.py" "$PWD/listen.jsonl" "$ECHO" diff --git a/.agents/skills/hardware-test/templates/api-actions-dut.yaml b/.agents/skills/hardware-test/templates/api-actions-dut.yaml new file mode 100644 index 00000000000..304f7cff953 --- /dev/null +++ b/.agents/skills/hardware-test/templates/api-actions-dut.yaml @@ -0,0 +1,65 @@ +esphome: + name: ${name} + +esp8266: + board: d1_mini + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +logger: + level: DEBUG + +api: + reboot_timeout: 0s + actions: + - action: play_buzzer + description: Play a song or build one from parts + variables: + song_str: + type: string + description: Full RTTTL string + example: "scale:d=4,o=5,b=100:c,d,e" + required: false + notes: + type: string + required: false + octave: + type: int + default: 5 + bpm: + type: int + default: 100 + tempo: + type: float + default: 1.5 + loud: + type: bool + default: true + label: + type: string + default: "" + then: + - lambda: |- + char s[40], n[40], l[40]; + snprintf(s, sizeof(s), "%.*s", (int) song_str.size(), song_str.c_str()); + snprintf(n, sizeof(n), "%.*s", (int) notes.size(), notes.c_str()); + snprintf(l, sizeof(l), "%.*s", (int) label.size(), label.c_str()); + ESP_LOGD("main", "play_buzzer song=%s(%d) notes=%s(%d) octave=%d bpm=%d tempo=%.1f loud=%d label=%s(%d)", s, + (int) song_str.size(), n, (int) notes.size(), octave, bpm, tempo, loud, l, (int) label.size()); + - action: plain_action + variables: + value: int + then: + - logger.log: + format: "plain_action value=%d" + args: ["value"] + - action: list_action + variables: + values: int[] + then: + - logger.log: + format: "list_action n=%d" + args: ["values.size()"] diff --git a/.agents/skills/hardware-test/templates/api-actions-strings-dut.yaml b/.agents/skills/hardware-test/templates/api-actions-strings-dut.yaml new file mode 100644 index 00000000000..1f58f29c314 --- /dev/null +++ b/.agents/skills/hardware-test/templates/api-actions-strings-dut.yaml @@ -0,0 +1,28 @@ +esphome: + name: ${name} + +esp8266: + board: d1_mini + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +logger: + level: DEBUG + +api: + reboot_timeout: 0s + actions: + - action: str_action + variables: + song_str: string + notes: string + octave: int + then: + - lambda: |- + char s[40], n[40]; + snprintf(s, sizeof(s), "%.*s", (int) song_str.size(), song_str.c_str()); + snprintf(n, sizeof(n), "%.*s", (int) notes.size(), notes.c_str()); + ESP_LOGD("main", "str_action song=%s(%d) notes=%s(%d) octave=%d", s, (int) song_str.size(), n, (int) notes.size(), octave); diff --git a/.agents/skills/hardware-test/templates/entity-icon-dut.yaml b/.agents/skills/hardware-test/templates/entity-icon-dut.yaml new file mode 100644 index 00000000000..0a068c22dde --- /dev/null +++ b/.agents/skills/hardware-test/templates/entity-icon-dut.yaml @@ -0,0 +1,148 @@ +# Entity icon and device class readback (esphome#21111): the per-run file supplies name and broker_ip through substitutions and includes this file as a package. Every readback path is covered: API listing, MQTT discovery, web_server JSON and dump_config. See reference/recipes/web_server.md. +esp8266: + board: d1_mini + +logger: + level: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + +ota: + - platform: esphome + +web_server: + port: 80 + +mqtt: + broker: ${broker_ip} + reboot_timeout: 0s + +interval: + - interval: 10s + then: + - lambda: |- + ESP_LOGI("probe", "free heap %u, max block %u", (unsigned) ESP.getFreeHeap(), (unsigned) ESP.getMaxFreeBlockSize()); + +esphome: + name: ${name} + +sensor: + - platform: template + name: Temp + id: temp + device_class: temperature + unit_of_measurement: "°C" + icon: mdi:thermometer + accuracy_decimals: 1 + lambda: return 21.5; + update_interval: 30s + - platform: template + name: Long Icon + id: long_icon + icon: mdi:this-icon-name-is-exactly-sixty-three-characters-long-abcde + device_class: humidity + unit_of_measurement: "%" + lambda: return 40.0; + update_interval: 30s + - platform: template + name: Plain + id: plain + lambda: return 1.0; + update_interval: 30s + - platform: template + name: Hidden + id: hidden + internal: true + icon: mdi:eye-off + device_class: power + lambda: return 2.0; + update_interval: 30s + +binary_sensor: + - platform: template + name: Motion + id: motion + device_class: motion + icon: mdi:motion-sensor + lambda: return false; + +switch: + - platform: template + name: Relay + id: relay + device_class: outlet + icon: mdi:power-plug + optimistic: true + +button: + - platform: template + name: Reboot + id: reboot_btn + device_class: restart + icon: mdi:restart + on_press: + - logger.log: "Reboot pressed" + +text_sensor: + - platform: template + name: Version + id: version + device_class: timestamp + icon: mdi:tag + lambda: return {"2026-10-21T00:00:00+00:00"}; + update_interval: 30s + +number: + - platform: template + name: Level + id: level + device_class: battery + icon: mdi:knob + unit_of_measurement: "%" + min_value: 0 + max_value: 100 + step: 1 + optimistic: true + initial_value: 50 + +select: + - platform: template + name: Mode + id: mode + icon: mdi:format-list-bulleted + options: [a, b, c] + optimistic: true + initial_option: a + +event: + - platform: template + name: Doorbell + id: doorbell + device_class: doorbell + icon: mdi:doorbell + event_types: [ring, knock] + +cover: + - platform: template + name: Garage + id: garage + device_class: garage + icon: mdi:garage + optimistic: true + +text: + - platform: template + name: Secret Note + id: note + mode: password + icon: mdi:form-textbox-password + optimistic: true + initial_value: "x" diff --git a/.agents/skills/hardware-test/templates/ld2412-select-only-dut.yaml b/.agents/skills/hardware-test/templates/ld2412-select-only-dut.yaml new file mode 100644 index 00000000000..63456162b81 --- /dev/null +++ b/.agents/skills/hardware-test/templates/ld2412-select-only-dut.yaml @@ -0,0 +1,57 @@ +substitutions: + name: ld2412sel + +esphome: + name: ${name} + +esp32: + variant: esp32s3 + board: esp32-s3-devkitc-1 + flash_size: 8MB + framework: + type: esp-idf + +logger: + level: VERBOSE + +api: + reboot_timeout: 0s + +ota: + - platform: esphome + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +uart: + - id: uart_ld2412 + tx_pin: GPIO43 + rx_pin: GPIO44 + baud_rate: 115200 + debug: + direction: BOTH + dummy_receiver: false + after: + timeout: 10ms + bytes: 120 + +ld2412: + id: ld2412_radar + uart_id: uart_ld2412 + +select: + - platform: ld2412 + ld2412_id: ld2412_radar + out_pin_level: + name: "LD2412 Hardware output pin level" + +binary_sensor: + - platform: gpio + name: "LD2412 Out Pin" + pin: GPIO38 + - platform: ld2412 + ld2412_id: ld2412_radar + has_target: + name: "LD2412 Presence" diff --git a/.agents/skills/hardware-test/templates/ld2450-loopback.yaml b/.agents/skills/hardware-test/templates/ld2450-loopback.yaml new file mode 100644 index 00000000000..a9c390c43c3 --- /dev/null +++ b/.agents/skills/hardware-test/templates/ld2450-loopback.yaml @@ -0,0 +1,258 @@ +esphome: + name: ${name} + on_boot: + priority: -100 + then: + - lambda: |- + // UART0 TX looped back into its own RX so injected frames reach the hub + USC0(0) |= (1 << UCLBE); + ESP_LOGI("harness", "UART0 loopback on"); + +esp8266: + board: d1_mini + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + reboot_timeout: 0s + +logger: + baud_rate: 0 + level: VERBOSE + +uart: + id: radar_uart + tx_pin: GPIO1 + rx_pin: GPIO3 + baud_rate: ${baud} + debug: + direction: TX + dummy_receiver: false + after: + delimiter: [0x04, 0x03, 0x02, 0x01] + +ld2450: + id: radar + uart_id: radar_uart + +select: + - platform: ld2450 + baud_rate: + name: Baud Rate + zone_type: + name: Zone Type + +text_sensor: + - platform: ld2450 + version: + name: Radar Version + target_1: + direction: + name: T1 Direction + target_2: + direction: + name: T2 Direction + target_3: + direction: + name: T3 Direction + +sensor: + - platform: ld2450 + target_count: + name: Target Count + +button: + # periodic frames: header, 3 x (x, y, speed, resolution), footer + - platform: template + name: Frame Approach + on_press: + - uart.write: + id: radar_uart + data: [0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC] + - platform: template + name: Frame Away + on_press: + - uart.write: + id: radar_uart + data: [0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0xE8, 0x83, 0x14, 0x80, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC] + - platform: template + name: Frame Stationary + on_press: + - uart.write: + id: radar_uart + data: [0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0xE8, 0x83, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC] + - platform: template + name: Frame None + on_press: + - uart.write: + id: radar_uart + data: [0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC] + - platform: template + name: Frame T2 T3 + on_press: + - uart.write: + id: radar_uart + data: [0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xF4, 0x81, 0x20, 0x83, 0x1E, 0x00, 0x64, 0x00, + 0x2C, 0x01, 0xB0, 0x84, 0x0A, 0x80, 0x64, 0x00, + 0x55, 0xCC] + # query zone acks: header, length, command C1, status 01, 00 00, zone type, 00, 24 coordinate bytes, footer + - platform: template + name: Zone Ack Disabled + on_press: + - uart.write: + id: radar_uart + data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01] + - platform: template + name: Zone Ack Detection + on_press: + - uart.write: + id: radar_uart + data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01] + - platform: template + name: Zone Ack Filter + on_press: + - uart.write: + id: radar_uart + data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01] + # name based setters, the path lambdas use + - platform: template + name: Set Zone Filter By Name + on_press: + - lambda: id(radar).set_zone_type("Filter"); + - platform: template + name: Set Zone Bogus By Name + on_press: + - lambda: id(radar).set_zone_type("Bogus"); + - platform: template + name: Set Baud 57600 By Name + on_press: + - lambda: id(radar).set_baud_rate("57600"); + - platform: template + name: Set Baud Bogus By Name + on_press: + - lambda: id(radar).set_baud_rate("Bogus"); + # target 1 at x 772, y 258: bytes 04 03 02 01 are the command footer + - platform: template + name: Frame CmdFooter In Payload + on_press: + - uart.write: + id: radar_uart + data: [0xAA, 0xFF, 0x03, 0x00, + 0x04, 0x03, 0x02, 0x01, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC] + # zone ack, type Detection, zone 1 x1 = 0xCC55: bytes 55 CC are the periodic footer + - platform: template + name: Zone Ack DataFooter In Payload + on_press: + - uart.write: + id: radar_uart + data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x01, 0x00, + 0x55, 0xCC, 0xC8, 0x00, 0xDC, 0x05, 0xB8, 0x0B, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01] + # zone ack, type Filter, zone 1 x1 = 0x0304 and y1 = 0x0102: bytes 04 03 02 01 are the command footer + - platform: template + name: Zone Ack CmdFooter In Payload + on_press: + - uart.write: + id: radar_uart + data: [0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x02, 0x00, + 0x04, 0x03, 0x02, 0x01, 0xDC, 0x05, 0xB8, 0x0B, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01] + # a periodic frame cut after 12 bytes, then a complete zone ack (type Disabled, zeros) + - platform: template + name: Cut Frame Then Zone Ack + on_press: + - uart.write: + id: radar_uart + data: [0xAA, 0xFF, 0x03, 0x00, 0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00, + 0xFD, 0xFC, 0xFB, 0xFA, 0x1E, 0x00, 0xC1, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01] + # a periodic frame missing its last byte, then a complete one (target 1 moving away) + - platform: template + name: Cut Frame Then Frame + on_press: + - uart.write: + id: radar_uart + data: [0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0xE8, 0x83, 0x14, 0x80, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC] + # a command header claiming a 255 byte payload, then a complete periodic frame (target 1 stationary) + - platform: template + name: Bogus Length Then Frame + on_press: + - uart.write: + id: radar_uart + data: [0xFD, 0xFC, 0xFB, 0xFA, 0xFF, 0x00, + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0xE8, 0x83, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC] + # five garbage bytes, then a complete periodic frame (target 1 approaching) + - platform: template + name: Garbage Then Frame + on_press: + - uart.write: + id: radar_uart + data: [0x01, 0x02, 0x03, 0x42, 0x99, + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0xE8, 0x83, 0x14, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC] + +number: + - platform: ld2450 + presence_timeout: + name: Presence Timeout + zone_1: + x1: + name: Z1 X1 + y1: + name: Z1 Y1 + x2: + name: Z1 X2 + y2: + name: Z1 Y2 diff --git a/.agents/skills/hardware-test/templates/mqtt-tls-dut.yaml b/.agents/skills/hardware-test/templates/mqtt-tls-dut.yaml new file mode 100644 index 00000000000..863704202ae --- /dev/null +++ b/.agents/skills/hardware-test/templates/mqtt-tls-dut.yaml @@ -0,0 +1,99 @@ +# MQTT TLS trust-source test (#14318, needs that PR's tree for tls and verify_ssl): one build per trust source, with +# the name and the mqtt block edited per file (see reference/recipes/mqtt.md). Secrets: broker_ip (the local broker +# from make_test_ca.sh), mqtt_ca, mqtt_client_cert and mqtt_client_key as block scalars from the same run. +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: VERBOSE + logs: + wifi: DEBUG + api: DEBUG + api.connection: DEBUG + scheduler: DEBUG + component: DEBUG + sensor: DEBUG + esp32: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + +ota: + - platform: esphome + +interval: + - interval: 10s + then: + - lambda: |- + ESP_LOGI("probe", "mqtt connected %d, internal free %u, largest block %u", id(mq).is_connected(), + (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); + +sensor: + - platform: template + name: Counter + update_interval: 2s + accuracy_decimals: 0 + lambda: |- + static uint32_t n = 0; + return n++; + +switch: + - platform: template + name: Relay + id: relay + optimistic: true + on_turn_on: + - logger.log: "Relay ON" + on_turn_off: + - logger.log: "Relay OFF" + +button: + - platform: template + name: Publish big + on_press: + - mqtt.publish: + topic: ${name}/big + payload: !lambda |- + return std::string(3000, 'q'); + +substitutions: + name: mqtt-dut + +esphome: + name: ${name} + +mqtt: + id: mq + broker: !secret broker_ip # test.mosquitto.org for the bundle run + # No port: it must resolve to 1883 without TLS options and to 8883 with them. Spell it out only for the + # client-certificate listener (8884), wss (9002) and the public bundle broker (8886). + # The next two lines are the own-CA build. Remove both for the broker-only build (skip_cert_cn_check alone turns + # TLS on), replace both with verify_ssl: false for the unverified build (the flag is rejected next to it), or with + # tls: true for the bundle builds (public broker, and the local one as the negative). + skip_cert_cn_check: true # with the local CA: the certificate names an address + certificate_authority: !secret mqtt_ca + # client_certificate: !secret mqtt_client_cert # with the CA, port 8884 + # client_certificate_key: !secret mqtt_client_key + # transport: wss # with the CA, port 9002 + reboot_timeout: 0s + discovery: false + on_message: + - topic: ${name}/cmd + then: + - logger.log: + format: "cmd payload: %s" + args: ["x.c_str()"] + on_connect: + - logger.log: "MQTT on_connect" + on_disconnect: + - logger.log: "MQTT on_disconnect" diff --git a/.agents/skills/hardware-test/templates/udp-write-dut.yaml b/.agents/skills/hardware-test/templates/udp-write-dut.yaml new file mode 100644 index 00000000000..9945e79c1ec --- /dev/null +++ b/.agents/skills/hardware-test/templates/udp-write-dut.yaml @@ -0,0 +1,93 @@ +# udp.write payload shapes (#21263): every shape the action takes behind a template button, plus an on_receive that +# replies with the received bytes reversed through a lambda. Per run file: substitutions name and listener_ip (the test +# host that runs scripts/udp/udp_listen.py), then packages: common: !include this file. Drive it with +# scripts/udp/write_cases.sh DEVICE_IP KEY, which presses the buttons, sends the echo datagram and checks every payload. +esp8266: + board: d1_mini + +logger: + level: VERBOSE + logs: + wifi: DEBUG + api: DEBUG + api.connection: DEBUG + scheduler: DEBUG + component: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + +ota: + - platform: esphome + +interval: + - interval: 10s + then: + - lambda: |- + ESP_LOGI("probe", "free heap %u, max block %u", (unsigned) ESP.getFreeHeap(), (unsigned) ESP.getMaxFreeBlockSize()); + +esphome: + name: ${name} + +udp: + id: u + addresses: + - ${listener_ip} + on_receive: + - lambda: ESP_LOGI("echo", "received %u bytes", (unsigned) data.size()); + - udp.write: !lambda |- + std::vector v(data.rbegin(), data.rend()); + return v; + +button: + - platform: template + name: Small + on_press: + - logger.log: "case small" + - udp.write: [0xAA, 0x55, 0x01, 0x02] + - udp.write: [0xAA, 0x55, 0x01, 0x02] + - platform: template + name: Simple + on_press: + - logger.log: "case simple" + - udp.write: [1, 2, 3] + - platform: template + name: Text + on_press: + - logger.log: "case text" + - udp.write: "hello udp" + - platform: template + name: Empty + on_press: + - logger.log: "case empty" + - udp.write: [] + - platform: template + name: Stack128 + on_press: + - logger.log: "case stack128" + - udp.write: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F] + - platform: template + name: Heap129 + on_press: + - logger.log: "case heap129" + - udp.write: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA0] + - platform: template + name: Heap600 + on_press: + - logger.log: "case heap600" + - udp.write: [0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCE, 0xCF, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF, 0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFB, 0xFC, 0xFD, 0xFE, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, 0x5E, 0x5F, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7E, 0x7F, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87] + - platform: template + name: Lambda300 + on_press: + - logger.log: "case lambda300" + - udp.write: !lambda |- + std::vector v(300); + for (size_t i = 0; i < v.size(); i++) v[i] = (uint8_t) (i * 7); + return v; diff --git a/.agents/skills/hardware-test/templates/wisp-client.yaml b/.agents/skills/hardware-test/templates/wisp-client.yaml new file mode 100644 index 00000000000..4082150fee2 --- /dev/null +++ b/.agents/skills/hardware-test/templates/wisp-client.yaml @@ -0,0 +1,57 @@ +substitutions: + name: wispcli + ap_ssid: wisp-bridge + +esphome: + name: ${name} + +esp8266: + board: d1_mini + +logger: + level: DEBUG + +wifi: + ssid: ${ap_ssid} + password: !secret wisp_psk + power_save_mode: none + reboot_timeout: 0s + +http_request: + esp8266_disable_ssl_support: true + timeout: 8s + +time: + - platform: sntp + id: sntp_time + on_time_sync: + - logger.log: + format: "SNTP synced" + level: INFO + +interval: + - interval: 15s + then: + - http_request.get: + url: !secret lan_http + capture_response: true + on_response: + - logger.log: + format: "LAN GET status %d body %s" + args: ["response->status_code", "body.c_str()"] + level: INFO + on_error: + - logger.log: + format: "LAN GET failed" + level: WARN + - http_request.get: + url: http://example.com/ + on_response: + - logger.log: + format: "WAN GET status %d" + args: ["response->status_code"] + level: INFO + on_error: + - logger.log: + format: "WAN GET failed" + level: WARN diff --git a/.agents/skills/hardware-test/templates/wisp-ethernet-dut.yaml b/.agents/skills/hardware-test/templates/wisp-ethernet-dut.yaml new file mode 100644 index 00000000000..ddca368151c --- /dev/null +++ b/.agents/skills/hardware-test/templates/wisp-ethernet-dut.yaml @@ -0,0 +1,40 @@ +substitutions: + name: wispeth + ap_ssid: wisp-bridge + +esphome: + name: ${name} + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +logger: + hardware_uart: USB_SERIAL_JTAG + level: VERBOSE + +api: + encryption: + key: !secret api_key + 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 + +wifi: + ap: + ssid: ${ap_ssid} + password: !secret wisp_psk + ap_mode: WISP diff --git a/.clang-tidy b/.clang-tidy index 6dab84fbd95..000e00a5adc 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -12,6 +12,7 @@ Checks: >- -bugprone-multi-level-implicit-pointer-conversion, -bugprone-narrowing-conversions, -bugprone-tagged-union-member-count, + -bugprone-signed-bitwise, -bugprone-signed-char-misuse, -bugprone-switch-missing-default-case, -cert-dcl50-cpp, @@ -39,6 +40,7 @@ Checks: >- -cppcoreguidelines-avoid-const-or-ref-data-members, -cppcoreguidelines-avoid-do-while, -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-explicit-constructor, -cppcoreguidelines-init-variables, -cppcoreguidelines-macro-to-enum, -cppcoreguidelines-macro-usage, @@ -84,6 +86,7 @@ Checks: >- -llvm-use-ranges, -llvmlibc-*, -misc-const-correctness, + -misc-explicit-constructor, -misc-include-cleaner, -misc-multiple-inheritance, -misc-no-recursion, @@ -113,6 +116,7 @@ Checks: >- -objc-*, -performance-enum-size, -portability-avoid-pragma-once, + -portability-no-assembler, -portability-template-virtual-member-function, -readability-ambiguous-smartptr-reset-call, -readability-avoid-nested-conditional-operator, @@ -129,9 +133,13 @@ Checks: >- -readability-named-parameter, -readability-redundant-casting, -readability-redundant-inline-specifier, + -readability-redundant-lambda-parameter-list, -readability-redundant-member-init, + -readability-redundant-nested-if, -readability-redundant-parentheses, -readability-redundant-typename, + -readability-trailing-comma, + -readability-trivial-switch, -readability-uppercase-literal-suffix, -readability-use-anyofallof, -readability-use-std-min-max, diff --git a/.coveragerc b/.coveragerc index c15e79a31b4..26ede39cb95 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,4 +2,6 @@ omit = esphome/components/* esphome/analyze_memory/* + esphome/espidf/get_idf_tool_paths.py + esphome/espidf/get_idf_version.py tests/integration/* diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index 5e8acb09c96..72c763bd03f 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -21,6 +21,7 @@ module.exports = { 'github-actions', 'by-code-owner', 'has-tests', + 'api.proto', 'needs-tests', 'needs-docs', 'needs-developer-docs', diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 1d76c18be81..09e23adca27 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -272,6 +272,18 @@ async function detectTests(changedFiles) { return labels; } +// Strategy: API protocol change detection +// aioesphomeapi carries copies of both files, so every change here needs a sync there +const API_PROTO_FILES = ['esphome/components/api/api.proto', 'esphome/components/api/api_options.proto']; + +async function detectApiProto(changedFiles) { + const labels = new Set(); + if (changedFiles.some(file => API_PROTO_FILES.includes(file))) { + labels.add('api.proto'); + } + return labels; +} + // Strategy: PR Template Checkbox detection async function detectPRTemplateCheckboxes(context) { const labels = new Set(); @@ -460,6 +472,7 @@ module.exports = { detectGitHubActionsChanges, detectCodeOwner, detectTests, + detectApiProto, detectPRTemplateCheckboxes, detectDeprecatedComponents, detectMaintainerAccess, diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 1d071b38662..f6fb8998e32 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -10,6 +10,7 @@ const { detectGitHubActionsChanges, detectCodeOwner, detectTests, + detectApiProto, detectPRTemplateCheckboxes, detectDeprecatedComponents, detectMaintainerAccess, @@ -114,6 +115,7 @@ module.exports = async ({ github, context }) => { actionsLabels, codeOwnerLabels, testLabels, + apiProtoLabels, checkboxLabels, deprecatedResult, maintainerAccess @@ -128,6 +130,7 @@ module.exports = async ({ github, context }) => { detectGitHubActionsChanges(changedFiles), detectCodeOwner(github, context, changedFiles), detectTests(changedFiles), + detectApiProto(changedFiles), detectPRTemplateCheckboxes(context), detectDeprecatedComponents(github, context, changedFiles), detectMaintainerAccess(context) @@ -156,6 +159,7 @@ module.exports = async ({ github, context }) => { ...actionsLabels, ...codeOwnerLabels, ...testLabels, + ...apiProtoLabels, ...checkboxLabels, ...deprecatedLabels ]); diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index be239e2f1bb..da222f7580f 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { + detectApiProto, detectMergeBranch, detectNewPlatforms, detectNewComponents, @@ -375,6 +376,27 @@ describe('detectRequirements', () => { }); }); +// --------------------------------------------------------------------------- +// detectApiProto +// --------------------------------------------------------------------------- + +describe('detectApiProto', () => { + it('labels a PR that changes api.proto', async () => { + const labels = await detectApiProto(['esphome/components/api/api.proto', 'esphome/components/api/api_pb2.cpp']); + assert.deepEqual(Array.from(labels), ['api.proto']); + }); + + it('labels a PR that changes only api_options.proto', async () => { + const labels = await detectApiProto(['esphome/components/api/api_options.proto']); + assert.deepEqual(Array.from(labels), ['api.proto']); + }); + + it('ignores the generated protocol files and other components', async () => { + const labels = await detectApiProto(['esphome/components/api/api_pb2.cpp', 'esphome/components/uart/api.proto']); + assert.equal(labels.size, 0); + }); +}); + // --------------------------------------------------------------------------- // MANAGED_LABELS // --------------------------------------------------------------------------- @@ -387,6 +409,10 @@ describe('MANAGED_LABELS', () => { it('includes needs-developer-docs so the workflow syncs it', () => { assert.ok(MANAGED_LABELS.includes('needs-developer-docs')); }); + + it('includes api.proto so the workflow removes it when the file drops out of the diff', () => { + assert.ok(MANAGED_LABELS.includes('api.proto')); + }); }); // --------------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d19f85b491..5c317447799 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,7 +286,7 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - name: Run prek - uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1 + uses: j178/prek-action@af4d4fcb6922b8e6f65c0426947125fcc38794ce # v3.0.2 with: prek-version: ${{ steps.prek.outputs.version }} # This job only runs on pull requests, so nothing ever populates diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6cad82e569e..52e855047b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: - id: yamllint exclude: ^(\.clang-format|\.clang-tidy)$ - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v22.1.8 + rev: v23.1.3 hooks: - id: clang-format types_or: [c, c++] diff --git a/AGENTS.md b/AGENTS.md index 29239e8a35f..cc402678b8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,15 +205,16 @@ file does, and it is the authority when they disagree. The most useful starting for a table of structs copy the whole entry out and return the copy, never a pointer into flash. On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with `ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g. - `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array - a `TEMPLATABLE_BYTES` field so a static code stays in flash. + `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`. An action that only forwards a byte payload is + 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`. 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`, `bcd_to_uint8`); pick the helper's - arguments instead of writing a loop. Epson style 7-byte RTC calendars (one-hot weekday) use - `time::decode_bcd_calendar`. + `reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend`, `bcd_to_uint8`, `assign_bits`); pick + the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar` + and `encode_bcd_calendar` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`. * **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with `LineReader`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning. @@ -445,6 +446,8 @@ file does, and it is the authority when they disagree. The most useful starting switches = switch.sub_switches(config, parent=hub) await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after") ``` + Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)` + to still drive a state when the restore mode is disabled. - **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`, `spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`) @@ -604,7 +607,9 @@ file does, and it is the authority when they disagree. The most useful starting ) ``` The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover - the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with + the rest; `cover.control` and `cover.template.publish` are in-tree examples. An action whose input is a + byte payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; a constant payload + stays in flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with `cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values. * **Conditions:** diff --git a/CODEOWNERS b/CODEOWNERS index 523cdb539ca..29c05368407 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -695,10 +695,12 @@ esphome/components/usb_uart/* @clydebarrow esphome/components/valve/* @esphome/core esphome/components/vbus/* @ssieb esphome/components/veml3235/* @kbx81 +esphome/components/veml6040/* @aalaei esphome/components/veml7700/* @latonita esphome/components/version/* @esphome/core esphome/components/vevor_dtx/* @Bofr5555 esphome/components/vl53l1x/* @will-tm +esphome/components/vnc/* @MindFreeze esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher diff --git a/codecov.yml b/codecov.yml index f8afbbde049..ff72cb4dbef 100644 --- a/codecov.yml +++ b/codecov.yml @@ -11,6 +11,9 @@ coverage: ignore: - "esphome/components/**/*" - "esphome/analyze_memory/**/*" + # Run as standalone scripts under the ESP-IDF Python, never imported by pytest + - "esphome/espidf/get_idf_tool_paths.py" + - "esphome/espidf/get_idf_version.py" - "tests/integration/**/*" comment: diff --git a/docker/Dockerfile b/docker/Dockerfile index 3c160153cc9..6c2ab7bae84 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.25.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.26.0 RUN \ platformio settings set enable_telemetry No \ diff --git a/esphome/automation.py b/esphome/automation.py index d1d504f9121..738623d2a12 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -24,6 +24,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda from esphome.cpp_generator import ( + CallExpression, Expression, LambdaExpression, MockObj, @@ -59,10 +60,15 @@ async def templatable_bytes( if cg.is_template(value): fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) cg.add(set_template(fn)) - elif len(value) > 0xFFFF: - raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535") else: - cg.add(set_static(progmem_bytes(table_name, value), len(value))) + cg.add(set_static(*_static_bytes(table_name, value))) + + +def _static_bytes(table_name: str, value: bytes | list[int]) -> tuple[MockObj, int]: + """The shared PROGMEM table for a constant payload and its length.""" + if (size := len(value)) > 0xFFFF: + raise EsphomeError(f"Byte payload is {size} bytes; the maximum is 65535") + return progmem_bytes(table_name, value), size def maybe_simple_id(*validators): @@ -356,6 +362,33 @@ class ApplyField: return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),)) +@dataclass(frozen=True) +class ApplyBytes: + """A byte payload key forwarded as ``target``, whose ``{}`` stands for ``data, len``. + + A constant payload is a shared PROGMEM table passed as is; on ESP8266 it is first copied + out, to the stack up to ``stack`` bytes and to the heap beyond, unless ``progmem_target`` + names a call that reads flash itself. A lambda's ``std::vector`` is passed as + its data and size. The template may name the parent as ``{parent}``. An absent key + emits nothing. + """ + + conf_key: str | tuple[str, ...] + target: str + stack: int = 64 + progmem_target: str | None = None + + def __post_init__(self) -> None: + for target in (self.target, self.progmem_target): + if target is None: + continue + fields = [ + f for _, f, _, _ in string.Formatter().parse(target) if f is not None + ] + if fields.count("") != 1 or any(f not in ("", "parent") for f in fields): + raise ValueError(f"apply bytes target {target!r} needs one {{}}") + + def _config_lookup(config: ConfigType, key: str | tuple[str, ...]) -> Any: if isinstance(key, str): return config.get(key) @@ -414,9 +447,17 @@ async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: - # The generated function's parameters; a std::string arg is never copied. + # The generated function's parameters: a reference keeps its spelling, so a mutable + # one stays mutable; a value is taken by const reference, so a std::string is never copied. return [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) + ( + ( + t + if str(cg.safe_exp(t)).rstrip().endswith("&") + else cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &") + ), + arg, + ) for t, arg in args ] @@ -488,7 +529,7 @@ def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> li def register_apply_action( name: str, schema: cv.Schema, - *fields: ApplyField | ApplyCall, + *fields: ApplyField | ApplyCall | ApplyBytes, call: str | None = None, id_key: str = CONF_ID, ) -> None: @@ -504,7 +545,7 @@ def register_apply_action( def apply_action_builder( name: str, schema: cv.Schema, - *fields: ApplyField | ApplyCall, + *fields: ApplyField | ApplyCall | ApplyBytes, call: str | None = None, id_key: str = CONF_ID, ) -> Callable[..., Awaitable[MockObj]]: @@ -517,17 +558,20 @@ def apply_action_builder( and ``apply_call.perform()`` is appended. """ # An action stores the value, so a std::string constant stays in flash on ESP8266. - statements_spec = [ - ( - c.target, - _default_std_string(c.members, flash_string), - ) - for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) - ] _check_key_in_schema(name, schema, id_key) - for _, members in statements_spec: + statements_spec: list[ApplyBytes | tuple[str, list[tuple[Any, Any, Any]]]] = [] + for f in fields: + if isinstance(f, ApplyBytes): + _check_key_in_schema(name, schema, f.conf_key) + statements_spec.append(f) + continue + c = f if isinstance(f, ApplyCall) else f.call() + members = _default_std_string(c.members, flash_string) for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) + statements_spec.append((c.target, members)) + # Named like the generated function, so it cannot clash with an SDK symbol + table_name = f"esphome__{name.replace('.', '_')}__bytes" async def builder( config: ConfigType, @@ -539,7 +583,15 @@ def apply_action_builder( lambda_args = _apply_lambda_args(args) receiver = "apply_call." if call else f"{parent}->" statements: list[str] = [] - for target, members in statements_spec: + for spec in statements_spec: + if isinstance(spec, ApplyBytes): + statements.extend( + await _render_bytes( + spec, config, receiver, parent, args, table_name + ) + ) + continue + target, members = spec values = _apply_values(config, members) if not _apply_call_active(members, values): continue @@ -560,6 +612,62 @@ def apply_action_builder( return builder +async def _render_bytes( + spec: ApplyBytes, + config: ConfigType, + receiver: str, + parent: str, + args: TemplateArgsType, + table_name: str, +) -> list[str]: + """The statements for one ``ApplyBytes``: what ``TemplatableBytes::visit`` does, inlined. + + The payload lambda keeps the automation's own argument types, as ``TemplatableBytes`` + gave it, and is always called, so a body that modifies an argument still compiles. + """ + value = _config_lookup(config, spec.conf_key) + if value is None: + return [] + + # The temporary carries the generated-name prefix so a trigger argument cannot shadow it + def send(target: str, data: str) -> str: + return f"{receiver}{target.format(data, parent=parent)};" + + if isinstance(value, Lambda): + inner = await cg.process_lambda( + value, args, return_type=cg.std_vector.template(cg.uint8) + ) + # Never reduced into the outer function, whose own parameters are const + call = CallExpression(inner, *[cg.MockObj(name) for _, name in args]) + return [ + "{", + f" const std::vector esphome__bytes = {call};", + f" {send(spec.target, 'esphome__bytes.data(), esphome__bytes.size()')}", + "}", + ] + table, size = _static_bytes(table_name, value) + # Qualified so a trigger argument cannot shadow a reused table; an empty payload is nullptr + table_args = f"::{table}, {size}" if size else f"{table}, {size}" + if CORE.is_esp8266 and spec.progmem_target is not None: + return [send(spec.progmem_target, table_args)] + if not CORE.is_esp8266: + return [send(spec.target, table_args)] + # A PROGMEM table must be read with aligned loads on ESP8266, so copy it out first; + # the length is known here, so the copy is an exact array, on the heap past ``stack``. + # An empty payload still gets a one byte array: the raw lwIP socket rejects a null buffer. + if size <= spec.stack: + buffer, data = f"uint8_t esphome__bytes[{max(size, 1)}];", "esphome__bytes" + else: + buffer, data = ( + f"auto esphome__bytes = std::make_unique({size});", + "esphome__bytes.get()", + ) + statements = ["{", f" {buffer}"] + if size: + statements.append(f" esphome::progmem_memcpy({data}, {table_args});") + return [*statements, f" {send(spec.target, f'{data}, {size}')}", "}"] + + def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool: """An ``ApplyCall`` is emitted unless it has keys and none of them is set.""" return not members or any(value is not None for value in values) diff --git a/esphome/build_helpers/tool_runner.py b/esphome/build_helpers/tool_runner.py index 52cd6c937a9..b7f5650d79b 100644 --- a/esphome/build_helpers/tool_runner.py +++ b/esphome/build_helpers/tool_runner.py @@ -111,7 +111,9 @@ class ToolOutput(RedirectText): def _emit_line(self, line: str) -> None: if self._progress and _PROGRESS.match(line): - if not self._is_filtered(line): + if self._is_filtered(line): + self._drop_line(line) + else: text = _fit_terminal(line.strip("\r\n")) self._write_color_replace(f"\r{text}\x1b[K") self._on_progress_line = True diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index 36b6534c0de..c927afcd509 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -10,7 +10,7 @@ void AbsoluteHumidityComponent::setup() { this->temperature_ = state; this->enable_loop(); }); - ESP_LOGD(TAG, " Added callback for temperature '%s'", this->temperature_sensor_->get_name().c_str()); + ESP_LOGD(TAG, " Added callback for temperature '%s'", LOG_STR_ARG(this->temperature_sensor_->get_log_name())); // Get initial value if (this->temperature_sensor_->has_state()) { this->temperature_ = this->temperature_sensor_->get_state(); @@ -20,7 +20,7 @@ void AbsoluteHumidityComponent::setup() { this->humidity_ = state; this->enable_loop(); }); - ESP_LOGD(TAG, " Added callback for relative humidity '%s'", this->humidity_sensor_->get_name().c_str()); + ESP_LOGD(TAG, " Added callback for relative humidity '%s'", LOG_STR_ARG(this->humidity_sensor_->get_log_name())); // Get initial value if (this->humidity_sensor_->has_state()) { this->humidity_ = this->humidity_sensor_->get_state(); @@ -49,7 +49,8 @@ void AbsoluteHumidityComponent::dump_config() { "Sources\n" " Temperature: '%s'\n" " Relative Humidity: '%s'", - this->temperature_sensor_->get_name().c_str(), this->humidity_sensor_->get_name().c_str()); + LOG_STR_ARG(this->temperature_sensor_->get_log_name()), + LOG_STR_ARG(this->humidity_sensor_->get_log_name())); } void AbsoluteHumidityComponent::loop() { diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index dcb41382015..86c864ea2a1 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -66,7 +66,7 @@ template class Aggregator; void ADCSensor::update() { float value_v = this->sample(); - ESP_LOGV(TAG, "'%s': Voltage=%.4fV", this->get_name().c_str(), value_v); + ESP_LOGV(TAG, "'%s': Voltage=%.4fV", LOG_STR_ARG(this->get_log_name()), value_v); this->publish_state(value_v); } diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index c2f4aedd497..198019b7d8f 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -111,6 +111,8 @@ static const LogString *input_to_str(uint8_t input) { return LOG_STR("VDD"); case NRF_SAADC_INPUT_VDDHDIV5: return LOG_STR("VDDHDIV5"); + default: + break; } return LOG_STR("undefined input"); } diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.cpp b/esphome/components/ads1115/sensor/ads1115_sensor.cpp index 776346171b7..d2f18142970 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.cpp +++ b/esphome/components/ads1115/sensor/ads1115_sensor.cpp @@ -13,7 +13,7 @@ float ADS1115Sensor::sample() { void ADS1115Sensor::update() { float v = this->sample(); if (!std::isnan(v)) { - ESP_LOGD(TAG, "'%s': Got Voltage=%fV", this->get_name().c_str(), v); + ESP_LOGD(TAG, "'%s': Got Voltage=%fV", LOG_STR_ARG(this->get_log_name()), v); this->publish_state(v); } } diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.cpp b/esphome/components/ads1118/sensor/ads1118_sensor.cpp index 2b3d68d1f40..74d25214155 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.cpp +++ b/esphome/components/ads1118/sensor/ads1118_sensor.cpp @@ -21,7 +21,7 @@ float ADS1118Sensor::sample() { void ADS1118Sensor::update() { float v = this->sample(); if (!std::isnan(v)) { - ESP_LOGD(TAG, "'%s': Got Voltage=%fV", this->get_name().c_str(), v); + ESP_LOGD(TAG, "'%s': Got Voltage=%fV", LOG_STR_ARG(this->get_log_name()), v); this->publish_state(v); } } diff --git a/esphome/components/airton/airton.cpp b/esphome/components/airton/airton.cpp index dffb4bc4961..fd8d04535b6 100644 --- a/esphome/components/airton/airton.cpp +++ b/esphome/components/airton/airton.cpp @@ -261,6 +261,8 @@ void AirtonClimate::parse_state_frame_(const uint8_t *frame) { case AIRTON_FAN_AUTO: this->fan_mode = climate::CLIMATE_FAN_AUTO; break; + default: + break; } this->target_temperature = (frame[3] & 0b00001111) + AIRTON_TEMP_MIN; diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index 6f23b1aca89..730cb80fd17 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -31,7 +31,7 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { this->last_update_ = millis(); if (state != this->current_state_) { auto prev_state = this->current_state_; - ESP_LOGV(TAG, "'%s' >> %s (was %s)", this->get_name().c_str(), + ESP_LOGV(TAG, "'%s' >> %s (was %s)", LOG_STR_ARG(this->get_log_name()), LOG_STR_ARG(alarm_control_panel_state_to_string(state)), LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state))); this->current_state_ = state; diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp index 6fb8c0c7eba..7fac19b50f1 100644 --- a/esphome/components/am43/cover/am43_cover.cpp +++ b/esphome/components/am43/cover/am43_cover.cpp @@ -30,9 +30,9 @@ void Am43Component::loop() { auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - ESP_LOGI(TAG, "[%s] Logging into AM43", this->get_name().c_str()); + ESP_LOGI(TAG, "[%s] Logging into AM43", LOG_STR_ARG(this->get_log_name())); if (status) { - ESP_LOGW(TAG, "[%s] Error writing set_pin to device, error = %d", this->get_name().c_str(), status); + ESP_LOGW(TAG, "[%s] Error writing set_pin to device, error = %d", LOG_STR_ARG(this->get_log_name()), status); } else { this->logged_in_ = true; } @@ -50,7 +50,7 @@ CoverTraits Am43Component::get_traits() { void Am43Component::control(const CoverCall &call) { if (this->node_state != espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%s] Cannot send cover control, not connected", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Cannot send cover control, not connected", LOG_STR_ARG(this->get_log_name())); return; } if (call.get_stop()) { @@ -59,7 +59,7 @@ void Am43Component::control(const CoverCall &call) { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] Error writing stop command to device, error = %d", this->get_name().c_str(), status); + ESP_LOGW(TAG, "[%s] Error writing stop command to device, error = %d", LOG_STR_ARG(this->get_log_name()), status); } } auto opt_pos = call.get_position(); @@ -73,7 +73,8 @@ void Am43Component::control(const CoverCall &call) { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] Error writing set_position command to device, error = %d", this->get_name().c_str(), status); + ESP_LOGW(TAG, "[%s] Error writing set_position command to device, error = %d", LOG_STR_ARG(this->get_log_name()), + status); } } } @@ -89,9 +90,9 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ auto *chr = this->parent_->get_characteristic(AM43_SERVICE_UUID, AM43_CHARACTERISTIC_UUID); if (chr == nullptr) { if (this->parent_->get_characteristic(AM43_TUYA_SERVICE_UUID, AM43_TUYA_CHARACTERISTIC_UUID) != nullptr) { - ESP_LOGE(TAG, "[%s] Detected a Tuya AM43 which is not supported, sorry.", this->get_name().c_str()); + ESP_LOGE(TAG, "[%s] Detected a Tuya AM43 which is not supported, sorry.", LOG_STR_ARG(this->get_log_name())); } else { - ESP_LOGE(TAG, "[%s] No control service found at device, not an AM43..?", this->get_name().c_str()); + ESP_LOGE(TAG, "[%s] No control service found at device, not an AM43..?", LOG_STR_ARG(this->get_log_name())); } break; } @@ -100,7 +101,8 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ auto status = esp_ble_gattc_register_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(), chr->handle); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", LOG_STR_ARG(this->get_log_name()), + status); } break; } @@ -126,25 +128,26 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->decoder_->has_pin_response()) { if (this->decoder_->pin_ok_) { - ESP_LOGI(TAG, "[%s] AM43 pin accepted.", this->get_name().c_str()); + ESP_LOGI(TAG, "[%s] AM43 pin accepted.", LOG_STR_ARG(this->get_log_name())); auto *packet = this->encoder_->get_position_request(); auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] Error writing set_position to device, error = %d", this->get_name().c_str(), status); + ESP_LOGW(TAG, "[%s] Error writing set_position to device, error = %d", LOG_STR_ARG(this->get_log_name()), + status); } } else { - ESP_LOGW(TAG, "[%s] AM43 pin rejected!", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] AM43 pin rejected!", LOG_STR_ARG(this->get_log_name())); } } if (this->decoder_->has_set_position_response() && !this->decoder_->set_position_ok_) { - ESP_LOGW(TAG, "[%s] Got nack after sending set_position. Bad pin?", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Got nack after sending set_position. Bad pin?", LOG_STR_ARG(this->get_log_name())); } if (this->decoder_->has_set_state_response() && !this->decoder_->set_state_ok_) { - ESP_LOGW(TAG, "[%s] Got nack after sending set_state. Bad pin?", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Got nack after sending set_state. Bad pin?", LOG_STR_ARG(this->get_log_name())); } break; } diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index 28e2da93e93..72a29607599 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -67,8 +67,9 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ case ESP_GATTC_SEARCH_CMPL_EVT: { auto *chr = this->parent_->get_characteristic(ANOVA_SERVICE_UUID, ANOVA_CHARACTERISTIC_UUID); if (chr == nullptr) { - ESP_LOGW(TAG, "[%s] No control service found at device, not an Anova..?", this->get_name().c_str()); - ESP_LOGW(TAG, "[%s] Note, this component does not currently support Anova Nano.", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] No control service found at device, not an Anova..?", LOG_STR_ARG(this->get_log_name())); + ESP_LOGW(TAG, "[%s] Note, this component does not currently support Anova Nano.", + LOG_STR_ARG(this->get_log_name())); break; } this->char_handle_ = chr->handle; @@ -76,7 +77,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ auto status = esp_ble_gattc_register_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(), chr->handle); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", LOG_STR_ARG(this->get_log_name()), + status); } break; } diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6d8f419b0b3..de8ded70ec9 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,5 +1,7 @@ +from collections.abc import Callable from ipaddress import IPv4Address, IPv6Address import logging +import math import re from typing import Any @@ -27,6 +29,7 @@ from esphome.const import ( CONF_CAPTURE_RESPONSE, CONF_DATA, CONF_DATA_TEMPLATE, + CONF_DEFAULT, CONF_DELAY, CONF_ENCRYPTION, CONF_EVENT, @@ -136,6 +139,7 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = { CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_EXAMPLE = "example" +CONF_REQUIRED = "required" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" @@ -247,12 +251,66 @@ def _validate_supports_response(value: Any) -> str: return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) -VARIABLE_SCHEMA = cv.Schema( - { - cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), - cv.Optional(CONF_DESCRIPTION): cv.string_strict, - cv.Optional(CONF_EXAMPLE): cv.string_strict, - } +def _not_boolean(value: Any) -> Any: + # bool is an int subclass, and "true" cannot be parsed as a number by the client + if isinstance(value, bool): + raise cv.Invalid("Expected a number, got a boolean") + return value + + +def _finite(value: float) -> float: + if not math.isfinite(value): + raise cv.Invalid("Expected a finite number") + return value + + +def _no_nul(value: str) -> str: + # The default is a C string in flash, so a NUL would cut it short + if "\0" in value: + raise cv.Invalid("A default cannot contain a NUL character") + return value + + +# The wire carries the default as text; the client parses it into ExecuteServiceArgument's +# types, so the values are kept within what int32 and float hold +FLOAT32_MAX = 3.4028235e38 +_DEFAULT_VALUE_VALIDATORS: dict[str, Callable[[Any], Any]] = { + "bool": cv.boolean, + "int": cv.All(_not_boolean, cv.int_range(min=-(2**31), max=2**31 - 1)), + "float": cv.All( + _not_boolean, + cv.float_, + _finite, + cv.float_range(min=-FLOAT32_MAX, max=FLOAT32_MAX), + ), + "string": cv.All(cv.string_strict, _no_nul), +} +assert _DEFAULT_VALUE_VALIDATORS.keys() == _SERVICE_ARG_SCALAR_TYPES.keys() + + +def _validate_variable_optionality(value: ConfigType) -> ConfigType: + """Cross-validate required/default against the variable type.""" + var_type = value[CONF_TYPE] + if _is_optional_var(value) and var_type not in _DEFAULT_VALUE_VALIDATORS: + raise cv.Invalid("Array variables cannot be optional") + if CONF_DEFAULT in value: + if value.get(CONF_REQUIRED) is True: + raise cv.Invalid("A variable with a default cannot set required: true") + value[CONF_DEFAULT] = _DEFAULT_VALUE_VALIDATORS[var_type](value[CONF_DEFAULT]) + return value + + +VARIABLE_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), + cv.Optional(CONF_DESCRIPTION): cv.string_strict, + cv.Optional(CONF_EXAMPLE): cv.string_strict, + cv.Optional(CONF_REQUIRED): cv.boolean, + cv.Optional(CONF_DEFAULT): cv.valid, + } + ), + _validate_variable_optionality, ) # Accepts the plain `name: type` shorthand or the full mapping form @@ -464,7 +522,28 @@ def _has_action_metadata(actions: list[ConfigType]) -> bool: ) -def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]: +def _is_optional_var(var_: ConfigType) -> bool: + return CONF_DEFAULT in var_ or var_.get(CONF_REQUIRED) is False + + +def _has_optional_args(actions: list[ConfigType]) -> bool: + return any( + _is_optional_var(var_) + for conf in actions + for var_ in conf[CONF_VARIABLES].values() + ) + + +def _default_to_wire(value: Any) -> str: + """Serialize a validated default for the wire; clients coerce by arg type.""" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _action_strings( + conf: ConfigType, has_metadata: bool, has_optional: bool +) -> list[str | None]: """Strings of one action in the table order UserServiceStatic (user_services.h) expects.""" # An empty description or example is treated as unset strings: list[str | None] = [conf[CONF_ACTION]] @@ -477,6 +556,15 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]: var_.get(CONF_DESCRIPTION) or None, var_.get(CONF_EXAMPLE) or None, ] + if has_optional: + # The default slot doubles as the optional flag: nullptr for a required variable, + # "" for an optional one without a default (an empty string default is the same) + if not _is_optional_var(var_): + strings.append(None) + elif (default := var_.get(CONF_DEFAULT)) is None: + strings.append("") + else: + strings.append(_default_to_wire(default)) return strings @@ -485,9 +573,7 @@ def _validate_wizard(config: ConfigType) -> ConfigType: return config -FINAL_VALIDATE_SCHEMA = cv.All( - _validate_wizard, -) +FINAL_VALIDATE_SCHEMA = _validate_wizard def _add_action_strings(index: int, strings: list[str | None]) -> MockObj: @@ -546,6 +632,9 @@ async def to_code(config: ConfigType) -> None: has_metadata = _has_action_metadata(actions) if has_metadata: cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA") + has_optional = _has_optional_args(actions) + if has_optional: + cg.add_define("USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS") # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.MockObj] = [] for index, conf in enumerate(actions): @@ -589,7 +678,7 @@ async def to_code(config: ConfigType) -> None: native = SERVICE_ARG_NATIVE_TYPES[var_type] service_template_args.append(native) func_args.append((native, name)) - strings = _action_strings(conf, has_metadata) + strings = _action_strings(conf, has_metadata, has_optional) table = _add_action_strings(index, strings) # Template args: supports_response mode, then user service arg types templ = cg.TemplateArguments(supports_response, *service_template_args) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f2626dc4ce1..af11d925faa 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -227,11 +227,17 @@ message AreaInfo { string name = 2 [(max_data_length) = 120, (force) = true]; } +enum DeviceRelation { + DEVICE_RELATION_VIA = 0; + DEVICE_RELATION_CHILD = 1; +} + message DeviceInfo { uint32 device_id = 1; // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via DEVICE_SCHEMA string name = 2 [(max_data_length) = 120, (force) = true]; uint32 area_id = 3; + DeviceRelation relation = 4; } enum SerialProxyPortType { @@ -555,13 +561,13 @@ message ListEntitiesBinarySensorResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string device_class = 5 [(max_data_length) = 47]; + string device_class = 5 [(max_data_length) = 47, (progmem) = true]; bool is_status_binary_sensor = 6; bool disabled_by_default = 7; - string icon = 8 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 8 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; EntityCategory entity_category = 9; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } @@ -589,18 +595,20 @@ message ListEntitiesCoverResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id bool assumed_state = 5; bool supports_position = 6; bool supports_tilt = 7; - string device_class = 8 [(max_data_length) = 47]; + string device_class = 8 [(max_data_length) = 47, (progmem) = true]; bool disabled_by_default = 9; - string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; EntityCategory entity_category = 11; bool supports_stop = 12; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; + bool supports_stop_tilt = 14; + bool supports_toggle = 15; } // Deprecated in API version 1.1 @@ -662,6 +670,8 @@ message CoverCommandRequest { float tilt = 7; bool stop = 8; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; + bool stop_tilt = 10; + bool toggle = 11; } // ==================== FAN ==================== @@ -673,7 +683,7 @@ message ListEntitiesFanResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id bool supports_oscillation = 5; @@ -681,7 +691,7 @@ message ListEntitiesFanResponse { bool supports_direction = 7; int32 supported_speed_count = 8; bool disabled_by_default = 9; - string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; EntityCategory entity_category = 11; repeated string supported_preset_modes = 12 [(container_pointer_no_template) = "ConstVector"]; uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"]; @@ -761,7 +771,7 @@ message ListEntitiesLightResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id repeated ColorMode supported_color_modes = 12 [(container_pointer_no_template) = "light::ColorModeMask"]; @@ -778,7 +788,7 @@ message ListEntitiesLightResponse { float max_mireds = 10; repeated string effects = 11 [(container_pointer_no_template) = "light::LightEffectNames"]; bool disabled_by_default = 13; - string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; EntityCategory entity_category = 15; uint32 device_id = 16 [(field_ifdef) = "USE_DEVICES"]; } @@ -866,14 +876,14 @@ message ListEntitiesSensorResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; string unit_of_measurement = 6 [(max_data_length) = 63]; int32 accuracy_decimals = 7; bool force_update = 8; - string device_class = 9 [(max_data_length) = 47]; + string device_class = 9 [(max_data_length) = 47, (progmem) = true]; SensorStateClass state_class = 10; // Last reset type removed in 2021.9.0 // Deprecated in API version 1.5 @@ -907,14 +917,14 @@ message ListEntitiesSwitchResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool assumed_state = 6; bool disabled_by_default = 7; EntityCategory entity_category = 8; - string device_class = 9 [(max_data_length) = 47]; + string device_class = 9 [(max_data_length) = 47, (progmem) = true]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } message SwitchStateResponse { @@ -952,13 +962,13 @@ message ListEntitiesTextSensorResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8 [(max_data_length) = 47]; + string device_class = 8 [(max_data_length) = 47, (progmem) = true]; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; } message TextSensorStateResponse { @@ -1164,6 +1174,10 @@ message ListEntitiesServicesArgument { ServiceArgType type = 2; string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA", (progmem) = true]; + // Advisory: ExecuteServiceRequest still requires every argument + bool optional = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS"]; + // Always text: bool "true"/"false", numbers parseable as int/float; empty means no default + string default_value = 6 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS", (progmem) = true]; } message ListEntitiesServicesResponse { option (id) = 41; @@ -1223,10 +1237,10 @@ message ListEntitiesCameraResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id bool disabled_by_default = 5; - string icon = 6 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 6 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; EntityCategory entity_category = 7; uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"]; } @@ -1315,7 +1329,7 @@ message ListEntitiesClimateResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id bool supports_current_temperature = 5; // Deprecated: use feature_flags @@ -1335,7 +1349,7 @@ message ListEntitiesClimateResponse { repeated ClimatePreset supported_presets = 16 [(container_pointer_no_template) = "climate::ClimatePresetMask"]; repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "ConstVector"]; bool disabled_by_default = 18; - string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; EntityCategory entity_category = 20; float visual_current_temperature_step = 21; bool supports_current_humidity = 22; // Deprecated: use feature_flags @@ -1430,8 +1444,8 @@ message ListEntitiesWaterHeaterResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; - string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 5; EntityCategory entity_category = 6; uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; @@ -1510,10 +1524,10 @@ message ListEntitiesNumberResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; float min_value = 6; float max_value = 7; float step = 8; @@ -1521,7 +1535,7 @@ message ListEntitiesNumberResponse { EntityCategory entity_category = 10; string unit_of_measurement = 11 [(max_data_length) = 63]; NumberMode mode = 12; - string device_class = 13 [(max_data_length) = 47]; + string device_class = 13 [(max_data_length) = 47, (progmem) = true]; uint32 device_id = 14 [(field_ifdef) = "USE_DEVICES"]; } message NumberStateResponse { @@ -1559,10 +1573,10 @@ message ListEntitiesSelectResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; repeated string options = 6 [(container_pointer_no_template) = "std::span"]; bool disabled_by_default = 7; EntityCategory entity_category = 8; @@ -1603,10 +1617,10 @@ message ListEntitiesSirenResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; repeated string tones = 7 [(container_pointer_no_template) = "FixedVector"]; bool supports_duration = 8; @@ -1668,10 +1682,10 @@ message ListEntitiesLockResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; bool assumed_state = 8; @@ -1717,13 +1731,13 @@ message ListEntitiesButtonResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8 [(max_data_length) = 47]; + string device_class = 8 [(max_data_length) = 47, (progmem) = true]; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; } message ButtonCommandRequest { @@ -1775,6 +1789,8 @@ message MediaPlayerSupportedFormat { uint32 num_channels = 3; MediaPlayerFormatPurpose purpose = 4; uint32 sample_bytes = 5; + // Constant bitrate in kbps for lossy formats; 0 means no preference + uint32 bitrate = 6; } message ListEntitiesMediaPlayerResponse { option (id) = 63; @@ -1784,10 +1800,10 @@ message ListEntitiesMediaPlayerResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; @@ -2376,9 +2392,9 @@ message ListEntitiesAlarmControlPanelResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; uint32 supported_features = 8; @@ -2423,9 +2439,9 @@ message ListEntitiesTextResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; @@ -2471,10 +2487,10 @@ message ListEntitiesDateResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"]; @@ -2518,10 +2534,10 @@ message ListEntitiesTimeResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"]; @@ -2565,13 +2581,13 @@ message ListEntitiesEventResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8 [(max_data_length) = 47]; + string device_class = 8 [(max_data_length) = 47, (progmem) = true]; repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector"]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; @@ -2596,13 +2612,13 @@ message ListEntitiesValveResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8 [(max_data_length) = 47]; + string device_class = 8 [(max_data_length) = 47, (progmem) = true]; bool assumed_state = 9; bool supports_position = 10; @@ -2651,10 +2667,10 @@ message ListEntitiesDateTimeResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"]; @@ -2694,13 +2710,13 @@ message ListEntitiesUpdateResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; reserved 4; // Deprecated: was string unique_id - string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 6; EntityCategory entity_category = 7; - string device_class = 8 [(max_data_length) = 47]; + string device_class = 8 [(max_data_length) = 47, (progmem) = true]; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; } message UpdateStateResponse { @@ -2794,8 +2810,8 @@ message ListEntitiesInfraredResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; - string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 5; EntityCategory entity_category = 6; uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; @@ -2863,8 +2879,8 @@ message ListEntitiesRadioFrequencyResponse { string object_id = 1 [(max_data_length) = 120, (force) = true]; fixed32 key = 2 [(force) = true]; - string name = 3 [(max_data_length) = 120, (force) = true]; - string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; + string name = 3 [(max_data_length) = 120, (force) = true, (progmem) = true]; + string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63, (progmem) = true]; bool disabled_by_default = 5; EntityCategory entity_category = 6; uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 83269d07653..7a9b96abe24 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -24,6 +24,7 @@ #endif #include "esphome/components/network/util.h" #include "esphome/core/application.h" +#include "esphome/core/device.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -485,13 +486,12 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp msg.key = entity->get_object_id_hash(); if (entity->has_own_name()) { - msg.name = entity->get_name(); + msg.name = entity->get_name_progmem(); } // Set common EntityBase properties #ifdef USE_ENTITY_ICON - char icon_buf[MAX_ICON_LENGTH]; - msg.icon = StringRef(entity->get_icon_to(icon_buf)); + msg.icon = entity->get_icon_progmem(); #endif msg.disabled_by_default = entity->is_disabled_by_default(); msg.entity_category = static_cast(entity->get_entity_category()); @@ -502,12 +502,11 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp } uint16_t APIConnection::fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg, - StringRef &device_class_field, + ProgmemStringRef &device_class_field, CalculateSizeFn size_fn, MessageEncodeFn encode_fn, APIConnection *conn, uint32_t remaining_size) { - char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - device_class_field = StringRef(entity->get_device_class_to(dc_buf)); + device_class_field = entity->get_device_class_progmem(); return fill_and_encode_entity_info(entity, msg, size_fn, encode_fn, conn, remaining_size); } @@ -555,6 +554,8 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c msg.supports_position = traits.get_supports_position(); msg.supports_tilt = traits.get_supports_tilt(); msg.supports_stop = traits.get_supports_stop(); + msg.supports_stop_tilt = traits.get_supports_stop_tilt(); + msg.supports_toggle = traits.get_supports_toggle(); return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, conn, remaining_size); } void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) { @@ -565,6 +566,10 @@ void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) { call.set_tilt(msg.tilt); if (msg.stop) call.set_command_stop(); + if (msg.stop_tilt) + call.set_command_stop_tilt(); + if (msg.toggle) + call.set_command_toggle(); call.perform(); } #endif @@ -1126,6 +1131,7 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec media_format.num_channels = supported_format.num_channels; media_format.purpose = static_cast(supported_format.purpose); media_format.sample_bytes = supported_format.sample_bytes; + media_format.bitrate = supported_format.bitrate; } return fill_and_encode_entity_info(media_player, msg, conn, remaining_size); } @@ -2057,6 +2063,7 @@ bool APIConnection::send_device_info_response_() { device_info.device_id = device->get_device_id(); device_info.name = StringRef(device->get_name()); device_info.area_id = device->get_area_id(); + device_info.relation = static_cast(device->get_relation()); } #endif #ifdef USE_AREAS diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 863e378a5e5..cfb98911c44 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -472,15 +472,15 @@ class APIConnection final : public APIServerConnectionBase { // Non-template core — fills device_class, then delegates to fill_and_encode_entity_info static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg, - StringRef &device_class_field, CalculateSizeFn size_fn, - MessageEncodeFn encode_fn, APIConnection *conn, - uint32_t remaining_size); + ProgmemStringRef &device_class_field, + CalculateSizeFn size_fn, MessageEncodeFn encode_fn, + APIConnection *conn, uint32_t remaining_size); // Thin template wrapper template static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, T &msg, - StringRef &device_class_field, APIConnection *conn, - uint32_t remaining_size) { + ProgmemStringRef &device_class_field, + APIConnection *conn, uint32_t remaining_size) { return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &T::calc_size_msg, &T::encode_msg, conn, remaining_size); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 515357dc0a5..acc25610446 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -25,6 +25,8 @@ void HelloRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, p msg.outgoing_connection_target = value.as_bool(); break; #endif + default: + break; } } uint8_t *HelloResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -51,6 +53,8 @@ void DisconnectRequest::decode_field(void *self, uint32_t tag, const uint8_t *da case proto_tag(1, WIRE_TYPE_VARINT): msg.reason = static_cast(value.as_varint()); break; + default: + break; } } uint8_t *DisconnectRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -85,6 +89,7 @@ uint8_t *DeviceInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROT pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.area_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.relation)); return pos; } uint32_t DeviceInfo::calc_size_msg(const void *self) { @@ -93,6 +98,7 @@ uint32_t DeviceInfo::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.device_id); size += 2 + msg.name.size(); size += ProtoSize::calc_uint32(1, msg.area_id); + size += msg.relation ? 2 : 0; return size; } #endif @@ -394,6 +400,8 @@ void WizardInputSetRequest::decode_field(void *self, uint32_t tag, const uint8_t case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): msg.entity_id = StringRef(value.data(), value.size()); break; + default: + break; } } #endif @@ -403,12 +411,12 @@ uint8_t *ListEntitiesBinarySensorResponse::encode_msg(const void *self, const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_class); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_class); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.is_status_binary_sensor); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.icon); #endif pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(msg.entity_category)); #ifdef USE_DEVICES @@ -461,20 +469,22 @@ uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__rest const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.assumed_state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_position); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.supports_tilt); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.icon); #endif pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.entity_category)); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supports_stop); #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id); #endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.supports_stop_tilt); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.supports_toggle); return pos; } uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) { @@ -496,6 +506,8 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) { #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, msg.device_id); #endif + size += ProtoSize::calc_bool(1, msg.supports_stop_tilt); + size += ProtoSize::calc_bool(1, msg.supports_toggle); return size; } uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -552,6 +564,14 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * msg.device_id = value.as_varint(); break; #endif + case proto_tag(10, WIRE_TYPE_VARINT): + msg.stop_tilt = value.as_bool(); + break; + case proto_tag(11, WIRE_TYPE_VARINT): + msg.toggle = value.as_bool(); + break; + default: + break; } } #endif @@ -560,14 +580,14 @@ uint8_t *ListEntitiesFanResponse::encode_msg(const void *self, uint8_t *__restri const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.supports_oscillation); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_speed); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.supports_direction); pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supported_speed_count); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.icon); #endif pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.entity_category)); for (const char *it : *msg.supported_preset_modes) { @@ -672,6 +692,8 @@ void FanCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *da msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -680,7 +702,7 @@ uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, uint8_t *__rest const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); for (const auto &it : *msg.supported_color_modes) { pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(it)); } @@ -695,7 +717,7 @@ uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, uint8_t *__rest } pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.icon); #endif pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, static_cast(msg.entity_category)); #ifdef USE_DEVICES @@ -878,6 +900,8 @@ void LightCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -886,14 +910,14 @@ uint8_t *ListEntitiesSensorResponse::encode_msg(const void *self, uint8_t *__res const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.unit_of_measurement); pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.accuracy_decimals); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.force_update); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_class); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.state_class)); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(msg.entity_category)); @@ -954,14 +978,14 @@ uint8_t *ListEntitiesSwitchResponse::encode_msg(const void *self, uint8_t *__res const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.assumed_state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_class); #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif @@ -1021,6 +1045,8 @@ void SwitchCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -1030,13 +1056,13 @@ uint8_t *ListEntitiesTextSensorResponse::encode_msg(const void *self, const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif @@ -1091,6 +1117,8 @@ void SubscribeLogsRequest::decode_field(void *self, uint32_t tag, const uint8_t case proto_tag(2, WIRE_TYPE_VARINT): msg.dump_config = value.as_bool(); break; + default: + break; } } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) @@ -1120,6 +1148,8 @@ void NoiseEncryptionSetKeyRequest::decode_field(void *self, uint32_t tag, const msg.key = value.data(); msg.key_len = value.size(); break; + default: + break; } } uint8_t *NoiseEncryptionSetKeyResponse::encode_msg(const void *self, @@ -1226,6 +1256,8 @@ void HomeassistantActionResponse::decode_field(void *self, uint32_t tag, const u msg.response_data_len = value.size(); break; #endif + default: + break; } } #endif @@ -1260,6 +1292,8 @@ void HomeAssistantStateResponse::decode_field(void *self, uint32_t tag, const ui case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): msg.attribute = StringRef(value.data(), value.size()); break; + default: + break; } } #endif @@ -1285,6 +1319,8 @@ void DSTRule::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_ case proto_tag(6, WIRE_TYPE_VARINT): msg.day_of_week = value.as_varint(); break; + default: + break; } } void ParsedTimezone::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -1303,6 +1339,8 @@ void ParsedTimezone::decode_field(void *self, uint32_t tag, const uint8_t *data, case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): value.decode_to_message(msg.dst_end); break; + default: + break; } } void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -1316,6 +1354,8 @@ void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data value.decode_to_message(msg.parsed_timezone); msg.has_parsed_timezone = true; break; + default: + break; } } #ifdef USE_API_USER_DEFINED_ACTIONS @@ -1329,6 +1369,12 @@ uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.optional); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.default_value); #endif return pos; } @@ -1342,6 +1388,12 @@ uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) { #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA size += ProtoSize::calc_length(1, msg.example.size()); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + size += ProtoSize::calc_bool(1, msg.optional); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + size += ProtoSize::calc_length(1, msg.default_value.size()); #endif return size; } @@ -1406,6 +1458,8 @@ void ExecuteServiceArgument::decode_field(void *self, uint32_t tag, const uint8_ case proto_tag(9, WIRE_TYPE_LENGTH_DELIMITED): msg.string_array.push_back(value.as_string()); break; + default: + break; } } void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { @@ -1440,6 +1494,8 @@ void ExecuteServiceRequest::decode_field(void *self, uint32_t tag, const uint8_t msg.return_response = value.as_bool(); break; #endif + default: + break; } } void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { @@ -1476,10 +1532,10 @@ uint8_t *ListEntitiesCameraResponse::encode_msg(const void *self, uint8_t *__res const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.icon); #endif pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES @@ -1534,6 +1590,8 @@ void CameraImageRequest::decode_field(void *self, uint32_t tag, const uint8_t *d case proto_tag(2, WIRE_TYPE_VARINT): msg.stream = value.as_bool(); break; + default: + break; } } #endif @@ -1542,7 +1600,7 @@ uint8_t *ListEntitiesClimateResponse::encode_msg(const void *self, uint8_t *__re const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.supports_current_temperature); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_two_point_target_temperature); for (const auto &it : *msg.supported_modes) { @@ -1575,7 +1633,7 @@ uint8_t *ListEntitiesClimateResponse::encode_msg(const void *self, uint8_t *__re } pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 19, msg.icon); #endif pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(msg.entity_category)); pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.visual_current_temperature_step); @@ -1770,6 +1828,8 @@ void ClimateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -1779,9 +1839,9 @@ uint8_t *ListEntitiesWaterHeaterResponse::encode_msg(const void *self, const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); @@ -1898,6 +1958,8 @@ void WaterHeaterCommandRequest::decode_field(void *self, uint32_t tag, const uin case proto_tag(8, WIRE_TYPE_FIXED32): msg.target_temperature_high = value.as_float(); break; + default: + break; } } #endif @@ -1906,9 +1968,9 @@ uint8_t *ListEntitiesNumberResponse::encode_msg(const void *self, uint8_t *__res const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif if (uint32_t raw = float_to_raw(msg.min_value); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); @@ -1923,7 +1985,7 @@ uint8_t *ListEntitiesNumberResponse::encode_msg(const void *self, uint8_t *__res pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.entity_category)); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.unit_of_measurement); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(msg.mode)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_class); #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.device_id); #endif @@ -1989,6 +2051,8 @@ void NumberCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -1997,9 +2061,9 @@ uint8_t *ListEntitiesSelectResponse::encode_msg(const void *self, uint8_t *__res const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif for (const char *it : *msg.options) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it)); @@ -2068,6 +2132,8 @@ void SelectCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -2076,9 +2142,9 @@ uint8_t *ListEntitiesSirenResponse::encode_msg(const void *self, uint8_t *__rest const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); for (const char *it : *msg.tones) { @@ -2170,6 +2236,8 @@ void SirenCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -2178,9 +2246,9 @@ uint8_t *ListEntitiesLockResponse::encode_msg(const void *self, uint8_t *__restr const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); @@ -2253,6 +2321,8 @@ void LockCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *d msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -2261,13 +2331,13 @@ uint8_t *ListEntitiesButtonResponse::encode_msg(const void *self, uint8_t *__res const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif @@ -2302,6 +2372,8 @@ void ButtonCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -2313,6 +2385,7 @@ uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, uint8_t *__res pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.num_channels); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.purpose)); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.sample_bytes); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.bitrate); return pos; } uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) { @@ -2323,6 +2396,7 @@ uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.num_channels); size += msg.purpose ? 2 : 0; size += ProtoSize::calc_uint32(1, msg.sample_bytes); + size += ProtoSize::calc_uint32(1, msg.bitrate); return size; } uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self, @@ -2330,9 +2404,9 @@ uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self, const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); @@ -2429,6 +2503,8 @@ void MediaPlayerCommandRequest::decode_field(void *self, uint32_t tag, const uin msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -2441,6 +2517,8 @@ void SubscribeBluetoothLEAdvertisementsRequest::decode_field(void *self, uint32_ case proto_tag(1, WIRE_TYPE_VARINT): msg.flags = value.as_varint(); break; + default: + break; } } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) @@ -2499,6 +2577,8 @@ void BluetoothDeviceRequest::decode_field(void *self, uint32_t tag, const uint8_ case proto_tag(4, WIRE_TYPE_VARINT): msg.address_type = value.as_varint(); break; + default: + break; } } uint8_t *BluetoothDeviceConnectionResponse::encode_msg(const void *self, @@ -2527,6 +2607,8 @@ void BluetoothGATTGetServicesRequest::decode_field(void *self, uint32_t tag, con case proto_tag(1, WIRE_TYPE_VARINT): msg.address = value.as_varint(); break; + default: + break; } } uint8_t *BluetoothGATTDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -2653,6 +2735,8 @@ void BluetoothGATTReadRequest::decode_field(void *self, uint32_t tag, const uint case proto_tag(2, WIRE_TYPE_VARINT): msg.handle = value.as_varint(); break; + default: + break; } } uint8_t *BluetoothGATTReadResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -2688,6 +2772,8 @@ void BluetoothGATTWriteRequest::decode_field(void *self, uint32_t tag, const uin msg.data = value.data(); msg.data_len = value.size(); break; + default: + break; } } void BluetoothGATTReadDescriptorRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, @@ -2701,6 +2787,8 @@ void BluetoothGATTReadDescriptorRequest::decode_field(void *self, uint32_t tag, case proto_tag(2, WIRE_TYPE_VARINT): msg.handle = value.as_varint(); break; + default: + break; } } void BluetoothGATTWriteDescriptorRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, @@ -2718,6 +2806,8 @@ void BluetoothGATTWriteDescriptorRequest::decode_field(void *self, uint32_t tag, msg.data = value.data(); msg.data_len = value.size(); break; + default: + break; } } void BluetoothGATTNotifyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, @@ -2734,6 +2824,8 @@ void BluetoothGATTNotifyRequest::decode_field(void *self, uint32_t tag, const ui case proto_tag(3, WIRE_TYPE_VARINT): msg.enable = value.as_bool(); break; + default: + break; } } uint8_t *BluetoothGATTNotifyDataResponse::encode_msg(const void *self, @@ -2891,6 +2983,8 @@ void BluetoothScannerSetModeRequest::decode_field(void *self, uint32_t tag, cons case proto_tag(1, WIRE_TYPE_VARINT): msg.mode = static_cast(value.as_varint()); break; + default: + break; } } #endif @@ -2906,6 +3000,8 @@ void SubscribeVoiceAssistantRequest::decode_field(void *self, uint32_t tag, cons case proto_tag(2, WIRE_TYPE_VARINT): msg.flags = value.as_varint(); break; + default: + break; } } uint8_t *VoiceAssistantAudioSettings::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -2954,6 +3050,8 @@ void VoiceAssistantResponse::decode_field(void *self, uint32_t tag, const uint8_ case proto_tag(2, WIRE_TYPE_VARINT): msg.error = value.as_bool(); break; + default: + break; } } void VoiceAssistantEventData::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -2966,6 +3064,8 @@ void VoiceAssistantEventData::decode_field(void *self, uint32_t tag, const uint8 case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): msg.value = StringRef(value.data(), value.size()); break; + default: + break; } } void VoiceAssistantEventResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, @@ -2980,6 +3080,8 @@ void VoiceAssistantEventResponse::decode_field(void *self, uint32_t tag, const u msg.data.emplace_back(); value.decode_to_message(msg.data.back()); break; + default: + break; } } void VoiceAssistantAudio::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -2997,6 +3099,8 @@ void VoiceAssistantAudio::decode_field(void *self, uint32_t tag, const uint8_t * msg.data2 = value.data(); msg.data2_len = value.size(); break; + default: + break; } } uint8_t *VoiceAssistantAudio::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -3037,6 +3141,8 @@ void VoiceAssistantTimerEventResponse::decode_field(void *self, uint32_t tag, co case proto_tag(6, WIRE_TYPE_VARINT): msg.is_active = value.as_bool(); break; + default: + break; } } void VoiceAssistantAnnounceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, @@ -3056,6 +3162,8 @@ void VoiceAssistantAnnounceRequest::decode_field(void *self, uint32_t tag, const case proto_tag(4, WIRE_TYPE_VARINT): msg.start_conversation = value.as_bool(); break; + default: + break; } } uint8_t *VoiceAssistantAnnounceFinished::encode_msg(const void *self, @@ -3117,6 +3225,8 @@ void VoiceAssistantExternalWakeWord::decode_field(void *self, uint32_t tag, cons case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): msg.url = StringRef(value.data(), value.size()); break; + default: + break; } } void VoiceAssistantConfigurationRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, @@ -3128,6 +3238,8 @@ void VoiceAssistantConfigurationRequest::decode_field(void *self, uint32_t tag, msg.external_wake_words.emplace_back(); value.decode_to_message(msg.external_wake_words.back()); break; + default: + break; } } uint8_t *VoiceAssistantConfigurationResponse::encode_msg(const void *self, @@ -3166,6 +3278,8 @@ void VoiceAssistantSetConfiguration::decode_field(void *self, uint32_t tag, cons case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): msg.active_wake_words.push_back(value.as_string()); break; + default: + break; } } #endif @@ -3175,9 +3289,9 @@ uint8_t *ListEntitiesAlarmControlPanelResponse::encode_msg(const void *self, const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); @@ -3247,6 +3361,8 @@ void AlarmControlPanelCommandRequest::decode_field(void *self, uint32_t tag, con msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -3255,9 +3371,9 @@ uint8_t *ListEntitiesTextResponse::encode_msg(const void *self, uint8_t *__restr const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); @@ -3326,6 +3442,8 @@ void TextCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *d msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -3334,9 +3452,9 @@ uint8_t *ListEntitiesDateResponse::encode_msg(const void *self, uint8_t *__restr const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); @@ -3407,6 +3525,8 @@ void DateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *d msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -3415,9 +3535,9 @@ uint8_t *ListEntitiesTimeResponse::encode_msg(const void *self, uint8_t *__restr const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); @@ -3488,6 +3608,8 @@ void TimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *d msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -3496,13 +3618,13 @@ uint8_t *ListEntitiesEventResponse::encode_msg(const void *self, uint8_t *__rest const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); for (const char *it : *msg.event_types) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it)); } @@ -3558,13 +3680,13 @@ uint8_t *ListEntitiesValveResponse::encode_msg(const void *self, uint8_t *__rest const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.assumed_state); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.supports_position); pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.supports_stop); @@ -3637,6 +3759,8 @@ void ValveCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t * msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -3646,9 +3770,9 @@ uint8_t *ListEntitiesDateTimeResponse::encode_msg(const void *self, const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); @@ -3711,6 +3835,8 @@ void DateTimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_ msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -3719,13 +3845,13 @@ uint8_t *ListEntitiesUpdateResponse::encode_msg(const void *self, uint8_t *__res const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif @@ -3800,6 +3926,8 @@ void UpdateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t msg.device_id = value.as_varint(); break; #endif + default: + break; } } #endif @@ -3812,6 +3940,8 @@ void ZWaveProxyFrame::decode_field(void *self, uint32_t tag, const uint8_t *data msg.data = value.data(); msg.data_len = value.size(); break; + default: + break; } } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) @@ -3838,6 +3968,8 @@ void ZWaveProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *da msg.data = value.data(); msg.data_len = value.size(); break; + default: + break; } } uint8_t *ZWaveProxyRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -3873,9 +4005,9 @@ uint8_t *ListEntitiesInfraredResponse::encode_msg(const void *self, const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); @@ -3933,6 +4065,8 @@ void InfraredRFTransmitRawTimingsRequest::decode_field(void *self, uint32_t tag, case proto_tag(6, WIRE_TYPE_VARINT): msg.modulation = value.as_varint(); break; + default: + break; } } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) @@ -3989,9 +4123,9 @@ uint8_t *ListEntitiesRadioFrequencyResponse::encode_msg(const void *self, const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); + pos = ProtoEncode::encode_progmem_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); @@ -4049,6 +4183,8 @@ void SerialProxyConfigureRequest::decode_field(void *self, uint32_t tag, const u case proto_tag(6, WIRE_TYPE_VARINT): msg.data_size = value.as_varint(); break; + default: + break; } } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) @@ -4077,6 +4213,8 @@ void SerialProxyWriteRequest::decode_field(void *self, uint32_t tag, const uint8 msg.data = value.data(); msg.data_len = value.size(); break; + default: + break; } } void SerialProxySetModemPinsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, @@ -4090,6 +4228,8 @@ void SerialProxySetModemPinsRequest::decode_field(void *self, uint32_t tag, cons case proto_tag(2, WIRE_TYPE_VARINT): msg.line_states = value.as_varint(); break; + default: + break; } } void SerialProxyGetModemPinsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, @@ -4100,6 +4240,8 @@ void SerialProxyGetModemPinsRequest::decode_field(void *self, uint32_t tag, cons case proto_tag(1, WIRE_TYPE_VARINT): msg.instance = value.as_varint(); break; + default: + break; } } uint8_t *SerialProxyGetModemPinsResponse::encode_msg(const void *self, @@ -4128,6 +4270,8 @@ void SerialProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *d case proto_tag(2, WIRE_TYPE_VARINT): msg.type = static_cast(value.as_varint()); break; + default: + break; } } uint8_t *SerialProxyRequestResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -4158,6 +4302,8 @@ void SerialProxySetModeRequest::decode_field(void *self, uint32_t tag, const uin case proto_tag(2, WIRE_TYPE_VARINT): msg.mode = static_cast(value.as_varint()); break; + default: + break; } } uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { @@ -4226,6 +4372,8 @@ void BluetoothSetConnectionParamsRequest::decode_field(void *self, uint32_t tag, case proto_tag(5, WIRE_TYPE_VARINT): msg.timeout = value.as_varint(); break; + default: + break; } } uint8_t *BluetoothSetConnectionParamsResponse::encode_msg(const void *self, diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 86e50281384..bf300e4c752 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -19,6 +19,10 @@ enum DisconnectReason : uint32_t { DISCONNECT_REASON_UNSPECIFIED = 0, DISCONNECT_REASON_PROVISIONING_CLOSED = 1, }; +enum DeviceRelation : uint32_t { + DEVICE_RELATION_VIA = 0, + DEVICE_RELATION_CHILD = 1, +}; enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, @@ -399,10 +403,10 @@ class InfoResponseProtoMessage : public ProtoMessage { public: StringRef object_id{}; uint32_t key{0}; - StringRef name{}; + ProgmemStringRef name{}; bool disabled_by_default{false}; #ifdef USE_ENTITY_ICON - StringRef icon{nullptr, 0}; // null until set, encode only + ProgmemStringRef icon{}; #endif enums::EntityCategory entity_category{}; #ifdef USE_DEVICES @@ -567,6 +571,7 @@ class DeviceInfo final : public ProtoMessage { uint32_t device_id{0}; StringRef name{}; uint32_t area_id{0}; + enums::DeviceRelation relation{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -866,7 +871,7 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_binary_sensor_response"); } #endif - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; bool is_status_binary_sensor{false}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -906,15 +911,17 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage { class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 13; - static constexpr uint8_t ESTIMATED_SIZE = 57; + static constexpr uint8_t ESTIMATED_SIZE = 61; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); } #endif bool assumed_state{false}; bool supports_position{false}; bool supports_tilt{false}; - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; bool supports_stop{false}; + bool supports_stop_tilt{false}; + bool supports_toggle{false}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -952,7 +959,7 @@ class CoverStateResponse final : public StateResponseProtoMessage { class CoverCommandRequest final : public CommandProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 30; - static constexpr uint8_t ESTIMATED_SIZE = 25; + static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("cover_command_request"); } #endif @@ -961,6 +968,8 @@ class CoverCommandRequest final : public CommandProtoMessage { bool has_tilt{false}; float tilt{0.0f}; bool stop{false}; + bool stop_tilt{false}; + bool toggle{false}; void decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } @@ -1159,7 +1168,7 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { StringRef unit_of_measurement{nullptr, 0}; // null until set, encode only int32_t accuracy_decimals{0}; bool force_update{false}; - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; enums::SensorStateClass state_class{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1204,7 +1213,7 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_switch_response"); } #endif bool assumed_state{false}; - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -1265,7 +1274,7 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); } #endif - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -1584,6 +1593,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage { #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA ProgmemStringRef example{}; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + bool optional{false}; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + ProgmemStringRef default_value{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1959,7 +1974,7 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { float step{0.0f}; StringRef unit_of_measurement{nullptr, 0}; // null until set, encode only enums::NumberMode mode{}; - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -2215,7 +2230,7 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); } #endif - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -2254,6 +2269,7 @@ class MediaPlayerSupportedFormat final : public ProtoMessage { uint32_t num_channels{0}; enums::MediaPlayerFormatPurpose purpose{}; uint32_t sample_bytes{0}; + uint32_t bitrate{0}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); @@ -3460,7 +3476,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } #endif - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; const ConstVector *event_types{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -3503,7 +3519,7 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_valve_response"); } #endif - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; @@ -3629,7 +3645,7 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); } #endif - StringRef device_class{nullptr, 0}; // null until set, encode only + ProgmemStringRef device_class{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 757c569cc89..60b64b41838 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -154,6 +154,16 @@ template<> const char *proto_enum_to_string(enums::Disc return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::DeviceRelation value) { + switch (value) { + case enums::DEVICE_RELATION_VIA: + return ESPHOME_PSTR("DEVICE_RELATION_VIA"); + case enums::DEVICE_RELATION_CHILD: + return ESPHOME_PSTR("DEVICE_RELATION_CHILD"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} template<> const char *proto_enum_to_string(enums::SerialProxyPortType value) { switch (value) { case enums::SERIAL_PROXY_PORT_TYPE_TTL: @@ -1005,6 +1015,7 @@ const char *DeviceInfo::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); dump_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("area_id"), this->area_id); + dump_field(out, ESPHOME_PSTR("relation"), static_cast(this->relation)); return out.c_str(); } #endif @@ -1191,12 +1202,12 @@ const char *ListEntitiesBinarySensorResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesBinarySensorResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); dump_field(out, ESPHOME_PSTR("is_status_binary_sensor"), this->is_status_binary_sensor); dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); #ifdef USE_DEVICES @@ -1220,20 +1231,22 @@ const char *ListEntitiesCoverResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesCoverResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("assumed_state"), this->assumed_state); dump_field(out, ESPHOME_PSTR("supports_position"), this->supports_position); dump_field(out, ESPHOME_PSTR("supports_tilt"), this->supports_tilt); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); dump_field(out, ESPHOME_PSTR("supports_stop"), this->supports_stop); #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("supports_stop_tilt"), this->supports_stop_tilt); + dump_field(out, ESPHOME_PSTR("supports_toggle"), this->supports_toggle); return out.c_str(); } const char *CoverStateResponse::dump_to(DumpBuffer &out) const { @@ -1258,6 +1271,8 @@ const char *CoverCommandRequest::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("stop_tilt"), this->stop_tilt); + dump_field(out, ESPHOME_PSTR("toggle"), this->toggle); return out.c_str(); } #endif @@ -1266,14 +1281,14 @@ const char *ListEntitiesFanResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesFanResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("supports_oscillation"), this->supports_oscillation); dump_field(out, ESPHOME_PSTR("supports_speed"), this->supports_speed); dump_field(out, ESPHOME_PSTR("supports_direction"), this->supports_direction); dump_field(out, ESPHOME_PSTR("supported_speed_count"), this->supported_speed_count); dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); for (const auto &it : *this->supported_preset_modes) { @@ -1321,7 +1336,7 @@ const char *ListEntitiesLightResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesLightResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); for (const auto &it : *this->supported_color_modes) { dump_field(out, ESPHOME_PSTR("supported_color_modes"), static_cast(it), 4); } @@ -1332,7 +1347,7 @@ const char *ListEntitiesLightResponse::dump_to(DumpBuffer &out) const { } dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); #ifdef USE_DEVICES @@ -1400,14 +1415,14 @@ const char *ListEntitiesSensorResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesSensorResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("unit_of_measurement"), this->unit_of_measurement); dump_field(out, ESPHOME_PSTR("accuracy_decimals"), this->accuracy_decimals); dump_field(out, ESPHOME_PSTR("force_update"), this->force_update); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); dump_field(out, ESPHOME_PSTR("state_class"), static_cast(this->state_class)); dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -1432,14 +1447,14 @@ const char *ListEntitiesSwitchResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesSwitchResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("assumed_state"), this->assumed_state); dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif @@ -1470,13 +1485,13 @@ const char *ListEntitiesTextSensorResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesTextSensorResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif @@ -1628,6 +1643,12 @@ const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const { #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA dump_progmem_string_field(out, ESPHOME_PSTR("example"), this->example); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + dump_field(out, ESPHOME_PSTR("optional"), this->optional); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + dump_progmem_string_field(out, ESPHOME_PSTR("default_value"), this->default_value); #endif return out.c_str(); } @@ -1701,10 +1722,10 @@ const char *ListEntitiesCameraResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesCameraResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); #ifdef USE_DEVICES @@ -1734,7 +1755,7 @@ const char *ListEntitiesClimateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesClimateResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("supports_current_temperature"), this->supports_current_temperature); dump_field(out, ESPHOME_PSTR("supports_two_point_target_temperature"), this->supports_two_point_target_temperature); for (const auto &it : *this->supported_modes) { @@ -1761,7 +1782,7 @@ const char *ListEntitiesClimateResponse::dump_to(DumpBuffer &out) const { } dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); dump_field(out, ESPHOME_PSTR("visual_current_temperature_step"), this->visual_current_temperature_step); @@ -1832,9 +1853,9 @@ const char *ListEntitiesWaterHeaterResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesWaterHeaterResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -1886,9 +1907,9 @@ const char *ListEntitiesNumberResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesNumberResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("min_value"), this->min_value); dump_field(out, ESPHOME_PSTR("max_value"), this->max_value); @@ -1897,7 +1918,7 @@ const char *ListEntitiesNumberResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); dump_field(out, ESPHOME_PSTR("unit_of_measurement"), this->unit_of_measurement); dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif @@ -1928,9 +1949,9 @@ const char *ListEntitiesSelectResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesSelectResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif for (const auto &it : *this->options) { dump_field(out, ESPHOME_PSTR("options"), it, 4); @@ -1967,9 +1988,9 @@ const char *ListEntitiesSirenResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesSirenResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); for (const auto &it : *this->tones) { @@ -2014,9 +2035,9 @@ const char *ListEntitiesLockResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesLockResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -2055,13 +2076,13 @@ const char *ListEntitiesButtonResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesButtonResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif @@ -2084,15 +2105,16 @@ const char *MediaPlayerSupportedFormat::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("num_channels"), this->num_channels); dump_field(out, ESPHOME_PSTR("purpose"), static_cast(this->purpose)); dump_field(out, ESPHOME_PSTR("sample_bytes"), this->sample_bytes); + dump_field(out, ESPHOME_PSTR("bitrate"), this->bitrate); return out.c_str(); } const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesMediaPlayerResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -2480,9 +2502,9 @@ const char *ListEntitiesAlarmControlPanelResponse::dump_to(DumpBuffer &out) cons MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesAlarmControlPanelResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -2519,9 +2541,9 @@ const char *ListEntitiesTextResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesTextResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -2559,9 +2581,9 @@ const char *ListEntitiesDateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesDateResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -2599,9 +2621,9 @@ const char *ListEntitiesTimeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesTimeResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -2639,13 +2661,13 @@ const char *ListEntitiesEventResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesEventResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); for (const auto &it : *this->event_types) { dump_field(out, ESPHOME_PSTR("event_types"), it, 4); } @@ -2669,13 +2691,13 @@ const char *ListEntitiesValveResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesValveResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); dump_field(out, ESPHOME_PSTR("assumed_state"), this->assumed_state); dump_field(out, ESPHOME_PSTR("supports_position"), this->supports_position); dump_field(out, ESPHOME_PSTR("supports_stop"), this->supports_stop); @@ -2711,9 +2733,9 @@ const char *ListEntitiesDateTimeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesDateTimeResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -2747,13 +2769,13 @@ const char *ListEntitiesUpdateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesUpdateResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); - dump_field(out, ESPHOME_PSTR("device_class"), this->device_class); + dump_progmem_string_field(out, ESPHOME_PSTR("device_class"), this->device_class); #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif @@ -2810,9 +2832,9 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesInfraredResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); @@ -2868,9 +2890,9 @@ const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesRadioFrequencyResponse")); dump_field(out, ESPHOME_PSTR("object_id"), this->object_id); dump_field(out, ESPHOME_PSTR("key"), this->key); - dump_field(out, ESPHOME_PSTR("name"), this->name); + dump_progmem_string_field(out, ESPHOME_PSTR("name"), this->name); #ifdef USE_ENTITY_ICON - dump_field(out, ESPHOME_PSTR("icon"), this->icon); + dump_progmem_string_field(out, ESPHOME_PSTR("icon"), this->icon); #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index e01deccbc21..9baa59f2059 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -301,7 +301,7 @@ constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128 constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384 /// Generated encode body: writes the fields at pos, returns the cursor past them. -using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM); +using ProtoEncodeFn = uint8_t *(*)(const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM); /// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and /// returns it advanced, so outlined calls at -Os chain through the return register instead of a @@ -409,6 +409,10 @@ class ProtoEncode { /// Tag must be a single-byte varint (< 128). Always encodes (no zero check). [[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, const StringRef &ref) { +#ifdef USE_ESP8266 + // memcpy_P reads RAM too, and sharing the progmem copy is smaller than an inline memcpy + return encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, tag, ProgmemStringRef(ref)); +#else #ifdef ESPHOME_DEBUG_API assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128"); #endif @@ -417,6 +421,7 @@ class ProtoEncode { pos[1] = static_cast(ref.size()); std::memcpy(pos + 2, ref.c_str(), ref.size()); return pos + 2 + ref.size(); +#endif } /// encode_short_string_force for a string that may be in flash (PROGMEM). [[nodiscard]] static inline uint8_t *encode_short_progmem_string_force( @@ -496,13 +501,22 @@ class ProtoEncode { /// encode_progmem_bytes for a string field that may be in flash. [[nodiscard]] static inline uint8_t *encode_progmem_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, const ProgmemStringRef &ref) { +#ifdef USE_ESP8266 return encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(ref.progmem_ptr()), ref.size()); +#else + // In RAM here, so share the plain string encoder + return encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.ram_ref()); +#endif } [[nodiscard]] static inline uint8_t *encode_progmem_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, const ProgmemStringRef &ref) { +#ifdef USE_ESP8266 return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(ref.progmem_ptr()), ref.size()); +#else + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.ram_ref()); +#endif } [[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) { diff --git a/esphome/components/api/user_services.cpp b/esphome/components/api/user_services.cpp index a9836170640..bf264176c9d 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -31,6 +31,13 @@ ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_( #ifdef USE_API_USER_DEFINED_ACTION_METADATA arg.description = this->str_(base + 1); arg.example = this->str_(base + 2); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS + // The default slot doubles as the optional flag: nullptr is required, "" optional without a default + const char *d = progmem_read_ptr(&this->strings_[base + USER_ACTION_ARG_BASE_STRINGS]); + arg.optional = d != nullptr; + if (d != nullptr) + arg.default_value = ProgmemStringRef(d, ESPHOME_strlen_P(d)); #endif } return msg; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 057e04dd7a7..bf6cb9d753d 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -40,13 +40,20 @@ template enums::ServiceArgType to_service_arg_type(); // All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in // __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then // each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows -// the name and every argument is (name, description, example). Unset metadata is nullptr. +// the name and every argument is (name, description, example); with +// USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS every argument ends with its default: nullptr for a +// required argument, "" for an optional one without a default. Unset is nullptr. #ifdef USE_API_USER_DEFINED_ACTION_METADATA static constexpr size_t USER_ACTION_HEADER_STRINGS = 2; -static constexpr size_t USER_ACTION_ARG_STRINGS = 3; +static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 3; #else static constexpr size_t USER_ACTION_HEADER_STRINGS = 1; -static constexpr size_t USER_ACTION_ARG_STRINGS = 1; +static constexpr size_t USER_ACTION_ARG_BASE_STRINGS = 1; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS +static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS + 1; +#else +static constexpr size_t USER_ACTION_ARG_STRINGS = USER_ACTION_ARG_BASE_STRINGS; #endif class UserServiceStatic : public UserServiceDescriptor { public: diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index c242ee39136..95f798d2b3d 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -30,10 +30,10 @@ void AQISensor::dump_config() { ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled")); if (this->pm_2_5_sensor_ != nullptr) { - ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str()); + ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", LOG_STR_ARG(this->pm_2_5_sensor_->get_log_name())); } if (this->pm_10_0_sensor_ != nullptr) { - ESP_LOGCONFIG(TAG, " PM10 Sensor: '%s'", this->pm_10_0_sensor_->get_name().c_str()); + ESP_LOGCONFIG(TAG, " PM10 Sensor: '%s'", LOG_STR_ARG(this->pm_10_0_sensor_->get_log_name())); } LOG_SENSOR(" ", "AQI", this); } diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index 7a3010b8e35..23dce193324 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -23,7 +23,6 @@ AS5600Sensor = as5600_ns.class_("AS5600Sensor", sensor.Sensor, cg.PollingCompone CONF_RAW_POSITION = "raw_position" CONF_SLOW_FILTER = "slow_filter" CONF_FAST_FILTER = "fast_filter" -CONF_PWM_FREQUENCY = "pwm_frequency" CONF_BURN_COUNT = "burn_count" CONF_START_POSITION = "start_position" CONF_END_POSITION = "end_position" diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index ab027645bc5..e83dbec1128 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -639,7 +639,7 @@ float ATM90E32Component::get_chip_temperature_() { void ATM90E32Component::press_calibration_button(EntityBase *entity, void (ATM90E32Component::*action)(), const LogString *hint, const LogString *hint2) { - ESP_LOGI(TAG, "%s", entity->get_name().c_str()); + ESP_LOGI(TAG, "%s", LOG_STR_ARG(entity->get_log_name())); if (hint != nullptr) { ESP_LOGI(TAG, "%s", LOG_STR_ARG(hint)); } @@ -1295,7 +1295,7 @@ void ATM90E32Component::check_phase_status() { if (!status.empty()) { status.pop_back(); // remove space status.pop_back(); // remove semicolon - ESP_LOGW(TAG, "%s: %s", sensor->get_name().c_str(), status.c_str()); + ESP_LOGW(TAG, "%s: %s", LOG_STR_ARG(sensor->get_log_name()), status.c_str()); sensor->publish_state(status); } else { sensor->publish_state("Okay"); diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index 0469ae453ca..b8e48805e18 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -168,6 +168,8 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { case BALLU_AUTO: this->mode = climate::CLIMATE_MODE_AUTO; break; + default: + break; } } diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp index 62e82f3146b..f813f2368fe 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.cpp +++ b/esphome/components/bedjet/climate/bedjet_climate.cpp @@ -211,7 +211,7 @@ void BedJetClimate::control(const ClimateCall &call) { } else if (fan_mode == CLIMATE_FAN_HIGH) { result = this->parent_->set_fan_speed(75); } else { - ESP_LOGW(TAG, "[%s] Unsupported fan mode: %s", this->get_name().c_str(), + ESP_LOGW(TAG, "[%s] Unsupported fan mode: %s", LOG_STR_ARG(this->get_log_name()), LOG_STR_ARG(climate_fan_mode_to_string(fan_mode))); return; } @@ -223,7 +223,7 @@ void BedJetClimate::control(const ClimateCall &call) { auto fan_mode = call.get_custom_fan_mode(); auto fan_index = bedjet_fan_speed_to_step(fan_mode.c_str()); if (fan_index <= 19) { - ESP_LOGV(TAG, "[%s] Converted fan mode %.*s to bedjet fan step %d", this->get_name().c_str(), + ESP_LOGV(TAG, "[%s] Converted fan mode %.*s to bedjet fan step %d", LOG_STR_ARG(this->get_log_name()), (int) fan_mode.size(), fan_mode.c_str(), fan_index); bool result = this->parent_->set_fan_index(fan_index); if (result) { @@ -236,7 +236,7 @@ void BedJetClimate::control(const ClimateCall &call) { void BedJetClimate::on_bedjet_state(bool is_ready) {} void BedJetClimate::on_status(const BedjetStatusPacket *data) { - ESP_LOGV(TAG, "[%s] Handling on_status with data=%p", this->get_name().c_str(), (void *) data); + ESP_LOGV(TAG, "[%s] Handling on_status with data=%p", LOG_STR_ARG(this->get_log_name()), (void *) data); auto converted_temp = bedjet_temp_to_c(data->target_temp_step); if (converted_temp > 0) @@ -310,11 +310,11 @@ void BedJetClimate::on_status(const BedjetStatusPacket *data) { break; default: - ESP_LOGW(TAG, "[%s] Unexpected mode: 0x%02X", this->get_name().c_str(), data->mode); + ESP_LOGW(TAG, "[%s] Unexpected mode: 0x%02X", LOG_STR_ARG(this->get_log_name()), data->mode); break; } - ESP_LOGV(TAG, "[%s] After on_status, new mode=%s", this->get_name().c_str(), + ESP_LOGV(TAG, "[%s] After on_status, new mode=%s", LOG_STR_ARG(this->get_log_name()), LOG_STR_ARG(climate_mode_to_string(this->mode))); // FIXME: compare new state to previous state. this->publish_state(); @@ -351,11 +351,11 @@ bool BedJetClimate::update_status_() { } void BedJetClimate::update() { - ESP_LOGD(TAG, "[%s] update()", this->get_name().c_str()); + ESP_LOGD(TAG, "[%s] update()", LOG_STR_ARG(this->get_log_name())); // TODO: if the hub component is already polling, do we also need to include polling? // We're already going to get on_status() at the hub's polling interval. auto result = this->update_status_(); - ESP_LOGD(TAG, "[%s] update_status result=%s", this->get_name().c_str(), result ? "true" : "false"); + ESP_LOGD(TAG, "[%s] update_status result=%s", LOG_STR_ARG(this->get_log_name()), result ? "true" : "false"); } } // namespace esphome::bedjet diff --git a/esphome/components/bedjet/fan/bedjet_fan.cpp b/esphome/components/bedjet/fan/bedjet_fan.cpp index 4b1bd14ae34..1f0762e40ce 100644 --- a/esphome/components/bedjet/fan/bedjet_fan.cpp +++ b/esphome/components/bedjet/fan/bedjet_fan.cpp @@ -53,7 +53,7 @@ void BedJetFan::control(const fan::FanCall &call) { } void BedJetFan::on_status(const BedjetStatusPacket *data) { - ESP_LOGVV(TAG, "[%s] Handling on_status with data=%p", this->get_name().c_str(), (void *) data); + ESP_LOGVV(TAG, "[%s] Handling on_status with data=%p", LOG_STR_ARG(this->get_log_name()), (void *) data); bool did_change = false; bool new_state = data->mode != MODE_STANDBY && data->mode != MODE_WAIT; @@ -96,11 +96,11 @@ bool BedJetFan::update_status_() { } void BedJetFan::update() { - ESP_LOGD(TAG, "[%s] update()", this->get_name().c_str()); + ESP_LOGD(TAG, "[%s] update()", LOG_STR_ARG(this->get_log_name())); // TODO: if the hub component is already polling, do we also need to include polling? // We're already going to get on_status() at the hub's polling interval. auto result = this->update_status_(); - ESP_LOGD(TAG, "[%s] update_status result=%s", this->get_name().c_str(), result ? "true" : "false"); + ESP_LOGD(TAG, "[%s] update_status result=%s", LOG_STR_ARG(this->get_log_name()), result ? "true" : "false"); } /** Resets states to defaults. */ diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index 94ee60ea8d3..131e4643222 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -151,7 +151,7 @@ void BH1750Sensor::loop() { break; } - ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); + ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", LOG_STR_ARG(this->get_log_name()), lx); this->status_clear_warning(); this->publish_state(lx); this->state_ = IDLE; diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 55793436f2d..db128cf7b1a 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -36,7 +36,7 @@ bool BinarySensor::set_new_state(const optional &new_state) { #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_binary_sensor_update(this); #endif - ESP_LOGV(TAG, "'%s' >> %s", this->get_name().c_str(), ONOFFMAYBE(new_state)); + ESP_LOGV(TAG, "'%s' >> %s", LOG_STR_ARG(this->get_log_name()), ONOFFMAYBE(new_state)); return true; } return false; diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 4f00a85c8dd..78270046e06 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -47,7 +47,7 @@ void BinarySensorMap::process_group_() { } } else if (this->last_mask_ != 0ULL) { // no buttons are pressed and the states have changed since last run, so publish NAN - ESP_LOGV(TAG, "'%s' - No binary sensor active, publishing NAN", this->name_.c_str()); + ESP_LOGV(TAG, "'%s' - No binary sensor active, publishing NAN", LOG_STR_ARG(this->get_log_name())); this->publish_state(NAN); } diff --git a/esphome/components/bl0906/constants.h b/esphome/components/bl0906/constants.h index 423d460210c..3cd939475ba 100644 --- a/esphome/components/bl0906/constants.h +++ b/esphome/components/bl0906/constants.h @@ -111,10 +111,9 @@ static const uint8_t BL0906_USR_WRPROT = 0x9E; // Reset Register static const uint8_t BL0906_SOFT_RESET = 0x9F; -const uint8_t BL0906_INIT[2][6] = { - // Reset to default - {BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52}, - // Enable User Operation Write - {BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}}; +const uint8_t BL0906_INIT[2][6] = {// Reset to default + {BL0906_WRITE_COMMAND, BL0906_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x52}, + // Enable User Operation Write + {BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}}; } // namespace esphome::bl0906 diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index 82153fa5b43..5f09ec741bf 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -192,7 +192,7 @@ float BL0940::update_temp_(sensor::Sensor *sensor, uint16_le_t temperature) cons if (sensor != nullptr) { if (sensor->has_state() && std::abs(converted_temp - sensor->get_state()) > max_temperature_diff_) { ESP_LOGD(TAG, "Invalid temperature change. Sensor: '%s', Old temperature: %f, New temperature: %f", - sensor->get_name().c_str(), sensor->get_state(), converted_temp); + LOG_STR_ARG(sensor->get_log_name()), sensor->get_state(), converted_temp); return 0.0f; } sensor->publish_state(converted_temp); diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp index 69e7450dc20..f3f3b180fe9 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp @@ -63,7 +63,7 @@ void BLEClientRSSISensor::gap_event_handler(esp_gap_ble_cb_event_t event, esp_bl void BLEClientRSSISensor::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, not connected", LOG_STR_ARG(this->get_log_name())); this->should_update_ = true; return; } diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 1ddd50c3079..cca77e6fd79 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -37,13 +37,13 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga switch (event) { case ESP_GATTC_OPEN_EVT: { if (param->open.status == ESP_GATT_OK) { - ESP_LOGI(TAG, "[%s] Connected successfully!", this->get_name().c_str()); + ESP_LOGI(TAG, "[%s] Connected successfully!", LOG_STR_ARG(this->get_log_name())); break; } break; } case ESP_GATTC_CLOSE_EVT: { - ESP_LOGW(TAG, "[%s] Disconnected!", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Disconnected!", LOG_STR_ARG(this->get_log_name())); this->status_set_warning(); this->publish_state(NAN); break; @@ -102,10 +102,10 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga } case ESP_GATTC_NOTIFY_EVT: { if (param->notify.value_len == 0) { - ESP_LOGW(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: empty value", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: empty value", LOG_STR_ARG(this->get_log_name())); break; } - ESP_LOGD(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: handle=0x%x, value=0x%x", this->get_name().c_str(), + ESP_LOGD(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: handle=0x%x, value=0x%x", LOG_STR_ARG(this->get_log_name()), param->notify.handle, param->notify.value[0]); if (param->notify.handle != this->handle) break; @@ -143,11 +143,11 @@ float BLESensor::parse_data_(uint8_t *value, uint16_t value_len) { void BLESensor::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, not connected", LOG_STR_ARG(this->get_log_name())); return; } if (this->handle == 0) { - ESP_LOGW(TAG, "[%s] Cannot poll, no service or characteristic found", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, no service or characteristic found", LOG_STR_ARG(this->get_log_name())); return; } @@ -156,7 +156,7 @@ void BLESensor::update() { if (status) { this->status_set_warning(); this->publish_state(NAN); - ESP_LOGW(TAG, "[%s] Error sending read request for sensor, status=%d", this->get_name().c_str(), status); + ESP_LOGW(TAG, "[%s] Error sending read request for sensor, status=%d", LOG_STR_ARG(this->get_log_name()), status); } } diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index f6df7b43364..c82822d4296 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -38,7 +38,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ switch (event) { case ESP_GATTC_OPEN_EVT: { if (param->open.status == ESP_GATT_OK) { - ESP_LOGI(TAG, "[%s] Connected successfully!", this->get_name().c_str()); + ESP_LOGI(TAG, "[%s] Connected successfully!", LOG_STR_ARG(this->get_log_name())); break; } break; @@ -104,10 +104,10 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (param->notify.handle != this->handle) break; if (param->notify.value_len == 0) { - ESP_LOGW(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: empty value", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: empty value", LOG_STR_ARG(this->get_log_name())); break; } - ESP_LOGV(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: handle=0x%x, value=0x%x", this->get_name().c_str(), + ESP_LOGV(TAG, "[%s] ESP_GATTC_NOTIFY_EVT: handle=0x%x, value=0x%x", LOG_STR_ARG(this->get_log_name()), param->notify.handle, param->notify.value[0]); this->publish_state(reinterpret_cast(param->notify.value), param->notify.value_len); break; @@ -124,11 +124,11 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ void BLETextSensor::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, not connected", LOG_STR_ARG(this->get_log_name())); return; } if (this->handle == 0) { - ESP_LOGW(TAG, "[%s] Cannot poll, no service or characteristic found", this->get_name().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, no service or characteristic found", LOG_STR_ARG(this->get_log_name())); return; } @@ -137,7 +137,7 @@ void BLETextSensor::update() { if (status) { this->status_set_warning(); this->publish_state(""); - ESP_LOGW(TAG, "[%s] Error sending read request for sensor, status=%d", this->get_name().c_str(), status); + ESP_LOGW(TAG, "[%s] Error sending read request for sensor, status=%d", LOG_STR_ARG(this->get_log_name()), status); } } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index b642c544c56..67a6ff35ab7 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -268,6 +268,7 @@ int BluedroidGattClient::discover_services() { case SearchState::REPORT_PENDING: return 0; // One completion is already owed to this claimant. case SearchState::NONE: + default: break; } int err = this->check_and_log_error_("esp_ble_gattc_search_service", diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 4c87b876c3a..a55d0e2917b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -22,8 +22,8 @@ class BluetoothProxy; namespace esphome::bluetooth_connection { -using ClientState = ble_device_base::ClientState; -using ConnectionType = ble_device_base::ConnectionType; +using ble_device_base::ClientState; +using ble_device_base::ConnectionType; /// A refused GATT reply owed to the current subscriber. Payload-free only: /// these rebuild from address + handle + error, so a retry costs no buffered diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 567109dc60b..a47a2cd3d1e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -30,8 +30,8 @@ using bluetooth_connection::INIT_SENDING_SERVICES; using bluetooth_connection::SERVICES_DONE_PENDING; #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -using BluetoothConnection = bluetooth_connection::BluetoothConnection; -using ClientState = ble_device_base::ClientState; +using bluetooth_connection::BluetoothConnection; +using ble_device_base::ClientState; #endif // Legacy versions: diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index 2a522c7a8ce..e5f8b130e47 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -123,7 +123,7 @@ void BMI160Component::internal_setup_(int stage) { bool BMI160Component::setup_stage_(int stage) { switch (stage) { - case 0: + case 0: { uint8_t chipid; if (!this->read_byte(BMI160_REGISTER_CHIPID, &chipid) || (chipid != 0b11010001)) { return false; @@ -138,7 +138,7 @@ bool BMI160Component::setup_stage_(int stage) { // min 5ms, 10ms this->set_timeout(10, [this]() { this->internal_setup_(1); }); break; - + } case 1: ESP_LOGV(TAG, " Bringing gyroscope out of sleep"); if (!this->write_byte(BMI160_REGISTER_CMD, (uint8_t) Cmd::GYRO_SET_PMU_MODE | (uint8_t) GyroPmuMode::NORMAL)) { @@ -149,7 +149,7 @@ bool BMI160Component::setup_stage_(int stage) { this->set_timeout(GYRO_WAKEUP_TIMEOUT_MS, [this]() { this->internal_setup_(2); }); break; - case 2: + case 2: { ESP_LOGV(TAG, " Setting up Gyro Config"); uint8_t gyro_config = (uint8_t) GyroBandwidth::OSR4 | (uint8_t) GyroOuputDataRate::HZ_25; ESP_LOGV(TAG, " Output gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); @@ -178,6 +178,10 @@ bool BMI160Component::setup_stage_(int stage) { } this->setup_complete_ = true; + break; + } + default: + break; } return true; } diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index 01f6a385e65..e7f8b81a2b7 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -14,7 +14,7 @@ void log_button(const char *tag, const char *prefix, const char *type, Button *o } void Button::press() { - ESP_LOGV(TAG, "'%s' Pressed.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Pressed.", LOG_STR_ARG(this->get_log_name())); this->press_action(); this->press_callback_.call(); } diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 4f2ca2ad266..ef14c02c58e 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -295,11 +295,6 @@ async def to_code(config): ) -# Actions -SendPacketAction = ns.class_( - "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) -) - CC1101_ACTION_SCHEMA = cv.Schema( maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)}) ) @@ -338,23 +333,11 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "cc1101.send_packet", - SendPacketAction, SEND_PACKET_ACTION_SCHEMA, - synchronous=True, + automation.ApplyBytes(CONF_DATA, "transmit_packet({})"), ) -async def send_packet_action_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - await automation.templatable_bytes( - config[CONF_DATA], - args, - var.set_data_template, - var.set_data_static, - "cc1101_data", - ) - return var _CONFIG_VALIDATORS = {opt.schema: validator for opt, validator in CONFIG_MAP.items()} diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index d7065c60487..042541d5201 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -108,7 +108,7 @@ class CC1101Component final : public Component, // GDO pin for packet reception InternalGPIOPin *gdo0_pin_{nullptr}; - static void IRAM_ATTR gpio_intr(CC1101Component *arg); + static void gpio_intr(CC1101Component *arg); // Packet handling void call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi); @@ -132,13 +132,4 @@ class CC1101Component final : public Component, bool enter_tx_(); }; -// Action Wrappers -template class SendPacketAction final : public Action, public Parented { - TEMPLATABLE_BYTES(data) - - void play(const Ts &...x) override { - this->data_.visit([this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); - } -}; - } // namespace esphome::cc1101 diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py index 40d44747430..19439356ba9 100644 --- a/esphome/components/cdc_acm_uart/bridge/__init__.py +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -2,6 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import esp32, uart, usb_cdc_acm from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN +from esphome.components.const import CONF_DTR_PIN, CONF_RTS_PIN from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 import esphome.config_validation as cv from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID @@ -11,8 +12,6 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"] -CONF_DTR_PIN = "dtr_pin" -CONF_RTS_PIN = "rts_pin" CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id" cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart") diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 87cedae9944..0ed1e164467 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -46,7 +46,7 @@ constexpr StringToUint8 CLIMATE_SWING_MODES_BY_STR[] = { void ClimateCall::perform() { this->parent_->control_callback_.call(*this); - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); this->validate_(); if (this->mode_.has_value()) { const LogString *mode_s = climate_mode_to_string(*this->mode_); @@ -183,7 +183,7 @@ ClimateCall &ClimateCall::set_mode(const char *mode, size_t len) { return *this; } } - ESP_LOGW(TAG, "'%s' - Unrecognized mode %.*s", this->parent_->get_name().c_str(), (int) len, mode); + ESP_LOGW(TAG, "'%s' - Unrecognized mode %.*s", LOG_STR_ARG(this->parent_->get_log_name()), (int) len, mode); return *this; } @@ -214,7 +214,8 @@ ClimateCall &ClimateCall::set_fan_mode(const char *custom_fan_mode, size_t len) this->fan_mode_.reset(); return *this; } - ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %.*s", this->parent_->get_name().c_str(), (int) len, custom_fan_mode); + ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %.*s", LOG_STR_ARG(this->parent_->get_log_name()), (int) len, + custom_fan_mode); return *this; } @@ -252,7 +253,8 @@ ClimateCall &ClimateCall::set_preset(const char *custom_preset, size_t len) { this->preset_.reset(); return *this; } - ESP_LOGW(TAG, "'%s' - Unrecognized preset %.*s", this->parent_->get_name().c_str(), (int) len, custom_preset); + ESP_LOGW(TAG, "'%s' - Unrecognized preset %.*s", LOG_STR_ARG(this->parent_->get_log_name()), (int) len, + custom_preset); return *this; } @@ -280,7 +282,8 @@ ClimateCall &ClimateCall::set_swing_mode(const char *swing_mode, size_t len) { return *this; } } - ESP_LOGW(TAG, "'%s' - Unrecognized swing mode %.*s", this->parent_->get_name().c_str(), (int) len, swing_mode); + ESP_LOGW(TAG, "'%s' - Unrecognized swing mode %.*s", LOG_STR_ARG(this->parent_->get_log_name()), (int) len, + swing_mode); return *this; } @@ -428,7 +431,7 @@ void Climate::save_state_(const ClimateTraits &traits) { void Climate::publish_state() { this->set_has_state(true); - ESP_LOGV(TAG, "'%s' >>", this->name_.c_str()); + ESP_LOGV(TAG, "'%s' >>", LOG_STR_ARG(this->get_log_name())); auto traits = this->get_traits(); ESP_LOGV(TAG, " Mode: %s", LOG_STR_ARG(climate_mode_to_string(this->mode))); @@ -547,7 +550,7 @@ void ClimateDeviceRestoreState::apply(Climate *climate) { if (traits.supports_mode(this->mode)) { climate->mode = this->mode; } else { - ESP_LOGW(TAG, "'%s' - Saved mode %s is no longer supported, keeping %s", climate->get_name().c_str(), + ESP_LOGW(TAG, "'%s' - Saved mode %s is no longer supported, keeping %s", LOG_STR_ARG(climate->get_log_name()), LOG_STR_ARG(climate_mode_to_string(this->mode)), LOG_STR_ARG(climate_mode_to_string(climate->mode))); } if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 7c193c07f6e..05c45333278 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -79,6 +79,10 @@ class ClimateCall { ClimateCall &set_fan_mode(optional fan_mode); /// Set the custom fan mode of the climate device. ClimateCall &set_fan_mode(const char *custom_fan_mode); + /// Set the custom fan mode of the climate device (StringRef overload to resolve ambiguity, zero-copy). + ClimateCall &set_fan_mode(StringRef custom_fan_mode) { + return this->set_fan_mode(custom_fan_mode.c_str(), custom_fan_mode.size()); + } /// Set the custom fan mode of the climate device (zero-copy API path). ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len); /// Set the swing mode of the climate device. @@ -99,6 +103,10 @@ class ClimateCall { ClimateCall &set_preset(optional preset); /// Set the custom preset of the climate device. ClimateCall &set_preset(const char *custom_preset); + /// Set the custom preset of the climate device (StringRef overload to resolve ambiguity, zero-copy). + ClimateCall &set_preset(StringRef custom_preset) { + return this->set_preset(custom_preset.c_str(), custom_preset.size()); + } /// Set the custom preset of the climate device (zero-copy API path). ClimateCall &set_preset(const char *custom_preset, size_t len); diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index 0488fbb6ab2..dcdabec8a27 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -30,14 +30,14 @@ void CombinationOneParameterComponent::add_source(Sensor *sensor, float value) { void CombinationNoParameterComponent::log_source_sensors() { ESP_LOGCONFIG(TAG, " Source Sensors:"); for (const auto &sensor : this->sensors_) { - ESP_LOGCONFIG(TAG, " - %s", sensor->get_name().c_str()); + ESP_LOGCONFIG(TAG, " - %s", LOG_STR_ARG(sensor->get_log_name())); } } void CombinationOneParameterComponent::log_source_sensors() { ESP_LOGCONFIG(TAG, " Source Sensors:"); for (const auto &source : this->sensor_sources_) { - ESP_LOGCONFIG(TAG, " - %s", source.sensor->get_name().c_str()); + ESP_LOGCONFIG(TAG, " - %s", LOG_STR_ARG(source.sensor->get_log_name())); } } diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index b58db9aa388..26d3f2d3aa3 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -33,6 +33,7 @@ CONF_DATA_BITS = "data_bits" CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" CONF_DRIVE_STRENGTH = "drive_strength" +CONF_DTR_PIN = "dtr_pin" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_ERROR_CODE = "error_code" @@ -40,6 +41,7 @@ CONF_GREEN_CHANNEL = "green_channel" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_HOLD_STATE = "hold_state" +CONF_HORIZONTAL_DEFAULT = "horizontal_default" CONF_HOST = "host" CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" @@ -58,9 +60,11 @@ CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" +CONF_OUT_PIN = "out_pin" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" CONF_PROVIDER = "provider" +CONF_PWM_FREQUENCY = "pwm_frequency" CONF_QUERY_PARAMS = "query_params" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" @@ -70,6 +74,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" CONF_RST_PIN = "rst_pin" +CONF_RTS_PIN = "rts_pin" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SERIAL_NUMBER = "serial_number" CONF_SHA256 = "sha256" @@ -81,6 +86,7 @@ CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" CONF_VALUE_TYPE = "value_type" CONF_VERIFY_SSL = "verify_ssl" +CONF_VERTICAL_DEFAULT = "vertical_default" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" diff --git a/esphome/components/copy/cover/copy_cover.cpp b/esphome/components/copy/cover/copy_cover.cpp index 71bdef667e9..da17cd78c80 100644 --- a/esphome/components/copy/cover/copy_cover.cpp +++ b/esphome/components/copy/cover/copy_cover.cpp @@ -31,12 +31,14 @@ cover::CoverTraits CopyCover::get_traits() { traits.set_supports_position(base.get_supports_position()); traits.set_supports_tilt(base.get_supports_tilt()); traits.set_supports_toggle(base.get_supports_toggle()); + traits.set_supports_stop_tilt(base.get_supports_stop_tilt()); return traits; } void CopyCover::control(const cover::CoverCall &call) { auto call2 = source_->make_call(); call2.set_stop(call.get_stop()); + call2.set_stop_tilt(call.get_stop_tilt()); auto tilt = call.get_tilt(); if (tilt.has_value()) call2.set_tilt(*tilt); diff --git a/esphome/components/copy/lock/copy_lock.cpp b/esphome/components/copy/lock/copy_lock.cpp index d8b0b17f84b..1c1ae1e6c6e 100644 --- a/esphome/components/copy/lock/copy_lock.cpp +++ b/esphome/components/copy/lock/copy_lock.cpp @@ -8,10 +8,7 @@ ESPHOME_LOG_TAG(TAG, "copy.lock"); void CopyLock::setup() { source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); }); - traits.set_assumed_state(source_->traits.get_assumed_state()); - traits.set_requires_code(source_->traits.get_requires_code()); - traits.set_supported_states_mask(source_->traits.get_supported_states_mask()); - traits.set_supports_open(source_->traits.get_supports_open()); + this->traits = this->source_->traits; this->publish_state(source_->state); } diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 7bdd9000094..197eaaf2206 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -99,6 +99,7 @@ CONF_ON_CLOSED = "on_closed" CONF_ON_OPENED = "on_opened" CONF_ON_OPENING = "on_opening" CONF_ON_CLOSING = "on_closing" +CONF_STOP_TILT = "stop_tilt" TRIGGERS = { CONF_ON_OPEN: CoverOpenedTrigger, # Deprecated, use on_opened @@ -248,6 +249,7 @@ for _name, _command in ( ("cover.open", "set_command_open()"), ("cover.close", "set_command_close()"), ("cover.stop", "set_command_stop()"), + ("cover.stop_tilt", "set_command_stop_tilt()"), ("cover.toggle", "set_command_toggle()"), ): automation.register_apply_action( @@ -259,6 +261,7 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.use_id(Cover), cv.Optional(CONF_STOP): cv.templatable(cv.boolean), + cv.Optional(CONF_STOP_TILT): cv.templatable(cv.boolean), cv.Exclusive(CONF_STATE, "pos"): cv.templatable(validate_cover_state), cv.Exclusive(CONF_POSITION, "pos"): cv.templatable(cv.percentage), cv.Optional(CONF_TILT): cv.templatable(cv.percentage), @@ -272,6 +275,7 @@ automation.register_apply_action( "cover.control", COVER_CONTROL_ACTION_SCHEMA, automation.ApplyField(CONF_STOP, "set_stop", cg.bool_), + automation.ApplyField(CONF_STOP_TILT, "set_stop_tilt", cg.bool_), automation.ApplyField(CONF_STATE, "set_position", cg.float_), automation.ApplyField(CONF_POSITION, "set_position", cg.float_), automation.ApplyField(CONF_TILT, "set_tilt", cg.float_), diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 30e7f70b02b..54750755b10 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -39,7 +39,7 @@ CoverCall &CoverCall::set_command(const char *command) { } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("TOGGLE")) == 0) { this->set_command_toggle(); } else { - ESP_LOGW(TAG, "'%s' - Unrecognized command %s", this->parent_->get_name().c_str(), command); + ESP_LOGW(TAG, "'%s' - Unrecognized command %s", LOG_STR_ARG(this->parent_->get_log_name()), command); } return *this; } @@ -55,6 +55,10 @@ CoverCall &CoverCall::set_command_stop() { this->stop_ = true; return *this; } +CoverCall &CoverCall::set_command_stop_tilt() { + this->stop_tilt_ = true; + return *this; +} CoverCall &CoverCall::set_command_toggle() { this->toggle_ = true; return *this; @@ -68,12 +72,15 @@ CoverCall &CoverCall::set_tilt(float tilt) { return *this; } void CoverCall::perform() { - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); auto traits = this->parent_->get_traits(); this->validate_(); if (this->stop_) { ESP_LOGV(TAG, " Command: STOP"); } + if (this->stop_tilt_) { + ESP_LOGV(TAG, " Command: STOP_TILT"); + } if (this->position_.has_value()) { if (traits.get_supports_position()) { ESP_LOGV(TAG, " Position: %.0f%%", *this->position_ * 100.0f); @@ -94,7 +101,7 @@ const optional &CoverCall::get_tilt() const { return this->tilt_; } const optional &CoverCall::get_toggle() const { return this->toggle_; } void CoverCall::validate_() { auto traits = this->parent_->get_traits(); - const char *name = this->parent_->get_name().c_str(); + const char *name = LOG_STR_ARG(this->parent_->get_log_name()); if (this->position_.has_value()) { auto pos = *this->position_; @@ -122,6 +129,15 @@ void CoverCall::validate_() { this->toggle_.reset(); } } + if (this->stop_tilt_) { + if (!traits.get_supports_stop_tilt()) { + ESP_LOGW(TAG, "'%s': stop tilt unsupported", name); + this->stop_tilt_ = false; + } else if (this->tilt_.has_value()) { + ESP_LOGW(TAG, "'%s': cannot tilt when stopping tilt", name); + this->tilt_.reset(); + } + } if (this->stop_) { if (this->position_.has_value() || this->tilt_.has_value() || this->toggle_.has_value()) { ESP_LOGW(TAG, "'%s': cannot position/tilt/toggle when stopping", name); @@ -135,11 +151,15 @@ CoverCall &CoverCall::set_stop(bool stop) { this->stop_ = stop; return *this; } +CoverCall &CoverCall::set_stop_tilt(bool stop_tilt) { + this->stop_tilt_ = stop_tilt; + return *this; +} void Cover::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); this->tilt = clamp(this->tilt, 0.0f, 1.0f); - ESP_LOGV(TAG, "'%s' >>", this->name_.c_str()); + ESP_LOGV(TAG, "'%s' >>", LOG_STR_ARG(this->get_log_name())); auto traits = this->get_traits(); if (traits.get_supports_position()) { ESP_LOGV(TAG, " Position: %.0f%%", this->position * 100.0f); diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index db59c371ff9..fe1058c9d91 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -36,6 +36,8 @@ class CoverCall { CoverCall &set_command_close(); /// Set the command to stop the cover. CoverCall &set_command_stop(); + /// Set the command to stop the cover tilt. + CoverCall &set_command_stop_tilt(); /// Set the command to toggle the cover. CoverCall &set_command_toggle(); /// Set the call to a certain target position. @@ -44,12 +46,15 @@ class CoverCall { CoverCall &set_tilt(float tilt); /// Set whether this cover call should stop the cover. CoverCall &set_stop(bool stop); + /// Set whether this cover call should stop the cover tilt. + CoverCall &set_stop_tilt(bool stop_tilt); /// Perform the cover call. void perform(); const optional &get_position() const; bool get_stop() const { return this->stop_; } + bool get_stop_tilt() const { return this->stop_tilt_; } const optional &get_tilt() const; const optional &get_toggle() const; @@ -58,6 +63,7 @@ class CoverCall { Cover *parent_; bool stop_{false}; + bool stop_tilt_{false}; optional position_{}; optional tilt_{}; optional toggle_{}; diff --git a/esphome/components/cover/cover_traits.h b/esphome/components/cover/cover_traits.h index 723516318b1..c872a0f1262 100644 --- a/esphome/components/cover/cover_traits.h +++ b/esphome/components/cover/cover_traits.h @@ -1,28 +1,37 @@ #pragma once +#include + namespace esphome::cover { class CoverTraits { public: CoverTraits() = default; - bool get_is_assumed_state() const { return this->is_assumed_state_; } - void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; } - bool get_supports_position() const { return this->supports_position_; } - void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; } - bool get_supports_tilt() const { return this->supports_tilt_; } - void set_supports_tilt(bool supports_tilt) { this->supports_tilt_ = supports_tilt; } - bool get_supports_toggle() const { return this->supports_toggle_; } - void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; } - bool get_supports_stop() const { return this->supports_stop_; } - void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; } + bool get_is_assumed_state() const { return this->flags_.is_assumed_state; } + void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; } + bool get_supports_position() const { return this->flags_.supports_position; } + void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; } + bool get_supports_tilt() const { return this->flags_.supports_tilt; } + void set_supports_tilt(bool supports_tilt) { this->flags_.supports_tilt = supports_tilt; } + bool get_supports_toggle() const { return this->flags_.supports_toggle; } + void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; } + bool get_supports_stop() const { return this->flags_.supports_stop; } + void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; } + bool get_supports_stop_tilt() const { return this->flags_.supports_stop_tilt; } + void set_supports_stop_tilt(bool supports_stop_tilt) { this->flags_.supports_stop_tilt = supports_stop_tilt; } protected: - bool is_assumed_state_{false}; - bool supports_position_{false}; - bool supports_tilt_{false}; - bool supports_toggle_{false}; - bool supports_stop_{false}; + // Bit-packed flags to save memory (1 byte instead of 6) + struct CoverTraitFlags { + uint8_t is_assumed_state : 1; + uint8_t supports_position : 1; + uint8_t supports_tilt : 1; + uint8_t supports_toggle : 1; + uint8_t supports_stop : 1; + uint8_t supports_stop_tilt : 1; + uint8_t reserved : 2; + } flags_{}; }; } // namespace esphome::cover diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 8ec520b7396..d73b11d0753 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -175,6 +175,8 @@ uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) { case POWER_PAC: coeff = this->data_.coefficient[POWER_PAC] * scale; return coeff ? 0x80000000 / coeff : 0; + default: + break; } return 0; } diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.cpp b/esphome/components/ct_clamp/ct_clamp_sensor.cpp index 6488c468c34..4c50c5ad045 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.cpp +++ b/esphome/components/ct_clamp/ct_clamp_sensor.cpp @@ -40,7 +40,8 @@ void CTClampSensor::update() { if (rms_ac_squared > 0) rms_ac = std::sqrt(rms_ac_squared); ESP_LOGD(TAG, "'%s' - Raw AC Value: %.3fA after %" PRIu32 " different samples (%" PRIu32 " SPS)", - this->name_.c_str(), rms_ac, this->num_samples_, 1000 * this->num_samples_ / this->sample_duration_); + LOG_STR_ARG(this->get_log_name()), rms_ac, this->num_samples_, + 1000 * this->num_samples_ / this->sample_duration_); this->publish_state(rms_ac); }); diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index 04743975715..0c8e435fa23 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -70,20 +70,20 @@ void CurrentBasedCover::loop() { this->direction_idle_(); this->malfunction_trigger_.trigger(); ESP_LOGI(TAG, "'%s' - Malfunction detected during opening. Current flow detected in close circuit", - this->name_.c_str()); + LOG_STR_ARG(this->get_log_name())); } else if (this->is_opening_blocked_()) { // Blocked - ESP_LOGD(TAG, "'%s' - Obstacle detected during opening.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Obstacle detected during opening.", LOG_STR_ARG(this->get_log_name())); this->direction_idle_(); if (this->obstacle_rollback_ != 0) { this->set_timeout(ROLLBACK_TIMEOUT_ID, 300, [this]() { - ESP_LOGD(TAG, "'%s' - Rollback.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Rollback.", LOG_STR_ARG(this->get_log_name())); this->target_position_ = clamp(this->position - this->obstacle_rollback_, 0.0F, 1.0F); this->start_direction_(COVER_OPERATION_CLOSING); }); } } else if (this->is_initial_delay_finished_() && !this->is_opening_()) { // End reached auto dur = (now - this->start_dir_time_) / 1e3f; - ESP_LOGD(TAG, "'%s' - Open position reached. Took %.1fs.", this->name_.c_str(), dur); + ESP_LOGD(TAG, "'%s' - Open position reached. Took %.1fs.", LOG_STR_ARG(this->get_log_name()), dur); this->direction_idle_(COVER_OPEN); } } else if (this->current_operation == COVER_OPERATION_CLOSING) { @@ -91,25 +91,25 @@ void CurrentBasedCover::loop() { this->direction_idle_(); this->malfunction_trigger_.trigger(); ESP_LOGI(TAG, "'%s' - Malfunction detected during closing. Current flow detected in open circuit", - this->name_.c_str()); + LOG_STR_ARG(this->get_log_name())); } else if (this->is_closing_blocked_()) { // Blocked - ESP_LOGD(TAG, "'%s' - Obstacle detected during closing.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Obstacle detected during closing.", LOG_STR_ARG(this->get_log_name())); this->direction_idle_(); if (this->obstacle_rollback_ != 0) { this->set_timeout(ROLLBACK_TIMEOUT_ID, 300, [this]() { - ESP_LOGD(TAG, "'%s' - Rollback.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Rollback.", LOG_STR_ARG(this->get_log_name())); this->target_position_ = clamp(this->position + this->obstacle_rollback_, 0.0F, 1.0F); this->start_direction_(COVER_OPERATION_OPENING); }); } } else if (this->is_initial_delay_finished_() && !this->is_closing_()) { // End reached auto dur = (now - this->start_dir_time_) / 1e3f; - ESP_LOGD(TAG, "'%s' - Close position reached. Took %.1fs.", this->name_.c_str(), dur); + ESP_LOGD(TAG, "'%s' - Close position reached. Took %.1fs.", LOG_STR_ARG(this->get_log_name()), dur); this->direction_idle_(COVER_CLOSED); } } if (now - this->start_dir_time_ > this->max_duration_) { - ESP_LOGD(TAG, "'%s' - Max duration reached. Stopping cover.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Max duration reached. Stopping cover.", LOG_STR_ARG(this->get_log_name())); this->direction_idle_(); } diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 4514624c75c..b7ac705de47 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -54,7 +54,7 @@ void DallasTemperatureSensor::update() { } float tempc = this->get_temp_c_(); - ESP_LOGD(TAG, "'%s': Got Temperature=%f°C", this->get_name().c_str(), tempc); + ESP_LOGD(TAG, "'%s': Got Temperature=%f°C", LOG_STR_ARG(this->get_log_name()), tempc); this->publish_state(tempc); }); } @@ -66,7 +66,7 @@ bool DallasTemperatureSensor::read_scratch_pad_() { i = this->bus_->read8(); } } else { - ESP_LOGW(TAG, "'%s' - reading scratch pad failed bus reset", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s' - reading scratch pad failed bus reset", LOG_STR_ARG(this->get_log_name())); this->status_set_warning(LOG_STR("bus reset failed")); } return success; diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index 795d54afd90..91b7303d6c4 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -30,7 +30,7 @@ void DateEntity::publish_state() { return; } this->set_has_state(true); - ESP_LOGV(TAG, "'%s' >> %d-%d-%d", this->get_name().c_str(), this->year_, this->month_, this->day_); + ESP_LOGV(TAG, "'%s' >> %d-%d-%d", LOG_STR_ARG(this->get_log_name()), this->year_, this->month_, this->day_); this->state_callback_.call(); #if defined(USE_DATETIME_DATE) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_date_update(this); @@ -81,7 +81,7 @@ void DateCall::validate_() { void DateCall::perform() { this->validate_(); - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); if (this->year_.has_value()) { ESP_LOGV(TAG, " Year: %d", *this->year_); diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index 6aa14163430..b3bb7c12eaa 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -45,7 +45,7 @@ void DateTimeEntity::publish_state() { return; } this->set_has_state(true); - ESP_LOGV(TAG, "'%s' >> %04u-%02u-%02u %02d:%02d:%02d", this->get_name().c_str(), this->year_, this->month_, + ESP_LOGV(TAG, "'%s' >> %04u-%02u-%02u %02d:%02d:%02d", LOG_STR_ARG(this->get_log_name()), this->year_, this->month_, this->day_, this->hour_, this->minute_, this->second_); this->state_callback_.call(); #if defined(USE_DATETIME_DATETIME) && defined(USE_CONTROLLER_REGISTRY) @@ -125,7 +125,7 @@ void DateTimeCall::validate_() { void DateTimeCall::perform() { this->validate_(); - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); if (this->year_.has_value()) { ESP_LOGV(TAG, " Year: %d", *this->year_); diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index ea1a97256cd..8dcbe0ee1ca 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -26,7 +26,7 @@ void TimeEntity::publish_state() { return; } this->set_has_state(true); - ESP_LOGV(TAG, "'%s' >> %02d:%02d:%02d", this->get_name().c_str(), this->hour_, this->minute_, this->second_); + ESP_LOGV(TAG, "'%s' >> %02d:%02d:%02d", LOG_STR_ARG(this->get_log_name()), this->hour_, this->minute_, this->second_); this->state_callback_.call(); #if defined(USE_DATETIME_TIME) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_time_update(this); @@ -50,7 +50,7 @@ void TimeCall::validate_() { void TimeCall::perform() { this->validate_(); - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); if (this->hour_.has_value()) { ESP_LOGV(TAG, " Hour: %d", *this->hour_); } diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index f3a5f3d770d..2da0f23084a 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -156,6 +156,8 @@ static const char *regout0_to_str(uint32_t value) { return "3.0V"; case (UICR_REGOUT0_VOUT_3V3): return "3.3V"; + default: + break; } return "???V"; } @@ -337,6 +339,8 @@ size_t DebugComponent::get_device_info_(std::span return "QFxx - 6x6 48-pin QFN"; case 0x2005: return "CKxx - 3.544 x 3.607 WLCSP"; + default: + break; } return "Unspecified"; }; diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 47ebd3641e4..35b0e2658f2 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -7,7 +7,6 @@ namespace esphome::deep_sleep { ESPHOME_LOG_TAG(TAG, "deep_sleep"); -#ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { switch (lt_get_reboot_reason()) { case REBOOT_REASON_SLEEP_GPIO: @@ -20,7 +19,6 @@ WakeupCause get_wakeup_cause() { return WAKEUP_CAUSE_NONE; } } -#endif // USE_DEEP_SLEEP_ON_WAKE optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 2afe19c0698..62f67eb5f1c 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -60,8 +60,6 @@ struct WakeupCauseToRunDuration { #endif // USE_ESP32 -#ifdef USE_DEEP_SLEEP_ON_WAKE - /// Why the device woke from deep sleep. Passed to on_wake automations. enum WakeupCause : uint8_t { /// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart). @@ -79,6 +77,11 @@ enum WakeupCause : uint8_t { /// Return why the device woke from deep sleep. Implemented per platform. WakeupCause get_wakeup_cause(); +/// Return true when this boot is a wake from deep sleep. +inline bool woke_from_deep_sleep() { return get_wakeup_cause() != WAKEUP_CAUSE_NONE; } + +#ifdef USE_DEEP_SLEEP_ON_WAKE + /** Setup priority of on_wake triggers. * * Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 7cf90bc33e3..d43a10d9d45 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -30,7 +30,6 @@ namespace esphome::deep_sleep { ESPHOME_LOG_TAG(TAG, "deep_sleep"); -#ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { switch (esp_sleep_get_wakeup_cause()) { case ESP_SLEEP_WAKEUP_EXT0: @@ -47,7 +46,6 @@ WakeupCause get_wakeup_cause() { return WAKEUP_CAUSE_UNKNOWN; } } -#endif // USE_DEEP_SLEEP_ON_WAKE optional DeepSleepComponent::get_run_duration_() const { if (this->wakeup_cause_to_run_duration_.has_value()) { diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index 408d5a6fe60..5bfcaa615ea 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -3,17 +3,14 @@ #include -#ifdef USE_DEEP_SLEEP_ON_WAKE extern "C" { #include } -#endif namespace esphome::deep_sleep { ESPHOME_LOG_TAG(TAG, "deep_sleep"); -#ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { // The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST). // NOLINTNEXTLINE(readability-static-accessed-through-instance) @@ -22,7 +19,6 @@ WakeupCause get_wakeup_cause() { } return WAKEUP_CAUSE_NONE; } -#endif // USE_DEEP_SLEEP_ON_WAKE optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index aff5c0e9c7d..04160518c7e 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -31,6 +31,9 @@ static bool wakeable_delay_feed_wdt(uint32_t ms) { return false; } +// A wake from System OFF is not told apart from a reset yet. +WakeupCause get_wakeup_cause() { return WAKEUP_CAUSE_NONE; } + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index cb2c5127b0c..854c2cc0ee9 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -143,6 +143,8 @@ bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) { case DELONGHI_FAN_AUTO: this->fan_mode = climate::CLIMATE_FAN_AUTO; break; + default: + break; } this->publish_state(); return true; diff --git a/esphome/components/dew_point/dew_point.cpp b/esphome/components/dew_point/dew_point.cpp index 7012bcbb755..6c6a852334d 100644 --- a/esphome/components/dew_point/dew_point.cpp +++ b/esphome/components/dew_point/dew_point.cpp @@ -36,7 +36,8 @@ void DewPointComponent::dump_config() { "Sources\n" " Temperature: '%s'\n" " Humidity: '%s'", - this->temperature_sensor_->get_name().c_str(), this->humidity_sensor_->get_name().c_str()); + LOG_STR_ARG(this->temperature_sensor_->get_log_name()), + LOG_STR_ARG(this->humidity_sensor_->get_log_name())); } float DewPointComponent::get_setup_priority() const { return setup_priority::DATA; } @@ -75,8 +76,8 @@ void DewPointComponent::loop() { // Publish the calculated dew point this->publish_state(dew_point); - ESP_LOGD(TAG, "'%s' >> %.1f°C (T: %.1f°C, RH: %.1f%%)", this->get_name().c_str(), dew_point, this->temperature_value_, - this->humidity_value_); + ESP_LOGD(TAG, "'%s' >> %.1f°C (T: %.1f°C, RH: %.1f%%)", LOG_STR_ARG(this->get_log_name()), dew_point, + this->temperature_value_, this->humidity_value_); } } // namespace esphome::dew_point diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 4b031274d26..5f3b0009faf 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -250,6 +250,8 @@ void DFPlayer::loop() { ESP_LOGE(TAG, "Entered into sleep mode"); this->is_playing_ = false; break; + default: + break; } break; case 0x41: diff --git a/esphome/components/ds3231/__init__.py b/esphome/components/ds3231/__init__.py index b3e496a883c..ff9b49018b3 100644 --- a/esphome/components/ds3231/__init__.py +++ b/esphome/components/ds3231/__init__.py @@ -1,7 +1,18 @@ -from esphome import automation +from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv +from esphome.const import ( + CONF_FREQUENCY, + CONF_HOUR, + CONF_INTERRUPT_PIN, + CONF_MINUTE, + CONF_MODE, + CONF_OFFSET, + CONF_SECOND, + CONF_STATE, +) from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] @@ -10,23 +21,245 @@ MULTI_CONF = True DOMAIN = "ds3231" CONF_DS3231_ID = "ds3231_id" +CONF_ALARM = "alarm" +CONF_ALARM_CHECK_INTERVAL = "alarm_check_interval" +CONF_AGING_OFFSET = "aging_offset" +CONF_AUTO_CLEAR_ALARMS = "auto_clear_alarms" +CONF_BATTERY_BACKED_SQUARE_WAVE = "battery_backed_square_wave" +CONF_DAY_OF_MONTH = "day_of_month" +CONF_DAY_OF_WEEK = "day_of_week" +CONF_ON_ALARM_1 = "on_alarm_1" +CONF_ON_ALARM_2 = "on_alarm_2" +CONF_OUTPUT_32KHZ = "output_32khz" +CONF_SQUARE_WAVE_FREQUENCY = "square_wave_frequency" ds3231_ns = cg.esphome_ns.namespace("ds3231") DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice) -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)}) +AlarmMode = ds3231_ns.enum("AlarmMode") +ALARM_1_MODES = AlarmMode.options( + "every_second", + "every_minute", + "hourly", + "daily", + "monthly", + "weekly", + prefix="ALARM_MODE_", +) +# Alarm 2 has no seconds register. +ALARM_2_MODES = {k: v for k, v in ALARM_1_MODES.items() if k != "every_second"} + +SquareWaveFrequency = ds3231_ns.enum("SquareWaveFrequency") +# Keyed by the frequency in Hz; cv.frequency turns "1024Hz", "1.024kHz" or 1024 into it. +SQUARE_WAVE_FREQUENCIES = { + 1: SquareWaveFrequency.SQUARE_WAVE_1HZ, + 1024: SquareWaveFrequency.SQUARE_WAVE_1024HZ, + 4096: SquareWaveFrequency.SQUARE_WAVE_4096HZ, + 8192: SquareWaveFrequency.SQUARE_WAVE_8192HZ, +} +validate_square_wave_frequency = cv.All( + cv.frequency, cv.enum(SQUARE_WAVE_FREQUENCIES, int=True) +) + +validate_aging_offset = cv.int_range(min=-128, max=127) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS3231Component), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_SQUARE_WAVE_FREQUENCY): validate_square_wave_frequency, + cv.Optional(CONF_BATTERY_BACKED_SQUARE_WAVE): cv.boolean, + cv.Optional(CONF_OUTPUT_32KHZ): cv.boolean, + cv.Optional(CONF_AGING_OFFSET): validate_aging_offset, + cv.Optional(CONF_AUTO_CLEAR_ALARMS, default=True): cv.boolean, + cv.Optional( + CONF_ALARM_CHECK_INTERVAL, default="1s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_ON_ALARM_1): automation.validate_automation(single=True), + cv.Optional(CONF_ON_ALARM_2): automation.validate_automation(single=True), + } + ) .extend(cv.COMPONENT_SCHEMA) - .extend(i2c.i2c_device_schema(0x68)) + .extend(i2c.i2c_device_schema(0x68)), + cv.has_at_most_one_key(CONF_INTERRUPT_PIN, CONF_SQUARE_WAVE_FREQUENCY), ) async def to_code(config: ConfigType) -> None: - await i2c.new_i2c_component(config) + var = await i2c.new_i2c_component(config) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + cg.add_optional( + var.set_square_wave_frequency, config.get(CONF_SQUARE_WAVE_FREQUENCY) + ) + cg.add_optional( + var.set_battery_backed_square_wave, config.get(CONF_BATTERY_BACKED_SQUARE_WAVE) + ) + cg.add_optional(var.set_output_32khz_config, config.get(CONF_OUTPUT_32KHZ)) + cg.add_optional(var.set_aging_offset_config, config.get(CONF_AGING_OFFSET)) + cg.add(var.set_auto_clear_alarms(config[CONF_AUTO_CLEAR_ALARMS])) + cg.add(var.set_alarm_check_interval(config[CONF_ALARM_CHECK_INTERVAL])) + # The alarm flags are only read when something uses them; the binary sensor platform + # turns this on as well. With an interrupt pin the check also releases INT/SQW. + if any( + key in config for key in (CONF_INTERRUPT_PIN, CONF_ON_ALARM_1, CONF_ON_ALARM_2) + ): + cg.add(var.set_monitor_alarms(True)) + + # One callback for both alarms; each automation runs only for its own alarm number. + for conf_key, alarm in ((CONF_ON_ALARM_1, 1), (CONF_ON_ALARM_2, 2)): + if (conf := config.get(conf_key)) is not None: + await automation.build_callback_automation( + var, + "add_on_alarm_callback", + [], + conf, + params=[(cg.uint8, "alarm")], + forward=[], + when=f"alarm == {alarm}", + ) + + +# --------------------------------------------------------------------------------------------- +# Actions +# --------------------------------------------------------------------------------------------- + +DS3231_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(DS3231Component)} +) automation.register_apply_action( "ds3231.force_temperature_conversion", - automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}), + DS3231_ACTION_SCHEMA, automation.ApplyCall("force_temperature_conversion()"), ) + + +def _validate_alarm_day(config: ConfigType) -> ConfigType: + mode = config[CONF_MODE] + if mode == "monthly" and CONF_DAY_OF_MONTH not in config: + raise cv.Invalid(f"'{CONF_DAY_OF_MONTH}' is required in monthly mode") + if mode == "weekly" and CONF_DAY_OF_WEEK not in config: + raise cv.Invalid(f"'{CONF_DAY_OF_WEEK}' is required in weekly mode") + # The unused day still has to be a valid register value. + config.setdefault(CONF_DAY_OF_MONTH, 1) + config.setdefault(CONF_DAY_OF_WEEK, 1) + return config + + +def _alarm_schema(modes: dict, with_second: bool) -> cv.Schema: + schema = { + cv.GenerateID(): cv.use_id(DS3231Component), + cv.Required(CONF_MODE): cv.enum(modes, lower=True), + cv.Optional(CONF_MINUTE, default=0): cv.templatable( + cv.int_range(min=0, max=59) + ), + cv.Optional(CONF_HOUR, default=0): cv.templatable(cv.int_range(min=0, max=23)), + cv.Optional(CONF_DAY_OF_MONTH): cv.templatable(cv.int_range(min=1, max=31)), + # 1 = Sunday ... 7 = Saturday, as in ESPHome's time component. + cv.Optional(CONF_DAY_OF_WEEK): cv.templatable(cv.int_range(min=1, max=7)), + cv.Optional(CONF_ENABLED, default=True): cv.templatable(cv.boolean), + } + if with_second: + schema[cv.Optional(CONF_SECOND, default=0)] = cv.templatable( + cv.int_range(min=0, max=59) + ) + return cv.All(cv.Schema(schema), _validate_alarm_day) + + +automation.register_apply_action( + "ds3231.set_alarm_1", + _alarm_schema(ALARM_1_MODES, with_second=True), + automation.ApplyCall( + "set_alarm_1({}, {}, {}, {}, {}, {}, {})", + ( + (CONF_MODE, AlarmMode), + (CONF_SECOND, cg.uint8), + (CONF_MINUTE, cg.uint8), + (CONF_HOUR, cg.uint8), + (CONF_DAY_OF_MONTH, cg.uint8), + (CONF_DAY_OF_WEEK, cg.uint8), + (CONF_ENABLED, cg.bool_), + ), + ), +) + +automation.register_apply_action( + "ds3231.set_alarm_2", + _alarm_schema(ALARM_2_MODES, with_second=False), + automation.ApplyCall( + "set_alarm_2({}, {}, {}, {}, {}, {})", + ( + (CONF_MODE, AlarmMode), + (CONF_MINUTE, cg.uint8), + (CONF_HOUR, cg.uint8), + (CONF_DAY_OF_MONTH, cg.uint8), + (CONF_DAY_OF_WEEK, cg.uint8), + (CONF_ENABLED, cg.bool_), + ), + ), +) + +DS3231_ALARM_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(DS3231Component), + cv.Required(CONF_ALARM): cv.templatable(cv.int_range(min=1, max=2)), + } +) + +for _name, _method in ( + ("ds3231.enable_alarm", "enable_alarm"), + ("ds3231.disable_alarm", "disable_alarm"), + ("ds3231.clear_alarm", "clear_alarm"), +): + automation.register_apply_action( + _name, + DS3231_ALARM_ACTION_SCHEMA, + automation.ApplyField(CONF_ALARM, _method, cg.uint8), + ) + +automation.register_apply_condition( + "ds3231.is_alarm_triggered", + DS3231_ALARM_ACTION_SCHEMA, + automation.ApplyCall("is_alarm_triggered({})", ((CONF_ALARM, cg.uint8),)), +) + +automation.register_apply_action( + "ds3231.enable_square_wave", + cv.Schema( + { + cv.GenerateID(): cv.use_id(DS3231Component), + cv.Required(CONF_FREQUENCY): validate_square_wave_frequency, + } + ), + automation.ApplyField(CONF_FREQUENCY, "enable_square_wave", SquareWaveFrequency), +) + +automation.register_apply_action( + "ds3231.disable_square_wave", + DS3231_ACTION_SCHEMA, + automation.ApplyCall("disable_square_wave()"), +) + +automation.register_apply_action( + "ds3231.set_32khz_output", + cv.Schema( + { + cv.GenerateID(): cv.use_id(DS3231Component), + cv.Required(CONF_STATE): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_STATE, "set_output_32khz", cg.bool_), +) + +automation.register_apply_action( + "ds3231.set_aging_offset", + cv.Schema( + { + cv.GenerateID(): cv.use_id(DS3231Component), + cv.Required(CONF_OFFSET): cv.templatable(validate_aging_offset), + } + ), + automation.ApplyField(CONF_OFFSET, "set_aging_offset", cg.int8), +) diff --git a/esphome/components/ds3231/binary_sensor/__init__.py b/esphome/components/ds3231/binary_sensor/__init__.py new file mode 100644 index 00000000000..3703d73c475 --- /dev/null +++ b/esphome/components/ds3231/binary_sensor/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .. import CONF_DS3231_ID, DS3231Component + +DEPENDENCIES = ["ds3231"] + +CONF_ALARM_1 = "alarm_1" +CONF_ALARM_2 = "alarm_2" +CONF_OSCILLATOR_STOPPED = "oscillator_stopped" + +ICON_ALARM = "mdi:alarm" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component), + cv.Optional(CONF_ALARM_1): binary_sensor.binary_sensor_schema( + icon=ICON_ALARM + ), + cv.Optional(CONF_ALARM_2): binary_sensor.binary_sensor_schema( + icon=ICON_ALARM + ), + cv.Optional(CONF_OSCILLATOR_STOPPED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ), + cv.has_at_least_one_key(CONF_ALARM_1, CONF_ALARM_2, CONF_OSCILLATOR_STOPPED), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DS3231_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_ALARM_1, hub.set_alarm_1_binary_sensor) + await binary_sensors(CONF_ALARM_2, hub.set_alarm_2_binary_sensor) + await binary_sensors( + CONF_OSCILLATOR_STOPPED, hub.set_oscillator_stopped_binary_sensor + ) + if CONF_ALARM_1 in config or CONF_ALARM_2 in config: + cg.add(hub.set_monitor_alarms(True)) diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp index 1033ddfb454..a8e88480dd2 100644 --- a/esphome/components/ds3231/ds3231.cpp +++ b/esphome/components/ds3231/ds3231.cpp @@ -9,29 +9,184 @@ namespace esphome::ds3231 { ESPHOME_LOG_TAG(TAG, "ds3231"); +static constexpr uint8_t DS3231_REG_SECONDS = 0x00; +static constexpr uint8_t DS3231_REG_ALARM_1 = 0x07; +static constexpr uint8_t DS3231_REG_ALARM_2 = 0x0B; static constexpr uint8_t DS3231_REG_CONTROL = 0x0E; +static constexpr uint8_t DS3231_REG_STATUS = 0x0F; +static constexpr uint8_t DS3231_REG_AGING_OFFSET = 0x10; static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11; +static constexpr uint8_t ALARM_MASK_BIT = 0x80; // AxMy: this register is ignored when matching +static constexpr uint8_t ALARM_DAY_OF_WEEK = 0x40; // DY/DT: match day of week instead of date + +/// How long an alarm binary sensor stays on when the flag is cleared automatically. +static constexpr uint32_t ALARM_PULSE_MS = 1000; + +static const LogString *alarm_state_to_string(uint8_t control, uint8_t alarm) { + return (control & (alarm == 1 ? 0x01 : 0x02)) != 0 ? LOG_STR("enabled") : LOG_STR("disabled"); +} + +static const LogString *square_wave_to_string(SquareWaveFrequency frequency) { + switch (frequency) { + case SQUARE_WAVE_1HZ: + return LOG_STR("1 Hz"); + case SQUARE_WAVE_1024HZ: + return LOG_STR("1.024 kHz"); + case SQUARE_WAVE_4096HZ: + return LOG_STR("4.096 kHz"); + case SQUARE_WAVE_8192HZ: + return LOG_STR("8.192 kHz"); + default: + return LOG_STR("unknown"); + } +} + +void IRAM_ATTR DS3231Component::interrupt_handler(DS3231Component *arg) { arg->enable_loop_soon_any_context(); } + void DS3231Component::setup() { if (!this->read_control_status_()) { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery - // INT/SQW is the (unused) interrupt line rather than a square-wave output. - this->control_reg_ |= CONTROL_INTCN; - if (!this->write_control_()) { + // Keep the oscillator running on battery. INT/SQW is the alarm interrupt line unless a square + // wave is configured. The alarm interrupt enables (A1IE/A2IE) are kept as they are: the alarms + // live on the chip and survive a reboot of the ESP on battery. + uint8_t mask = CONTROL_EOSC | CONTROL_INTCN; + uint8_t value = CONTROL_INTCN; + if (this->square_wave_configured_) { + mask |= CONTROL_RS_MASK; + value = this->square_wave_frequency_ << CONTROL_RS_SHIFT; + } + if (this->battery_backed_square_wave_ >= 0) { + mask |= CONTROL_BBSQW; + assign_bits(value, CONTROL_BBSQW, this->battery_backed_square_wave_ != 0); + } + if (!this->update_control_(mask, value)) { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } + + if (this->output_32khz_ >= 0) { + assign_bits(this->status_reg_, STATUS_EN32KHZ, this->output_32khz_ != 0); + if (!this->write_status_(0)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + } + + if (this->aging_offset_configured_ && !this->write_aging_offset_(this->aging_offset_)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + if ((this->status_reg_ & STATUS_OSF) != 0) { + ESP_LOGW(TAG, "Oscillator was stopped (battery empty or missing?), the stored time is invalid."); + } + this->publish_oscillator_stopped_(); + + if (!this->monitor_alarms_) { + this->disable_loop(); + return; + } + + // Start from a known state; an alarm that fired while the ESP was off is picked up by the first + // check, which runs from the loop right after setup. + this->publish_alarm_state_(1, false); + this->publish_alarm_state_(2, false); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // INT/SQW is open drain and active low: a falling edge means an enabled alarm matched. + // The handler only wakes the loop; the flags are read from there. + this->interrupt_pin_->attach_interrupt(&DS3231Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE); + } + // Without an interrupt pin this is how alarms are seen. With one it is a fallback: it picks up an + // edge that was missed, and latched flags (auto_clear_alarms: false) that were cleared elsewhere. + this->set_interval(this->alarm_check_interval_, [this]() { this->check_alarms_(); }); +} + +void DS3231Component::loop() { + // Only runs once after setup and when woken by the interrupt pin. + this->disable_loop(); + this->check_alarms_(); } void DS3231Component::dump_config() { ESP_LOGCONFIG(TAG, "DS3231:"); LOG_I2C_DEVICE(this); + if (this->is_failed()) + return; + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + if ((this->control_reg_ & CONTROL_INTCN) == 0) { + ESP_LOGCONFIG(TAG, " INT/SQW: square wave %s", + LOG_STR_ARG(square_wave_to_string( + static_cast((this->control_reg_ & CONTROL_RS_MASK) >> CONTROL_RS_SHIFT)))); + } else { + ESP_LOGCONFIG(TAG, " INT/SQW: alarm interrupt"); + } + ESP_LOGCONFIG(TAG, + " Battery-backed square wave: %s\n" + " 32 kHz output: %s\n" + " Alarm 1: %s\n" + " Alarm 2: %s\n" + " Auto clear alarms: %s", + YESNO(this->control_reg_ & CONTROL_BBSQW), ONOFF(this->status_reg_ & STATUS_EN32KHZ), + LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 1)), + LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 2)), YESNO(this->auto_clear_alarms_)); + if (this->aging_offset_configured_) { + ESP_LOGCONFIG(TAG, " Aging offset: %d", this->aging_offset_); + } + if (this->monitor_alarms_) { + ESP_LOGCONFIG(TAG, " Alarm check interval: %" PRIu32 " ms", this->alarm_check_interval_); + } +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Alarm 1", this->alarm_1_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Alarm 2", this->alarm_2_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Oscillator Stopped", this->oscillator_stopped_binary_sensor_); +#endif } +// --------------------------------------------------------------------------------------------- +// Clock +// --------------------------------------------------------------------------------------------- + +bool DS3231Component::read_calendar(uint8_t *raw) { + if (this->is_failed() || !this->status_comm_result(this->read_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN))) + return false; + // Without a valid status the oscillator-stop flag is unknown, so the time can't be trusted. + if (!this->read_control_status_()) + return false; + this->publish_oscillator_stopped_(); + if ((this->status_reg_ & STATUS_OSF) != 0) { + ESP_LOGW(TAG, "Oscillator stop flag set, the RTC time is invalid. Write a valid time first."); + return false; + } + return true; +} + +bool DS3231Component::write_calendar(const uint8_t *raw) { + if (this->is_failed() || !this->status_comm_result(this->write_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN))) + return false; + + // The time is valid again: clear the oscillator stop flag. If that fails, the RTC would keep + // reporting its time as invalid, so the write is not reported as successful. + if (!this->read_control_status_()) + return false; + if ((this->status_reg_ & STATUS_OSF) != 0) { + if (!this->write_status_(STATUS_OSF)) + return false; + this->status_reg_ &= ~STATUS_OSF; + } + this->publish_oscillator_stopped_(); + return true; +} + +// --------------------------------------------------------------------------------------------- +// Temperature +// --------------------------------------------------------------------------------------------- + bool DS3231Component::read_temperature(float &out) { uint8_t raw[2]; if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) { @@ -53,26 +208,280 @@ void DS3231Component::force_temperature_conversion() { ESP_LOGW(TAG, "A temperature conversion is already in progress."); return; } - this->control_reg_ |= CONTROL_CONV; - this->write_control_(); + // CONV is a one-shot command bit, so it is not kept in the cached value. + this->status_comm_result(this->write_byte(DS3231_REG_CONTROL, this->control_reg_ | CONTROL_CONV)); } +// --------------------------------------------------------------------------------------------- +// Alarms +// --------------------------------------------------------------------------------------------- + +void DS3231Component::set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month, + uint8_t day_of_week, bool enabled) { + if (second > 59 || minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 || + day_of_week > 7 || mode > ALARM_MODE_WEEKLY) { + ESP_LOGE(TAG, "Invalid alarm 1 setting."); + return; + } + // Mask bits A1M1..A1M4: a set bit means the register is ignored when matching. + uint8_t regs[4] = { + uint8_to_bcd(second), + uint8_to_bcd(minute), + uint8_to_bcd(hour), + mode == ALARM_MODE_WEEKLY ? static_cast(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month), + }; + switch (mode) { + case ALARM_MODE_EVERY_SECOND: + regs[0] |= ALARM_MASK_BIT; + [[fallthrough]]; + case ALARM_MODE_EVERY_MINUTE: + regs[1] |= ALARM_MASK_BIT; + [[fallthrough]]; + case ALARM_MODE_HOURLY: + regs[2] |= ALARM_MASK_BIT; + [[fallthrough]]; + case ALARM_MODE_DAILY: + regs[3] |= ALARM_MASK_BIT; + break; + default: // MONTHLY / WEEKLY: everything matches + break; + } + ESP_LOGD(TAG, "Setting alarm 1: mode %u, %02u:%02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute, + second, day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled")); + this->write_alarm_(1, regs, sizeof(regs), enabled); +} + +void DS3231Component::set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month, + uint8_t day_of_week, bool enabled) { + if (minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 || day_of_week > 7 || + mode == ALARM_MODE_EVERY_SECOND || mode > ALARM_MODE_WEEKLY) { + ESP_LOGE(TAG, "Invalid alarm 2 setting."); + return; + } + // Mask bits A2M2..A2M4. + uint8_t regs[3] = { + uint8_to_bcd(minute), + uint8_to_bcd(hour), + mode == ALARM_MODE_WEEKLY ? static_cast(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month), + }; + switch (mode) { + case ALARM_MODE_EVERY_MINUTE: + regs[0] |= ALARM_MASK_BIT; + [[fallthrough]]; + case ALARM_MODE_HOURLY: + regs[1] |= ALARM_MASK_BIT; + [[fallthrough]]; + case ALARM_MODE_DAILY: + regs[2] |= ALARM_MASK_BIT; + break; + default: // MONTHLY / WEEKLY: everything matches + break; + } + ESP_LOGD(TAG, "Setting alarm 2: mode %u, %02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute, + day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled")); + this->write_alarm_(2, regs, sizeof(regs), enabled); +} + +void DS3231Component::write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled) { + if (this->is_failed()) + return; + if (!this->status_comm_result(this->write_bytes(alarm == 1 ? DS3231_REG_ALARM_1 : DS3231_REG_ALARM_2, regs, len))) + return; + // Changing the registers may have matched on the way: start from a clean flag. Enabling the + // alarm with a stale flag would assert INT/SQW right away. + if (!this->clear_alarm_flag_(alarm)) { + ESP_LOGW(TAG, "Alarm %u written, but its flag could not be cleared; not changing its enable.", alarm); + return; + } + this->set_alarm_enabled_(alarm, enabled); +} + +void DS3231Component::set_alarm_enabled_(uint8_t alarm, bool enabled) { + if (alarm != 1 && alarm != 2) { + ESP_LOGE(TAG, "Invalid alarm number %u.", alarm); + return; + } + if (this->is_failed()) + return; + const uint8_t bit = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE; + if (!this->update_control_(bit, enabled ? bit : 0)) + return; + if (enabled && (this->control_reg_ & CONTROL_INTCN) == 0) { + ESP_LOGW(TAG, "Alarm %u enabled, but INT/SQW is a square-wave output: only the flag will be set.", alarm); + } + ESP_LOGD(TAG, "Alarm %u %s.", alarm, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled")); +} + +void DS3231Component::enable_alarm(uint8_t alarm) { + // The chip sets the flag on every match even while the alarm is disabled; enabling with it set + // would fire the trigger (and INT/SQW) for that old match right away. + if (this->clear_alarm_flag_(alarm)) + this->set_alarm_enabled_(alarm, true); +} + +void DS3231Component::clear_alarm(uint8_t alarm) { this->clear_alarm_flag_(alarm); } + +bool DS3231Component::clear_alarm_flag_(uint8_t alarm) { + if (alarm != 1 && alarm != 2) { + ESP_LOGE(TAG, "Invalid alarm number %u.", alarm); + return false; + } + if (this->is_failed() || !this->read_control_status_()) + return false; + const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F; + if ((this->status_reg_ & flag) != 0) { + // On failure the flag is still set on the chip: keep reporting the alarm as it is. + if (!this->write_status_(flag)) + return false; + this->status_reg_ &= ~flag; + } + this->alarm_active_[alarm - 1] = false; + this->publish_alarm_state_(alarm, false); + return true; +} + +bool DS3231Component::is_alarm_triggered(uint8_t alarm) { + if (this->is_failed() || !this->read_control_status_()) + return false; + return (this->status_reg_ & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) != 0; +} + +void DS3231Component::check_alarms_() { + if (this->is_failed() || !this->read_control_status_()) + return; + this->publish_oscillator_stopped_(); + + uint8_t to_clear = 0; + for (uint8_t alarm = 1; alarm <= 2; alarm++) { + const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F; + const uint8_t enable = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE; + const bool triggered = (this->status_reg_ & flag) != 0; + const bool enabled = (this->control_reg_ & enable) != 0; + bool &active = this->alarm_active_[alarm - 1]; + + if (!triggered) { + if (active && !this->auto_clear_alarms_) + this->publish_alarm_state_(alarm, false); + active = false; + continue; + } + // The flag is set on every match even when the alarm is disabled; only enabled alarms count. + if (enabled && !active) { + ESP_LOGI(TAG, "Alarm %u triggered.", alarm); + active = true; + this->publish_alarm_state_(alarm, true); + this->alarm_callback_.call(alarm); + } + if (this->auto_clear_alarms_) + to_clear |= flag; + } + + if (to_clear != 0 && this->write_status_(to_clear)) { + this->status_reg_ &= ~to_clear; + for (uint8_t alarm = 1; alarm <= 2; alarm++) { + if ((to_clear & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) == 0 || !this->alarm_active_[alarm - 1]) + continue; + this->alarm_active_[alarm - 1] = false; +#ifdef USE_BINARY_SENSOR + // Keep the binary sensor on long enough for the state to be seen, then turn it off again. + this->set_timeout(ALARM_PULSE_TIMEOUT_ID + alarm - 1, ALARM_PULSE_MS, + [this, alarm]() { this->publish_alarm_state_(alarm, false); }); +#endif + } + } +} + +void DS3231Component::publish_alarm_state_(uint8_t alarm, bool state) { +#ifdef USE_BINARY_SENSOR + auto *sensor = alarm == 1 ? this->alarm_1_binary_sensor_ : this->alarm_2_binary_sensor_; + if (sensor != nullptr) + sensor->publish_state(state); +#endif +} + +void DS3231Component::publish_oscillator_stopped_() { +#ifdef USE_BINARY_SENSOR + if (this->oscillator_stopped_binary_sensor_ != nullptr) + this->oscillator_stopped_binary_sensor_->publish_state((this->status_reg_ & STATUS_OSF) != 0); +#endif +} + +// --------------------------------------------------------------------------------------------- +// Outputs and calibration +// --------------------------------------------------------------------------------------------- + +void DS3231Component::enable_square_wave(SquareWaveFrequency frequency) { + if (this->interrupt_pin_ != nullptr) { + ESP_LOGE(TAG, "Can't enable the square wave: INT/SQW is used as the alarm interrupt pin."); + return; + } + if (this->is_failed()) + return; + if (this->update_control_(CONTROL_INTCN | CONTROL_RS_MASK, frequency << CONTROL_RS_SHIFT)) { + ESP_LOGD(TAG, "Square wave enabled at %s.", LOG_STR_ARG(square_wave_to_string(frequency))); + } +} + +void DS3231Component::disable_square_wave() { + if (this->is_failed()) + return; + if (this->update_control_(CONTROL_INTCN, CONTROL_INTCN)) { + ESP_LOGD(TAG, "Square wave disabled, INT/SQW is the alarm interrupt."); + } +} + +void DS3231Component::set_output_32khz(bool enabled) { + if (this->is_failed() || !this->read_control_status_()) + return; + assign_bits(this->status_reg_, STATUS_EN32KHZ, enabled); + if (this->write_status_(0)) { + ESP_LOGD(TAG, "32 kHz output %s.", ONOFF(enabled)); + } +} + +void DS3231Component::set_aging_offset(int8_t offset) { + if (this->is_failed()) + return; + if (!this->write_aging_offset_(offset)) + return; + ESP_LOGD(TAG, "Aging offset set to %d.", offset); + // The new offset takes effect with the next temperature conversion. + this->force_temperature_conversion(); +} + +// --------------------------------------------------------------------------------------------- +// Register access +// --------------------------------------------------------------------------------------------- + bool DS3231Component::read_control_status_() { uint8_t raw[2]; - if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) { - ESP_LOGW(TAG, "Can't read control/status registers."); + if (!this->status_comm_result(this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw)))) return false; - } - this->control_reg_ = raw[0]; + this->control_reg_ = raw[0] & ~CONTROL_CONV; this->status_reg_ = raw[1]; return true; } -bool DS3231Component::write_control_() { - if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) { - ESP_LOGW(TAG, "Can't write control register."); +bool DS3231Component::update_control_(uint8_t mask, uint8_t value) { + // CONV is a one-shot command bit: always write it back as 0 so a control change never starts + // a temperature conversion. + if (!this->status_comm_result(this->update_register(DS3231_REG_CONTROL, mask | CONTROL_CONV, value))) return false; - } + this->control_reg_ = (this->control_reg_ & ~mask) | (value & mask); + return true; +} + +bool DS3231Component::write_status_(uint8_t clear) { + // The flags can only be written to 0; writing 1 leaves them unchanged, so a flag that was set + // between the read and this write is never lost. + const uint8_t value = ((this->status_reg_ & STATUS_EN32KHZ) | STATUS_FLAGS) & ~clear; + return this->status_comm_result(this->write_byte(DS3231_REG_STATUS, value)); +} + +bool DS3231Component::write_aging_offset_(int8_t offset) { + if (!this->status_comm_result(this->write_byte(DS3231_REG_AGING_OFFSET, static_cast(offset)))) + return false; + this->aging_offset_ = offset; return true; } diff --git a/esphome/components/ds3231/ds3231.h b/esphome/components/ds3231/ds3231.h index b8a81a2e490..27ab441eacc 100644 --- a/esphome/components/ds3231/ds3231.h +++ b/esphome/components/ds3231/ds3231.h @@ -1,32 +1,163 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/components/i2c/i2c.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + namespace esphome::ds3231 { +/// When an alarm fires. Alarm 1 supports every mode, alarm 2 has no seconds register and so no EVERY_SECOND. +enum AlarmMode : uint8_t { + ALARM_MODE_EVERY_SECOND = 0, ///< Alarm 1 only. + ALARM_MODE_EVERY_MINUTE, ///< Alarm 1: when the seconds match. Alarm 2: at second 00 of every minute. + ALARM_MODE_HOURLY, ///< When minutes (and seconds) match. + ALARM_MODE_DAILY, ///< When hours, minutes (and seconds) match. + ALARM_MODE_MONTHLY, ///< When date, hours, minutes (and seconds) match. + ALARM_MODE_WEEKLY, ///< When day of week, hours, minutes (and seconds) match. +}; + +/// Frequency of the INT/SQW pin when it is a square-wave output (RS2:RS1 in the control register). +enum SquareWaveFrequency : uint8_t { + SQUARE_WAVE_1HZ = 0, + SQUARE_WAVE_1024HZ = 1, + SQUARE_WAVE_4096HZ = 2, + SQUARE_WAVE_8192HZ = 3, +}; + /// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the /// chip through this class. class DS3231Component final : public Component, public i2c::I2CDevice { public: + /// Seconds through year, the 7-byte BCD calendar the time platform decodes with time::DS_BCD_CALENDAR. + static constexpr size_t CALENDAR_LEN = 7; + void setup() override; + void loop() override; void dump_config() override; + // ---- Configuration (set from codegen) ---- + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_square_wave_frequency(SquareWaveFrequency frequency) { + this->square_wave_frequency_ = frequency; + this->square_wave_configured_ = true; + } + void set_battery_backed_square_wave(bool enabled) { this->battery_backed_square_wave_ = enabled ? 1 : 0; } + void set_output_32khz_config(bool enabled) { this->output_32khz_ = enabled ? 1 : 0; } + void set_aging_offset_config(int8_t offset) { + this->aging_offset_ = offset; + this->aging_offset_configured_ = true; + } + void set_auto_clear_alarms(bool auto_clear) { this->auto_clear_alarms_ = auto_clear; } + void set_alarm_check_interval(uint32_t interval) { this->alarm_check_interval_ = interval; } + void set_monitor_alarms(bool monitor) { this->monitor_alarms_ = monitor; } + + /// Register a void(uint8_t alarm) callback that runs when an enabled alarm (1 or 2) matches. + template void add_on_alarm_callback(F &&callback) { + this->alarm_callback_.add(std::forward(callback)); + } + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(alarm_1) + SUB_BINARY_SENSOR(alarm_2) + SUB_BINARY_SENSOR(oscillator_stopped) +#endif + + // ---- Clock registers (used by the time platform) ---- + /// Read the calendar registers into @p raw. Fails if the oscillator was stopped since the time was last set. + bool read_calendar(uint8_t *raw); + /// Write the calendar registers from @p raw and clear the oscillator-stop flag. + bool write_calendar(const uint8_t *raw); + + // ---- Temperature ---- bool read_temperature(float &out); void force_temperature_conversion(); + // ---- Alarms ---- + /// Program alarm 1. @p day_of_month is used in MONTHLY mode, @p day_of_week (1 = Sunday) in WEEKLY mode. + void set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month, + uint8_t day_of_week, bool enabled); + /// Program alarm 2 (minute resolution, fires at second 00). + void set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month, uint8_t day_of_week, + bool enabled); + /// Enable @p alarm. A match from while it was disabled is cleared first, so only the next one fires. + void enable_alarm(uint8_t alarm); + void disable_alarm(uint8_t alarm) { this->set_alarm_enabled_(alarm, false); } + /// Clear the alarm flag, which also releases the INT/SQW line. + void clear_alarm(uint8_t alarm); + /// True while the alarm flag of @p alarm (1 or 2) is set on the chip. + bool is_alarm_triggered(uint8_t alarm); + + // ---- Outputs and calibration ---- + /// Switch INT/SQW to a square-wave output at @p frequency. Alarms then no longer drive the pin. + void enable_square_wave(SquareWaveFrequency frequency); + /// Switch INT/SQW back to the (active-low) alarm interrupt output. + void disable_square_wave(); + void set_output_32khz(bool enabled); + /// Aging offset in LSB (about 0.1 ppm each at 25 degC); positive values slow the clock down. + void set_aging_offset(int8_t offset); + protected: + static constexpr uint8_t CONTROL_A1IE = 0x01; + static constexpr uint8_t CONTROL_A2IE = 0x02; static constexpr uint8_t CONTROL_INTCN = 0x04; + static constexpr uint8_t CONTROL_RS_MASK = 0x18; + static constexpr uint8_t CONTROL_RS_SHIFT = 3; static constexpr uint8_t CONTROL_CONV = 0x20; + static constexpr uint8_t CONTROL_BBSQW = 0x40; static constexpr uint8_t CONTROL_EOSC = 0x80; + static constexpr uint8_t STATUS_A1F = 0x01; + static constexpr uint8_t STATUS_A2F = 0x02; static constexpr uint8_t STATUS_BSY = 0x04; + static constexpr uint8_t STATUS_EN32KHZ = 0x08; + static constexpr uint8_t STATUS_OSF = 0x80; + static constexpr uint8_t STATUS_FLAGS = STATUS_A1F | STATUS_A2F | STATUS_OSF; + /// Scheduler ids: one pulse timer per alarm (id + alarm - 1). + static constexpr uint32_t ALARM_PULSE_TIMEOUT_ID = 0; + + static void interrupt_handler(DS3231Component *arg); + + /// Read the control and status registers into the cached copies. Sets the warning flag on failure. bool read_control_status_(); - bool write_control_(); + /// Replace the bits in @p mask of the control register with @p value, on the chip and in the cache. + bool update_control_(uint8_t mask, uint8_t value); + /// Write the status register, clearing the flags in @p clear (writing 1 to a flag leaves it unchanged). + bool write_status_(uint8_t clear); + bool write_aging_offset_(int8_t offset); + /// Clear the flag of @p alarm and report it off. Returns false (state unchanged) if that fails. + bool clear_alarm_flag_(uint8_t alarm); + void set_alarm_enabled_(uint8_t alarm, bool enabled); + /// Write the alarm registers, clear the stale flag and set the interrupt enable. + void write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled); + /// Read the flags, fire triggers and update the binary sensors. + void check_alarms_(); + void publish_alarm_state_(uint8_t alarm, bool state); + void publish_oscillator_stopped_(); + InternalGPIOPin *interrupt_pin_{nullptr}; + LazyCallbackManager alarm_callback_; + + uint32_t alarm_check_interval_{1000}; uint8_t control_reg_{0}; uint8_t status_reg_{0}; + int8_t aging_offset_{0}; + SquareWaveFrequency square_wave_frequency_{SQUARE_WAVE_1HZ}; + /// -1: leave the chip as it is, 0: off, 1: on. + int8_t battery_backed_square_wave_{-1}; + int8_t output_32khz_{-1}; + bool square_wave_configured_{false}; + bool aging_offset_configured_{false}; + bool auto_clear_alarms_{true}; + bool monitor_alarms_{false}; + /// Set while an alarm flag has been reported, so a latched flag fires its trigger only once. + bool alarm_active_[2]{false, false}; }; } // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/time/__init__.py b/esphome/components/ds3231/time/__init__.py new file mode 100644 index 00000000000..0fa9ef9b14c --- /dev/null +++ b/esphome/components/ds3231/time/__init__.py @@ -0,0 +1,40 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import time +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns + +DEPENDENCIES = ["ds3231"] + +DS3231Time = ds3231_ns.class_( + "DS3231Time", time.RealTimeClock, cg.Parented.template(DS3231Component) +) + +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(DS3231Time), + cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component), + } +) + +DS3231_TIME_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(DS3231Time)} +) + +automation.register_apply_action( + "ds3231.write_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) + +automation.register_apply_action( + "ds3231.read_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("read_time()") +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_DS3231_ID]) + await time.register_time(var, config) diff --git a/esphome/components/ds3231/time/ds3231_time.cpp b/esphome/components/ds3231/time/ds3231_time.cpp new file mode 100644 index 00000000000..87527769378 --- /dev/null +++ b/esphome/components/ds3231/time/ds3231_time.cpp @@ -0,0 +1,48 @@ +#include "ds3231_time.h" +#include "esphome/core/log.h" + +namespace esphome::ds3231 { + +ESPHOME_LOG_TAG(TAG, "ds3231.time"); + +void DS3231Time::update() { this->read_time(); } + +void DS3231Time::dump_config() { + ESP_LOGCONFIG(TAG, "DS3231 Time:"); + RealTimeClock::dump_config(); +} + +void DS3231Time::read_time() { + uint8_t raw[DS3231Component::CALENDAR_LEN]; + if (!this->status_comm_result(this->parent_->read_calendar(raw), LOG_STR("No valid time from the RTC"))) + return; + const ESPTime rtc_time = time::decode_bcd_calendar(raw); + ESP_LOGD(TAG, "Read UTC time: %04u-%02u-%02u %02u:%02u:%02u (day of week %u)", rtc_time.year, rtc_time.month, + rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, rtc_time.day_of_week); + // The day of week is a free-running counter on the DS3231 and may have been set with a different + // numbering by other software, so it is not used to validate the time. + if (!this->status_comm_result(rtc_time.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false), + LOG_STR("Invalid RTC time"))) + return; + this->synchronize_epoch_(rtc_time.timestamp); +} + +void DS3231Time::write_time() { + auto now = this->utcnow(); + if (!now.is_valid()) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC."); + return; + } + if (now.year > 2199) { + ESP_LOGE(TAG, "Year %u is out of the DS3231 range (2000-2199).", now.year); + return; + } + uint8_t raw[DS3231Component::CALENDAR_LEN]; + time::encode_bcd_calendar(now, raw); + if (!this->status_comm_result(this->parent_->write_calendar(raw))) + return; + ESP_LOGD(TAG, "Wrote UTC time: %04u-%02u-%02u %02u:%02u:%02u", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); +} + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/time/ds3231_time.h b/esphome/components/ds3231/time/ds3231_time.h new file mode 100644 index 00000000000..2f4c0e06129 --- /dev/null +++ b/esphome/components/ds3231/time/ds3231_time.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/time/real_time_clock.h" + +#include "../ds3231.h" + +namespace esphome::ds3231 { + +class DS3231Time final : public time::RealTimeClock, public Parented { + public: + void update() override; + void dump_config() override; + + /// Set the system clock from the RTC. + void read_time(); + /// Store the system clock in the RTC. + void write_time(); +}; + +} // namespace esphome::ds3231 diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.cpp b/esphome/components/duty_cycle/duty_cycle_sensor.cpp index 69a41eb548d..9c986192b1d 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.cpp +++ b/esphome/components/duty_cycle/duty_cycle_sensor.cpp @@ -36,7 +36,7 @@ void DutyCycleSensor::update() { const float total_time = float(now - this->last_update_); const float value = (on_time * 100.0f) / total_time; - ESP_LOGD(TAG, "'%s' Got duty cycle=%.1f%%", this->get_name().c_str(), value); + ESP_LOGD(TAG, "'%s' Got duty cycle=%.1f%%", LOG_STR_ARG(this->get_log_name()), value); this->publish_state(value); } this->last_update_ = now; diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index 9b41ac7e0f6..b695e9fcaef 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include "esphome/core/component.h" #include "esphome/core/preferences.h" @@ -26,7 +27,7 @@ class DutyTimeSensor final : public sensor::Sensor, public PollingComponent { #ifdef USE_BINARY_SENSOR void set_sensor(binary_sensor::BinarySensor *sensor); #endif - void set_lambda(std::function &&func) { this->func_ = func; } + void set_lambda(std::function &&func) { this->func_ = std::move(func); } void set_last_duty_time_sensor(sensor::Sensor *sensor) { this->last_duty_time_sensor_ = sensor; } void set_restore(bool restore) { this->restore_ = restore; } diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 1992f36d328..3097a4080a3 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -126,7 +126,7 @@ void EmonTx::loop() { // Read all available data to prevent UART buffer overflow this->read_available([this](uint8_t received) { - size_t len; + size_t len = 0; char *line = this->line_reader_.feed(received, len); this->line_reader_.log_if_overflowed(TAG); if (line == nullptr || len == 0) { diff --git a/esphome/components/endstop/endstop_cover.cpp b/esphome/components/endstop/endstop_cover.cpp index 25b5b542092..991f0d8f666 100644 --- a/esphome/components/endstop/endstop_cover.cpp +++ b/esphome/components/endstop/endstop_cover.cpp @@ -70,20 +70,20 @@ void EndstopCover::loop() { if (this->current_operation == COVER_OPERATION_OPENING && this->is_open_()) { float dur = (now - this->start_dir_time_) / 1e3f; - ESP_LOGD(TAG, "'%s' - Open endstop reached. Took %.1fs.", this->name_.c_str(), dur); + ESP_LOGD(TAG, "'%s' - Open endstop reached. Took %.1fs.", LOG_STR_ARG(this->get_log_name()), dur); this->start_direction_(COVER_OPERATION_IDLE); this->position = COVER_OPEN; this->publish_state(); } else if (this->current_operation == COVER_OPERATION_CLOSING && this->is_closed_()) { float dur = (now - this->start_dir_time_) / 1e3f; - ESP_LOGD(TAG, "'%s' - Close endstop reached. Took %.1fs.", this->name_.c_str(), dur); + ESP_LOGD(TAG, "'%s' - Close endstop reached. Took %.1fs.", LOG_STR_ARG(this->get_log_name()), dur); this->start_direction_(COVER_OPERATION_IDLE); this->position = COVER_CLOSED; this->publish_state(); } else if (now - this->start_dir_time_ > this->max_duration_) { - ESP_LOGD(TAG, "'%s' - Max duration reached. Stopping cover.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Max duration reached. Stopping cover.", LOG_STR_ARG(this->get_log_name())); this->start_direction_(COVER_OPERATION_IDLE); this->publish_state(); } diff --git a/esphome/components/epaper_spi/epaper_goodisplay_3c.cpp b/esphome/components/epaper_spi/epaper_goodisplay_3c.cpp new file mode 100644 index 00000000000..ebd69ab6cc0 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_goodisplay_3c.cpp @@ -0,0 +1,12 @@ +#include "epaper_goodisplay_3c.h" + +namespace esphome::epaper_spi { + +void EPaperGoodDisplay3C::power_on() {} + +void EPaperGoodDisplay3C::refresh_screen(bool partial) { + EPaperWeAct3C::refresh_screen(partial); + this->next_delay_ = REFRESH_START_MS; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_goodisplay_3c.h b/esphome/components/epaper_spi/epaper_goodisplay_3c.h new file mode 100644 index 00000000000..2cb4f5ba723 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_goodisplay_3c.h @@ -0,0 +1,28 @@ +#pragma once + +#include "epaper_weact_3c.h" + +namespace esphome::epaper_spi { + +/** + * Good Display 3-color e-paper displays (SSD1680 controller), e.g. GDEY029Z95. + * + * Driven as EPaperWeAct3C, except that there is no separate power-on: the full refresh (0xF7) powers the panel + * up itself. Busy then only goes high a little after the refresh starts, so the next state waits before + * checking it. + */ +class EPaperGoodDisplay3C : public EPaperWeAct3C { + public: + using EPaperWeAct3C::EPaperWeAct3C; + + protected: + // Time from master activation (0x20) until busy can be relied on + static constexpr uint16_t REFRESH_START_MS = 50; + + // With EPaperWeAct3C's power-on (0x22 0xF8) first, this panel's refresh ends almost at once and the image + // does not change. + void power_on() override; + void refresh_screen(bool partial) override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/weact_bwr.py b/esphome/components/epaper_spi/models/weact_bwr.py index 8e2d04de94a..7cb0d797668 100644 --- a/esphome/components/epaper_spi/models/weact_bwr.py +++ b/esphome/components/epaper_spi/models/weact_bwr.py @@ -4,11 +4,17 @@ Supported models: - weact-2.13in-3c: 122x250 pixels (2.13" display) - weact-2.9in-3c: 128x296 pixels (2.9" display) - weact-4.2in-3c: 400x300 pixels (4.2" display) +- goodisplay-gdey029z95-2.9: 128x296 pixels (Good Display GDEY029Z95 2.9" display) These displays use SSD1680 or SSD1683 controller and require a specific initialization sequence. The DRV_OUT_CTL command is calculated from the display height. +The Good Display panel is driven as the WeAct ones, except that it must not get the +separate power-on before the refresh, so it has its own driver class, EPaperGoodDisplay3C. """ +import esphome.config_validation as cv +from esphome.const import CONF_BUSY_PIN, CONF_RESET_PIN + from . import EpaperModel @@ -17,8 +23,10 @@ class WeActBWR(EpaperModel): source_files = ("epaper_weact_3c.cpp",) - def __init__(self, name, **defaults): - super().__init__(name, "EPaperWeAct3C", **defaults) + def __init__( + self, name: str, class_name: str = "EPaperWeAct3C", **defaults: object + ) -> None: + super().__init__(name, class_name, **defaults) def get_init_sequence(self, config): """Generate initialization sequence for WeAct BWR displays. @@ -75,3 +83,27 @@ weact_4p2in3c = WeActBWR( data_rate="10MHz", minimum_update_interval="10s", ) + + +class GoodDisplayBWR(WeActBWR): + """Good Display Black/White/Red displays using SSD1680 controller.""" + + source_files = ("epaper_weact_3c.cpp", "epaper_goodisplay_3c.cpp") + + def __init__(self, name: str, **defaults: object) -> None: + super().__init__(name, "EPaperGoodDisplay3C", **defaults) + + def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required: + # Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy + if name in (CONF_RESET_PIN, CONF_BUSY_PIN): + return cv.Required(name) + return super().option(name, fallback) + + +# Model: Good Display GDEY029Z95 2.9" BWR - 128x296 pixels, SSD1680 controller +GoodDisplayBWR( + "goodisplay-gdey029z95-2.9", + width=128, + height=296, + minimum_update_interval="10s", +) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index bb612cb214c..28669544a09 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1303,8 +1303,8 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version: raise cv.Invalid("Only Arduino 3.0+ is supported.") recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] else: - if version < cv.Version(5, 0, 0): - raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") + if version < cv.Version(5, 1, 0): + raise cv.Invalid("Only ESP-IDF 5.1+ is supported.") recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] if version != recommended: @@ -2213,6 +2213,22 @@ def idf_version() -> cv.Version: return CORE.data[KEY_ESP32][KEY_IDF_VERSION] +def require_idf_version( + minimum: cv.Version, feature: str = "This feature" +) -> Callable[[Any], Any]: + """Validator for an option that needs the underlying ESP-IDF at ``minimum`` or newer.""" + + def validator(value: Any) -> Any: + cv.only_on_esp32(value) + if (version := idf_version()) < minimum: + raise cv.Invalid( + f"{feature} requires ESP-IDF {minimum} or newer, got {version}" + ) + return value + + return validator + + def require_fatfs() -> None: """Mark that FATFS support is required by a component. @@ -2851,9 +2867,9 @@ async def _reconcile_network_sdkconfig() -> None: # Software coexistence: enable when requested (the schema only allows it # alongside WiFi). Disable only in the Ethernet-without-WiFi case. if net.software_coexistence: - set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", True) + set_idf_sdkconfig_default("CONFIG_ESP_COEX_SW_COEXIST_ENABLE", True) elif wifi_disabled: - set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", False) + set_idf_sdkconfig_default("CONFIG_ESP_COEX_SW_COEXIST_ENABLE", False) # SoftAP support: drop it when WiFi is used without AP mode (IDF only). if not is_arduino and net.wifi and not net.wifi_ap: @@ -3031,10 +3047,9 @@ async def to_code(config): # assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and # IDF's noflash placement puts the flash driver's copies in DRAM. The # basename keeps the panic output useful at a fraction of the size. - # __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2. - if idf_version() >= cv.Version(5, 1, 0): - cg.add_build_flag("-D__FILE__=__FILE_NAME__") - cg.add_build_flag("-Wno-builtin-macro-redefined") + # __FILE_NAME__ is a GCC 12 builtin, which every supported IDF ships. + cg.add_build_flag("-D__FILE__=__FILE_NAME__") + cg.add_build_flag("-Wno-builtin-macro-redefined") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index a308f7fed4e..b3478e45449 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -473,17 +473,11 @@ def final_validation(config: ConfigType) -> None: # Check if hosted bluetooth is being used if ESP32_HOSTED_DOMAIN in full_config: - from esphome.components.esp32_hosted import uses_esp_hosted_3x - add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False) add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True) - if uses_esp_hosted_3x(): - add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True) - else: - add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True) - add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True) # Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client) has_ble_client = ( diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 1379584120b..777aac232ed 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -14,15 +14,10 @@ #include extern "C" { #include -#ifndef CONFIG_ESP_HOSTED_HOST_FEAT_BT -#include -#include -#endif } -#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT +#include #include #endif -#endif #include #include #include @@ -246,44 +241,28 @@ bool ESP32BLE::ble_setup_() { } ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1); -#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT - esp_hosted_bt_host_stack_cfg_t bt_cfg{}; - bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID; - bt_cfg.bring_up_controller = true; - bt_cfg.controller_ready_timeout_ms = EH_BT_CTRL_DEFAULT_READY_TIMEOUT_MS; - if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) { - ESP_LOGE(TAG, - "BT controller bring-up failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 - " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", - fw_ver.major1, fw_ver.minor1, fw_ver.patch1); - return false; - } -#else - if (esp_hosted_bt_controller_init() != ESP_OK) { + // Not the library's bring-up: its retry loop can block for minutes on a silent co-processor + err = eh_host_bt_controller_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { ESP_LOGE(TAG, "BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } - - if (esp_hosted_bt_controller_enable() != ESP_OK) { - ESP_LOGE(TAG, - "BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 - " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", - fw_ver.major1, fw_ver.minor1, fw_ver.patch1); + err = eh_host_bt_controller_enable(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "BT controller enable failed: %s; BLE disabled", esp_err_to_name(err)); return false; } - hosted_hci_bluedroid_open(); - - esp_bluedroid_hci_driver_operations_t operations = { - .send = hosted_hci_bluedroid_send, - .check_send_available = hosted_hci_bluedroid_check_send_available, - .register_host_callback = hosted_hci_bluedroid_register_host_callback, - }; - esp_bluedroid_attach_hci_driver(&operations); -#endif + esp_hosted_bt_host_stack_cfg_t bt_cfg{}; + bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID; + bt_cfg.bring_up_controller = false; + if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) { + ESP_LOGE(TAG, "BT host stack setup failed; BLE disabled"); + return false; + } #endif err = esp_bluedroid_init(); @@ -440,23 +419,11 @@ bool ESP32BLE::ble_dismantle_() { return false; } } -#elif defined(CONFIG_ESP_HOSTED_HOST_FEAT_BT) +#else if (esp_hosted_bt_host_stack_teardown() != ESP_OK) { ESP_LOGE(TAG, "esp_hosted_bt_host_stack_teardown failed"); return false; } -#else - if (esp_hosted_bt_controller_disable() != ESP_OK) { - ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed"); - return false; - } - - if (esp_hosted_bt_controller_deinit(false) != ESP_OK) { - ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed"); - return false; - } - - hosted_hci_bluedroid_close(); #endif return true; } diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index fd8da4baee7..02039b6e135 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -15,7 +15,7 @@ namespace esphome::esp32_ble { using ble_device_base::UUID_STR_LEN; -using ESPBTUUID = ble_device_base::ESPBTUUID; +using ble_device_base::ESPBTUUID; } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 6b71480ed3e..df1547fdb81 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -33,18 +33,18 @@ namespace esphome::esp32_ble_tracker { using namespace esp32_ble; -using adv_data_t = ble_device_base::adv_data_t; +using ble_device_base::adv_data_t; #ifdef USE_ESP32_BLE_UUID -using ServiceData = ble_device_base::ServiceData; +using ble_device_base::ServiceData; #endif #ifdef USE_ESP32_BLE_DEVICE // The advertisement device types are owned by the platform-neutral // ble_device_base layer; re-exported here (esp32 only) for backward // compatibility. ESPBTDevice::parse_scan_rst() (esp32-only) adapts BLEScanResult. -using ESPBLEiBeacon = ble_device_base::ESPBLEiBeacon; -using ESPBTDevice = ble_device_base::ESPBTDevice; +using ble_device_base::ESPBLEiBeacon; +using ble_device_base::ESPBTDevice; #endif // USE_ESP32_BLE_DEVICE class ESP32BLETracker; @@ -82,12 +82,12 @@ struct ClientStateCounts { // The client connection state types are owned by the platform-neutral // ble_device_base layer; re-exported here for backward compatibility. -using ClientState = ble_device_base::ClientState; -using ConnectionType = ble_device_base::ConnectionType; +using ble_device_base::ClientState; +using ble_device_base::ConnectionType; using ble_device_base::client_state_to_string; // Neutral scanner lifecycle re-exported for backward compatibility. -using ScannerState = ble_device_base::ScannerState; +using ble_device_base::ScannerState; /// Base class for BLE GATT clients that connect to remote devices. /// diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index a36fc763a68..c7feeceee92 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -132,9 +132,9 @@ void ESP32Camera::dump_config() { " External Clock: Pin:%d Frequency:%u\n" " I2C Pins: SDA:%d SCL:%d\n" " Reset Pin: %d", - this->name_.c_str(), YESNO(this->is_internal()), conf.pin_d0, conf.pin_d1, conf.pin_d2, conf.pin_d3, - conf.pin_d4, conf.pin_d5, conf.pin_d6, conf.pin_d7, conf.pin_vsync, conf.pin_href, conf.pin_pclk, - conf.pin_xclk, conf.xclk_freq_hz, conf.pin_sccb_sda, conf.pin_sccb_scl, conf.pin_reset); + LOG_STR_ARG(this->get_log_name()), YESNO(this->is_internal()), conf.pin_d0, conf.pin_d1, conf.pin_d2, + conf.pin_d3, conf.pin_d4, conf.pin_d5, conf.pin_d6, conf.pin_d7, conf.pin_vsync, conf.pin_href, + conf.pin_pclk, conf.pin_xclk, conf.xclk_freq_hz, conf.pin_sccb_sda, conf.pin_sccb_scl, conf.pin_reset); ESP_LOGCONFIG(TAG, " Resolution: %s", frame_size_to_str(this->config_.frame_size)); ESP_LOGCONFIG(TAG, " Pixel Format: %s", pixel_format_to_str(this->config_.pixel_format)); diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index f84f9c64642..64266209169 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -1,6 +1,6 @@ +import logging import os from pathlib import Path -import re from esphome import pins from esphome.components import esp32 @@ -8,33 +8,25 @@ from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, - CONF_COMPONENTS, CONF_CS_PIN, - CONF_FRAMEWORK, CONF_FREQUENCY, CONF_MISO_PIN, CONF_MOSI_PIN, - CONF_NAME, - CONF_REF, CONF_RESET_PIN, CONF_TYPE, CONF_VARIANT, - KEY_ESP32, ) -from esphome.core import CORE from esphome.cpp_generator import add_define -import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@swoboda1337"] DEPENDENCIES = ["esp32"] DOMAIN = "esp32_hosted" # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] -# 3.x is opt-in via a pin under esp32.framework.components (needs ESP-IDF 5.5) -ESP_HOSTED_COMPONENT = "espressif/esp_hosted" - CONF_ACTIVE_HIGH = "active_high" CONF_BUS_WIDTH = "bus_width" CONF_CMD_PIN = "cmd_pin" @@ -72,7 +64,7 @@ _MAX_CUSTOM_MSG_HANDLERS = 8 BASE_SCHEMA = cv.Schema( { cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True), - cv.Required(CONF_ACTIVE_HIGH): cv.boolean, + cv.Optional(CONF_ACTIVE_HIGH): cv.boolean, cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean, } @@ -83,7 +75,7 @@ SDIO_SCHEMA = BASE_SCHEMA.extend( cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_CMD_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_D0_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_D1_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_D1_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_D2_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_D3_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_BUS_WIDTH, default=4): cv.one_of(1, 4, int=True), @@ -95,9 +87,26 @@ SDIO_SCHEMA = BASE_SCHEMA.extend( ) +def _validate_active_high(config: ConfigType) -> ConfigType: + # Remove before 2027.5.0 + if (active_high := config.get(CONF_ACTIVE_HIGH)) is None: + return config + if not active_high: + raise cv.Invalid( + "esp_hosted always parks the reset line high with a low pulse, so " + "'active_high: false' is not supported.", + path=[CONF_ACTIVE_HIGH], + ) + _LOGGER.warning( + "'active_high' is deprecated and ignored, remove it from esp32_hosted. " + "Removed in 2027.5.0" + ) + return config + + def _validate_sdio(config: ConfigType) -> ConfigType: if config[CONF_BUS_WIDTH] == 4: - for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN): + for pin in (CONF_D2_PIN, CONF_D3_PIN): if pin not in config: raise cv.Invalid( f"{pin} is required when bus_width is 4", @@ -150,121 +159,26 @@ def _validate_spi(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.typed_schema( { - "sdio": cv.All(SDIO_SCHEMA, _validate_sdio), - "spi": cv.All(SPI_SCHEMA, _validate_spi), + "sdio": cv.All(SDIO_SCHEMA, _validate_sdio, _validate_active_high), + "spi": cv.All(SPI_SCHEMA, _validate_spi, _validate_active_high), }, default_type="sdio", ) -# Version specs that stay within one major (exact, ^, ~, ~=); other ranges are 3.x -_PINNED_MAJOR = re.compile(r"^(?:==|\^|~=|~)?(\d+)(?:\.(?:\d+|\*))*$") - - -def user_esp_hosted_major() -> int | None: - """Major version of a user-pinned esp_hosted, or None without a pin.""" - try: - full_config = fv.full_config.get() - except LookupError: - full_config = CORE.config - esp32_config = full_config.get(KEY_ESP32) or {} - for component in esp32_config.get(CONF_FRAMEWORK, {}).get(CONF_COMPONENTS, []): - if component.get(CONF_NAME) != ESP_HOSTED_COMPONENT: - continue - ref = component.get(CONF_REF, "").strip() - if not ref: - return 2 - if match := _PINNED_MAJOR.match(ref): - return int(match.group(1)) - return 3 - return None - - -def uses_esp_hosted_3x() -> bool: - """Whether the user pinned esp_hosted 3.x.""" - return (major := user_esp_hosted_major()) is not None and major >= 3 - - def _final_validate(config: ConfigType) -> None: - # The esp_hosted releases compatible with older ESP-IDF versions crash at - # boot with a heap double free in the SDIO RX path (fixed in esp_hosted - # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. - if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0): + if (idf_ver := esp32.idf_version()) < cv.Version(5, 5, 0): raise cv.Invalid( - f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. " + f"esp32_hosted requires ESP-IDF 5.5 or newer, got {idf_ver}. " "Remove the framework version from your configuration to use the " - "recommended version, or pin a version at or above 5.3." - ) - if (major := user_esp_hosted_major()) is None or major < 3: - return - if idf_ver < cv.Version(5, 5, 0): - raise cv.Invalid( - f"esp_hosted 3.x requires ESP-IDF 5.5 or newer, got {idf_ver}. " - f"Remove the {ESP_HOSTED_COMPONENT} pin from esp32.framework." - "components to stay on the 2.x line, or use ESP-IDF 5.5 or newer." - ) - if not config[CONF_ACTIVE_HIGH]: - raise cv.Invalid( - "esp_hosted 3.x always parks the reset line high with a low pulse, so " - "'active_high: false' cannot be expressed on it. Remove the " - f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay " - "on the 2.x line." - ) - if config[CONF_TYPE] == "sdio" and config[CONF_BUS_WIDTH] == 1: - raise cv.Invalid( - "esp_hosted 3.x cannot be built with a 1-bit SDIO bus " - "(espressif/esp-hosted-mcu#245). Remove the " - f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay " - "on the 2.x line, or use a 4-bit bus." + "recommended version, or pin a version at or above 5.5." ) FINAL_VALIDATE_SCHEMA = _final_validate -def _configure_sdio_2x(config: ConfigType) -> None: - slot = config[CONF_SLOT] - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}", - True, - ) - if config[CONF_BUS_WIDTH] == 1: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SDIO_1_BIT_BUS", True) - else: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS", True) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_{slot}", - config[CONF_CLK_PIN], - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_{slot}", - config[CONF_CMD_PIN], - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_{slot}", - config[CONF_D0_PIN], - ) - if config[CONF_BUS_WIDTH] == 4: - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_{slot}", - config[CONF_D1_PIN], - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_{slot}", - config[CONF_D2_PIN], - ) - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_{slot}", - config[CONF_D3_PIN], - ) - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS", True) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ", - int(config[CONF_SDIO_FREQUENCY] // 1000), - ) - - -def _configure_sdio_3x(config: ConfigType) -> None: +def _configure_sdio(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO", True) esp32.add_idf_sdkconfig_option( f"CONFIG_ESP_HOSTED_SDIO_SLOT_{config[CONF_SLOT]}", @@ -283,10 +197,11 @@ def _configure_sdio_3x(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0", config[CONF_D0_PIN] ) + # D1 carries the card interrupt on a 1-bit bus too + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN] + ) if config[CONF_BUS_WIDTH] == 4: - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN] - ) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2", config[CONF_D2_PIN] ) @@ -299,55 +214,7 @@ def _configure_sdio_3x(config: ConfigType) -> None: ) -def _configure_spi_2x(config: ConfigType) -> None: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True) - # SPI mode is set via per-variant choice options - variant = config[CONF_VARIANT] - mode = config[CONF_SPI_MODE] - suffix = "ESP32" if variant == "ESP32" else "ESP32XX" - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_SPI_PRIV_MODE_{mode}_{suffix}", - True, - ) - # Frequency is set via per-variant options - freq_mhz = int(config[CONF_FREQUENCY] // 1e6) - if variant == "ESP32": - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32", freq_mhz) - elif variant == "ESP32C6": - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32C6", freq_mhz) - else: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_FREQ_ESP32XX", freq_mhz) - # Pin configuration (use HSPI variant as P4/H2 hosts don't have VSPI) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_MOSI", config[CONF_MOSI_PIN] - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_MISO", config[CONF_MISO_PIN] - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_CLK", config[CONF_CLK_PIN] - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SPI_HSPI_GPIO_CS", config[CONF_CS_PIN] - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SPI_GPIO_HANDSHAKE", config[CONF_HANDSHAKE_PIN] - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_SPI_GPIO_DATA_READY", config[CONF_DATA_READY_PIN] - ) - # Handshake and data_ready polarity - if config[CONF_HANDSHAKE_ACTIVE_HIGH]: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HS_ACTIVE_HIGH", True) - else: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HS_ACTIVE_LOW", True) - if config[CONF_DATA_READY_ACTIVE_HIGH]: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_HIGH", True) - else: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True) - - -def _configure_spi_3x(config: ConfigType) -> None: +def _configure_spi(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SPI", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_HOST_SPI_MODE", config[CONF_SPI_MODE] @@ -392,59 +259,28 @@ def _configure_spi_3x(config: ConfigType) -> None: ) -def _configure_2x(config: ConfigType) -> None: - transport_prefix = "SDIO" if config[CONF_TYPE] == "sdio" else "SPI" - # Reset polarity - if config[CONF_ACTIVE_HIGH]: - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_{transport_prefix}_RESET_ACTIVE_HIGH", True - ) - else: - esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_{transport_prefix}_RESET_ACTIVE_LOW", True - ) - # Reset GPIO +async def to_code(config: ConfigType) -> None: + add_define("USE_ESP32_HOSTED") + # The board choice defaults to an Espressif dev board, which locks every + # pin's Kconfig range to that board and silently drops other values + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_P4_DEV_BOARD_NONE", True) esp32.add_idf_sdkconfig_option( - f"CONFIG_ESP_HOSTED_{transport_prefix}_GPIO_RESET_SLAVE", # NOLINT - config[CONF_RESET_PIN], + "CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN] ) - # Slave variant # NOLINT + # Co-processor variant. Wi-Fi Remote owns the choice and esp_hosted only + # offers the matching CP target, so both must name the same chip esp32.add_idf_sdkconfig_option( f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT True, ) - if config[CONF_TYPE] == "sdio": - _configure_sdio_2x(config) - else: - _configure_spi_2x(config) - if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: - # esp-hosted's CustomRpc ("peer data transfer") path — off by default. - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS - ) - # Place the transport mempool in PSRAM. Required on memory-tight host - # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM - # mempool allocation fails at boot with `sdio_mempool_create` assert. - if config[CONF_USE_PSRAM]: - esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) - - -def _configure_3x(config: ConfigType) -> None: - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN] - ) - # Co-processor variant esp32.add_idf_sdkconfig_option( f"CONFIG_ESP_HOSTED_CP_TARGET_{config[CONF_VARIANT]}", True, ) if config[CONF_TYPE] == "sdio": - _configure_sdio_3x(config) + _configure_sdio(config) else: - _configure_spi_3x(config) + _configure_spi(config) if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA", True) esp32.add_idf_sdkconfig_option( @@ -455,16 +291,6 @@ def _configure_3x(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_EH_HOST_PORT_DMA_PREFER_SPIRAM", True) esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DFLT_TASK_FROM_SPIRAM", True) - -async def to_code(config: ConfigType) -> None: - add_define("USE_ESP32_HOSTED") - use_3x = uses_esp_hosted_3x() - - if use_3x: - _configure_3x(config) - else: - _configure_2x(config) - # ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see # the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not # gated on the `espnow` component being present: the shim is tiny and the @@ -478,14 +304,14 @@ async def to_code(config: ConfigType) -> None: # esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides esp32.include_builtin_idf_component("esp_wifi") - # Library versions; this component set requires ESP-IDF 5.3 or newer, + # Library versions; this component set requires ESP-IDF 5.5 or newer, # which is enforced at validation time. idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.5") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13") + esp32.add_idf_component(name="espressif/esp_hosted", ref="3.0.10") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp index d226a83c1eb..3c64187be9f 100644 --- a/esphome/components/esp32_hosted/esp_now_hosted.cpp +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -425,7 +425,7 @@ esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len // task, and a shared build buffer here would let two callers corrupt each // other's frame. Passing the body through also drops a full-frame copy per // transmit, on the path this shim exists to keep quick. - uint8_t hdr[sizeof(esp_now_hosted_send_req_t)]; + uint8_t hdr[sizeof(esp_now_hosted_send_req_t)] = {}; auto *s = reinterpret_cast(hdr); s->has_addr = peer_addr != nullptr ? 1 : 0; if (peer_addr != nullptr) { diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 4ccd6a12f8b..74f95fe4eed 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -1,12 +1,21 @@ import hashlib +import logging +from pathlib import Path +from tempfile import TemporaryDirectory from typing import Any +import voluptuous as vol + +from esphome import external_files import esphome.codegen as cg from esphome.components import esp32, update from esphome.components.const import CONF_SHA256 import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE +from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE, CONF_URL from esphome.core import CORE, ID, HexInt +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@swoboda1337"] AUTO_LOAD = ["sha256", "watchdog", "json"] @@ -40,11 +49,15 @@ BASE_SCHEMA = update.update_schema(Esp32HostedUpdate, device_class="firmware").e cv.polling_component_schema("6h") ) -EMBEDDED_SCHEMA = BASE_SCHEMA.extend( - { - cv.Required(CONF_PATH): cv.file_, - cv.Required(CONF_SHA256): _validate_sha256, - } +EMBEDDED_SCHEMA = cv.All( + BASE_SCHEMA.extend( + { + cv.Optional(CONF_PATH): cv.file_, + cv.Optional(CONF_URL): cv.All(cv.url, vol.Match(r"^https?://")), + cv.Required(CONF_SHA256): _validate_sha256, + } + ), + cv.has_exactly_one_key(CONF_PATH, CONF_URL), ) HTTP_SCHEMA = BASE_SCHEMA.extend( @@ -70,21 +83,52 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_firmware(config: dict[str, Any]) -> None: - if config[CONF_TYPE] != TYPE_EMBEDDED: - return - - path = CORE.relative_config_path(config[CONF_PATH]) - with path.open("rb") as f: - firmware_data = f.read() - calculated = hashlib.sha256(firmware_data).hexdigest() - expected = config[CONF_SHA256].lower() +def _check_firmware_hash(data: bytes, expected: str, source: str | Path) -> None: + calculated = hashlib.sha256(data).hexdigest() if calculated != expected: raise cv.Invalid( - f"SHA256 mismatch for {config[CONF_PATH]}: expected {expected}, got {calculated}" + f"SHA256 mismatch for {source}: expected {expected}, got {calculated}" ) +def _firmware_path(config: ConfigType) -> Path: + """The configured image file, or the cache slot of the pinned digest for a downloaded one.""" + if CONF_URL in config: + cache_dir = external_files.compute_local_file_dir("esp32_hosted") / "firmware" + return cache_dir / f"{config[CONF_SHA256].lower()}.bin" + return CORE.relative_config_path(config[CONF_PATH]) + + +def _validate_firmware(config: ConfigType) -> None: + """Fetch and verify the image here; codegen only reads the file this leaves behind.""" + if config[CONF_TYPE] != TYPE_EMBEDDED: + return + expected = config[CONF_SHA256].lower() + target = _firmware_path(config) + if (url := config.get(CONF_URL)) is None: + _check_firmware_hash(target.read_bytes(), expected, target) + return + # A pinned digest needs no remote freshness check. + if target.is_file(): + actual = hashlib.sha256(target.read_bytes()).hexdigest() + if actual == expected: + return + # Named after its digest, so a mismatch means the file was damaged or edited + _LOGGER.warning( + "Cached firmware %s hashes to %s, downloading it again", target, actual + ) + target.parent.mkdir(parents=True, exist_ok=True) + # Keep unverified bytes out of the shared cache, even across concurrent builds. + with TemporaryDirectory(dir=target.parent) as temporary: + candidate = Path(temporary) / "firmware.bin" + data = external_files.download_content(url, candidate, allow_stale=False) + _check_firmware_hash(data, expected, url) + try: + candidate.replace(target) + except OSError as err: + raise cv.Invalid(f"Could not cache the firmware from {url}: {err}") from err + + FINAL_VALIDATE_SCHEMA = _validate_firmware @@ -92,9 +136,7 @@ async def to_code(config: dict[str, Any]) -> None: var = await update.new_update(config) if config[CONF_TYPE] == TYPE_EMBEDDED: - path = config[CONF_PATH] - with CORE.relative_config_path(path).open("rb") as f: - firmware_data = f.read() + firmware_data = _firmware_path(config).read_bytes() rhs = [HexInt(x) for x in firmware_data] arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8) prog_arr = cg.progmem_array(arr_id, rhs) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 7b9056bc809..d631963b08f 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -24,11 +24,16 @@ namespace esphome::esp32_hosted { ESPHOME_LOG_TAG(TAG, "esp32_hosted.update"); -// Older coprocessor firmware versions have a 1500-byte limit per RPC call -constexpr size_t CHUNK_SIZE = 1500; -// OTA begin blocks while the coprocessor erases its partition +// The host rejects writes above its per-RPC payload limit before they reach the coprocessor +constexpr size_t CHUNK_SIZE = EH_HOST_CP_OTA_CHUNK_MAX; +// OTA begin blocks while the coprocessor erases its partition, end while it validates the image constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000; +static esp_err_t ota_end_guarded() { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + return esp_hosted_slave_ota_end(); // NOLINT +} + #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // Interval/timeout IDs (uint32_t to avoid string comparison) constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; @@ -380,7 +385,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { } else { ESP_LOGE(TAG, "Error reading firmware data: %d", read_or_error); } - esp_hosted_slave_ota_end(); // NOLINT + ota_end_guarded(); container->end(); this->status_set_error(LOG_STR("Download failed")); return false; @@ -390,7 +395,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { err = esp_hosted_slave_ota_write(buffer, read_or_error); // NOLINT if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); - esp_hosted_slave_ota_end(); // NOLINT + ota_end_guarded(); container->end(); this->status_set_error(LOG_STR("Failed to write OTA data")); return false; @@ -402,7 +407,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { hasher.calculate(); if (!hasher.equals_bytes(this->firmware_sha256_.data())) { ESP_LOGE(TAG, "SHA256 mismatch"); - esp_hosted_slave_ota_end(); // NOLINT + ota_end_guarded(); this->status_set_error(LOG_STR("SHA256 verification failed")); return false; } @@ -451,7 +456,7 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() { err = esp_hosted_slave_ota_write(chunk, chunk_size); // NOLINT if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); - esp_hosted_slave_ota_end(); // NOLINT + ota_end_guarded(); this->status_set_error(LOG_STR("Failed to write OTA data")); return false; } @@ -494,7 +499,7 @@ void Esp32HostedUpdate::perform(bool force) { } // End OTA and activate new firmware - esp_err_t end_err = esp_hosted_slave_ota_end(); // NOLINT + esp_err_t end_err = ota_end_guarded(); if (end_err != ESP_OK) { ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err)); this->state_ = prev_state; diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index 12869a8b227..403b1a2aa10 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -341,13 +341,13 @@ void ESP32TouchComponent::loop() { child->publish_state(new_state); #ifdef USE_ESP32_VARIANT_ESP32 ESP_LOGV(TAG, "Touch Pad '%s' state: %s (value: %" PRIu32 ", threshold: %" PRIu32 ")", - child->get_name().c_str(), ONOFF(new_state), value, child->get_threshold()); + LOG_STR_ARG(child->get_log_name()), ONOFF(new_state), value, child->get_threshold()); #else if (new_state) { ESP_LOGV(TAG, "Touch Pad '%s' state: ON (value: %" PRIu32 ", benchmark: %" PRIu32 ", threshold: %" PRIu32 ")", - child->get_name().c_str(), value, benchmark, child->get_threshold()); + LOG_STR_ARG(child->get_log_name()), value, benchmark, child->get_threshold()); } else { - ESP_LOGV(TAG, "Touch Pad '%s' state: OFF", child->get_name().c_str()); + ESP_LOGV(TAG, "Touch Pad '%s' state: OFF", LOG_STR_ARG(child->get_log_name())); } #endif } @@ -470,7 +470,8 @@ void ESP32TouchComponent::process_setup_mode_logging_(uint32_t now) { child->value_ = smooth_value; #ifdef USE_ESP32_VARIANT_ESP32 - ESP_LOGD(TAG, "Touch Pad '%s' (Ch%d): %" PRIu32, child->get_name().c_str(), child->channel_id_, smooth_value); + ESP_LOGD(TAG, "Touch Pad '%s' (Ch%d): %" PRIu32, LOG_STR_ARG(child->get_log_name()), child->channel_id_, + smooth_value); #else uint32_t benchmark = 0; touch_channel_read_data(child->chan_handle_, TOUCH_CHAN_DATA_TYPE_BENCHMARK, &benchmark); @@ -479,7 +480,7 @@ void ESP32TouchComponent::process_setup_mode_logging_(uint32_t now) { ESP_LOGD(TAG, "Touch Pad '%s' (Ch%d): value=%" PRIu32 ", benchmark=%" PRIu32 ", difference=%" PRId32 " (set threshold < %" PRId32 " to detect touch)", - child->get_name().c_str(), child->channel_id_, smooth_value, benchmark, difference, difference); + LOG_STR_ARG(child->get_log_name()), child->channel_id_, smooth_value, benchmark, difference, difference); #endif } this->setup_mode_last_log_print_ = now; @@ -491,7 +492,7 @@ void ESP32TouchComponent::publish_initial_state_if_needed_(ESP32TouchBinarySenso if (now > INITIAL_STATE_DELAY_MS) { child->publish_initial_state(false); child->initial_state_published_ = true; - ESP_LOGV(TAG, "Touch Pad '%s' state: OFF (initial)", child->get_name().c_str()); + ESP_LOGV(TAG, "Touch Pad '%s' state: OFF (initial)", LOG_STR_ARG(child->get_log_name())); } } } diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 3c1e89673ea..af5917edbfe 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -48,7 +48,8 @@ static inline bool esp_rtc_user_mem_read(uint32_t index, uint32_t *dest) { if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) { return false; } - *dest = ESP_RTC_USER_MEM[index]; // NOLINT(performance-no-int-to-ptr,clang-analyzer-core.FixedAddressDereference) + // NOLINTNEXTLINE(performance-no-int-to-ptr,clang-analyzer-core.FixedAddressDereference,clang-analyzer-optin.core.FixedAddressDereference) + *dest = ESP_RTC_USER_MEM[index]; return true; } @@ -61,7 +62,8 @@ static inline bool esp_rtc_user_mem_write(uint32_t index, uint32_t value) { } auto *ptr = &ESP_RTC_USER_MEM[index]; // NOLINT(performance-no-int-to-ptr) - *ptr = value; // NOLINT(clang-analyzer-core.FixedAddressDereference) + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference,clang-analyzer-optin.core.FixedAddressDereference) + *ptr = value; return true; } diff --git a/esphome/components/esphome/ota/ota_esphome_udp.cpp b/esphome/components/esphome/ota/ota_esphome_udp.cpp index 7d549ae02ed..bc2838c768d 100644 --- a/esphome/components/esphome/ota/ota_esphome_udp.cpp +++ b/esphome/components/esphome/ota/ota_esphome_udp.cpp @@ -222,10 +222,13 @@ void ESPHomeOTAComponent::udp_open_(UdpLink &link) { if (!random_bytes(token, sizeof(token))) return; #ifdef ESPHOME_OTA_UDP_LWIP +#if USE_NETWORK_IPV4 uint32_t ipv4; if (socket::sockaddr_to_ipv4(reinterpret_cast(&peer), &ipv4)) { ip_addr_set_ip4_u32(&link.peer_ip, ipv4); - } else { + } else +#endif + { #if USE_NETWORK_IPV6 && LWIP_IPV6 inet6_addr_to_ip6addr(ip_2_ip6(&link.peer_ip), &reinterpret_cast(&peer)->sin6_addr); IP_SET_TYPE_VAL(link.peer_ip, IPADDR_TYPE_V6); diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 167db854da1..9876e57ce20 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -46,6 +46,7 @@ from esphome.const import ( CONF_TYPE, CONF_USE_ADDRESS, CONF_VALUE, + CONF_WIFI, KEY_CORE, KEY_FRAMEWORK_VERSION, Platform, @@ -562,6 +563,25 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate_wifi(config: ConfigType) -> None: + """A wifi station competes with ethernet for the primary interface, so both must be in network: priority:.""" + full_config = fv.full_config.get() + if (wifi_config := full_config.get(CONF_WIFI)) is None: + return + + from esphome.components.wifi import wifi_stays_ap_only + + if wifi_stays_ap_only(wifi_config, full_config): + return # an access point does not claim the primary interface + + priority_ifaces = get_priority_interfaces_from_full_config(full_config) + if missing := [i for i in (DOMAIN, CONF_WIFI) if i not in priority_ifaces]: + raise cv.Invalid( + "When ethernet and a wifi station are used together, 'network: priority:' " + f"must list both interfaces; missing: {', '.join(missing)}" + ) + + def _final_validate_spi(config: ConfigType) -> None: if not CORE.is_esp32: return # SPI interface validation is ESP32-only @@ -846,22 +866,7 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: """Final validation for Ethernet component.""" - # Allow ethernet + wifi coexistence only when both are declared in network: priority:. - if "wifi" in fv.full_config.get(): - priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get()) - missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces] - if missing and priority_ifaces: - # A priority list exists but is incomplete: point at what to add. - raise cv.Invalid( - "When ethernet and wifi are used together, 'network: priority:' must " - f"list both interfaces; missing: {', '.join(missing)}" - ) - if missing: - raise cv.Invalid( - "Component ethernet cannot be used together with component wifi " - "unless both are listed under 'network: priority:'" - ) - + _final_validate_wifi(config) _final_validate_spi(config) _final_validate_rmii_pins(config) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 671e11e7d21..70d48e0c2e9 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -17,11 +17,11 @@ void Event::trigger(const char *event_type) { } } if (found == nullptr) { - ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type); + ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", LOG_STR_ARG(this->get_log_name()), event_type); return; } this->last_event_type_ = found; - ESP_LOGV(TAG, "'%s' >> '%s'", this->get_name().c_str(), this->last_event_type_); + ESP_LOGV(TAG, "'%s' >> '%s'", LOG_STR_ARG(this->get_log_name()), this->last_event_type_); this->event_callback_.call(StringRef(found)); #if defined(USE_EVENT) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_event(this); diff --git a/esphome/components/ezo_pmp/ezo_pmp.cpp b/esphome/components/ezo_pmp/ezo_pmp.cpp index 393ba66fdf0..ef4bf30295c 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.cpp +++ b/esphome/components/ezo_pmp/ezo_pmp.cpp @@ -175,6 +175,8 @@ void EzoPMP::read_command_result_() { third_parameter_buffer[position_in_parameter_buffer] = current_char; third_parameter_buffer[position_in_parameter_buffer + 1] = '\0'; break; + default: + break; } position_in_parameter_buffer++; } diff --git a/esphome/components/factory_reset/button/__init__.py b/esphome/components/factory_reset/button/__init__.py index 040614c151a..723574aee73 100644 --- a/esphome/components/factory_reset/button/__init__.py +++ b/esphome/components/factory_reset/button/__init__.py @@ -1,29 +1,20 @@ -import esphome.codegen as cg from esphome.components import button -import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_RESTART, ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) from esphome.types import ConfigType -from .. import factory_reset_ns - -FactoryResetButton = factory_reset_ns.class_( - "FactoryResetButton", button.Button, cg.Component -) - CONFIG_SCHEMA = button.button_schema( - FactoryResetButton, + button.ApplyButton, device_class=DEVICE_CLASS_RESTART, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_RESTART_ALERT, -).extend(cv.COMPONENT_SCHEMA) +) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await button.register_button(var, config) + await button.sub_buttons(config).create( + config, None, apply="factory_reset::factory_reset_device()" + ) diff --git a/esphome/components/factory_reset/button/factory_reset_button.cpp b/esphome/components/factory_reset/button/factory_reset_button.cpp index 8ef7dc67eb4..3eac330f263 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.cpp +++ b/esphome/components/factory_reset/button/factory_reset_button.cpp @@ -13,24 +13,20 @@ namespace esphome::factory_reset { ESPHOME_LOG_TAG(TAG, "factory_reset.button"); -void FactoryResetButton::dump_config() { LOG_BUTTON("", "Factory Reset Button", this); } -void FactoryResetButton::press_action() { +static void reset_and_reboot() { + global_preferences->reset(); + App.safe_reboot(); +} + +void factory_reset_device() { ESP_LOGI(TAG, "Resetting"); // Let MQTT settle a bit delay(100); // NOLINT #ifdef USE_OPENTHREAD - openthread::global_openthread_component->on_factory_reset(FactoryResetButton::factory_reset_callback); + openthread::global_openthread_component->on_factory_reset(reset_and_reboot); #else - global_preferences->reset(); - App.safe_reboot(); + reset_and_reboot(); #endif } -#ifdef USE_OPENTHREAD -void FactoryResetButton::factory_reset_callback() { - global_preferences->reset(); - App.safe_reboot(); -} -#endif - } // namespace esphome::factory_reset diff --git a/esphome/components/factory_reset/button/factory_reset_button.h b/esphome/components/factory_reset/button/factory_reset_button.h index 0bb8a62f5eb..0a7aa87272b 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.h +++ b/esphome/components/factory_reset/button/factory_reset_button.h @@ -1,21 +1,8 @@ #pragma once -#include "esphome/core/defines.h" - -#include "esphome/components/button/button.h" -#include "esphome/core/component.h" - namespace esphome::factory_reset { -class FactoryResetButton final : public button::Button, public Component { - public: - void dump_config() override; -#ifdef USE_OPENTHREAD - static void factory_reset_callback(); -#endif - - protected: - void press_action() override; -}; +/// Run by the factory reset button. +void factory_reset_device(); } // namespace esphome::factory_reset diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index bbba0fc3f94..17131ef5ea1 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -42,14 +42,15 @@ FanCall &FanCall::set_preset_mode(const char *preset_mode, size_t len) { this->preset_mode_ = validated_mode; // Store pointer from traits } else { // Preset mode not found in traits - log warning and don't set - ESP_LOGW(TAG, "%s: Preset mode '%.*s' not supported", this->parent_.get_name().c_str(), (int) len, preset_mode); + ESP_LOGW(TAG, "%s: Preset mode '%.*s' not supported", LOG_STR_ARG(this->parent_.get_log_name()), (int) len, + preset_mode); this->preset_mode_ = nullptr; } return *this; } void FanCall::perform() { - ESP_LOGV(TAG, "'%s' - Setting:", this->parent_.get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting:", LOG_STR_ARG(this->parent_.get_log_name())); this->validate_(); if (this->binary_state_.has_value()) { ESP_LOGV(TAG, " State: %s", ONOFF(*this->binary_state_)); @@ -93,17 +94,17 @@ void FanCall::validate_() { } if (this->oscillating_.has_value() && !traits.supports_oscillation()) { - ESP_LOGW(TAG, "%s: Oscillation not supported", this->parent_.get_name().c_str()); + ESP_LOGW(TAG, "%s: Oscillation not supported", LOG_STR_ARG(this->parent_.get_log_name())); this->oscillating_.reset(); } if (this->speed_.has_value() && !traits.supports_speed()) { - ESP_LOGW(TAG, "%s: Speed control not supported", this->parent_.get_name().c_str()); + ESP_LOGW(TAG, "%s: Speed control not supported", LOG_STR_ARG(this->parent_.get_log_name())); this->speed_.reset(); } if (this->direction_.has_value() && !traits.supports_direction()) { - ESP_LOGW(TAG, "%s: Direction control not supported", this->parent_.get_name().c_str()); + ESP_LOGW(TAG, "%s: Direction control not supported", LOG_STR_ARG(this->parent_.get_log_name())); this->direction_.reset(); } } @@ -207,7 +208,7 @@ void Fan::publish_state() { ESP_LOGV(TAG, "'%s' >>\n" " State: %s", - this->name_.c_str(), ONOFF(this->state)); + LOG_STR_ARG(this->get_log_name()), ONOFF(this->state)); if (traits.supports_speed()) { ESP_LOGV(TAG, " Speed: %d", this->speed); } diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 8fa56bd370e..d59c3d1e745 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -98,7 +98,7 @@ void FeedbackCover::set_open_sensor(binary_sensor::BinarySensor *open_feedback) // setup callbacks to react to sensor changes open_feedback->add_on_state_callback([this](bool state) { - ESP_LOGD(TAG, "'%s' - Open feedback '%s'.", this->name_.c_str(), + ESP_LOGD(TAG, "'%s' - Open feedback '%s'.", LOG_STR_ARG(this->get_log_name()), state ? LOG_STR_LITERAL("STARTED") : LOG_STR_LITERAL("ENDED")); this->recompute_position_(); if (!state && this->infer_endstop_ && this->current_trigger_operation_ == COVER_OPERATION_OPENING) { @@ -112,7 +112,7 @@ void FeedbackCover::set_close_sensor(binary_sensor::BinarySensor *close_feedback this->close_feedback_ = close_feedback; close_feedback->add_on_state_callback([this](bool state) { - ESP_LOGD(TAG, "'%s' - Close feedback '%s'.", this->name_.c_str(), + ESP_LOGD(TAG, "'%s' - Close feedback '%s'.", LOG_STR_ARG(this->get_log_name()), state ? LOG_STR_LITERAL("STARTED") : LOG_STR_LITERAL("ENDED")); this->recompute_position_(); if (!state && this->infer_endstop_ && this->current_trigger_operation_ == COVER_OPERATION_CLOSING) { @@ -151,7 +151,7 @@ void FeedbackCover::endstop_reached_(bool open_endstop) { // from a position slightly past the endpoint if (this->current_trigger_operation_ == (open_endstop ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING)) { float dur = (now - this->start_dir_time_) / 1e3f; - ESP_LOGD(TAG, "'%s' - %s endstop reached. Took %.1fs.", this->name_.c_str(), + ESP_LOGD(TAG, "'%s' - %s endstop reached. Took %.1fs.", LOG_STR_ARG(this->get_log_name()), open_endstop ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close"), dur); // if there is no external mechanism, stop the cover @@ -198,7 +198,7 @@ void FeedbackCover::set_close_obstacle_sensor(binary_sensor::BinarySensor *close close_obstacle->add_on_state_callback([this](bool state) { if (state && (this->current_operation == COVER_OPERATION_CLOSING || this->current_trigger_operation_ == COVER_OPERATION_CLOSING)) { - ESP_LOGD(TAG, "'%s' - Close obstacle detected.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Close obstacle detected.", LOG_STR_ARG(this->get_log_name())); this->start_direction_(COVER_OPERATION_IDLE); if (this->obstacle_rollback_) { @@ -215,7 +215,7 @@ void FeedbackCover::set_open_obstacle_sensor(binary_sensor::BinarySensor *open_o open_obstacle->add_on_state_callback([this](bool state) { if (state && (this->current_operation == COVER_OPERATION_OPENING || this->current_trigger_operation_ == COVER_OPERATION_OPENING)) { - ESP_LOGD(TAG, "'%s' - Open obstacle detected.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Open obstacle detected.", LOG_STR_ARG(this->get_log_name())); this->start_direction_(COVER_OPERATION_IDLE); if (this->obstacle_rollback_) { @@ -251,7 +251,7 @@ void FeedbackCover::loop() { this->start_overshoot_time_ = now; } else if (now - this->start_overshoot_time_ > this->overshoot_duration_ || now - this->start_dir_time_ > this->max_duration_) { - ESP_LOGD(TAG, "'%s' - Overshoot duration reached. Stopping cover.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Overshoot duration reached. Stopping cover.", LOG_STR_ARG(this->get_log_name())); this->start_direction_(COVER_OPERATION_IDLE); } #endif @@ -259,7 +259,7 @@ void FeedbackCover::loop() { this->start_direction_(COVER_OPERATION_IDLE); } } else if (now - this->start_dir_time_ > this->max_duration_) { - ESP_LOGD(TAG, "'%s' - Max duration reached. Stopping cover.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Max duration reached. Stopping cover.", LOG_STR_ARG(this->get_log_name())); this->start_direction_(COVER_OPERATION_IDLE); } } @@ -390,7 +390,7 @@ void FeedbackCover::start_direction_(CoverOperation dir) { // check if there is an obstacle to start the new operation -> abort without any change // the case when an obstacle appears while moving is handled in the callback if (obstacle != nullptr && obstacle->state) { - ESP_LOGD(TAG, "'%s' - %s obstacle detected. Action not started.", this->name_.c_str(), + ESP_LOGD(TAG, "'%s' - %s obstacle detected. Action not started.", LOG_STR_ARG(this->get_log_name()), dir == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close")); return; } @@ -401,13 +401,13 @@ void FeedbackCover::start_direction_(CoverOperation dir) { if (this->direction_change_waittime_.has_value() && dir != COVER_OPERATION_IDLE && this->current_operation != COVER_OPERATION_IDLE && dir != this->current_operation) { const uint32_t waittime = *this->direction_change_waittime_; - ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Reversing direction.", LOG_STR_ARG(this->get_log_name())); this->start_direction_(COVER_OPERATION_IDLE); this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, waittime, [this, dir]() { this->start_direction_(dir); }); } else { this->set_current_operation_(dir, true); this->prev_command_trigger_ = trig; - ESP_LOGD(TAG, "'%s' - Firing '%s' trigger.", this->name_.c_str(), + ESP_LOGD(TAG, "'%s' - Firing '%s' trigger.", LOG_STR_ARG(this->get_log_name()), dir == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("OPEN") : dir == COVER_OPERATION_CLOSING ? LOG_STR_LITERAL("CLOSE") : LOG_STR_LITERAL("STOP")); diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 3abd1862f34..d8f14b5fac0 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -194,6 +194,8 @@ uint8_t FingerprintGrowComponent::scan_image_(uint8_t buffer) { ESP_LOGE(TAG, "Could not find fingerprint features"); this->finger_scan_invalid_callback_.call(); break; + default: + break; } return send_result; } diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index 68086d94660..4626916606a 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -28,7 +28,7 @@ void GPIOSwitch::dump_config() { for (auto *lock : this->interlock_) { if (lock == this) continue; - ESP_LOGCONFIG(TAG, " %s", lock->get_name().c_str()); + ESP_LOGCONFIG(TAG, " %s", LOG_STR_ARG(lock->get_log_name())); } } #endif diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 65a3ed2616f..8d948156386 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -318,20 +318,21 @@ void HaierClimateBase::process_protocol_reset() { bool HaierClimateBase::prepare_pending_action() { if (this->action_request_.has_value()) { - switch (this->action_request_.value().action) { + PendingAction &request = this->action_request_.value(); + switch (request.action) { case ActionRequest::SEND_CUSTOM_COMMAND: return true; case ActionRequest::TURN_POWER_ON: - this->action_request_.value().message = this->get_power_message(true); + request.message = this->get_power_message(true); return true; case ActionRequest::TURN_POWER_OFF: - this->action_request_.value().message = this->get_power_message(false); + request.message = this->get_power_message(false); return true; case ActionRequest::TOGGLE_POWER: - this->action_request_.value().message = this->get_power_message(this->mode == ClimateMode::CLIMATE_MODE_OFF); + request.message = this->get_power_message(this->mode == ClimateMode::CLIMATE_MODE_OFF); return true; default: - ESP_LOGW(TAG, "Unsupported action: %d", (uint8_t) this->action_request_.value().action); + ESP_LOGW(TAG, "Unsupported action: %d", (uint8_t) request.action); this->action_request_.reset(); return false; } diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 6eea5528675..7d5e9a4b546 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -1071,6 +1071,8 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * case (uint8_t) hon_protocol::FanMode::FAN_HIGH: this->fan_mode = CLIMATE_FAN_HIGH; break; + default: + break; } should_publish = should_publish || (!old_fan_mode.has_value()) || (old_fan_mode.value_or(CLIMATE_FAN_ON) != this->fan_mode.value_or(CLIMATE_FAN_ON)); @@ -1144,6 +1146,8 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * case (uint8_t) hon_protocol::ConditioningMode::AUTO: this->mode = CLIMATE_MODE_HEAT_COOL; break; + default: + break; } } should_publish = should_publish || (old_mode != this->mode); diff --git a/esphome/components/hdmi_cec/hdmi_cec.h b/esphome/components/hdmi_cec/hdmi_cec.h index d001a7ae23b..f1b6e90fa07 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.h +++ b/esphome/components/hdmi_cec/hdmi_cec.h @@ -22,10 +22,10 @@ namespace esphome::hdmi_cec { // Release/acquire store and load. The read-modify-write forms of std::atomic are not available on // every target (the ESP8266 has none), so a writer computes the new value itself; that is safe here // because every atomic in this component has exactly one writer. -template ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic &atom, T value) { +template inline ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic &atom, T value) { atom.store(value, std::memory_order_release); } -template ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic &atom) { +template inline ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic &atom) { return atom.load(std::memory_order_acquire); } diff --git a/esphome/components/he60r/he60r.cpp b/esphome/components/he60r/he60r.cpp index a15551f4c1a..76362915730 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -58,7 +58,7 @@ void HE60rCover::endstop_reached_(CoverOperation operation) { this->current_operation = COVER_OPERATION_IDLE; if (this->last_command_ == operation) { float dur = (float) (now - this->start_dir_time_) / 1e3f; - ESP_LOGD(TAG, "'%s' - %s endstop reached. Took %.1fs.", this->name_.c_str(), + ESP_LOGD(TAG, "'%s' - %s endstop reached. Took %.1fs.", LOG_STR_ARG(this->get_log_name()), operation == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close"), dur); } this->publish_state(); @@ -207,7 +207,7 @@ void HE60rCover::start_direction_(CoverOperation dir) { this->last_command_ = dir; if (this->current_operation == dir) return; - ESP_LOGD(TAG, "'%s' - Direction '%s' requested.", this->name_.c_str(), + ESP_LOGD(TAG, "'%s' - Direction '%s' requested.", LOG_STR_ARG(this->get_log_name()), dir == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("OPEN") : dir == COVER_OPERATION_CLOSING ? LOG_STR_LITERAL("CLOSE") : LOG_STR_LITERAL("STOP")); @@ -223,7 +223,7 @@ void HE60rCover::start_direction_(CoverOperation dir) { // just stop and reverse this->toggles_needed_ = 2; } - ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Reversing direction.", LOG_STR_ARG(this->get_log_name())); } this->start_dir_time_ = millis(); } diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 03148194fe6..72d86c42893 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.components.const import CONF_HORIZONTAL_DEFAULT, CONF_VERTICAL_DEFAULT import esphome.config_validation as cv from esphome.const import ( CONF_MAX_TEMPERATURE, @@ -76,13 +77,11 @@ PROTOCOLS = { "r51m": Protocol.PROTOCOL_R51M, } -CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = heatpumpir_ns.enum("HorizontalDirections") HORIZONTAL_DIRECTIONS = HorizontalDirections.options( "auto", "middle", "left", "mleft", "mright", "right", prefix="HORIZONTAL_DIRECTION_" ) -CONF_VERTICAL_DEFAULT = "vertical_default" VerticalDirections = heatpumpir_ns.enum("VerticalDirections") VERTICAL_DIRECTIONS = VerticalDirections.options( "auto", "up", "mup", "middle", "mdown", "down", prefix="VERTICAL_DIRECTION_" diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp index 4d92c35c77e..3c6de852f61 100644 --- a/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp @@ -40,6 +40,8 @@ void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENG case MITSUBISHI_HEAVY_ZMP_ECONO: climate.preset = climate::CLIMATE_PRESET_ECO; break; + default: + break; } uint8_t swing_h = frame[BYTE_SWING_H] & HORIZONTAL_SWING_MASK; diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.cpp b/esphome/components/hitachi_ac344/hitachi_ac344.cpp index c712500a53d..68be44cd6f2 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.cpp +++ b/esphome/components/hitachi_ac344/hitachi_ac344.cpp @@ -260,6 +260,8 @@ bool HitachiClimate::parse_mode_(const uint8_t remote_state[]) { case HITACHI_AC344_MODE_FAN: this->mode = climate::CLIMATE_MODE_FAN_ONLY; break; + default: + break; } } else { this->mode = climate::CLIMATE_MODE_OFF; @@ -294,6 +296,8 @@ bool HitachiClimate::parse_fan_(const uint8_t remote_state[]) { case HITACHI_AC344_FAN_AUTO: this->fan_mode = climate::CLIMATE_FAN_AUTO; break; + default: + break; } return true; } diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.cpp b/esphome/components/hitachi_ac424/hitachi_ac424.cpp index 229cdad6aa8..ab3351e138a 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.cpp +++ b/esphome/components/hitachi_ac424/hitachi_ac424.cpp @@ -255,6 +255,8 @@ bool HitachiClimate::parse_mode_(const uint8_t remote_state[]) { case HITACHI_AC424_MODE_FAN: this->mode = climate::CLIMATE_MODE_FAN_ONLY; break; + default: + break; } } else { this->mode = climate::CLIMATE_MODE_OFF; @@ -289,6 +291,8 @@ bool HitachiClimate::parse_fan_(const uint8_t remote_state[]) { case HITACHI_AC424_FAN_AUTO: this->fan_mode = climate::CLIMATE_FAN_AUTO; break; + default: + break; } return true; } diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp index 5c830c8c923..f49cef8ee30 100644 --- a/esphome/components/homeassistant/button/homeassistant_button.cpp +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -27,7 +27,7 @@ void HomeassistantButton::press_action() { #ifdef USE_API_WIZARD_LINKED_INPUTS if (this->entity_id_[0] == '\0') { - ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s': No entity ID set yet", LOG_STR_ARG(this->get_log_name())); return; } #endif diff --git a/esphome/components/homeassistant/light/homeassistant_light.cpp b/esphome/components/homeassistant/light/homeassistant_light.cpp index 21823bb9097..f272fc6df6d 100644 --- a/esphome/components/homeassistant/light/homeassistant_light.cpp +++ b/esphome/components/homeassistant/light/homeassistant_light.cpp @@ -203,12 +203,8 @@ void HomeassistantLight::on_report_(bool changed) { this->cancel_timeout(ECHO_TIMEOUT_ID); } // Noted as the report arrives, not when the deferred apply runs: a call read from the same loop - // pass before this report is older than it, one read after it is newer and must not pass for - // the transition the report is held for - this->report_in_transition_ = this->state_->is_transitioning(); - if (this->report_in_transition_) { - this->held_hash_ = state_hash(this->state_->remote_values); - } + // pass before this report is older than it, one read after it is newer + this->state_->clear_called(); } this->schedule_apply_(); } @@ -257,8 +253,9 @@ void HomeassistantLight::apply_imported_() { } if (!this->state_->set_external_state(values)) { // A report that changed something during a transition or flash is applied once it ends, in - // place of the values it ends at; a trait or a repeated report has nothing newer to say - if (this->report_in_transition_) { + // place of the values it ends at, unless a call came after the report; a trait or a repeated + // report has nothing newer to say + if (this->state_->is_transitioning() && !this->state_->called_since_clear()) { this->report_held_ = true; return; } @@ -268,20 +265,9 @@ void HomeassistantLight::apply_imported_() { ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_); return; } - this->report_in_transition_ = false; ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_); } -uint32_t HomeassistantLight::state_hash(const LightColorValues &values) { - uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast(values.get_color_mode())); - for (const float channel : - {values.get_state(), values.get_brightness(), values.get_color_brightness(), values.get_red(), - values.get_green(), values.get_blue(), values.get_white(), values.get_cold_white(), values.get_warm_white()}) { - hash = fnv1a_hash_extend(hash, to_uint8_scale(channel)); - } - return fnv1a_hash_extend(hash, static_cast(values.get_color_temperature())); // mireds exceed a byte -} - void HomeassistantLight::update_state(LightState *state) { // The light core's setup performs one call for the initial state; a second one before the first // write (an on_boot automation) is a local change merged into that write, so it must be sent @@ -291,11 +277,10 @@ void HomeassistantLight::update_state(LightState *state) { } this->initial_seen_ = true; } - // A call without a transition replaces whatever ran, so a report held for it, or still waiting to - // be applied, is older than the call - if (!state->is_transitioning()) { + // Every call that changes the light reaches here within a loop pass (a new transformer's first + // apply() or set_immediately_()), and a call since the held report was read is newer than it + if (this->report_held_ && state->called_since_clear()) { this->report_held_ = false; - this->report_in_transition_ = false; } } @@ -338,11 +323,7 @@ void HomeassistantLight::write_state(LightState *state) { return; } if (state->is_transitioning()) { - // A transition or flash runs here alone; Home Assistant gets the state it ends at. A call that - // replaces the transition a report was held for retargets it and is newer than the report - if (this->report_held_ && state_hash(state->remote_values) != this->held_hash_) { - this->report_held_ = false; - } + // A transition or flash runs here alone; Home Assistant gets the state it ends at this->transitioned_ = true; return; } @@ -350,7 +331,6 @@ void HomeassistantLight::write_state(LightState *state) { const bool transitioned = this->transitioned_; this->report_held_ = false; this->transitioned_ = false; - this->report_in_transition_ = false; // this change is newer than any report so far, held or dropped // Home Assistant changed meanwhile; its report is newer than what the transition ends at or // what the flash goes back to if (held) { @@ -408,7 +388,7 @@ void HomeassistantLight::write_state(LightState *state) { } #ifdef USE_API_WIZARD_LINKED_INPUTS if (this->entity_id_[0] == '\0') { - ESP_LOGW(TAG, "'%s': No entity ID set yet", state->get_name().c_str()); + ESP_LOGW(TAG, "'%s': No entity ID set yet", LOG_STR_ARG(state->get_log_name())); this->fall_back_(); return; } diff --git a/esphome/components/homeassistant/light/homeassistant_light.h b/esphome/components/homeassistant/light/homeassistant_light.h index a90a39f2c0a..fbf46b26503 100644 --- a/esphome/components/homeassistant/light/homeassistant_light.h +++ b/esphome/components/homeassistant/light/homeassistant_light.h @@ -48,8 +48,6 @@ class HomeassistantLight final : public light::LightOutput, public Component { this->on_report_(field != value); field = value; } - /// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call - static uint32_t state_hash(const light::LightColorValues &values); /// Schedules apply_imported_() once for the attributes of one update void schedule_apply_(); void apply_imported_(); @@ -63,14 +61,12 @@ class HomeassistantLight final : public light::LightOutput, public Component { light::LightTraits traits_{}; Attributes imported_{}; bool apply_scheduled_ : 1 {false}; - bool initial_write_ : 1 {true}; // the light core's own first write is not a local change - bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state - bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back - bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends - bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent - bool report_in_transition_ : 1 {false}; // the last changed report, not yet applied, arrived during a transition + bool initial_write_ : 1 {true}; // the light core's own first write is not a local change + bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state + bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back + bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends + bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent uint8_t report_timeout_s_{10}; - uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at }; } // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 73efc8e5922..e1e10896d1e 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -32,7 +32,7 @@ void HomeassistantNumber::min_retrieved_(StringRef min) { ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str()); return; } - ESP_LOGD(TAG, "'%s': Min retrieved: %s", get_name().c_str(), min.c_str()); + ESP_LOGD(TAG, "'%s': Min retrieved: %s", LOG_STR_ARG(this->get_log_name()), min.c_str()); this->traits.set_min_value(min_value.value()); } @@ -42,7 +42,7 @@ void HomeassistantNumber::max_retrieved_(StringRef max) { ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str()); return; } - ESP_LOGD(TAG, "'%s': Max retrieved: %s", get_name().c_str(), max.c_str()); + ESP_LOGD(TAG, "'%s': Max retrieved: %s", LOG_STR_ARG(this->get_log_name()), max.c_str()); this->traits.set_max_value(max_value.value()); } @@ -52,7 +52,7 @@ void HomeassistantNumber::step_retrieved_(StringRef step) { ESP_LOGE(TAG, "'%s': Can't convert 'step' value '%s' to number!", this->entity_id_, step.c_str()); return; } - ESP_LOGD(TAG, "'%s': Step Retrieved %s", get_name().c_str(), step.c_str()); + ESP_LOGD(TAG, "'%s': Step Retrieved %s", LOG_STR_ARG(this->get_log_name()), step.c_str()); this->traits.set_step(step_value.value()); } @@ -83,7 +83,7 @@ void HomeassistantNumber::control(float value) { #ifdef USE_API_WIZARD_LINKED_INPUTS if (this->entity_id_[0] == '\0') { - ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s': No entity ID set yet", LOG_STR_ARG(this->get_log_name())); return; } #endif diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index 75b77ca7bb8..6f9075de049 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -123,7 +123,7 @@ void HomeassistantSelect::control(size_t index) { #ifdef USE_API_WIZARD_LINKED_INPUTS if (this->entity_id_[0] == '\0') { - ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s': No entity ID set yet", LOG_STR_ARG(this->get_log_name())); return; } #endif diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index f6fbdc1ef48..f09b8b9dc55 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -43,7 +43,7 @@ void HomeassistantSwitch::write_state(bool state) { #ifdef USE_API_WIZARD_LINKED_INPUTS if (this->entity_id_[0] == '\0') { - ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s': No entity ID set yet", LOG_STR_ARG(this->get_log_name())); return; } #endif diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp index 2a5598756a5..66377bf008a 100644 --- a/esphome/components/homeassistant/text/homeassistant_text.cpp +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -82,7 +82,7 @@ void HomeassistantText::control(const std::string &value) { #ifdef USE_API_WIZARD_LINKED_INPUTS if (this->entity_id_[0] == '\0') { - ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s': No entity ID set yet", LOG_STR_ARG(this->get_log_name())); return; } #endif diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index e22270f1f2e..675d37c4d28 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -35,7 +35,7 @@ async def to_code(config: ConfigType) -> None: await cg.add_variable(var.set_ota_parent, config[CONF_OTA_ID]) await cg.add_variable(var.set_request_parent, config[CONF_HTTP_REQUEST_ID]) - cg.add(var.set_source_url(config[CONF_SOURCE])) + cg.add(var.set_source_url_static(config[CONF_SOURCE])) ota.request_ota_state_listeners() diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index bedb0e98e70..2fb9fcdee5e 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -24,12 +24,6 @@ namespace esphome::http_request { ESPHOME_LOG_TAG(TAG, "http_request.update"); -// Wraps UpdateInfo + error for the task→main-loop handoff. -struct TaskResult { - update::UpdateInfo info; - const LogString *error_str{nullptr}; -}; - static const size_t MAX_READ_SIZE = 256; static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0; static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000; @@ -68,6 +62,37 @@ void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_ } } +void HttpRequestUpdate::set_source_url(const char *source_url, size_t len) { + // Copy before releasing, so a caller passing the current URL back in stays valid. + char *copy = new char[len + 1]; + memcpy(copy, source_url, len); + copy[len] = '\0'; + this->release_source_url_(); + this->source_url_ = copy; + this->source_url_owned_ = true; +} + +void HttpRequestUpdate::release_source_url_() { + if (this->source_url_owned_) + delete[] this->source_url_; + this->source_url_owned_ = false; +} + +void HttpRequestUpdate::begin_check_(TaskResult *result) { + result->update = this; + result->source_url = this->source_url_; + result->owns_source_url = this->source_url_owned_; + this->source_url_owned_ = false; +} + +void HttpRequestUpdate::end_check_(TaskResult *result) { + if (this->source_url_ == result->source_url) { + this->source_url_owned_ = result->owns_source_url; + } else if (result->owns_source_url) { + delete[] result->source_url; // replaced while the check ran + } +} + void HttpRequestUpdate::update() { if (!network::is_connected()) { ESP_LOGD(TAG, "Network not connected, skipping update check"); @@ -75,28 +100,37 @@ void HttpRequestUpdate::update() { } this->cancel_interval(INITIAL_CHECK_INTERVAL_ID); #ifdef USE_ESP32 - if (this->update_task_handle_ != nullptr) { + if (this->task_result_ != nullptr) { ESP_LOGW(TAG, "Update check already in progress"); return; } - xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); +#endif + // Allocated once per check; every path in the task returns via its single defer, which frees it. + auto *result = new TaskResult(); + this->begin_check_(result); +#ifdef USE_ESP32 + this->task_result_ = result; + if (xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, result, 1, nullptr) != pdPASS) { + this->task_result_ = nullptr; + this->end_check_(result); + delete result; + } #else - this->update_task(this); + this->update_task(result); + this->end_check_(result); #endif } void HttpRequestUpdate::update_task(void *params) { - HttpRequestUpdate *this_update = (HttpRequestUpdate *) params; - - // Allocate once — every path below returns via the single defer at the end. - // On failure, error_str is set; on success it is nullptr. - auto *result = new TaskResult(); + auto *result = static_cast(params); + HttpRequestUpdate *this_update = result->update; auto *info = &result->info; + const char *source_url = result->source_url; - auto container = this_update->request_parent_->get(this_update->source_url_); + auto container = this_update->request_parent_->get(source_url); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", source_url); if (container != nullptr) container->end(); result->error_str = LOG_STR("Failed to fetch manifest"); @@ -176,14 +210,14 @@ void HttpRequestUpdate::update_task(void *params) { allocator.deallocate(data, content_length); if (!valid) { - ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_); + ESP_LOGE(TAG, "Failed to parse JSON from %s", source_url); result->error_str = LOG_STR("Failed to parse manifest JSON"); goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } // Merge source_url_ and firmware_url if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { - const char *source = this_update->source_url_; + const char *source = source_url; const size_t source_len = strlen(source); size_t prefix_len; if (info->firmware_url[0] == '/') { @@ -215,7 +249,8 @@ defer: // Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains. this_update->defer([this_update, result]() { #ifdef USE_ESP32 - this_update->update_task_handle_ = nullptr; + this_update->task_result_ = nullptr; + this_update->end_check_(result); #endif if (result->error_str != nullptr) { this_update->status_set_error(result->error_str); diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index 05a741b6cd9..353d620c3bd 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -13,6 +13,21 @@ namespace esphome::http_request { +namespace testing { +class HttpRequestUpdateTest; +} // namespace testing + +class HttpRequestUpdate; + +// One manifest check: handed from update() to the task and back to the main loop. +struct TaskResult { + HttpRequestUpdate *update{nullptr}; + const char *source_url{nullptr}; // the URL this check reads, fixed before it starts + update::UpdateInfo info; + const LogString *error_str{nullptr}; // set on failure, nullptr on success + bool owns_source_url{false}; // the check holds the buffer until it ends, see begin_check_() +}; + class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener { public: void setup() override; @@ -21,7 +36,18 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo void perform(bool force) override; void check() override { this->update(); } - void set_source_url(const char *source_url) { this->source_url_ = source_url; } + ~HttpRequestUpdate() override { this->release_source_url_(); } + + /// Codegen only: the config literal lives in flash for the life of the program, so it is kept by pointer. + void set_source_url_static(const char *source_url) { + this->release_source_url_(); + this->source_url_ = source_url; + } + /// Set the manifest URL at run time; the string is copied, so any storage may be passed. + void set_source_url(const char *source_url) { this->set_source_url(source_url, strlen(source_url)); } + void set_source_url(const std::string &source_url) { this->set_source_url(source_url.data(), source_url.size()); } + void set_source_url(const char *source_url, size_t len); + const char *get_source_url() const { return this->source_url_; } void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; } void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; } @@ -36,12 +62,21 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo static void update_task(void *params); #ifdef USE_ESP32 - TaskHandle_t update_task_handle_{nullptr}; + TaskResult *task_result_{nullptr}; // set while a check runs on the update task #endif uint8_t initial_check_remaining_{0}; private: - const char *source_url_{nullptr}; // literal from codegen + friend class testing::HttpRequestUpdateTest; + void release_source_url_(); + // A check reads the URL it started with until it ends, on the update task or inline through an + // automation it triggers. Ownership of that buffer moves to the check, so a set_source_url in the + // meantime cannot free it; end_check_() hands it back or frees it on the main loop. + void begin_check_(TaskResult *result); + void end_check_(TaskResult *result); + + bool source_url_owned_{false}; // source_url_ was copied by a run-time setter; sits in the padding above + const char *source_url_{nullptr}; }; } // namespace esphome::http_request diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index c6bf03b72e1..dfae3bd8259 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -25,7 +25,7 @@ void HX711Sensor::update() { uint32_t result; if (this->read_sensor_(&result)) { int32_t value = static_cast(result); - ESP_LOGD(TAG, "'%s': Got value %" PRId32, this->name_.c_str(), value); + ESP_LOGD(TAG, "'%s': Got value %" PRId32, LOG_STR_ARG(this->get_log_name()), value); this->publish_state(value); } } diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index e25980545cf..437474d595b 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -69,7 +69,7 @@ size_t ImprovBase::get_formatted_next_url_(char *buffer, size_t buffer_size) { void ImprovBase::add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len) { // The builder rejects strings above 254 bytes, so anything longer than this // buffer could never be sent anyway - char url_buffer[256]; + char url_buffer[256] = {}; size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer)); if (len == 0) { return; diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 9b766a8996c..78077af8fb8 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -11,7 +11,7 @@ void Infrared::dump_config() { "Infrared '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + LOG_STR_ARG(this->get_log_name()), YESNO(this->get_supports_transmitter()), YESNO(this->get_supports_receiver())); } diff --git a/esphome/components/ir_rf_base/ir_rf_base.cpp b/esphome/components/ir_rf_base/ir_rf_base.cpp index 91db4252c07..4bfdc7b7ef6 100644 --- a/esphome/components/ir_rf_base/ir_rf_base.cpp +++ b/esphome/components/ir_rf_base/ir_rf_base.cpp @@ -143,7 +143,7 @@ bool IrRfEntity::expect_api_reply_(api::APIConnection *conn) { if (this->api_reply_ == ApiReply::API_REPLY_WAITING) this->finish_api_reply_(false); if (this->api_reply_ != ApiReply::API_REPLY_NONE && !this->send_api_reply_()) { - ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("refused, reply still owed")); + ESP_LOGW(TAG, "'%s': transmit %s", LOG_STR_ARG(this->get_log_name()), LOG_STR_LITERAL("refused, reply still owed")); this->refuse_api_call_(conn); return false; } @@ -198,13 +198,14 @@ void IrRfEntity::loop() { if (this->api_reply_ != ApiReply::API_REPLY_WAITING) { if (this->send_api_reply_() || remaining > 0) return; - ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("reply dropped, client not reading")); + ESP_LOGW(TAG, "'%s': transmit %s", LOG_STR_ARG(this->get_log_name()), + LOG_STR_LITERAL("reply dropped, client not reading")); this->clear_api_reply_(); return; } if (remaining > 0) return; - ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("never reported completion")); + ESP_LOGW(TAG, "'%s': transmit %s", LOG_STR_ARG(this->get_log_name()), LOG_STR_LITERAL("never reported completion")); this->finish_api_reply_(false); } diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index 0eae11d1a51..c88f1315916 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -17,7 +17,7 @@ void IrRfProxy::dump_config() { "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + LOG_STR_ARG(this->get_log_name()), YESNO(this->get_supports_transmitter()), YESNO(this->get_supports_receiver())); if (this->is_rf()) { @@ -44,7 +44,7 @@ void RfProxy::dump_config() { "RF Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + LOG_STR_ARG(this->get_log_name()), YESNO(this->get_supports_transmitter()), YESNO(this->get_supports_receiver())); const auto &traits = this->traits_; diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9b4cc53a2e8..1147191b1d7 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "esphome/core/defines.h" @@ -50,15 +51,15 @@ template class SerializationBuffer { ~SerializationBuffer() { delete[] heap_buffer_; } // Move constructor - works with same template instantiation - SerializationBuffer(SerializationBuffer &&other) noexcept : heap_buffer_(other.heap_buffer_), size_(other.size_) { + SerializationBuffer(SerializationBuffer &&other) noexcept + : heap_buffer_(std::exchange(other.heap_buffer_, nullptr)), size_(other.size_) { if (other.buffer_ == other.stack_buffer_) { // Stack buffer - must copy content std::memcpy(stack_buffer_, other.stack_buffer_, size_ + 1); buffer_ = stack_buffer_; } else { - // Heap buffer - steal ownership + // Heap buffer - ownership was taken above buffer_ = heap_buffer_; - other.heap_buffer_ = nullptr; } // Leave moved-from object in valid empty state other.stack_buffer_[0] = '\0'; @@ -70,14 +71,13 @@ template class SerializationBuffer { SerializationBuffer &operator=(SerializationBuffer &&other) noexcept { if (this != &other) { delete[] heap_buffer_; - heap_buffer_ = other.heap_buffer_; + heap_buffer_ = std::exchange(other.heap_buffer_, nullptr); size_ = other.size_; if (other.buffer_ == other.stack_buffer_) { std::memcpy(stack_buffer_, other.stack_buffer_, size_ + 1); buffer_ = stack_buffer_; } else { buffer_ = heap_buffer_; - other.heap_buffer_ = nullptr; } // Leave moved-from object in valid empty state other.stack_buffer_[0] = '\0'; diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index 647227524e6..27c81cb6d7d 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -61,6 +61,8 @@ void Kuntze::on_read_holding_registers(uint16_t start_address, std::spanoci_sensor_ != nullptr) this->oci_sensor_->publish_state(value); break; + default: + break; } } diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 74660d7f2e9..7f94231d761 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -407,12 +407,10 @@ void LD2410Component::handle_periodic_data_() { } #ifdef USE_NUMBER -std::function set_number_value(number::Number *n, float value) { +void set_number_value(number::Number *n, float value) { if (n != nullptr && (!n->has_state() || n->state != value)) { - n->state = value; - return [n, value]() { n->publish_state(value); }; + n->publish_state(value); } - return []() {}; } #endif @@ -560,29 +558,24 @@ bool LD2410Component::handle_ack_data_() { Moving distance range: 13th byte Still distance range: 14th byte */ - std::vector> updates; - updates.push_back(set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12])); - updates.push_back(set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13])); + set_number_value(this->max_move_distance_gate_number_, this->buffer_data_[12]); + set_number_value(this->max_still_distance_gate_number_, this->buffer_data_[13]); /* Moving Sensitivities: 15~23th bytes */ - for (std::vector::size_type i = 0; i != this->gate_move_threshold_numbers_.size(); i++) { - updates.push_back(set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i])); + for (size_t i = 0; i < TOTAL_GATES; i++) { + set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[14 + i]); } /* Still Sensitivities: 24~32th bytes */ - for (std::vector::size_type i = 0; i != this->gate_still_threshold_numbers_.size(); i++) { - updates.push_back(set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i])); + for (size_t i = 0; i < TOTAL_GATES; i++) { + set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[23 + i]); } /* None Duration: 33~34th bytes */ - updates.push_back( - set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32]))); - for (auto &update : updates) { - update(); - } + set_number_value(this->timeout_number_, encode_uint16(this->buffer_data_[33], this->buffer_data_[32])); #endif break; } diff --git a/esphome/components/ld2450/polygon_zone.cpp b/esphome/components/ld2450/polygon_zone.cpp index ecafd40360b..d9a60d6b552 100644 --- a/esphome/components/ld2450/polygon_zone.cpp +++ b/esphome/components/ld2450/polygon_zone.cpp @@ -15,7 +15,7 @@ void PolygonZone::setup() { if (saved.is_valid()) { this->polygon_ = saved; } else { - ESP_LOGW(TAG, "'%s': saved polygon is invalid; zone disabled", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s': saved polygon is invalid; zone disabled", LOG_STR_ARG(this->get_log_name())); } } // Presence stays unknown until the first radar frame @@ -33,11 +33,11 @@ void PolygonZone::dump_config() { void PolygonZone::control(const std::string &value) { Polygon polygon; if (!polygon.parse(value.c_str(), value.size())) { - ESP_LOGW(TAG, "'%s': invalid polygon '%s'; expected 'x,y;x,y;...' with %u to %u points", this->get_name().c_str(), - value.c_str(), MIN_POLYGON_POINTS, MAX_POLYGON_POINTS); + ESP_LOGW(TAG, "'%s': invalid polygon '%s'; expected 'x,y;x,y;...' with %u to %u points", + LOG_STR_ARG(this->get_log_name()), value.c_str(), MIN_POLYGON_POINTS, MAX_POLYGON_POINTS); } else if (!this->pref_.save(&polygon)) { // On ESP8266, the space for saved settings is small and can run out with several zones - ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", LOG_STR_ARG(this->get_log_name())); } else { this->polygon_ = polygon; // A target seen inside the previous polygon must not keep the new one on diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 51682358e7c..131e70aad97 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -1041,8 +1041,8 @@ void LD6002BComponent::publish_number_clamped_(number::Number *number, float val // Outside the declared range the user cannot write the value back, so publish // what they can reach and say what the module actually sent. if (value < min_value || value > max_value) { - ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", number->get_name().c_str(), value, min_value, - max_value); + ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", LOG_STR_ARG(number->get_log_name()), value, + min_value, max_value); value = std::clamp(value, min_value, max_value); } number->publish_state(value); diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 18ce19a183b..0039f38adea 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -169,7 +169,9 @@ void LEDCOutput::setup() { #if !defined(CLANG_TIDY) #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } -#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) || \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) && !defined(__PERIPH_CTRL_ALLOW_LEGACY_API)) + // Also on the targets whose periph_module_reset() is an empty stub in ESP-IDF 5.x (P4, C5, C61, H4) PERIPH_RCC_ATOMIC() { ledc_ll_enable_reset_reg(true); ledc_ll_enable_reset_reg(false); diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 9d21e88b14b..3288c930849 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -230,7 +230,8 @@ bool LibreTinyPreferences::is_changed_(fdb_kvdb_t db, const NVSData &to_save, co return true; } - // Compare the actual data + // Compare the actual data. fdb_kv_get_blob filled stored_data through this->blob, which the analyzer cannot see. + // NOLINTNEXTLINE(clang-analyzer-unix.cstring.UninitializedRead) return memcmp(to_save.data.data(), stored_data.get(), kv.value_len) != 0; } diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dcd88a80ea4..af01eedb754 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -37,6 +37,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority from esphome.core.entity_helpers import ( + RESTORE_STATE_NONE, entity_duplicate_validator, queue_entity_register, setup_entity, @@ -56,7 +57,6 @@ from .effects import ( ) from .restore_state import ( LEGACY_RESTORE_MODES, - RESTORE_STATE_NONE, RESTORE_STATE_SCHEMA, _build_state_lambda, _initial_state_overridden_by_legacy_mode, diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 5292db13ac0..6b73417b6f5 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -132,7 +132,7 @@ void AddressableLight::call_setup() { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE this->set_interval(5000, [this]() { - const char *name = this->state_parent_ == nullptr ? "" : this->state_parent_->get_name().c_str(); + const char *name = this->state_parent_ == nullptr ? "" : LOG_STR_ARG(this->state_parent_->get_log_name()); ESP_LOGVV(TAG, "Addressable Light '%s' (effect_active=%s)", name, YESNO(this->effect_active_)); for (int i = 0; i < this->size(); i++) { auto color = this->get(i); diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 0970c513a29..c291f12154f 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -74,8 +74,10 @@ static void log_percent(const LogString *param, float value) { #endif void LightCall::perform() { - const char *name = this->parent_->get_name().c_str(); + const char *name = LOG_STR_ARG(this->parent_->get_log_name()); LightColorValues v = this->validate_(); + // Any call counts as newer, even one that changes nothing + this->parent_->called_ = true; const bool publish = this->get_publish_(); if (publish) { @@ -182,7 +184,7 @@ void LightCall::perform() { } void LightCall::log_and_clear_unsupported_(FieldFlags flag, const LogString *feature, bool use_color_mode_log) { - auto *name = this->parent_->get_name().c_str(); + auto *name = LOG_STR_ARG(this->parent_->get_log_name()); if (use_color_mode_log) { log_color_mode_not_supported(name, feature); } else { @@ -192,7 +194,7 @@ void LightCall::log_and_clear_unsupported_(FieldFlags flag, const LogString *fea } LightColorValues LightCall::validate_() { - auto *name = this->parent_->get_name().c_str(); + auto *name = LOG_STR_ARG(this->parent_->get_log_name()); auto traits = this->parent_->get_traits(); // Snapshot before the adjustments below add flags of their own @@ -446,7 +448,7 @@ void LightCall::transform_parameters_(const LightTraits &traits) { !(this->color_mode_ & ColorCapability::COLOR_TEMPERATURE) && // min_mireds > 0.0f && max_mireds > 0.0f) { ESP_LOGV(TAG, "'%s': setting cold/warm white channels using white/color temperature values", - this->parent_->get_name().c_str()); + LOG_STR_ARG(this->parent_->get_log_name())); // Only compute cold_white/warm_white from color_temperature if they're not already explicitly set. // This is important for state restoration, where both color_temperature and cold_white/warm_white // are restored from flash - we want to preserve the saved cold_white/warm_white values. @@ -492,7 +494,7 @@ ColorMode LightCall::compute_color_mode_(const LightTraits &traits) { // Don't change if the current mode is in the intersection (suitable AND supported) if (ColorModeMask::mask_contains(intersection, current_mode)) { - ESP_LOGV(TAG, "'%s': color mode not specified; retaining %s", this->parent_->get_name().c_str(), + ESP_LOGV(TAG, "'%s': color mode not specified; retaining %s", LOG_STR_ARG(this->parent_->get_log_name()), LOG_STR_ARG(color_mode_to_human(current_mode))); return current_mode; } @@ -500,7 +502,7 @@ ColorMode LightCall::compute_color_mode_(const LightTraits &traits) { // Use the preferred suitable mode. if (intersection != 0) { ColorMode mode = ColorModeMask::first_value_from_mask(intersection); - ESP_LOGV(TAG, "'%s': color mode not specified; using %s", this->parent_->get_name().c_str(), + ESP_LOGV(TAG, "'%s': color mode not specified; using %s", LOG_STR_ARG(this->parent_->get_log_name()), LOG_STR_ARG(color_mode_to_human(mode))); return mode; } @@ -508,7 +510,7 @@ ColorMode LightCall::compute_color_mode_(const LightTraits &traits) { // There's no supported mode for this call, so warn, use the current more or a mode at random and let validation strip // out whatever we don't support. auto color_mode = current_mode != ColorMode::UNKNOWN ? current_mode : *supported_modes.begin(); - ESP_LOGW(TAG, "'%s': no suitable color mode supported; defaulting to %s", this->parent_->get_name().c_str(), + ESP_LOGW(TAG, "'%s': no suitable color mode supported; defaulting to %s", LOG_STR_ARG(this->parent_->get_log_name()), LOG_STR_ARG(color_mode_to_human(color_mode))); return color_mode; } @@ -587,7 +589,7 @@ LightCall &LightCall::set_effect(const char *effect, size_t len) { } } if (!found) { - ESP_LOGW(TAG, "'%s': no such effect '%.*s'", this->parent_->get_name().c_str(), (int) len, effect); + ESP_LOGW(TAG, "'%s': no such effect '%.*s'", LOG_STR_ARG(this->parent_->get_log_name()), (int) len, effect); } return *this; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index a226690853e..d9db630fcb8 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -1,6 +1,7 @@ #pragma once #include "light_color_values.h" +#include "esphome/core/string_ref.h" namespace esphome { @@ -133,6 +134,8 @@ class LightCall { LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } /// Set the effect of the light by its name (const char * overload to resolve ambiguity). LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); } + /// Set the effect of the light by its name (StringRef overload to resolve ambiguity, zero-copy). + LightCall &set_effect(StringRef effect) { return this->set_effect(effect.c_str(), effect.size()); } /// Set the effect of the light by its name and length (zero-copy from API). LightCall &set_effect(const char *effect, size_t len); /// Set the effect of the light by its internal index number (only for internal use). diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 84db3653a36..19b25514d1d 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -91,7 +91,7 @@ void LightState::setup() { call.perform(); } void LightState::dump_config() { - ESP_LOGCONFIG(TAG, "Light '%s'", this->get_name().c_str()); + ESP_LOGCONFIG(TAG, "Light '%s'", LOG_STR_ARG(this->get_log_name())); auto traits = this->get_traits(); if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) { #ifdef USE_LIGHT_GAMMA_LUT diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 86a132c1434..a675cb000c2 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -90,6 +90,11 @@ class LightState : public EntityBase, public Component { /// True from the call that starts a transition or flash until it reaches its target. bool is_transitioning() const { return this->transformer_ != nullptr; } + + /// True once a call is performed after clear_called(), so an output can tell whether a call came + /// after something it noted. Every call that changes the light reaches update_state() within a loop pass. + bool called_since_clear() const { return this->called_; } + void clear_called() { this->called_ = false; } LightCall make_call(); /// Take over the state of a light mirrored from elsewhere: current and target values at once, @@ -416,6 +421,7 @@ class LightState : public EntityBase, public Component { bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word // for effects, true if a transformer (transition) is active. bool is_transformer_active_ : 1 {false}; + bool called_ : 1 {false}; // see called_since_clear() /// Whether this light persists its state to preferences at all. bool save_enabled_ : 1 {false}; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL diff --git a/esphome/components/light/restore_state.py b/esphome/components/light/restore_state.py index 712b4895478..7465a163831 100644 --- a/esphome/components/light/restore_state.py +++ b/esphome/components/light/restore_state.py @@ -11,6 +11,14 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_COLOR_MODE, CONF_STATE from esphome.core import Lambda +from esphome.core.entity_helpers import ( + RESTORE_STATE_INITIAL, + RESTORE_STATE_INVERT, + RESTORE_STATE_KEEP, + RESTORE_STATE_NONE, + restore_state_keyword, + restore_state_schema, +) from esphome.cpp_generator import call_lambda from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType @@ -18,12 +26,6 @@ from esphome.types import ConfigType from .automation import LIGHT_STATE_FIELDS, validate_light_state from .types import ColorMode, LightStateRTCState -RESTORE_STATE_KEEP = "KEEP" -RESTORE_STATE_INVERT = "INVERT" -RESTORE_STATE_INITIAL = "INITIAL" -RESTORE_STATE_ALL = "ALL" -RESTORE_STATE_NONE = "NONE" - @dataclass(frozen=True) class LegacyRestoreMode: @@ -290,10 +292,12 @@ def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: except cv.Invalid: inner = () return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner) - if isinstance(value, str): - upper = value.strip().upper() - if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL): - return upper + if ( + keyword := restore_state_keyword( + value, (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL) + ) + ) is not None: + return keyword return validator(value) return validate @@ -308,10 +312,12 @@ def _validate_restore_state_state(value: Any) -> str | bool: RESTORE_STATE_INITIAL, *validate_light_state(SCHEMA_EXTRACT), ) - if isinstance(value, str): - upper = value.strip().upper() - if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL): - return upper + if ( + keyword := restore_state_keyword( + value, (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL) + ) + ) is not None: + return keyword return validate_light_state(value) @@ -327,19 +333,7 @@ _RESTORE_STATE_FIELDS_SCHEMA = cv.Schema( ) -@schema_extractor("schema") -def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str: - """ - The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`. - """ - if value == SCHEMA_EXTRACT: - # The `all`/`none` string shorthands have no representation in the extracted - # docs schema; only the per-field mapping form is walked here. - return _RESTORE_STATE_FIELDS_SCHEMA - if isinstance(value, str): - upper = value.strip().upper() - if upper == RESTORE_STATE_ALL: - value = {} - elif upper == RESTORE_STATE_NONE: - return RESTORE_STATE_NONE - return _RESTORE_STATE_FIELDS_SCHEMA(value) +# A mapping of per-field overrides, `all`, or `none`, which stays the keyword for to_code +RESTORE_STATE_SCHEMA = restore_state_schema( + _RESTORE_STATE_FIELDS_SCHEMA, RESTORE_STATE_NONE +) diff --git a/esphome/components/lightwaverf/LwRx.cpp b/esphome/components/lightwaverf/LwRx.cpp index dfb8edfaf7a..a1b056e0463 100644 --- a/esphome/components/lightwaverf/LwRx.cpp +++ b/esphome/components/lightwaverf/LwRx.cpp @@ -218,6 +218,8 @@ bool LwRx::lwrx_getmessage(uint8_t *buf, uint8_t len) { case 10: buf[i] = j; break; + default: + break; } } } else { diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index aca3a3b0248..ff6bb71c291 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -30,10 +30,10 @@ void Lock::lock() { this->set_state_(LOCK_STATE_LOCKED); } void Lock::unlock() { this->set_state_(LOCK_STATE_UNLOCKED); } void Lock::open() { if (traits.get_supports_open()) { - ESP_LOGD(TAG, "'%s' Opening.", this->get_name().c_str()); + ESP_LOGD(TAG, "'%s' Opening.", LOG_STR_ARG(this->get_log_name())); this->open_latch(); } else { - ESP_LOGW(TAG, "'%s' Does not support Open.", this->get_name().c_str()); + ESP_LOGW(TAG, "'%s' Does not support Open.", LOG_STR_ARG(this->get_log_name())); } } void Lock::publish_state(LockState state) { @@ -42,7 +42,7 @@ void Lock::publish_state(LockState state) { this->state = state; this->rtc_.save(&this->state); - ESP_LOGV(TAG, "'%s' >> %s", this->name_.c_str(), LOG_STR_ARG(lock_state_to_string(state))); + ESP_LOGV(TAG, "'%s' >> %s", LOG_STR_ARG(this->get_log_name()), LOG_STR_ARG(lock_state_to_string(state))); this->state_callback_.call(state); #if defined(USE_LOCK) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_lock_update(this); @@ -50,7 +50,7 @@ void Lock::publish_state(LockState state) { } void LockCall::perform() { - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); this->validate_(); if (this->state_.has_value()) { ESP_LOGV(TAG, " State: %s", LOG_STR_ARG(lock_state_to_string(*this->state_))); @@ -92,7 +92,7 @@ LockCall &LockCall::set_state(const char *state) { } else if (ESPHOME_strcasecmp_P(state, ESPHOME_PSTR("NONE")) == 0) { this->set_state(LOCK_STATE_NONE); } else { - ESP_LOGW(TAG, "'%s' - Unrecognized state %s", this->parent_->get_name().c_str(), state); + ESP_LOGW(TAG, "'%s' - Unrecognized state %s", LOG_STR_ARG(this->parent_->get_log_name()), state); } return *this; } diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 6510f721352..53dbf307ac1 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -35,12 +35,12 @@ class LockTraits { public: LockTraits() = default; - bool get_supports_open() const { return this->supports_open_; } - void set_supports_open(bool supports_open) { this->supports_open_ = supports_open; } - bool get_requires_code() const { return this->requires_code_; } - void set_requires_code(bool requires_code) { this->requires_code_ = requires_code; } - bool get_assumed_state() const { return this->assumed_state_; } - void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } + bool get_supports_open() const { return this->flags_.supports_open; } + void set_supports_open(bool supports_open) { this->flags_.supports_open = supports_open; } + bool get_requires_code() const { return this->flags_.requires_code; } + void set_requires_code(bool requires_code) { this->flags_.requires_code = requires_code; } + bool get_assumed_state() const { return this->flags_.assumed_state; } + void set_assumed_state(bool assumed_state) { this->flags_.assumed_state = assumed_state; } bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); } void set_supported_states(std::initializer_list states) { @@ -54,9 +54,13 @@ class LockTraits { void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); } protected: - bool supports_open_{false}; - bool requires_code_{false}; - bool assumed_state_{false}; + // Bit-packed flags to save memory (1 byte instead of 3) + struct LockTraitFlags { + uint8_t supports_open : 1; + uint8_t requires_code : 1; + uint8_t assumed_state : 1; + uint8_t reserved : 5; + } flags_{}; uint8_t supported_states_mask_{(1 << LOCK_STATE_NONE) | (1 << LOCK_STATE_LOCKED) | (1 << LOCK_STATE_UNLOCKED)}; }; @@ -176,8 +180,8 @@ class Lock : public EntityBase { */ virtual void control(const LockCall &call) = 0; + Deduplicator publish_dedup_; // packs with state and traits ahead of the pointer members LazyCallbackManager state_callback_{}; - Deduplicator publish_dedup_; ESPPreferenceObject rtc_; }; diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index b6bfb6127be..0a6d49d1736 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -91,28 +91,28 @@ bool MAX31856Sensor::has_fault_() { this->status_set_warning(); if ((faults & MAX31856_FAULT_CJRANGE) == MAX31856_FAULT_CJRANGE) { - ESP_LOGW(TAG, "Cold Junction Out-of-Range: '%s'", this->name_.c_str()); + ESP_LOGW(TAG, "Cold Junction Out-of-Range: '%s'", LOG_STR_ARG(this->get_log_name())); } if ((faults & MAX31856_FAULT_TCRANGE) == MAX31856_FAULT_TCRANGE) { - ESP_LOGW(TAG, "Thermocouple Out-of-Range: '%s'", this->name_.c_str()); + ESP_LOGW(TAG, "Thermocouple Out-of-Range: '%s'", LOG_STR_ARG(this->get_log_name())); } if ((faults & MAX31856_FAULT_CJHIGH) == MAX31856_FAULT_CJHIGH) { - ESP_LOGW(TAG, "Cold-Junction High Fault: '%s'", this->name_.c_str()); + ESP_LOGW(TAG, "Cold-Junction High Fault: '%s'", LOG_STR_ARG(this->get_log_name())); } if ((faults & MAX31856_FAULT_CJLOW) == MAX31856_FAULT_CJLOW) { - ESP_LOGW(TAG, "Cold-Junction Low Fault: '%s'", this->name_.c_str()); + ESP_LOGW(TAG, "Cold-Junction Low Fault: '%s'", LOG_STR_ARG(this->get_log_name())); } if ((faults & MAX31856_FAULT_TCHIGH) == MAX31856_FAULT_TCHIGH) { - ESP_LOGW(TAG, "Thermocouple Temperature High Fault: '%s'", this->name_.c_str()); + ESP_LOGW(TAG, "Thermocouple Temperature High Fault: '%s'", LOG_STR_ARG(this->get_log_name())); } if ((faults & MAX31856_FAULT_TCLOW) == MAX31856_FAULT_TCLOW) { - ESP_LOGW(TAG, "Thermocouple Temperature Low Fault: '%s'", this->name_.c_str()); + ESP_LOGW(TAG, "Thermocouple Temperature Low Fault: '%s'", LOG_STR_ARG(this->get_log_name())); } if ((faults & MAX31856_FAULT_OVUV) == MAX31856_FAULT_OVUV) { - ESP_LOGW(TAG, "Overvoltage or Undervoltage Input Fault: '%s'", this->name_.c_str()); + ESP_LOGW(TAG, "Overvoltage or Undervoltage Input Fault: '%s'", LOG_STR_ARG(this->get_log_name())); } if ((faults & MAX31856_FAULT_OPEN) == MAX31856_FAULT_OPEN) { - ESP_LOGW(TAG, "Thermocouple Open-Circuit Fault (possibly not connected): '%s'", this->name_.c_str()); + ESP_LOGW(TAG, "Thermocouple Open-Circuit Fault (possibly not connected): '%s'", LOG_STR_ARG(this->get_log_name())); } return true; diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp index f8787e71d50..d25b0c5df00 100644 --- a/esphome/components/max31888/max31888.cpp +++ b/esphome/components/max31888/max31888.cpp @@ -66,7 +66,7 @@ void MAX31888Sensor::update() { return; } const float temperature = raw * MAX31888_DEGREES_PER_LSB; - ESP_LOGD(TAG, "'%s': Got Temperature=%.3f°C", this->get_name().c_str(), temperature); + ESP_LOGD(TAG, "'%s': Got Temperature=%.3f°C", LOG_STR_ARG(this->get_log_name()), temperature); this->status_clear_warning(); this->publish_state(temperature); }); diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index 14eaca89164..51e30466fbb 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -38,7 +38,7 @@ void MAX6675Sensor::read_data_() { } float temperature = float(val >> 3) / 4.0f; - ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", this->name_.c_str(), temperature); + ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", LOG_STR_ARG(this->get_log_name()), temperature); this->publish_state(temperature); } diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index a55d4dc6187..6c7eee5c3b9 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -38,7 +38,7 @@ class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expa void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: - static void IRAM_ATTR gpio_intr(MCP23016 *arg); + static void gpio_intr(MCP23016 *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 7414677175e..bdbfbddf9bd 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -10,7 +10,7 @@ enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANG // Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the // ISR in flash. -void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg); +void mcp23xxx_gpio_intr(Component *arg); template class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander { public: diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 800ec631e75..013828016e5 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -81,6 +81,8 @@ void Mcp4461Component::initialize_terminal_disabled(Mcp4461WiperIdx wiper, char case 'w': this->reg_[wiper_idx].terminal_w = false; break; + default: + break; } } diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index 6582a9ab6db..8ddd320e721 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -23,7 +23,7 @@ void MCP9808Sensor::setup() { return; } if (manu != MCP9808_MANUF_ID) { - ESP_LOGE(TAG, "Incorrect manufacturer ID (%X) for '%s'", manu, this->name_.c_str()); + ESP_LOGE(TAG, "Incorrect manufacturer ID (%X) for '%s'", manu, LOG_STR_ARG(this->get_log_name())); this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -33,13 +33,13 @@ void MCP9808Sensor::setup() { return; } if (dev_id != MCP9808_DEV_ID) { - ESP_LOGE(TAG, "Incorrect device ID (%X) for '%s'", dev_id, this->name_.c_str()); + ESP_LOGE(TAG, "Incorrect device ID (%X) for '%s'", dev_id, LOG_STR_ARG(this->get_log_name())); this->mark_failed(LOG_STR("Wrong chip ID")); return; } } void MCP9808Sensor::dump_config() { - ESP_LOGCONFIG(TAG, "%s:", this->name_.c_str()); + ESP_LOGCONFIG(TAG, "%s:", LOG_STR_ARG(this->get_log_name())); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); @@ -71,7 +71,7 @@ void MCP9808Sensor::update() { if (!this->status_comm_result(!std::isnan(temp), nullptr)) return; - ESP_LOGD(TAG, "%s: Got temperature=%.4f°C", this->name_.c_str(), temp); + ESP_LOGD(TAG, "%s: Got temperature=%.4f°C", LOG_STR_ARG(this->get_log_name()), temp); this->publish_state(temp); } diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 88b000f5fcb..0b0a59ce542 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -3,6 +3,7 @@ from collections.abc import Callable from esphome import automation import esphome.codegen as cg from esphome.components import audio +from esphome.components.const import CONF_BITRATE import esphome.config_validation as cv from esphome.const import ( CONF_ENTITY_CATEGORY, @@ -55,6 +56,30 @@ FORMAT_MAPPING = { } +# MP3 constant bitrates in kbps; the encoder rounds anything else to the nearest one +MP3_BITRATES = ( + 8, + 16, + 24, + 32, + 40, + 48, + 56, + 64, + 80, + 96, + 112, + 128, + 144, # MPEG-2 only, at 16, 22.05 and 24 kHz + 160, + 192, + 224, + 256, + 320, +) +validate_mp3_bitrate = cv.one_of(*MP3_BITRATES, int=True) + + def build_supported_format_struct( format_config: ConfigType, purpose: MockObj ) -> cg.StructInitializer: @@ -75,6 +100,10 @@ def build_supported_format_struct( if format_config[CONF_FORMAT] != "MP3": args.append(("sample_bytes", 2)) + # Omitted means no preference, and the client keeps encoding as it always has. + if bitrate := format_config.get(CONF_BITRATE): + args.append(("bitrate", bitrate)) + return cg.StructInitializer(*args) @@ -91,6 +120,14 @@ def validate_preferred_format( inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config) inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config) + # A constant bitrate is only meaningful for the one lossy format whose + # encoder Home Assistant can be told to fix + if CONF_BITRATE in config and config.get(CONF_FORMAT) != "MP3": + raise cv.Invalid( + f"{CONF_BITRATE} is only supported for the MP3 format", + path=[CONF_BITRATE], + ) + # Opus only supports 48 kHz if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") diff --git a/esphome/components/media_player/media_player.cpp b/esphome/components/media_player/media_player.cpp index 423832dd2ff..1716a054c71 100644 --- a/esphome/components/media_player/media_player.cpp +++ b/esphome/components/media_player/media_player.cpp @@ -109,7 +109,7 @@ void MediaPlayerCall::validate_() { } void MediaPlayerCall::perform() { - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); this->validate_(); if (this->command_.has_value()) { const char *command_s = media_player_command_to_string(this->command_.value()); @@ -177,7 +177,7 @@ MediaPlayerCall &MediaPlayerCall::set_command(const char *command) { } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("GROUP_JOIN")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_GROUP_JOIN); } else { - ESP_LOGW(TAG, "'%s' - Unrecognized command %s", this->parent_->get_name().c_str(), command); + ESP_LOGW(TAG, "'%s' - Unrecognized command %s", LOG_STR_ARG(this->parent_->get_log_name()), command); } return *this; } diff --git a/esphome/components/media_player/media_player.h b/esphome/components/media_player/media_player.h index 73de6036923..daec4a38cf4 100644 --- a/esphome/components/media_player/media_player.h +++ b/esphome/components/media_player/media_player.h @@ -77,6 +77,7 @@ struct MediaPlayerSupportedFormat { uint32_t num_channels; MediaPlayerFormatPurpose purpose; uint32_t sample_bytes; + uint32_t bitrate; }; // Base features always reported for all media players diff --git a/esphome/components/mics_5524/mics_5524.cpp b/esphome/components/mics_5524/mics_5524.cpp index bc559209afe..39bfbe98308 100644 --- a/esphome/components/mics_5524/mics_5524.cpp +++ b/esphome/components/mics_5524/mics_5524.cpp @@ -35,7 +35,7 @@ void MiCS5524GasSensor::publish_ratio(float ratio, micsmath::ConversionModel mod micsmath::clamp_ppm(micsmath::ppm_from_power_law(this->p0_, this->p1_, ratio), this->min_ppm_, this->max_ppm_); } #endif - ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", this->get_name().c_str(), ratio, ppm); + ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", LOG_STR_ARG(this->get_log_name()), ratio, ppm); this->publish_state(ppm); } diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index f8fecf2e4ea..013980ac272 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -244,9 +244,9 @@ class MipiSpi : public display::Display, char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)]; // Don't spam the log after setup if (this->init_sequence_len_ == 0) { - esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); + esph_log_v(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); } else { - esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); + esph_log_d(TAG, "Command %02X, length %zu, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); } if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { this->enable(); diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index 84f44a3dae7..755cd8cdecc 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -52,6 +52,7 @@ ST7796.extend( height=480, offset_width=49, draw_rounding=1, + data_rate="40MHz", cs_pin=39, reset_pin=47, dc_pin=9, diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 0085d062d77..240bce46014 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.components.const import CONF_HORIZONTAL_DEFAULT, CONF_VERTICAL_DEFAULT import esphome.config_validation as cv from esphome.types import ConfigType @@ -22,7 +23,6 @@ CONF_SUPPORTS_DRY = "supports_dry" CONF_SUPPORTS_FAN_ONLY = "supports_fan_only" CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility" -CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections") HORIZONTAL_DIRECTIONS = HorizontalDirections.options( "left", @@ -34,7 +34,6 @@ HORIZONTAL_DIRECTIONS = HorizontalDirections.options( prefix="HORIZONTAL_DIRECTION_", ) -CONF_VERTICAL_DEFAULT = "vertical_default" VerticalDirections = mitsubishi_ns.enum("VerticalDirections") VERTICAL_DIRECTIONS = VerticalDirections.options( "auto", diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 353057dc34d..7459c71f227 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -174,7 +174,7 @@ void MLX90614Component::update() { // Bit 15 set means the device flagged the reading as invalid const float temperature = (raw & 0x8000) ? NAN : raw * 0.02f - 273.15f; - ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", sensor->get_name().c_str(), temperature); + ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", LOG_STR_ARG(sensor->get_log_name()), temperature); sensor->publish_state(temperature); return ec; }; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 6c90bfa4646..50e871dd6d4 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -652,7 +652,7 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( ESP_LOGI(TAG, "Custom Command sent"); }; } else { - cmd.on_data_func = handler; + cmd.on_data_func = std::move(handler); } cmd.payload.set(values.data(), values.size()); @@ -669,7 +669,7 @@ ModbusCommandItem ModbusCommandItem::create_custom_command( ESP_LOGI(TAG, "Custom Command sent"); }; } else { - cmd.on_data_func = handler; + cmd.on_data_func = std::move(handler); } uint8_t *p = cmd.payload.init(values.size() * 2); for (auto v : values) { diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index f33fc36b0a1..6d228872443 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -57,7 +57,8 @@ void ModbusNumber::control(float value) { bytes.push_back(word_bytes[1]); } if (!this->send_raw_frame_deprecated_(std::span(bytes.data(), bytes.size()))) { - ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", + LOG_STR_ARG(this->get_log_name())); return; } this->publish_state(value); @@ -82,7 +83,7 @@ void ModbusNumber::control(float value) { ESP_LOGD(TAG, "Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)", - this->get_name().c_str(), this->start_address, this->entity_count(), value, write_value); + LOG_STR_ARG(this->get_log_name()), this->start_address, this->entity_count(), value, write_value); bool queued; if (this->entity_count() == 1 && !this->use_write_multiple_) { @@ -91,7 +92,8 @@ void ModbusNumber::control(float value) { queued = this->write_multiple_registers(this->write_address(), data); } if (!queued) { - ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", + LOG_STR_ARG(this->get_log_name())); return; } this->publish_state(value); diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index 793aa8e9ea4..a68bb0c9318 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -100,7 +100,8 @@ void ModbusSelect::control(size_t index) { } if (!queued) { - ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", + LOG_STR_ARG(this->get_log_name())); return; } if (this->optimistic_) diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index c29378c5436..2c92493c5a3 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -48,7 +48,7 @@ void ModbusSwitch::parse_and_publish(std::span data) { } } - ESP_LOGV(TAG, "Publish '%s': new value = %s type = %d address = %X offset = %zx", this->get_name().c_str(), + ESP_LOGV(TAG, "Publish '%s': new value = %s type = %d address = %X offset = %zx", LOG_STR_ARG(this->get_log_name()), ONOFF(value), (int) this->register_type, this->start_address, offset); this->publish_state(value); } @@ -73,7 +73,8 @@ void ModbusSwitch::write_state(bool state) { ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_SWITCH_MAX_LOG_BYTES, "Modbus Switch write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { - ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", + LOG_STR_ARG(this->get_log_name())); return; } this->publish_state(state); @@ -89,8 +90,8 @@ void ModbusSwitch::write_state(bool state) { write_value = val.value(); } ESP_LOGV(TAG, "write_state '%s': new value = %s (wire = %s) type = %d address = %X offset = %x", - this->get_name().c_str(), ONOFF(state), ONOFF(write_value), (int) this->register_type, this->start_address, - this->offset); + LOG_STR_ARG(this->get_log_name()), ONOFF(state), ONOFF(write_value), (int) this->register_type, + this->start_address, this->offset); bool queued; if (this->register_type == EntityType::COIL) { // offset for coil and discrete inputs is the coil/register number not bytes @@ -109,7 +110,8 @@ void ModbusSwitch::write_state(bool state) { } } if (!queued) { - ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", + LOG_STR_ARG(this->get_log_name())); return; } this->publish_state(state); diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 010277478c2..5c5a49f78aa 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,8 +1,10 @@ from esphome import automation import esphome.codegen as cg from esphome.components import logger, network, socket +from esphome.components.const import CONF_VERIFY_SSL from esphome.components.esp32 import ( add_idf_component, + allow_insecure_tls, idf_version, include_builtin_idf_component, request_tls, @@ -79,6 +81,7 @@ def AUTO_LOAD(): return ["json"] +CONF_TLS = "tls" CONF_IDF_SEND_ASYNC = "idf_send_async" CONF_WAIT_FOR_CONNECTION = "wait_for_connection" CONF_TRANSPORT = "transport" @@ -187,26 +190,8 @@ def _validate_transport(value: str) -> str: def validate_config(value): - transport = value.get(CONF_TRANSPORT) - # A certificate with plain ws is almost certainly a typo for wss - if transport == MQTT_TRANSPORT_WS and ( - CONF_CERTIFICATE_AUTHORITY in value or CONF_CLIENT_CERTIFICATE in value - ): - raise cv.Invalid( - "transport 'ws' is unencrypted; remove the certificate options " - "or switch to transport 'wss'" - ) - # TLS runs only with a CA or over wss, so a client certificate needs one of them if ( - CONF_CLIENT_CERTIFICATE in value - and CONF_CERTIFICATE_AUTHORITY not in value - and transport != MQTT_TRANSPORT_WSS - ): - raise cv.Invalid( - "'client_certificate' requires 'certificate_authority' or transport 'wss'" - ) - if ( - transport not in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS) + value.get(CONF_TRANSPORT) not in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS) and CONF_WS_PATH in value ): raise cv.Invalid( @@ -260,6 +245,47 @@ def validate_config(value): return out +# Keys that only make sense over TLS; any of them switches it on +TLS_IMPLYING_KEYS = frozenset( + {CONF_CERTIFICATE_AUTHORITY, CONF_VERIFY_SSL, CONF_CLIENT_CERTIFICATE} +) + + +def _derive_tls(config: ConfigType) -> ConfigType: + """Derive ``tls`` from the certificate options and the transport, reject contradictions, pick the port.""" + # skip_cert_cn_check has a default on ESP32, so its value decides, not its presence + if config.get(CONF_VERIFY_SSL) is False and config.get(CONF_SKIP_CERT_CN_CHECK): + raise cv.Invalid( + "'skip_cert_cn_check' has no effect when 'verify_ssl' is false." + ) + transport = config.get(CONF_TRANSPORT) + if ( + config.keys() & TLS_IMPLYING_KEYS + or config.get(CONF_SKIP_CERT_CN_CHECK) + or transport == MQTT_TRANSPORT_WSS + ): + if config.get(CONF_TLS) is False: + raise cv.Invalid( + "'tls' cannot be false when a certificate, 'verify_ssl', " + "'skip_cert_cn_check' or transport 'wss' is set." + ) + config[CONF_TLS] = True + # TLS with plain ws is almost certainly a typo for wss + if transport == MQTT_TRANSPORT_WS and config.get(CONF_TLS): + raise cv.Invalid( + "transport 'ws' is unencrypted; remove the TLS options " + "or switch to transport 'wss'" + ) + config.setdefault(CONF_PORT, 8883 if config.get(CONF_TLS) else 1883) + return config + + +_validate_tls = cv.All( + cv.only_when(CONF_CERTIFICATE_AUTHORITY, CONF_VERIFY_SSL, False, negate=True), + _derive_tls, +) + + def _consume_mqtt_sockets(config: ConfigType) -> ConfigType: """Register socket needs for MQTT component.""" # MQTT needs 1 socket for the broker connection @@ -283,7 +309,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(MQTTClientComponent), cv.Required(CONF_BROKER): cv.string_strict, cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, - cv.Optional(CONF_PORT, default=1883): cv.port, + cv.Optional(CONF_PORT): cv.port, cv.Optional(CONF_USERNAME, default=""): cv.string, cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(), cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean, @@ -295,6 +321,8 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All( cv.boolean, cv.only_on_esp32 ), + cv.Optional(CONF_TLS): cv.All(cv.boolean, cv.only_on_esp32), + cv.Optional(CONF_VERIFY_SSL): cv.All(cv.boolean, cv.only_on_esp32), cv.Optional(CONF_CERTIFICATE_AUTHORITY): cv.All( cv.string, cv.only_on_esp32 ), @@ -356,6 +384,7 @@ CONFIG_SCHEMA = cv.All( } ), validate_config, + _validate_tls, cv.only_on( [ PLATFORM_BK72XX, @@ -497,21 +526,28 @@ async def to_code(config): # esp-idf only transport = config.get(CONF_TRANSPORT) - ca = cg.add_optional(var.set_ca_certificate, config.get(CONF_CERTIFICATE_AUTHORITY)) - if ca is not None or transport == MQTT_TRANSPORT_WSS: - cg.add(var.set_skip_cert_cn_check(config[CONF_SKIP_CERT_CN_CHECK])) + if config.get(CONF_TLS): + cg.add_define("USE_MQTT_TLS") if (client_certificate := config.get(CONF_CLIENT_CERTIFICATE)) is not None: cg.add(var.set_cl_certificate(client_certificate)) cg.add(var.set_cl_key(config[CONF_CLIENT_CERTIFICATE_KEY])) + if config.get(CONF_SKIP_CERT_CN_CHECK): + cg.add(var.set_skip_cert_cn_check(True)) + # Trust source: none, the given CA, or the certificate bundle + if config.get(CONF_VERIFY_SSL) is False: + cg.add_define("USE_MQTT_TLS_INSECURE") + allow_insecure_tls() + elif (ca := config.get(CONF_CERTIFICATE_AUTHORITY)) is not None: + cg.add(var.set_ca_certificate(ca)) + else: + cg.add_define("USE_MQTT_CERT_BUNDLE") + require_certificate_bundle() if CORE.is_esp32: if transport != MQTT_TRANSPORT_TCP: cg.add(var.set_transport(transport)) if transport in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS): cg.add(var.set_ws_path(config.get(CONF_WS_PATH, DEFAULT_WS_PATH))) - # wss without an explicit CA verifies the broker against the bundled roots - if transport == MQTT_TRANSPORT_WSS and ca is None: - require_certificate_bundle() if CONF_IDF_SEND_ASYNC in config and config[CONF_IDF_SEND_ASYNC]: cg.add_define("USE_MQTT_IDF_ENQUEUE") diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp index ef18c0319a6..027d73e9517 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp @@ -59,9 +59,9 @@ void MQTTAlarmControlPanelComponent::setup() { if (!root.isNull()) { const char *state = root[ESPHOME_F("state")]; if (state == nullptr) { - ESP_LOGW(TAG, "'%s': JSON payload missing 'state' key", this->friendly_name_().c_str()); + ESP_LOGW(TAG, "'%s': JSON payload missing 'state' key", LOG_STR_ARG(this->log_name_())); } else if (!apply_command(call, state)) { - ESP_LOGW(TAG, "'%s': Received unknown state in JSON payload: %s", this->friendly_name_().c_str(), state); + ESP_LOGW(TAG, "'%s': Received unknown state in JSON payload: %s", LOG_STR_ARG(this->log_name_()), state); } else { const char *code = root[ESPHOME_F("code")]; if (code != nullptr) { @@ -70,7 +70,7 @@ void MQTTAlarmControlPanelComponent::setup() { } } } else if (!apply_command(call, payload.c_str())) { - ESP_LOGW(TAG, "'%s': Received unknown command payload %s", this->friendly_name_().c_str(), payload.c_str()); + ESP_LOGW(TAG, "'%s': Received unknown command payload %s", LOG_STR_ARG(this->log_name_()), payload.c_str()); } call.perform(); }); diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 27e4cb63028..7941d1207d6 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -8,7 +8,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE +#ifdef USE_MQTT_CERT_BUNDLE #include "esp_crt_bundle.h" #endif @@ -43,46 +43,36 @@ bool MQTTBackendESP32::initialize_() { if (!this->client_id_.empty()) { mqtt_cfg_.credentials.client_id = this->client_id_.c_str(); } - - // Plain TCP keeps upgrading to SSL when a CA certificate is configured - bool uses_tls = false; +#ifdef USE_MQTT_TLS + mqtt_cfg_.broker.verification.skip_cert_common_name_check = this->skip_cert_cn_check_; + mqtt_cfg_.broker.verification.certificate = this->ca_certificate_; +#ifdef USE_MQTT_CERT_BUNDLE + mqtt_cfg_.broker.verification.crt_bundle_attach = esp_crt_bundle_attach; +#endif + // Validation only accepts the client certificate together with its key + mqtt_cfg_.credentials.authentication.certificate = this->cl_certificate_; + mqtt_cfg_.credentials.authentication.key = this->cl_key_; +#endif + // Validation keeps TLS off plain ws, so only the TCP transport has two forms switch (this->transport_) { case MQTTTransport::MQTT_TRANSPORT_WS: mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_WS; break; case MQTTTransport::MQTT_TRANSPORT_WSS: mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_WSS; - uses_tls = true; break; case MQTTTransport::MQTT_TRANSPORT_TCP: default: - if (this->ca_certificate_.has_value()) { - mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_SSL; - uses_tls = true; - } else { - mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_TCP; - } +#ifdef USE_MQTT_TLS + mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_SSL; +#else + mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_TCP; +#endif break; } // Only set for the WebSocket transports mqtt_cfg_.broker.address.path = this->ws_path_; - if (uses_tls) { - if (this->ca_certificate_.has_value()) { - mqtt_cfg_.broker.verification.certificate = this->ca_certificate_.value().c_str(); -#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE - } else { - // wss without a CA verifies the broker against the bundled roots, as http_request does - mqtt_cfg_.broker.verification.crt_bundle_attach = esp_crt_bundle_attach; -#endif - } - mqtt_cfg_.broker.verification.skip_cert_common_name_check = this->skip_cert_cn_check_; - if (this->cl_certificate_.has_value() && this->cl_key_.has_value()) { - mqtt_cfg_.credentials.authentication.certificate = this->cl_certificate_.value().c_str(); - mqtt_cfg_.credentials.authentication.key = this->cl_key_.value().c_str(); - } - } - auto *mqtt_client = esp_mqtt_client_init(&mqtt_cfg_); if (mqtt_client) { handler_.reset(mqtt_client); @@ -91,7 +81,11 @@ bool MQTTBackendESP32::initialize_() { #if defined(USE_MQTT_IDF_ENQUEUE) // Create the task only after MQTT client is initialized successfully // Use larger stack size when TLS is enabled - size_t stack_size = uses_tls ? TASK_STACK_SIZE_TLS : TASK_STACK_SIZE; +#ifdef USE_MQTT_TLS + constexpr size_t stack_size = TASK_STACK_SIZE_TLS; +#else + constexpr size_t stack_size = TASK_STACK_SIZE; +#endif xTaskCreate(esphome_mqtt_task, "esphome_mqtt", stack_size, (void *) this, TASK_PRIORITY, &this->task_handle_); if (this->task_handle_ == nullptr) { ESP_LOGE(TAG, "Failed to create MQTT task"); diff --git a/esphome/components/mqtt/mqtt_backend_esp32.h b/esphome/components/mqtt/mqtt_backend_esp32.h index 117db664f26..4b16a124470 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.h +++ b/esphome/components/mqtt/mqtt_backend_esp32.h @@ -214,10 +214,13 @@ class MQTTBackendESP32 final : public MQTTBackend { void loop() final; - void set_ca_certificate(const std::string &cert) { ca_certificate_ = cert; } - void set_cl_certificate(const std::string &cert) { cl_certificate_ = cert; } - void set_cl_key(const std::string &key) { cl_key_ = key; } - void set_skip_cert_cn_check(bool skip_check) { skip_cert_cn_check_ = skip_check; } +#ifdef USE_MQTT_TLS + // The certificates are string literals from code generation, so only the pointers are kept + void set_ca_certificate(const char *cert) { this->ca_certificate_ = cert; } + void set_cl_certificate(const char *cert) { this->cl_certificate_ = cert; } + void set_cl_key(const char *key) { this->cl_key_ = key; } + void set_skip_cert_cn_check(bool skip_check) { this->skip_cert_cn_check_ = skip_check; } +#endif void set_transport(MQTTTransport transport) { this->transport_ = transport; } void set_ws_path(const char *path) { this->ws_path_ = path; } MQTTTransport get_transport() const { return this->transport_; } @@ -256,10 +259,12 @@ class MQTTBackendESP32 final : public MQTTBackend { std::string client_id_; uint16_t keep_alive_; bool clean_session_; - optional ca_certificate_; - optional cl_certificate_; - optional cl_key_; +#ifdef USE_MQTT_TLS + const char *ca_certificate_{nullptr}; + const char *cl_certificate_{nullptr}; + const char *cl_key_{nullptr}; bool skip_cert_cn_check_{false}; +#endif MQTTTransport transport_{MQTTTransport::MQTT_TRANSPORT_TCP}; const char *ws_path_{nullptr}; #if defined(USE_MQTT_IDF_ENQUEUE) diff --git a/esphome/components/mqtt/mqtt_button.cpp b/esphome/components/mqtt/mqtt_button.cpp index df2163ca2f1..0cff67f79ae 100644 --- a/esphome/components/mqtt/mqtt_button.cpp +++ b/esphome/components/mqtt/mqtt_button.cpp @@ -19,7 +19,7 @@ void MQTTButtonComponent::setup() { if (payload == "PRESS") { this->button_->press(); } else { - ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); + ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", LOG_STR_ARG(this->log_name_()), payload.c_str()); this->status_momentary_warning(5000); } }); diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 2b4147de78d..64066c149d3 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -222,6 +222,13 @@ void MQTTClientComponent::dump_config() { transport == MQTTTransport::MQTT_TRANSPORT_WSS ? LOG_STR_LITERAL("wss") : LOG_STR_LITERAL("ws"), this->mqtt_backend_.get_ws_path()); } +#endif +#if defined(USE_MQTT_CERT_BUNDLE) + ESP_LOGCONFIG(TAG, " TLS: certificate bundle"); +#elif defined(USE_MQTT_TLS_INSECURE) + ESP_LOGCONFIG(TAG, " TLS: no verification"); +#elif defined(USE_MQTT_TLS) + ESP_LOGCONFIG(TAG, " TLS: CA certificate"); #endif if (this->is_discovery_ip_enabled()) { ESP_LOGCONFIG(TAG, " Discovery IP enabled"); diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 9fa2b3ef411..cf4f2a617ae 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -147,11 +147,13 @@ class MQTTClientComponent final : public Component { bool is_discovery_enabled() const; bool is_discovery_ip_enabled() const; -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_MQTT_TLS) void set_ca_certificate(const char *cert) { this->mqtt_backend_.set_ca_certificate(cert); } void set_cl_certificate(const char *cert) { this->mqtt_backend_.set_cl_certificate(cert); } void set_cl_key(const char *key) { this->mqtt_backend_.set_cl_key(key); } void set_skip_cert_cn_check(bool skip_check) { this->mqtt_backend_.set_skip_cert_cn_check(skip_check); } +#endif +#ifdef USE_ESP32 void set_transport(MQTTTransport transport) { this->mqtt_backend_.set_transport(transport); } void set_ws_path(const char *path) { this->mqtt_backend_.set_ws_path(path); } #endif diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index f4391790c8a..bd40af1a607 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -38,7 +38,7 @@ inline char *append_char(char *p, char c) { // Function implementation of LOG_MQTT_COMPONENT macro to reduce code size void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) { - ESP_LOGCONFIG(tag, "MQTT %s '%s':", obj->component_type(), obj->friendly_name_().c_str()); + ESP_LOGCONFIG(tag, "MQTT %s '%s':", obj->component_type(), LOG_STR_ARG(obj->get_entity()->get_log_name())); char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; if (state_topic) { ESP_LOGCONFIG(tag, " State Topic: '%s'", obj->get_state_topic_to_(buf).c_str()); @@ -187,11 +187,11 @@ bool MQTTComponent::send_discovery_() { StringRef discovery_topic = this->get_discovery_topic_to_(discovery_topic_buf, discovery_info); if (discovery_info.clean) { - ESP_LOGV(TAG, "'%s': Cleaning discovery", this->friendly_name_().c_str()); + ESP_LOGV(TAG, "'%s': Cleaning discovery", LOG_STR_ARG(this->log_name_())); return global_mqtt_client->publish(discovery_topic.c_str(), "", 0, this->qos_, true); } - ESP_LOGV(TAG, "'%s': Sending discovery", this->friendly_name_().c_str()); + ESP_LOGV(TAG, "'%s': Sending discovery", LOG_STR_ARG(this->log_name_())); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return global_mqtt_client->publish_json( @@ -208,19 +208,21 @@ bool MQTTComponent::send_discovery_() { } // Fields from EntityBase - root[MQTT_NAME] = this->get_entity()->has_own_name() ? this->friendly_name_() : StringRef(); + if (this->get_entity()->has_own_name()) { + root[MQTT_NAME] = json::linked(this->get_entity()->get_name_progmem()); + } else { + root[MQTT_NAME] = StringRef(); + } if (this->is_disabled_by_default_()) root[MQTT_ENABLED_BY_DEFAULT] = false; - char icon_buf[MAX_ICON_LENGTH]; - const char *icon = this->get_icon_to_(icon_buf); - if (icon[0] != '\0') { - root[MQTT_ICON] = icon; + ProgmemStringRef icon = this->get_entity()->get_icon_progmem(); + if (!icon.empty()) { + root[MQTT_ICON] = json::linked(icon); } - char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - const char *dc = this->get_entity()->get_device_class_to(dc_buf); - if (dc[0] != '\0') { - root[MQTT_DEVICE_CLASS] = dc; + ProgmemStringRef dc = this->get_entity()->get_device_class_progmem(); + if (!dc.empty()) { + root[MQTT_DEVICE_CLASS] = json::linked(dc); } const auto entity_category = this->get_entity()->get_entity_category(); @@ -254,9 +256,10 @@ bool MQTTComponent::send_discovery_() { char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = this->get_default_object_id_to_(object_id_buf); if (discovery_info.unique_id_generator == MQTT_MAC_ADDRESS_UNIQUE_ID_GENERATOR) { + char name_buf[ENTITY_NAME_BUF_SIZE]; char friendly_name_hash[9]; buf_append_printf(friendly_name_hash, sizeof(friendly_name_hash), 0, "%08" PRIx32, - fnv1_hash(this->friendly_name_().c_str())); + fnv1_hash(this->get_entity()->get_name_to(name_buf).c_str())); // Format: mac-component_type-hash (e.g. "aabbccddeeff-sensor-12345678") // MAC (12) + "-" (1) + domain (max 20) + "-" (1) + hash (8) + null (1) = 43 char unique_id[MAC_ADDRESS_BUFFER_SIZE + ESPHOME_DOMAIN_MAX_LEN + 11]; @@ -400,7 +403,11 @@ void MQTTComponent::schedule_resend_state() { this->resend_state_ = true; } bool MQTTComponent::is_connected_() const { return global_mqtt_client->is_connected(); } // Pull these properties from EntityBase if not overridden +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" const StringRef &MQTTComponent::friendly_name_() const { return this->get_entity()->get_name(); } +#pragma GCC diagnostic pop +const LogString *MQTTComponent::log_name_() const { return this->get_entity()->get_log_name(); } StringRef MQTTComponent::get_default_object_id_to_(std::span buf) const { return this->get_entity()->get_object_id_to(buf); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 461db5d3e76..d5a766e48c3 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -304,12 +304,11 @@ class MQTTComponent : public Component { */ virtual const EntityBase *get_entity() const = 0; - /// Get the friendly name of this MQTT component. + /// Entity name for a "%s" log argument. + const LogString *log_name_() const; + ESPDEPRECATED("Use log_name_() for logging. Removed in 2027.5.0", "2026.11.0") const StringRef &friendly_name_() const; - /// Get the icon field of this component into a stack buffer - const char *get_icon_to_(std::span buf) const { return this->get_entity()->get_icon_to(buf); } - /// Get whether the underlying Entity is disabled by default bool is_disabled_by_default_() const; diff --git a/esphome/components/mqtt/mqtt_cover.cpp b/esphome/components/mqtt/mqtt_cover.cpp index f279403de13..560377bc47f 100644 --- a/esphome/components/mqtt/mqtt_cover.cpp +++ b/esphome/components/mqtt/mqtt_cover.cpp @@ -50,6 +50,10 @@ void MQTTCoverComponent::setup() { } if (traits.get_supports_tilt()) { this->subscribe(this->get_tilt_command_topic(), [this](const std::string &topic, const std::string &payload) { + if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("STOP")) == 0) { + this->cover_->make_call().set_command_stop_tilt().perform(); + return; + } auto value = parse_number(payload); if (!value.has_value()) { ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str()); diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 8702c962d78..1756d68ac61 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -32,15 +32,15 @@ void MQTTFanComponent::setup() { auto val = parse_on_off(payload.c_str()); switch (val) { case PARSE_ON: - ESP_LOGD(TAG, "'%s' Turning Fan ON.", this->friendly_name_().c_str()); + ESP_LOGD(TAG, "'%s' Turning Fan ON.", LOG_STR_ARG(this->log_name_())); this->state_->turn_on().perform(); break; case PARSE_OFF: - ESP_LOGD(TAG, "'%s' Turning Fan OFF.", this->friendly_name_().c_str()); + ESP_LOGD(TAG, "'%s' Turning Fan OFF.", LOG_STR_ARG(this->log_name_())); this->state_->turn_off().perform(); break; case PARSE_TOGGLE: - ESP_LOGD(TAG, "'%s' Toggling Fan.", this->friendly_name_().c_str()); + ESP_LOGD(TAG, "'%s' Toggling Fan.", LOG_STR_ARG(this->log_name_())); this->state_->toggle().perform(); break; case PARSE_NONE: @@ -56,11 +56,11 @@ void MQTTFanComponent::setup() { auto val = parse_on_off(payload.c_str(), "forward", "reverse"); switch (val) { case PARSE_ON: - ESP_LOGD(TAG, "'%s': Setting direction FORWARD", this->friendly_name_().c_str()); + ESP_LOGD(TAG, "'%s': Setting direction FORWARD", LOG_STR_ARG(this->log_name_())); this->state_->make_call().set_direction(fan::FanDirection::FORWARD).perform(); break; case PARSE_OFF: - ESP_LOGD(TAG, "'%s': Setting direction REVERSE", this->friendly_name_().c_str()); + ESP_LOGD(TAG, "'%s': Setting direction REVERSE", LOG_STR_ARG(this->log_name_())); this->state_->make_call().set_direction(fan::FanDirection::REVERSE).perform(); break; case PARSE_TOGGLE: @@ -83,11 +83,11 @@ void MQTTFanComponent::setup() { auto val = parse_on_off(payload.c_str(), "oscillate_on", "oscillate_off"); switch (val) { case PARSE_ON: - ESP_LOGD(TAG, "'%s': Setting oscillating ON", this->friendly_name_().c_str()); + ESP_LOGD(TAG, "'%s': Setting oscillating ON", LOG_STR_ARG(this->log_name_())); this->state_->make_call().set_oscillating(true).perform(); break; case PARSE_OFF: - ESP_LOGD(TAG, "'%s': Setting oscillating OFF", this->friendly_name_().c_str()); + ESP_LOGD(TAG, "'%s': Setting oscillating OFF", LOG_STR_ARG(this->log_name_())); this->state_->make_call().set_oscillating(false).perform(); break; case PARSE_TOGGLE: @@ -167,9 +167,8 @@ void MQTTFanComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig bool MQTTFanComponent::publish_state() { char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; const char *state_s = this->state_->state ? "ON" : "OFF"; - ESP_LOGD(TAG, "'%s' Sending state %s.", this->state_->get_name().c_str(), state_s); - this->publish(this->get_state_topic_to_(topic_buf), state_s); - bool failed = false; + ESP_LOGD(TAG, "'%s' Sending state %s.", LOG_STR_ARG(this->state_->get_log_name()), state_s); + bool failed = !this->publish(this->get_state_topic_to_(topic_buf), state_s); if (this->state_->get_traits().supports_direction()) { bool success = this->publish(this->get_direction_state_topic_to(topic_buf), fan_direction_to_mqtt_str(this->state_->direction)); diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 2263856f43d..77dc7cc679d 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -24,7 +24,7 @@ void MQTTLockComponent::setup() { } else if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("OPEN")) == 0) { this->lock_->open(); } else { - ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); + ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", LOG_STR_ARG(this->log_name_()), payload.c_str()); this->status_momentary_warning(5000); } }); diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index 77ec004cb98..d386d5c3238 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -28,7 +28,7 @@ void MQTTSwitchComponent::setup() { break; case PARSE_NONE: default: - ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); + ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", LOG_STR_ARG(this->log_name_()), payload.c_str()); this->status_momentary_warning(5000); break; } diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index 7cdc0b4fc12..4e03f634d13 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -19,7 +19,7 @@ void MQTTUpdateComponent::setup() { if (payload == "INSTALL") { this->update_->perform(); } else { - ESP_LOGW(TAG, "'%s': Received unknown update payload: %s", this->friendly_name_().c_str(), payload.c_str()); + ESP_LOGW(TAG, "'%s': Received unknown update payload: %s", LOG_STR_ARG(this->log_name_()), payload.c_str()); this->status_momentary_warning(5000); } }); diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index 2e20c4e0ea5..edbbbe5b1d9 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -329,8 +329,8 @@ void MS8607Component::read_humidity_(float temperature_float) { humidity &= ~(0b11); // strip status & unassigned bits from data // map 16 bit humidity value into range [-6%, 118%] - float const humidity_partial = double(humidity) / (1 << 16); - float const humidity_percentage = std::lerp(-6.0, 118.0, humidity_partial); + float const humidity_partial = float(humidity) / (1 << 16); + float const humidity_percentage = std::lerp(-6.0f, 118.0f, humidity_partial); float const compensated_humidity_percentage = humidity_percentage + (20 - temperature_float) * MS8607_H_TEMP_COEFFICIENT; ESP_LOGD(TAG, "Compensated for temperature, humidity=%.2f%%", compensated_humidity_percentage); diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py index 0c9539fd9f2..19691589fd9 100644 --- a/esphome/components/mt6701_i2c/__init__.py +++ b/esphome/components/mt6701_i2c/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c +from esphome.components.const import CONF_OUT_PIN, CONF_PWM_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_DIRECTION, CONF_HYSTERESIS, CONF_ID, CONF_MODE from esphome.types import ConfigType @@ -27,8 +28,6 @@ CONF_PULSES_PER_REVOLUTION = "pulses_per_revolution" CONF_Z_PULSE_WIDTH = "z_pulse_width" CONF_UVW = "uvw" CONF_POLE_PAIRS = "pole_pairs" -CONF_OUT_PIN = "out_pin" -CONF_PWM_FREQUENCY = "pwm_frequency" CONF_PWM_POLARITY = "pwm_polarity" CONF_ANALOG_START = "analog_start" CONF_ANALOG_STOP = "analog_stop" diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 813321fac9e..299a98fca2c 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -297,7 +297,7 @@ void NAU7802Sensor::update() { // Get the most recent sample to publish int32_t result = this->read_value_(ADCO_B2_REG, 3); - ESP_LOGD(TAG, "'%s': Got value %" PRId32, this->name_.c_str(), result); + ESP_LOGD(TAG, "'%s': Got value %" PRId32, LOG_STR_ARG(this->get_log_name()), result); this->publish_state(result); } diff --git a/esphome/components/nfc/nfc_helpers.cpp b/esphome/components/nfc/nfc_helpers.cpp index 412cc385c6b..79d10471344 100644 --- a/esphome/components/nfc/nfc_helpers.cpp +++ b/esphome/components/nfc/nfc_helpers.cpp @@ -28,12 +28,12 @@ std::string get_random_ha_tag_ndef() { for (int i = 0; i < 8; i++) { uri += ALPHANUM[random_uint32() % (sizeof(ALPHANUM) - 1)]; } - uri += "-"; + uri += '-'; for (int j = 0; j < 3; j++) { for (int i = 0; i < 4; i++) { uri += ALPHANUM[random_uint32() % (sizeof(ALPHANUM) - 1)]; } - uri += "-"; + uri += '-'; } for (int i = 0; i < 12; i++) { uri += ALPHANUM[random_uint32() % (sizeof(ALPHANUM) - 1)]; diff --git a/esphome/components/noblex/noblex.cpp b/esphome/components/noblex/noblex.cpp index a9fb3935f1f..c5bdbdf6214 100644 --- a/esphome/components/noblex/noblex.cpp +++ b/esphome/components/noblex/noblex.cpp @@ -254,6 +254,8 @@ bool NoblexClimate::on_receive(remote_base::RemoteReceiveData data) { case IRNoblexMode::IR_NOBLEX_MODE_HEAT: this->mode = climate::CLIMATE_MODE_HEAT; break; + default: + break; } } diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 8b97b3feb24..caca46ed28c 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1030,11 +1030,22 @@ def _generate_cmake_lists(pch_on: bool) -> bool: 'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")', ] - if compile_flags: + defines = [flag for flag in compile_flags if flag.startswith("-D")] + options = [flag for flag in compile_flags if not flag.startswith("-D")] + if defines: lines += [ "", "target_compile_options(app PRIVATE", - *[f' "{flag}"' for flag in compile_flags], + *[f' "{flag}"' for flag in defines], + ")", + ] + if options: + # A target's own options come before the ones it inherits from zephyr_interface, and the + # compiler keeps the last of two opposing warning flags. Source options come after both. + lines += [ + "", + "set_source_files_properties(${APP_SOURCES} PROPERTIES", + f' COMPILE_OPTIONS "{";".join(options)}"', ")", ] diff --git a/esphome/components/ntc/ntc.cpp b/esphome/components/ntc/ntc.cpp index d2b095a3d75..60b0dd2d891 100644 --- a/esphome/components/ntc/ntc.cpp +++ b/esphome/components/ntc/ntc.cpp @@ -21,7 +21,7 @@ void NTC::process_(float value) { double v = this->a_ + this->b_ * lr + this->c_ * lr * lr * lr; auto temp = float(1.0 / v - 273.15); - ESP_LOGV(TAG, "'%s' - Temperature: %.1f°C", this->name_.c_str(), temp); + ESP_LOGV(TAG, "'%s' - Temperature: %.1f°C", LOG_STR_ARG(this->get_log_name()), temp); this->publish_state(temp); } diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index 78917fed564..cc371655330 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -20,7 +20,7 @@ void log_number(const char *tag, const char *prefix, const char *type, Number *o void Number::publish_state(float state) { this->set_has_state(true); this->state = state; - ESP_LOGV(TAG, "'%s' >> %.2f", this->get_name().c_str(), state); + ESP_LOGV(TAG, "'%s' >> %.2f", LOG_STR_ARG(this->get_log_name()), state); this->state_callback_.call(state); #if defined(USE_NUMBER) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_number_update(this); diff --git a/esphome/components/number/number_call.cpp b/esphome/components/number/number_call.cpp index a928eaf3fa4..4079d775227 100644 --- a/esphome/components/number/number_call.cpp +++ b/esphome/components/number/number_call.cpp @@ -8,12 +8,12 @@ ESPHOME_LOG_TAG(TAG, "number"); // Helper functions to reduce code size for logging void NumberCall::log_perform_warning_(const LogString *message) { - ESP_LOGW(TAG, "'%s': %s", this->parent_->get_name().c_str(), LOG_STR_ARG(message)); + ESP_LOGW(TAG, "'%s': %s", LOG_STR_ARG(this->parent_->get_log_name()), LOG_STR_ARG(message)); } void NumberCall::log_perform_warning_value_range_(const LogString *comparison, const LogString *limit_type, float val, float limit) { - ESP_LOGW(TAG, "'%s': %f %s %s %f", this->parent_->get_name().c_str(), val, LOG_STR_ARG(comparison), + ESP_LOGW(TAG, "'%s': %f %s %s %f", LOG_STR_ARG(this->parent_->get_log_name()), val, LOG_STR_ARG(comparison), LOG_STR_ARG(limit_type), limit); } @@ -48,7 +48,7 @@ NumberCall &NumberCall::with_cycle(bool cycle) { void NumberCall::perform() { auto *parent = this->parent_; - const auto *name = parent->get_name().c_str(); + const auto *name = LOG_STR_ARG(parent->get_log_name()); const auto &traits = parent->traits; if (this->operation_ == NUMBER_OP_NONE) { diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 309d35aac19..2298e5df8ba 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_OUT_PIN from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 @@ -15,7 +16,6 @@ DOMAIN = "opentherm" MULTI_CONF = True CONF_IN_PIN = "in_pin" -CONF_OUT_PIN = "out_pin" CONF_CH_ENABLE = "ch_enable" CONF_DHW_ENABLE = "dhw_enable" CONF_COOLING_ENABLE = "cooling_enable" diff --git a/esphome/components/opentherm/switch/opentherm_switch.cpp b/esphome/components/opentherm/switch/opentherm_switch.cpp index 6bf06f825e9..17640dc7180 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.cpp +++ b/esphome/components/opentherm/switch/opentherm_switch.cpp @@ -10,9 +10,9 @@ void OpenthermSwitch::setup() { auto restored = this->get_initial_state_with_restore_mode(); bool state = false; if (!restored.has_value()) { - ESP_LOGD(TAG, "Couldn't restore state for OpenTherm switch '%s'", this->get_name().c_str()); + ESP_LOGD(TAG, "Couldn't restore state for OpenTherm switch '%s'", LOG_STR_ARG(this->get_log_name())); } else { - ESP_LOGD(TAG, "Restored state for OpenTherm switch '%s': %d", this->get_name().c_str(), restored.value()); + ESP_LOGD(TAG, "Restored state for OpenTherm switch '%s': %d", LOG_STR_ARG(this->get_log_name()), restored.value()); state = restored.value(); } this->write_state(state); diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index caf7decde94..dd655743913 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -108,7 +108,7 @@ void OPT3001Sensor::update() { this->publish_state(NAN); return; } - ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), val); + ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", LOG_STR_ARG(this->get_log_name()), val); this->publish_state(val); }); } diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 61b65501cfa..37aa3286fe9 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -27,7 +27,7 @@ class PCA6416AComponent final : public Component, void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: - static void IRAM_ATTR gpio_intr(PCA6416AComponent *arg); + static void gpio_intr(PCA6416AComponent *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h index a8ce6f1a324..1ccf86df3c2 100644 --- a/esphome/components/pca9505/pca9505.h +++ b/esphome/components/pca9505/pca9505.h @@ -29,7 +29,7 @@ class PCA9505Component final : public Component, void dump_config() override; protected: - static void IRAM_ATTR gpio_intr(PCA9505Component *arg); + static void gpio_intr(PCA9505Component *arg); bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 053139bc9e7..b3efdab819f 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -38,7 +38,7 @@ class PCA9554Component final : public Component, #endif protected: - static void IRAM_ATTR gpio_intr(PCA9554Component *arg); + static void gpio_intr(PCA9554Component *arg); bool read_inputs_(); bool write_register_(uint8_t reg, uint16_t value) { return this->write_address_(reg * this->reg_width_, value); } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 4bc19d204fa..593b2db5534 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -33,7 +33,7 @@ class PCF8574Component final : public Component, void dump_config() override; protected: - static void IRAM_ATTR gpio_intr(PCF8574Component *arg); + static void gpio_intr(PCF8574Component *arg); bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 556404a3743..dd7a8ef6540 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -25,7 +25,7 @@ class PI4IOE5V6408Component final : public Component, void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: - static void IRAM_ATTR gpio_intr(PI4IOE5V6408Component *arg); + static void gpio_intr(PI4IOE5V6408Component *arg); bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index 8b9ce7bbee3..140bf209ff6 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -74,7 +74,7 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce } if (!std::isnan(this->setpoint_) && this->setpoint_ != setpoint) { - ESP_LOGW(TAG, "%s: Setpoint changed during autotune! The result will not be accurate!", this->id_.c_str()); + ESP_LOGW(TAG, "%s: Setpoint changed during autotune! The result will not be accurate!", LOG_STR_ARG(this->id_)); } this->setpoint_ = setpoint; @@ -88,7 +88,7 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce if (!this->frequency_detector_.has_enough_data() || !this->amplitude_detector_.has_enough_data()) { // not enough data for calculation yet - ESP_LOGV(TAG, "%s: Not enough data yet for autotuner", this->id_.c_str()); + ESP_LOGV(TAG, "%s: Not enough data yet for autotuner", LOG_STR_ARG(this->id_)); return res; } @@ -98,13 +98,13 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce // The frequency/amplitude is not fully accurate yet, try to wait // until the fault clears, or terminate after a while anyway if (!zc_symmetrical) { - ESP_LOGVV(TAG, "%s: ZC is not symmetrical", this->id_.c_str()); + ESP_LOGVV(TAG, "%s: ZC is not symmetrical", LOG_STR_ARG(this->id_)); } if (!amplitude_convergent) { - ESP_LOGVV(TAG, "%s: Amplitude is not convergent", this->id_.c_str()); + ESP_LOGVV(TAG, "%s: Amplitude is not convergent", LOG_STR_ARG(this->id_)); } uint32_t phase = this->relay_function_.phase_count; - ESP_LOGVV(TAG, "%s: >", this->id_.c_str()); + ESP_LOGVV(TAG, "%s: >", LOG_STR_ARG(this->id_)); ESP_LOGVV(TAG, " Phase %" PRIu32 ", enough=%" PRIu32, phase, enough_data_phase_); if (this->enough_data_phase_ == 0) { @@ -118,7 +118,7 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce } } - ESP_LOGI(TAG, "%s: PID Autotune finished!", this->id_.c_str()); + ESP_LOGI(TAG, "%s: PID Autotune finished!", LOG_STR_ARG(this->id_)); float osc_ampl = this->amplitude_detector_.get_mean_oscillation_amplitude(); float d = (this->relay_function_.output_positive - this->relay_function_.output_negative) / 2.0f; @@ -137,7 +137,7 @@ void PIDAutotuner::dump_config() { ESP_LOGI(TAG, "%s: PID Autotune:\n" " State: Succeeded!", - this->id_.c_str()); + LOG_STR_ARG(this->id_)); bool has_issue = false; if (!this->amplitude_detector_.is_amplitude_convergent()) { ESP_LOGW(TAG, " Could not reliably determine oscillation amplitude, PID parameters may be inaccurate!\n" @@ -181,7 +181,7 @@ void PIDAutotuner::dump_config() { print_rule_("Pessen Integral PID", 0.7f, 1.75f, 0.105f); print_rule_("Some Overshoot PID", 0.333f, 0.667f, 0.111f); print_rule_("No Overshoot PID", 0.2f, 0.4f, 0.0625f); - ESP_LOGI(TAG, "%s: Autotune completed", this->id_.c_str()); + ESP_LOGI(TAG, "%s: Autotune completed", LOG_STR_ARG(this->id_)); } if (this->state_ == AUTOTUNE_RUNNING) { @@ -193,7 +193,7 @@ void PIDAutotuner::dump_config() { " Phases: %" PRIu32 "\n" " Detected %zu zero-crossings\n" " Current Phase Min: %.2f, Max: %.2f", - this->id_.c_str(), setpoint_ - relay_function_.current_target_error(), relay_function_.phase_count, + LOG_STR_ARG(this->id_), setpoint_ - relay_function_.current_target_error(), relay_function_.phase_count, frequency_detector_.zerocrossing_intervals.size(), amplitude_detector_.phase_min, amplitude_detector_.phase_max); } diff --git a/esphome/components/pid/pid_autotuner.h b/esphome/components/pid/pid_autotuner.h index 5115c73c95b..d0c2d10063c 100644 --- a/esphome/components/pid/pid_autotuner.h +++ b/esphome/components/pid/pid_autotuner.h @@ -29,7 +29,8 @@ class PIDAutotuner { void dump_config(); - void set_autotuner_id(std::string id) { this->id_ = std::move(id); } + /// Name shown in log lines; must outlive the autotuner. + void set_autotuner_id(const LogString *id) { this->id_ = id; } void set_noiseband(float noiseband) { relay_function_.noiseband = noiseband; @@ -106,7 +107,7 @@ class PIDAutotuner { } state_ = AUTOTUNE_RUNNING; float ku_; float pu_; - std::string id_; + const LogString *id_{reinterpret_cast("")}; // empty until set, like the old std::string }; } // namespace esphome::pid diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index d33a7f75441..059f130130e 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -184,14 +184,14 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { float min_value = this->supports_cool_() ? -1.0f : 0.0f; float max_value = this->supports_heat_() ? 1.0f : 0.0f; this->autotuner_->config(min_value, max_value); - this->autotuner_->set_autotuner_id(this->get_name()); + this->autotuner_->set_autotuner_id(this->get_log_name()); ESP_LOGI(TAG, "%s: Autotune has started. This can take a long time depending on the " "responsiveness of your system. Your system " "output will be altered to deliberately oscillate above and below the setpoint multiple times. " "Until your sensor provides a reading, the autotuner may display \'nan\'", - this->get_name().c_str()); + LOG_STR_ARG(this->get_log_name())); this->set_interval(AUTOTUNE_PROGRESS_INTERVAL_ID, 10000, [this]() { if (this->autotuner_ != nullptr && !this->autotuner_->is_finished()) @@ -200,7 +200,7 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { if (mode != climate::CLIMATE_MODE_HEAT_COOL) { ESP_LOGW(TAG, "%s: !!! For PID autotuner you need to set AUTO (also called heat/cool) mode!", - this->get_name().c_str()); + LOG_STR_ARG(this->get_log_name())); } } diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index 1415aa33b86..a9ca968a6f5 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -656,6 +656,8 @@ void Pipsolar::handle_qpiws_(const char *message) { this->publish_binary_sensor_(enabled, this->warning_battery_equalization_); value_warnings_present |= enabled.value_or(false); break; + default: + break; } } diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index b2bd7bd9bd7..78ee6eaaaa4 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -109,18 +109,23 @@ void PrometheusHandler::handleRequest(AsyncWebServerRequest *req) { req->send(stream); } -std::string PrometheusHandler::relabel_id_(EntityBase *obj) { +void PrometheusHandler::print_relabel_id_(AsyncResponseStream *stream, EntityBase *obj) { auto item = relabel_map_id_.find(obj); if (item != relabel_map_id_.end()) { - return item->second; + stream->print(item->second.c_str()); + return; } char object_id_buf[OBJECT_ID_MAX_LEN]; - return obj->get_object_id_to(object_id_buf).str(); + stream->print(obj->get_object_id_to(object_id_buf).c_str()); } -std::string PrometheusHandler::relabel_name_(EntityBase *obj) { +void PrometheusHandler::print_relabel_name_(AsyncResponseStream *stream, EntityBase *obj) { auto item = relabel_map_name_.find(obj); - return item == relabel_map_name_.end() ? obj->get_name() : item->second; + if (item != relabel_map_name_.end()) { + stream->print(item->second.c_str()); + return; + } + stream->print(reinterpret_cast(obj->get_name_progmem().progmem_ptr())); } void PrometheusHandler::add_area_label_(AsyncResponseStream *stream, std::string &area) { @@ -148,12 +153,12 @@ void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, Progme std::string &area, std::string &node, std::string &friendly_name) { stream->print(metric_name); stream->print(ESPHOME_F("{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); } // Type-specific implementation @@ -169,21 +174,21 @@ void PrometheusHandler::sensor_row_(AsyncResponseStream *stream, sensor::Sensor if (!std::isnan(obj->state)) { // We have a valid value, output this value stream->print(ESPHOME_F("esphome_sensor_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_sensor_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",unit=\"")); stream->print(obj->get_unit_of_measurement_ref().c_str()); stream->print(ESPHOME_F("\"} ")); @@ -194,12 +199,12 @@ void PrometheusHandler::sensor_row_(AsyncResponseStream *stream, sensor::Sensor } else { // Invalid state stream->print(ESPHOME_F("esphome_sensor_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -218,33 +223,33 @@ void PrometheusHandler::binary_sensor_row_(AsyncResponseStream *stream, binary_s if (obj->has_state()) { // We have a valid value, output this value stream->print(ESPHOME_F("esphome_binary_sensor_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_binary_sensor_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->state); stream->print(ESPHOME_F("\n")); } else { // Invalid state stream->print(ESPHOME_F("esphome_binary_sensor_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -262,33 +267,33 @@ void PrometheusHandler::fan_row_(AsyncResponseStream *stream, fan::Fan *obj, std if (obj->is_internal() && !this->include_internal_) return; stream->print(ESPHOME_F("esphome_fan_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_fan_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->state); stream->print(ESPHOME_F("\n")); // Speed if available if (obj->get_traits().supports_speed()) { stream->print(ESPHOME_F("esphome_fan_speed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->speed); stream->print(ESPHOME_F("\n")); @@ -296,12 +301,12 @@ void PrometheusHandler::fan_row_(AsyncResponseStream *stream, fan::Fan *obj, std // Oscillation if available if (obj->get_traits().supports_oscillation()) { stream->print(ESPHOME_F("esphome_fan_oscillation{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->oscillating); stream->print(ESPHOME_F("\n")); @@ -385,32 +390,32 @@ void PrometheusHandler::cover_row_(AsyncResponseStream *stream, cover::Cover *ob if (!std::isnan(obj->position)) { // We have a valid value, output this value stream->print(ESPHOME_F("esphome_cover_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_cover_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->position); stream->print(ESPHOME_F("\n")); if (obj->get_traits().get_supports_tilt()) { stream->print(ESPHOME_F("esphome_cover_tilt{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->tilt); stream->print(ESPHOME_F("\n")); @@ -418,12 +423,12 @@ void PrometheusHandler::cover_row_(AsyncResponseStream *stream, cover::Cover *ob } else { // Invalid state stream->print(ESPHOME_F("esphome_cover_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -439,21 +444,21 @@ void PrometheusHandler::switch_row_(AsyncResponseStream *stream, switch_::Switch if (obj->is_internal() && !this->include_internal_) return; stream->print(ESPHOME_F("esphome_switch_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_switch_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->state); stream->print(ESPHOME_F("\n")); @@ -470,21 +475,21 @@ void PrometheusHandler::lock_row_(AsyncResponseStream *stream, lock::Lock *obj, if (obj->is_internal() && !this->include_internal_) return; stream->print(ESPHOME_F("esphome_lock_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_lock_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->state); stream->print(ESPHOME_F("\n")); @@ -504,21 +509,21 @@ void PrometheusHandler::text_sensor_row_(AsyncResponseStream *stream, text_senso if (obj->has_state()) { // We have a valid value, output this value stream->print(ESPHOME_F("esphome_text_sensor_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_text_sensor_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",value=\"")); stream->print(obj->state.c_str()); stream->print(ESPHOME_F("\"} ")); @@ -527,12 +532,12 @@ void PrometheusHandler::text_sensor_row_(AsyncResponseStream *stream, text_senso } else { // Invalid state stream->print(ESPHOME_F("esphome_text_sensor_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -551,21 +556,21 @@ void PrometheusHandler::text_row_(AsyncResponseStream *stream, text::Text *obj, if (obj->has_state()) { // We have a valid value, output this value stream->print(ESPHOME_F("esphome_text_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_text_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",value=\"")); stream->print(obj->state.c_str()); stream->print(ESPHOME_F("\"} ")); @@ -574,12 +579,12 @@ void PrometheusHandler::text_row_(AsyncResponseStream *stream, text::Text *obj, } else { // Invalid state stream->print(ESPHOME_F("esphome_text_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -598,21 +603,21 @@ void PrometheusHandler::event_row_(AsyncResponseStream *stream, event::Event *ob if (obj->has_event()) { // We have a valid event type, output this value stream->print(ESPHOME_F("esphome_event_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_event_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",last_event_type=\"")); // get_last_event_type() returns StringRef (null-terminated) stream->print(obj->get_last_event_type().c_str()); @@ -622,12 +627,12 @@ void PrometheusHandler::event_row_(AsyncResponseStream *stream, event::Event *ob } else { // No event triggered yet stream->print(ESPHOME_F("esphome_event_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -646,33 +651,33 @@ void PrometheusHandler::number_row_(AsyncResponseStream *stream, number::Number if (!std::isnan(obj->state)) { // We have a valid value, output this value stream->print(ESPHOME_F("esphome_number_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_number_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->state); stream->print(ESPHOME_F("\n")); } else { // Invalid state stream->print(ESPHOME_F("esphome_number_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -690,21 +695,21 @@ void PrometheusHandler::select_row_(AsyncResponseStream *stream, select::Select if (obj->has_state()) { // We have a valid value, output this value stream->print(ESPHOME_F("esphome_select_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_select_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",value=\"")); // c_str() is safe as option values are null-terminated strings from codegen stream->print(obj->current_option().c_str()); @@ -714,12 +719,12 @@ void PrometheusHandler::select_row_(AsyncResponseStream *stream, select::Select } else { // Invalid state stream->print(ESPHOME_F("esphome_select_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -737,43 +742,43 @@ void PrometheusHandler::media_player_row_(AsyncResponseStream *stream, media_pla if (obj->is_internal() && !this->include_internal_) return; stream->print(ESPHOME_F("esphome_media_player_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_media_player_state_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",value=\"")); stream->print(media_player::media_player_state_to_string(obj->state)); stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); stream->print(ESPHOME_F("esphome_media_player_volume{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->volume); stream->print(ESPHOME_F("\n")); stream->print(ESPHOME_F("esphome_media_player_is_muted{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); if (obj->is_muted()) { stream->print(ESPHOME_F("1.0")); @@ -818,21 +823,21 @@ void PrometheusHandler::update_entity_row_(AsyncResponseStream *stream, update:: if (obj->has_state()) { // We have a valid value, output this value stream->print(ESPHOME_F("esphome_update_entity_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // First update state stream->print(ESPHOME_F("esphome_update_entity_state{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",value=\"")); handle_update_state_(stream, obj->state); stream->print(ESPHOME_F("\"} ")); @@ -840,12 +845,12 @@ void PrometheusHandler::update_entity_row_(AsyncResponseStream *stream, update:: stream->print(ESPHOME_F("\n")); // Next update info stream->print(ESPHOME_F("esphome_update_entity_info{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",current_version=\"")); stream->print(obj->update_info.current_version.c_str()); stream->print(ESPHOME_F("\",latest_version=\"")); @@ -858,12 +863,12 @@ void PrometheusHandler::update_entity_row_(AsyncResponseStream *stream, update:: } else { // Invalid state stream->print(ESPHOME_F("esphome_update_entity_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 1\n")); } } @@ -881,21 +886,21 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob if (obj->is_internal() && !this->include_internal_) return; stream->print(ESPHOME_F("esphome_valve_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} 0\n")); // Data itself stream->print(ESPHOME_F("esphome_valve_operation{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",operation=\"")); stream->print(reinterpret_cast(valve::valve_operation_to_str(obj->current_operation))); stream->print(ESPHOME_F("\"} ")); @@ -904,12 +909,12 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob // Now see if position is supported if (obj->get_traits().get_supports_position()) { stream->print(ESPHOME_F("esphome_valve_position{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\"} ")); stream->print(obj->position); stream->print(ESPHOME_F("\n")); @@ -928,12 +933,12 @@ void PrometheusHandler::climate_setting_row_(AsyncResponseStream *stream, climat std::string &node, std::string &friendly_name, std::string &setting, const LogString *setting_value) { stream->print(ESPHOME_F("esphome_climate_setting{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",category=\"")); stream->print(setting.c_str()); stream->print(ESPHOME_F("\",setting_value=\"")); @@ -951,12 +956,12 @@ void PrometheusHandler::climate_value_row_(AsyncResponseStream *stream, climate: std::string &node, std::string &friendly_name, std::string &category, const char *climate_value) { stream->print(ESPHOME_F("esphome_climate_value{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",category=\"")); stream->print(category.c_str()); stream->print(ESPHOME_F("\"} ")); @@ -968,12 +973,12 @@ void PrometheusHandler::climate_failed_row_(AsyncResponseStream *stream, climate std::string &node, std::string &friendly_name, std::string &category, bool is_failed_value) { stream->print(ESPHOME_F("esphome_climate_failed{id=\"")); - stream->print(relabel_id_(obj).c_str()); + this->print_relabel_id_(stream, obj); add_area_label_(stream, area); add_node_label_(stream, node); add_friendly_name_label_(stream, friendly_name); stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + this->print_relabel_name_(stream, obj); stream->print(ESPHOME_F("\",category=\"")); stream->print(category.c_str()); stream->print(ESPHOME_F("\"} ")); diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index ae5bbcae74a..20c03653dd5 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -63,8 +63,8 @@ class PrometheusHandler final : public AsyncWebHandler, public Component { } protected: - std::string relabel_id_(EntityBase *obj); - std::string relabel_name_(EntityBase *obj); + void print_relabel_id_(AsyncResponseStream *stream, EntityBase *obj); + void print_relabel_name_(AsyncResponseStream *stream, EntityBase *obj); void add_area_label_(AsyncResponseStream *stream, std::string &area); void add_node_label_(AsyncResponseStream *stream, std::string &node); void add_friendly_name_label_(AsyncResponseStream *stream, std::string &friendly_name); diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 65c55efea50..a707e082766 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -115,7 +115,11 @@ def request_external_task_stack() -> None: ``to_code`` when the option is enabled. The sdkconfig option only permits external stacks; it does not move any stack into PSRAM on its own, so it stays opt-in per task. """ - add_idf_sdkconfig_option("CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True) + # Renamed in ESP-IDF 5.4; the old name is still mapped there, but each configure reports it + if idf_version() >= cv.Version(5, 4, 0): + add_idf_sdkconfig_option("CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM", True) + else: + add_idf_sdkconfig_option("CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True) def validate_task_stack_in_psram(value: Any) -> bool: diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.cpp b/esphome/components/pulse_counter/pulse_counter_sensor.cpp index b104016fb8f..f39e54bc875 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.cpp +++ b/esphome/components/pulse_counter/pulse_counter_sensor.cpp @@ -29,18 +29,16 @@ void IRAM_ATTR BasicPulseCounterStorage::gpio_intr(BasicPulseCounterStorage *arg if (discard) return; - PulseCounterCountMode mode = arg->isr_pin.digital_read() ? arg->rising_edge_mode : arg->falling_edge_mode; - switch (mode) { - case PULSE_COUNTER_DISABLE: - break; - case PULSE_COUNTER_INCREMENT: { - auto x = arg->counter + 1; - arg->counter = x; - } break; - case PULSE_COUNTER_DECREMENT: { - auto x = arg->counter - 1; - arg->counter = x; - } break; + PulseCounterCountMode mode = arg->fixed_mode; + if (mode == PULSE_COUNTER_DISABLE) { + mode = arg->isr_pin.digital_read() ? arg->rising_edge_mode : arg->falling_edge_mode; + } + if (mode == PULSE_COUNTER_INCREMENT) { + auto x = arg->counter + 1; + arg->counter = x; + } else if (mode == PULSE_COUNTER_DECREMENT) { + auto x = arg->counter - 1; + arg->counter = x; } } @@ -48,7 +46,24 @@ bool BasicPulseCounterStorage::pulse_counter_setup(InternalGPIOPin *pin) { this->pin = pin; this->pin->setup(); this->isr_pin = this->pin->to_isr(); - this->pin->attach_interrupt(BasicPulseCounterStorage::gpio_intr, this, gpio::INTERRUPT_ANY_EDGE); + const bool rising = this->rising_edge_mode != PULSE_COUNTER_DISABLE; + const bool falling = this->falling_edge_mode != PULSE_COUNTER_DISABLE; + if (!rising && !falling) { + return true; + } + gpio::InterruptType type = gpio::INTERRUPT_ANY_EDGE; + if (this->filter_us == 0) { + if (!falling) { + type = gpio::INTERRUPT_RISING_EDGE; + this->fixed_mode = this->rising_edge_mode; + } else if (!rising) { + type = gpio::INTERRUPT_FALLING_EDGE; + this->fixed_mode = this->falling_edge_mode; + } else if (this->rising_edge_mode == this->falling_edge_mode) { + this->fixed_mode = this->rising_edge_mode; + } + } + this->pin->attach_interrupt(BasicPulseCounterStorage::gpio_intr, this, type); return true; } @@ -166,6 +181,32 @@ pulse_counter_t HwPulseCounterStorage::read_raw_value() { } #endif // HAS_PCNT +bool PulseCounterSensor::before_setup_() { + if (this->storage_.pin == nullptr) { + return true; + } + ESP_LOGE(TAG, "Edge modes and filter cannot change after setup"); + return false; +} + +void PulseCounterSensor::set_rising_edge_mode(PulseCounterCountMode mode) { + if (this->before_setup_()) { + this->storage_.rising_edge_mode = mode; + } +} + +void PulseCounterSensor::set_falling_edge_mode(PulseCounterCountMode mode) { + if (this->before_setup_()) { + this->storage_.falling_edge_mode = mode; + } +} + +void PulseCounterSensor::set_filter_us(uint32_t filter) { + if (this->before_setup_()) { + this->storage_.filter_us = filter; + } +} + void PulseCounterSensor::setup() { if (!this->storage_.pulse_counter_setup(this->pin_)) { this->mark_failed(); @@ -197,13 +238,13 @@ void PulseCounterSensor::update() { if (this->last_time_ != 0) { uint32_t interval = now - this->last_time_; float value = (60000.0f * raw) / float(interval); // per minute - ESP_LOGD(TAG, "'%s': Retrieved counter: %0.2f pulses/min", this->get_name().c_str(), value); + ESP_LOGD(TAG, "'%s': Retrieved counter: %0.2f pulses/min", LOG_STR_ARG(this->get_log_name()), value); this->publish_state(value); } if (this->total_sensor_ != nullptr) { current_total_ += raw; - ESP_LOGD(TAG, "'%s': Total : %" PRIu32 " pulses", this->get_name().c_str(), current_total_); + ESP_LOGD(TAG, "'%s': Total : %" PRIu32 " pulses", LOG_STR_ARG(this->get_log_name()), current_total_); this->total_sensor_->publish_state(current_total_); } this->last_time_ = now; diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.h b/esphome/components/pulse_counter/pulse_counter_sensor.h index 6704d3dc310..d6dd6697c7b 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.h +++ b/esphome/components/pulse_counter/pulse_counter_sensor.h @@ -28,7 +28,7 @@ struct PulseCounterStorageBase { virtual bool pulse_counter_setup(InternalGPIOPin *pin) = 0; virtual pulse_counter_t read_raw_value() = 0; - InternalGPIOPin *pin; + InternalGPIOPin *pin{nullptr}; PulseCounterCountMode rising_edge_mode{PULSE_COUNTER_INCREMENT}; PulseCounterCountMode falling_edge_mode{PULSE_COUNTER_DISABLE}; uint32_t filter_us{0}; @@ -45,6 +45,7 @@ struct BasicPulseCounterStorage : public PulseCounterStorageBase { volatile uint32_t last_pulse{0}; ISRInternalGPIOPin isr_pin; + PulseCounterCountMode fixed_mode{PULSE_COUNTER_DISABLE}; }; #ifdef HAS_PCNT @@ -64,9 +65,9 @@ class PulseCounterSensor final : public sensor::Sensor, public PollingComponent explicit PulseCounterSensor(bool hw_pcnt = false) : storage_(*get_storage(hw_pcnt)) {} void set_pin(InternalGPIOPin *pin) { pin_ = pin; } - void set_rising_edge_mode(PulseCounterCountMode mode) { storage_.rising_edge_mode = mode; } - void set_falling_edge_mode(PulseCounterCountMode mode) { storage_.falling_edge_mode = mode; } - void set_filter_us(uint32_t filter) { storage_.filter_us = filter; } + void set_rising_edge_mode(PulseCounterCountMode mode); + void set_falling_edge_mode(PulseCounterCountMode mode); + void set_filter_us(uint32_t filter); void set_total_sensor(sensor::Sensor *total_sensor) { total_sensor_ = total_sensor; } void set_total_pulses(uint32_t pulses); @@ -77,6 +78,8 @@ class PulseCounterSensor final : public sensor::Sensor, public PollingComponent void dump_config() override; protected: + bool before_setup_(); + InternalGPIOPin *pin_; PulseCounterStorageBase &storage_; uint32_t last_time_{0}; diff --git a/esphome/components/pulse_width/pulse_width.cpp b/esphome/components/pulse_width/pulse_width.cpp index 53896cbacdb..b53e71ef58d 100644 --- a/esphome/components/pulse_width/pulse_width.cpp +++ b/esphome/components/pulse_width/pulse_width.cpp @@ -22,7 +22,7 @@ void PulseWidthSensor::dump_config() { } void PulseWidthSensor::update() { float width = this->store_.get_pulse_width_s(); - ESP_LOGCONFIG(TAG, "'%s' - Got pulse width %.3f s", this->name_.c_str(), width); + ESP_LOGCONFIG(TAG, "'%s' - Got pulse width %.3f s", LOG_STR_ARG(this->get_log_name()), width); this->publish_state(width); } diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index d941c5d2e1e..e59427f66da 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -44,7 +44,7 @@ class QMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } protected: - static void IRAM_ATTR gpio_intr(QMC5883LComponent *arg); + static void gpio_intr(QMC5883LComponent *arg); void read_sensor_(); QMC5883LDatarate datarate_{QMC5883L_DATARATE_10_HZ}; diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 231d6c3f898..5f379790f3d 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -13,7 +13,7 @@ void RadioFrequency::dump_config() { "Radio Frequency '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + LOG_STR_ARG(this->get_log_name()), YESNO(this->get_supports_transmitter()), YESNO(this->get_supports_receiver())); if (this->traits_.get_frequency_min_hz() > 0) { if (this->traits_.get_frequency_min_hz() == this->traits_.get_frequency_max_hz()) { diff --git a/esphome/components/resistance/resistance_sensor.cpp b/esphome/components/resistance/resistance_sensor.cpp index d6bc324fa42..de5d4a7879e 100644 --- a/esphome/components/resistance/resistance_sensor.cpp +++ b/esphome/components/resistance/resistance_sensor.cpp @@ -38,7 +38,7 @@ void ResistanceSensor::process_(float value) { } res *= this->resistor_; - ESP_LOGV(TAG, "'%s' - Resistance %.1fΩ", this->name_.c_str(), res); + ESP_LOGV(TAG, "'%s' - Resistance %.1fΩ", LOG_STR_ARG(this->get_log_name()), res); this->publish_state(res); } diff --git a/esphome/components/restart/button/__init__.py b/esphome/components/restart/button/__init__.py index 2648d1e85d3..a147be39d1c 100644 --- a/esphome/components/restart/button/__init__.py +++ b/esphome/components/restart/button/__init__.py @@ -1,26 +1,16 @@ -import esphome.codegen as cg from esphome.components import button -import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - DEVICE_CLASS_RESTART, - ENTITY_CATEGORY_CONFIG, - ICON_RESTART, -) +from esphome.const import DEVICE_CLASS_RESTART, ENTITY_CATEGORY_CONFIG, ICON_RESTART from esphome.types import ConfigType -restart_ns = cg.esphome_ns.namespace("restart") -RestartButton = restart_ns.class_("RestartButton", button.Button, cg.Component) - CONFIG_SCHEMA = button.button_schema( - RestartButton, + button.ApplyButton, icon=ICON_RESTART, device_class=DEVICE_CLASS_RESTART, entity_category=ENTITY_CATEGORY_CONFIG, -).extend(cv.COMPONENT_SCHEMA) +) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await button.register_button(var, config) + await button.sub_buttons(config).create( + config, None, apply="restart::restart_device()" + ) diff --git a/esphome/components/restart/button/restart_button.cpp b/esphome/components/restart/button/restart_button.cpp index cffa47896ce..008da563d8f 100644 --- a/esphome/components/restart/button/restart_button.cpp +++ b/esphome/components/restart/button/restart_button.cpp @@ -7,12 +7,11 @@ namespace esphome::restart { ESPHOME_LOG_TAG(TAG, "restart.button"); -void RestartButton::press_action() { +void restart_device() { ESP_LOGI(TAG, "Restarting device"); // Let MQTT settle a bit delay(100); // NOLINT App.safe_reboot(); } -void RestartButton::dump_config() { LOG_BUTTON("", "Restart Button", this); } } // namespace esphome::restart diff --git a/esphome/components/restart/button/restart_button.h b/esphome/components/restart/button/restart_button.h index 4baac6472c1..fbc64f64dd1 100644 --- a/esphome/components/restart/button/restart_button.h +++ b/esphome/components/restart/button/restart_button.h @@ -1,19 +1,8 @@ #pragma once -#include "esphome/components/button/button.h" -#include "esphome/core/component.h" - namespace esphome::restart { -class RestartButton final : public button::Button, public Component { - public: - // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. - RestartButton() {} - - void dump_config() override; - - protected: - void press_action() override; -}; +/// Run by the restart button. +void restart_device(); } // namespace esphome::restart diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 1eb2680e9c8..73145ff4469 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -147,7 +147,7 @@ void RFBridgeComponent::finish_bucket_frame_() { buckets--; } if ((i < 3) || (buckets % 2) || (i == at - 1)) { - str += " "; + str += ' '; } } ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index bccf5248687..13c7ef3dfc3 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -92,7 +92,17 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") - cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + # 1.8.4 plus decodeBegin()/decodeRows(), so an image can be decoded a few rows at a time and + # can wait for input that has not arrived yet, and fixes for: input that arrives in short + # reads, garbled RGB8888 output of grayscale images, and two ARM NEON faults seen on arm64 hosts + # (swapped red and blue in the 4:2:0 RGB8888 path, writes past the row on clipped MCUs). It also + # uses the ESP32-S3 SIMD colour conversion in ESP-IDF builds, not only Arduino ones, and is built + # with -O2. + cg.add_library( + "JPEGDEC", + "1.8.4", + "https://github.com/esphome-libs/JPEGDEC#1.8.4-esphome.3", + ) if CORE.is_host: # JPEGDEC's host detection checks __MACH__/__LINUX__, but gcc only # predefines the lowercase __linux__; without this a Linux host diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 6472638261e..d651279163e 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -1,4 +1,6 @@ #pragma once +#include + #include "esphome/core/color.h" #include "image_format.h" @@ -9,6 +11,14 @@ enum DecodeError : int { DECODE_ERROR_UNSUPPORTED_FORMAT = -2, DECODE_ERROR_OUT_OF_MEMORY = -3, DECODE_ERROR_INTERNAL_DECODER_ERROR = -4, + DECODE_ERROR_READ = -5, +}; + +/// Results of ImageDecoder::decode_step() other than a DecodeError +enum DecodeStep : int { + DECODE_STEP_DONE = 0, + DECODE_STEP_MORE = 1, + DECODE_STEP_NEED_DATA = 2, // The source has nothing more yet; call again once more has arrived }; constexpr const char *decode_error_to_string(int error) { @@ -21,6 +31,8 @@ constexpr const char *decode_error_to_string(int error) { return "Out of memory"; case DECODE_ERROR_INTERNAL_DECODER_ERROR: return "Internal decoder error"; + case DECODE_ERROR_READ: + return "Read error"; default: return "Unknown error"; } @@ -28,6 +40,19 @@ constexpr const char *decode_error_to_string(int error) { class RuntimeImage; +/// Input that a decoder reads itself as it goes, so the whole image never has to be in memory at once +class DecodeSource { + public: + virtual ~DecodeSource() = default; + /** + * Read up to len bytes at the current position. Returns the number read, which is less than len, even 0, when + * no more has arrived yet or at the end of the input, or negative on error. + */ + virtual int32_t read(uint8_t *buf, uint32_t len) = 0; + /// Move to a position counted from the start. Returns false on error. + virtual bool seek(uint32_t position) = 0; +}; + /** * @brief Class to abstract decoding different image formats. */ @@ -88,6 +113,28 @@ class ImageDecoder { */ virtual int decode(uint8_t *buffer, size_t size) = 0; + /// Whether this decoder can read a DecodeSource itself, a step at a time, instead of being fed data. + virtual bool supports_source() const { return false; } + + /** + * @brief Start decoding from a source the decoder reads itself. Call after prepare(), in place of decode(). + * + * The image headers (everything before the image data) must be readable from the source when this is called; + * only decode_step() can wait for more to arrive. The source must hold expected_size bytes in the end, and stay + * valid until the session ends. + * @return int 0 on success, or a {@see DecodeError} value. + */ + virtual int begin_source(DecodeSource *source) { return DECODE_ERROR_UNSUPPORTED_FORMAT; } + + /** + * @brief Decode a little more of an image started with begin_source(), keeping each call short. + * + * @return int DECODE_STEP_MORE while there is more to do, DECODE_STEP_NEED_DATA when the source has not + * delivered enough to go on, DECODE_STEP_DONE when the image is complete, or a {@see DecodeError} + * value. + */ + virtual int decode_step() { return DECODE_ERROR_UNSUPPORTED_FORMAT; } + /** * @brief Request the image to be resized once the actual dimensions are known. * Called by the callback functions, to be able to access the parent Image class. @@ -112,6 +159,9 @@ class ImageDecoder { */ void draw(int x, int y, int w, int h, const Color &color); + /// Bytes of the image the decoder has used so far + size_t get_decoded_bytes() const { return this->decoded_bytes_; } + /** * @brief Check if the decoder has finished processing. * diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index d4783701c4d..bfc9561db4d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -135,13 +135,24 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError()); return DECODE_ERROR_INVALID_TYPE; } + const int err = this->start_(); + if (err != 0) + return err; + if (!this->jpeg_.decode(0, 0, 0)) + return this->fail_(); + this->decoded_bytes_ = size; + this->jpeg_.close(); + return size; +} + +int JpegDecoder::start_() { auto jpeg_type = this->jpeg_.getJPEGType(); if (jpeg_type == JPEG_MODE_INVALID) { ESP_LOGE(TAG, "Unsupported JPEG image"); return DECODE_ERROR_INVALID_TYPE; } else if (jpeg_type == JPEG_MODE_PROGRESSIVE) { ESP_LOGE(TAG, "Progressive JPEG images not supported"); - return DECODE_ERROR_INVALID_TYPE; + return DECODE_ERROR_UNSUPPORTED_FORMAT; } ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp()); @@ -160,26 +171,109 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { return DECODE_ERROR_OUT_OF_MEMORY; } - if (!this->jpeg_.decode(0, 0, 0)) { - auto error = this->jpeg_.getLastError(); - ESP_LOGE(TAG, "Error while decoding: %d", error); - this->jpeg_.close(); - switch (error) { - case JPEG_ERROR_MEMORY: - return DECODE_ERROR_OUT_OF_MEMORY; - case JPEG_UNSUPPORTED_FEATURE: - return DECODE_ERROR_UNSUPPORTED_FORMAT; - case JPEG_INVALID_FILE: - case JPEG_INVALID_PARAMETER: - return DECODE_ERROR_INVALID_TYPE; - case JPEG_DECODE_ERROR: - default: - return DECODE_ERROR_INTERNAL_DECODER_ERROR; - } - } - this->decoded_bytes_ = size; + return 0; +} + +int JpegDecoder::fail_() { + const bool read_failed = this->source_ != nullptr && this->source_failed_; + const int error = this->jpeg_.getLastError(); this->jpeg_.close(); - return size; + this->source_ = nullptr; + if (read_failed) { + ESP_LOGE(TAG, "Reading the image failed"); + return DECODE_ERROR_READ; + } + ESP_LOGE(TAG, "Error while decoding: %d", error); + switch (error) { + case JPEG_ERROR_MEMORY: + return DECODE_ERROR_OUT_OF_MEMORY; + case JPEG_UNSUPPORTED_FEATURE: + return DECODE_ERROR_UNSUPPORTED_FORMAT; + case JPEG_INVALID_FILE: + case JPEG_INVALID_PARAMETER: + return DECODE_ERROR_INVALID_TYPE; + case JPEG_DECODE_ERROR: + default: + return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } +} + +void JpegDecoder::reset() { + // A decode from a source that was given up part way still has the image open + if (this->source_ != nullptr) { + this->jpeg_.close(); + this->source_ = nullptr; + } + ImageDecoder::reset(); +} + +int32_t JpegDecoder::source_read(JPEGFILE *file, uint8_t *buf, int32_t len) { + auto *decoder = static_cast(file->fHandle); + const int32_t n = decoder->source_->read(buf, len); + if (n < 0) { + // JPEGDEC has no way to report a failed read, so remember it and give it no data + decoder->source_failed_ = true; + return 0; + } + file->iPos += n; + return n; +} + +int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) { + auto *decoder = static_cast(file->fHandle); + if (position < 0 || !decoder->source_->seek(position)) { + decoder->source_failed_ = true; + return file->iPos; + } + file->iPos = position; + return position; +} + +int JpegDecoder::begin_source(DecodeSource *source) { + if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) { + ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_); + return DECODE_ERROR_INVALID_TYPE; + } + if (!source->seek(0)) { + ESP_LOGE(TAG, "Could not read the image"); + return DECODE_ERROR_READ; + } + this->source_ = source; + this->source_failed_ = false; + if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) { + // The caller may recover, for example by reading the whole image first, so it reports the failure + ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError()); + this->source_ = nullptr; + return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE; + } + const int err = this->start_(); + if (err != 0) { + this->jpeg_.close(); + this->source_ = nullptr; + return err; + } + if (!this->jpeg_.decodeBegin(0, 0, 0)) + return this->fail_(); + return 0; +} + +int HOT JpegDecoder::decode_step() { + if (this->source_ == nullptr) + return DECODE_ERROR_INVALID_TYPE; + const int result = this->jpeg_.decodeRows(1); + // JPEGDEC may decode on with the missing data, so check the read before its result + if (this->source_failed_) + return this->fail_(); + if (result == JPEG_DECODE_MORE) + return DECODE_STEP_MORE; + if (result == JPEG_DECODE_NEED_DATA) + return DECODE_STEP_NEED_DATA; + if (result == 0) + return this->fail_(); + this->decoded_bytes_ = this->expected_size_; + this->jpeg_.close(); + this->source_ = nullptr; + return DECODE_STEP_DONE; } } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index f72f69422d0..79e0ed7faca 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -22,19 +22,36 @@ class JpegDecoder : public ImageDecoder { ~JpegDecoder() override {} int HOT decode(uint8_t *buffer, size_t size) override; + void reset() override; + + /// The source is read as the decode needs it, through JPEGDEC's own 2 KB buffer + bool supports_source() const override { return true; } + int begin_source(DecodeSource *source) override; + /// Decodes one row of MCUs (8 or 16 pixel rows), or waits part way through one for more of the source + int HOT decode_step() override; bool uses_direct_rgb565() const { return this->direct_rgb565_; } void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels); protected: bool prepare_rgb565_scaling_(int width, int height); + /// Set up an opened image for decoding. Returns 0, or a DecodeError. + int start_(); + /// The DecodeError for a failed read of the source or JPEGDEC's last error, closing the image + int fail_(); + + static int32_t source_read(JPEGFILE *file, uint8_t *buf, int32_t len); + static int32_t source_seek(JPEGFILE *file, int32_t position); + static void source_close(void *handle) {} JPEGDEC jpeg_{}; FixedVector x_boundaries_{}; FixedVector y_boundaries_{}; int source_width_{0}; int source_height_{0}; + DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress bool direct_rgb565_{false}; + bool source_failed_{false}; }; } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 1c6a2bc6002..df402fb34d5 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -239,6 +239,25 @@ int RuntimeImage::feed_data(uint8_t *data, size_t len) { return consumed; } +int RuntimeImage::begin_source(DecodeSource *source) { + if (!this->decoder_supports_source()) { + ESP_LOGE(TAG, "Decoder cannot read a source"); + return DECODE_ERROR_UNSUPPORTED_FORMAT; + } + return this->decoder_->begin_source(source); +} + +int RuntimeImage::decode_step() { + if (!this->is_decoding()) { + ESP_LOGE(TAG, "No decoder initialized"); + return DECODE_ERROR_INVALID_TYPE; + } + const int result = this->decoder_->decode_step(); + if (result == DECODE_STEP_DONE) + this->decoded_bytes_ = this->decoder_->get_decoded_bytes(); + return result; +} + bool RuntimeImage::end_decode() { if (!this->is_decoding()) { return false; diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 4dc60213649..62dc8b17657 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -76,6 +76,22 @@ class RuntimeImage : public image::Image { */ int feed_data(uint8_t *data, size_t len); + /// Whether the active decoder can read a DecodeSource itself (see begin_source()). + bool decoder_supports_source() const { return this->is_decoding() && this->decoder_->supports_source(); } + + /** + * @brief Decode from a source the decoder reads itself, in place of feed_data(). + * + * Call after begin_decode() when decoder_supports_source() is true, once the image headers can be read from the + * source, then call decode_step() until it + * returns DECODE_STEP_DONE, and finish with end_decode(). + * @return 0 on success, or a DecodeError value. + */ + int begin_source(DecodeSource *source); + + /// Decode a little more from the source; see ImageDecoder::decode_step(). + int decode_step(); + /** * @brief Complete the decoding process. * diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index 4d7e9b4e694..71331a64706 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -59,7 +59,7 @@ void RX8025TComponent::read_time() { return; } - ESPTime rtc_time = time::decode_bcd_calendar(date); + ESPTime rtc_time = time::decode_bcd_calendar(date); if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; @@ -77,7 +77,7 @@ void RX8025TComponent::write_time() { } uint8_t buff[7]; - time::encode_bcd_calendar(now, buff); + time::encode_bcd_calendar(now, buff); if (!this->write_bytes(RX8025T_REG_SEC, buff, sizeof(buff))) { ESP_LOGE(TAG, "Can't write I2C data."); this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index 62923482c7e..1188d945b0f 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -1,79 +1,226 @@ #include "rx8111.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::rx8111 { ESPHOME_LOG_TAG(TAG, "rx8111"); +// Indexed by the TSEL value stored in timer_clock_ +PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz"); + static constexpr uint8_t RX8111_REG_SEC = 0x10; +static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17; +static constexpr uint8_t RX8111_REG_HOUR_ALARM = 0x18; +static constexpr uint8_t RX8111_REG_DAY_ALARM = 0x19; +static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D; static constexpr uint8_t RX8111_REG_FLAG = 0x1E; +static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A; static constexpr uint8_t RX8111_REG_CONTROL = 0x1F; +static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C; +static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80; +static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08; +static constexpr uint8_t RX8111_CONTROL_AIE = 0x08; +static constexpr uint8_t RX8111_CONTROL_TIE = 0x10; +static constexpr uint8_t RX8111_EXTENSION_TE = 0x10; +static constexpr uint8_t RX8111_EXTENSION_TSEL = 0x03; +static constexpr uint8_t RX8111_FLAG_TF = 0x10; +static constexpr uint8_t RX8111_FLAG_AF = 0x08; static constexpr uint8_t RX8111_FLAG_POR = 0x80; static constexpr uint8_t RX8111_FLAG_VLF = 0x02; static constexpr uint8_t RX8111_FLAG_XST = 0x01; static constexpr uint8_t RX8111_CTRL_STOP = 0x01; // FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write. static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C; +static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0; +static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1; +static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2; +static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3; -void RX8111Component::setup() { this->read_time(); } +void RX8111Component::setup() { + this->configure_boot_alarm_(); + this->configure_boot_timer_(); + this->read_time(); + if (this->alarm_enabled_ || this->watching_()) { + this->start_event_polling_(); + } +} -void RX8111Component::update() { this->read_time(); } +void RX8111Component::configure_boot_alarm_() { + bool success = true; + if (this->alarm_enabled_) { + if (this->alarm_second_ != ALARM_FIELD_UNSET || this->alarm_minute_ != ALARM_FIELD_UNSET || + this->alarm_hour_ != ALARM_FIELD_UNSET || this->alarm_weekday_ != ALARM_FIELD_UNSET || + this->alarm_day_ != ALARM_FIELD_UNSET) { + success = this->program_alarm_(this->alarm_second_, this->alarm_minute_, this->alarm_hour_, this->alarm_weekday_, + this->alarm_day_, true); + if (success) { + // Only the first successful boot program can introduce a boot wake-up edge. + if (!this->boot_alarm_event_seen_) + this->alarm_flag_seen_ = false; + this->start_event_polling_(); + } + } else { + // Preserve an alarm programmed by an action before a reboot or deep sleep. + uint8_t control; + success = this->read_byte(RX8111_REG_CONTROL, &control); + if (success) { + this->alarm_armed_ = (control & RX8111_CONTROL_AIE) != 0; + if (this->alarm_armed_) + this->start_event_polling_(); + } + } + } else if (this->alarm_explicitly_configured_) { + success = this->set_alarm_interrupt_(false); + } + this->boot_config_result_(true, success); +} + +void RX8111Component::configure_boot_timer_() { + bool success = true; + // An omitted timer setting leaves battery-backed state alone; explicit false stops it. + if (this->timer_configured_) { + const bool start_timer = this->timer_enabled_; + if (start_timer) { + success = this->configure_timer_(); + } else { + uint8_t extension, control; + success = this->disable_timer_(extension, control); + } + if (success && !start_timer && this->timer_count_ != 0) { + this->timer_retained_event_pending_ = true; + this->start_event_polling_(); + } + } + this->boot_config_result_(false, success); +} + +void RX8111Component::boot_config_result_(bool alarm, bool success) { + auto &attempts = alarm ? this->boot_alarm_attempts_ : this->boot_timer_attempts_; + const uint8_t warning = alarm ? WARN_ALARM : WARN_TIMER; + ++attempts; + if (success) { + this->clear_warning_(warning); + return; + } + this->set_warning_(warning, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (attempts >= RX8111_BOOT_MAX_ATTEMPTS) + return; + this->set_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID, this->boot_retry_delay_ms_, + [this, alarm]() { + if (alarm) { + this->configure_boot_alarm_(); + } else { + this->configure_boot_timer_(); + } + }); +} + +// A runtime action supersedes pending boot programming of the same feature +void RX8111Component::cancel_boot_retry_(bool alarm) { + this->cancel_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID); +} + +void RX8111Component::check_event_flags_() { + if (this->watching_() && !this->poll_event_flags_()) + this->set_warning_(WARN_EVENT_POLL, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // A callback or a runtime action may have stopped the last watched feature + if (!this->watching_() && this->event_polling_started_) { + this->cancel_interval(RX8111_EVENT_INTERVAL_ID); + this->event_polling_started_ = false; + } +} + +void RX8111Component::start_event_polling_() { + if (this->event_polling_started_) + return; + this->set_interval(RX8111_EVENT_INTERVAL_ID, this->event_poll_interval_, [this]() { this->check_event_flags_(); }); + this->event_polling_started_ = true; +} + +void RX8111Component::update() { + this->read_time(); + this->check_event_flags_(); +} + +void RX8111Component::set_warning_(uint8_t bit, const LogString *message) { + this->warnings_ |= bit; + this->status_set_warning(message); +} + +void RX8111Component::clear_warning_(uint8_t bit) { + if ((this->warnings_ & bit) == 0) + return; + this->warnings_ &= ~bit; + if (this->warnings_ == 0) + this->status_clear_warning(); +} void RX8111Component::dump_config() { ESP_LOGCONFIG(TAG, "RX8111:"); LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, + " Alarm interrupt: %s\n" + " Wake-up timer: %s", + ONOFF(this->alarm_enabled_), ONOFF(this->timer_enabled_)); + if (this->timer_enabled_) { + ESP_LOGCONFIG(TAG, " Count: %" PRIu32 ", clock: %s", this->timer_count_, + LOG_STR_ARG(TimerClockNames::get_log_str(this->timer_clock_, TimerClockNames::LAST_INDEX))); + } RealTimeClock::dump_config(); } void RX8111Component::read_time() { + ESPTime rtc_time; + if (!this->read_calendar_(rtc_time)) + return; + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, + rtc_time.hour, rtc_time.minute, rtc_time.second); + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); +} + +bool RX8111Component::read_calendar_(ESPTime &rtc_time) { uint8_t flags; if (!this->read_byte(RX8111_REG_FLAG, &flags)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; + this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; } if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) { ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time"); - this->status_set_warning(LOG_STR("RTC time invalidated; write trusted time")); - return; + this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time")); + return false; } uint8_t buf[7]; // I2C access freezes the RX8111 counter, yielding a coherent calendar snapshot. if (!this->read_bytes(RX8111_REG_SEC, buf, sizeof(buf))) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; + this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; } + // decode_bcd_calendar() masks the same bits; the check here catches a corrupted register static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF}; + bool valid = true; for (size_t i = 0; i < sizeof(buf); i++) { if (i == 3) continue; // Weekday uses one-hot encoding, not BCD. buf[i] &= CALENDAR_MASKS[i]; - if ((buf[i] & 0x0F) > 9 || (buf[i] >> 4) > 9) { - ESP_LOGW(TAG, "Invalid BCD in RTC time"); - this->status_set_warning(LOG_STR("Invalid RTC time")); - return; - } + valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9; } const uint8_t onehot = buf[3]; - if (onehot == 0 || onehot > 0x40 || (onehot & (onehot - 1)) != 0) { - ESP_LOGW(TAG, "Invalid RTC weekday"); - this->status_set_warning(LOG_STR("Invalid RTC time")); - return; + valid = valid && onehot != 0 && onehot <= 0x40 && (onehot & (onehot - 1)) == 0; + if (valid) { + rtc_time = time::decode_bcd_calendar(buf); + valid = rtc_time.is_valid(true, false) && rtc_time.second < 60 && + ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week; } - ESPTime rtc_time = time::decode_bcd_calendar(buf); - if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) { + if (!valid) { ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock"); - this->status_set_warning(LOG_STR("Invalid RTC time")); - return; + this->set_warning_(WARN_CALENDAR, LOG_STR("Invalid RTC time")); + return false; } - if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week, - LOG_STR("Invalid RTC time"))) { - ESP_LOGW(TAG, "RTC weekday does not match calendar date"); - return; - } - ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, - rtc_time.hour, rtc_time.minute, rtc_time.second); - time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); + this->clear_warning_(WARN_CALENDAR); + return true; } void RX8111Component::write_time() { @@ -83,35 +230,243 @@ void RX8111Component::write_time() { return; } if (!this->set_stop_bit_(true)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t buf[7]; - time::encode_bcd_calendar(now, buf); + time::encode_bcd_calendar(now, buf); const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf)); // Always try to restart the counter, even after a failed calendar write. const bool restarted = this->set_stop_bit_(false); if (!written || !restarted) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - if (!this->status_comm_result(this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS))) + if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) { + this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; + } + this->clear_warning_(WARN_WRITE_TIME); + this->clear_warning_(WARN_CALENDAR); ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, now.minute, now.second); } bool RX8111Component::set_stop_bit_(bool stop) { - uint8_t control; - if (!this->read_byte(RX8111_REG_CONTROL, &control)) + return this->update_register(RX8111_REG_CONTROL, RX8111_CTRL_STOP, stop ? RX8111_CTRL_STOP : 0); +} + +bool RX8111Component::set_alarm_interrupt_(bool enabled) { + return this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_AIE, enabled ? RX8111_CONTROL_AIE : 0); +} + +bool RX8111Component::program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, + bool boot) { + uint8_t extension; + if (!this->read_byte(RX8111_REG_EXTENSION, &extension)) return false; - if (stop) { - control |= RX8111_CTRL_STOP; - } else { - control &= ~RX8111_CTRL_STOP; + if (weekday != ALARM_FIELD_UNSET) { + extension &= ~RX8111_EXTENSION_WADA; + } else if (day != ALARM_FIELD_UNSET) { + extension |= RX8111_EXTENSION_WADA; } - return this->write_byte(RX8111_REG_CONTROL, control); + + const uint8_t fields[] = { + second == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(second), + minute == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(minute), + hour == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(hour), + weekday != ALARM_FIELD_UNSET ? static_cast(1U << (weekday - 1)) + : day != ALARM_FIELD_UNSET ? uint8_to_bcd(day) + : RX8111_ALARM_DISABLED, + }; + // Disable the interrupt during partial programming; keep AF (past events) latched. + if (!this->set_alarm_interrupt_(false)) + return false; + this->alarm_armed_ = false; + uint8_t flags; + if (!this->read_byte(RX8111_REG_FLAG, &flags)) + return false; + if (!boot) + this->alarm_flag_seen_ = (flags & RX8111_FLAG_AF) != 0; + if (!this->write_byte(RX8111_REG_SEC_ALARM, fields[0]) || !this->write_bytes(RX8111_REG_MIN_ALARM, &fields[1], 3) || + !this->write_byte(RX8111_REG_EXTENSION, extension)) + return false; + if (!this->set_alarm_interrupt_(this->alarm_enabled_)) + return false; + this->alarm_armed_ = true; + return true; +} + +bool RX8111Component::set_alarm(uint16_t second, uint16_t minute, uint16_t hour, uint16_t weekday, uint16_t day) { + if (!this->alarm_enabled_) { + ESP_LOGE(TAG, "Alarm interrupt disabled; set alarm_enabled: true"); + return false; + } + constexpr uint16_t unset = ALARM_FIELD_UNSET; + if ((second != unset && second > 59) || (minute != unset && minute > 59) || (hour != unset && hour > 23) || + (weekday != unset && (weekday < 1 || weekday > 7)) || (day != unset && (day < 1 || day > 31)) || + (weekday != unset && day != unset) || + (second == unset && minute == unset && hour == unset && weekday == unset && day == unset)) { + ESP_LOGE(TAG, "Invalid alarm fields"); + return false; + } + this->cancel_boot_retry_(true); + if (!this->program_alarm_(second, minute, hour, weekday, day)) { + this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->clear_warning_(WARN_ALARM); + this->boot_alarm_event_seen_ = true; + this->start_event_polling_(); + return true; +} + +bool RX8111Component::schedule_alarm_in(uint32_t seconds) { + if (!this->alarm_enabled_ || seconds == 0 || seconds >= 28U * 86400U) { + ESP_LOGE(TAG, "Relative alarm requires alarm_enabled and a duration of 1 second to less than 28 days"); + return false; + } + ESPTime now; + if (!this->read_calendar_(now)) { + ESP_LOGE(TAG, "Cannot read valid RTC time for relative alarm"); + return false; + } + auto target = ESPTime::from_epoch_utc(now.timestamp + seconds); + if (!target.is_valid() || target.year > 2099) { + ESP_LOGE(TAG, "Relative alarm target is outside RTC year range"); + return false; + } + return this->set_alarm(target.second, target.minute, target.hour, ALARM_FIELD_UNSET, target.day_of_month); +} + +bool RX8111Component::unset_alarm() { + this->cancel_boot_retry_(true); + if (!this->set_alarm_interrupt_(false)) { + this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->alarm_armed_ = false; + // Minute, hour and day alarm registers are contiguous + static constexpr uint8_t DISABLED_FIELDS[3] = {RX8111_ALARM_DISABLED, RX8111_ALARM_DISABLED, RX8111_ALARM_DISABLED}; + if (!this->write_byte(RX8111_REG_SEC_ALARM, RX8111_ALARM_DISABLED) || + !this->write_bytes(RX8111_REG_MIN_ALARM, DISABLED_FIELDS, sizeof(DISABLED_FIELDS))) { + this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->clear_warning_(WARN_ALARM); + return true; +} + +bool RX8111Component::clear_alarm_flag() { + if (!this->write_byte(RX8111_REG_FLAG, static_cast(~RX8111_FLAG_AF))) { + this->set_warning_(WARN_ALARM_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->alarm_flag_seen_ = false; + this->clear_warning_(WARN_ALARM_FLAG_CLEAR); + return true; +} + +// Clears TE and TIE; returns the register values read so callers can build on them +bool RX8111Component::disable_timer_(uint8_t &extension, uint8_t &control) { + return this->read_byte(RX8111_REG_EXTENSION, &extension) && + this->write_byte(RX8111_REG_EXTENSION, extension & ~RX8111_EXTENSION_TE) && + this->read_byte(RX8111_REG_CONTROL, &control) && + this->write_byte(RX8111_REG_CONTROL, control & ~RX8111_CONTROL_TIE); +} + +bool RX8111Component::configure_timer_(bool clear_stale_flag) { + // Stop counting before updating the 24-bit reload value; preserve the boot flag unless explicitly starting. + uint8_t extension; + uint8_t control; + if (!this->disable_timer_(extension, control)) + return false; + + const uint8_t count[] = {static_cast(this->timer_count_), static_cast(this->timer_count_ >> 8), + static_cast(this->timer_count_ >> 16)}; + if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) || + !this->write_byte(RX8111_REG_EXTENSION, + (extension & ~(RX8111_EXTENSION_TE | RX8111_EXTENSION_TSEL)) | this->timer_clock_) || + (clear_stale_flag && !this->clear_timer_flag()) || + !this->write_byte(RX8111_REG_CONTROL, control | RX8111_CONTROL_TIE)) + return false; + return this->write_byte(RX8111_REG_EXTENSION, + (extension & ~RX8111_EXTENSION_TSEL) | this->timer_clock_ | RX8111_EXTENSION_TE); +} + +bool RX8111Component::start_timer() { + if (this->timer_count_ == 0 || this->timer_count_ > 0xFFFFFF) { + ESP_LOGW(TAG, "Cannot start timer without a valid timer_count"); + return false; + } + this->cancel_boot_retry_(false); + if (!this->configure_timer_(true)) { + // A partial configuration can leave either enable bit set. Disable both if the bus permits it; + // otherwise keep watching a timer that may still be running. + uint8_t extension, control; + if (this->disable_timer_(extension, control)) + this->timer_enabled_ = false; + this->set_warning_(WARN_TIMER, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->clear_warning_(WARN_TIMER); + this->timer_enabled_ = true; + this->timer_retained_event_pending_ = false; + this->start_event_polling_(); + return true; +} + +bool RX8111Component::clear_timer_flag() { + if (!this->write_byte(RX8111_REG_FLAG, static_cast(~RX8111_FLAG_TF))) { + this->set_warning_(WARN_TIMER_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->timer_flag_seen_ = false; + this->timer_retained_event_pending_ = false; + this->clear_warning_(WARN_TIMER_FLAG_CLEAR); + return true; +} + +bool RX8111Component::stop_timer() { + this->cancel_boot_retry_(false); + uint8_t extension, control; + if (!this->disable_timer_(extension, control)) { + this->set_warning_(WARN_TIMER, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->timer_enabled_ = false; + this->clear_warning_(WARN_TIMER); + return true; +} + +bool RX8111Component::poll_event_flags_() { + uint8_t flags; + if (!this->read_byte(RX8111_REG_FLAG, &flags)) + return false; + this->clear_warning_(WARN_EVENT_POLL); + const bool alarm_active = (flags & RX8111_FLAG_AF) != 0; + const bool timer_active = (flags & RX8111_FLAG_TF) != 0; + // alarm_armed_ is only ever set while the alarm is enabled + const bool timer_watched = this->timer_enabled_ || this->timer_retained_event_pending_; + if (alarm_active && this->alarm_armed_) + this->boot_alarm_event_seen_ = true; + const bool alarm_rising = alarm_active && !this->alarm_flag_seen_ && this->alarm_armed_; + const bool timer_rising = timer_active && !this->timer_flag_seen_ && timer_watched; + const bool deliver_stopped_event = timer_rising && this->timer_retained_event_pending_; + // Record eligible edges before callbacks, which may clear flags synchronously. + // Leave flags unseen while boot configuration is still waiting to enable delivery. + if (this->alarm_armed_) + this->alarm_flag_seen_ = alarm_active; + if (timer_watched) + this->timer_flag_seen_ = timer_active; + this->timer_retained_event_pending_ = false; + if (alarm_rising) + this->alarm_callbacks_.call(); + // An alarm callback may stop the timer before its callback is delivered. + if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event)) + this->timer_callbacks_.call(); + return true; } } // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/rx8111.h b/esphome/components/rx8111/rx8111.h index 9405d362b0a..008d05ad67b 100644 --- a/esphome/components/rx8111/rx8111.h +++ b/esphome/components/rx8111/rx8111.h @@ -1,10 +1,15 @@ #pragma once +#include "esphome/core/helpers.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/time/real_time_clock.h" namespace esphome::rx8111 { +namespace testing { +class RX8111Test; +} // namespace testing + class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; @@ -12,9 +17,91 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice void dump_config() override; void read_time(); void write_time(); + void set_alarm_enabled(bool enabled) { this->alarm_enabled_ = enabled; } + void set_alarm_explicitly_configured(bool configured) { this->alarm_explicitly_configured_ = configured; } + void set_alarm_second(uint8_t value) { this->alarm_second_ = value; } + void set_alarm_minute(uint8_t value) { this->alarm_minute_ = value; } + void set_alarm_hour(uint8_t value) { this->alarm_hour_ = value; } + void set_alarm_weekday(uint8_t value) { this->alarm_weekday_ = value; } + void set_alarm_day(uint8_t value) { this->alarm_day_ = value; } + void set_timer_count(uint32_t count) { this->timer_count_ = count; } + void set_timer_clock(uint8_t clock) { this->timer_clock_ = clock; } + void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; } + void set_timer_configured(bool configured) { this->timer_configured_ = configured; } + void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; } + template void add_on_alarm_callback(F &&callback) { + this->alarm_callbacks_.add(std::forward(callback)); + } + bool set_alarm(uint16_t second, uint16_t minute, uint16_t hour, uint16_t weekday, uint16_t day); + bool schedule_alarm_in(uint32_t seconds); + bool unset_alarm(); + bool clear_alarm_flag(); + bool clear_timer_flag(); + bool start_timer(); + bool stop_timer(); + template void add_on_timer_callback(F &&callback) { + this->timer_callbacks_.add(std::forward(callback)); + } protected: + friend class testing::RX8111Test; + + static constexpr uint32_t BOOT_RETRY_DELAY_MS = 1000; + // set_alarm() and the boot alarm ignore a field set to this value + static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF; + + // One bit per fault source, so a recovery in one path cannot hide another path's fault + enum Warning : uint8_t { + WARN_CALENDAR = 1 << 0, + WARN_EVENT_POLL = 1 << 1, + WARN_ALARM = 1 << 2, + WARN_TIMER = 1 << 3, + WARN_WRITE_TIME = 1 << 4, + WARN_ALARM_FLAG_CLEAR = 1 << 5, + WARN_TIMER_FLAG_CLEAR = 1 << 6, + }; + bool set_stop_bit_(bool stop); + bool read_calendar_(ESPTime &rtc_time); + void set_warning_(uint8_t bit, const LogString *message); + void clear_warning_(uint8_t bit); + bool watching_() const { return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_; } + void check_event_flags_(); + bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false); + bool set_alarm_interrupt_(bool enabled); + bool disable_timer_(uint8_t &extension, uint8_t &control); + bool configure_timer_(bool clear_stale_flag = false); + void start_event_polling_(); + bool poll_event_flags_(); + void configure_boot_alarm_(); + void configure_boot_timer_(); + void boot_config_result_(bool alarm, bool success); + void cancel_boot_retry_(bool alarm); + + LazyCallbackManager alarm_callbacks_; + LazyCallbackManager timer_callbacks_; + uint32_t timer_count_{0}; + uint32_t event_poll_interval_{1000}; + uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS}; + uint8_t warnings_{0}; + uint8_t timer_clock_{2}; // 1 Hz + uint8_t boot_alarm_attempts_{0}; + uint8_t boot_timer_attempts_{0}; + uint8_t alarm_second_{ALARM_FIELD_UNSET}; + uint8_t alarm_minute_{ALARM_FIELD_UNSET}; + uint8_t alarm_hour_{ALARM_FIELD_UNSET}; + uint8_t alarm_weekday_{ALARM_FIELD_UNSET}; + uint8_t alarm_day_{ALARM_FIELD_UNSET}; + bool timer_enabled_{false}; + bool timer_configured_{false}; + bool timer_flag_seen_{false}; + bool timer_retained_event_pending_{false}; + bool event_polling_started_{false}; + bool boot_alarm_event_seen_{false}; + bool alarm_enabled_{false}; + bool alarm_explicitly_configured_{false}; + bool alarm_flag_seen_{false}; + bool alarm_armed_{false}; }; } // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/time.py b/esphome/components/rx8111/time.py index fc6a1cca0d5..52387583707 100644 --- a/esphome/components/rx8111/time.py +++ b/esphome/components/rx8111/time.py @@ -4,6 +4,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv +from esphome.const import ( + CONF_DAY, + CONF_DURATION, + CONF_HOUR, + CONF_ID, + CONF_MINUTE, + CONF_SECOND, +) from esphome.types import ConfigType CODEOWNERS = ["@techdregs"] @@ -11,23 +19,186 @@ DEPENDENCIES = ["i2c"] rx8111_ns = cg.esphome_ns.namespace("rx8111") RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice) +TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3} +# set_alarm() ignores a field set to this value +ALARM_FIELD_UNSET = 0xFF -CONFIG_SCHEMA = time.TIME_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(RX8111Component)} -).extend(i2c.i2c_device_schema(0x32)) +CONF_ALARM_SECOND = "alarm_second" +CONF_ALARM_MINUTE = "alarm_minute" +CONF_ALARM_HOUR = "alarm_hour" +CONF_ALARM_WEEKDAY = "alarm_weekday" +CONF_ALARM_DAY = "alarm_day" +CONF_ALARM_ENABLED = "alarm_enabled" +CONF_ON_ALARM = "on_alarm" +CONF_TIMER_COUNT = "timer_count" +CONF_TIMER_CLOCK = "timer_clock" +CONF_TIMER_ENABLED = "timer_enabled" +CONF_ON_TIMER = "on_timer" +CONF_WEEKDAY = "weekday" +CONF_EVENT_POLL_INTERVAL = "event_poll_interval" + +_ALARM_FIELDS = ( + CONF_ALARM_SECOND, + CONF_ALARM_MINUTE, + CONF_ALARM_HOUR, + CONF_ALARM_WEEKDAY, + CONF_ALARM_DAY, +) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"), + automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"), +) + + +def _validate_alarm_config(config: ConfigType) -> ConfigType: + """Rules that depend on the derived alarm_enabled and timer_enabled defaults.""" + if CONF_ON_ALARM in config and not config.get( + CONF_ALARM_ENABLED, any(f in config for f in _ALARM_FIELDS) + ): + raise cv.Invalid( + "on_alarm requires alarm_enabled: true or alarm matching fields" + ) + if CONF_TIMER_COUNT not in config and ( + config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config + ): + raise cv.Invalid("timer_enabled or on_timer requires timer_count") + return config + + +_validate_alarm_options = cv.All( + cv.has_at_most_one_key(CONF_ALARM_DAY, CONF_ALARM_WEEKDAY), _validate_alarm_config +) + + +CONFIG_SCHEMA = cv.All( + time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(RX8111Component), + cv.Optional(CONF_ALARM_SECOND): cv.int_range(min=0, max=59), + cv.Optional(CONF_ALARM_MINUTE): cv.int_range(min=0, max=59), + cv.Optional(CONF_ALARM_HOUR): cv.int_range(min=0, max=23), + cv.Optional(CONF_ALARM_WEEKDAY): cv.int_range(min=1, max=7), + cv.Optional(CONF_ALARM_DAY): cv.int_range(min=1, max=31), + cv.Optional(CONF_ALARM_ENABLED): cv.boolean, + cv.Optional(CONF_ON_ALARM): automation.validate_automation({}), + cv.Optional(CONF_TIMER_COUNT): cv.int_range(min=1, max=0xFFFFFF), + cv.Optional(CONF_TIMER_CLOCK, default="1hz"): cv.enum( + TIMER_CLOCKS, lower=True + ), + cv.Optional(CONF_TIMER_ENABLED): cv.boolean, + cv.Optional(CONF_ON_TIMER): automation.validate_automation({}), + # Below 500 ms a scheduler interval costs more than it saves + cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=cv.TimePeriod(milliseconds=500)), + ), + } + ).extend(i2c.i2c_device_schema(0x32)), + _validate_alarm_options, +) RX8111_ACTION_SCHEMA = automation.maybe_simple_id( {cv.GenerateID(): cv.use_id(RX8111Component)} ) -automation.register_apply_action( - "rx8111.write_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("write_time()") +# Actions that call one argument-less method on the component +for _method in ( + "write_time", + "read_time", + "clear_alarm_flag", + "clear_timer_flag", + "unset_alarm", + "stop_timer", + "start_timer", +): + automation.register_apply_action( + f"rx8111.{_method}", RX8111_ACTION_SCHEMA, automation.ApplyCall(f"{_method}()") + ) + +_SET_ALARM_FIELDS = (CONF_SECOND, CONF_MINUTE, CONF_HOUR, CONF_WEEKDAY, CONF_DAY) + + +def _fill_unset_alarm_fields(config: ConfigType) -> ConfigType: + """Marks the fields not given as unset, so set_alarm() always gets all five.""" + for field in _SET_ALARM_FIELDS: + config.setdefault(field, ALARM_FIELD_UNSET) + return config + + +_validate_set_alarm = cv.All( + cv.has_at_least_one_key(*_SET_ALARM_FIELDS), + cv.has_at_most_one_key(CONF_WEEKDAY, CONF_DAY), + _fill_unset_alarm_fields, ) + + automation.register_apply_action( - "rx8111.read_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("read_time()") + "rx8111.set_alarm", + cv.All( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(RX8111Component), + cv.Optional(CONF_SECOND): cv.templatable(cv.int_range(min=0, max=59)), + cv.Optional(CONF_MINUTE): cv.templatable(cv.int_range(min=0, max=59)), + cv.Optional(CONF_HOUR): cv.templatable(cv.int_range(min=0, max=23)), + cv.Optional(CONF_WEEKDAY): cv.templatable(cv.int_range(min=1, max=7)), + cv.Optional(CONF_DAY): cv.templatable(cv.int_range(min=1, max=31)), + } + ), + _validate_set_alarm, + ), + automation.ApplyCall( + "set_alarm({}, {}, {}, {}, {})", + ( + (CONF_SECOND, cg.uint16), + (CONF_MINUTE, cg.uint16), + (CONF_HOUR, cg.uint16), + (CONF_WEEKDAY, cg.uint16), + (CONF_DAY, cg.uint16), + ), + ), +) + + +# The alarm registers hold a day of month, so the target must stay inside the current month cycle +_ALARM_DURATION = cv.All( + cv.positive_time_period_seconds, + cv.Range( + min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(days=28), max_included=False + ), +) + +automation.register_apply_action( + "rx8111.schedule_alarm_in", + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(RX8111Component), + cv.Required(CONF_DURATION): cv.templatable(_ALARM_DURATION), + } + ), + automation.ApplyField(CONF_DURATION, "schedule_alarm_in", cg.uint32), ) async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) await time.register_time(var, config) + cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL])) + cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND)) + cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE)) + cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR)) + cg.add_optional(var.set_alarm_weekday, config.get(CONF_ALARM_WEEKDAY)) + cg.add_optional(var.set_alarm_day, config.get(CONF_ALARM_DAY)) + cg.add( + var.set_alarm_enabled( + config.get(CONF_ALARM_ENABLED, any(f in config for f in _ALARM_FIELDS)) + ) + ) + cg.add(var.set_alarm_explicitly_configured(CONF_ALARM_ENABLED in config)) + if (count := config.get(CONF_TIMER_COUNT)) is not None: + cg.add(var.set_timer_count(count)) + cg.add(var.set_timer_clock(config[CONF_TIMER_CLOCK])) + # Validation guarantees on_timer comes with a timer_count + cg.add(var.set_timer_enabled(config.get(CONF_TIMER_ENABLED, count is not None))) + cg.add(var.set_timer_configured(CONF_TIMER_ENABLED in config or count is not None)) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index a5b1243c263..cf9ea901412 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -67,7 +67,7 @@ void RX8130Component::read_time() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - ESPTime rtc_time = time::decode_bcd_calendar(date); + ESPTime rtc_time = time::decode_bcd_calendar(date); if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; @@ -84,7 +84,7 @@ void RX8130Component::write_time() { return; } uint8_t buff[7]; - time::encode_bcd_calendar(now, buff); + time::encode_bcd_calendar(now, buff); this->stop_(true); if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); diff --git a/esphome/components/safe_mode/button/__init__.py b/esphome/components/safe_mode/button/__init__.py index d0ef080f10e..104e02a05c1 100644 --- a/esphome/components/safe_mode/button/__init__.py +++ b/esphome/components/safe_mode/button/__init__.py @@ -1,4 +1,3 @@ -import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ( @@ -9,26 +8,18 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import SafeModeComponent, safe_mode_ns +from .. import SafeModeComponent DEPENDENCIES = ["safe_mode"] -SafeModeButton = safe_mode_ns.class_("SafeModeButton", button.Button, cg.Component) - -CONFIG_SCHEMA = ( - button.button_schema( - SafeModeButton, - device_class=DEVICE_CLASS_RESTART, - entity_category=ENTITY_CATEGORY_CONFIG, - icon=ICON_RESTART_ALERT, - ) - .extend({cv.GenerateID(CONF_SAFE_MODE): cv.use_id(SafeModeComponent)}) - .extend(cv.COMPONENT_SCHEMA) -) +CONFIG_SCHEMA = button.button_schema( + button.ApplyButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, +).extend({cv.GenerateID(CONF_SAFE_MODE): cv.use_id(SafeModeComponent)}) async def to_code(config: ConfigType) -> None: - var = await button.new_button(config) - await cg.register_component(var, config) - - await cg.add_variable(var.set_safe_mode, config[CONF_SAFE_MODE]) + buttons = button.sub_buttons(config, parent=config[CONF_SAFE_MODE]) + await buttons.create(config, None, apply="restart_in_safe_mode()") diff --git a/esphome/components/safe_mode/button/safe_mode_button.cpp b/esphome/components/safe_mode/button/safe_mode_button.cpp deleted file mode 100644 index e9779953f02..00000000000 --- a/esphome/components/safe_mode/button/safe_mode_button.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#include "safe_mode_button.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" -#include "esphome/core/application.h" - -namespace esphome::safe_mode { - -ESPHOME_LOG_TAG(TAG, "safe_mode.button"); - -void SafeModeButton::press_action() { - ESP_LOGI(TAG, "Restarting in safe mode"); - this->safe_mode_component_->set_safe_mode_pending(true); - - // Let MQTT settle a bit - delay(100); // NOLINT - App.safe_reboot(); -} - -void SafeModeButton::dump_config() { LOG_BUTTON("", "Safe Mode Button", this); } - -} // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/button/safe_mode_button.h b/esphome/components/safe_mode/button/safe_mode_button.h deleted file mode 100644 index 035bd778025..00000000000 --- a/esphome/components/safe_mode/button/safe_mode_button.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/components/button/button.h" -#include "esphome/components/safe_mode/safe_mode.h" -#include "esphome/core/component.h" - -namespace esphome::safe_mode { - -class SafeModeButton final : public button::Button, public Component { - public: - void dump_config() override; - void set_safe_mode(SafeModeComponent *safe_mode_component) { this->safe_mode_component_ = safe_mode_component; } - - protected: - SafeModeComponent *safe_mode_component_; - void press_action() override; -}; - -} // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 3099df58601..828402e10d1 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -174,6 +174,14 @@ bool SafeModeComponent::get_safe_mode_pending() { return this->read_rtc_() == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; } +void SafeModeComponent::restart_in_safe_mode() { + ESP_LOGI(TAG, "Restarting in safe mode"); + this->set_safe_mode_pending(true); + // Let MQTT settle a bit + delay(100); // NOLINT + App.safe_reboot(); +} + bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash) { this->safe_mode_start_time_ = millis(); diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 903d9eb79fd..f1f205e2552 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -25,6 +25,8 @@ class SafeModeComponent final : public Component { /// Set to true if the next startup will enter safe mode void set_safe_mode_pending(const bool &pending); bool get_safe_mode_pending(); + /// Set safe mode pending and reboot; run by the safe mode button. + void restart_in_safe_mode(); void dump_config() override; float get_setup_priority() const override; diff --git a/esphome/components/scd4x/scd4x.cpp b/esphome/components/scd4x/scd4x.cpp index bd30f3a42cb..e6510ce02df 100644 --- a/esphome/components/scd4x/scd4x.cpp +++ b/esphome/components/scd4x/scd4x.cpp @@ -106,7 +106,7 @@ void SCD4XComponent::dump_config() { ONOFF(this->enable_asc_), measurement_mode_str, this->temperature_offset_); if (this->ambient_pressure_source_ != nullptr) { ESP_LOGCONFIG(TAG, " Dynamic ambient pressure compensation using '%s'", - this->ambient_pressure_source_->get_name().c_str()); + LOG_STR_ARG(this->ambient_pressure_source_->get_log_name())); } else { if (this->ambient_pressure_) { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index db9eca8288c..8a3fd17ca6a 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -46,7 +46,7 @@ static void publish_float(sensor::Sensor *sens, std::span regist bool word_swapped) { float value = read_float(registers, 0, reg, word_swapped); if (!std::isfinite(value)) { - ESP_LOGD(TAG, "Skipping non-finite value for '%s'", sens->get_name().c_str()); + ESP_LOGD(TAG, "Skipping non-finite value for '%s'", LOG_STR_ARG(sens->get_log_name())); return; } sens->publish_state(kilo ? value * 1000.0f : value); diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 4d77c6d6f6c..acbb1b3a915 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -13,19 +13,19 @@ void Select::publish_state(const char *state) { if (index.has_value()) { this->publish_state(index.value()); } else { - ESP_LOGE(TAG, "'%s': Invalid option %s", this->get_name().c_str(), state); + ESP_LOGE(TAG, "'%s': Invalid option %s", LOG_STR_ARG(this->get_log_name()), state); } } void Select::publish_state(size_t index) { if (!this->has_index(index)) { - ESP_LOGE(TAG, "'%s': Invalid index %zu", this->get_name().c_str(), index); + ESP_LOGE(TAG, "'%s': Invalid index %zu", LOG_STR_ARG(this->get_log_name()), index); return; } const char *option = this->option_at(index); this->set_has_state(true); this->active_index_ = index; - ESP_LOGV(TAG, "'%s' >> %s (%zu)", this->get_name().c_str(), option, index); + ESP_LOGV(TAG, "'%s' >> %s (%zu)", LOG_STR_ARG(this->get_log_name()), option, index); this->state_callback_.call(index); #if defined(USE_SELECT) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_select_update(this); diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 167e6769a4a..9841dbde792 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -40,7 +40,7 @@ SelectCall &SelectCall::with_option(const char *option, size_t len) { SelectCall &SelectCall::with_index(size_t index) { this->operation_ = SELECT_OP_SET; if (index >= this->parent_->size()) { - ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", this->parent_->get_name().c_str(), index); + ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", LOG_STR_ARG(this->parent_->get_log_name()), index); this->index_ = nullopt; // Store nullopt for invalid index } else { this->index_ = index; @@ -101,7 +101,7 @@ optional SelectCall::calculate_target_index_(const char *name) { void SelectCall::perform() { auto *parent = this->parent_; - const auto *name = parent->get_name().c_str(); + const auto *name = LOG_STR_ARG(parent->get_log_name()); if (this->operation_ == SELECT_OP_NONE) { ESP_LOGW(TAG, "'%s' - SelectCall performed without selecting an operation", name); diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 4e8a79124c6..66a12ff3797 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -190,7 +190,7 @@ void SEN6XComponent::setup() { if ((entry.status == Sen6xStatus::SEN6X_STATUS_GAS_ERROR && !has_voc_nox) || (entry.status == Sen6xStatus::SEN6X_STATUS_CO2_ERROR && !has_co2) || (entry.status == Sen6xStatus::SEN6X_STATUS_HCHO_ERROR && !has_hcho)) { - ESP_LOGE(TAG, "'%s' needs a variant with that feature", entry.sensor->get_name().c_str()); + ESP_LOGE(TAG, "'%s' needs a variant with that feature", LOG_STR_ARG(entry.sensor->get_log_name())); continue; } entry.bit = status_bit(entry.status, this->sen6x_type_); @@ -360,7 +360,7 @@ void SEN6XComponent::dump_config() { } if (this->ambient_pressure_source_ != nullptr) { ESP_LOGCONFIG(TAG, " Ambient pressure compensation source: %s", - this->ambient_pressure_source_->get_name().c_str()); + LOG_STR_ARG(this->ambient_pressure_source_->get_log_name())); } } LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 9f0bad4ab76..693eacb7be1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -39,13 +39,16 @@ from esphome.const import ( CONF_REFERENCE_TEMPERATURE, CONF_SEND_EVERY, CONF_SEND_FIRST_AT, + CONF_SENSOR_ID, CONF_STATE_CLASS, CONF_TEMPERATURE, + CONF_TEMPERATURE_OFFSET, CONF_TIMEOUT, CONF_TO, CONF_TRIGGER_ID, CONF_TYPE_ID, CONF_UNIT_OF_MEASUREMENT, + CONF_USE_FAHRENHEIT, CONF_VALUE, CONF_WEB_SERVER, CONF_WINDOW_SIZE, @@ -310,6 +313,7 @@ OrFilter = sensor_ns.class_("OrFilter", Filter) CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter) ToNTCResistanceFilter = sensor_ns.class_("ToNTCResistanceFilter", Filter) ToNTCTemperatureFilter = sensor_ns.class_("ToNTCTemperatureFilter", Filter) +RHCorrectionFilter = sensor_ns.class_("RHCorrectionFilter", Filter) CalibratePolynomialFilter = sensor_ns.class_("CalibratePolynomialFilter", Filter) SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter) ClampFilter = sensor_ns.class_("ClampFilter", Filter) @@ -1281,6 +1285,23 @@ async def calibrate_ntc_temperature_filter_to_code(config, filter_id): ) +@FILTER_REGISTRY.register( + "rh_correction", + RHCorrectionFilter, + cv.Schema( + { + cv.Required(CONF_TEMPERATURE_OFFSET): cv.templatable(cv.float_), + cv.Required(CONF_SENSOR_ID): cv.use_id(Sensor), + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + } + ), +) +async def rh_correction_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj: + sensor = await cg.get_variable(config[CONF_SENSOR_ID]) + offset = await cg.templatable(config[CONF_TEMPERATURE_OFFSET], [], cg.float_) + return cg.new_Pvariable(filter_id, offset, sensor, config[CONF_USE_FAHRENHEIT]) + + def _mean(xs): return sum(xs) / len(xs) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 9b027c4e11f..9c692c47f67 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -493,6 +493,48 @@ optional ToNTCTemperatureFilter::new_value(float value) { return temp; } +// RHCorrectionFilter +static constexpr float MAGNUS_B = 17.67f, MAGNUS_C = 243.5f; + +static bool rh_correction_temp_in_band(float t) { return t >= -40.0f && t <= 50.0f; } + +optional RHCorrectionFilter::new_value(float value) { + if (!this->temperature_sensor_->has_state()) { + if (!this->warned_) { + ESP_LOGW(TAG, "RHCorrectionFilter(%p): no temperature yet, passing humidity through uncorrected", this); + this->warned_ = true; + } + return value; + } + float offset = this->temperature_offset_.value(); + float tc = this->temperature_sensor_->get_state(); + if (this->use_fahrenheit_) { + tc = fahrenheit_to_celsius(tc); + offset /= 1.8f; + } + const float tnc = tc - offset; + // A NaN temperature or offset is a faulted input: keep the last corrected value + if (std::isnan(tnc)) { + if (!this->warned_) { + ESP_LOGW(TAG, "RHCorrectionFilter(%p): temperature or offset is NaN, dropping humidity updates", this); + this->warned_ = true; + } + return {}; + } + if (!rh_correction_temp_in_band(tc) || !rh_correction_temp_in_band(tnc)) { + if (!this->warned_) { + ESP_LOGW(TAG, "RHCorrectionFilter(%p): Temperature out of range -40..50 °C. Tc=%f Tnc=%f", this, tc, tnc); + this->warned_ = true; + } + return value; + } + this->warned_ = false; + const float rh = + clamp(value * std::exp(MAGNUS_B * MAGNUS_C * (tnc - tc) / ((tnc + MAGNUS_C) * (tc + MAGNUS_C))), 0.0f, 100.0f); + ESP_LOGVV(TAG, "RHCorrectionFilter(%p)::new_value(%f) -> %f", this, value, rh); + return rh; +} + // StreamingFilter (base class) StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at) : window_size_(window_size), send_first_at_(send_first_at) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 890382a5d44..577790eb0a5 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -647,6 +647,26 @@ class ToNTCTemperatureFilter : public Filter { double c_; }; +/** RH Temperature correction filter + * + * Allows for Relative Humidity Correction when a temperature offset is applied to the temperature sensor. + * Uses the Magnus formula, rh = rh_nc * exp(b*(t_nc/(c+t_nc) - t/(c+t))) with b = 17.67, c = 243.5 °C; + * see https://en.wikipedia.org/wiki/Dew_point + */ +class RHCorrectionFilter : public Filter { + public: + RHCorrectionFilter(TemplatableFn offset, const Sensor *temperature_sensor, bool use_fahrenheit) + : temperature_offset_(offset), temperature_sensor_(temperature_sensor), use_fahrenheit_(use_fahrenheit) {} + + optional new_value(float value) override; + + protected: + TemplatableFn temperature_offset_; + const Sensor *temperature_sensor_; + bool use_fahrenheit_; + bool warned_{false}; +}; + /** Base class for streaming filters (batch windows where window_size == send_every). * * When window_size equals send_every, we don't need a sliding window. diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index df73e2c481a..c235ff08f5e 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -19,7 +19,7 @@ void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *o "%s State Class: '%s'\n" "%s Unit of Measurement: '%s'\n" "%s Accuracy Decimals: %d", - prefix, type, obj->get_name().c_str(), prefix, + prefix, type, LOG_STR_ARG(obj->get_log_name()), prefix, LOG_STR_ARG(state_class_to_string(obj->get_state_class())), prefix, obj->get_unit_of_measurement_ref().c_str(), prefix, obj->get_accuracy_decimals()); @@ -68,7 +68,7 @@ void Sensor::publish_state(float state) { this->raw_callback_.call(state); #endif - ESP_LOGV(TAG, "'%s': Received new state %f", this->name_.c_str(), state); + ESP_LOGV(TAG, "'%s': Received new state %f", LOG_STR_ARG(this->get_log_name()), state); #ifdef USE_SENSOR_FILTER if (this->filter_list_ == nullptr) { @@ -116,8 +116,8 @@ void Sensor::clear_filters() { void Sensor::internal_send_state_to_frontend(float state) { this->set_has_state(true); this->state = state; - ESP_LOGV(TAG, "'%s' >> %.*f %s", this->get_name().c_str(), std::max(0, (int) this->get_accuracy_decimals()), state, - this->get_unit_of_measurement_ref().c_str()); + ESP_LOGV(TAG, "'%s' >> %.*f %s", LOG_STR_ARG(this->get_log_name()), std::max(0, (int) this->get_accuracy_decimals()), + state, this->get_unit_of_measurement_ref().c_str()); this->callback_.call(state); #if defined(USE_SENSOR) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_sensor_update(this); diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index d1b4e889066..e482130be80 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_DTR_PIN, CONF_RTS_PIN from esphome.components.usb_uart import is_usb_uart_channel import esphome.config_validation as cv from esphome.const import CONF_NAME, CONF_UART_ID @@ -40,9 +41,7 @@ SERIAL_PROXY_PORT_TYPES = SerialProxyPortType.options( "TTL", "RS232", "RS485", "USB_SERIAL", prefix="SERIAL_PROXY_PORT_TYPE_" ) -CONF_DTR_PIN = "dtr_pin" CONF_PORT_TYPE = "port_type" -CONF_RTS_PIN = "rts_pin" DOMAIN = "serial_proxy" diff --git a/esphome/components/shutdown/button/__init__.py b/esphome/components/shutdown/button/__init__.py index 44fd819d252..5839bea86cb 100644 --- a/esphome/components/shutdown/button/__init__.py +++ b/esphome/components/shutdown/button/__init__.py @@ -1,18 +1,13 @@ -import esphome.codegen as cg from esphome.components import button -import esphome.config_validation as cv -from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_POWER +from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_POWER from esphome.types import ConfigType -shutdown_ns = cg.esphome_ns.namespace("shutdown") -ShutdownButton = shutdown_ns.class_("ShutdownButton", button.Button, cg.Component) - CONFIG_SCHEMA = button.button_schema( - ShutdownButton, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_POWER -).extend(cv.COMPONENT_SCHEMA) + button.ApplyButton, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_POWER +) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await button.register_button(var, config) + await button.sub_buttons(config).create( + config, None, apply="shutdown::shutdown_device()" + ) diff --git a/esphome/components/shutdown/button/shutdown_button.cpp b/esphome/components/shutdown/button/shutdown_button.cpp index 7a1380469bb..f24cff1935d 100644 --- a/esphome/components/shutdown/button/shutdown_button.cpp +++ b/esphome/components/shutdown/button/shutdown_button.cpp @@ -14,8 +14,7 @@ namespace esphome::shutdown { ESPHOME_LOG_TAG(TAG, "shutdown.button"); -void ShutdownButton::dump_config() { LOG_BUTTON("", "Shutdown Button", this); } -void ShutdownButton::press_action() { +void shutdown_device() { ESP_LOGI(TAG, "Shutting down"); // Let MQTT settle a bit delay(100); // NOLINT diff --git a/esphome/components/shutdown/button/shutdown_button.h b/esphome/components/shutdown/button/shutdown_button.h index 4fc534030e0..16d4cb00133 100644 --- a/esphome/components/shutdown/button/shutdown_button.h +++ b/esphome/components/shutdown/button/shutdown_button.h @@ -1,16 +1,8 @@ #pragma once -#include "esphome/core/component.h" -#include "esphome/components/button/button.h" - namespace esphome::shutdown { -class ShutdownButton final : public button::Button, public Component { - public: - void dump_config() override; - - protected: - void press_action() override; -}; +/// Run by the shutdown button. +void shutdown_device(); } // namespace esphome::shutdown diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index 1c23de43abf..78f1bc8fae3 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -331,7 +331,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->state_ = STATE_RECEIVED_SMS; } else { if (!this->message_.empty()) - this->message_ += "\n"; + this->message_ += '\n'; this->message_ += message; } break; diff --git a/esphome/components/sml/sml.cpp b/esphome/components/sml/sml.cpp index 6924e2fdd9c..05488012da8 100644 --- a/esphome/components/sml/sml.cpp +++ b/esphome/components/sml/sml.cpp @@ -108,7 +108,7 @@ bool check_sml_data(const bytes &buffer) { uint16_t crc_received = (buffer.at(buffer.size() - 2) << 8) | buffer.at(buffer.size() - 1); uint16_t crc_calculated = crc16(buffer.data() + 8, buffer.size() - 10, 0x6e23, 0x8408, true, true); - crc_calculated = (crc_calculated >> 8) | (crc_calculated << 8); + crc_calculated = byteswap(crc_calculated); if (crc_received == crc_calculated) { ESP_LOGV(TAG, "Checksum verification successful with CRC16/X25."); return true; diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.cpp b/esphome/components/sml/text_sensor/sml_text_sensor.cpp index 9bd31923ecd..e49e7af2b3f 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.cpp +++ b/esphome/components/sml/text_sensor/sml_text_sensor.cpp @@ -48,6 +48,8 @@ void SmlTextSensor::publish_val(const ObisInfo &obis_info) { publish_state(reinterpret_cast(obis_info.value.begin()), obis_info.value.size()); break; } + default: + break; } } diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index ca3b4267e84..f8551d1571e 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -288,8 +288,8 @@ void SonoffD1Output::dump_config() { " Use RM433 Remote: %s\n" " Minimal brightness: %d\n" " Maximal brightness: %d", - this->light_state_ ? this->light_state_->get_name().c_str() : "", ONOFF(this->use_rm433_remote_), - this->min_value_, this->max_value_); + this->light_state_ ? LOG_STR_ARG(this->light_state_->get_log_name()) : LOG_STR_LITERAL(""), + ONOFF(this->use_rm433_remote_), this->min_value_, this->max_value_); } void SonoffD1Output::loop() { diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index abbb26445bd..0ac82fb9aa4 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -13,6 +13,7 @@ from esphome.components import ( ) from esphome.components.const import ( CONF_ANNOUNCEMENT_VOLUME, + CONF_BITRATE, CONF_VOLUME_INCREMENT, CONF_VOLUME_INITIAL, CONF_VOLUME_MAX, @@ -107,6 +108,7 @@ PIPELINE_SCHEMA = cv.Schema( cv.Optional(CONF_FORMAT, default="FLAC"): cv.enum(audio.AUDIO_FILE_TYPE_ENUM), cv.Optional(CONF_SAMPLE_RATE): cv.int_range(min=1), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(1, 2), + cv.Optional(CONF_BITRATE): media_player.validate_mp3_bitrate, } ) diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 589814df6c1..e8f93a47835 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import audio, media_player, media_source, speaker from esphome.components.const import ( CONF_ANNOUNCEMENT_VOLUME, + CONF_BITRATE, CONF_SOURCES, CONF_VOLUME_INCREMENT, CONF_VOLUME_INITIAL, @@ -78,6 +79,7 @@ PIPELINE_SCHEMA = cv.Schema( cv.Optional(CONF_FORMAT, default="FLAC"): cv.enum(audio.AUDIO_FILE_TYPE_ENUM), cv.Optional(CONF_SAMPLE_RATE): cv.int_range(min=1), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(1, 2), + cv.Optional(CONF_BITRATE): media_player.validate_mp3_bitrate, } ) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 8ff1842a7c1..9965fd80aa0 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -18,6 +18,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, VARIANT_ESP32S31, only_on_variant, + require_idf_version, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN from esphome.config_helpers import filter_source_files_from_platform @@ -147,8 +148,7 @@ def _validate_psram_dma(value: Any) -> bool: if not value: return value return cv.All( - cv.only_on_esp32, - cv.only_with_framework("esp-idf"), + require_idf_version(cv.Version(5, 5, 3), "psram_dma"), only_on_variant( supported=[ VARIANT_ESP32C5, @@ -159,7 +159,6 @@ def _validate_psram_dma(value: Any) -> bool: ], msg_prefix="PSRAM DMA", ), - cv.require_framework_version(esp_idf=cv.Version(5, 5, 3)), cv.requires_component(PSRAM_DOMAIN), )(value) diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 8e3f9ca714b..13cf1fa84ed 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include @@ -224,8 +225,7 @@ class SPIDelegate { // write 16 bits virtual void write16(uint16_t data) { if (this->bit_order_ == BIT_ORDER_MSB_FIRST) { - uint16_t buffer; - buffer = (data >> 8) | (data << 8); + uint16_t buffer = byteswap(data); this->write_array(reinterpret_cast(&buffer), 2); } else { this->write_array(reinterpret_cast(&data), 2); diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index eb9e61958f5..5a0cd14940c 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -868,12 +868,22 @@ void Sprinkler::reset_resume() { this->resume_duration_.reset(); } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" const char *Sprinkler::valve_name(const size_t valve_number) { if (this->is_a_valid_valve(valve_number)) { return this->valve_[valve_number].controller_switch->get_name().c_str(); } return nullptr; } +#pragma GCC diagnostic pop + +const LogString *Sprinkler::valve_log_name(const size_t valve_number) { + if (this->is_a_valid_valve(valve_number)) { + return this->valve_[valve_number].controller_switch->get_log_name(); + } + return nullptr; +} optional Sprinkler::active_valve_request_is_from() { if (this->active_req_.has_request()) { @@ -1586,7 +1596,8 @@ void Sprinkler::dump_config() { " Valve %zu:\n" " Name: %s\n" " Run Duration: %" PRIu32 " seconds", - valve_number, this->valve_name(valve_number), this->valve_run_duration(valve_number)); + valve_number, LOG_STR_ARG(this->valve_log_name(valve_number)), + this->valve_run_duration(valve_number)); } if (!this->pump_.empty()) { ESP_LOGCONFIG(TAG, " Total number of pumps: %zu", this->pump_.size()); diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index c51824d8833..354ce09347c 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -336,7 +336,11 @@ class Sprinkler final : public Component { void reset_resume(); /// returns a pointer to a valve's name string object; returns nullptr if valve_number is invalid + ESPDEPRECATED("Use valve_log_name() or control_switch()->get_name_to() instead. Will be removed in ESPHome 2027.5.0", + "2026.11.0") const char *valve_name(size_t valve_number); + /// returns a valve's name for a "%s" log argument; returns nullptr if valve_number is invalid + const LogString *valve_log_name(size_t valve_number); /// returns what invoked the valve that is currently active, if any. check with 'has_value()' optional active_valve_request_is_from(); diff --git a/esphome/components/st25r/st25r.h b/esphome/components/st25r/st25r.h index b16b6503fef..c5424483455 100644 --- a/esphome/components/st25r/st25r.h +++ b/esphome/components/st25r/st25r.h @@ -106,7 +106,7 @@ class ST25R : public PollingComponent, public nfc::Nfcc { void finish_scan_(); void finalize_scan_(); void remove_tag_(); - static void IRAM_ATTR isr(ST25R *arg); + static void isr(ST25R *arg); GPIOPin *reset_pin_{nullptr}; InternalGPIOPin *irq_pin_{nullptr}; diff --git a/esphome/components/status_led/light/status_led_light.cpp b/esphome/components/status_led/light/status_led_light.cpp index 6eeb9732a47..5877dcfb76f 100644 --- a/esphome/components/status_led/light/status_led_light.cpp +++ b/esphome/components/status_led/light/status_led_light.cpp @@ -35,7 +35,7 @@ void StatusLEDLightOutput::loop() { void StatusLEDLightOutput::setup_state(light::LightState *state) { lightstate_ = state; - ESP_LOGD(TAG, "'%s': Setting initial state", state->get_name().c_str()); + ESP_LOGD(TAG, "'%s': Setting initial state", LOG_STR_ARG(state->get_log_name())); this->write_state(state); } @@ -46,7 +46,7 @@ void StatusLEDLightOutput::write_state(light::LightState *state) { // if in warning/error, don't overwrite the status_led // once it is back to OK, the loop will restore the state if ((App.get_app_state() & (STATUS_LED_ERROR | STATUS_LED_WARNING)) == 0u) { - ESP_LOGD(TAG, "'%s': Setting state %s", state->get_name().c_str(), ONOFF(binary)); + ESP_LOGD(TAG, "'%s': Setting state %s", LOG_STR_ARG(state->get_log_name()), ONOFF(binary)); this->output_state_(binary); } } diff --git a/esphome/components/stcc4/stcc4.cpp b/esphome/components/stcc4/stcc4.cpp index a5e47584eb0..503f83bfd04 100644 --- a/esphome/components/stcc4/stcc4.cpp +++ b/esphome/components/stcc4/stcc4.cpp @@ -145,16 +145,16 @@ void STCC4Component::dump_config() { : LOG_STR_LITERAL("Single shot")); if (this->ambient_pressure_source_ != nullptr) { ESP_LOGCONFIG(TAG, " Dynamic ambient pressure compensation using '%s'", - this->ambient_pressure_source_->get_name().c_str()); + LOG_STR_ARG(this->ambient_pressure_source_->get_log_name())); } else if (this->ambient_pressure_in_pa_2_ != 0) { ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %f hPa", pressure_in_pa_2_to_hpa(this->ambient_pressure_in_pa_2_)); } if (this->temperature_source_ != nullptr) { - ESP_LOGCONFIG(TAG, " Temperature compensation using '%s'", this->temperature_source_->get_name().c_str()); + ESP_LOGCONFIG(TAG, " Temperature compensation using '%s'", LOG_STR_ARG(this->temperature_source_->get_log_name())); } if (this->humidity_source_ != nullptr) { - ESP_LOGCONFIG(TAG, " Humidity compensation using '%s'", this->humidity_source_->get_name().c_str()); + ESP_LOGCONFIG(TAG, " Humidity compensation using '%s'", LOG_STR_ARG(this->humidity_source_->get_log_name())); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "CO2", this->co2_sensor_); diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 576ff0459a9..6a517693e28 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -1,4 +1,6 @@ -from esphome import automation +from collections.abc import Callable + +from esphome import automation, preferences from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee @@ -8,13 +10,16 @@ from esphome.const import ( CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, + CONF_INITIAL_STATE, CONF_INVERTED, CONF_MQTT_ID, CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, CONF_RESTORE_MODE, + CONF_RESTORE_STATE, CONF_STATE, + CONF_STORAGE, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, DEVICE_CLASS_OUTLET, @@ -22,10 +27,14 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + RESTORE_STATE_INITIAL, + RESTORE_STATE_INVERT, + RESTORE_STATE_KEEP, ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, + restore_state_schema, setup_device_class, setup_entity, ) @@ -47,6 +56,8 @@ SwitchPtr = Switch.operator("ptr") ApplySwitch = switch_ns.class_("ApplySwitch", Switch) SwitchRestoreMode = switch_ns.enum("SwitchRestoreMode") +RESTORE_MODE_STORAGE_FLASH_MASK = switch_ns.RESTORE_MODE_STORAGE_FLASH_MASK +RESTORE_MODE_STORAGE_RTC_MASK = switch_ns.RESTORE_MODE_STORAGE_RTC_MASK RESTORE_MODES = { "RESTORE_DEFAULT_OFF": SwitchRestoreMode.SWITCH_RESTORE_DEFAULT_OFF, @@ -60,6 +71,115 @@ RESTORE_MODES = { validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) +validate_restore_mode = cv.enum(RESTORE_MODES, upper=True, space="_") + + +def _validate_restore_state_storage(config: ConfigType) -> ConfigType: + if CONF_STORAGE in config and config[CONF_STATE] == RESTORE_STATE_INITIAL: + raise cv.Invalid( + "'storage' only applies when a saved state is restored (KEEP or INVERT)", + path=[CONF_STORAGE], + ) + return config + + +def _validate_restore_state_state(value: object) -> str: + # light accepts ON/OFF here (restore, then force a state); a switch has no such mode, and + # YAML turns ON into True, so say what to use instead of asking for quotes + if isinstance(value, bool) or ( + isinstance(value, str) and value.upper() in ("ON", "OFF") + ): + raise cv.Invalid( + "a switch can't restore and then force ON or OFF; to always start in a fixed state, " + "use 'initial_state: ON' (or OFF) with 'restore_state: none'" + ) + return cv.one_of( + RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL, upper=True + )(value) + + +_RESTORE_STATE_FIELDS_SCHEMA = cv.Schema( + { + cv.Optional( + CONF_STATE, default=RESTORE_STATE_KEEP + ): _validate_restore_state_state, + # No default: when unset, the existing restore_mode storage applies (on ESP8266, + # restore_from_flash decides), as with wifi + cv.Optional(CONF_STORAGE): preferences.validate_storage, + } +) +_RESTORE_STATE_FIELDS_SCHEMA.add_extra(_validate_restore_state_storage) + + +# A mapping of `state` and `storage`, or the shorthands `all` (KEEP) and `none` (INITIAL) +RESTORE_STATE_SCHEMA = restore_state_schema( + _RESTORE_STATE_FIELDS_SCHEMA, {CONF_STATE: RESTORE_STATE_INITIAL} +) + + +def _validate_initial_state(config: ConfigType) -> ConfigType: + if CONF_INITIAL_STATE in config and CONF_RESTORE_MODE in config: + raise cv.Invalid( + "'initial_state' can't be combined with 'restore_mode', which already sets " + "the boot state; use 'restore_state' instead", + path=[CONF_INITIAL_STATE], + ) + return config + + +# (restore_state state, initial_state) -> restore mode +_RESTORE_MODE_FOR_STATE = { + (RESTORE_STATE_KEEP, False): "RESTORE_DEFAULT_OFF", + (RESTORE_STATE_KEEP, True): "RESTORE_DEFAULT_ON", + (RESTORE_STATE_INVERT, False): "RESTORE_INVERTED_DEFAULT_OFF", + (RESTORE_STATE_INVERT, True): "RESTORE_INVERTED_DEFAULT_ON", + (RESTORE_STATE_INITIAL, False): "ALWAYS_OFF", + (RESTORE_STATE_INITIAL, True): "ALWAYS_ON", +} +_STATE_FOR_RESTORE_MODE = { + mode: state for (state, _), mode in _RESTORE_MODE_FOR_STATE.items() +} + + +def _apply_default_restore_mode( + default_restore_mode: str, +) -> Callable[[ConfigType], ConfigType]: + # cv.Exclusive has no default, so apply it here when no restore option is configured. + # With only initial_state set, a restoring default keeps restoring and initial_state + # supplies the state used when nothing is saved. + default = validate_restore_mode(default_restore_mode) + state = _STATE_FOR_RESTORE_MODE.get(str(default), RESTORE_STATE_INITIAL) + + def validator(config: ConfigType) -> ConfigType: + if CONF_RESTORE_MODE in config or CONF_RESTORE_STATE in config: + return config + if CONF_INITIAL_STATE in config: + config[CONF_RESTORE_STATE] = {CONF_STATE: state} + else: + config[CONF_RESTORE_MODE] = default + return config + + return validator + + +def _resolve_restore_mode(config: ConfigType) -> MockObj | str: + """Translate `initial_state` / `restore_state` into one of the RESTORE_MODES, with the storage bit.""" + if (restore_mode := config.get(CONF_RESTORE_MODE)) is not None: + return restore_mode + restore_state = config.get(CONF_RESTORE_STATE, {}) + state = restore_state.get(CONF_STATE, RESTORE_STATE_INITIAL) + mode = RESTORE_MODES[ + _RESTORE_MODE_FOR_STATE[state, config.get(CONF_INITIAL_STATE, False)] + ] + if (storage := restore_state.get(CONF_STORAGE)) is None: + return mode + cg.add_define("USE_SWITCH_STORAGE") + mask = ( + RESTORE_MODE_STORAGE_FLASH_MASK + if preferences.is_in_flash(storage) + else RESTORE_MODE_STORAGE_RTC_MASK + ) + return cg.RawExpression(f"static_cast<{SwitchRestoreMode}>({mode} | {mask})") _SWITCH_SCHEMA = ( @@ -70,9 +190,9 @@ _SWITCH_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSwitchComponent), cv.Optional(CONF_INVERTED): cv.boolean, - cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( - RESTORE_MODES, upper=True, space="_" - ), + cv.Exclusive(CONF_RESTORE_MODE, "restore"): validate_restore_mode, + cv.Exclusive(CONF_RESTORE_STATE, "restore"): RESTORE_STATE_SCHEMA, + cv.Optional(CONF_INITIAL_STATE): cv.boolean, cv.Optional(CONF_ON_STATE): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), @@ -86,6 +206,7 @@ _SWITCH_SCHEMA = ( _SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch")) _SWITCH_SCHEMA.add_extra(zigbee.validate_switch) +_SWITCH_SCHEMA.add_extra(_validate_initial_state) def switch_schema( @@ -103,13 +224,6 @@ def switch_schema( (CONF_DEVICE_CLASS, device_class, validate_device_class), (CONF_ENTITY_CATEGORY, entity_category, cv.entity_category), (CONF_ICON, icon, cv.icon), - ( - CONF_RESTORE_MODE, - default_restore_mode, - cv.enum(RESTORE_MODES, upper=True, space="_") - if default_restore_mode is not cv.UNDEFINED - else cv.UNDEFINED, - ), ]: if default is not cv.UNDEFINED: schema[cv.Optional(key, default=default)] = validator @@ -119,7 +233,15 @@ def switch_schema( "Inverted is not supported for this platform!" ) - return _SWITCH_SCHEMA.extend(schema) + result = _SWITCH_SCHEMA.extend(schema) + result.add_extra( + _apply_default_restore_mode( + "ALWAYS_OFF" + if default_restore_mode is cv.UNDEFINED + else default_restore_mode + ) + ) + return result _CALLBACK_AUTOMATIONS = ( @@ -159,7 +281,7 @@ async def setup_switch_core_(var, config): setup_device_class(config) - cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) + cg.add(var.set_restore_mode(_resolve_restore_mode(config))) await zigbee.setup_switch(var, config) diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 179ef2ce35d..57eec137378 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -17,15 +17,15 @@ void Switch::control(bool target_state) { } } void Switch::turn_on() { - ESP_LOGV(TAG, "'%s' Turning ON.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Turning ON.", LOG_STR_ARG(this->get_log_name())); this->write_state(!this->inverted_); } void Switch::turn_off() { - ESP_LOGV(TAG, "'%s' Turning OFF.", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s' Turning OFF.", LOG_STR_ARG(this->get_log_name())); this->write_state(this->inverted_); } void Switch::toggle() { - ESP_LOGV(TAG, "'%s' Toggling %s.", this->get_name().c_str(), + ESP_LOGV(TAG, "'%s' Toggling %s.", LOG_STR_ARG(this->get_log_name()), this->state ? LOG_STR_LITERAL("OFF") : LOG_STR_LITERAL("ON")); this->write_state(this->inverted_ == this->state); } @@ -33,7 +33,14 @@ optional Switch::get_initial_state() { if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK)) return {}; - this->rtc_ = this->make_entity_preference(); +#ifdef USE_SWITCH_STORAGE + if (restore_mode & RESTORE_MODE_STORAGE_MASK) { + this->rtc_ = this->make_entity_preference(0, restore_mode & RESTORE_MODE_STORAGE_FLASH_MASK); + } else +#endif + { + this->rtc_ = this->make_entity_preference(); + } bool initial_state; if (!this->rtc_.load(&initial_state)) return {}; @@ -53,6 +60,13 @@ optional Switch::get_initial_state_with_restore_mode() { } return initial_state; } +void Switch::restore() { + if (auto initial = this->get_initial_state_with_restore_mode()) + this->control(*initial); +} +void Switch::restore(bool when_disabled) { + this->control(this->get_initial_state_with_restore_mode().value_or(when_disabled)); +} void Switch::publish_state(bool state) { if (!this->publish_dedup_.next(state)) return; @@ -62,7 +76,7 @@ void Switch::publish_state(bool state) { if (restore_mode & RESTORE_MODE_PERSISTENT_MASK) this->rtc_.save(&this->state); - ESP_LOGV(TAG, "'%s' >> %s", this->name_.c_str(), ONOFF(this->state)); + ESP_LOGV(TAG, "'%s' >> %s", LOG_STR_ARG(this->get_log_name()), ONOFF(this->state)); this->state_callback_.call(this->state); #if defined(USE_SWITCH) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_switch_update(this); @@ -86,7 +100,7 @@ void log_switch(const char *tag, const char *prefix, const char *type, Switch *o ESP_LOGCONFIG(tag, "%s%s '%s'\n" "%s Restore Mode: %s%s %s", - prefix, type, obj->get_name().c_str(), prefix, LOG_STR_ARG(inverted), LOG_STR_ARG(restore), + prefix, type, LOG_STR_ARG(obj->get_log_name()), prefix, LOG_STR_ARG(inverted), LOG_STR_ARG(restore), LOG_STR_ARG(onoff)); // Add optional fields separately diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 1449d22b819..fca865adba3 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -14,11 +14,16 @@ namespace esphome::switch_ { public: \ void set_##name##_switch(switch_::Switch *s) { this->name##_switch_ = s; } -// bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled +// bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled. +// bit4: restore from flash. bit5: restore from RTC memory (neither: platform default). +// Code generation ors the storage bit into the restore mode passed to set_restore_mode(). constexpr int RESTORE_MODE_ON_MASK = 0x01; constexpr int RESTORE_MODE_PERSISTENT_MASK = 0x02; constexpr int RESTORE_MODE_INVERTED_MASK = 0x04; constexpr int RESTORE_MODE_DISABLED_MASK = 0x08; +constexpr int RESTORE_MODE_STORAGE_FLASH_MASK = 0x10; +constexpr int RESTORE_MODE_STORAGE_RTC_MASK = 0x20; +constexpr int RESTORE_MODE_STORAGE_MASK = RESTORE_MODE_STORAGE_FLASH_MASK | RESTORE_MODE_STORAGE_RTC_MASK; enum SwitchRestoreMode : uint8_t { SWITCH_ALWAYS_OFF = !RESTORE_MODE_ON_MASK, @@ -110,6 +115,11 @@ class Switch : public EntityBase { */ optional get_initial_state_with_restore_mode(); + /// Drive the switch to the state its restore mode yields, through control(), so inversion and + /// write_state() apply; a disabled restore mode drives \p when_disabled, or nothing without one. + void restore(); + void restore(bool when_disabled); + /** Return whether this switch uses an assumed state - i.e. if both the ON/OFF actions should be displayed in Home * Assistant because the real state is unknown. * @@ -139,9 +149,9 @@ class Switch : public EntityBase { LazyCallbackManager state_callback_{}; // Small types grouped together - Deduplicator publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_) + Deduplicator publish_dedup_; // 3 bytes (bool has_value_ + bool value_unknown_ + bool last_value_) bool inverted_{false}; // 1 byte - // Total: 3 bytes, 1 byte padding + // Total: 4 bytes, no padding }; #define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj)) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 5bc6a2c970c..2f16132a50b 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -11,10 +11,9 @@ from esphome.components.const import ( CONF_RST_PIN, ) import esphome.config_validation as cv -from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID -from esphome.core import ID, TimePeriod -from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY +from esphome.core import TimePeriod +from esphome.types import ConfigType DOMAIN = "sx126x" MULTI_CONF = True @@ -133,10 +132,6 @@ SHAPING = { "NONE": SX126xPulseShape.NO_FILTER, } -SendPacketAction = sx126x_ns.class_( - "SendPacketAction", automation.Action, cg.Parented.template(SX126x) -) - def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): @@ -324,25 +319,8 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "sx126x.send_packet", - SendPacketAction, SEND_PACKET_ACTION_SCHEMA, - synchronous=True, + automation.ApplyBytes(CONF_DATA, "transmit_packet({})", stack=MAX_PACKET_SIZE), ) -async def send_packet_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - await automation.templatable_bytes( - config[CONF_DATA], - args, - var.set_data_template, - var.set_data_static, - "sx126x_data", - ) - return var diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h deleted file mode 100644 index 0dbf3cb42c4..00000000000 --- a/esphome/components/sx126x/automation.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/sx126x/sx126x.h" - -namespace esphome::sx126x { - -template class SendPacketAction final : public Action, public Parented { - TEMPLATABLE_BYTES(data) - - void play(const Ts &...x) override { - this->data_.template visit( - [this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); - } -}; - -} // namespace esphome::sx126x diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index c853d7c9ed0..f6a81d0edd2 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -107,7 +107,7 @@ class SX126x final : public Component, Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } protected: - static void IRAM_ATTR gpio_intr(SX126x *arg); + static void gpio_intr(SX126x *arg); void configure_fsk_ook_(); void configure_lora_(); void set_packet_params_(uint8_t payload_length); diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index daf501f3258..834d9e59ddf 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -11,9 +11,7 @@ from esphome.components.const import ( CONF_RST_PIN, ) import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.const import CONF_DATA, CONF_FREQUENCY from esphome.types import ConfigType DOMAIN = "sx127x" @@ -117,10 +115,6 @@ SHAPING = { "NONE": SX127xPaRamp.SHAPING_NONE, } -SendPacketAction = sx127x_ns.class_( - "SendPacketAction", automation.Action, cg.Parented.template(SX127x) -) - def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): @@ -291,25 +285,8 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "sx127x.send_packet", - SendPacketAction, SEND_PACKET_ACTION_SCHEMA, - synchronous=True, + automation.ApplyBytes(CONF_DATA, "transmit_packet({})", stack=MAX_PACKET_SIZE), ) -async def send_packet_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - await automation.templatable_bytes( - config[CONF_DATA], - args, - var.set_data_template, - var.set_data_static, - "sx127x_data", - ) - return var diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h deleted file mode 100644 index 8d409f0b012..00000000000 --- a/esphome/components/sx127x/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/sx127x/sx127x.h" - -namespace esphome::sx127x { - -template class SendPacketAction final : public Action, public Parented { - TEMPLATABLE_BYTES(data) - - public: - void play(const Ts &...x) override { - this->data_.template visit( - [this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); - } -}; - -} // namespace esphome::sx127x diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 547c52e7bb3..cf744e8a468 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -91,7 +91,7 @@ class SX127x final : public Component, Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } protected: - static void IRAM_ATTR gpio_intr(SX127x *arg); + static void gpio_intr(SX127x *arg); void configure_fsk_ook_(); void configure_lora_(); void set_mode_(uint8_t modulation, uint8_t mode); diff --git a/esphome/components/tca8418/tca8418.h b/esphome/components/tca8418/tca8418.h index 383182c1e0f..83d564d742c 100644 --- a/esphome/components/tca8418/tca8418.h +++ b/esphome/components/tca8418/tca8418.h @@ -58,7 +58,7 @@ class TCA8418Component : public key_provider::KeyProvider, public Component, pub #endif /// Wakes the loop so queued events are read. Runs in interrupt context. - static void IRAM_ATTR interrupt_handler(TCA8418Component *arg); + static void interrupt_handler(TCA8418Component *arg); protected: /// Write the matrix / individual-input pin configuration. Returns false on I2C failure. diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index fe4f6c21264..2df3433cdb5 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -27,7 +27,7 @@ class TCA9555Component final : public Component, void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } protected: - static void IRAM_ATTR gpio_intr(TCA9555Component *arg); + static void gpio_intr(TCA9555Component *arg); // Virtual methods from GpioExpander base class — names come from base bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index d1647a522b5..e259470e46d 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -191,6 +191,8 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { case TCL112_AUTO: this->mode = climate::CLIMATE_MODE_HEAT_COOL; break; + default: + break; } } auto temp = TCL112_TEMP_MAX - remote_state[7]; diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index 6a23836c8ab..eac9841c2ec 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -1,3 +1,5 @@ +import logging + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -9,6 +11,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_REACTIVE_ENERGY, @@ -17,12 +20,15 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_HOUR, UNIT_KILOVOLT_AMPS, UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, UNIT_KILOWATT, UNIT_KILOWATT_HOURS, UNIT_MILLIAMP, UNIT_MILLIVOLT, + UNIT_MINUTE, + UNIT_SECOND, UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_VOLT_AMPS_REACTIVE_HOURS, @@ -33,6 +39,8 @@ from esphome.types import ConfigType from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns +_LOGGER = logging.getLogger(__name__) + TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component) @@ -59,6 +67,9 @@ _APPARENT_POWER_KVA = _preset( UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT ) _POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT) +_DURATION_MIN = _preset(UNIT_MINUTE, DEVICE_CLASS_DURATION, STATE_CLASS_MEASUREMENT) +# Numeric values without a physical unit (indexes, bit fields, versions) +_NO_UNIT: dict[str, str] = {} # Legacy defaults, kept for tags without a known preset _LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH} @@ -75,6 +86,7 @@ _COMPATIBLE_UNITS = { DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT}, DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS}, DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT}, + DEVICE_CLASS_DURATION: {UNIT_SECOND, UNIT_MINUTE, UNIT_HOUR}, } # Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant. @@ -90,6 +102,10 @@ TIC_TAG_CONFIGS = { "CC": _POWER, "PREF": _APPARENT_POWER_KVA, "PCOUP": _APPARENT_POWER_KVA, + "VTIC": _NO_UNIT, + "NTARF": _NO_UNIT, + "NJOURF": _NO_UNIT, + "RELAIS": _NO_UNIT, # Historical mode "BASE": _ENERGY, "HCH": _ENERGY, @@ -101,15 +117,42 @@ TIC_TAG_CONFIGS = { "ADPS": _CURRENT, "ADIR": _CURRENT, "PAPP": _APPARENT_POWER_VA, - "PMAX": _POWER, + "PMAX": _APPARENT_POWER_VA, + "PEJP": _DURATION_MIN, } +# Prefixes of text tags, which a sensor cannot publish; a text_sensor should be used +# instead. They get no preset, and no prefix here is a prefix of a preset one either. +TIC_TEXT_TAGS = ( + # Standard mode + "ADSC", + "DATE", + "NGTF", + "LTARF", + "STGE", + "DPM", + "FPM", + "MSG", + "PRM", + "PJOURF", + "PPOINTE", + # Historical mode + "ADCO", + "OPTARIF", + "PTEC", + "DEMAIN", + "HHPHC", + "MOTDETAT", + "PPOT", +) + def apply_tag_config(config: ConfigType) -> ConfigType: """Apply preset configurations based on the tag name. Only keys not set by the user are filled in. For known tags, a preset unit or device class is dropped if it is incompatible with the one set by the user. + Known text tags get no preset and log a warning, as a sensor cannot publish them. Unknown tags get the legacy defaults, merged key by key as before. """ if CONF_TAG_NAME not in config: @@ -117,6 +160,12 @@ def apply_tag_config(config: ConfigType) -> ConfigType: # Runs before cv.string, so the raw value may not be a string yet tag_name = str(config[CONF_TAG_NAME]) + if tag_name.startswith(TIC_TEXT_TAGS): + _LOGGER.warning( + "TeleInfo tag '%s' holds a text value, use a text_sensor instead", tag_name + ) + return config + preset = next( (p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)), None, @@ -128,8 +177,8 @@ def apply_tag_config(config: ConfigType) -> ConfigType: config.setdefault(key, value) return config - unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT]) - device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS]) + unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset.get(CONF_UNIT_OF_MEASUREMENT)) + device_class = config.get(CONF_DEVICE_CLASS, preset.get(CONF_DEVICE_CLASS)) compatible = unit in _COMPATIBLE_UNITS.get(device_class, ()) for key, value in preset.items(): diff --git a/esphome/components/tem3200/tem3200.cpp b/esphome/components/tem3200/tem3200.cpp index e964814bbb9..98f979413fd 100644 --- a/esphome/components/tem3200/tem3200.cpp +++ b/esphome/components/tem3200/tem3200.cpp @@ -39,6 +39,8 @@ void TEM3200Component::setup() { ESP_LOGE(TAG, "STALE data. Data has not been updated since last fetch"); this->status_set_warning(); break; + default: + break; } } @@ -127,6 +129,8 @@ void TEM3200Component::update() { ESP_LOGE(TAG, "Warning: STALE data. Data has not been updated since last fetch"); this->status_set_warning(); return; + default: + break; } float temperature = convert_temperature(raw_temperature); diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index abb399c51e3..317e44640a5 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -67,7 +67,8 @@ void TemplateAlarmControlPanel::dump_config() { " Armed night bypass: %s\n" " Auto bypass: %s\n" " Chime mode: %s", - alarm_sensor.sensor->get_name().c_str(), LOG_STR_ARG(sensor_type_to_string(alarm_sensor.info.type)), + LOG_STR_ARG(alarm_sensor.sensor->get_log_name()), + LOG_STR_ARG(sensor_type_to_string(alarm_sensor.info.type)), TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME), TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT), TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_AUTO), TRUEFALSE(flags & BINARY_SENSOR_MODE_CHIME)); @@ -252,7 +253,7 @@ void TemplateAlarmControlPanel::auto_bypass_sensors_() { auto *sensor = alarm_sensor.sensor; // Check for faulted bypass_auto sensors and remove them from monitoring if ((info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (sensor->state)) { - ESP_LOGW(TAG, "'%s' is faulted and will be automatically bypassed", sensor->get_name().c_str()); + ESP_LOGW(TAG, "'%s' is faulted and will be automatically bypassed", LOG_STR_ARG(sensor->get_log_name())); info.auto_bypassed = true; } } diff --git a/esphome/components/template/climate/template_climate.cpp b/esphome/components/template/climate/template_climate.cpp index a731f037e32..9345cf2ac20 100644 --- a/esphome/components/template/climate/template_climate.cpp +++ b/esphome/components/template/climate/template_climate.cpp @@ -115,7 +115,7 @@ void TemplateClimate::control(const climate::ClimateCall &call) { // so check here instead -- otherwise a typo is published as state the receiving end will reject. void TemplateClimate::set_mode(climate::ClimateMode mode) { if (!this->traits_.supports_mode(mode)) { - ESP_LOGW(TAG, "'%s' - Unsupported mode %u", this->get_name().c_str(), static_cast(mode)); + ESP_LOGW(TAG, "'%s' - Unsupported mode %u", LOG_STR_ARG(this->get_log_name()), static_cast(mode)); return; } this->mode = mode; @@ -123,7 +123,8 @@ void TemplateClimate::set_mode(climate::ClimateMode mode) { void TemplateClimate::set_swing_mode(climate::ClimateSwingMode swing_mode) { if (!this->traits_.supports_swing_mode(swing_mode)) { - ESP_LOGW(TAG, "'%s' - Unsupported swing mode %u", this->get_name().c_str(), static_cast(swing_mode)); + ESP_LOGW(TAG, "'%s' - Unsupported swing mode %u", LOG_STR_ARG(this->get_log_name()), + static_cast(swing_mode)); return; } this->swing_mode = swing_mode; @@ -131,7 +132,7 @@ void TemplateClimate::set_swing_mode(climate::ClimateSwingMode swing_mode) { void TemplateClimate::set_fan_mode(climate::ClimateFanMode fan_mode) { if (!this->traits_.supports_fan_mode(fan_mode)) { - ESP_LOGW(TAG, "'%s' - Unsupported fan mode %u", this->get_name().c_str(), static_cast(fan_mode)); + ESP_LOGW(TAG, "'%s' - Unsupported fan mode %u", LOG_STR_ARG(this->get_log_name()), static_cast(fan_mode)); return; } this->set_fan_mode_(fan_mode); @@ -139,7 +140,7 @@ void TemplateClimate::set_fan_mode(climate::ClimateFanMode fan_mode) { void TemplateClimate::set_preset(climate::ClimatePreset preset) { if (!this->traits_.supports_preset(preset)) { - ESP_LOGW(TAG, "'%s' - Unsupported preset %u", this->get_name().c_str(), static_cast(preset)); + ESP_LOGW(TAG, "'%s' - Unsupported preset %u", LOG_STR_ARG(this->get_log_name()), static_cast(preset)); return; } this->set_preset_(preset); @@ -147,7 +148,7 @@ void TemplateClimate::set_preset(climate::ClimatePreset preset) { void TemplateClimate::set_custom_fan_mode(StringRef mode) { if (this->find_custom_fan_mode_(mode.c_str(), mode.size()) == nullptr) { - ESP_LOGW(TAG, "'%s' - Unsupported custom fan mode '%s'", this->get_name().c_str(), mode.c_str()); + ESP_LOGW(TAG, "'%s' - Unsupported custom fan mode '%s'", LOG_STR_ARG(this->get_log_name()), mode.c_str()); return; } this->set_custom_fan_mode_(mode); @@ -155,7 +156,7 @@ void TemplateClimate::set_custom_fan_mode(StringRef mode) { void TemplateClimate::set_custom_preset(StringRef preset) { if (this->find_custom_preset_(preset.c_str(), preset.size()) == nullptr) { - ESP_LOGW(TAG, "'%s' - Unsupported custom preset '%s'", this->get_name().c_str(), preset.c_str()); + ESP_LOGW(TAG, "'%s' - Unsupported custom preset '%s'", LOG_STR_ARG(this->get_log_name()), preset.c_str()); return; } this->set_custom_preset_(preset); diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index e47fdd9a56d..83f2fc9f5ea 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -31,9 +31,11 @@ RESTORE_MODES = TemplateCoverRestoreMode.options( ) CONF_HAS_POSITION = "has_position" +CONF_STOP_TILT_ACTION = "stop_tilt_action" CONF_TOGGLE_ACTION = "toggle_action" -CONFIG_SCHEMA = ( + +CONFIG_SCHEMA = cv.All( cv.with_visibility( cover.cover_schema(TemplateCover), cv.Visibility.UI, @@ -50,6 +52,9 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_STOP_ACTION): automation.validate_automation(single=True), cv.Optional(CONF_TILT_ACTION): automation.validate_automation(single=True), cv.Optional(CONF_TILT_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_STOP_TILT_ACTION): automation.validate_automation( + single=True + ), cv.Optional(CONF_TOGGLE_ACTION): automation.validate_automation( single=True ), @@ -61,7 +66,8 @@ CONFIG_SCHEMA = ( ), } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + cv.key_requires(CONF_STOP_TILT_ACTION, CONF_TILT_ACTION), ) @@ -101,6 +107,11 @@ async def to_code(config): config[CONF_TILT_LAMBDA], [], return_type=cg.optional.template(float) ) cg.add(var.set_tilt_lambda(tilt_template_)) + if CONF_STOP_TILT_ACTION in config: + await automation.build_automation( + var.get_stop_tilt_trigger(), [], config[CONF_STOP_TILT_ACTION] + ) + cg.add(var.set_has_stop_tilt(True)) if CONF_POSITION_ACTION in config: await automation.build_automation( var.get_position_trigger(), [(float, "pos")], config[CONF_POSITION_ACTION] diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 88a0af10b5c..621040fdc50 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -57,6 +57,7 @@ float TemplateCover::get_setup_priority() const { return setup_priority::HARDWAR Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } Trigger<> *TemplateCover::get_close_trigger() { return &this->close_trigger_; } Trigger<> *TemplateCover::get_stop_trigger() { return &this->stop_trigger_; } +Trigger<> *TemplateCover::get_stop_tilt_trigger() { return &this->stop_tilt_trigger_; } Trigger<> *TemplateCover::get_toggle_trigger() { return &this->toggle_trigger_; } void TemplateCover::dump_config() { LOG_COVER("", "Template Cover", this); } void TemplateCover::control(const CoverCall &call) { @@ -66,6 +67,11 @@ void TemplateCover::control(const CoverCall &call) { this->prev_command_trigger_ = &this->stop_trigger_; this->publish_state(); } + if (call.get_stop_tilt()) { + this->tilt_trigger_.stop_action(); + this->stop_tilt_trigger_.stop_action(); + this->stop_tilt_trigger_.trigger(); + } if (call.get_toggle().has_value()) { this->stop_prev_trigger_(); this->toggle_trigger_.trigger(); @@ -95,6 +101,7 @@ void TemplateCover::control(const CoverCall &call) { auto tilt_val = call.get_tilt(); if (tilt_val.has_value()) { auto tilt = *tilt_val; + this->stop_tilt_trigger_.stop_action(); this->tilt_trigger_.trigger(tilt); if (this->optimistic_) { @@ -111,6 +118,7 @@ CoverTraits TemplateCover::get_traits() { traits.set_supports_toggle(this->has_toggle_); traits.set_supports_position(this->has_position_); traits.set_supports_tilt(this->has_tilt_); + traits.set_supports_stop_tilt(this->has_stop_tilt_); return traits; } Trigger *TemplateCover::get_position_trigger() { return &this->position_trigger_; } diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index d3096ba86f9..13e58b10359 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -22,6 +22,7 @@ class TemplateCover final : public cover::Cover, public Component { Trigger<> *get_open_trigger(); Trigger<> *get_close_trigger(); Trigger<> *get_stop_trigger(); + Trigger<> *get_stop_tilt_trigger(); Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); @@ -30,6 +31,7 @@ class TemplateCover final : public cover::Cover, public Component { void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } void set_has_position(bool has_position) { this->has_position_ = has_position; } void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } + void set_has_stop_tilt(bool has_stop_tilt) { this->has_stop_tilt_ = has_stop_tilt; } void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateCoverRestoreMode restore_mode) { restore_mode_ = restore_mode; } @@ -59,7 +61,9 @@ class TemplateCover final : public cover::Cover, public Component { Trigger position_trigger_; bool has_position_{false}; Trigger tilt_trigger_; + Trigger<> stop_tilt_trigger_; bool has_tilt_{false}; + bool has_stop_tilt_{false}; }; } // namespace esphome::template_ diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index 58ab300b904..809ee57e0a6 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -15,9 +15,9 @@ void Text::publish_state(const char *state, size_t len) { this->state.assign(state, len); } if (this->traits.get_mode() == TEXT_MODE_PASSWORD) { - ESP_LOGV(TAG, "'%s' >> " LOG_SECRET("'%s'"), this->get_name().c_str(), this->state.c_str()); + ESP_LOGV(TAG, "'%s' >> " LOG_SECRET("'%s'"), LOG_STR_ARG(this->get_log_name()), this->state.c_str()); } else { - ESP_LOGV(TAG, "'%s' >> '%s'", this->get_name().c_str(), this->state.c_str()); + ESP_LOGV(TAG, "'%s' >> '%s'", LOG_STR_ARG(this->get_log_name()), this->state.c_str()); } this->state_callback_.call(this->state); #if defined(USE_TEXT) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/text/text_call.cpp b/esphome/components/text/text_call.cpp index 93f0610d7d6..c4283af318d 100644 --- a/esphome/components/text/text_call.cpp +++ b/esphome/components/text/text_call.cpp @@ -17,7 +17,7 @@ TextCall &TextCall::set_value(const char *value, size_t len) { } void TextCall::validate_() { - const auto *name = this->parent_->get_name().c_str(); + const auto *name = LOG_STR_ARG(this->parent_->get_log_name()); if (!this->value_.has_value()) { ESP_LOGW(TAG, "'%s' - No value set for TextCall", name); @@ -42,16 +42,16 @@ void TextCall::validate_() { void TextCall::perform() { this->validate_(); if (!this->value_.has_value()) { - ESP_LOGW(TAG, "'%s' - No value set for TextCall", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s' - No value set for TextCall", LOG_STR_ARG(this->parent_->get_log_name())); return; } std::string target_value = this->value_.value(); if (this->parent_->traits.get_mode() == TEXT_MODE_PASSWORD) { - ESP_LOGV(TAG, "'%s' - Setting password value: " LOG_SECRET("'%s'"), this->parent_->get_name().c_str(), + ESP_LOGV(TAG, "'%s' - Setting password value: " LOG_SECRET("'%s'"), LOG_STR_ARG(this->parent_->get_log_name()), target_value.c_str()); } else { - ESP_LOGV(TAG, "'%s' - Setting text value: %s", this->parent_->get_name().c_str(), target_value.c_str()); + ESP_LOGV(TAG, "'%s' - Setting text value: %s", LOG_STR_ARG(this->parent_->get_log_name()), target_value.c_str()); } this->parent_->control(target_value); } diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 6ccaf0be029..06da5c21593 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -28,7 +28,7 @@ void TextSensor::publish_state(const char *state, size_t len) { #ifdef USE_TEXT_SENSOR_FILTER this->raw_callback_.call(this->state); #endif - ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->state.c_str()); + ESP_LOGV(TAG, "'%s': Received new state %s", LOG_STR_ARG(this->get_log_name()), this->state.c_str()); this->notify_frontend_(); #ifdef USE_TEXT_SENSOR_FILTER } else { @@ -38,7 +38,7 @@ void TextSensor::publish_state(const char *state, size_t len) { this->raw_state_.assign(state, len); } this->raw_callback_.call(this->raw_state_); - ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state_.c_str()); + ESP_LOGV(TAG, "'%s': Received new state %s", LOG_STR_ARG(this->get_log_name()), this->raw_state_.c_str()); this->filter_list_->input(this->raw_state_); } #endif @@ -95,7 +95,7 @@ void TextSensor::internal_send_state_to_frontend(const char *state, size_t len) void TextSensor::notify_frontend_() { this->set_has_state(true); - ESP_LOGV(TAG, "'%s' >> '%s'", this->name_.c_str(), this->state.c_str()); + ESP_LOGV(TAG, "'%s' >> '%s'", LOG_STR_ARG(this->get_log_name()), this->state.c_str()); this->callback_.call(this->state); #if defined(USE_TEXT_SENSOR) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_text_sensor_update(this); diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 8afd7d284c7..c14fc4db15d 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -108,37 +108,4 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { this->time_sync_callback_.call(); } -// Bits of each calendar byte that hold the value, byte 0 lowest; byte 3 is the one-hot weekday. -// Packed into one constant so it stays in code rather than RAM on ESP8266. -static constexpr uint64_t BCD_CALENDAR_MASKS = 0xFF1F3F7F3F7F7FULL; - -ESPTime decode_bcd_calendar(const uint8_t *raw) { - uint8_t value[7]; - for (size_t i = 0; i < 7; i++) - value[i] = bcd_to_uint8(raw[i] & static_cast(BCD_CALENDAR_MASKS >> (8 * i))); - const uint8_t weekday = raw[3] & static_cast(BCD_CALENDAR_MASKS >> 24); - ESPTime time{ - .second = value[0], - .minute = value[1], - .hour = value[2], - .day_of_week = static_cast(weekday ? __builtin_ctz(weekday) + 1 : 1), - .day_of_month = value[4], - .day_of_year = 1, - .month = value[5], - .year = static_cast(value[6] + 2000), - .is_dst = false, - .timestamp = 0, - }; - time.recalc_timestamp_utc(false); - return time; -} - -void encode_bcd_calendar(const ESPTime &time, uint8_t *raw) { - const uint8_t value[7] = { - time.second, time.minute, time.hour, 0, time.day_of_month, time.month, static_cast(time.year % 100)}; - for (size_t i = 0; i < 7; i++) - raw[i] = uint8_to_bcd(value[i]); - raw[3] = 1 << (time.day_of_week - 1); -} - } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index a87778e470c..8ae0c027cd1 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -44,10 +44,95 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; -/// Decode the 7-byte BCD calendar (second, minute, hour, one-hot weekday, day, month, year since 2000) -/// shared by Epson RTCs such as the RX8025T, RX8111 and RX8130, and fill in the UTC timestamp. -ESPTime decode_bcd_calendar(const uint8_t *raw); -/// Encode @p time into that 7-byte layout; @p time must be valid (is_valid()), with the year 2000 to 2099. -void encode_bcd_calendar(const ESPTime &time, uint8_t *raw); +/// How the weekday byte of a BCD calendar names the day, Sunday first. +enum class BcdWeekday : uint8_t { + ONE_HOT, ///< one bit per day, bit 0 is Sunday + FROM_1, ///< 1 is Sunday + FROM_0, ///< 0 is Sunday +}; + +/// Layout of a 7-byte BCD calendar: second, minute, hour, weekday and day of the month in either order, +/// month and year since 2000. +struct BcdCalendarLayout { + BcdWeekday weekday; + bool weekday_before_day{true}; + /// Bit 7 of the month byte is set from the year 2100 on. Chips without the bit read it as 0. + bool century_bit{false}; + /// Bit 6 of the hour byte selects 12-hour mode, with PM in bit 5; decoded only, encoding writes 24-hour mode. + bool hour_12h{false}; + + constexpr size_t weekday_index() const { return this->weekday_before_day ? 3 : 4; } + constexpr size_t day_index() const { return this->weekday_before_day ? 4 : 3; } +}; + +/// Epson RX8025T, RX8111, RX8130. +inline constexpr BcdCalendarLayout EPSON_BCD_CALENDAR{.weekday = BcdWeekday::ONE_HOT}; +/// DS1307, DS3231. +inline constexpr BcdCalendarLayout DS_BCD_CALENDAR{ + .weekday = BcdWeekday::FROM_1, .century_bit = true, .hour_12h = true}; +/// PCF8563, PCF85063, BM8563. +inline constexpr BcdCalendarLayout PCF_BCD_CALENDAR{.weekday = BcdWeekday::FROM_0, .weekday_before_day = false}; + +/// Decode the 7-byte BCD calendar at @p raw in the given layout and fill in the UTC timestamp. Bits each byte +/// does not use for its value, such as a chip's flag bits, are dropped; day_of_year is not worked out. +/// Kept out of line so that drivers sharing a layout share one copy. +template __attribute__((noinline)) ESPTime decode_bcd_calendar(const uint8_t *raw) { + uint8_t day_of_week; + if constexpr (LAYOUT.weekday == BcdWeekday::ONE_HOT) { + const uint8_t weekday = raw[LAYOUT.weekday_index()] & 0x7F; + day_of_week = weekday ? __builtin_ctz(weekday) + 1 : 1; + } else if constexpr (LAYOUT.weekday == BcdWeekday::FROM_1) { + day_of_week = raw[LAYOUT.weekday_index()] & 0x07; + } else { + day_of_week = (raw[LAYOUT.weekday_index()] & 0x07) + 1; + } + uint8_t hour = bcd_to_uint8(raw[2] & 0x3F); + if constexpr (LAYOUT.hour_12h) { + if ((raw[2] & 0x40) != 0) { + // 12-hour mode, e.g. set by another library: 12 AM reads as 12 with PM clear, 12 PM as 12 with PM set. + hour = bcd_to_uint8(raw[2] & 0x1F) % 12 + ((raw[2] & 0x20) != 0 ? 12 : 0); + } + } + uint16_t year = 2000 + bcd_to_uint8(raw[6]); + if constexpr (LAYOUT.century_bit) { + if ((raw[5] & 0x80) != 0) + year += 100; + } + ESPTime time{ + .second = bcd_to_uint8(raw[0] & 0x7F), + .minute = bcd_to_uint8(raw[1] & 0x7F), + .hour = hour, + .day_of_week = day_of_week, + .day_of_month = bcd_to_uint8(raw[LAYOUT.day_index()] & 0x3F), + .day_of_year = 1, + .month = bcd_to_uint8(raw[5] & 0x1F), + .year = year, + .is_dst = false, + .timestamp = 0, + }; + time.recalc_timestamp_utc(false); + return time; +} + +/// Encode @p time into the 7-byte BCD calendar at @p raw in the given layout, always in 24-hour mode. +/// @p time must be valid (is_valid()); the year must be 2000 to 2099 unless the layout has a century bit. +template +__attribute__((noinline)) void encode_bcd_calendar(const ESPTime &time, uint8_t *raw) { + uint8_t value[7] = {time.second, time.minute, time.hour, 0, 0, time.month, static_cast(time.year % 100)}; + value[LAYOUT.day_index()] = time.day_of_month; + for (size_t i = 0; i < 7; i++) + raw[i] = uint8_to_bcd(value[i]); + if constexpr (LAYOUT.weekday == BcdWeekday::ONE_HOT) { + raw[LAYOUT.weekday_index()] = 1 << (time.day_of_week - 1); + } else if constexpr (LAYOUT.weekday == BcdWeekday::FROM_1) { + raw[LAYOUT.weekday_index()] = time.day_of_week; + } else { + raw[LAYOUT.weekday_index()] = time.day_of_week - 1; + } + if constexpr (LAYOUT.century_bit) { + if (time.year >= 2100) + raw[5] |= 0x80; + } +} } // namespace esphome::time diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 58858b9b386..198cee0ae5f 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -19,7 +19,7 @@ static float regvalue2temp(uint16_t regvalue); void TMP1075Sensor::setup() { uint8_t cfg; if (!this->read_byte(REG_CFGR, &cfg)) { - ESP_LOGE(TAG, "'%s' - unable to read", this->name_.c_str()); + ESP_LOGE(TAG, "'%s' - unable to read", LOG_STR_ARG(this->get_log_name())); this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -30,7 +30,7 @@ void TMP1075Sensor::setup() { void TMP1075Sensor::update() { uint16_t regvalue; if (!this->status_comm_result(this->read_byte_16(REG_TEMP, ®value), LOG_STR("can't read"))) { - ESP_LOGW(TAG, "'%s' - unable to read temperature register", this->name_.c_str()); + ESP_LOGW(TAG, "'%s' - unable to read temperature register", LOG_STR_ARG(this->get_log_name())); return; } @@ -51,20 +51,20 @@ void TMP1075Sensor::dump_config() { " polarity : %d\n" " alert_mode : %d\n" " shutdown : %d", - alert_limit_low_, alert_limit_high_, config_.fields.oneshot, config_.fields.rate, config_.fields.faults, - config_.fields.polarity, config_.fields.alert_mode, config_.fields.shutdown); + this->alert_limit_low_, this->alert_limit_high_, this->config_.oneshot, this->config_.rate, + this->config_.faults, this->config_.polarity, this->config_.alert_mode, this->config_.shutdown); } void TMP1075Sensor::set_fault_count(const int faults) { if (faults < 1) { - ESP_LOGE(TAG, "'%s' - fault_count too low: %d", this->name_.c_str(), faults); + ESP_LOGE(TAG, "'%s' - fault_count too low: %d", LOG_STR_ARG(this->get_log_name()), faults); return; } if (faults > 4) { - ESP_LOGE(TAG, "'%s' - fault_count too high: %d", this->name_.c_str(), faults); + ESP_LOGE(TAG, "'%s' - fault_count too high: %d", LOG_STR_ARG(this->get_log_name()), faults); return; } - config_.fields.faults = faults - 1; + this->config_.faults = faults - 1; } void TMP1075Sensor::log_config_() { @@ -75,39 +75,39 @@ void TMP1075Sensor::log_config_() { " polarity : %d\n" " alert_mode: %d\n" " shutdown : %d", - config_.fields.oneshot, config_.fields.rate, config_.fields.faults, config_.fields.polarity, - config_.fields.alert_mode, config_.fields.shutdown); + this->config_.oneshot, this->config_.rate, this->config_.faults, this->config_.polarity, + this->config_.alert_mode, this->config_.shutdown); } void TMP1075Sensor::write_config() { - send_alert_limit_low_(); - send_alert_limit_high_(); - send_config_(); + this->send_alert_limit_low_(); + this->send_alert_limit_high_(); + this->send_config_(); } void TMP1075Sensor::send_config_() { - ESP_LOGV(TAG, "'%s' - sending configuration %02x", this->name_.c_str(), config_.regvalue); - log_config_(); - if (!this->write_byte(REG_CFGR, config_.regvalue)) { - ESP_LOGW(TAG, "'%s' - unable to write configuration register", this->name_.c_str()); + ESP_LOGV(TAG, "'%s' - sending configuration %02x", LOG_STR_ARG(this->get_log_name()), this->config_.to_byte()); + this->log_config_(); + if (!this->write_byte(REG_CFGR, this->config_.to_byte())) { + ESP_LOGW(TAG, "'%s' - unable to write configuration register", LOG_STR_ARG(this->get_log_name())); return; } } void TMP1075Sensor::send_alert_limit_low_() { - ESP_LOGV(TAG, "'%s' - sending alert limit low %.3f °C", this->name_.c_str(), alert_limit_low_); - const uint16_t regvalue = temp2regvalue(alert_limit_low_); + ESP_LOGV(TAG, "'%s' - sending alert limit low %.3f °C", LOG_STR_ARG(this->get_log_name()), this->alert_limit_low_); + const uint16_t regvalue = temp2regvalue(this->alert_limit_low_); if (!this->write_byte_16(REG_LLIM, regvalue)) { - ESP_LOGW(TAG, "'%s' - unable to write low limit register", this->name_.c_str()); + ESP_LOGW(TAG, "'%s' - unable to write low limit register", LOG_STR_ARG(this->get_log_name())); return; } } void TMP1075Sensor::send_alert_limit_high_() { - ESP_LOGV(TAG, "'%s' - sending alert limit high %.3f °C", this->name_.c_str(), alert_limit_high_); - const uint16_t regvalue = temp2regvalue(alert_limit_high_); + ESP_LOGV(TAG, "'%s' - sending alert limit high %.3f °C", LOG_STR_ARG(this->get_log_name()), this->alert_limit_high_); + const uint16_t regvalue = temp2regvalue(this->alert_limit_high_); if (!this->write_byte_16(REG_HLIM, regvalue)) { - ESP_LOGW(TAG, "'%s' - unable to write high limit register", this->name_.c_str()); + ESP_LOGW(TAG, "'%s' - unable to write high limit register", LOG_STR_ARG(this->get_log_name())); return; } } diff --git a/esphome/components/tmp1075/tmp1075.h b/esphome/components/tmp1075/tmp1075.h index 519d48ad29d..c2efb7550ef 100644 --- a/esphome/components/tmp1075/tmp1075.h +++ b/esphome/components/tmp1075/tmp1075.h @@ -7,37 +7,38 @@ namespace esphome::tmp1075 { struct TMP1075Config { - union { - struct { - uint8_t oneshot : 1; // One-shot conversion mode. Writing 1, starts a single temperature - // conversion. Read returns 0. + bool oneshot{false}; // One-shot conversion mode. Writing 1, starts a single temperature + // conversion. Read returns 0. - uint8_t rate : 2; // Conversion rate setting when device is in continuous conversion mode. - // 00: 27.5 ms conversion rate - // 01: 55 ms conversion rate - // 10: 110 ms conversion rate - // 11: 220 ms conversion rate (35 ms TMP1075N) + uint8_t rate{0}; // Conversion rate setting when device is in continuous conversion mode. + // 00: 27.5 ms conversion rate + // 01: 55 ms conversion rate + // 10: 110 ms conversion rate + // 11: 220 ms conversion rate (35 ms TMP1075N) - uint8_t faults : 2; // Consecutive fault measurements to trigger the alert function. - // 00: 1 fault - // 01: 2 faults - // 10: 3 faults (4 faults TMP1075N) - // 11: 4 faults (6 faults TMP1075N) + uint8_t faults{0}; // Consecutive fault measurements to trigger the alert function. + // 00: 1 fault + // 01: 2 faults + // 10: 3 faults (4 faults TMP1075N) + // 11: 4 faults (6 faults TMP1075N) - uint8_t polarity : 1; // Polarity of the output pin. - // 0: Active low ALERT pin - // 1: Active high ALERT pin + bool polarity{false}; // Polarity of the output pin. + // 0: Active low ALERT pin + // 1: Active high ALERT pin - uint8_t alert_mode : 1; // Selects the function of the ALERT pin. - // 0: ALERT pin functions in comparator mode - // 1: ALERT pin functions in interrupt mode + bool alert_mode{false}; // Selects the function of the ALERT pin. + // 0: ALERT pin functions in comparator mode + // 1: ALERT pin functions in interrupt mode - uint8_t shutdown : 1; // Sets the device in shutdown mode to conserve power. - // 0: Device is in continuous conversion - // 1: Device is in shutdown mode - } fields; - uint8_t regvalue; - }; + bool shutdown{false}; // Sets the device in shutdown mode to conserve power. + // 0: Device is in continuous conversion + // 1: Device is in shutdown mode + + // Upper byte of the configuration register: OS R1 R0 F1 F0 POL TM SD + uint8_t to_byte() const { + return (this->oneshot << 7) | ((this->rate & 0x03) << 5) | ((this->faults & 0x03) << 3) | (this->polarity << 2) | + (this->alert_mode << 1) | this->shutdown; + } }; enum EConversionRate { @@ -63,10 +64,10 @@ class TMP1075Sensor final : public PollingComponent, public sensor::Sensor, publ // the IC. The setup() function also does this. void set_alert_limit_low(const float temp) { this->alert_limit_low_ = temp; } void set_alert_limit_high(const float temp) { this->alert_limit_high_ = temp; } - void set_oneshot(const bool oneshot) { config_.fields.oneshot = oneshot; } - void set_conversion_rate(const enum EConversionRate rate) { config_.fields.rate = rate; } - void set_alert_polarity(const bool polarity) { config_.fields.polarity = polarity; } - void set_alert_function(const enum EAlertFunction function) { config_.fields.alert_mode = function; } + void set_oneshot(const bool oneshot) { this->config_.oneshot = oneshot; } + void set_conversion_rate(const enum EConversionRate rate) { this->config_.rate = rate; } + void set_alert_polarity(const bool polarity) { this->config_.polarity = polarity; } + void set_alert_function(const enum EAlertFunction function) { this->config_.alert_mode = function; } void set_fault_count(int faults); void write_config(); diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index d847c45bfb9..e5370234cd9 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -121,7 +121,6 @@ HOST_BAUD_RATES = [ ] UARTDevice = uart_ns.class_("UARTDevice") -UARTWriteAction = uart_ns.class_("UARTWriteAction", automation.Action) UARTDebugger = uart_ns.class_("UARTDebugger", cg.Component, automation.Action) UARTDummyReceiver = uart_ns.class_("UARTDummyReceiver", cg.Component) MULTI_CONF = True @@ -593,9 +592,8 @@ def new_uart_component( return cg.new_bus_component(register_uart_device, config, *args) -@automation.register_action( +automation.register_apply_action( "uart.write", - UARTWriteAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(UARTComponent), @@ -603,15 +601,11 @@ def new_uart_component( }, key=CONF_DATA, ), - synchronous=True, + # A constant stays in flash: write_array_progmem copies it out in chunks on ESP8266 + automation.ApplyBytes( + CONF_DATA, "write_array({})", progmem_target="write_array_progmem({})" + ), ) -async def uart_write_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - await automation.templatable_bytes( - config[CONF_DATA], args, var.set_data_template, var.set_data_static, "uart_data" - ) - return var @coroutine_with_priority(CoroPriority.FINAL) diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h deleted file mode 100644 index b134c8af9f6..00000000000 --- a/esphome/components/uart/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "uart.h" -#include "esphome/core/automation.h" - -#include - -namespace esphome::uart { - -template class UARTWriteAction final : public Action, public Parented { - TEMPLATABLE_BYTES(data) - - void play(const Ts &...x) override { - if (this->data_.is_static()) { - this->parent_->write_array_progmem(this->data_.data(), this->data_.size()); - } else { - this->parent_->write_array(this->data_.value(x...)); - } - } -}; - -} // namespace esphome::uart diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index 99b5ee548d0..07cc75bfdf8 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -6,7 +6,7 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart.button"); void UARTButton::press_action() { - ESP_LOGD(TAG, "'%s': Sending data", this->get_name().c_str()); + ESP_LOGD(TAG, "'%s': Sending data", LOG_STR_ARG(this->get_log_name())); this->write_array_progmem(this->data_, this->data_len_); } diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 1af497ffcfd..c078e023461 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -18,11 +18,11 @@ void UARTSwitch::loop() { void UARTSwitch::write_command_(bool state) { if (state && this->data_on_len_ != 0) { - ESP_LOGD(TAG, "'%s': Sending on data", this->get_name().c_str()); + ESP_LOGD(TAG, "'%s': Sending on data", LOG_STR_ARG(this->get_log_name())); this->write_array_progmem(this->data_on_, this->data_on_len_); } if (!state && this->data_off_len_ != 0) { - ESP_LOGD(TAG, "'%s': Sending off data", this->get_name().c_str()); + ESP_LOGD(TAG, "'%s': Sending off data", LOG_STR_ARG(this->get_log_name())); this->write_array_progmem(this->data_off_, this->data_off_len_); } } diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index a654172507b..d9a01335433 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -29,7 +29,6 @@ DOMAIN = "udp" MULTI_CONF = True udp_ns = cg.esphome_ns.namespace("udp") UDPComponent = udp_ns.class_("UDPComponent", cg.Component) -UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action) trigger_argname = "data" # Listener callback type (non-owning span from UDP component) listener_args = cg.std_span.template(cg.uint8.operator("const")) @@ -240,17 +239,23 @@ def validate_raw_data(value: Any) -> bytes | list[int]: ) -@automation.register_action( +_UDP_WRITE_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(UDPComponent), + cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + }, + key=CONF_DATA, +) +_udp_write_builder = automation.apply_action_builder( "udp.write", - UDPWriteAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(UDPComponent), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), - }, - key=CONF_DATA, - ), - synchronous=True, + _UDP_WRITE_SCHEMA, + # Constant payloads up to this size are sent from the stack on ESP8266 + automation.ApplyBytes(CONF_DATA, "send_packet({})", stack=128), +) + + +@automation.register_action( + "udp.write", automation.ApplyAction, _UDP_WRITE_SCHEMA, synchronous=True ) async def udp_write_to_code( config: ConfigType, @@ -258,11 +263,6 @@ async def udp_write_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) udp_var = await cg.get_variable(config[CONF_ID]) - await cg.register_parented(var, udp_var) cg.add(udp_var.set_should_broadcast()) - await automation.templatable_bytes( - config[CONF_DATA], args, var.set_data_template, var.set_data_static, "udp_data" - ) - return var + return await _udp_write_builder(config, action_id, template_arg, args) diff --git a/esphome/components/udp/automation.h b/esphome/components/udp/automation.h deleted file mode 100644 index 0936aa3ab50..00000000000 --- a/esphome/components/udp/automation.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "udp_component.h" -#ifdef USE_NETWORK -#include "esphome/core/automation.h" - -namespace esphome::udp { - -/// Constant payloads up to this size are sent from the stack on ESP8266. -static constexpr size_t STACK_PAYLOAD_SIZE = 128; - -template class UDPWriteAction final : public Action, public Parented { - TEMPLATABLE_BYTES(data) - - void play(const Ts &...x) override { - // One sendto per packet, so a failed allocation never sends a partial packet - this->data_.template visit( - [this](const uint8_t *data, size_t len) { this->parent_->send_packet(data, len); }, x...); - } -}; - -} // namespace esphome::udp - -#endif diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.cpp b/esphome/components/ultrasonic/ultrasonic_sensor.cpp index 039d08eaae7..c119c91d9e5 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.cpp +++ b/esphome/components/ultrasonic/ultrasonic_sensor.cpp @@ -60,7 +60,7 @@ void UltrasonicSensorComponent::loop() { if (!this->store_.echo_start) { uint32_t elapsed = micros() - this->measurement_start_us_; if (elapsed >= START_TIMEOUT_US) { - ESP_LOGW(TAG, "'%s' - Measurement start timed out", this->name_.c_str()); + ESP_LOGW(TAG, "'%s' - Measurement start timed out", LOG_STR_ARG(this->get_log_name())); this->publish_state(NAN); this->measurement_pending_ = false; return; @@ -73,7 +73,8 @@ void UltrasonicSensorComponent::loop() { elapsed = micros() - this->store_.echo_start_us; } if (elapsed >= this->timeout_us_) { - ESP_LOGD(TAG, "'%s' - Measurement pulse timed out after %" PRIu32 "us", this->name_.c_str(), elapsed); + ESP_LOGD(TAG, "'%s' - Measurement pulse timed out after %" PRIu32 "us", LOG_STR_ARG(this->get_log_name()), + elapsed); this->publish_state(NAN); this->measurement_pending_ = false; return; @@ -84,7 +85,7 @@ void UltrasonicSensorComponent::loop() { uint32_t pulse_duration = this->store_.echo_end_us - this->store_.echo_start_us; ESP_LOGV(TAG, "Echo took %" PRIu32 "us", pulse_duration); float result = UltrasonicSensorComponent::us_to_m(pulse_duration); - ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", this->name_.c_str(), result); + ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", LOG_STR_ARG(this->get_log_name()), result); this->publish_state(result); this->measurement_pending_ = false; return; diff --git a/esphome/components/unicode/unicode.cpp b/esphome/components/unicode/unicode.cpp index f5b4084fe09..699f2e6dfbe 100644 --- a/esphome/components/unicode/unicode.cpp +++ b/esphome/components/unicode/unicode.cpp @@ -2,95 +2,57 @@ namespace esphome::unicode { -uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { - // Safely cast to uint8_t* for correct bitwise operations on bytes - const uint8_t *current = reinterpret_cast(utf8_str); - uint32_t code_point = 0; - uint8_t c1 = *current++; +// Reads count continuation bytes into code_point, stopping at the first byte that is not one so a +// sequence cut short by the terminator is never read past +static bool read_continuation(const uint8_t *¤t, uint8_t count, uint32_t &code_point) { + for (uint8_t i = 0; i < count; i++) { + const uint8_t c = *current; + if ((c & 0xC0) != 0x80) { + return false; + } + current++; + code_point = (code_point << 6) | (c & 0x3F); + } + return true; +} - // check for end of string +uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { + const auto *start = reinterpret_cast(utf8_str); + const uint8_t *current = start; + const uint8_t c1 = *current++; + uint8_t count; + uint32_t code_point; + uint32_t lowest; // the smallest code point this sequence length may encode; shorter forms are overlong if (c1 == 0) { *length = 0; return 0; } - - // --- 1-Byte Sequence: 0xxxxxxx (ASCII) --- if (c1 < 0x80) { - // Valid ASCII byte. + count = 0; code_point = c1; - // Optimization: No need to check for continuation bytes. + lowest = 0; + } else if ((c1 & 0xE0) == 0xC0) { + count = 1; + code_point = c1 & 0x1F; + lowest = 0x80; + } else if ((c1 & 0xF0) == 0xE0) { + count = 2; + code_point = c1 & 0x0F; + lowest = 0x800; + } else if ((c1 & 0xF8) == 0xF0) { + count = 3; + code_point = c1 & 0x07; + lowest = 0x10000; + } else { + *length = 0; // a continuation byte or an invalid lead byte + return 0; } - // --- 2-Byte Sequence: 110xxxxx 10xxxxxx --- - else if ((c1 & 0xE0) == 0xC0) { - uint8_t c2 = *current++; - - // Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx) - if ((c2 & 0xC0) != 0x80) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x1F) << 6; - code_point |= (c2 & 0x3F); - - // Error Check 2: Overlong check (2-byte must be > 0x7F) - if (code_point <= 0x7F) { - *length = 0; - return 0; - } - } - // --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx --- - else if ((c1 & 0xF0) == 0xE0) { - uint8_t c2 = *current++; - uint8_t c3 = *current++; - - // Error Check 1: Check continuation bytes - if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x0F) << 12; - code_point |= (c2 & 0x3F) << 6; - code_point |= (c3 & 0x3F); - - // Error Check 2: Overlong check (3-byte must be > 0x7FF) - // Also check for surrogates (0xD800-0xDFFF) - if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) { - *length = 0; - return 0; - } - } - // --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx --- - else if ((c1 & 0xF8) == 0xF0) { - uint8_t c2 = *current++; - uint8_t c3 = *current++; - uint8_t c4 = *current++; - - // Error Check 1: Check continuation bytes - if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x07) << 18; - code_point |= (c2 & 0x3F) << 12; - code_point |= (c3 & 0x3F) << 6; - code_point |= (c4 & 0x3F); - - // Error Check 2: Overlong check (4-byte must be > 0xFFFF) - // Also check for valid Unicode range (must be <= 0x10FFFF) - if (code_point <= 0xFFFF || code_point > 0x10FFFF) { - *length = 0; - return 0; - } - } - // --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) --- - else { + if (!read_continuation(current, count, code_point) || code_point < lowest || code_point > 0x10FFFF || + (code_point >= 0xD800 && code_point <= 0xDFFF)) { *length = 0; return 0; } - *length = current - reinterpret_cast(utf8_str); + *length = current - start; return code_point; } diff --git a/esphome/components/update/update_entity.cpp b/esphome/components/update/update_entity.cpp index 33c33ccb621..89608c797d6 100644 --- a/esphome/components/update/update_entity.cpp +++ b/esphome/components/update/update_entity.cpp @@ -20,7 +20,7 @@ void UpdateEntity::publish_state() { ESP_LOGV(TAG, "'%s' >>\n" " Current Version: %s", - this->name_.c_str(), this->update_info_.current_version.c_str()); + LOG_STR_ARG(this->get_log_name()), this->update_info_.current_version.c_str()); if (!this->update_info_.md5.empty()) { ESP_LOGV(TAG, " Latest Version: %s", this->update_info_.latest_version.c_str()); diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 07a5cae0ae5..20b8567beaf 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( get_esp32_variant, idf_version, only_on_variant, + require_idf_version, ) import esphome.config_validation as cv from esphome.const import CONF_DEVICES, CONF_FRAMEWORK, CONF_ID @@ -201,10 +202,9 @@ def fifo_split() -> tuple[int, int, int] | None: def _reconcile_fifo() -> None: if (split := fifo_split()) is None: return - if idf_version() < FIFO_CUSTOM_MIN_IDF: - raise cv.Invalid( - f"Sizing the USB FIFO on the {get_esp32_variant()} needs ESP-IDF {FIFO_CUSTOM_MIN_IDF} or later" - ) + require_idf_version( + FIFO_CUSTOM_MIN_IDF, f"Sizing the USB FIFO on the {get_esp32_variant()}" + )(None) rx, nptx, ptx = split cg.add_define("USE_USB_HOST_FIFO_CONFIG") cg.add_define("USB_HOST_FIFO_RX_LINES", rx) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 213ff54afb1..d0874e4b6cc 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant @@ -154,6 +156,19 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( ) +def _channel_limit(type_: Type) -> Callable[[list], list]: + """Check the channel count against the type's limit, which depends on the target.""" + + def validator(value: list) -> list: + if len(value) > (limit := type_.max_channels): + raise cv.Invalid( + f"Device type {type_.name} supports a maximum of {limit} channels" + ) + return value + + return validator + + def channel_schema(type_: "Type") -> cv.Schema: schema = cv.Schema( { @@ -192,10 +207,7 @@ def channel_schema(type_: "Type") -> cv.Schema: } ) ), - cv.Length( - max=type_.max_channels, - msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", - ), + _channel_limit(type_), ), } ) diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index a5ad9817df7..fdee764936f 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -42,7 +42,7 @@ ValveCall &ValveCall::set_command(const char *command) { } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("TOGGLE")) == 0) { this->set_command_toggle(); } else { - ESP_LOGW(TAG, "'%s' - Unrecognized command %s", this->parent_->get_name().c_str(), command); + ESP_LOGW(TAG, "'%s' - Unrecognized command %s", LOG_STR_ARG(this->parent_->get_log_name()), command); } return *this; } @@ -67,7 +67,7 @@ ValveCall &ValveCall::set_position(float position) { return *this; } void ValveCall::perform() { - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); auto traits = this->parent_->get_traits(); this->validate_(); if (this->stop_) { @@ -92,16 +92,18 @@ void ValveCall::validate_() { if (this->position_.has_value()) { auto pos = *this->position_; if (!traits.get_supports_position() && pos != VALVE_OPEN && pos != VALVE_CLOSED) { - ESP_LOGW(TAG, "'%s' - This valve device does not support setting position!", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s' - This valve device does not support setting position!", + LOG_STR_ARG(this->parent_->get_log_name())); this->position_.reset(); } else if (pos < 0.0f || pos > 1.0f) { - ESP_LOGW(TAG, "'%s' - Position %.2f is out of range [0.0 - 1.0]", this->parent_->get_name().c_str(), pos); + ESP_LOGW(TAG, "'%s' - Position %.2f is out of range [0.0 - 1.0]", LOG_STR_ARG(this->parent_->get_log_name()), + pos); this->position_ = clamp(pos, 0.0f, 1.0f); } } if (this->toggle_.has_value()) { if (!traits.get_supports_toggle()) { - ESP_LOGW(TAG, "'%s' - This valve device does not support toggle!", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s' - This valve device does not support toggle!", LOG_STR_ARG(this->parent_->get_log_name())); this->toggle_.reset(); } } @@ -123,7 +125,7 @@ ValveCall &ValveCall::set_stop(bool stop) { void Valve::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); - ESP_LOGV(TAG, "'%s' >>", this->name_.c_str()); + ESP_LOGV(TAG, "'%s' >>", LOG_STR_ARG(this->get_log_name())); auto traits = this->get_traits(); if (traits.get_supports_position()) { ESP_LOGV(TAG, " Position: %.0f%%", this->position * 100.0f); diff --git a/esphome/components/valve/valve_traits.h b/esphome/components/valve/valve_traits.h index 81b845a5f20..5b13ebc24bd 100644 --- a/esphome/components/valve/valve_traits.h +++ b/esphome/components/valve/valve_traits.h @@ -1,25 +1,31 @@ #pragma once +#include + namespace esphome::valve { class ValveTraits { public: ValveTraits() = default; - bool get_is_assumed_state() const { return this->is_assumed_state_; } - void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; } - bool get_supports_position() const { return this->supports_position_; } - void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; } - bool get_supports_toggle() const { return this->supports_toggle_; } - void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; } - bool get_supports_stop() const { return this->supports_stop_; } - void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; } + bool get_is_assumed_state() const { return this->flags_.is_assumed_state; } + void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; } + bool get_supports_position() const { return this->flags_.supports_position; } + void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; } + bool get_supports_toggle() const { return this->flags_.supports_toggle; } + void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; } + bool get_supports_stop() const { return this->flags_.supports_stop; } + void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; } protected: - bool is_assumed_state_{false}; - bool supports_position_{false}; - bool supports_toggle_{false}; - bool supports_stop_{false}; + // Bit-packed flags to save memory (1 byte instead of 4) + struct ValveTraitFlags { + uint8_t is_assumed_state : 1; + uint8_t supports_position : 1; + uint8_t supports_toggle : 1; + uint8_t supports_stop : 1; + uint8_t reserved : 4; + } flags_{}; }; } // namespace esphome::valve diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 1b8984247a4..3064bede4da 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -45,7 +45,7 @@ bool VEML3235Sensor::refresh_config_reg() { void VEML3235Sensor::update() { if (this->measurement_in_progress_) { - ESP_LOGV(TAG, "'%s': Previous measurement still in progress; skipping update", this->get_name().c_str()); + ESP_LOGV(TAG, "'%s': Previous measurement still in progress; skipping update", LOG_STR_ARG(this->get_log_name())); return; } this->measurement_in_progress_ = true; @@ -75,9 +75,9 @@ void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { } float lux = this->counts_to_lux_(als_counts); - ESP_LOGVV(TAG, "'%s': ALS counts = %u, sensitivity = %ux", this->get_name().c_str(), als_counts, + ESP_LOGVV(TAG, "'%s': ALS counts = %u, sensitivity = %ux", LOG_STR_ARG(this->get_log_name()), als_counts, this->sensitivity_factor_()); - ESP_LOGV(TAG, "'%s': Illuminance = %.4flx", this->get_name().c_str(), lux); + ESP_LOGV(TAG, "'%s': Illuminance = %.4flx", LOG_STR_ARG(this->get_log_name()), lux); this->publish_state(lux); this->measurement_in_progress_ = false; } @@ -172,7 +172,7 @@ bool VEML3235Sensor::adjust_sensitivity_(uint16_t counts) { return false; } - ESP_LOGV(TAG, "'%s': Sensitivity adjusted from %ux to %ux (ALS counts = %u)", this->get_name().c_str(), + ESP_LOGV(TAG, "'%s': Sensitivity adjusted from %ux to %ux (ALS counts = %u)", LOG_STR_ARG(this->get_log_name()), current_factor, new_factor, counts); return true; } diff --git a/esphome/components/veml6040/__init__.py b/esphome/components/veml6040/__init__.py new file mode 100644 index 00000000000..bd7aa9649a4 --- /dev/null +++ b/esphome/components/veml6040/__init__.py @@ -0,0 +1 @@ +DOMAIN = "veml6040" diff --git a/esphome/components/veml6040/sensor.py b/esphome/components/veml6040/sensor.py new file mode 100644 index 00000000000..02d6df9affd --- /dev/null +++ b/esphome/components/veml6040/sensor.py @@ -0,0 +1,98 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ILLUMINANCE, + CONF_INTEGRATION_TIME, + DEVICE_CLASS_ILLUMINANCE, + ICON_LIGHTBULB, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@aalaei"] +DEPENDENCIES = ["i2c"] + +CONF_COLOR_TEMPERATURE_OFFSET = "color_temperature_offset" +CONF_WHITE_CHANNEL = "white_channel" + +veml6040_ns = cg.esphome_ns.namespace("veml6040") +VEML6040Component = veml6040_ns.class_( + "VEML6040Component", cg.PollingComponent, i2c.I2CDevice +) + +VEML6040IntegrationTime = veml6040_ns.enum("VEML6040IntegrationTime") +VEML6040_INTEGRATION_TIMES = { + "40ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_40MS, + "80ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_80MS, + "160ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_160MS, + "320ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_320MS, + "640ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_640MS, + "1280ms": VEML6040IntegrationTime.VEML6040_INTEGRATION_TIME_1280MS, +} + +color_channel_schema = sensor.sensor_schema( + icon=ICON_LIGHTBULB, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(VEML6040Component), + cv.Optional(CONF_RED_CHANNEL): color_channel_schema, + cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema, + cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema, + cv.Optional(CONF_WHITE_CHANNEL): color_channel_schema, + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_THERMOMETER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_INTEGRATION_TIME, default="160ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.enum(VEML6040_INTEGRATION_TIMES, lower=True), + ), + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range( + min=1.0 + ), + cv.Optional(CONF_COLOR_TEMPERATURE_OFFSET, default=0.5): cv.float_, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x10)) +) + + +async def to_code(config: ConfigType) -> None: + var = await i2c.new_i2c_component( + config, + config[CONF_INTEGRATION_TIME], + config[CONF_GLASS_ATTENUATION_FACTOR], + config[CONF_COLOR_TEMPERATURE_OFFSET], + ) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, var.set_red_sensor) + await sensors(CONF_GREEN_CHANNEL, var.set_green_sensor) + await sensors(CONF_BLUE_CHANNEL, var.set_blue_sensor) + await sensors(CONF_WHITE_CHANNEL, var.set_white_sensor) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, var.set_color_temperature_sensor) diff --git a/esphome/components/veml6040/veml6040.cpp b/esphome/components/veml6040/veml6040.cpp new file mode 100644 index 00000000000..f4c3819de90 --- /dev/null +++ b/esphome/components/veml6040/veml6040.cpp @@ -0,0 +1,120 @@ +#include "veml6040.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::veml6040 { + +ESPHOME_LOG_TAG(TAG, "veml6040"); + +static constexpr uint8_t CONFIG_REG = 0x00; +static constexpr uint8_t R_DATA_REG = 0x08; +static constexpr uint8_t G_DATA_REG = 0x09; +static constexpr uint8_t B_DATA_REG = 0x0A; +static constexpr uint8_t W_DATA_REG = 0x0B; + +static constexpr uint8_t CONFIG_REG_IT_BIT = 4; + +static constexpr uint16_t MAX_COUNTS = 0xFFFF; + +// Lux per green count at 40 ms integration time; halves for each longer integration time step +static constexpr float LUX_PER_COUNT_40MS = 0.25168f; + +// Correlated colour temperature fit from the Vishay application note "Designing the VEML6040 into an +// Application": CCT = A * ((R - B) / G + offset) ^ EXPONENT. The note gives offset = 0.5 in open air and says it +// changes behind cover glass, so it is configurable. +static constexpr float CCT_A = 4278.6f; +static constexpr float CCT_EXPONENT = -1.2455f; + +void VEML6040Component::setup() { + // Auto mode (AF = 0, TRIG = 0) with shutdown bit cleared: the device measures continuously. + // The high byte of the configuration register is reserved and must be written as zero. + const uint8_t config[] = {static_cast(this->integration_time_ << CONFIG_REG_IT_BIT), 0x00}; + if (this->write_register(CONFIG_REG, config, sizeof config) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + // The data registers hold zeros until the first integration period has finished; publish as soon as it has + this->set_timeout(this->integration_time_ms_(), [this]() { + this->data_ready_ = true; + this->update(); + }); +} + +void VEML6040Component::dump_config() { + ESP_LOGCONFIG(TAG, + "VEML6040:\n" + " Integration time: %" PRIu32 " ms\n" + " Glass attenuation factor: %.2f\n" + " Color temperature offset: %.3f", + this->integration_time_ms_(), this->glass_attenuation_factor_, this->color_temperature_offset_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Red channel", this->red_sensor_); + LOG_SENSOR(" ", "Green channel", this->green_sensor_); + LOG_SENSOR(" ", "Blue channel", this->blue_sensor_); + LOG_SENSOR(" ", "White channel", this->white_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Color temperature", this->color_temperature_sensor_); +} + +bool VEML6040Component::read_channel_(uint8_t reg, uint16_t &counts) { + uint8_t data[2]; + if (this->read_register(reg, data, sizeof data) != i2c::ERROR_OK) + return false; + counts = encode_uint16(data[1], data[0]); + return true; +} + +void VEML6040Component::update() { + if (!this->data_ready_) + return; + + uint16_t red, green, blue, white; + if (!this->read_channel_(R_DATA_REG, red) || !this->read_channel_(G_DATA_REG, green) || + !this->read_channel_(B_DATA_REG, blue) || !this->read_channel_(W_DATA_REG, white)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + ESP_LOGV(TAG, "Counts: red=%u green=%u blue=%u white=%u", red, green, blue, white); + + if (this->red_sensor_ != nullptr) + this->red_sensor_->publish_state(red); + if (this->green_sensor_ != nullptr) + this->green_sensor_->publish_state(green); + if (this->blue_sensor_ != nullptr) + this->blue_sensor_->publish_state(blue); + if (this->white_sensor_ != nullptr) + this->white_sensor_->publish_state(white); + + const bool saturated = red == MAX_COUNTS || green == MAX_COUNTS || blue == MAX_COUNTS; + if (saturated) { + ESP_LOGW(TAG, "Sensor saturated; use a shorter integration time"); + } + + if (this->illuminance_sensor_ != nullptr) { + float lux = NAN; + if (green != MAX_COUNTS) { + const float lux_per_count = LUX_PER_COUNT_40MS / static_cast(1u << this->integration_time_); + lux = green * lux_per_count * this->glass_attenuation_factor_; + } + this->illuminance_sensor_->publish_state(lux); + } + + if (this->color_temperature_sensor_ != nullptr) { + float cct = NAN; + if (!saturated && green != 0) { + const float index = + (static_cast(red) - static_cast(blue)) / green + this->color_temperature_offset_; + if (index > 0.0f) + cct = CCT_A * powf(index, CCT_EXPONENT); + } + this->color_temperature_sensor_->publish_state(cct); + } +} + +} // namespace esphome::veml6040 diff --git a/esphome/components/veml6040/veml6040.h b/esphome/components/veml6040/veml6040.h new file mode 100644 index 00000000000..971ce24c684 --- /dev/null +++ b/esphome/components/veml6040/veml6040.h @@ -0,0 +1,55 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::veml6040 { + +// Register values for the IT bits of the configuration register, taken from the VEML6040 datasheet. +// Each step doubles the integration time and halves the lux per count. +enum VEML6040IntegrationTime : uint8_t { + VEML6040_INTEGRATION_TIME_40MS = 0b000, + VEML6040_INTEGRATION_TIME_80MS = 0b001, + VEML6040_INTEGRATION_TIME_160MS = 0b010, + VEML6040_INTEGRATION_TIME_320MS = 0b011, + VEML6040_INTEGRATION_TIME_640MS = 0b100, + VEML6040_INTEGRATION_TIME_1280MS = 0b101, +}; + +class VEML6040Component final : public PollingComponent, public i2c::I2CDevice { + public: + VEML6040Component(VEML6040IntegrationTime integration_time, float glass_attenuation_factor, + float color_temperature_offset) + : glass_attenuation_factor_(glass_attenuation_factor), + color_temperature_offset_(color_temperature_offset), + integration_time_(integration_time) {} + + void setup() override; + void dump_config() override; + void update() override; + + void set_red_sensor(sensor::Sensor *sensor) { this->red_sensor_ = sensor; } + void set_green_sensor(sensor::Sensor *sensor) { this->green_sensor_ = sensor; } + void set_blue_sensor(sensor::Sensor *sensor) { this->blue_sensor_ = sensor; } + void set_white_sensor(sensor::Sensor *sensor) { this->white_sensor_ = sensor; } + void set_illuminance_sensor(sensor::Sensor *sensor) { this->illuminance_sensor_ = sensor; } + void set_color_temperature_sensor(sensor::Sensor *sensor) { this->color_temperature_sensor_ = sensor; } + + protected: + bool read_channel_(uint8_t reg, uint16_t &counts); + uint32_t integration_time_ms_() const { return 40u << this->integration_time_; } + + sensor::Sensor *red_sensor_{nullptr}; + sensor::Sensor *green_sensor_{nullptr}; + sensor::Sensor *blue_sensor_{nullptr}; + sensor::Sensor *white_sensor_{nullptr}; + sensor::Sensor *illuminance_sensor_{nullptr}; + sensor::Sensor *color_temperature_sensor_{nullptr}; + const float glass_attenuation_factor_; + const float color_temperature_offset_; + const VEML6040IntegrationTime integration_time_; + bool data_ready_{false}; +}; + +} // namespace esphome::veml6040 diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 2e3d1c85299..fdd3c063f1e 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -92,7 +92,7 @@ void VL53L0XSensor::setup() { uint32_t timeout_start_us = micros(); while (reg(0x83).get() == 0x00) { if (this->timeout_us_ > 0 && (micros() - timeout_start_us > this->timeout_us_)) { - ESP_LOGE(TAG, "'%s' - setup timeout", this->name_.c_str()); + ESP_LOGE(TAG, "'%s' - setup timeout", LOG_STR_ARG(this->get_log_name())); this->mark_failed(); return; } @@ -267,7 +267,7 @@ void VL53L0XSensor::update() { this->publish_state(NAN); this->status_momentary_warning(5000); ESP_LOGW(TAG, "%s - update called before prior reading complete - initiated:%d waiting_for_interrupt:%d", - this->name_.c_str(), this->initiated_read_, this->waiting_for_interrupt_); + LOG_STR_ARG(this->get_log_name()), this->initiated_read_, this->waiting_for_interrupt_); return; } @@ -306,13 +306,14 @@ void VL53L0XSensor::loop() { this->waiting_for_interrupt_ = false; if (range_mm >= 8190) { - ESP_LOGD(TAG, "'%s' - Distance is out of range, please move the target closer", this->name_.c_str()); + ESP_LOGD(TAG, "'%s' - Distance is out of range, please move the target closer", + LOG_STR_ARG(this->get_log_name())); this->publish_state(NAN); return; } float range_m = range_mm / 1e3f; - ESP_LOGD(TAG, "'%s' - Got distance %.3f m", this->name_.c_str(), range_m); + ESP_LOGD(TAG, "'%s' - Got distance %.3f m", LOG_STR_ARG(this->get_log_name()), range_m); this->publish_state(range_m); } } diff --git a/esphome/components/vnc/__init__.py b/esphome/components/vnc/__init__.py new file mode 100644 index 00000000000..f09387ce2a8 --- /dev/null +++ b/esphome/components/vnc/__init__.py @@ -0,0 +1,138 @@ +"""VNC (RFB) client that shows a remote screen on a display.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import display, network, socket +from esphome.components.const import ( + BYTE_ORDER_LITTLE, + CONF_HOST, + CONF_RECONNECT_INTERVAL, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, + CONF_DISPLAY_ID, + CONF_ID, + CONF_ON_CONNECT, + CONF_ON_DISCONNECT, + CONF_PLATFORM, + CONF_PORT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@MindFreeze"] +DEPENDENCIES = ["display", "network"] +AUTO_LOAD = ["socket"] +DOMAIN = "vnc" +MULTI_CONF = True + +CONF_MAX_UPDATE_DELAY = "max_update_delay" +CONF_UPDATE_DELAY = "update_delay" + +vnc_ns = cg.esphome_ns.namespace("vnc") +VncClient = vnc_ns.class_("VncClient", cg.Component) + +# Drivers that cannot take another component's pixels yet. +UNSUPPORTED_DISPLAYS = dict.fromkeys( + ("mipi_dsi", "mipi_rgb", "mipi_spi"), "it is not supported by vnc yet" +) | { + "waveshare_epaper": "it draws every colour except pure black as ink; use epaper_spi" +} + + +def _register_writer(config: ConfigType) -> ConfigType: + display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(VncClient), + cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display), + cv.Required(CONF_HOST): socket.ipv4_host, + cv.Optional(CONF_PORT, default=5900): cv.port, + cv.Optional(CONF_RECONNECT_INTERVAL, default="5s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=cv.TimePeriod(seconds=1)), + ), + cv.Optional( + CONF_UPDATE_DELAY, default="200ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_MAX_UPDATE_DELAY, default="1s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}), + } + ).extend(cv.COMPONENT_SCHEMA), + _register_writer, + socket.consume_sockets(1, DOMAIN), + network.require_ipv4(DOMAIN), +) + + +def _final_validate(config: ConfigType) -> None: + full_config = fv.full_config.get() + disp_id = config[CONF_DISPLAY_ID] + disp_conf = fv.get_declaring_config(disp_id) + + if (platform := disp_conf.get(CONF_PLATFORM)) in UNSUPPORTED_DISPLAYS: + raise cv.Invalid( + f"Display '{disp_id}' uses the {platform} platform: " + f"{UNSUPPORTED_DISPLAYS[platform]}", + path=[CONF_DISPLAY_ID], + ) + if ( + display.get_display_metadata(disp_id).has_writer + or disp_conf.get(CONF_AUTO_CLEAR_ENABLED) is True + ): + raise cv.Invalid( + f"vnc draws the whole of display '{disp_id}'; remove lambda, pages, " + "auto_clear_enabled and show_test_card from it", + path=[CONF_DISPLAY_ID], + ) + if len(writers := display.get_external_writers(disp_id)) > 1: + raise cv.Invalid( + f"Display '{disp_id}' is drawn by more than one component: {', '.join(writers)}", + path=[CONF_DISPLAY_ID], + ) + lvgl_configs = full_config.get("lvgl") or [] + if isinstance(lvgl_configs, dict): + lvgl_configs = [lvgl_configs] + for lvgl_config in lvgl_configs: + if any(d.id == disp_id.id for d in lvgl_config.get("displays", [])): + raise cv.Invalid( + f"Display '{disp_id}' is also used by LVGL", path=[CONF_DISPLAY_ID] + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_CONNECT, "add_on_connect_callback"), + automation.CallbackAutomation(CONF_ON_DISCONNECT, "add_on_disconnect_callback"), +) + + +async def to_code(config: ConfigType) -> None: + disp = await cg.get_variable(config[CONF_DISPLAY_ID]) + # Ask the server for the display's own byte order, so block-copying drivers skip the per-pixel path + meta = display.get_display_metadata(config[CONF_DISPLAY_ID]) + var = cg.new_Pvariable(config[CONF_ID], disp, meta.byte_order != BYTE_ORDER_LITTLE) + await cg.register_component(var, config) + socket.require_tcp_client_link() + cg.add(var.set_host(config[CONF_HOST])) + cg.add(var.set_port(config[CONF_PORT])) + cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) + cg.add(var.set_update_delay(config[CONF_UPDATE_DELAY])) + cg.add(var.set_max_update_delay(config[CONF_MAX_UPDATE_DELAY])) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +VNC_ID_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(VncClient)}, +) + +automation.register_apply_condition("vnc.is_connected", VNC_ID_SCHEMA, "is_connected()") diff --git a/esphome/components/vnc/vnc_client.cpp b/esphome/components/vnc/vnc_client.cpp new file mode 100644 index 00000000000..e11fdad97f2 --- /dev/null +++ b/esphome/components/vnc/vnc_client.cpp @@ -0,0 +1,702 @@ +#include "vnc_client.h" + +#include "esphome/components/network/util.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::vnc { + +ESPHOME_LOG_TAG(TAG, "vnc"); + +// Below the 50 ms blocking warning, and the same slice it8951 uses for its transfers. +static constexpr uint32_t LOOP_BUDGET_MS = 20; +static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 15000; +static constexpr uint32_t STABLE_SESSION_MS = 60000; +static constexpr uint32_t MAX_BACKOFF_MS = 60000; +static constexpr uint32_t CONFIG_RETRY_MS = 300000; +static constexpr uint32_t HEARTBEAT_MS = 60000; +static constexpr uint32_t MIN_REQUEST_SPACING_MS = 50; +// The longest the first refresh waits for a display with a loop to park once after boot. +static constexpr uint32_t DISPLAY_START_MS = 5000; +// Longer than any driver's own busy timeout: a panel that stays busy this long is stuck +static constexpr uint32_t DISPLAY_BUSY_TIMEOUT_MS = 120000; +static constexpr uint16_t MAX_CURSOR_SIDE = 1024; +static constexpr uint32_t MAX_TEXT_LEN = 63; + +static constexpr uint8_t SECURITY_NONE = 1; +static constexpr uint8_t SECURITY_VNC_AUTH = 2; + +static constexpr int32_t ENCODING_RAW = 0; +static constexpr int32_t ENCODING_RICH_CURSOR = -239; + +static constexpr uint8_t MSG_FRAMEBUFFER_UPDATE = 0; +static constexpr uint8_t MSG_SET_COLOUR_MAP = 1; +static constexpr uint8_t MSG_BELL = 2; +static constexpr uint8_t MSG_SERVER_CUT_TEXT = 3; + +static inline uint16_t read_be16(const uint8_t *p) { return encode_uint16(p[0], p[1]); } +static inline uint32_t read_be32(const uint8_t *p) { return encode_uint32(p[0], p[1], p[2], p[3]); } +static inline void write_be16(uint8_t *p, uint16_t v) { + p[0] = v >> 8; + p[1] = v; +} +static inline void write_be32(uint8_t *p, uint32_t v) { + p[0] = v >> 24; + p[1] = v >> 16; + p[2] = v >> 8; + p[3] = v; +} + +static int parse_three_digits(const uint8_t *p) { + int value = 0; + for (int i = 0; i < 3; i++) { + if (p[i] < '0' || p[i] > '9') { + return -1; + } + value = value * 10 + (p[i] - '0'); + } + return value; +} + +void VncClient::setup() { + this->setup_ms_ = App.get_loop_component_start_time(); + this->link_.begin(TAG); +} + +void VncClient::dump_config() { + ESP_LOGCONFIG(TAG, + "VNC:\n" + " Server: %s:%u\n" + " Reconnect Interval: %" PRIu32 "ms\n" + " Update Delay: %" PRIu32 "ms\n" + " Max Update Delay: %" PRIu32 "ms", + this->link_.host(), this->link_.port(), this->link_.reconnect_interval(), this->update_delay_ms_, + this->max_update_delay_ms_); +} + +void VncClient::on_shutdown() { this->link_.close(); } + +void VncClient::loop() { + const uint32_t now = App.get_loop_component_start_time(); + this->pass_start_ms_ = millis(); + if (this->display_->is_failed()) { + this->link_.close(); // a connect still in flight would otherwise keep its socket for good + if (this->state_ != VncState::VNC_STATE_IDLE) { + this->session_end_(now, VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Display failed")); + } + this->status_set_error(LOG_STR("Display failed")); + this->disable_loop(); + return; + } + // A display that parks its loop when idle is busy while that loop runs. Displays that never + // park (no loop, or one that always runs) update synchronously, so they are never busy. + if (this->display_->is_idle()) { + this->display_parks_ = true; + this->display_busy_ = false; + } else { + this->display_busy_ = this->display_parks_; + } + // A stuck panel, or another owner of the display's loop, would otherwise freeze the screen silently + if (!this->display_busy_) { + this->busy_since_ms_ = now; + if (this->busy_warned_) { + this->busy_warned_ = false; + // The connection path owns the warning while no session runs + if (this->state_ == VncState::VNC_STATE_RUNNING) { + this->status_clear_warning(); + } + } + } else if (!this->busy_warned_ && now - this->busy_since_ms_ >= DISPLAY_BUSY_TIMEOUT_MS) { + this->busy_warned_ = true; + this->status_set_warning(LOG_STR("Display busy too long")); + } + + if (!this->link_.connected()) { + if (this->state_ != VncState::VNC_STATE_IDLE) { + this->session_lost_(now); + } + this->connect_step_(now); + if (!this->link_.connected()) { + return; + } + this->start_handshake_(now); + } + if (this->state_ == VncState::VNC_STATE_HANDSHAKE && now - this->session_start_ms_ >= HANDSHAKE_TIMEOUT_MS) { + this->fail_(this->lost_failure_(), LOG_STR("Handshake timed out")); + return; + } + + const bool blocked = this->in_update_ && this->display_busy_; + // A display still starting up (it8951) can go from its start-up straight into a refresh without parking. + const bool display_starting = + !this->display_parks_ && this->display_->has_overridden_loop() && now - this->setup_ms_ < DISPLAY_START_MS; + // Wait until the screen stops changing, so an animation becomes one refresh showing where it ended. + if (this->refresh_pending_ && !this->in_update_ && !this->display_busy_ && !display_starting && + (now - this->last_change_ms_ >= this->update_delay_ms_ || + now - this->first_change_ms_ >= this->max_update_delay_ms_)) { + // Synchronous displays flush inside update(), so this pass does nothing else. + this->refresh_pending_ = false; + this->display_->update(); + this->display_busy_ = this->display_parks_ && !this->display_->is_idle(); + } else if (!blocked && (this->rx_pending_ || this->link_.ready())) { + this->service_rx_(now); + } + if (this->state_ == VncState::VNC_STATE_RUNNING) { + // Mid-frame there is nothing to send, so a vanished server would never fail a write. + if (this->in_update_ && !blocked && !this->rx_pending_ && now - this->last_rx_ms_ >= HEARTBEAT_MS) { + this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Server stopped sending")); + return; + } + this->pace_(now); + } + this->link_.flush_tx(); + if (this->rx_pending_ && !(this->in_update_ && this->display_busy_)) { + this->high_freq_.start(); + } else { + this->high_freq_.stop(); + } +} + +void VncClient::connect_step_(uint32_t now) { + const bool up = network::is_connected(); + if (up && !this->network_was_up_) { + this->backoff_ms_ = 0; + this->hold_ms_ = 0; + } + this->network_was_up_ = up; + if (!up) { + return; + } + if (!this->link_.connecting() && (now - this->hold_start_ms_ < this->hold_ms_ || this->link_.in_backoff())) { + return; + } + this->link_.poll(); + // DNS failures, refused connects and connect timeouts all leave no socket and start the link's backoff. + if (!this->link_.connected() && !this->link_.connecting() && this->link_.in_backoff()) { + this->session_end_(now, VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Cannot reach the server")); + } +} + +void VncClient::reset_session_() { + this->rx_pos_ = 0; + this->rx_len_ = 0; + this->rx_pending_ = false; + this->in_update_ = false; + this->request_outstanding_ = false; + this->refresh_pending_ = false; + this->frame_drawn_ = false; + this->step_ = VncStep::VNC_STEP_VERSION; + this->reason_failure_ = VncFailure::VNC_FAILURE_TRANSIENT; + this->high_freq_.stop(); +} + +void VncClient::start_handshake_(uint32_t now) { + this->reset_session_(); + this->state_ = VncState::VNC_STATE_HANDSHAKE; + this->session_start_ms_ = now; + this->last_rx_ms_ = now; +} + +VncFailure VncClient::lost_failure_() const { + if (this->state_ == VncState::VNC_STATE_HANDSHAKE) { + if (this->step_ == VncStep::VNC_STEP_REASON_LENGTH || this->step_ == VncStep::VNC_STEP_REASON) { + return this->reason_failure_; + } + } + return VncFailure::VNC_FAILURE_TRANSIENT; +} + +void VncClient::session_lost_(uint32_t now) { + this->session_end_(now, this->lost_failure_(), LOG_STR("Connection lost")); +} + +VncParse VncClient::fail_(VncFailure failure, const LogString *reason) { + this->link_.close(); + this->link_.note_attempt(); + this->session_end_(App.get_loop_component_start_time(), failure, reason); + return VncParse::VNC_PARSE_STOP; +} + +void VncClient::session_end_(uint32_t now, VncFailure failure, const LogString *reason) { + const bool was_running = this->state_ == VncState::VNC_STATE_RUNNING; + this->reset_session_(); + this->state_ = VncState::VNC_STATE_IDLE; + this->hold_start_ms_ = now; + if (was_running) { + const uint32_t lasted = now - this->session_start_ms_; + ESP_LOGW(TAG, "%s after %" PRIu32 "s", LOG_STR_ARG(reason), lasted / 1000); + this->disconnect_callback_.call(); + this->status_set_warning(); + if (lasted >= STABLE_SESSION_MS) { + this->backoff_ms_ = 0; + this->hold_ms_ = 0; + return; + } + failure = VncFailure::VNC_FAILURE_TRANSIENT; + } + switch (failure) { + case VncFailure::VNC_FAILURE_CONFIG: + this->hold_ms_ = CONFIG_RETRY_MS; + ESP_LOGE(TAG, "%s; retrying in %" PRIu32 "s", LOG_STR_ARG(reason), this->hold_ms_ / 1000); + this->status_set_error(reason); + return; + default: { + const uint32_t base = this->link_.reconnect_interval(); + this->backoff_ms_ = + this->backoff_ms_ == 0 ? base : std::min(this->backoff_ms_ * 2, std::max(MAX_BACKOFF_MS, base)); + this->hold_ms_ = this->backoff_ms_; + if (!was_running) { + ESP_LOGW(TAG, "%s; retrying in %" PRIu32 "s", LOG_STR_ARG(reason), this->hold_ms_ / 1000); + } + this->status_set_warning(); + return; + } + } +} + +bool VncClient::budget_exhausted_() const { return millis() - this->pass_start_ms_ >= LOOP_BUDGET_MS; } + +void VncClient::service_rx_(uint32_t now) { + for (;;) { + VncParse result = this->parse_(); + if (result == VncParse::VNC_PARSE_STOP) { + return; + } + if (result == VncParse::VNC_PARSE_PAUSE) { + this->rx_pending_ = true; + return; + } + // Less than one item is left, so the buffer always has room after compacting. + if (this->rx_pos_ != 0) { + this->rx_len_ -= this->rx_pos_; + std::memmove(this->rx_, this->rx_ + this->rx_pos_, this->rx_len_); + this->rx_pos_ = 0; + } + if (this->budget_exhausted_()) { + this->rx_pending_ = true; + return; + } + ssize_t count = this->link_.read(this->rx_ + this->rx_len_, RX_BUFFER_SIZE - this->rx_len_); + if (count < 0) { + this->session_lost_(now); + return; + } + if (count == 0) { + this->rx_pending_ = false; + return; + } + this->rx_len_ += static_cast(count); + this->rx_total_ += count; + this->last_rx_ms_ = now; + } +} + +void VncClient::skip_(uint32_t count, VncStep next) { + this->skip_left_ = count; + this->after_skip_ = next; + this->step_ = VncStep::VNC_STEP_SKIP; +} + +bool VncClient::send_client_init_() { + const uint8_t shared = 1; + return this->send_(&shared, 1); +} + +VncParse VncClient::parse_() { + for (;;) { + const uint8_t *p = this->rx_ + this->rx_pos_; + const size_t avail = this->rx_len_ - this->rx_pos_; + switch (this->step_) { + case VncStep::VNC_STEP_VERSION: { + if (avail < 12) { + return VncParse::VNC_PARSE_MORE; + } + const int major = parse_three_digits(p + 4); + const int minor = parse_three_digits(p + 8); + if (std::memcmp(p, "RFB ", 4) != 0 || p[7] != '.' || p[11] != '\n' || major < 0 || minor < 0) { + return this->fail_(VncFailure::VNC_FAILURE_CONFIG, LOG_STR("Not a VNC server")); + } + if (major < 3) { + return this->fail_(VncFailure::VNC_FAILURE_CONFIG, LOG_STR("Unsupported RFB version")); + } + this->minor_ = (major > 3 || minor >= 8) ? 8 : (minor == 7 ? 7 : 3); + this->rx_pos_ += 12; + uint8_t reply[12] = {'R', 'F', 'B', ' ', '0', '0', '3', '.', '0', '0', '8', '\n'}; + reply[10] = '0' + this->minor_; + if (!this->send_(reply, sizeof(reply))) { + return this->fail_send_(); + } + this->step_ = this->minor_ == 3 ? VncStep::VNC_STEP_SECURITY_TYPE_33 : VncStep::VNC_STEP_SECURITY_COUNT; + break; + } + case VncStep::VNC_STEP_SECURITY_COUNT: { + if (avail < 1) { + return VncParse::VNC_PARSE_MORE; + } + this->sec_count_ = p[0]; + this->rx_pos_ += 1; + if (this->sec_count_ == 0) { + // A refusal before authentication, such as a lockout caused by another client. + this->step_ = VncStep::VNC_STEP_REASON_LENGTH; + } else { + this->step_ = VncStep::VNC_STEP_SECURITY_LIST; + } + break; + } + case VncStep::VNC_STEP_SECURITY_LIST: { + if (avail < this->sec_count_) { + return VncParse::VNC_PARSE_MORE; + } + uint8_t chosen = 0; + bool wants_password = false; + for (uint8_t i = 0; i < this->sec_count_; i++) { + if (p[i] == SECURITY_NONE) { + chosen = p[i]; + break; + } + wants_password |= p[i] == SECURITY_VNC_AUTH; + } + this->rx_pos_ += this->sec_count_; + if (chosen == 0) { + return this->fail_(VncFailure::VNC_FAILURE_CONFIG, wants_password ? LOG_STR("The server requires a password") + : LOG_STR("No supported security type")); + } + if (!this->send_(&chosen, 1)) { + return this->fail_send_(); + } + if (this->minor_ == 8) { + this->step_ = VncStep::VNC_STEP_SECURITY_RESULT; + } else { + if (!this->send_client_init_()) { + return this->fail_send_(); + } + this->step_ = VncStep::VNC_STEP_SERVER_INIT; + } + break; + } + case VncStep::VNC_STEP_SECURITY_TYPE_33: { + if (avail < 4) { + return VncParse::VNC_PARSE_MORE; + } + const uint32_t type = read_be32(p); + this->rx_pos_ += 4; + if (type == 0) { + this->step_ = VncStep::VNC_STEP_REASON_LENGTH; + } else if (type == SECURITY_NONE) { + if (!this->send_client_init_()) { + return this->fail_send_(); + } + this->step_ = VncStep::VNC_STEP_SERVER_INIT; + } else { + return this->fail_(VncFailure::VNC_FAILURE_CONFIG, type == SECURITY_VNC_AUTH + ? LOG_STR("The server requires a password") + : LOG_STR("No supported security type")); + } + break; + } + case VncStep::VNC_STEP_SECURITY_RESULT: { + if (avail < 4) { + return VncParse::VNC_PARSE_MORE; + } + const uint32_t result = read_be32(p); + this->rx_pos_ += 4; + if (result == 0) { + if (!this->send_client_init_()) { + return this->fail_send_(); + } + this->step_ = VncStep::VNC_STEP_SERVER_INIT; + break; + } + this->reason_failure_ = VncFailure::VNC_FAILURE_CONFIG; + this->step_ = VncStep::VNC_STEP_REASON_LENGTH; + break; + } + case VncStep::VNC_STEP_REASON_LENGTH: { + if (avail < 4) { + return VncParse::VNC_PARSE_MORE; + } + this->text_len_ = static_cast(std::min(read_be32(p), MAX_TEXT_LEN)); + this->rx_pos_ += 4; + this->step_ = VncStep::VNC_STEP_REASON; + break; + } + case VncStep::VNC_STEP_REASON: { + if (avail < this->text_len_) { + return VncParse::VNC_PARSE_MORE; + } + ESP_LOGE(TAG, "Server: %.*s", static_cast(this->text_len_), reinterpret_cast(p)); + return this->fail_(this->reason_failure_, LOG_STR("Connection refused")); + } + case VncStep::VNC_STEP_SERVER_INIT: { + if (avail < 24) { + return VncParse::VNC_PARSE_MORE; + } + this->fb_w_ = read_be16(p); + this->fb_h_ = read_be16(p + 2); + // Reused for the length of the name that follows. + this->skip_left_ = read_be32(p + 20); + this->rx_pos_ += 24; + if (this->fb_w_ == 0 || this->fb_h_ == 0) { + return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("The server screen is empty")); + } + this->text_len_ = static_cast(std::min(this->skip_left_, MAX_TEXT_LEN)); + this->step_ = VncStep::VNC_STEP_SERVER_NAME; + break; + } + case VncStep::VNC_STEP_SERVER_NAME: { + if (avail < this->text_len_) { + return VncParse::VNC_PARSE_MORE; + } + ESP_LOGI(TAG, "Server '%.*s', %ux%u, RFB 3.%u", static_cast(this->text_len_), + reinterpret_cast(p), this->fb_w_, this->fb_h_, this->minor_); + this->rx_pos_ += this->text_len_; + this->skip_(this->skip_left_ - this->text_len_, VncStep::VNC_STEP_SESSION_START); + break; + } + case VncStep::VNC_STEP_SESSION_START: + if (!this->start_session_(App.get_loop_component_start_time())) { + return this->fail_send_(); + } + this->step_ = VncStep::VNC_STEP_MESSAGE; + break; + case VncStep::VNC_STEP_MESSAGE: { + if (avail < 1) { + return VncParse::VNC_PARSE_MORE; + } + switch (p[0]) { + case MSG_FRAMEBUFFER_UPDATE: + if (avail < 4) { + return VncParse::VNC_PARSE_MORE; + } + this->rects_left_ = read_be16(p + 2); + this->rx_pos_ += 4; + this->in_update_ = true; + this->update_start_ms_ = millis(); + this->update_start_bytes_ = this->rx_total_ - (this->rx_len_ - this->rx_pos_); + this->rect_done_(); + break; + case MSG_SET_COLOUR_MAP: + if (avail < 6) { + return VncParse::VNC_PARSE_MORE; + } + this->rx_pos_ += 6; + this->skip_(6u * read_be16(p + 4), VncStep::VNC_STEP_MESSAGE); + break; + case MSG_BELL: + this->rx_pos_ += 1; + break; + case MSG_SERVER_CUT_TEXT: + if (avail < 8) { + return VncParse::VNC_PARSE_MORE; + } + this->rx_pos_ += 8; + this->skip_(read_be32(p + 4), VncStep::VNC_STEP_MESSAGE); + break; + default: + ESP_LOGW(TAG, "Unknown message type %u", p[0]); + return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error")); + } + break; + } + case VncStep::VNC_STEP_RECT: { + if (this->display_busy_) { + return VncParse::VNC_PARSE_PAUSE; + } + if (avail < 12) { + return VncParse::VNC_PARSE_MORE; + } + this->rect_x_ = read_be16(p); + this->rect_y_ = read_be16(p + 2); + this->rect_w_ = read_be16(p + 4); + this->rect_h_ = read_be16(p + 6); + const auto encoding = static_cast(read_be32(p + 8)); + this->rx_pos_ += 12; + this->rects_left_--; + if (encoding == ENCODING_RAW) { + if (static_cast(this->rect_x_) + this->rect_w_ > this->fb_w_ || + static_cast(this->rect_y_) + this->rect_h_ > this->fb_h_) { + ESP_LOGW(TAG, "Rectangle %ux%u at %u,%u is outside the %ux%u screen", this->rect_w_, this->rect_h_, + this->rect_x_, this->rect_y_, this->fb_w_, this->fb_h_); + return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error")); + } + if (this->rect_w_ == 0 || this->rect_h_ == 0) { + this->rect_done_(); + } else { + this->pos_x_ = 0; + this->pos_y_ = 0; + this->step_ = VncStep::VNC_STEP_RAW; + } + } else if (encoding == ENCODING_RICH_CURSOR) { + if (this->rect_w_ > MAX_CURSOR_SIDE || this->rect_h_ > MAX_CURSOR_SIDE) { + return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error")); + } + const uint32_t size = static_cast(this->rect_w_) * this->rect_h_ * this->bpp_ + + ((this->rect_w_ + 7u) / 8u) * this->rect_h_; + this->skip_(size, VncStep::VNC_STEP_RECT_DONE); + } else { + ESP_LOGW(TAG, "Unsupported encoding %" PRId32, encoding); + return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Protocol error")); + } + break; + } + case VncStep::VNC_STEP_RECT_DONE: + this->rect_done_(); + break; + case VncStep::VNC_STEP_RAW: { + if (this->display_busy_) { + return VncParse::VNC_PARSE_PAUSE; + } + const size_t pixels = avail / this->bpp_; + if (pixels == 0) { + return VncParse::VNC_PARSE_MORE; + } + if (this->budget_exhausted_()) { + return VncParse::VNC_PARSE_PAUSE; + } + const auto count = static_cast(std::min(pixels, this->rect_w_ - this->pos_x_)); + this->draw_row_(this->rect_x_ + this->pos_x_, this->rect_y_ + this->pos_y_, count, p); + this->rx_pos_ += count * this->bpp_; + this->pos_x_ += count; + if (this->pos_x_ == this->rect_w_) { + this->pos_x_ = 0; + if (++this->pos_y_ == this->rect_h_) { + this->rect_done_(); + } + } + break; + } + case VncStep::VNC_STEP_SKIP: { + const auto count = static_cast(std::min(avail, this->skip_left_)); + this->rx_pos_ += count; + this->skip_left_ -= count; + if (this->skip_left_ != 0) { + return VncParse::VNC_PARSE_MORE; + } + this->step_ = this->after_skip_; + break; + } + } + } +} + +void VncClient::rect_done_() { + if (this->rects_left_ == 0) { + this->frame_done_(); + } else { + this->step_ = VncStep::VNC_STEP_RECT; + } +} + +void VncClient::frame_done_() { + this->in_update_ = false; + this->request_outstanding_ = false; + this->step_ = VncStep::VNC_STEP_MESSAGE; + if (this->frame_drawn_) { + const uint32_t now = App.get_loop_component_start_time(); + this->frame_drawn_ = false; + if (!this->refresh_pending_) { + this->first_change_ms_ = now; + } + this->last_change_ms_ = now; + this->refresh_pending_ = true; + ESP_LOGD(TAG, "Update: %" PRIu32 " bytes in %" PRIu32 "ms", + this->rx_total_ - (this->rx_len_ - this->rx_pos_) - this->update_start_bytes_, + millis() - this->update_start_ms_); + } +} + +void VncClient::draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t *pixels) { + if (y >= this->vis_h_ || x >= this->vis_w_) { + return; + } + const int visible = std::min(count, this->vis_w_ - x); + this->display_->draw_pixels_at(x, y, visible, 1, pixels, this->bitness_, this->big_endian_); + this->frame_drawn_ = true; +} + +bool VncClient::start_session_(uint32_t now) { + const bool rgb565 = this->display_->get_display_type() != display::DISPLAY_TYPE_BINARY; + this->bpp_ = rgb565 ? 2 : 1; + this->bitness_ = rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_332; + + const int disp_w = this->display_->get_width(); + const int disp_h = this->display_->get_height(); + this->vis_w_ = std::min(this->fb_w_, disp_w); + this->vis_h_ = std::min(this->fb_h_, disp_h); + if (this->fb_w_ != disp_w || this->fb_h_ != disp_h) { + ESP_LOGW(TAG, "Server screen is %ux%u but the display is %dx%d; showing the top-left %ux%u", this->fb_w_, + this->fb_h_, disp_w, disp_h, this->vis_w_, this->vis_h_); + } + + // SetPixelFormat: true colour, in the byte order the display takes + uint8_t pixel_format[20] = {}; + pixel_format[4] = rgb565 ? 16 : 8; + pixel_format[5] = rgb565 ? 16 : 8; + pixel_format[6] = this->big_endian_; + pixel_format[7] = 1; + write_be16(pixel_format + 8, rgb565 ? 31 : 7); + write_be16(pixel_format + 10, rgb565 ? 63 : 7); + write_be16(pixel_format + 12, rgb565 ? 31 : 3); + pixel_format[14] = rgb565 ? 11 : 5; + pixel_format[15] = rgb565 ? 5 : 2; + if (!this->send_(pixel_format, sizeof(pixel_format))) { + return false; + } + + // SetEncodings: TightVNC uses the first real encoding it supports for every rectangle. + uint8_t encodings[12] = {2, 0, 0, 2}; + write_be32(encodings + 4, ENCODING_RAW); + write_be32(encodings + 8, static_cast(ENCODING_RICH_CURSOR)); + if (!this->send_(encodings, sizeof(encodings)) || !this->send_update_request_(false, now)) { + return false; + } + this->state_ = VncState::VNC_STATE_RUNNING; + this->session_start_ms_ = now; + if (!this->busy_warned_) { + this->status_clear_warning(); // a stuck display keeps its warning through a reconnect + } + this->status_clear_error(); + this->connect_callback_.call(); + return true; +} + +void VncClient::pace_(uint32_t now) { + if (this->display_busy_ || this->in_update_ || now - this->last_request_ms_ < MIN_REQUEST_SPACING_MS) { + return; + } + // Repeating a parked request costs nothing on the server and detects a dead link. + if (!this->request_outstanding_ || + (now - this->last_rx_ms_ >= HEARTBEAT_MS && now - this->last_request_ms_ >= HEARTBEAT_MS)) { + this->send_update_request_(true, now); + } +} + +bool VncClient::send_(const uint8_t *data, size_t len) { + if (this->link_.tx_free() < len) { + this->link_.flush_tx(); + if (this->link_.tx_free() < len) { + return false; + } + } + this->link_.queue(data, len); + return true; +} + +bool VncClient::send_update_request_(bool incremental, uint32_t now) { + uint8_t msg[10] = {3, static_cast(incremental ? 1 : 0)}; + write_be16(msg + 6, this->vis_w_); + write_be16(msg + 8, this->vis_h_); + if (!this->send_(msg, sizeof(msg))) { + return false; + } + this->request_outstanding_ = true; + this->last_request_ms_ = now; + return true; +} + +} // namespace esphome::vnc diff --git a/esphome/components/vnc/vnc_client.h b/esphome/components/vnc/vnc_client.h new file mode 100644 index 00000000000..7efde2506dd --- /dev/null +++ b/esphome/components/vnc/vnc_client.h @@ -0,0 +1,162 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/components/socket/tcp_client_link.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::vnc { + +enum class VncState : uint8_t { + VNC_STATE_IDLE, + VNC_STATE_HANDSHAKE, + VNC_STATE_RUNNING, +}; + +enum class VncStep : uint8_t { + VNC_STEP_VERSION, + VNC_STEP_SECURITY_COUNT, + VNC_STEP_SECURITY_LIST, + VNC_STEP_SECURITY_TYPE_33, + VNC_STEP_SECURITY_RESULT, + VNC_STEP_REASON_LENGTH, + VNC_STEP_REASON, + VNC_STEP_SERVER_INIT, + VNC_STEP_SERVER_NAME, + VNC_STEP_SESSION_START, + VNC_STEP_MESSAGE, + VNC_STEP_RECT, + VNC_STEP_RECT_DONE, + VNC_STEP_RAW, + VNC_STEP_SKIP, +}; + +enum class VncParse : uint8_t { + VNC_PARSE_MORE, // the next step needs bytes that have not arrived + VNC_PARSE_PAUSE, // out of time, or the display is busy; resume without new data + VNC_PARSE_STOP, // the session ended +}; + +enum class VncFailure : uint8_t { + VNC_FAILURE_TRANSIENT, + VNC_FAILURE_CONFIG, +}; + +/// RFB (VNC) client that draws the remote framebuffer into a display. +class VncClient : public Component { + public: + explicit VncClient(display::Display *display, bool big_endian = false) : display_(display), big_endian_(big_endian) {} + + void set_host(const char *host) { this->link_.set_host(host); } + void set_port(uint16_t port) { this->link_.set_port(port); } + void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } + void set_update_delay(uint32_t ms) { this->update_delay_ms_ = ms; } + void set_max_update_delay(uint32_t ms) { this->max_update_delay_ms_ = ms; } + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + bool is_connected() const { return this->state_ == VncState::VNC_STATE_RUNNING; } + + template void add_on_connect_callback(F &&callback) { + this->connect_callback_.add(std::forward(callback)); + } + template void add_on_disconnect_callback(F &&callback) { + this->disconnect_callback_.add(std::forward(callback)); + } + + protected: + static constexpr size_t RX_BUFFER_SIZE = 512; + + void connect_step_(uint32_t now); + void start_handshake_(uint32_t now); + void service_rx_(uint32_t now); + VncParse parse_(); + void pace_(uint32_t now); + bool start_session_(uint32_t now); + bool send_client_init_(); + void frame_done_(); + void rect_done_(); + void draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t *pixels); + void skip_(uint32_t count, VncStep next); + bool budget_exhausted_() const; + bool send_(const uint8_t *data, size_t len); + bool send_update_request_(bool incremental, uint32_t now); + VncParse fail_(VncFailure failure, const LogString *reason); + VncParse fail_send_() { return this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Send failed")); } + VncFailure lost_failure_() const; + void session_lost_(uint32_t now); + void session_end_(uint32_t now, VncFailure failure, const LogString *reason); + void reset_session_(); + + display::Display *display_; + socket::TcpClientLink link_; + LazyCallbackManager connect_callback_; + LazyCallbackManager disconnect_callback_; + HighFrequencyLoopRequester high_freq_; + + uint32_t update_delay_ms_{200}; + uint32_t max_update_delay_ms_{1000}; + uint32_t backoff_ms_{0}; + uint32_t hold_ms_{0}; + uint32_t hold_start_ms_{0}; + uint32_t setup_ms_{0}; + uint32_t busy_since_ms_{0}; + uint32_t session_start_ms_{0}; + uint32_t update_start_ms_{0}; + // The first and the latest frame drawn since the display last refreshed. + uint32_t first_change_ms_{0}; + uint32_t last_change_ms_{0}; + uint32_t rx_total_{0}; + uint32_t update_start_bytes_{0}; + uint32_t last_request_ms_{0}; + uint32_t last_rx_ms_{0}; + uint32_t pass_start_ms_{0}; + uint32_t skip_left_{0}; + + uint16_t rx_pos_{0}; + uint16_t rx_len_{0}; + uint16_t fb_w_{0}; + uint16_t fb_h_{0}; + uint16_t vis_w_{0}; + uint16_t vis_h_{0}; + uint16_t rects_left_{0}; + uint16_t rect_x_{0}; + uint16_t rect_y_{0}; + uint16_t rect_w_{0}; + uint16_t rect_h_{0}; + // Raw: column and row inside the rect. + uint16_t pos_x_{0}; + uint16_t pos_y_{0}; + + uint8_t bpp_{2}; + uint8_t minor_{8}; + uint8_t sec_count_{0}; + uint8_t text_len_{0}; // clamped to MAX_TEXT_LEN + display::ColorBitness bitness_{display::COLOR_BITNESS_565}; + VncState state_{VncState::VNC_STATE_IDLE}; + VncStep step_{VncStep::VNC_STEP_VERSION}; + VncStep after_skip_{VncStep::VNC_STEP_MESSAGE}; + VncFailure reason_failure_{VncFailure::VNC_FAILURE_TRANSIENT}; + + bool rx_pending_{false}; + bool in_update_{false}; + bool request_outstanding_{false}; + bool refresh_pending_{false}; + bool frame_drawn_{false}; + bool display_parks_{false}; + bool display_busy_{false}; + bool busy_warned_{false}; + bool network_was_up_{false}; + bool big_endian_; // the pixel order the display takes + + uint8_t rx_[RX_BUFFER_SIZE]; +}; + +} // namespace esphome::vnc diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 3e39d1f7f69..82874506d64 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,12 +1,6 @@ from esphome import automation, core import esphome.codegen as cg -from esphome.components import ( - media_player, - micro_wake_word, - microphone, - network, - speaker, -) +from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -175,7 +169,6 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.key_requires((CONF_ON_TTS_STREAM_START, CONF_ON_TTS_STREAM_END), CONF_SPEAKER), - network.require_ipv4("voice_assistant"), ) FINAL_VALIDATE_SCHEMA = cv.All( diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index a0fa321812c..00fc1df8731 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -82,7 +82,11 @@ void VoiceAssistant::setup() { float VoiceAssistant::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } bool VoiceAssistant::start_udp_socket_() { +#if USE_NETWORK_IPV4 this->socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); +#else + this->socket_ = socket::socket_ip(SOCK_DGRAM, IPPROTO_IP); +#endif if (this->socket_ == nullptr) { ESP_LOGE(TAG, "Could not create socket"); this->mark_failed(); @@ -357,7 +361,7 @@ void VoiceAssistant::loop() { msg.start = true; msg.conversation_id = StringRef(this->conversation_id_); msg.flags = flags; - msg.audio_settings = audio_settings; + msg.audio_settings = std::move(audio_settings); msg.wake_word_phrase = StringRef(this->wake_word_); // Reset media player state tracking @@ -663,17 +667,20 @@ void VoiceAssistant::start_streaming(struct sockaddr_storage *addr, uint16_t por } memcpy(&this->dest_addr_, addr, sizeof(this->dest_addr_)); - if (this->dest_addr_.ss_family == AF_INET) { - ((struct sockaddr_in *) &this->dest_addr_)->sin_port = htons(port); - } -#if LWIP_IPV6 - else if (this->dest_addr_.ss_family == AF_INET6) { - ((struct sockaddr_in6 *) &this->dest_addr_)->sin6_port = htons(port); - } + switch (this->dest_addr_.ss_family) { +#if USE_NETWORK_IPV4 + case AF_INET: + ((struct sockaddr_in *) &this->dest_addr_)->sin_port = htons(port); + break; #endif - else { - ESP_LOGW(TAG, "Unknown address family: %d", this->dest_addr_.ss_family); - return; +#if LWIP_IPV6 + case AF_INET6: + ((struct sockaddr_in6 *) &this->dest_addr_)->sin6_port = htons(port); + break; +#endif + default: + ESP_LOGW(TAG, "Unknown address family: %d", this->dest_addr_.ss_family); + return; } // Only primary microphone channel over UDP diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 9aaaa970b07..c3963c58286 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -38,7 +38,7 @@ WaterHeaterCall &WaterHeaterCall::set_mode(const char *mode, size_t len) { } else if (len == 3 && ESPHOME_strncasecmp_P(mode, ESPHOME_PSTR("GAS"), 3) == 0) { this->set_mode(WATER_HEATER_MODE_GAS); } else { - ESP_LOGW(TAG, "'%s' - Unrecognized mode %.*s", this->parent_->get_name().c_str(), (int) len, mode); + ESP_LOGW(TAG, "'%s' - Unrecognized mode %.*s", LOG_STR_ARG(this->parent_->get_log_name()), (int) len, mode); } return *this; } @@ -79,7 +79,7 @@ WaterHeaterCall &WaterHeaterCall::set_on(bool on) { } void WaterHeaterCall::perform() { - ESP_LOGV(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); + ESP_LOGV(TAG, "'%s' - Setting", LOG_STR_ARG(this->parent_->get_log_name())); this->validate_(); if (this->mode_.has_value()) { ESP_LOGV(TAG, " Mode: %s", LOG_STR_ARG(water_heater_mode_to_string(*this->mode_))); @@ -107,7 +107,7 @@ void WaterHeaterCall::validate_() { auto traits = this->parent_->get_traits(); if (this->mode_.has_value()) { if (!traits.supports_mode(*this->mode_)) { - ESP_LOGW(TAG, "'%s' - Mode %" PRIu32 " not supported", this->parent_->get_name().c_str(), + ESP_LOGW(TAG, "'%s' - Mode %" PRIu32 " not supported", LOG_STR_ARG(this->parent_->get_log_name()), static_cast(*this->mode_)); this->mode_.reset(); } @@ -115,34 +115,36 @@ void WaterHeaterCall::validate_() { if (!std::isnan(this->target_temperature_)) { if (traits.get_supports_two_point_target_temperature()) { ESP_LOGW(TAG, "'%s' - Cannot set target temperature for device with two-point target temperature", - this->parent_->get_name().c_str()); + LOG_STR_ARG(this->parent_->get_log_name())); this->target_temperature_ = NAN; } else if (this->target_temperature_ < traits.get_min_temperature() || this->target_temperature_ > traits.get_max_temperature()) { - ESP_LOGW(TAG, "'%s' - Target temperature %.1f is out of range [%.1f - %.1f]", this->parent_->get_name().c_str(), - this->target_temperature_, traits.get_min_temperature(), traits.get_max_temperature()); + ESP_LOGW(TAG, "'%s' - Target temperature %.1f is out of range [%.1f - %.1f]", + LOG_STR_ARG(this->parent_->get_log_name()), this->target_temperature_, traits.get_min_temperature(), + traits.get_max_temperature()); this->target_temperature_ = std::max(traits.get_min_temperature(), std::min(this->target_temperature_, traits.get_max_temperature())); } } if (!std::isnan(this->target_temperature_low_) || !std::isnan(this->target_temperature_high_)) { if (!traits.get_supports_two_point_target_temperature()) { - ESP_LOGW(TAG, "'%s' - Cannot set low/high target temperature", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s' - Cannot set low/high target temperature", LOG_STR_ARG(this->parent_->get_log_name())); this->target_temperature_low_ = NAN; this->target_temperature_high_ = NAN; } } if (!std::isnan(this->target_temperature_low_) && !std::isnan(this->target_temperature_high_)) { if (this->target_temperature_low_ > this->target_temperature_high_) { - ESP_LOGW(TAG, "'%s' - Target temperature low %.2f must be less than high %.2f", this->parent_->get_name().c_str(), - this->target_temperature_low_, this->target_temperature_high_); + ESP_LOGW(TAG, "'%s' - Target temperature low %.2f must be less than high %.2f", + LOG_STR_ARG(this->parent_->get_log_name()), this->target_temperature_low_, + this->target_temperature_high_); this->target_temperature_low_ = NAN; this->target_temperature_high_ = NAN; } } if (!traits.get_supports_away_mode()) { if (this->state_ & WATER_HEATER_STATE_AWAY) { - ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str()); + ESP_LOGW(TAG, "'%s' - Away mode not supported", LOG_STR_ARG(this->parent_->get_log_name())); } this->state_ &= ~WATER_HEATER_STATE_AWAY; this->state_mask_ &= ~WATER_HEATER_STATE_AWAY; @@ -160,7 +162,7 @@ void WaterHeater::publish_state() { ESP_LOGV(TAG, "'%s' >>\n" " Mode: %s", - this->name_.c_str(), LOG_STR_ARG(water_heater_mode_to_string(this->mode_))); + LOG_STR_ARG(this->get_log_name()), LOG_STR_ARG(water_heater_mode_to_string(this->mode_))); if (!std::isnan(this->current_temperature_)) { ESP_LOGV(TAG, " Current Temperature: %.2f°C", this->current_temperature_); } diff --git a/esphome/components/waveshare_epaper/__init__.py b/esphome/components/waveshare_epaper/__init__.py index 4bcfcdfda5a..992e04dbb79 100644 --- a/esphome/components/waveshare_epaper/__init__.py +++ b/esphome/components/waveshare_epaper/__init__.py @@ -4,4 +4,5 @@ DOMAIN = "waveshare_epaper" DEPRECATED_COMPONENT = """ The 'waveshare_epaper' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'epaper_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/waveshare_epaper/display.py b/esphome/components/waveshare_epaper/display.py index be87c623779..fd72f3fe947 100644 --- a/esphome/components/waveshare_epaper/display.py +++ b/esphome/components/waveshare_epaper/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import core, pins import esphome.codegen as cg from esphome.components import display, spi @@ -17,6 +19,8 @@ from esphome.types import ConfigType DEPENDENCIES = ["spi"] +LOGGER = logging.getLogger(__name__) + waveshare_epaper_ns = cg.esphome_ns.namespace("waveshare_epaper") WaveshareEPaperBase = waveshare_epaper_ns.class_( "WaveshareEPaperBase", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer @@ -229,6 +233,11 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 + LOGGER.warning( + "The 'waveshare_epaper' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'epaper_spi' instead." + ) model_type, model = MODELS[config[CONF_MODEL]] if model_type == "a": rhs = WaveshareEPaperTypeA.new(model) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 64139dcff47..5b9868e4ddb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -63,24 +63,14 @@ ESPHOME_LOG_TAG(TAG, "web_server"); using json::linked; -#ifdef USE_STORE_LOG_STR_IN_FLASH -// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. +#ifdef USE_ESP8266 +// A LogString is always in flash on ESP8266: view it as a ProgmemStr so ArduinoJson copies it PROGMEM-aware. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } #else -// Without the define a LogString is in RAM and can be linked +// Elsewhere a LogString is in RAM and can be linked [[maybe_unused]] static JsonString json_state_str(const LogString *s) { return linked(LOG_STR_ARG(s)); } #endif -// get_icon_to() and get_device_class_to() return a table pointer, except on ESP8266 where they -// copy out of PROGMEM into the caller's buffer -[[maybe_unused]] static JsonString json_table_str(const char *s) { -#ifdef USE_ESP8266 - return JsonString(s); -#else - return linked(s); -#endif -} - // One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the // table needs no RAM on ESP8266. template struct ActionEntry { @@ -222,7 +212,7 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { #endif // Match by entity name - if (this->id == entity->get_name()) { + if (entity->name_equals(this->id)) { result.matched = true; } @@ -625,9 +615,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name} // Uses names (not object_id) to avoid UTF-8 collision issues static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) { - const StringRef &name = obj->get_name(); size_t prefix_len = strlen(prefix); - size_t name_len = name.size(); #ifdef USE_DEVICES Device *device = obj->get_device(); @@ -657,21 +645,27 @@ static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, Js *p++ = '/'; } #endif - memcpy(p, name.c_str(), name_len); - p[name_len] = '\0'; +#ifdef USE_ESP8266 + const char *name = p; +#endif + obj->write_name_to(p, id_buf + ID_BUF_SIZE - p); root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { root[ESPHOME_F("domain")] = linked(prefix, prefix_len); - root[ESPHOME_F("name")] = linked(name); +#ifdef USE_ESP8266 + root[ESPHOME_F("name")] = name; // in id_buf on the stack, so copied +#else + const StringRef &ram_name = obj->get_name_progmem().ram_ref(); + root[ESPHOME_F("name")] = linked(ram_name.c_str(), ram_name.size()); +#endif #ifdef USE_DEVICES if (device_name) { root[ESPHOME_F("device")] = linked(device_name, device_len); } #endif #ifdef USE_ENTITY_ICON - char icon_buf[MAX_ICON_LENGTH]; - root[ESPHOME_F("icon")] = json_table_str(obj->get_icon_to(icon_buf)); + root[ESPHOME_F("icon")] = linked(obj->get_icon_progmem()); #endif root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); @@ -1049,7 +1043,7 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa if (is_on) { parse_cstr_param_( request, ESPHOME_F("effect"), call, - static_cast(&decltype(call)::set_effect)); + static_cast(&decltype(call)::set_effect)); } DEFER_ACTION(call, call.perform()); @@ -1101,9 +1095,8 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); static constexpr ActionEntry ACTIONS[] PROGMEM = { - {"open", &cover::CoverCall::set_command_open}, - {"close", &cover::CoverCall::set_command_close}, - {"stop", &cover::CoverCall::set_command_stop}, + {"open", &cover::CoverCall::set_command_open}, {"close", &cover::CoverCall::set_command_close}, + {"stop", &cover::CoverCall::set_command_stop}, {"stop_tilt", &cover::CoverCall::set_command_stop_tilt}, {"toggle", &cover::CoverCall::set_command_toggle}, }; @@ -1411,7 +1404,7 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat auto call = obj->make_call(); parse_cstr_param_( request, ESPHOME_F("value"), call, - static_cast(&decltype(call)::set_value)); + static_cast(&decltype(call)::set_value)); DEFER_ACTION(call, call.perform()); request->send(200); @@ -1471,7 +1464,7 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM auto call = obj->make_call(); parse_cstr_param_( request, ESPHOME_F("option"), call, - static_cast(&decltype(call)::set_option)); + static_cast(&decltype(call)::set_option)); DEFER_ACTION(call, call.perform()); request->send(200); @@ -1529,26 +1522,26 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url // Parse string mode parameters parse_cstr_param_( request, ESPHOME_F("mode"), call, - static_cast(&decltype(call)::set_mode)); + static_cast(&decltype(call)::set_mode)); parse_cstr_param_(request, ESPHOME_F("fan_mode"), call, - static_cast( + static_cast( &decltype(call)::set_fan_mode)); parse_cstr_param_(request, ESPHOME_F("swing_mode"), call, - static_cast( + static_cast( &decltype(call)::set_swing_mode)); parse_cstr_param_(request, ESPHOME_F("preset"), call, - static_cast( + static_cast( &decltype(call)::set_preset)); // Parse temperature parameters // static_cast needed to disambiguate overloaded setters (float vs optional) using ClimateCall = decltype(call); parse_num_param_(request, ESPHOME_F("target_temperature_high"), call, - static_cast(&ClimateCall::set_target_temperature_high)); + static_cast(&ClimateCall::set_target_temperature_high)); parse_num_param_(request, ESPHOME_F("target_temperature_low"), call, - static_cast(&ClimateCall::set_target_temperature_low)); + static_cast(&ClimateCall::set_target_temperature_low)); parse_num_param_(request, ESPHOME_F("target_temperature"), call, - static_cast(&ClimateCall::set_target_temperature)); + static_cast(&ClimateCall::set_target_temperature)); DEFER_ACTION(call, call.perform()); request->send(200); @@ -1825,8 +1818,8 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques auto call = obj->make_call(); parse_cstr_param_( request, ESPHOME_F("code"), call, - static_cast(&decltype(call)::set_code)); + static_cast(&decltype(call)::set_code)); static constexpr ActionEntry ACTIONS[] PROGMEM = { {"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm}, @@ -1900,7 +1893,7 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons // Parse mode parameter parse_cstr_param_( request, ESPHOME_F("mode"), base_call, - static_cast( + static_cast( &water_heater::WaterHeaterCall::set_mode)); // Parse temperature parameters @@ -2211,8 +2204,7 @@ void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail for (const char *event_type : obj->get_event_types()) { event_types.add(linked(event_type)); } - char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - root[ESPHOME_F("device_class")] = json_table_str(obj->get_device_class_to(dc_buf)); + root[ESPHOME_F("device_class")] = linked(obj->get_device_class_progmem()); this->add_sorting_info_(root, obj); } } diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 08654e353a9..e49cb76ffa2 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -42,8 +42,10 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & // Add data attributes for hierarchical URL support stream->print("\" data-domain=\""); stream->print(klass.c_str()); + char name_buf[ENTITY_NAME_BUF_SIZE]; + const char *name = obj->get_name_to(name_buf).c_str(); stream->print("\" data-name=\""); - write_html_escaped(stream, obj->get_name().c_str()); + write_html_escaped(stream, name); #ifdef USE_DEVICES Device *device = obj->get_device(); if (device != nullptr) { @@ -59,7 +61,7 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & stream->print("] "); } #endif - write_html_escaped(stream, obj->get_name().c_str()); + write_html_escaped(stream, name); stream->print(""); stream->print(action.c_str()); if (action_func) { diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index 938b191756d..5fd44b3f8ba 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -5,6 +5,7 @@ #include "weikai.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" namespace esphome::weikai { @@ -37,35 +38,34 @@ const char *p2s(uart::UARTParityOptions parity) { } /// @brief Display a buffer in hexadecimal format (32 hex values / line) for debug -void print_buffer(const uint8_t *data, size_t length) { +void print_buffer(const char *tag, const uint8_t *data, size_t length, int level) { char hex_buffer[100]; hex_buffer[(3 * 32) + 1] = 0; for (size_t i = 0; i < length; i++) { snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]); if (i % 32 == 31) { - ESP_LOGVV(TAG, " %s", hex_buffer); + esp_log_printf_(level, tag, __LINE__, ESPHOME_LOG_FORMAT(" %s"), hex_buffer); } } if (length % 32) { // null terminate if incomplete line hex_buffer[3 * (length % 32) + 2] = 0; - ESP_LOGVV(TAG, " %s", hex_buffer); + esp_log_printf_(level, tag, __LINE__, ESPHOME_LOG_FORMAT(" %s"), hex_buffer); } } -static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER", - "SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"}; -static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL", - "TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"}; +PROGMEM_STRING_TABLE(RegNamesPage0, "GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER", "SIFR", "TFCNT", + "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"); +PROGMEM_STRING_TABLE(RegNamesPage1, "GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL", "TFTL", "FWTH", + "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"); -// method to print a register value as text: used in the log messages ... -const char *reg_to_str(int reg, bool page1) { +const LogString *reg_to_str(int reg, bool page1) { if (reg == WKREG_GPDAT) { - return "GPDAT"; + return LOG_STR("GPDAT"); } else if (reg == WKREG_GPDIR) { - return "GPDIR"; + return LOG_STR("GPDIR"); } else { - return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F]; + return page1 ? RegNamesPage1::get_log_str(reg & 0x0F, 0) : RegNamesPage0::get_log_str(reg & 0x0F, 0); } } @@ -450,9 +450,12 @@ uart::UARTFlushResult WeikaiChannel::flush() { size_t WeikaiChannel::xfer_fifo_to_buffer_() { size_t total = 0; - size_t to_transfer; - size_t free; - while ((to_transfer = this->rx_in_fifo_()) && (free = this->receive_buffer_.free())) { + while (true) { + size_t to_transfer = this->rx_in_fifo_(); + size_t free = this->receive_buffer_.free(); + if (to_transfer == 0 || free == 0) { + break; + } // while bytes in fifo and some room in the buffer we transfer if (to_transfer > XFER_MAX_SIZE) to_transfer = XFER_MAX_SIZE; // we can only do so much @@ -492,24 +495,6 @@ class Increment { uint8_t i_; }; -/// @brief Hex converter to print/display a buffer in hexadecimal format (32 hex values / line). -/// @param buffer contains the values to display -void print_buffer(std::vector buffer) { - char hex_buffer[100]; - hex_buffer[(3 * 32) + 1] = 0; - for (size_t i = 0; i < buffer.size(); i++) { - snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", buffer[i]); - if (i % 32 == 31) { - ESP_LOGI(TAG, " %s", hex_buffer); - } - } - if (buffer.size() % 32) { - // null terminate if incomplete line - hex_buffer[3 * (buffer.size() % 32) + 1] = 0; - ESP_LOGI(TAG, " %s", hex_buffer); - } -} - /// @brief test the write_array method void WeikaiChannel::uart_send_test_(char *message) { auto start_exec = micros(); @@ -557,7 +542,7 @@ bool WeikaiChannel::uart_receive_test_(char *message) { for (size_t i = 0; i < RING_BUFFER_SIZE; i++) { if (buffer[i] != i % XFER_MAX_SIZE) { ESP_LOGE(TAG, "Read buffer contains error b=%x i=%x", buffer[i], i % XFER_MAX_SIZE); - print_buffer(buffer); + print_buffer(TAG, buffer.data(), buffer.size(), ESPHOME_LOG_LEVEL_INFO); status = false; break; } diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index eb8a41ad9d0..d732e028e78 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -40,6 +40,12 @@ constexpr size_t FIFO_SIZE = 256; /// @brief size of the ring buffer set to size of the FIFO constexpr size_t RING_BUFFER_SIZE = FIFO_SIZE; +/// @brief Name of a register for the log messages +const LogString *reg_to_str(int reg, bool page1); + +/// Logs a buffer in hexadecimal, 32 values per line, under @p tag at @p level. +void print_buffer(const char *tag, const uint8_t *data, size_t length, int level = ESPHOME_LOG_LEVEL_VERY_VERBOSE); + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /// @brief This is an helper class that provides a simple ring buffers that works as a FIFO /// @details This ring buffer is used to buffer the bytes received in the FIFO of the Weika device. The best way to read diff --git a/esphome/components/weikai_i2c/weikai_i2c.cpp b/esphome/components/weikai_i2c/weikai_i2c.cpp index bafa6c4e03a..eebff3020a4 100644 --- a/esphome/components/weikai_i2c/weikai_i2c.cpp +++ b/esphome/components/weikai_i2c/weikai_i2c.cpp @@ -8,38 +8,7 @@ namespace esphome::weikai_i2c { ESPHOME_LOG_TAG(TAG, "weikai_i2c"); -/// @brief Display a buffer in hexadecimal format (32 hex values / line). -void print_buffer(const uint8_t *data, size_t length) { - char hex_buffer[100]; - hex_buffer[(3 * 32) + 1] = 0; - for (size_t i = 0; i < length; i++) { - snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]); - if (i % 32 == 31) { - ESP_LOGVV(TAG, " %s", hex_buffer); - } - } - if (length % 32) { - // null terminate if incomplete line - hex_buffer[3 * (length % 32) + 2] = 0; - ESP_LOGVV(TAG, " %s", hex_buffer); - } -} - -static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER", - "SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"}; -static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL", - "TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"}; using namespace weikai; -// method to print a register value as text: used in the log messages ... -const char *reg_to_str(int reg, bool page1) { - if (reg == WKREG_GPDAT) { - return "GPDAT"; - } else if (reg == WKREG_GPDIR) { - return "GPDIR"; - } else { - return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F]; - } -} enum RegType { REG = 0, FIFO = 1 }; ///< Register or FIFO /// @brief Computes the I²C bus's address used to access the component @@ -72,11 +41,11 @@ uint8_t WeikaiRegisterI2C::read_reg() const { if (error == i2c::NO_ERROR) { this->comp_->status_clear_warning(); ESP_LOGVV(TAG, "WeikaiRegisterI2C::read_reg() @%02X reg=%s ch=%u I2C_code:%d, buf=%02X", address, - reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value); + LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value); } else { // error this->comp_->status_set_warning(); ESP_LOGE(TAG, "WeikaiRegisterI2C::read_reg() @%02X reg=%s ch=%u I2C_code:%d, buf=%02X", address, - reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value); + LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value); } return value; } @@ -91,7 +60,7 @@ void WeikaiRegisterI2C::read_fifo(uint8_t *data, size_t length) const { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE ESP_LOGVV(TAG, "WeikaiRegisterI2C::read_fifo() @%02X ch=%d I2C_code:%d len=%d buffer", address, this->channel_, (int) error, length); - print_buffer(data, length); + print_buffer(TAG, data, length); #endif } else { // error this->comp_->status_set_warning(); @@ -108,11 +77,11 @@ void WeikaiRegisterI2C::write_reg(uint8_t value) { if (error == i2c::NO_ERROR) { this->comp_->status_clear_warning(); ESP_LOGVV(TAG, "WK2168Reg::write_reg() @%02X reg=%s ch=%d I2C_code:%d buf=%02X", address, - reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value); + LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value); } else { // error this->comp_->status_set_warning(); ESP_LOGE(TAG, "WK2168Reg::write_reg() @%02X reg=%s ch=%d I2C_code:%d buf=%d", address, - reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value); + LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value); } } @@ -126,7 +95,7 @@ void WeikaiRegisterI2C::write_fifo(uint8_t *data, size_t length) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE ESP_LOGVV(TAG, "WK2168Reg::write_fifo() @%02X ch=%d I2C_code:%d len=%d buffer", address, this->channel_, (int) error, length); - print_buffer(data, length); + print_buffer(TAG, data, length); #endif } else { // error this->comp_->status_set_warning(); diff --git a/esphome/components/weikai_spi/weikai_spi.cpp b/esphome/components/weikai_spi/weikai_spi.cpp index 6a44fad6f3b..625c1fc0ad0 100644 --- a/esphome/components/weikai_spi/weikai_spi.cpp +++ b/esphome/components/weikai_spi/weikai_spi.cpp @@ -35,39 +35,6 @@ const char *p2s(uart::UARTParityOptions parity) { } } -/// @brief Display a buffer in hexadecimal format (32 hex values / line). -void print_buffer(const uint8_t *data, size_t length) { - char hex_buffer[100]; - hex_buffer[(3 * 32) + 1] = 0; - for (size_t i = 0; i < length; i++) { - snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]); - if (i % 32 == 31) { - ESP_LOGVV(TAG, " %s", hex_buffer); - } - } - if (length % 32) { - // null terminate if incomplete line - hex_buffer[3 * (length % 32) + 2] = 0; - ESP_LOGVV(TAG, " %s", hex_buffer); - } -} - -static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER", - "SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"}; -static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL", - "TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"}; - -// method to print a register value as text: used in the log messages ... -const char *reg_to_str(int reg, bool page1) { - if (reg == WKREG_GPDAT) { - return "GPDAT"; - } else if (reg == WKREG_GPDIR) { - return "GPDIR"; - } else { - return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F]; - } -} - enum RegType { REG = 0, FIFO = 1 }; ///< Register or FIFO enum CmdType { WRITE_CMD = 0, READ_CMD = 1 }; ///< Read or Write transfer @@ -101,7 +68,7 @@ uint8_t WeikaiRegisterSPI::read_reg() const { spi_comp->disable(); char bin_buf[9]; ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", format_bin_to(bin_buf, cmd), cmd, - reg_to_str(this->register_, this->comp_->page1()), this->channel_, val); + LOG_STR_ARG(reg_to_str(this->register_, this->comp_->page1())), this->channel_, val); return val; } @@ -116,7 +83,7 @@ void WeikaiRegisterSPI::read_fifo(uint8_t *data, size_t length) const { char bin_buf[9]; ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_fifo() cmd=%s(%02X) ch=%d len=%d buffer", format_bin_to(bin_buf, cmd), cmd, this->channel_, length); - print_buffer(data, length); + print_buffer(TAG, data, length); #endif } @@ -128,7 +95,7 @@ void WeikaiRegisterSPI::write_reg(uint8_t value) { spi_comp->disable(); char bin_buf[9]; ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", format_bin_to(bin_buf, buf[0]), - buf[0], reg_to_str(this->register_, this->comp_->page1()), this->channel_, buf[1]); + buf[0], LOG_STR_ARG(reg_to_str(this->register_, this->comp_->page1())), this->channel_, buf[1]); } void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) { @@ -143,7 +110,7 @@ void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) { char bin_buf[9]; ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_fifo() cmd=%s(%02X) ch=%d len=%d buffer", format_bin_to(bin_buf, cmd), cmd, this->channel_, length); - print_buffer(data, length); + print_buffer(TAG, data, length); #endif } diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index 6d7506b433a..782f9a6b8dd 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -309,6 +309,8 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { case WHIRLPOOL_AUTO: this->mode = climate::CLIMATE_MODE_HEAT_COOL; break; + default: + break; } } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index ec06e1be85b..018c19c4d26 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -11,6 +11,7 @@ from esphome.components.esp32 import ( get_esp32_variant, only_on_variant, request_wifi, + require_idf_version, require_mbedtls_tls, require_mbedtls_tls_extras, ) @@ -39,6 +40,7 @@ from esphome.const import ( CONF_ENABLE_BTM, CONF_ENABLE_ON_BOOT, CONF_ENABLE_RRM, + CONF_ETHERNET, CONF_FAST_CONNECT, CONF_GATEWAY, CONF_HIDDEN, @@ -64,7 +66,6 @@ from esphome.const import ( CONF_USE_ADDRESS, CONF_USERNAME, PLACEHOLDER_WIFI_SSID, - Framework, Platform, PlatformFramework, ) @@ -223,10 +224,8 @@ WIFI_AP_MODE_WISP = "WISP" def _validate_ap_mode(value: str) -> str: value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value) if value == WIFI_AP_MODE_WISP: - # esp_netif_napt_enable() exists from ESP-IDF 5.1 - cv.require_framework_version( - esp_idf=cv.Version(5, 1, 0), extra_message="WISP mode routes through NAT" - )(value) + # The bridge is part of the ESP32 backend, on both of its frameworks + cv.only_on_esp32(value) return value @@ -234,10 +233,7 @@ def _validate_dns_servers(value: list) -> list: if any(ip.is_unspecified for ip in value): raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer") if len(value) > 1: - cv.require_framework_version( - esp_idf=cv.Version(6, 0, 0), - extra_message="The access point offers a second DNS server from ESP-IDF 6.0", - )(value) + require_idf_version(cv.Version(6, 0, 0), "A second DNS server")(value) return value @@ -431,8 +427,26 @@ def _apply_min_auth_mode_default(config): return config +def wifi_has_sta(config: ConfigType) -> bool: + """Whether the validated config has at least one station network.""" + return bool(config.get(CONF_NETWORKS)) + + +# Components that save station credentials at run time +_PROVISIONERS = ("captive_portal", "improv_ble", "improv_serial") + + +def wifi_stays_ap_only(config: ConfigType, full_config: ConfigType) -> bool: + """Whether no station can appear at run time: no networks, no DPP, no provisioner.""" + return not ( + wifi_has_sta(config) + or CONF_DPP in config + or any(c in full_config for c in _PROVISIONERS) + ) + + def final_validate(config): - has_sta = bool(config.get(CONF_NETWORKS, True)) + has_sta = wifi_has_sta(config) has_ap = CONF_AP in config has_dpp = CONF_DPP in config full_config = fv.full_config.get() @@ -447,9 +461,11 @@ def final_validate(config): ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP if ap_is_wisp: - if not has_sta: + # The clients are routed through the station, or through ethernet + if not has_sta and CONF_ETHERNET not in full_config: raise cv.Invalid( - "WISP mode requires at least one STA network (wifi.ssid or wifi.networks)" + "WISP mode needs a station network (wifi.ssid or wifi.networks), " + "or ethernet as the upstream interface" ) if has_captive_portal: raise cv.Invalid( @@ -458,14 +474,27 @@ def final_validate(config): # Every client packet passes through the WiFi driver, none through the device's own sockets require_high_performance_wifi() if CONF_DNS_SERVERS not in config[CONF_AP]: - # The access point passes the station's resolvers on, which a static address has to name - for network in [config, *config.get(CONF_NETWORKS, [])]: + # The access point passes the upstream's resolvers on, which a static address has to name + upstreams = [ + ([], config), + *( + ([CONF_NETWORKS, i], network) + for i, network in enumerate(config.get(CONF_NETWORKS, [])) + ), + (None, full_config.get(CONF_ETHERNET, {})), + ] + for path, network in upstreams: static = network.get(CONF_MANUAL_IP) - if static and static[CONF_DNS1].is_unspecified: + if not static or not static[CONF_DNS1].is_unspecified: + continue + if path is None: raise cv.Invalid( - "manual_ip needs dns1 in WISP mode unless ap.dns_servers is set", - path=[CONF_MANUAL_IP, CONF_DNS1], + "ethernet manual_ip needs dns1 in WISP mode unless ap.dns_servers is set" ) + raise cv.Invalid( + "manual_ip needs dns1 in WISP mode unless ap.dns_servers is set", + path=[*path, CONF_MANUAL_IP, CONF_DNS1], + ) if has_ap and not has_captive_portal and not has_web_server and not ap_is_wisp: _LOGGER.warning( @@ -565,7 +594,7 @@ def _report_provisioning_credentials(config): """ from esphome.components import provisioning - if config.get(CONF_NETWORKS): + if wifi_has_sta(config): provisioning.report_hardcoded_credentials("wifi") elif CONF_AP in config: # An access point with no station credentials: the AP shuts down when the @@ -613,7 +642,6 @@ def _lease_cache_schema(value: Any) -> ConfigType: value = LEASE_CACHE_SCHEMA(value) if value[CONF_ENABLED]: cv.only_on_esp32(value) - cv.only_with_framework(Framework.ESP_IDF)(value) if CONF_STORAGE not in value: if not preferences.rtc_supported(): raise cv.Invalid( @@ -840,7 +868,7 @@ async def to_code(config): if (dpp := config.get(CONF_DPP)) is not None: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True) - add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_DPP", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_WIFI_EXT_DPP", True) if (key := dpp.get(CONF_PRIVATE_KEY)) is not None: cg.add(var.set_dpp_key(key)) if (info := dpp.get(CONF_DEVICE_INFO)) is not None: diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index c37ec44384b..37b444eb8fe 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -91,7 +91,9 @@ CompactString &CompactString::operator=(const CompactString &other) { } CompactString::CompactString(CompactString &&other) noexcept : length_(other.length_), is_heap_(other.is_heap_) { - // Copy full storage (includes null terminator for inline, or pointer for heap) + // Copy full storage (includes null terminator for inline, or pointer for heap). The bytes past the + // terminator are never read, and a fixed-size copy is 8 bytes of flash smaller than a sized one. + // NOLINTNEXTLINE(clang-analyzer-unix.cstring.UninitializedRead) std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1); other.length_ = 0; other.is_heap_ = 0; @@ -781,12 +783,9 @@ void WiFiComponent::start() { // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden) this->start_initial_connection_(); #endif -#ifdef USE_WIFI_AP - } else if (this->has_ap()) { +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) + } else if (this->has_ap()) { // a bridge waits in loop() for the upstream's resolver this->setup_ap_config_(); - if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) { - ESP_LOGV(TAG, "Setting Output Power Option failed"); - } #ifdef USE_CAPTIVE_PORTAL // Where the radio scans alongside the AP, the portal can list networks. if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) { @@ -795,7 +794,7 @@ void WiFiComponent::start() { } #endif this->start_ap_portal_(); -#endif // USE_WIFI_AP +#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP } #ifdef USE_WIFI_DPP // Without networks there is nothing to try first, so listen for a configurator right away @@ -935,15 +934,7 @@ void WiFiComponent::loop() { return; } -#ifdef USE_WIFI_AP_WISP - // The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout - if (!this->ap_setup_ && this->ap_dns_known_() && - (this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) { - ESP_LOGI(TAG, "Starting AP"); - this->ap_changed_ = now; - this->setup_ap_config_(); - } -#elif defined(USE_WIFI_AP) +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // Once the provisioning window has closed, don't bring up the fallback AP (or // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. @@ -973,7 +964,7 @@ void WiFiComponent::loop() { this->start_ap_portal_(); } } -#endif // USE_WIFI_AP +#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP #ifdef USE_IMPROV_BLE if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() && @@ -1011,6 +1002,16 @@ void WiFiComponent::loop() { } } +#ifdef USE_WIFI_AP_WISP + // The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout + if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && this->ap_dns_known_() && + (this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) { + ESP_LOGI(TAG, "Starting AP"); + this->ap_changed_ = now; + this->setup_ap_config_(); + } +#endif + #ifdef USE_WIFI_DPP // Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again // here, at most once per timeout @@ -1096,6 +1097,11 @@ void WiFiComponent::setup_ap_config_() { // Reset before the AP exists: afterwards only the platform's event path writes the counter this->ap_clients_ = 0; this->ap_setup_ = this->wifi_start_ap_(this->ap_); + // The station path applied it in start(); without a station the radio only exists from here + if (this->ap_setup_ && !this->has_sta() && !std::isnan(this->output_power_) && + !this->wifi_apply_output_power_(this->output_power_)) { + ESP_LOGV(TAG, "Setting Output Power Option failed"); + } #if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse // the STA attempts until the next try, and a bridge would take clients it cannot serve. diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 1048df19c39..c167e0af1b0 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -16,6 +16,7 @@ #endif #include "esphome/core/string_ref.h" +#include #include #include #include @@ -813,13 +814,8 @@ class WiFiComponent final : public Component { } bool all_networks_hidden_() const { - if (this->sta_.empty()) - return false; - for (const auto &ap : this->sta_) { - if (!ap.get_hidden()) - return false; - } - return true; + return !this->sta_.empty() && + std::all_of(this->sta_.begin(), this->sta_.end(), [](const WiFiAP &ap) { return ap.get_hidden(); }); } bool wifi_loop_(); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 20d3ab91b71..5e96c19af6f 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -62,7 +62,6 @@ static esp_netif_t *s_sta_netif = nullptr; // NOLINT(cppcoreguidelines-avoid #ifdef USE_WIFI_AP static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif // USE_WIFI_AP -static bool s_sta_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connect_not_found = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -128,6 +127,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi #if USE_NETWORK_IPV6 } else if (event_base == IP_EVENT && event_id == IP_EVENT_GOT_IP6) { memcpy(&event.data.ip_got_ip6, event_data, sizeof(ip_event_got_ip6_t)); +#endif +#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS) + } else if (event_base == IP_EVENT && event_id == IP_EVENT_ETH_GOT_IP) { // NOLINT(bugprone-branch-clone) + // no data: the bridge rereads the resolvers an ethernet upstream just received #endif } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) { // NOLINT(bugprone-branch-clone) // no data @@ -727,7 +730,7 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { uint32_t age = lease_cache_age_secs(lease); uint32_t remaining = lease.lease_secs > age ? lease.lease_secs - age : 0; - ESP_LOGI(TAG, "Applying cached lease " IPSTR ", ~%us left, skipping DHCP", IP2STR(&ip4), remaining); + ESP_LOGI(TAG, "Applying cached lease " IPSTR ", ~%" PRIu32 "s left, skipping DHCP", IP2STR(&ip4), remaining); // Still awake at T1: switch to real DHCP. lease_valid_for_apply_() guarantees age < T1. uint32_t renew_in = lease_renew_at_secs(lease) - age; @@ -843,7 +846,7 @@ void WiFiComponent::save_lease_settings_() { this->lease_cache_pref_.save(&lease); this->cached_lease_ = lease; - ESP_LOGD(TAG, "Lease cache: saved " IPSTR " (network %d, lease %us)", IP2STR(&info.ip), lease.ap_index, + ESP_LOGD(TAG, "Lease cache: saved " IPSTR " (network %d, lease %" PRIu32 "s)", IP2STR(&info.ip), lease.ap_index, lease.lease_secs); } #endif @@ -1037,7 +1040,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err)); } - s_sta_started = true; // re-apply power save mode wifi_apply_power_save_(); #ifdef SOC_WIFI_SUPPORT_5G @@ -1046,7 +1048,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); - s_sta_started = false; s_sta_connecting = false; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { @@ -1126,6 +1127,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { this->notify_disconnect_state_listeners_(); #endif +#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS) + } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ETH_GOT_IP) { + this->apply_ap_dns_(); +#endif } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) { const auto &it = data->data.ip_got_ip; ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); @@ -1400,10 +1405,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { esp_err_t err; - // enable AP - if (!this->wifi_mode_({}, true)) - return false; - // Check if the AP interface is initialized before using it if (s_ap_netif == nullptr) { ESP_LOGW(TAG, "AP interface not initialized"); @@ -1481,8 +1482,8 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { #endif err = esp_netif_dhcps_start(s_ap_netif); - - if (err != ESP_OK) { + // Before the access point is up, its start event brings the server up with these settings + if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_IF_NOT_READY) { ESP_LOGE(TAG, "esp_netif_dhcps_start failed: %d", err); return false; } @@ -1491,6 +1492,17 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { } bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { + // The address and DHCP settings go in before the access point starts: its start event starts the DHCP + // server on the event task, and settings written after that race it. +#ifdef USE_WIFI_MANUAL_IP + if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) { +#else + if (!this->wifi_ap_ip_config_({})) { +#endif + ESP_LOGE(TAG, "wifi_ap_ip_config_ failed:"); + return false; + } + // enable AP if (!this->wifi_mode_({}, true)) return false; @@ -1527,19 +1539,6 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { ESP_LOGE(TAG, "esp_wifi_set_config failed: %d", err); return false; } - -#ifdef USE_WIFI_MANUAL_IP - if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) { - ESP_LOGE(TAG, "wifi_ap_ip_config_ failed:"); - return false; - } -#else - if (!this->wifi_ap_ip_config_({})) { - ESP_LOGE(TAG, "wifi_ap_ip_config_ failed:"); - return false; - } -#endif - return true; } @@ -1684,7 +1683,7 @@ bool WiFiComponent::write_ap_dns_() { #ifndef USE_WIFI_AP_WISP_STATIC_DNS void WiFiComponent::apply_ap_dns_() { if (!this->ap_setup_) - return; // loop() brings the bridge up once the station has a resolver, and its setup writes them + return; // loop() brings the bridge up once the upstream has a resolver, and its setup writes them // The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the // next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address. bool changed = false; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index c1620154e6c..0c93100dd16 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -166,8 +166,9 @@ WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED; case CYW43_LINK_NONET: return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND; + default: + return WiFiSTAConnectStatus::IDLE; } - return WiFiSTAConnectStatus::IDLE; } int WiFiComponent::s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) { diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp index f502cb1f660..b6c5cb38017 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp @@ -5,7 +5,7 @@ namespace esphome::xiaomi_hhccpot002 { ESPHOME_LOG_TAG(TAG, "xiaomi_hhccpot002"); -void XiaomiHHCCPOT002 ::dump_config() { +void XiaomiHHCCPOT002::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi HHCCPOT002"); LOG_SENSOR(" ", "Moisture", this->moisture_); LOG_SENSOR(" ", "Conductivity", this->conductivity_); diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp index 519358270d3..84e406a7ab0 100644 --- a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp @@ -60,7 +60,7 @@ bool XiaomiMCCGQ02HL::parse_device(const ble_device_base::ESPBTDevice &device) { if (!this->parse_service_data_(service_data.data, reading)) continue; - ESP_LOGD(TAG, "%s: open=%s light=%s battery=%s", this->get_name().c_str(), + ESP_LOGD(TAG, "%s: open=%s light=%s battery=%s", LOG_STR_ARG(this->get_log_name()), reading.open.has_value() ? (*reading.open ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no")) : LOG_STR_LITERAL("-"), reading.light.has_value() ? (*reading.light ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no")) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 1ba3ec098cd..b0e273c9324 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -169,6 +169,9 @@ def add_extra_script(stage: str, filename: str, path: Path) -> None: def zephyr_to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_ZEPHYR") + # Zephyr's base warnings include -Wdouble-promotion, which every float passed to a log call + # trips; the promotion is how variadic arguments work, so the warning only adds noise + cg.add_build_flag("-Wno-double-promotion") cg.add_define("USE_NATIVE_64BIT_TIME") # The settings subsystem finds stored preferences by key, so key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index d5522da03aa..e4668f9d036 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -115,7 +115,7 @@ int ZephyrPreferences::load_setting(const char *name, size_t len, settings_read_ auto type = parse_hex(name); if (!type.has_value()) { std::string full_name(ESPHOME_SETTINGS_KEY); - full_name += "/"; + full_name += '/'; full_name += name; // Delete unusable keys. Otherwise it will stay in flash forever. settings_delete(full_name.c_str()); diff --git a/esphome/components/zephyr_ble_server/ble_server.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index c890fba3d72..5f99ef092b6 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -40,8 +40,9 @@ static void advertise(k_work *work) { truncate_name_to(name, sizeof(name), app_name.c_str(), app_name.length(), App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); const uint8_t name_type = name_len < app_name.length() ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE; + static constexpr uint8_t FLAGS[] = {BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR}; const bt_data ad[] = { - BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), + BT_DATA(BT_DATA_FLAGS, FLAGS, sizeof(FLAGS)), BT_DATA(name_type, name, static_cast(name_len)), }; @@ -255,6 +256,8 @@ static const char *role_str(uint8_t role) { return "Central"; case BT_CONN_ROLE_PERIPHERAL: return "Peripheral"; + default: + break; } return "Unknown"; diff --git a/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp b/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp index af126a29c7a..fd45f930c82 100644 --- a/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp +++ b/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp @@ -80,6 +80,8 @@ static const char *swap_type_str(uint8_t type) { return "revert"; case BOOT_SWAP_TYPE_FAIL: return "fail"; + default: + break; } return "unknown"; diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 9af419919ba..32500a69223 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2593,6 +2593,7 @@ def _validate_no_slash(value): # This ensures web server URL IDs fit in a 280-byte buffer: # domain(20) + "/" + device(120) + "/" + name(120) + null = 263 bytes # Note: Must be < 255 because web_server UrlMatch uses uint8_t for length fields +# Keep in sync with ESPHOME_ENTITY_NAME_MAX_LEN in esphome/core/entity_base.h NAME_MAX_LENGTH = 120 diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 9b8f9f98642..ea71d430ec9 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -658,6 +658,8 @@ class EsphomeCore: self.main_statements: list[Statement] = [] # A list of statements to insert in the global block (includes and global variables) self.global_statements: list[Statement] = [] + # Generated code emitted after the user includes, so it can use what they declare + self.global_statements_after_includes: list[Statement] = [] # A map of platformio libraries to add to the project (shortname: (name, version, repository)) self.platformio_libraries: dict[str, Library] = {} # A set of build flags to set in the platformio project @@ -728,6 +730,7 @@ class EsphomeCore: self.variables = {} self.main_statements = [] self.global_statements = [] + self.global_statements_after_includes = [] self.platformio_libraries = {} self.build_flags = set() self.cmake_args = {} @@ -1045,8 +1048,8 @@ class EsphomeCore: except RuntimeError as e: raise EsphomeError(str(e)) from e - def add(self, expression, prepend=False) -> "Statement": - """Add an expression or statement to the main setup() block.""" + @staticmethod + def _as_statement(expression) -> "Statement": from esphome.cpp_generator import Expression, Statement, statement if isinstance(expression, Expression): @@ -1055,7 +1058,11 @@ class EsphomeCore: raise ValueError( f"Add '{expression}' must be expression or statement, not {type(expression)}" ) + return expression + def add(self, expression, prepend=False) -> "Statement": + """Add an expression or statement to the main setup() block.""" + expression = self._as_statement(expression) if prepend: self.main_statements.insert(0, expression) else: @@ -1064,14 +1071,7 @@ class EsphomeCore: return expression def add_global(self, expression, prepend=False) -> "Statement": - from esphome.cpp_generator import Expression, Statement, statement - - if isinstance(expression, Expression): - expression = statement(expression) - if not isinstance(expression, Statement): - raise ValueError( - f"Add '{expression}' must be expression or statement, not {type(expression)}" - ) + expression = self._as_statement(expression) if prepend: self.global_statements.insert(0, expression) else: @@ -1079,6 +1079,13 @@ class EsphomeCore: _LOGGER.debug("Adding global: %s", expression) return expression + def add_global_after_includes(self, expression) -> "Statement": + """Add a global after the user includes, for generated code that may use them.""" + expression = self._as_statement(expression) + self.global_statements_after_includes.append(expression) + _LOGGER.debug("Adding global after includes: %s", expression) + return expression + def add_library(self, library: Library): if not isinstance(library, Library): raise TypeError( @@ -1258,7 +1265,7 @@ class EsphomeCore: from esphome.cpp_generator import statement global_code = [] - for exp in self.global_statements: + for exp in (*self.global_statements, *self.global_statements_after_includes): text = str(statement(exp)) text = text.rstrip() global_code.append(text) diff --git a/esphome/core/config.py b/esphome/core/config.py index 667d780f71f..ea067a21693 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -69,6 +69,7 @@ from esphome.helpers import ( from esphome.types import ConfigType CONF_LOOP_INTERVAL = "loop_interval" +CONF_RELATION = "relation" CONF_SUSPEND_LOOP = "suspend_loop" _LOGGER = logging.getLogger(__name__) @@ -118,6 +119,17 @@ ProjectUpdateTrigger = cg.esphome_ns.class_( "ProjectUpdateTrigger", cg.Component, automation.Trigger.template(cg.std_string) ) Device = cg.esphome_ns.class_("Device") +DeviceRelation = cg.esphome_ns.enum("DeviceRelation", is_class=True) +DEVICE_RELATIONS: dict[str, cg.MockObj] = DeviceRelation.options( + "via", "child", prefix="DEVICE_RELATION_" +) +# Relation the C++ getter and field use when nothing sets one, and the API's default +DEFAULT_DEVICE_RELATION: str = "via" +# Define for a relation shared by every device +SHARED_DEVICE_RELATION_DEFINES: dict[str, str | None] = { + DEFAULT_DEVICE_RELATION: None, + "child": "USE_DEVICE_RELATION_ALL_CHILD", +} Area = cg.esphome_ns.class_("Area") VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"} @@ -295,6 +307,7 @@ DEVICE_SCHEMA = cv.Schema( cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA_ID): cv.use_id(Area), + cv.Required(CONF_RELATION): cv.enum(DEVICE_RELATIONS, lower=True), } ) @@ -577,7 +590,8 @@ async def add_arduino_global_workaround(): @coroutine_with_priority(CoroPriority.FINAL) async def add_includes(includes: list[str], is_c_header: bool = False) -> None: - # Add includes at the very end, so that the included files can access global variables + # After the id declarations, so the headers can use id(x); the generated function bodies + # and flash tables come later still, so their lambdas can use the headers. for include in includes: path = CORE.relative_config_path(include) if path.is_dir(): @@ -943,6 +957,15 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_DEVICES") cg.add_define("ESPHOME_DEVICE_COUNT", len(devices)) + # When every device shares one relation the getter returns it as a constant, + # so only a mix of relations needs a per-device field + relations: set[str] = {dev_conf[CONF_RELATION] for dev_conf in devices} + mixed_relations: bool = len(relations) > 1 + if mixed_relations: + cg.add_define("USE_DEVICE_RELATION") + elif (shared_define := SHARED_DEVICE_RELATION_DEFINES[relations.pop()]) is not None: + cg.add_define(shared_define) + # Process each device for dev_conf in devices: device_id: core.ID = dev_conf[CONF_ID] @@ -959,6 +982,13 @@ async def to_code(config: ConfigType) -> None: area_id_hash = fnv1a_32bit_hash(area_id.id) cg.add(dev.set_area_id(area_id_hash)) + # The field only exists for a mix of relations, and starts as VIA + if ( + mixed_relations + and (relation := dev_conf[CONF_RELATION]) != DEFAULT_DEVICE_RELATION + ): + cg.add(dev.set_relation(relation)) + cg.add(cg.App.register_device(dev)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8cd16afa4d9..2d2d9a32ebc 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,6 +70,7 @@ #define USE_DEEP_SLEEP #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES +#define USE_DEVICE_RELATION #define USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION #define DFROBOT_SEN0465_COMPENSATION_RANGES 3 #define USE_DISPLAY @@ -296,6 +297,7 @@ #define USE_STATUS_LED #define USE_STATUS_SENSOR #define USE_SWITCH +#define USE_SWITCH_STORAGE #define USE_TEXT #define USE_TEXT_SENSOR #define USE_TEXT_SENSOR_FILTER @@ -350,6 +352,7 @@ #define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES #define USE_API_USER_DEFINED_ACTION_METADATA +#define USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 @@ -434,6 +437,9 @@ #define USE_ESP32_CRASH_HANDLER #define USE_ESP32_VASPRINTF_STUB #define USE_MQTT_IDF_ENQUEUE +#define USE_MQTT_TLS +#define USE_MQTT_CERT_BUNDLE +#define USE_MQTT_TLS_INSECURE #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK diff --git a/esphome/core/device.h b/esphome/core/device.h index 3d0d1e7c23b..29101d7f2d3 100644 --- a/esphome/core/device.h +++ b/esphome/core/device.h @@ -1,7 +1,19 @@ #pragma once +#include + +#include "esphome/core/defines.h" + namespace esphome { +/// How a sub-device relates to the node that exposes it. Values match the API's DeviceRelation enum. +enum class DeviceRelation : uint8_t { + /// A separate physical device reached through this node (for example over RF, UART or ESP-NOW). + DEVICE_RELATION_VIA = 0, + /// A logical part of this node (for example one zone of a multi-zone controller). + DEVICE_RELATION_CHILD = 1, +}; + class Device { public: void set_device_id(uint32_t device_id) { this->device_id_ = device_id; } @@ -10,11 +22,23 @@ class Device { const char *get_name() { return this->name_; } void set_area_id(uint32_t area_id) { this->area_id_ = area_id; } uint32_t get_area_id() { return this->area_id_; } +#if defined(USE_DEVICE_RELATION) + // Only emitted when devices have different relations; otherwise the getter is a constant and costs no RAM. + void set_relation(DeviceRelation relation) { this->relation_ = relation; } + DeviceRelation get_relation() const { return this->relation_; } +#elif defined(USE_DEVICE_RELATION_ALL_CHILD) + DeviceRelation get_relation() const { return DeviceRelation::DEVICE_RELATION_CHILD; } +#else + DeviceRelation get_relation() const { return DeviceRelation::DEVICE_RELATION_VIA; } +#endif protected: uint32_t device_id_{}; uint32_t area_id_{}; const char *name_ = ""; +#ifdef USE_DEVICE_RELATION + DeviceRelation relation_{DeviceRelation::DEVICE_RELATION_VIA}; +#endif }; } // namespace esphome diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 0e3caa65fca..ab04310c087 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -1,20 +1,53 @@ #include "esphome/core/entity_base.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" +#include + namespace esphome { ESPHOME_LOG_TAG(TAG, "entity_base"); +#ifdef USE_ESP8266 +namespace { +// RAM copies made by the deprecated get_name(), only for entities whose name is asked for. Remove before 2027.5.0 +struct NameCopy { + NameCopy *next; + const char *key; + StringRef ref; +}; +NameCopy *name_copies = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +const StringRef EMPTY_NAME; +} // namespace + +const StringRef &EntityBase::get_name() const { + const char *key = this->name_.progmem_ptr(); + for (NameCopy *copy = name_copies; copy != nullptr; copy = copy->next) { + if (copy->key == key) + return copy->ref; + } + size_t len = this->name_.size(); + void *mem = malloc(sizeof(NameCopy) + len + 1); // NOLINT(cppcoreguidelines-no-malloc) + if (mem == nullptr) + return EMPTY_NAME; + char *buf = static_cast(mem) + sizeof(NameCopy); + this->name_.write_to(buf, len + 1); + name_copies = new (mem) NameCopy{name_copies, key, StringRef(buf, len)}; + return name_copies->ref; +} +#endif + void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { - this->name_ = StringRef(name); + this->name_ = ProgmemStringRef(name, ESPHOME_strlen_P(name)); if (this->name_.empty()) { + StringRef fallback; #ifdef USE_DEVICES if (this->device_ != nullptr) { - this->name_ = StringRef(this->device_->get_name()); + fallback = StringRef(this->device_->get_name()); } else #endif { @@ -24,23 +57,27 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui const auto &friendly = App.get_friendly_name(); if (App.is_name_add_mac_suffix_enabled()) { // MAC suffix enabled - use friendly_name directly (even if empty) for compatibility - this->name_ = friendly; + fallback = friendly; } else { // No MAC suffix - fallback to device name if friendly_name is empty - this->name_ = !friendly.empty() ? friendly : App.get_name(); + fallback = !friendly.empty() ? friendly : App.get_name(); } } + this->name_ = ProgmemStringRef(fallback); this->flags_.has_own_name = false; - // Dynamic name - must calculate hash at runtime - this->calc_object_id_(); + // Dynamic name in RAM - hash it at runtime + this->object_id_hash_ = fnv1_hash_object_id(fallback.c_str(), fallback.size()); } else { this->flags_.has_own_name = true; - // Static name - use pre-computed hash if provided - if (object_id_hash != 0) { - this->object_id_hash_ = object_id_hash; - } else { - this->calc_object_id_(); + // Static name - codegen precomputes the hash; external register_* callers may pass 0 + if (object_id_hash == 0) { + // The name is in flash on ESP8266, so read it byte by byte through progmem_read_byte() + const char *name_p = this->name_.progmem_ptr(); + object_id_hash = fnv1_hash_object_id(this->name_.size(), [name_p](size_t i) { + return static_cast(progmem_read_byte(reinterpret_cast(name_p + i))); + }); } + this->object_id_hash_ = object_id_hash; } // Unpack entity string table indices and flags from entity_fields. #ifdef USE_ENTITY_DEVICE_CLASS @@ -61,7 +98,7 @@ void EntityBase::set_internal(bool internal) { // Remove the after-setup path in 2027.3.0 and ignore the call instead. if (App.is_setup_complete()) { ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0", - this->get_name().c_str()); + LOG_STR_ARG(this->get_log_name())); } this->flags_.internal = internal; } @@ -71,22 +108,22 @@ __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return " __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ""; } -// Entity device class — buffer-based API for PROGMEM safety on ESP8266 -const char *EntityBase::get_device_class_to([[maybe_unused]] std::span buffer) const { +static ProgmemStringRef table_ref(const char *s) { return {s, ESPHOME_strlen_P(s)}; } + +ProgmemStringRef EntityBase::get_device_class_progmem() const { #ifdef USE_ENTITY_DEVICE_CLASS - const uint8_t idx = this->device_class_idx_; + return table_ref(entity_device_class_lookup(this->device_class_idx_)); #else - const uint8_t idx = 0; + return {}; #endif +} + +const char *EntityBase::get_device_class_to([[maybe_unused]] std::span buffer) const { #ifdef USE_ESP8266 - if (idx == 0) - return ""; - const char *dc = entity_device_class_lookup(idx); - ESPHOME_strncpy_P(buffer.data(), dc, buffer.size() - 1); - buffer[buffer.size() - 1] = '\0'; + this->get_device_class_progmem().write_to(buffer.data(), buffer.size()); return buffer.data(); #else - return entity_device_class_lookup(idx); + return this->get_device_class_progmem().progmem_ptr(); #endif } @@ -98,36 +135,28 @@ StringRef EntityBase::get_unit_of_measurement_ref() const { return StringRef(entity_uom_lookup(0)); #endif } -// Entity icon — buffer-based API for PROGMEM safety on ESP8266 -const char *EntityBase::get_icon_to([[maybe_unused]] std::span buffer) const { +ProgmemStringRef EntityBase::get_icon_progmem() const { #ifdef USE_ENTITY_ICON - const uint8_t idx = this->icon_idx_; + return table_ref(entity_icon_lookup(this->icon_idx_)); #else - const uint8_t idx = 0; -#endif -#ifdef USE_ESP8266 - if (idx == 0) - return ""; - const char *icon = entity_icon_lookup(idx); - ESPHOME_strncpy_P(buffer.data(), icon, buffer.size() - 1); - buffer[buffer.size() - 1] = '\0'; - return buffer.data(); -#else - return entity_icon_lookup(idx); + return {}; #endif } -// Calculate Object ID Hash directly from name using snake_case + sanitize -void EntityBase::calc_object_id_() { - this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); +const char *EntityBase::get_icon_to([[maybe_unused]] std::span buffer) const { +#ifdef USE_ESP8266 + this->get_icon_progmem().write_to(buffer.data(), buffer.size()); + return buffer.data(); +#else + return this->get_icon_progmem().progmem_ptr(); +#endif } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { - size_t len = std::min(this->name_.size(), buf_size - 1); + size_t len = this->write_name_to(buf, buf_size); for (size_t i = 0; i < len; i++) { - buf[i] = to_sanitized_char(to_snake_case_char(this->name_[i])); + buf[i] = to_sanitized_char(to_snake_case_char(buf[i])); } - buf[len] = '\0'; return len; } @@ -159,25 +188,23 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve bool log_entity_header(const char *tag, const char *prefix, const char *type, const EntityBase *obj) { if (obj == nullptr) return false; - ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, obj->get_name().c_str()); + ESP_LOGCONFIG(tag, "%s%s '%s'", prefix, type, LOG_STR_ARG(obj->get_log_name())); return true; } #ifdef USE_ENTITY_ICON void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) { - char icon_buf[MAX_ICON_LENGTH]; - const char *icon = obj.get_icon_to(icon_buf); - if (icon[0] != '\0') { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, icon); + ProgmemStringRef icon = obj.get_icon_progmem(); + if (!icon.empty()) { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, icon.progmem_ptr()); } } #endif void log_entity_device_class(const char *tag, const char *prefix, const EntityBase &obj) { - char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - const char *dc = obj.get_device_class_to(dc_buf); - if (dc[0] != '\0') { - ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, dc); + ProgmemStringRef dc = obj.get_device_class_progmem(); + if (!dc.empty()) { + ESP_LOGCONFIG(tag, "%s Device Class: '%s'", prefix, dc.progmem_ptr()); } } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5d67f6bc198..48533ad11f7 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -31,6 +31,11 @@ static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; // esphome/core/config.py static constexpr size_t ESPHOME_FRIENDLY_NAME_MAX_LEN = 120; +// Maximum entity name length - keep in sync with NAME_MAX_LENGTH in esphome/config_validation.py +static constexpr size_t ESPHOME_ENTITY_NAME_MAX_LEN = 120; + +static constexpr size_t ENTITY_NAME_BUF_SIZE = ESPHOME_ENTITY_NAME_MAX_LEN + 1; + // Maximum domain length (longest: "alarm_control_panel" = 19) static constexpr size_t ESPHOME_DOMAIN_MAX_LEN = 20; @@ -67,8 +72,32 @@ static constexpr uint8_t ENTITY_FIELD_ENTITY_CATEGORY_SHIFT = 26; // The generic Entity base class that provides an interface common to all Entities. class EntityBase { public: - // Get the name of this Entity - const StringRef &get_name() const { return this->name_; } +#ifdef USE_ESP8266 + /// The name is in flash here; the first call makes a RAM copy that is kept for the life of the device. + // Remove before 2027.5.0 + ESPDEPRECATED("Use get_name_to() or get_log_name() instead. Will be removed in ESPHome 2027.5.0", "2026.11.0") + const StringRef &get_name() const; + /// Get the name of this Entity, copied out of flash into buffer. + StringRef get_name_to(std::span buffer) const { + return StringRef(buffer.data(), this->write_name_to(buffer.data(), buffer.size())); + } +#else + // Remove before 2027.5.0 + ESPDEPRECATED("Use get_name_to() or get_log_name() instead. Will be removed in ESPHome 2027.5.0", "2026.11.0") + const StringRef &get_name() const { return this->name_.ram_ref(); } + const StringRef &get_name_to(std::span /*buffer*/) const { return this->name_.ram_ref(); } +#endif + + bool name_equals(const StringRef &other) const { return this->name_.equals(other); } + + /// The name as stored, for consumers that read flash themselves (e.g. API progmem fields). + const ProgmemStringRef &get_name_progmem() const { return this->name_; } + + /// Copy the name into buf (null terminated), returns its length. + size_t write_name_to(char *buf, size_t buf_size) const { return this->name_.write_to(buf, buf_size); } + + /// Get the name for a "%s" log argument (LOG_STR_ARG). + const LogString *get_log_name() const { return reinterpret_cast(this->name_.progmem_ptr()); } // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } @@ -121,6 +150,8 @@ class EntityBase { // On non-ESP8266: returns pointer to PROGMEM string directly (buffer unused). // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_device_class_to(std::span buffer) const; + /// The device class as stored (in flash on ESP8266), for consumers that read flash themselves. + ProgmemStringRef get_device_class_progmem() const; // Get unit of measurement as StringRef (from packed index) StringRef get_unit_of_measurement_ref() const; @@ -129,6 +160,8 @@ class EntityBase { // On ESP32: returns pointer to PROGMEM string directly (buffer unused). // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_icon_to(std::span buffer) const; + /// The icon as stored (in flash on ESP8266), for consumers that read flash themselves. + ProgmemStringRef get_icon_progmem() const; #ifdef USE_DEVICES // Get this entity's device id @@ -218,9 +251,7 @@ class EntityBase { /// Key for both helpers; preference hash migration goes here. uint32_t entity_preference_key_(uint32_t version); - void calc_object_id_(); - - StringRef name_; + ProgmemStringRef name_; uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 9ac6286443e..d44c01fa7ee 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -25,7 +25,14 @@ from esphome.core.config import ( ICON_MAX_LENGTH, UNIT_OF_MEASUREMENT_MAX_LENGTH, ) -from esphome.cpp_generator import MockObj, MockObjClass, RawStatement, add, get_variable +from esphome.cpp_generator import ( + MockObj, + MockObjClass, + RawExpression, + RawStatement, + add, + get_variable, +) from esphome.cpp_types import App import esphome.final_validate as fv from esphome.helpers import ( @@ -35,6 +42,7 @@ from esphome.helpers import ( sanitize, snake_case, ) +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType _LOGGER = logging.getLogger(__name__) @@ -308,7 +316,9 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: ``App.register_(var, name, hash, packed)``. Otherwise falls back to a standalone ``var->configure_entity_(name, hash, packed)``. """ - entity_name = config[_KEY_ENTITY_NAME] + entity_name = RawExpression( + f"ESPHOME_PSTR({cpp_string_escape(config[_KEY_ENTITY_NAME])})" + ) object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) @@ -524,7 +534,8 @@ class SubEntities: """Create the entity from ``conf``; ``setter`` may be None when the hub keeps no pointer. With ``apply`` the entity is the domain's ``Apply*`` class and its control runs - ``parent->apply``, with ``publish`` ("before"/"after") placing the domain's publish. + ``parent->apply``, or ``apply`` as a namespaced free statement when no parent is bound, with + ``publish`` ("before"/"after") placing the domain's publish. """ entity_id: ID = conf[CONF_ID] is_apply_class = self.apply_entity is not None and str(entity_id.type) == str( @@ -536,8 +547,13 @@ class SubEntities: raise ValueError( f"{entity_id}: apply= needs the domain's Apply class, not {entity_id.type}" ) - if apply is not None and self.parent is None: - raise ValueError(f"{entity_id}: apply= needs a parent") + # A parentless statement is a namespaced free function, so a hub platform that forgot + # parent= fails here instead of in the generated C++ + if apply is not None and self.parent is None and "::" not in apply: + raise ValueError( + f"{entity_id}: apply= without a parent must call a namespaced free function " + f"(ns::fn()); bind parent= for a hub method" + ) if publish is not None and (apply is None or self.apply_entity.publish is None): raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain") if publish not in (None, "before", "after"): @@ -562,7 +578,7 @@ class SubEntities: if isinstance(parent, ID): parent = await get_variable(parent) apply_entity = self.apply_entity - body = [f"{parent_ref(parent)}->{apply};"] + body = [f"{apply};" if parent is None else f"{parent_ref(parent)}->{apply};"] if publish == "before": body.insert(0, apply_entity.publish) elif publish == "after": @@ -749,3 +765,50 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy return config return validator + + +# restore_state: keywords, shared by the entity domains that save their state +RESTORE_STATE_KEEP = "KEEP" +RESTORE_STATE_INVERT = "INVERT" +RESTORE_STATE_INITIAL = "INITIAL" +RESTORE_STATE_ALL = "ALL" +RESTORE_STATE_NONE = "NONE" + + +def restore_state_keyword(value: Any, keywords: tuple[str, ...]) -> str | None: + """The upper-cased keyword when `value` spells one of `keywords`, else None.""" + if isinstance(value, str): + upper = value.strip().upper() + if upper in keywords: + return upper + return None + + +def restore_state_schema(fields_schema: cv.Schema, none: Any) -> Callable[[Any], Any]: + """The validator for a domain's `restore_state:` key. + + A mapping is validated with `fields_schema`. The shorthand `all` is the empty mapping, + so every field keeps its saved value, and `none` is returned as it is; a mapping goes through + `fields_schema` like one the user wrote, so each config gets its own copy. + """ + + @schema_extractor("schema") + def validate(value: Any) -> Any: + if value == SCHEMA_EXTRACT: + # The shorthands have no representation in the extracted schema + return fields_schema + keyword = restore_state_keyword(value, (RESTORE_STATE_ALL, RESTORE_STATE_NONE)) + if keyword == RESTORE_STATE_ALL: + value = {} + elif keyword == RESTORE_STATE_NONE: + if not isinstance(none, dict): + return none + value = none + elif isinstance(value, str): + raise cv.Invalid( + f"Unknown restore_state '{value}'; expected 'all', 'none' or a mapping, " + "for example '{state: INVERT}'" + ) + return fields_schema(value) + + return validate diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d0e3c91ab0b..3d19d211b71 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1067,6 +1067,16 @@ constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x /// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59. constexpr uint8_t uint8_to_bcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); } +/// Set the bits of \p mask in \p value when \p on is true, clear them otherwise. +/// Use an unsigned mask for a register wider than int; a negative int mask is sign-extended. +template ESPHOME_ALWAYS_INLINE constexpr void assign_bits(T &value, std::type_identity_t mask, bool on) { + if (on) { + value |= mask; + } else { + value &= ~mask; + } +} + /// Reverse the order of 8 bits. inline uint8_t reverse_bits(uint8_t x) { x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1); @@ -1185,17 +1195,21 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. /// This computes object_id hashes directly from names without creating an intermediate buffer. +/// read_byte(i) returns character i, so a name in flash can be hashed through progmem_read_byte(). /// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. /// If you modify this function, update the Python version and tests in both places. -inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { +template inline uint32_t fnv1_hash_object_id(size_t len, ReadByte read_byte) { uint32_t hash = FNV1_OFFSET_BASIS; for (size_t i = 0; i < len; i++) { hash *= FNV1_PRIME; // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize - hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); + hash ^= static_cast(to_sanitized_char(to_snake_case_char(read_byte(i)))); } return hash; } +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { + return fnv1_hash_object_id(len, [str](size_t i) { return str[i]; }); +} #ifdef USE_ESP8266 // ESP8266: Use vsnprintf_P to keep format strings in flash (PROGMEM) diff --git a/esphome/core/lock_free_queue.h b/esphome/core/lock_free_queue.h index 316d9c79289..5cc5d583da2 100644 --- a/esphome/core/lock_free_queue.h +++ b/esphome/core/lock_free_queue.h @@ -59,13 +59,15 @@ template class PlainAtomic { constexpr PlainAtomic(T value) : value_(value) {} T load(std::memory_order order = std::memory_order_seq_cst) const { T value = value_; - if (order != std::memory_order_relaxed) + if (order != std::memory_order_relaxed) { ESPHOME_LFQ_COMPILER_BARRIER(); // acquire: later reads may not hoist above this load + } return value; } void store(T value, std::memory_order order = std::memory_order_seq_cst) { - if (order != std::memory_order_relaxed) + if (order != std::memory_order_relaxed) { ESPHOME_LFQ_COMPILER_BARRIER(); // release: earlier writes may not sink below this store + } value_ = value; } T fetch_add(T amount, std::memory_order /*order*/ = std::memory_order_seq_cst) { diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 7deea8ff664..ffebf09a220 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -226,7 +226,7 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { } /// A string that may live in flash (on ESP8266): pointer and length, with no byte-reading operations. -/// progmem_ptr() is safe as a "%s" log argument; compare the contents with equals(). +/// progmem_ptr() is safe as a "%s" log argument; read the contents with write_to() or equals(). class ProgmemStringRef { public: constexpr ProgmemStringRef() = default; @@ -239,6 +239,20 @@ class ProgmemStringRef { constexpr size_t size() const { return this->ref_.size(); } constexpr bool empty() const { return this->ref_.empty(); } + /// Copy into buf (null terminated, truncated to fit), returns the length copied. + size_t write_to(char *buf, size_t buf_size) const { + if (buf_size == 0) + return 0; + size_t len = std::min(this->ref_.size(), buf_size - 1); +#ifdef USE_ESP8266 + memcpy_P(buf, this->ref_.c_str(), len); +#else + std::memcpy(buf, this->ref_.c_str(), len); +#endif + buf[len] = '\0'; + return len; + } + bool equals(const StringRef &other) const { #ifdef USE_ESP8266 // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes @@ -249,6 +263,11 @@ class ProgmemStringRef { #endif } +#ifndef USE_ESP8266 + /// The string is in RAM here, so it can be viewed directly. + constexpr const StringRef &ram_ref() const { return this->ref_; } +#endif + // Remove before 2027.5.0 [[deprecated( "May point to flash; use progmem_ptr() or the owner's accessors. Removed in 2027.5.0")]] constexpr const char * @@ -331,8 +350,16 @@ inline void convertToJson(const StringRef &src, JsonVariant dst) { } namespace json { -// For a StringRef that outlives the document, see json_util.h. A null, empty view links "" too +// For a view that outlives the document, see json_util.h. ArduinoJson keeps a linked string as a bare +// pointer and reads it to its terminator on every platform, so the view must be a whole string, as the +// entity getters' views are. A null, empty view links "" too inline JsonString linked(const StringRef &s) { return JsonString(s.empty() ? "" : s.c_str(), s.size(), true); } +#ifdef USE_ESP8266 +// ArduinoJson copies a flash string into the document +inline ProgmemStr linked(const ProgmemStringRef &s) { return reinterpret_cast(s.progmem_ptr()); } +#else +inline JsonString linked(const ProgmemStringRef &s) { return JsonString(s.progmem_ptr(), s.size(), true); } +#endif } // namespace json #endif // USE_JSON diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index fe9a2b94a9b..2b4da192208 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -15,6 +15,8 @@ uint8_t days_in_month(uint8_t month, uint16_t year) { return DAYS_IN_MONTH[month]; } +static uint16_t days_in_year(uint16_t year) { return days_in_month(2, year) == 29 ? 366 : 365; } + size_t ESPTime::strftime(char *buffer, size_t buffer_len, const char *format) { struct tm c_tm = this->to_c_tm(); #ifdef USE_TIME_TIMEZONE @@ -252,8 +254,7 @@ void ESPTime::increment_second() { increment_time_value(this->month, 1, 13); } - uint16_t days_in_year = (this->year % 4 == 0) ? 366 : 365; - if (increment_time_value(this->day_of_year, 1, days_in_year + 1)) { + if (increment_time_value(this->day_of_year, 1, days_in_year(this->year) + 1)) { // day of year roll-over, increment year this->year++; } @@ -270,8 +271,7 @@ void ESPTime::increment_day() { increment_time_value(this->month, 1, 13); } - uint16_t days_in_year = (this->year % 4 == 0) ? 366 : 365; - if (increment_time_value(this->day_of_year, 1, days_in_year + 1)) { + if (increment_time_value(this->day_of_year, 1, days_in_year(this->year) + 1)) { // day of year roll-over, increment year this->year++; } @@ -285,7 +285,7 @@ void ESPTime::recalc_timestamp_utc(bool use_day_of_year) { } for (int i = 1970; i < this->year; i++) - res += (i % 4 == 0) ? 366 : 365; + res += days_in_year(i); if (use_day_of_year) { res += this->day_of_year - 1; diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index b17db965212..d0f2a33bda8 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -536,8 +536,10 @@ def shared_progmem_array( used |= {str(i) for i in CORE.variables} used |= set(RESERVED_IDS) | CORE.loaded_integrations id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_) - # Global, so any scope can use it; anything a lambda references is already declared. - CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs, constexpr)) + # After the user includes, so a lambda in the table can use what their headers declare. + CORE.add_global_after_includes( + ProgmemAssignmentExpression(type_, id_, rhs, constexpr) + ) array = MockObj(id_, ".") CORE.register_variable(id_, array) if share: @@ -789,15 +791,16 @@ def static_function( """Emit ``static (parameters) { body }`` at global scope and return an expression naming it, for use as a template argument or a function pointer. - Every id the body names must already be declared, which holds when the statements were - rendered through ``get_variable`` or ``process_lambda``. + The declaration goes with the other globals, so a template argument can name the function + at once; the body goes after the user includes, where every id and everything those + headers declare is in scope. """ - params = ParameterListExpression(*parameters) - add_global( - RawStatement( - f"static {safe_exp(return_type)} {name}({params}) {{\n" - f"{indent(chr(10).join(body))}\n}}" - ) + signature = ( + f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})" + ) + add_global(RawStatement(f"{signature};")) + add_global_after_includes( + RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}") ) return RawExpression(name) @@ -816,6 +819,13 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False): CORE.add_global(expression, prepend) +def add_global_after_includes(expression: SafeExpType | Statement): + """Add a global after the user's ``includes:`` headers, for generated code whose + lambdas may use what those headers declare. + """ + CORE.add_global_after_includes(expression) + + def add_library(name: str, version: str | None, repository: str | None = None): """Add a library to the codegen library storage. diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index c53dbe17dba..d1431a77448 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -151,9 +151,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: str((read_path / Path(d)).resolve()) for d in build_include_dirs ] - # Split build_flags list into private and public lists + # Split build_flags list into private and public lists. Warnings and the optimisation level only concern the + # library's own sources, so a library built with -O2 does not change how its users are compiled. private_build_flags, public_build_flags = split_list_by_condition( - build_flags, lambda a: a if a.startswith("-W") else None + build_flags, lambda a: a if a.startswith(("-W", "-O")) else None ) # Generate the component diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 2c12eec6d47..cf41c3cb41c 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -40,7 +40,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.13 + version: 3.0.10 rules: - if: "target in [esp32h2, esp32p4]" francescopace/espectre: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index efe219287da..417b45ad8ca 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import contextlib from datetime import datetime import json @@ -6,7 +8,7 @@ from pathlib import Path import ssl import tempfile import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import paho.mqtt.client as mqtt @@ -36,12 +38,21 @@ from esphome.types import ConfigType from esphome.util import safe_print if TYPE_CHECKING: + from collections.abc import Callable import threading + from paho.mqtt.properties import Properties + from paho.mqtt.reasoncodes import ReasonCode + + OnMessage = Callable[[mqtt.Client, Any, mqtt.MQTTMessage], None] + OnConnect = Callable[ + [mqtt.Client, Any, mqtt.ConnectFlags, ReasonCode, Properties | None], None + ] + _LOGGER = logging.getLogger(__name__) -def config_from_env(): +def config_from_env() -> ConfigType: return { CONF_MQTT: { CONF_USERNAME: get_str_env("ESPHOME_DASHBOARD_MQTT_USERNAME"), @@ -53,8 +64,14 @@ def config_from_env(): def initialize( - config, subscriptions, on_message, on_connect, username, password, client_id -): + config: ConfigType, + subscriptions: list[str], + on_message: OnMessage, + on_connect: OnConnect | None, + username: str | None, + password: str | None, + client_id: str | None, +) -> int: client = prepare( config, subscriptions, on_message, on_connect, username, password, client_id ) @@ -64,17 +81,35 @@ def initialize( def prepare( - config, subscriptions, on_message, on_connect, username, password, client_id -): - def on_connect_(client, userdata, flags, return_code): + config: ConfigType, + subscriptions: list[str], + on_message: OnMessage, + on_connect: OnConnect | None, + username: str | None, + password: str | None, + client_id: str | None, +) -> mqtt.Client: + def on_connect_( + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code: ReasonCode, + properties: Properties | None, + ) -> None: _LOGGER.info("Connected to MQTT broker!") for topic in subscriptions: client.subscribe(topic) if on_connect is not None: - on_connect(client, userdata, flags, return_code) + on_connect(client, userdata, flags, reason_code, properties) - def on_disconnect(client, userdata, result_code): - if result_code == 0: + def on_disconnect( + client: mqtt.Client, + userdata: Any, + flags: mqtt.DisconnectFlags, + reason_code: ReasonCode, + properties: Properties | None, + ) -> None: + if reason_code == 0: return tries = 0 @@ -88,8 +123,9 @@ def prepare( wait_time = min(2**tries, 300) _LOGGER.warning( - "Disconnected from MQTT (%s). Trying to reconnect in %s s", - result_code, + "Disconnected from MQTT (%s, rc=%s). Trying to reconnect in %s s", + reason_code, + reason_code.value, wait_time, ) time.sleep(wait_time) @@ -97,17 +133,20 @@ def prepare( # Match the firmware's transport; imported here to keep component code off the CLI start-up path from esphome.components.mqtt import ( + CONF_TLS, CONF_TRANSPORT, + CONF_VERIFY_SSL, CONF_WS_PATH, DEFAULT_WS_PATH, MQTT_TRANSPORT_TCP, - MQTT_TRANSPORT_WSS, ) transport = config[CONF_MQTT].get(CONF_TRANSPORT, MQTT_TRANSPORT_TCP) websocket = transport != MQTT_TRANSPORT_TCP client = mqtt.Client( - client_id or "", transport="websockets" if websocket else "tcp" + mqtt.CallbackAPIVersion.VERSION2, + client_id or "", + transport="websockets" if websocket else "tcp", ) client.on_connect = on_connect_ client.on_message = on_message @@ -122,15 +161,21 @@ def prepare( elif username: client.username_pw_set(username, password) - # TLS with the CA, or the system CAs over wss - certificate_authority = config[CONF_MQTT].get(CONF_CERTIFICATE_AUTHORITY) - if certificate_authority or transport == MQTT_TRANSPORT_WSS: - context = ssl.create_default_context(cadata=certificate_authority) - if config[CONF_MQTT].get(CONF_SKIP_CERT_CN_CHECK): + mqtt_conf = config[CONF_MQTT] + # Validation sets tls whenever a certificate option or the wss transport implies it + if mqtt_conf.get(CONF_TLS): + if mqtt_conf.get(CONF_VERIFY_SSL) is False: + context = ssl.create_default_context() context.check_hostname = False - if config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE) and config[CONF_MQTT].get( - CONF_CLIENT_CERTIFICATE_KEY - ): + context.verify_mode = ssl.CERT_NONE + else: + # Without a CA the system trust store stands in for the firmware's bundle + context = ssl.create_default_context( + cadata=mqtt_conf.get(CONF_CERTIFICATE_AUTHORITY) + ) + if mqtt_conf.get(CONF_SKIP_CERT_CN_CHECK): + context.check_hostname = False + if mqtt_conf.get(CONF_CLIENT_CERTIFICATE): with ( tempfile.NamedTemporaryFile( encoding="utf-8", mode="w+", delete=False @@ -140,8 +185,8 @@ def prepare( ) as key_file, ): try: - cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) - key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY)) + cert_file.write(mqtt_conf[CONF_CLIENT_CERTIFICATE]) + key_file.write(mqtt_conf[CONF_CLIENT_CERTIFICATE_KEY]) cert_file.close() key_file.close() context.load_cert_chain(cert_file.name, key_file.name) @@ -160,18 +205,29 @@ def prepare( return client -def show_discover(config, username=None, password=None, client_id=None): +def show_discover( + config: ConfigType, + username: str | None = None, + password: str | None = None, + client_id: str | None = None, +) -> int: topic = "esphome/discover/#" _LOGGER.info("Starting log output from %s", topic) - def on_message(client, userdata, msg): + def on_message(client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None: time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") if len(payload) > 0: message = time_ + " " + payload safe_print(message) - def on_connect(client, userdata, flags, return_code): + def on_connect( + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code: ReasonCode, + properties: Properties | None, + ) -> None: _LOGGER.info("Send discover via MQTT broker") client.publish("esphome/discover", None, retain=False) @@ -186,7 +242,7 @@ def get_esphome_device_ip( password: str | None = None, client_id: str | None = None, timeout: float = 25, - stop_event: "threading.Event | None" = None, + stop_event: threading.Event | None = None, ) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( @@ -210,7 +266,7 @@ def get_esphome_device_ip( topic = "esphome/discover/" + dev_name _LOGGER.info("Starting looking for IP in topic %s", topic) - def on_message(client, userdata, msg): + def on_message(client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None: nonlocal dev_ip, failed time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") payload = msg.payload.decode(errors="backslashreplace") @@ -259,7 +315,13 @@ def get_esphome_device_ip( failed = False # a complete answer wins over an earlier empty one client.disconnect() - def on_connect(client, userdata, flags, return_code): + def on_connect( + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code: ReasonCode, + properties: Properties | None, + ) -> None: topic = "esphome/ping/" + dev_name _LOGGER.info("Send discover via MQTT broker topic: %s", topic) client.publish(topic, None, retain=False) @@ -268,10 +330,20 @@ def get_esphome_device_ip( # Teardown already started; don't open a broker connection at all return [] - def on_disconnect(client, userdata, result_code): + def on_disconnect( + client: mqtt.Client, + userdata: Any, + flags: mqtt.DisconnectFlags, + reason_code: ReasonCode, + properties: Properties | None, + ) -> None: nonlocal failed - if result_code != 0: - _LOGGER.warning("Disconnected from MQTT broker (%s)", result_code) + if reason_code != 0: + _LOGGER.warning( + "Disconnected from MQTT broker (%s, rc=%s)", + reason_code, + reason_code.value, + ) failed = True mqtt_client = prepare( @@ -315,7 +387,13 @@ def get_esphome_device_ip( return dev_ip -def show_logs(config, topic=None, username=None, password=None, client_id=None): +def show_logs( + config: ConfigType, + topic: str | None = None, + username: str | None = None, + password: str | None = None, + client_id: str | None = None, +) -> int: if topic is not None: pass # already have topic elif CONF_MQTT in config: @@ -346,11 +424,11 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): birth_topic = birth.get(CONF_TOPIC) if birth_payload else None ask_on_connect = birth_topic is None or not birth[CONF_RETAIN] - def request_crash_log(client): + def request_crash_log(client: mqtt.Client) -> None: _LOGGER.debug("Requesting the crash log via topic: %s", logs_topic) client.publish(logs_topic, None, retain=False) - def on_message(client, userdata, msg): + def on_message(client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None: payload = msg.payload.decode(errors="backslashreplace") if msg.topic == birth_topic and payload == birth_payload: request_crash_log(client) @@ -359,7 +437,13 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") safe_print(time_ + payload) - def on_connect(client, userdata, flags, return_code): + def on_connect( + client: mqtt.Client, + userdata: Any, + flags: mqtt.ConnectFlags, + reason_code: ReasonCode, + properties: Properties | None, + ) -> None: if ask_on_connect: request_crash_log(client) @@ -369,7 +453,13 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): ) -def clear_topic(config, topic, username=None, password=None, client_id=None): +def clear_topic( + config: ConfigType, + topic: str | None, + username: str | None = None, + password: str | None = None, + client_id: str | None = None, +) -> int: if topic is None: discovery_prefix = config[CONF_MQTT].get(CONF_DISCOVERY_PREFIX, "homeassistant") name = config[CONF_ESPHOME][CONF_NAME] @@ -380,7 +470,7 @@ def clear_topic(config, topic, username=None, password=None, client_id=None): "MQTT topic has been cleared of retained messages." ) - def on_message(client, userdata, msg): + def on_message(client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None: if not msg.payload or not msg.retain: return try: diff --git a/esphome/util.py b/esphome/util.py index d090caeef11..f978923393f 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -201,8 +201,19 @@ class RedirectText: """Whether output is handled line by line rather than passed through.""" return self._filter_pattern is not None or bool(self._line_callbacks) + def _drop_line(self, line: str) -> None: + """Leave out a filtered line, keeping the color codes it carried. + + cmake ends a warning with the reset code on a line of its own; dropped + with the blank lines, it would leave everything after it in the + warning's color. + """ + if codes := "".join(ANSI_ESCAPE.findall(line)): + self._write_color_replace(codes) + def _emit_line(self, line: str) -> None: if self._is_filtered(line): + self._drop_line(line) return line_without_end = ANSI_ESCAPE.sub("", line).rstrip() diff --git a/esphome/writer.py b/esphome/writer.py index d5cf622c2c1..82477576118 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -267,12 +267,6 @@ def copy_src_tree(): for t in ignore_targets: source_files_copy.pop(t, None) - # Files to exclude from sources_changed tracking (generated files) - generated_files = { - Path("esphome/core/build_info_data.h"), - Path("esphome/core/build_info_data.cpp"), - } - sources_changed = False for fname in walk_files(CORE.relative_src_path("esphome")): p = Path(fname) @@ -287,13 +281,12 @@ def copy_src_tree(): if target not in source_files_copy: # Source file removed, delete target p.unlink() - if target not in generated_files: - _LOGGER.debug("Source removed: %s", target) - sources_changed = True + _LOGGER.debug("Source removed: %s", target) + sources_changed = True else: src_file = source_files_copy.pop(target) with src_file.path() as src_path: - if copy_file_if_changed(src_path, p) and target not in generated_files: + if copy_file_if_changed(src_path, p): _LOGGER.debug("Source changed: %s", target) sources_changed = True @@ -301,10 +294,7 @@ def copy_src_tree(): for target, src_file in source_files_copy.items(): dst_path = CORE.relative_src_path(*target.parts) with src_file.path() as src_path: - if ( - copy_file_if_changed(src_path, dst_path) - and target not in generated_files - ): + if copy_file_if_changed(src_path, dst_path): _LOGGER.debug("Source added: %s", target) sources_changed = True diff --git a/platformio.ini b/platformio.ini index e5d0200ea7c..9102ffa6420 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,7 +48,7 @@ lib_deps = improv/Improv@1.2.7 ; improv_serial / improv_ble kikuchan98/pngle@1.1.0 ; runtime_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image + https://github.com/esphome-libs/JPEGDEC.git#1.8.4-esphome.3 ; runtime_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -253,7 +253,7 @@ lib_deps = heman/AsyncMqttClient-esphome@2.0.0 ; mqtt improv/Improv@1.2.7 ; improv_serial kikuchan98/pngle@1.1.0 ; runtime_image - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image + https://github.com/esphome-libs/JPEGDEC.git#1.8.4-esphome.3 ; runtime_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY diff --git a/requirements.txt b/requirements.txt index 295497e4994..6ffad249409 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ cryptography==50.0.2; platform_system != "Darwin" or platform_machine != "x86_64 cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64" voluptuous==0.16.0 PyYAML==6.0.3 -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 colorama==0.4.6 tzlocal==5.4.4 # from time tzdata>=2026.5 # from time @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.6.0 click==8.5.0 -aioesphomeapi==46.14.0 +aioesphomeapi==46.21.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 @@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.4 -platformdirs==4.13.3 # native esp-idf toolchain global cache dir +platformdirs==4.13.5 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.2.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.2.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 diff --git a/requirements_dev.txt b/requirements_dev.txt index ff302530232..cb68620d01b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment -clang-format==22.1.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -clang-tidy==22.1.8 +clang-format==23.1.3 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +clang-tidy==23.1.3 yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py diff --git a/requirements_test.txt b/requirements_test.txt index ea87506def1..a3061ae8558 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==4.1.3 ruff==0.17.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -prek==0.5.5 # .github/workflows/ci.yml reads this pin +prek==0.5.6 # .github/workflows/ci.yml reads this pin yamlrocks==0.7.1 # used by script/sync_dependency_versions.py # Unit tests diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 2f601f5c947..b2116fd46ee 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2711,6 +2711,8 @@ def build_message_type( o += " const ProtoFieldValue value(data, scalar);\n" o += " switch (tag) {\n" o += indent("\n".join(decode), " ").replace("this->", "msg.") + "\n" + o += " default:\n" + o += " break;\n" o += " }\n" o += "}\n" cpp += o diff --git a/script/clang-tidy b/script/clang-tidy index 3f923403a38..ab42ff2c8e7 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -319,21 +319,15 @@ def run_tidy( disabled_checks = [] if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): # MMIO peripheral access on these bare-metal platforms is all - # fixed-address. - disabled_checks.append("-clang-analyzer-core.FixedAddressDereference") + # fixed-address. clang-tidy 23 moved the checker under optin. + disabled_checks += [ + "-clang-analyzer-core.FixedAddressDereference", + "-clang-analyzer-optin.core.FixedAddressDereference", + ] if args.environment.startswith("host"): # glibc's assert() expands to a call the check mistakes for a # constant condition. disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"] - if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")): - # bugprone-pointer-arithmetic-on-polymorphic-object (and its - # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher - # recursion on lvgl_esphome.h under the RP2 defines and on the - # all-include file under the host defines. - disabled_checks += [ - "-bugprone-pointer-arithmetic-on-polymorphic-object", - "-cert-ctr56-cpp", - ] if os.path.normpath(path) in ANALYZER_SKIPPED_FILES: disabled_checks.append("-clang-analyzer-*") if disabled_checks: @@ -523,7 +517,7 @@ def main(): failed_files = [] timings = [] try: - executable = get_binary("clang-tidy", 22) + executable = get_binary("clang-tidy", 23) task_queue = queue.Queue(args.jobs) lock = threading.Lock() for _ in range(args.jobs): @@ -571,13 +565,13 @@ def main(): try: try: subprocess.call( - ["clang-apply-replacements-22", tmpdir], close_fds=False + ["clang-apply-replacements-23", tmpdir], close_fds=False ) except FileNotFoundError: subprocess.call(["clang-apply-replacements", tmpdir], close_fds=False) except FileNotFoundError: print( - "Error please install clang-apply-replacements-22 or clang-apply-replacements.\n", + "Error please install clang-apply-replacements-23 or clang-apply-replacements.\n", file=sys.stderr, ) except: diff --git a/sdkconfig.defaults.esp32p4 b/sdkconfig.defaults.esp32p4 index c8de2728f6c..3ad7b91a5d0 100644 --- a/sdkconfig.defaults.esp32p4 +++ b/sdkconfig.defaults.esp32p4 @@ -4,25 +4,29 @@ # codegen via add_idf_sdkconfig_option, which the stub tidy build skips). # esp32_hosted (P4 has no native Wi-Fi; it drives a co-processor over SDIO/SPI). -# Mirrors a default SDIO 4-bit setup (slot 1, ESP32-C6 slave) so the esp_hosted +# Mirrors a default SDIO 4-bit setup (slot 1, ESP32-C6 co-processor) on the +# esp_hosted 3.x host line pinned in esphome/idf_component.yml, so the esp_hosted # code paths compile under static analysis. -CONFIG_SLAVE_IDF_TARGET_ESP32C6=y +CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6=y +CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO=y CONFIG_ESP_HOSTED_SDIO_SLOT_1=y -CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS=y -CONFIG_ESP_HOSTED_CUSTOM_SDIO_PINS=y -CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ=40000 -CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH=y -CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE=54 -CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_1=18 -CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1=19 -CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_1=14 -CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_1=15 -CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_1=16 -CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1=17 +CONFIG_ESP_HOSTED_HOST_SDIO_BUS_WIDTH_4=y +CONFIG_ESP_HOSTED_HOST_SDIO_CLK_KHZ=40000 +CONFIG_ESP_HOSTED_HOST_RESET_GPIO=54 +CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CLK=18 +CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CMD=19 +CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0=14 +CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1=15 +CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2=16 +CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D3=17 + +# ESP-NOW-over-hosted shim (esp_now_hosted.cpp) uses the peer-data feature. +CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA=y +CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA_MAX_CUSTOM_MSG_HANDLERS=8 # BLE runs over the hosted co-processor on P4 (no native BT controller), so -# esp32_ble_tracker must take the hosted bluedroid path instead of . -CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID=y +# the esp32_ble components must take the hosted bluedroid path instead of . +CONFIG_ESP_HOSTED_HOST_FEAT_BT=y # tinyusb CDC (usb_cdc_acm), same as esp32s3 CONFIG_TINYUSB_CDC_ENABLED=y diff --git a/tests/benchmarks/components/api/bench_list_entities.cpp b/tests/benchmarks/components/api/bench_list_entities.cpp index 8a4cac1e2ef..9bf75380879 100644 --- a/tests/benchmarks/components/api/bench_list_entities.cpp +++ b/tests/benchmarks/components/api/bench_list_entities.cpp @@ -16,16 +16,16 @@ static ListEntitiesSensorResponse make_sensor_response() { ListEntitiesSensorResponse msg; msg.object_id = StringRef::from_lit("living_room_temperature"); msg.key = 0x12345678; - msg.name = StringRef::from_lit("Living Room Temperature"); + msg.name = ProgmemStringRef(StringRef::from_lit("Living Room Temperature")); #ifdef USE_ENTITY_ICON - msg.icon = StringRef::from_lit("mdi:thermometer"); + msg.icon = ProgmemStringRef::from_lit("mdi:thermometer"); #endif msg.entity_category = enums::ENTITY_CATEGORY_NONE; msg.disabled_by_default = false; msg.unit_of_measurement = StringRef::from_lit("°C"); msg.accuracy_decimals = 1; msg.force_update = false; - msg.device_class = StringRef::from_lit("temperature"); + msg.device_class = ProgmemStringRef::from_lit("temperature"); msg.state_class = enums::STATE_CLASS_MEASUREMENT; #ifdef USE_DEVICES msg.device_id = 1; @@ -87,13 +87,13 @@ static ListEntitiesBinarySensorResponse make_binary_sensor_response() { ListEntitiesBinarySensorResponse msg; msg.object_id = StringRef::from_lit("front_door_contact"); msg.key = 0xAABBCCDD; - msg.name = StringRef::from_lit("Front Door Contact"); + msg.name = ProgmemStringRef(StringRef::from_lit("Front Door Contact")); #ifdef USE_ENTITY_ICON - msg.icon = StringRef::from_lit("mdi:door"); + msg.icon = ProgmemStringRef::from_lit("mdi:door"); #endif msg.entity_category = enums::ENTITY_CATEGORY_NONE; msg.disabled_by_default = false; - msg.device_class = StringRef::from_lit("door"); + msg.device_class = ProgmemStringRef::from_lit("door"); msg.is_status_binary_sensor = false; #ifdef USE_DEVICES msg.device_id = 2; @@ -176,9 +176,9 @@ static ListEntitiesLightResponse make_light_response() { ListEntitiesLightResponse msg; msg.object_id = StringRef::from_lit("kitchen_ceiling_light"); msg.key = 0x55667788; - msg.name = StringRef::from_lit("Kitchen Ceiling Light"); + msg.name = ProgmemStringRef(StringRef::from_lit("Kitchen Ceiling Light")); #ifdef USE_ENTITY_ICON - msg.icon = StringRef::from_lit("mdi:ceiling-light"); + msg.icon = ProgmemStringRef::from_lit("mdi:ceiling-light"); #endif msg.entity_category = enums::ENTITY_CATEGORY_NONE; msg.disabled_by_default = false; diff --git a/tests/benchmarks/components/api/benchmark.yaml b/tests/benchmarks/components/api/benchmark.yaml index e57276ea660..f0be642df73 100644 --- a/tests/benchmarks/components/api/benchmark.yaml +++ b/tests/benchmarks/components/api/benchmark.yaml @@ -49,63 +49,83 @@ esphome: - id: device_1 name: "Device 1" area_id: area_1 + relation: via - id: device_2 name: "Device 2" area_id: area_2 + relation: via - id: device_3 name: "Device 3" area_id: area_3 + relation: via - id: device_4 name: "Device 4" area_id: area_4 + relation: via - id: device_5 name: "Device 5" area_id: area_5 + relation: via - id: device_6 name: "Device 6" area_id: area_6 + relation: via - id: device_7 name: "Device 7" area_id: area_7 + relation: via - id: device_8 name: "Device 8" area_id: area_8 + relation: via - id: device_9 name: "Device 9" area_id: area_9 + relation: via - id: device_10 name: "Device 10" area_id: area_10 + relation: via - id: device_11 name: "Device 11" area_id: area_11 + relation: via - id: device_12 name: "Device 12" area_id: area_12 + relation: via - id: device_13 name: "Device 13" area_id: area_13 + relation: via - id: device_14 name: "Device 14" area_id: area_14 + relation: via - id: device_15 name: "Device 15" area_id: area_15 + relation: via - id: device_16 name: "Device 16" area_id: area_16 + relation: via - id: device_17 name: "Device 17" area_id: area_17 + relation: via - id: device_18 name: "Device 18" area_id: area_18 + relation: via - id: device_19 name: "Device 19" area_id: area_19 + relation: via - id: device_20 name: "Device 20" area_id: area_20 + relation: via api: encryption: diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index 2b7662b6655..f1012d49304 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -112,7 +112,7 @@ def test_empty_metadata_is_unset() -> None: "description": "", "variables": {"b": {"type": "int", "description": "", "example": "ex"}}, } - strings = _action_strings(conf, has_metadata=True) + strings = _action_strings(conf, has_metadata=True, has_optional=False) assert strings == ["a", None, "b", None, "ex"] diff --git a/tests/component_tests/api/test_action_optional_args.py b/tests/component_tests/api/test_action_optional_args.py new file mode 100644 index 00000000000..474db969c51 --- /dev/null +++ b/tests/component_tests/api/test_action_optional_args.py @@ -0,0 +1,158 @@ +"""Tests for optional user-defined action variables (required/default).""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +from esphome.components.api import ( + _action_strings, + _has_optional_args, + validate_variable, +) +from esphome.config_validation import Invalid +from esphome.core import CORE + +CONFIG = "tests/component_tests/api/test_action_optional_args.yaml" +CONFIG_ESP8266 = "tests/component_tests/api/test_action_optional_args_esp8266.yaml" +CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml" + + +def test_defaults_are_emitted_in_the_string_table( + generate_main: Callable[[str | Path], str], +) -> None: + """Each argument gains a trailing default slot: "" marks optional without a default.""" + main_cpp = generate_main(CONFIG) + + assert ( + "static constexpr const char * api_action0_strings[] PROGMEM = {" + '"play_buzzer", "song_str", "", "notes", "", "octave", "5", ' + '"bpm", "100", "loud", "true"};' in main_cpp + ) + # An all-required action still carries the default slots (as nullptr) + assert ( + "static constexpr const char * api_action1_strings[] PROGMEM = {" + '"plain_action", "value", nullptr};' in main_cpp + ) + assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" in {d.name for d in CORE.defines} + + +def test_esp8266_defaults_are_progmem_arrays( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP8266 each default is its own PROGMEM array, referenced from the table.""" + main_cpp = generate_main(CONFIG_ESP8266) + + def array_for(text: str) -> str: + match = re.search( + rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp + ) + assert match, text + return match.group(1) + + assert ( + "static constexpr const char * api_action0_strings[] PROGMEM = {" + f"{array_for('play_buzzer')}, {array_for('song_str')}, {array_for('')}, " + f"{array_for('notes')}, {array_for('')}, {array_for('octave')}, {array_for('5')}, " + f"{array_for('bpm')}, {array_for('100')}, {array_for('loud')}, " + f"{array_for('true')}}};" in main_cpp + ) + + +def test_required_variables_emit_no_define( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_SHORTHAND) + + assert "USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS" not in { + d.name for d in CORE.defines + } + + +def test_action_strings_orders_default_after_metadata() -> None: + conf = { + "action": "a", + "description": "d", + "variables": { + "b": {"type": "int", "example": "1", "default": 5}, + "c": {"type": "bool", "default": False}, + }, + } + assert _action_strings(conf, has_metadata=True, has_optional=True) == [ + "a", + "d", + "b", + None, + "1", + "5", + "c", + None, + None, + "false", + ] + + +@pytest.mark.parametrize( + ("var", "expected"), + [ + ({"type": "int", "required": False}, ""), + ({"type": "string", "default": ""}, ""), + ({"type": "float", "default": 5}, "5.0"), + ], +) +def test_default_slot_marks_optional_and_keeps_the_type( + var: dict, expected: str +) -> None: + actions = [{"action": "a", "variables": {"b": validate_variable(var)}}] + assert _has_optional_args(actions) + assert _action_strings(actions[0], has_metadata=False, has_optional=True) == [ + "a", + "b", + expected, + ] + + +@pytest.mark.parametrize( + ("var", "expected"), + [ + ({"type": "int", "default": "5"}, 5), + ({"type": "int", "default": -(2**31)}, -(2**31)), + ({"type": "int", "default": 2**31 - 1}, 2**31 - 1), + ({"type": "bool", "default": "true"}, True), + ({"type": "float", "default": "1.5"}, 1.5), + ({"type": "float", "default": 3.4028235e38}, 3.4028235e38), + ({"type": "string", "default": "0123"}, "0123"), + ], +) +def test_default_is_validated_against_type(var: dict, expected: object) -> None: + assert validate_variable(var)["default"] == expected + + +@pytest.mark.parametrize( + "var", + [ + {"type": "int[]", "required": False}, + {"type": "string[]", "default": "x"}, + {"type": "int", "default": 5, "required": True}, + {"type": "int", "default": "not_a_number"}, + # ExecuteServiceArgument.int_ is a signed 32-bit value + {"type": "int", "default": 2**31}, + {"type": "int", "default": -(2**31) - 1}, + # A boolean is an int in Python but "true" is not a number on the wire + {"type": "int", "default": True}, + {"type": "float", "default": False}, + # ExecuteServiceArgument.float_ is a 32-bit float + {"type": "float", "default": 1e40}, + {"type": "float", "default": "nan"}, + {"type": "float", "default": "inf"}, + # A string default is what the user typed, and a C string in flash + {"type": "string", "default": 123}, + {"type": "string", "default": "a\0b"}, + {"type": "string", "default": "\0a"}, + {"type": "float", "default": -1e40}, + ], +) +def test_variable_rejects_invalid_optionality(var: dict) -> None: + with pytest.raises(Invalid): + validate_variable(var) diff --git a/tests/component_tests/api/test_action_optional_args.yaml b/tests/component_tests/api/test_action_optional_args.yaml new file mode 100644 index 00000000000..b7957798072 --- /dev/null +++ b/tests/component_tests/api/test_action_optional_args.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: MySSID + password: password1 + +logger: + +packages: + api: !include test_action_optional_args_common.yaml diff --git a/tests/component_tests/api/test_action_optional_args_common.yaml b/tests/component_tests/api/test_action_optional_args_common.yaml new file mode 100644 index 00000000000..01361ea1381 --- /dev/null +++ b/tests/component_tests/api/test_action_optional_args_common.yaml @@ -0,0 +1,26 @@ +api: + actions: + - action: play_buzzer + variables: + song_str: + type: string + required: false + notes: + type: string + required: false + octave: + type: int + default: 5 + bpm: + type: int + default: 100 + loud: + type: bool + default: true + then: + - logger.log: Action Called + - action: plain_action + variables: + value: int + then: + - logger.log: Action Called diff --git a/tests/component_tests/api/test_action_optional_args_esp8266.yaml b/tests/component_tests/api/test_action_optional_args_esp8266.yaml new file mode 100644 index 00000000000..5a5305e514e --- /dev/null +++ b/tests/component_tests/api/test_action_optional_args_esp8266.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: MySSID + password: password1 + +logger: + +packages: + api: !include test_action_optional_args_common.yaml diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index af4c57f7731..0048657f579 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -686,6 +686,7 @@ DEVICES_HEADER = """ devices: - id: kitchen_dev name: Kitchen + relation: via esp32: variant: esp32 @@ -749,7 +750,8 @@ def blob_in(main_cpp: str) -> bytes: def entity_hash(main_cpp: str, variable: str) -> int: """The key the generated code passes to configure_entity_, which ListEntities then sends.""" match = re.search( - rf'App\.register_switch\({variable}, "[^"]*", (\d+)UL, \d+\)', main_cpp + rf'App\.register_switch\({variable}, ESPHOME_PSTR\("[^"]*"\), (\d+)UL, \d+\)', + main_cpp, ) assert match is not None, variable return int(match.group(1)) diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index e1d999abc7c..323fadfbcc7 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -31,7 +31,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'App.register_binary_sensor(bs_1, "test bs1",' in main_cpp + assert 'App.register_binary_sensor(bs_1, ESPHOME_PSTR("test bs1"),' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index f8881a832ce..3b81359cd99 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -29,7 +29,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'App.register_button(wol_1, "wol_test_1",' in main_cpp + assert 'App.register_button(wol_1, ESPHOME_PSTR("wol_test_1"),' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/cc1101/test_cc1101_payload_tables.py b/tests/component_tests/cc1101/test_cc1101_payload_tables.py index f2d3264a326..f11489f5ef6 100644 --- a/tests/component_tests/cc1101/test_cc1101_payload_tables.py +++ b/tests/component_tests/cc1101/test_cc1101_payload_tables.py @@ -14,7 +14,7 @@ def test_constant_payloads_share_progmem_tables( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """Equal send_packet payloads share one table; lambdas stay templates.""" + """Equal send_packet payloads share one table; a lambda is sent from its vector.""" main_cpp = generate_main(component_config_path("payload_tables.yaml")) tables = re.findall( @@ -23,8 +23,10 @@ def test_constant_payloads_share_progmem_tables( assert [v for _, v in tables] == [ "{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}" ] - assert main_cpp.count(f"set_data_static({tables[0][0]}, 8);") == 2 - assert "set_data_template(" in main_cpp + assert main_cpp.count(f"->transmit_packet(::{tables[0][0]}, 8);") == 2 + assert ( + "->transmit_packet(esphome__bytes.data(), esphome__bytes.size());" in main_cpp + ) @pytest.mark.parametrize("size", [64, 65]) diff --git a/tests/component_tests/ds3231/test_validation.py b/tests/component_tests/ds3231/test_validation.py new file mode 100644 index 00000000000..f9d3bfbb377 --- /dev/null +++ b/tests/component_tests/ds3231/test_validation.py @@ -0,0 +1,70 @@ +"""Tests for the ds3231 hub and alarm action validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import ds3231 +from esphome.components.ds3231 import ( + ALARM_1_MODES, + ALARM_2_MODES, + CONF_DAY_OF_MONTH, + CONF_DAY_OF_WEEK, + SQUARE_WAVE_FREQUENCIES, + SquareWaveFrequency, + validate_square_wave_frequency, +) +from esphome.const import CONF_MODE + + +@pytest.mark.parametrize( + ("value", "frequency"), + [ + ("1Hz", SquareWaveFrequency.SQUARE_WAVE_1HZ), + ("1024Hz", SquareWaveFrequency.SQUARE_WAVE_1024HZ), + ("1.024kHz", SquareWaveFrequency.SQUARE_WAVE_1024HZ), + ("4096 Hz", SquareWaveFrequency.SQUARE_WAVE_4096HZ), + (8192, SquareWaveFrequency.SQUARE_WAVE_8192HZ), + ], +) +def test_square_wave_frequency_accepts_every_spelling( + value: str | int, frequency: object +) -> None: + assert validate_square_wave_frequency(value) == frequency + + +def test_square_wave_frequency_rejects_other_rates() -> None: + with pytest.raises(cv.Invalid, match="valid options are"): + validate_square_wave_frequency("2048Hz") + assert list(SQUARE_WAVE_FREQUENCIES) == [1, 1024, 4096, 8192] + + +def test_alarm_modes_map_to_their_enumerators() -> None: + assert str(ALARM_1_MODES["every_second"]) == "ds3231::ALARM_MODE_EVERY_SECOND" + assert str(ALARM_1_MODES["weekly"]) == "ds3231::ALARM_MODE_WEEKLY" + assert "every_second" not in ALARM_2_MODES + + +def _alarm_1_schema() -> cv.Schema: + return ds3231._alarm_schema(ALARM_1_MODES, with_second=True) + + +def test_monthly_alarm_needs_a_day_of_month() -> None: + with pytest.raises(cv.Invalid, match="'day_of_month' is required in monthly mode"): + _alarm_1_schema()({CONF_MODE: "monthly"}) + + +def test_weekly_alarm_needs_a_day_of_week() -> None: + with pytest.raises(cv.Invalid, match="'day_of_week' is required in weekly mode"): + _alarm_1_schema()({CONF_MODE: "weekly"}) + + +def test_daily_alarm_fills_the_unused_days() -> None: + config = _alarm_1_schema()({CONF_MODE: "daily"}) + assert config[CONF_DAY_OF_MONTH] == 1 + assert config[CONF_DAY_OF_WEEK] == 1 + + +def test_alarm_2_rejects_every_second() -> None: + schema = ds3231._alarm_schema(ALARM_2_MODES, with_second=False) + with pytest.raises(cv.Invalid, match="Unknown value 'every_second'"): + schema({CONF_MODE: "every_second"}) diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 546373b1dc5..a8fc27b0b12 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -621,6 +621,69 @@ def test_uc8279_width_must_be_a_multiple_of_8( ) +GDEY029Z95 = "goodisplay-gdey029z95-2.9" + + +def _gdey029z95_config(**overrides: Any) -> ConfigType: + config: ConfigType = { + "id": "test_display", + "model": GDEY029Z95, + CONF_DC_PIN: 21, + CONF_RESET_PIN: 22, + CONF_BUSY_PIN: 23, + } + config.update(overrides) + return config + + +def test_gdey029z95_uses_the_good_display_driver() -> None: + """The GDEY029Z95 uses EPaperGoodDisplay3C with the WeAct init sequence.""" + model = MODELS[GDEY029Z95.upper()] + + assert model.class_name == "EPaperGoodDisplay3C" + assert model.get_init_sequence({}) == ( + (0x01, 0x27, 0x01, 0x00), + (0x11, 0x03), + (0x3C, 0x05), + (0x18, 0x80), + (0x21, 0x00, 0x80), + ) + + +@pytest.mark.parametrize("pin", [CONF_RESET_PIN, CONF_BUSY_PIN]) +def test_gdey029z95_requires_reset_and_busy_pins( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + pin: str, +) -> None: + """Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = _gdey029z95_config() + del config[pin] + with pytest.raises(cv.Invalid, match=pin): + run_schema_validation(config) + + +def test_gdey029z95_has_no_partial_update( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + run_schema_validation(_gdey029z95_config()) + with pytest.raises(cv.Invalid, match="does not support partial update"): + run_schema_validation(_gdey029z95_config(full_update_every=2)) + + def test_uc8279_x4pro_code_generation( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml deleted file mode 100644 index 22ee1e480e6..00000000000 --- a/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml +++ /dev/null @@ -1,8 +0,0 @@ -esphome: - name: test - -esp32: - board: esp32dev - framework: - type: esp-idf - version: 5.0.6 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 86b27776dc6..71247f4c4e8 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -32,8 +32,10 @@ from esphome.components.esp32 import ( _user_sdkconfig_wants_tls, allow_insecure_tls, idf_version, + require_idf_version, ) from esphome.components.esp32.const import ( + KEY_BOARD, KEY_ESP32, KEY_EXCLUDE_COMPONENTS, KEY_IDF_VERSION, @@ -1279,24 +1281,33 @@ def test_platformio_arduino_enables_reproducible_build( @pytest.mark.parametrize( - ("config_file", "expected"), - [ - ("reproducible_build.yaml", True), - ("reproducible_build_arduino.yaml", True), - ("file_macro_idf_5_0.yaml", False), - ], + "config_file", ["reproducible_build.yaml", "reproducible_build_arduino.yaml"] ) def test_file_macro_is_basename_only( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], config_file: str, - expected: bool, ) -> None: - """__FILE__ becomes the basename on GCC 12 toolchains; IDF 5.0 (GCC 11) is skipped.""" + """__FILE__ becomes the basename; every supported IDF ships a GCC 12 toolchain.""" generate_main(component_config_path(config_file)) - assert ("-D__FILE__=__FILE_NAME__" in CORE.build_flags) is expected - assert ("-Wno-builtin-macro-redefined" in CORE.build_flags) is expected + assert "-D__FILE__=__FILE_NAME__" in CORE.build_flags + assert "-Wno-builtin-macro-redefined" in CORE.build_flags + + +def test_esp_idf_5_0_is_rejected(set_core_config: SetCoreConfigCallable) -> None: + """ESP-IDF 5.0 is out: its Kconfig names and toolchain predate what the component writes.""" + set_core_config(PlatformFramework.ESP32_IDF) + from esphome.components.esp32 import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Only ESP-IDF 5\.1\+ is supported"): + CONFIG_SCHEMA( + { + "variant": "esp32", + "board": "esp32dev", + "framework": {"type": "esp-idf", "version": "5.0.6"}, + } + ) def test_native_idf_enables_reproducible_build( @@ -1417,7 +1428,7 @@ def test_flash_mode_unset_leaves_defaults( {}, { "CONFIG_ESP_WIFI_ENABLED": False, - "CONFIG_SW_COEXIST_ENABLE": False, + "CONFIG_ESP_COEX_SW_COEXIST_ENABLE": False, }, id="idf_ethernet_only", ), @@ -1429,7 +1440,7 @@ def test_flash_mode_unset_leaves_defaults( "CONFIG_BT_ENABLED": True, "CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True, "CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False, - "CONFIG_SW_COEXIST_ENABLE": True, + "CONFIG_ESP_COEX_SW_COEXIST_ENABLE": True, "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, "CONFIG_LWIP_DHCPS": False, }, @@ -1481,7 +1492,7 @@ def test_flash_mode_unset_leaves_defaults( {}, { "CONFIG_ESP_WIFI_ENABLED": False, - "CONFIG_SW_COEXIST_ENABLE": False, + "CONFIG_ESP_COEX_SW_COEXIST_ENABLE": False, }, id="arduino_ethernet_only_disables_wifi", ), @@ -1492,7 +1503,7 @@ def test_flash_mode_unset_leaves_defaults( {}, { "CONFIG_ESP_WIFI_ENABLED": False, - "CONFIG_SW_COEXIST_ENABLE": False, + "CONFIG_ESP_COEX_SW_COEXIST_ENABLE": False, }, id="arduino_ethernet_dhcps_exclusion", ), @@ -1704,7 +1715,7 @@ def test_network_ethernet_only_reconciles_end_to_end( generate_main(component_config_path("network_ethernet_only.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False - assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False + assert sdkconfig.get("CONFIG_ESP_COEX_SW_COEXIST_ENABLE") is False # The whole radio stack stays out of the build set as well. excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] assert {"esp_wifi", "wpa_supplicant", "esp_phy", "esp_coex", "bt"} <= excluded @@ -1724,7 +1735,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end( assert sdkconfig.get("CONFIG_BT_ENABLED") is True assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False - assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True + assert sdkconfig.get("CONFIG_ESP_COEX_SW_COEXIST_ENABLE") is True assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False # WiFi present alongside BT -> WiFi stack must stay enabled. @@ -1742,7 +1753,7 @@ def test_network_wifi_ethernet_priority_keeps_wifi_enabled( generate_main(component_config_path("network_wifi_ethernet_priority.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig - assert "CONFIG_SW_COEXIST_ENABLE" not in sdkconfig + assert "CONFIG_ESP_COEX_SW_COEXIST_ENABLE" not in sdkconfig # WiFi has no AP here, so SoftAP/DHCP server are still dropped. assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False @@ -2433,3 +2444,44 @@ def test_allow_insecure_tls_only_switches_the_flags_on( "CONFIG_ESP_TLS_INSECURE": True, "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY": True, } + + +@pytest.mark.parametrize( + ("platform_framework", "idf", "ok"), + [ + (PlatformFramework.ESP32_IDF, cv.Version(5, 5, 3), True), + (PlatformFramework.ESP32_IDF, cv.Version(5, 5, 2), False), + (PlatformFramework.ESP32_ARDUINO, cv.Version(5, 5, 3), True), + (PlatformFramework.ESP32_ARDUINO, cv.Version(5, 5, 2), False), + ], +) +def test_require_idf_version_compares_the_idf_underneath( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + idf: cv.Version, + ok: bool, +) -> None: + set_core_config( + platform_framework, + platform_data={ + KEY_BOARD: "esp32dev", + KEY_VARIANT: VARIANT_ESP32, + KEY_IDF_VERSION: idf, + }, + ) + validator = require_idf_version(cv.Version(5, 5, 3), "The thing") + if ok: + assert validator("x") == "x" + else: + with pytest.raises( + cv.Invalid, match="The thing requires ESP-IDF 5.5.3 or newer, got 5.5.2" + ): + validator("x") + + +def test_require_idf_version_rejects_other_platforms( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="only available on"): + require_idf_version(cv.Version(5, 5, 3))("x") diff --git a/tests/component_tests/esp32_hosted/test_init.py b/tests/component_tests/esp32_hosted/test_init.py index 5cc3f928ccb..bdb6bd6e8af 100644 --- a/tests/component_tests/esp32_hosted/test_init.py +++ b/tests/component_tests/esp32_hosted/test_init.py @@ -1,20 +1,75 @@ -"""Tests for the esp32_hosted ESP-IDF version gate.""" +"""Tests for the esp32_hosted validation.""" + +import logging import pytest from esphome import config_validation as cv -from esphome.components.esp32 import KEY_IDF_VERSION -from esphome.components.esp32_hosted import _final_validate -from esphome.const import PlatformFramework +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_IDF_VERSION, + KEY_VARIANT, + VARIANT_ESP32P4, +) +from esphome.components.esp32_hosted import ( + CONF_ACTIVE_HIGH, + CONF_BUS_WIDTH, + CONF_CMD_PIN, + CONF_D0_PIN, + CONF_D1_PIN, + CONF_D2_PIN, + CONF_D3_PIN, + CONFIG_SCHEMA, + _final_validate, + _validate_active_high, +) +from esphome.const import CONF_CLK_PIN, CONF_RESET_PIN, CONF_VARIANT, PlatformFramework +from esphome.types import ConfigType from ..types import SetCoreConfigCallable +SDIO_PINS: ConfigType = { + CONF_VARIANT: "ESP32C6", + CONF_RESET_PIN: 54, + CONF_CMD_PIN: 19, + CONF_CLK_PIN: 18, + CONF_D0_PIN: 14, + CONF_D1_PIN: 15, + CONF_D2_PIN: 16, + CONF_D3_PIN: 17, +} -@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"]) + +@pytest.fixture +def p4_core(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-p4-evboard", + KEY_IDF_VERSION: cv.Version.parse("5.5.6"), + KEY_VARIANT: VARIANT_ESP32P4, + }, + ) + + +@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2", "5.3.2", "5.4.2"]) +def test_final_validate_rejects_old_idf( + set_core_config: SetCoreConfigCallable, idf: str +) -> None: + """ESP-IDF older than 5.5 is rejected with a clear error.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, + ) + with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.5 or newer"): + _final_validate({}) + + +@pytest.mark.parametrize("idf", ["5.5.0", "5.5.6"]) def test_final_validate_accepts_supported_idf( set_core_config: SetCoreConfigCallable, idf: str ) -> None: - """ESP-IDF 5.3 and newer passes validation unchanged.""" + """ESP-IDF 5.5 and newer passes validation unchanged.""" set_core_config( PlatformFramework.ESP32_IDF, platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, @@ -22,14 +77,48 @@ def test_final_validate_accepts_supported_idf( _final_validate({}) -@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"]) -def test_final_validate_rejects_old_idf( - set_core_config: SetCoreConfigCallable, idf: str -) -> None: - """ESP-IDF older than 5.3 is rejected with a clear error.""" - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)}, - ) - with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"): - _final_validate({}) +@pytest.mark.usefixtures("p4_core") +@pytest.mark.parametrize("bus_width", [1, 4]) +def test_sdio_requires_d1_pin(bus_width: int) -> None: + """D1 carries the card interrupt, so a 1-bit bus needs it too.""" + config = {k: v for k, v in SDIO_PINS.items() if k != CONF_D1_PIN} + config[CONF_BUS_WIDTH] = bus_width + with pytest.raises(cv.Invalid, match="required key not provided"): + CONFIG_SCHEMA(config) + + +@pytest.mark.usefixtures("p4_core") +@pytest.mark.parametrize("pin", [CONF_D2_PIN, CONF_D3_PIN]) +def test_sdio_4bit_requires_d2_d3(pin: str) -> None: + """A 4-bit bus needs D2 and D3.""" + config = {k: v for k, v in SDIO_PINS.items() if k != pin} + with pytest.raises(cv.Invalid, match=f"{pin} is required when bus_width is 4"): + CONFIG_SCHEMA(config) + + +@pytest.mark.usefixtures("p4_core") +def test_sdio_1bit_accepts_d0_d1_only() -> None: + """A 1-bit bus validates with just D0 and D1.""" + config = {k: v for k, v in SDIO_PINS.items() if k not in (CONF_D2_PIN, CONF_D3_PIN)} + config[CONF_BUS_WIDTH] = 1 + CONFIG_SCHEMA(config) + + +def test_active_high_absent_is_silent(caplog: pytest.LogCaptureFixture) -> None: + """A config without the deprecated key logs nothing.""" + with caplog.at_level(logging.WARNING): + assert _validate_active_high({}) == {} + assert "active_high" not in caplog.text + + +def test_active_high_true_warns(caplog: pytest.LogCaptureFixture) -> None: + """The old required value still validates, with a deprecation warning.""" + with caplog.at_level(logging.WARNING): + _validate_active_high({CONF_ACTIVE_HIGH: True}) + assert "'active_high' is deprecated" in caplog.text + + +def test_active_high_false_rejected() -> None: + """esp_hosted drives the reset line with a low pulse only.""" + with pytest.raises(cv.Invalid, match="active_high: false"): + _validate_active_high({CONF_ACTIVE_HIGH: False}) diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py index 73985597d9f..f4ff9f66c03 100644 --- a/tests/component_tests/ethernet/test_ethernet.py +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -11,9 +11,13 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S3, ) -from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate +from esphome.components.ethernet import ( + CONF_CLOCK_SPEED, + CONFIG_SCHEMA, + _final_validate_wifi, +) from esphome.components.network import _validate_priority_list -from esphome.const import CONF_PRIORITY, PlatformFramework +from esphome.const import CONF_AP, CONF_NETWORKS, CONF_PRIORITY, PlatformFramework from esphome.core import CORE import esphome.final_validate as fv @@ -28,24 +32,37 @@ _CH390_CONFIG = { } -def test_rejects_wifi_and_ethernet_without_priority() -> None: - """Wi-Fi + ethernet without a network: priority: list must be rejected.""" - fv.full_config.set({"wifi": {}, "ethernet": {}}) - with pytest.raises(Invalid, match="cannot be used together with component wifi"): - _final_validate({}) +_WIFI_STA_CONFIG = {CONF_NETWORKS: [{"ssid": "MySSID", "password": "password1"}]} +_WIFI_AP_CONFIG = {CONF_AP: {"ssid": "espap", "password": "mypassword"}} -def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None: - """A priority list missing an interface is rejected and names what's missing.""" - fv.full_config.set( - { - "wifi": {}, - "ethernet": {}, - "network": {CONF_PRIORITY: _validate_priority_list(["ethernet"])}, - } - ) - with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"): - _final_validate({}) +@pytest.mark.parametrize( + ("wifi", "priority", "extra", "match"), + [ + (_WIFI_STA_CONFIG, None, {}, "missing: ethernet, wifi"), + (_WIFI_STA_CONFIG, ["ethernet"], {}, "missing: wifi"), + (_WIFI_STA_CONFIG, ["wifi", "ethernet"], {}, None), + (_WIFI_AP_CONFIG, None, {}, None), + # A station can still appear at run time: DPP or a provisioner saves credentials + ({"dpp": {}}, None, {}, "missing: ethernet, wifi"), + ({"dpp": {}, **_WIFI_AP_CONFIG}, None, {}, "missing: ethernet, wifi"), + (_WIFI_AP_CONFIG, None, {"captive_portal": {}}, "missing: ethernet, wifi"), + (_WIFI_AP_CONFIG, None, {"improv_serial": {}}, "missing: ethernet, wifi"), + ], +) +def test_wifi_station_needs_a_full_priority_list_but_an_access_point_does_not( + wifi: dict, priority: list[str] | None, extra: dict, match: str | None +) -> None: + """A station, configured or provisioned later, competes with ethernet; an access point does not.""" + full = {"wifi": wifi, "ethernet": {}, **extra} + if priority: + full["network"] = {CONF_PRIORITY: _validate_priority_list(priority)} + fv.full_config.set(full) + if match is None: + _final_validate_wifi({}) + else: + with pytest.raises(Invalid, match=match): + _final_validate_wifi({}) @pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"]) diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py index 3b5e5bbd6ed..26051fa9f15 100644 --- a/tests/component_tests/helpers.py +++ b/tests/component_tests/helpers.py @@ -14,15 +14,12 @@ def extract_packed_value(main_cpp: str, var_name: str) -> int: combined form ``App.register_(var, name, hash, packed)``. """ escaped_var = re.escape(var_name) + name_re = r'ESPHOME_PSTR\("(?:\\.|[^"\\])*"\)' legacy_pattern = ( - rf"{escaped_var}->configure_entity_\(" - r'"(?:\\.|[^"\\])*"' - r",\s*\w+,\s*(\d+)\)" + rf"{escaped_var}->configure_entity_\(" + name_re + r",\s*\w+,\s*(\d+)\)" ) combined_pattern = ( - rf"App\.register_\w+\(\s*{escaped_var}\s*,\s*" - r'"(?:\\.|[^"\\])*"' - r",\s*\w+,\s*(\d+)\)" + rf"App\.register_\w+\(\s*{escaped_var}\s*,\s*" + name_re + r",\s*\w+,\s*(\d+)\)" ) match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp) assert match, f"configure call not found for {var_name}" diff --git a/tests/component_tests/mqtt/test_transport_validation.py b/tests/component_tests/mqtt/test_transport_validation.py index cb6fd220032..cb22c0e7d0c 100644 --- a/tests/component_tests/mqtt/test_transport_validation.py +++ b/tests/component_tests/mqtt/test_transport_validation.py @@ -10,6 +10,7 @@ from esphome.components.mqtt import ( MQTT_TRANSPORT_TCP, MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS, + _validate_tls, _validate_transport, validate_config, ) @@ -86,22 +87,11 @@ def test_plaintext_ws_with_certificate_rejected( ) -> None: """Test that pairing a CA or client certificate with unencrypted ws is rejected.""" with pytest.raises(cv.Invalid, match="unencrypted"): - validate_config( - { - CONF_TRANSPORT: MQTT_TRANSPORT_WS, - CONF_TOPIC_PREFIX: "test", - **certificate_options, - } - ) + _validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WS, **certificate_options}) -def test_client_certificate_requires_tls() -> None: - """Test that a client certificate without a CA is only accepted over wss.""" - certificate = { - CONF_CLIENT_CERTIFICATE: "-----BEGIN CERTIFICATE-----", - CONF_CLIENT_CERTIFICATE_KEY: "-----BEGIN PRIVATE KEY-----", - CONF_TOPIC_PREFIX: "test", - } - with pytest.raises(cv.Invalid, match="requires 'certificate_authority'"): - validate_config({CONF_TRANSPORT: MQTT_TRANSPORT_TCP, **certificate}) - validate_config({CONF_TRANSPORT: MQTT_TRANSPORT_WSS, **certificate}) +def test_wss_switches_tls_on() -> None: + """Test that wss implies tls and cannot be combined with tls false.""" + assert _validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WSS})["tls"] is True + with pytest.raises(cv.Invalid, match="'tls' cannot be false"): + _validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WSS, "tls": False}) diff --git a/tests/component_tests/mqtt/test_validation.py b/tests/component_tests/mqtt/test_validation.py new file mode 100644 index 00000000000..9ba8ba16638 --- /dev/null +++ b/tests/component_tests/mqtt/test_validation.py @@ -0,0 +1,72 @@ +"""Tests for the mqtt TLS validation rules and the port default.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.const import CONF_VERIFY_SSL +from esphome.components.mqtt import CONF_TLS, _validate_tls +from esphome.const import ( + CONF_BROKER, + CONF_CERTIFICATE_AUTHORITY, + CONF_CLIENT_CERTIFICATE, + CONF_PORT, + CONF_SKIP_CERT_CN_CHECK, + CONF_TOPIC_PREFIX, +) +from esphome.types import ConfigType + +BASE: ConfigType = {CONF_BROKER: "broker.local", CONF_TOPIC_PREFIX: "node"} + + +def _validate(**extra: object) -> ConfigType: + return _validate_tls({**BASE, **extra}) + + +def test_plain_defaults_to_port_1883() -> None: + out = _validate() + assert out[CONF_PORT] == 1883 + assert CONF_TLS not in out + + +@pytest.mark.parametrize( + "extra", + [ + {CONF_TLS: True}, + {CONF_CERTIFICATE_AUTHORITY: "cert"}, + {CONF_VERIFY_SSL: False}, + {CONF_CLIENT_CERTIFICATE: "cert"}, + {CONF_SKIP_CERT_CN_CHECK: True}, + ], +) +def test_tls_defaults_to_port_8883(extra: ConfigType) -> None: + out = _validate(**extra) + assert out[CONF_TLS] is True + assert out[CONF_PORT] == 8883 + + +def test_explicit_port_is_kept() -> None: + assert _validate(**{CONF_TLS: True, CONF_PORT: 1234})[CONF_PORT] == 1234 + + +def test_certificate_authority_with_verify_ssl_false_rejected() -> None: + with pytest.raises(cv.Invalid, match="only valid when 'verify_ssl' is not false"): + _validate(**{CONF_CERTIFICATE_AUTHORITY: "cert", CONF_VERIFY_SSL: False}) + + +def test_skip_cert_cn_check_with_verify_ssl_false_rejected() -> None: + with pytest.raises(cv.Invalid, match="'skip_cert_cn_check' has no effect"): + _validate(**{CONF_SKIP_CERT_CN_CHECK: True, CONF_VERIFY_SSL: False}) + + +@pytest.mark.parametrize( + "extra", + [ + {CONF_CERTIFICATE_AUTHORITY: "cert"}, + {CONF_VERIFY_SSL: True}, + {CONF_CLIENT_CERTIFICATE: "cert"}, + {CONF_SKIP_CERT_CN_CHECK: True}, + ], +) +def test_tls_false_with_certificate_options_rejected(extra: ConfigType) -> None: + with pytest.raises(cv.Invalid, match="'tls' cannot be false"): + _validate(**{CONF_TLS: False, **extra}) diff --git a/tests/component_tests/openthread/test_openthread.py b/tests/component_tests/openthread/test_openthread.py index 280435dd356..0e8d982727d 100644 --- a/tests/component_tests/openthread/test_openthread.py +++ b/tests/component_tests/openthread/test_openthread.py @@ -132,7 +132,7 @@ def test_border_router_codegen( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] for option, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): assert sdkconfig[option] == value - assert sdkconfig["CONFIG_SW_COEXIST_ENABLE"] is True + assert sdkconfig["CONFIG_ESP_COEX_SW_COEXIST_ENABLE"] is True assert sdkconfig["CONFIG_OPENTHREAD_SRP_CLIENT"] is False assert sdkconfig["CONFIG_OPENTHREAD_DNS64_CLIENT"] is False assert ( @@ -158,7 +158,7 @@ def test_border_router_rcp_codegen( assert sdkconfig["CONFIG_OPENTHREAD_RADIO_NATIVE"] is False assert sdkconfig["CONFIG_OPENTHREAD_RADIO_SPINEL_UART"] is True assert sdkconfig["CONFIG_VFS_SUPPORT_DIR"] is True - assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is not True + assert sdkconfig.get("CONFIG_ESP_COEX_SW_COEXIST_ENABLE") is not True assert any(define.name == "USE_OPENTHREAD_RCP_UART" for define in CORE.defines) assert "OpenThreadComponent(460800, 18, 17, nullptr)" in cpp_main diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 4a1ed2c72af..bf6e2da3a0a 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -5,6 +5,9 @@ from typing import Any import pytest from esphome.components.esp32 import ( + KEY_ESP32, + KEY_IDF_VERSION, + KEY_SDKCONFIG_OPTIONS, KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32C2, @@ -21,6 +24,7 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, PlatformFramework +from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable UNSUPPORTED_PSRAM_VARIANTS = [ @@ -255,3 +259,29 @@ def test_psram_final_validation( else: # This should not raise an exception FINAL_VALIDATE_SCHEMA(validated_config) + + +@pytest.mark.parametrize( + ("version", "key"), + [ + (cv.Version(5, 3, 2), "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY"), + (cv.Version(5, 4, 0), "CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM"), + ], +) +def test_external_task_stack_uses_the_kconfig_name_of_the_idf( + version: cv.Version, key: str, set_core_config: SetCoreConfigCallable +) -> None: + """ESP-IDF 5.4 renamed the option; the old name is mapped there but reported on every configure.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_VARIANT: VARIANT_ESP32, + KEY_IDF_VERSION: version, + KEY_SDKCONFIG_OPTIONS: {}, + }, + ) + from esphome.components.psram import request_external_task_stack + + request_external_task_stack() + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == {key: True} diff --git a/tests/component_tests/rx8111/__init__.py b/tests/component_tests/rx8111/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/rx8111/test_alarm_validation.py b/tests/component_tests/rx8111/test_alarm_validation.py new file mode 100644 index 00000000000..d24f668742b --- /dev/null +++ b/tests/component_tests/rx8111/test_alarm_validation.py @@ -0,0 +1,76 @@ +"""Validate RX8111 alarm and timer options before generating firmware.""" + +import pytest + +from esphome.components.rx8111.time import ( + _ALARM_DURATION, + ALARM_FIELD_UNSET, + CONF_ALARM_DAY, + CONF_ALARM_ENABLED, + CONF_ALARM_WEEKDAY, + CONF_DAY, + CONF_ON_ALARM, + CONF_ON_TIMER, + CONF_SECOND, + CONF_TIMER_COUNT, + CONF_TIMER_ENABLED, + CONF_WEEKDAY, + _validate_alarm_config, + _validate_alarm_options, + _validate_set_alarm, +) +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + "config", + [{}, {CONF_SECOND: 0, CONF_WEEKDAY: 1, CONF_DAY: 1}], +) +def test_set_alarm_rejects_empty_or_conflicting_fields(config: dict) -> None: + with pytest.raises(cv.Invalid): + _validate_set_alarm(config) + + +def test_set_alarm_marks_missing_fields_unset() -> None: + config = _validate_set_alarm({CONF_SECOND: 5}) + assert config[CONF_SECOND] == 5 + assert config[CONF_DAY] == ALARM_FIELD_UNSET + + +def test_fixed_alarm_rejects_day_and_weekday() -> None: + with pytest.raises(cv.Invalid): + _validate_alarm_options({CONF_ALARM_DAY: 1, CONF_ALARM_WEEKDAY: 2}) + + +@pytest.mark.parametrize("duration", ["0s", "28d", "29d"]) +def test_relative_alarm_rejects_out_of_range_duration(duration: str) -> None: + with pytest.raises(cv.Invalid): + _ALARM_DURATION(duration) + + +def test_relative_alarm_accepts_mixed_units() -> None: + assert _ALARM_DURATION({"days": 1, "minutes": 1}).total_seconds == 86460 + + +@pytest.mark.parametrize( + "config", + [ + {CONF_TIMER_ENABLED: True}, + {CONF_ON_TIMER: [{}]}, + {CONF_TIMER_ENABLED: False, CONF_ON_TIMER: [{}]}, + ], +) +def test_timer_requires_count(config: dict) -> None: + with pytest.raises(cv.Invalid, match="timer_count"): + _validate_alarm_config(config) + + +def test_timer_count_allows_explicit_disabled_configuration() -> None: + config = {CONF_TIMER_COUNT: 0xFFFFFF, CONF_TIMER_ENABLED: False} + assert _validate_alarm_config(config) == config + + +def test_on_alarm_requires_enabled_alarm() -> None: + with pytest.raises(cv.Invalid, match="on_alarm"): + _validate_alarm_config({CONF_ON_ALARM: [{}]}) + assert _validate_alarm_config({CONF_ON_ALARM: [{}], CONF_ALARM_ENABLED: True}) diff --git a/tests/component_tests/sensor/rh_correction.yaml b/tests/component_tests/sensor/rh_correction.yaml new file mode 100644 index 00000000000..1edffdce98f --- /dev/null +++ b/tests/component_tests/sensor/rh_correction.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temperature + filters: + - offset: -7.3 + - platform: template + id: constant_offset + filters: + - rh_correction: + sensor_id: temperature + temperature_offset: -7.3 + - platform: template + id: templated_offset + filters: + - rh_correction: + sensor_id: temperature + temperature_offset: !lambda return -7.3; + use_fahrenheit: true diff --git a/tests/component_tests/sensor/test_rh_correction.py b/tests/component_tests/sensor/test_rh_correction.py new file mode 100644 index 00000000000..f14cc285632 --- /dev/null +++ b/tests/component_tests/sensor/test_rh_correction.py @@ -0,0 +1,60 @@ +"""The rh_correction filter: schema and the constructor call it generates.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +from esphome.components.sensor import validate_filters +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + "filters", + [ + pytest.param([{"rh_correction": {"sensor_id": "t"}}], id="no_offset"), + pytest.param([{"rh_correction": {"temperature_offset": -7.3}}], id="no_sensor"), + pytest.param( + [ + { + "rh_correction": { + "sensor_id": "t", + "temperature_offset": -7.3, + "use_fahrenheit": "maybe", + } + } + ], + id="bad_fahrenheit", + ), + ], +) +def test_rh_correction_rejects_incomplete_config(filters: list[dict]) -> None: + with pytest.raises(cv.Invalid): + validate_filters(filters) + + +def test_rh_correction_defaults_to_celsius() -> None: + config = validate_filters( + [{"rh_correction": {"sensor_id": "t", "temperature_offset": -7.3}}] + ) + assert config[0]["rh_correction"]["use_fahrenheit"] is False + + +def test_rh_correction_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + main_cpp = generate_main("tests/component_tests/sensor/rh_correction.yaml") + + # A constant offset is wrapped in a stateless lambda so the filter stores a function pointer + assert re.search( + r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{\s*return -7\.3f;\s*\}, " + r"temperature, false\);\s*constant_offset->add_filter\(", + main_cpp, + ) + # A templated offset keeps the user's lambda body and passes use_fahrenheit through + assert re.search( + r"sensor::RHCorrectionFilter\(\[\]\(\) -> float \{[^}]*return -7\.3;[^}]*\}, " + r"temperature, true\);\s*templated_offset->add_filter\(", + main_cpp, + ) diff --git a/tests/component_tests/speaker_source/test_media_player.py b/tests/component_tests/speaker_source/test_media_player.py index 0db5b3875e3..f6148cb4c34 100644 --- a/tests/component_tests/speaker_source/test_media_player.py +++ b/tests/component_tests/speaker_source/test_media_player.py @@ -1,14 +1,22 @@ -"""Tests for the speaker_source media player announcement volume validation.""" +"""Tests for the speaker_source media player pipeline validation.""" + +from typing import Any import pytest from esphome import config_validation as cv -from esphome.components.const import CONF_ANNOUNCEMENT_VOLUME +from esphome.components import audio +from esphome.components.const import CONF_ANNOUNCEMENT_VOLUME, CONF_BITRATE from esphome.components.speaker_source.media_player import ( CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE, + PIPELINE_SCHEMA, _validate_announcement_volume, + _validate_pipeline, ) +from esphome.const import CONF_FORMAT, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE + +PIPELINE = {"speaker": "spk", "sources": ["src"]} def test_announcement_volume_with_both_pipelines() -> None: @@ -29,3 +37,49 @@ def test_no_announcement_volume_with_one_pipeline() -> None: def test_announcement_volume_needs_both_pipelines(pipeline: str) -> None: with pytest.raises(cv.Invalid, match="requires both"): _validate_announcement_volume({CONF_ANNOUNCEMENT_VOLUME: 0.6, pipeline: {}}) + + +@pytest.mark.parametrize("bitrate", [8, 48, 144, 320]) +def test_pipeline_accepts_mp3_bitrates(bitrate: int) -> None: + config = PIPELINE_SCHEMA({**PIPELINE, CONF_FORMAT: "MP3", CONF_BITRATE: bitrate}) + assert config[CONF_BITRATE] == bitrate + + +def test_pipeline_without_bitrate() -> None: + assert CONF_BITRATE not in PIPELINE_SCHEMA(PIPELINE) + + +@pytest.mark.parametrize("bitrate", [0, -48, 50, 48000]) +def test_pipeline_rejects_bitrates_the_encoder_cannot_use(bitrate: int) -> None: + with pytest.raises(cv.Invalid, match="Unknown value"): + PIPELINE_SCHEMA({**PIPELINE, CONF_FORMAT: "MP3", CONF_BITRATE: bitrate}) + + +def _final_validate(monkeypatch: pytest.MonkeyPatch, **overrides: Any) -> dict: + """Run the pipeline's final validator with the speaker checks isolated.""" + monkeypatch.setattr( + audio, "final_validate_audio_schema", lambda *args, **kwargs: lambda c: c + ) + # Both audio properties are set, so nothing is inherited from the speaker. + config = PIPELINE_SCHEMA( + {**PIPELINE, CONF_SAMPLE_RATE: 48000, CONF_NUM_CHANNELS: 1, **overrides} + ) + return _validate_pipeline(config) + + +def test_final_validation_keeps_mp3_bitrate(monkeypatch: pytest.MonkeyPatch) -> None: + config = _final_validate(monkeypatch, **{CONF_FORMAT: "MP3", CONF_BITRATE: 48}) + assert config[CONF_BITRATE] == 48 + + +def test_final_validation_without_bitrate(monkeypatch: pytest.MonkeyPatch) -> None: + assert CONF_BITRATE not in _final_validate(monkeypatch, **{CONF_FORMAT: "FLAC"}) + + +@pytest.mark.parametrize("format_", ["FLAC", "NONE", "WAV"]) +def test_final_validation_rejects_bitrate_for_other_formats( + format_: str, monkeypatch: pytest.MonkeyPatch +) -> None: + with pytest.raises(cv.Invalid, match="only supported for the MP3 format") as exc: + _final_validate(monkeypatch, **{CONF_FORMAT: format_, CONF_BITRATE: 48}) + assert exc.value.path == [CONF_BITRATE] diff --git a/tests/component_tests/spi/test_psram_dma.py b/tests/component_tests/spi/test_psram_dma.py index f721e0761bf..72e0f1e03d8 100644 --- a/tests/component_tests/spi/test_psram_dma.py +++ b/tests/component_tests/spi/test_psram_dma.py @@ -5,6 +5,7 @@ import pytest from esphome import config_validation as cv from esphome.components.esp32 import ( KEY_BOARD, + KEY_IDF_VERSION, KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32S3, @@ -35,11 +36,17 @@ def _stage( platform_framework: PlatformFramework, variant: str, version: cv.Version, + idf: cv.Version | None = None, ) -> None: + """``idf`` is the ESP-IDF underneath an Arduino ``version``; an ESP-IDF build is its own.""" set_core_config( platform_framework, core_data={KEY_FRAMEWORK_VERSION: version}, - platform_data={KEY_BOARD: "test-board", KEY_VARIANT: variant}, + platform_data={ + KEY_BOARD: "test-board", + KEY_VARIANT: variant, + KEY_IDF_VERSION: idf or version, + }, ) CORE.loaded_integrations.add("psram") @@ -74,17 +81,46 @@ def test_psram_dma_accepts_esp32s31( assert config[CONF_PSRAM_DMA] is True -def test_psram_dma_rejects_arduino( +def test_psram_dma_accepts_arduino_on_idf_5_5_3( + set_core_config: SetCoreConfigCallable, +) -> None: + """The ESP-IDF underneath an Arduino build is what counts; 3.3.7 is the first on 5.5.3.""" + _stage( + set_core_config, + PlatformFramework.ESP32_ARDUINO, + VARIANT_ESP32S3, + cv.Version(3, 3, 7), + idf=cv.Version(5, 5, 3), + ) + + config = _schema()({CONF_PSRAM_DMA: True}) + + assert config[CONF_PSRAM_DMA] is True + + +def test_psram_dma_rejects_other_platforms( + set_core_config: SetCoreConfigCallable, +) -> None: + """No ESP32 state to read a version from: the platform check comes first.""" + set_core_config(PlatformFramework.ESP8266_ARDUINO) + CORE.loaded_integrations.add("psram") + + with pytest.raises(cv.Invalid, match="only available on"): + _schema()({CONF_PSRAM_DMA: True}) + + +def test_psram_dma_rejects_older_arduino( set_core_config: SetCoreConfigCallable, ) -> None: _stage( set_core_config, PlatformFramework.ESP32_ARDUINO, VARIANT_ESP32S3, - cv.Version(5, 5, 3), + cv.Version(3, 3, 6), + idf=cv.Version(5, 5, 2), ) - with pytest.raises(cv.Invalid, match="only available with framework"): + with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.5.3"): _schema()({CONF_PSRAM_DMA: True}) @@ -127,7 +163,7 @@ def test_psram_dma_rejects_older_idf( cv.Version(5, 5, 2), ) - with pytest.raises(cv.Invalid, match="requires at least framework version 5.5.3"): + with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.5.3"): _schema()({CONF_PSRAM_DMA: True}) diff --git a/tests/component_tests/switch/__init__.py b/tests/component_tests/switch/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/switch/config/restore_state_absent_esp32.yaml b/tests/component_tests/switch/config/restore_state_absent_esp32.yaml new file mode 100644 index 00000000000..c757d6fef45 --- /dev/null +++ b/tests/component_tests/switch/config/restore_state_absent_esp32.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +switch: + - {platform: template, id: legacy, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF} + - {platform: template, id: plain, optimistic: true} diff --git a/tests/component_tests/switch/config/restore_state_esp32.yaml b/tests/component_tests/switch/config/restore_state_esp32.yaml new file mode 100644 index 00000000000..1dd773d8060 --- /dev/null +++ b/tests/component_tests/switch/config/restore_state_esp32.yaml @@ -0,0 +1,20 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +switch: + - {platform: template, id: keep_rtc, optimistic: true, initial_state: ON, restore_state: {state: KEEP, storage: rtc}} + - {platform: template, id: keep_flash, optimistic: true, restore_state: {state: KEEP, storage: flash}} + - {platform: template, id: keep_short, optimistic: true, restore_state: {state: KEEP}} + - {platform: template, id: all_short, optimistic: true, restore_state: ALL} + - {platform: template, id: invert_on, optimistic: true, initial_state: ON, restore_state: {state: INVERT}} + - {platform: template, id: initial_on, optimistic: true, initial_state: ON, restore_state: {state: INITIAL}} + - {platform: template, id: none_on, optimistic: true, initial_state: ON, restore_state: NONE} + - {platform: template, id: initial_only, optimistic: true, initial_state: ON} + - {platform: template, id: legacy, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF} + - {platform: template, id: plain, optimistic: true} diff --git a/tests/component_tests/switch/config/restore_state_esp8266.yaml b/tests/component_tests/switch/config/restore_state_esp8266.yaml new file mode 100644 index 00000000000..0633690ca67 --- /dev/null +++ b/tests/component_tests/switch/config/restore_state_esp8266.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: esp12e + restore_from_flash: true + +logger: + +switch: + - {platform: template, id: keep_rtc, optimistic: true, initial_state: ON, restore_state: {state: KEEP, storage: rtc}} diff --git a/tests/component_tests/switch/test_restore_state.py b/tests/component_tests/switch/test_restore_state.py new file mode 100644 index 00000000000..0e70934b4ba --- /dev/null +++ b/tests/component_tests/switch/test_restore_state.py @@ -0,0 +1,216 @@ +"""Tests for switch `initial_state` / `restore_state` (including `storage`).""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import switch +from esphome.components.libretiny.const import ( + FAMILY_BK7231T, + FAMILY_BK7251, + FAMILY_LN882H, + FAMILY_RTL8720C, + KEY_FAMILY, + KEY_LIBRETINY, +) +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +GATE = "USE_SWITCH_STORAGE" +ESP32_RTC = "USE_ESP32_RTC_PREFERENCES" + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +@pytest.mark.parametrize( + ("switch_id", "mode"), + [ + ("keep_short", "SWITCH_RESTORE_DEFAULT_OFF"), + ("all_short", "SWITCH_RESTORE_DEFAULT_OFF"), + ("invert_on", "SWITCH_RESTORE_INVERTED_DEFAULT_ON"), + ("initial_on", "SWITCH_ALWAYS_ON"), + ("none_on", "SWITCH_ALWAYS_ON"), + ("initial_only", "SWITCH_ALWAYS_ON"), + ("legacy", "SWITCH_RESTORE_DEFAULT_OFF"), + ("plain", "SWITCH_ALWAYS_OFF"), + ], +) +def test_restore_state_maps_to_a_restore_mode( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + switch_id: str, + mode: str, +) -> None: + """Every initial_state / restore_state combination becomes one existing restore mode.""" + main_cpp = generate_main(component_config_path("restore_state_esp32.yaml")) + + assert f"{switch_id}->set_restore_mode(switch_::{mode});" in main_cpp + + +def test_storage_is_passed_only_when_set( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("restore_state_esp32.yaml")) + + assert ( + "keep_rtc->set_restore_mode(static_cast(" + "switch_::SWITCH_RESTORE_DEFAULT_ON | switch_::RESTORE_MODE_STORAGE_RTC_MASK));" + ) in main_cpp + assert ( + "keep_flash->set_restore_mode(static_cast(" + "switch_::SWITCH_RESTORE_DEFAULT_OFF | switch_::RESTORE_MODE_STORAGE_FLASH_MASK));" + ) in main_cpp + assert ( + "keep_short->set_restore_mode(switch_::SWITCH_RESTORE_DEFAULT_OFF);" in main_cpp + ) + assert GATE in _defines() + assert ESP32_RTC in _defines() + + +def test_nothing_new_emitted_without_restore_state( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Configs using only restore_mode generate exactly what they did before.""" + main_cpp = generate_main(component_config_path("restore_state_absent_esp32.yaml")) + + assert "RESTORE_MODE_STORAGE_" not in main_cpp + assert GATE not in _defines() + assert ESP32_RTC not in _defines() + + +def test_rtc_applies_per_switch_on_esp8266_with_restore_from_flash( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("restore_state_esp8266.yaml")) + + assert ( + "keep_rtc->set_restore_mode(static_cast(" + "switch_::SWITCH_RESTORE_DEFAULT_ON | switch_::RESTORE_MODE_STORAGE_RTC_MASK));" + ) in main_cpp + assert ESP32_RTC not in _defines() + + +def _validate(config: dict, *, default_restore_mode: str = cv.UNDEFINED) -> dict: + schema = switch.switch_schema( + switch.Switch, default_restore_mode=default_restore_mode + ) + return schema({"id": "test_switch", **config}) + + +@pytest.fixture +def esp8266(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + + +@pytest.mark.usefixtures("esp8266") +def test_restore_mode_and_restore_state_are_exclusive() -> None: + with pytest.raises(cv.Invalid, match="restore"): + _validate({"restore_mode": "RESTORE_DEFAULT_OFF", "restore_state": "all"}) + + +@pytest.mark.usefixtures("esp8266") +def test_initial_state_rejected_with_restore_mode() -> None: + """restore_mode already decides the boot state, so initial_state would be ignored.""" + with pytest.raises(cv.Invalid, match="initial_state"): + _validate({"restore_mode": "ALWAYS_OFF", "initial_state": True}) + + +@pytest.mark.usefixtures("esp8266") +def test_storage_rejected_when_nothing_is_restored() -> None: + with pytest.raises(cv.Invalid, match="only applies"): + _validate({"restore_state": {"state": "INITIAL", "storage": "rtc"}}) + + +@pytest.mark.usefixtures("esp8266") +@pytest.mark.parametrize("value", ["RESTORE", "ON", "OFF", "KEEP", "INVERT", "INITIAL"]) +def test_unsupported_restore_state_values_rejected(value: object) -> None: + # Only light's shorthands (all, none) are accepted as a string + with pytest.raises(cv.Invalid, match="expected 'all', 'none' or a mapping"): + _validate({"restore_state": value}) + + +@pytest.mark.usefixtures("esp8266") +@pytest.mark.parametrize("state", [True, False, "ON", "off"]) +def test_restore_state_on_off_explains_what_to_use(state: object) -> None: + # Valid on light, so people will try it; YAML turns ON into True + with pytest.raises( + cv.Invalid, match="initial_state: ON' \\(or OFF\\) with 'restore_state: none'" + ): + _validate({"restore_state": {"state": state}}) + + +@pytest.mark.parametrize( + ("platform", "family"), + [ + (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7251), + (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C), + (PlatformFramework.LN882X_ARDUINO, FAMILY_LN882H), + (PlatformFramework.RP2040_ARDUINO, None), + (PlatformFramework.NRF52_ZEPHYR, None), + (PlatformFramework.HOST_NATIVE, None), + ], +) +def test_rtc_rejected_without_an_rtc_backend( + set_core_config: SetCoreConfigCallable, + platform: PlatformFramework, + family: str | None, +) -> None: + """These preferences backends ignore in_flash, so the state would silently stay in flash.""" + set_core_config(platform) + if family is not None: + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family} + with pytest.raises(cv.Invalid, match="'rtc' storage is not supported"): + _validate({"restore_state": {"state": "KEEP", "storage": "rtc"}}) + + +def test_rtc_accepted_on_a_beken_chip_with_the_emulation( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.BK72XX_ARDUINO) + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: FAMILY_BK7231T} + _validate({"restore_state": {"state": "KEEP", "storage": "rtc"}}) + + +@pytest.mark.usefixtures("esp8266") +def test_rtc_accepted_on_esp8266() -> None: + _validate({"restore_state": {"state": "KEEP", "storage": "rtc"}}) + + +@pytest.mark.usefixtures("esp8266") +def test_platform_default_restore_mode_applies_only_when_nothing_is_set() -> None: + """Platforms such as haier default to DISABLED; restore_state must still be usable there.""" + assert _validate({}, default_restore_mode="DISABLED")["restore_mode"] == "DISABLED" + configured = _validate({"restore_state": "all"}, default_restore_mode="DISABLED") + assert "restore_mode" not in configured + + +@pytest.mark.usefixtures("esp8266") +@pytest.mark.parametrize( + ("default_restore_mode", "initial_state", "expected"), + [ + # A restoring default keeps restoring; initial_state only replaces its fallback + ("RESTORE_DEFAULT_ON", False, "RESTORE_DEFAULT_OFF"), + ("RESTORE_DEFAULT_OFF", True, "RESTORE_DEFAULT_ON"), + ("RESTORE_INVERTED_DEFAULT_OFF", True, "RESTORE_INVERTED_DEFAULT_ON"), + # A default that restores nothing starts in initial_state + ("ALWAYS_OFF", True, "ALWAYS_ON"), + ("DISABLED", True, "ALWAYS_ON"), + ], +) +def test_initial_state_alone_keeps_a_restoring_platform_default( + default_restore_mode: str, initial_state: bool, expected: str +) -> None: + config = _validate( + {"initial_state": initial_state}, default_restore_mode=default_restore_mode + ) + assert "restore_mode" not in config + assert "storage" not in config.get("restore_state", {}) + assert switch._resolve_restore_mode(config) is switch.RESTORE_MODES[expected] diff --git a/tests/component_tests/sx126x/test_packet_tables.py b/tests/component_tests/sx126x/test_packet_tables.py index aebbb9efd21..87489bb5fa3 100644 --- a/tests/component_tests/sx126x/test_packet_tables.py +++ b/tests/component_tests/sx126x/test_packet_tables.py @@ -14,7 +14,7 @@ def test_constant_packets_share_progmem_tables( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """Equal constant packets share one table; lambdas stay templates.""" + """Equal constant packets share one table; a lambda is sent from its vector.""" main_cpp = generate_main(component_config_path("packet_tables.yaml")) tables = dict( @@ -26,8 +26,10 @@ def test_constant_packets_share_progmem_tables( ["{0xC5, 0x51, 0x78, 0x82}", "{0x68, 0x69}"] ) shared = next(k for k, v in tables.items() if v == "{0xC5, 0x51, 0x78, 0x82}") - assert main_cpp.count(f"set_data_static({shared}, 4);") == 2 - assert "set_data_template(" in main_cpp + assert main_cpp.count(f"->transmit_packet(::{shared}, 4);") == 2 + assert ( + "->transmit_packet(esphome__bytes.data(), esphome__bytes.size());" in main_cpp + ) assert "set_sx1261" not in main_cpp diff --git a/tests/component_tests/sx127x/test_packet_tables.py b/tests/component_tests/sx127x/test_packet_tables.py index f7573bb1cac..2faacb9ee63 100644 --- a/tests/component_tests/sx127x/test_packet_tables.py +++ b/tests/component_tests/sx127x/test_packet_tables.py @@ -14,7 +14,7 @@ def test_constant_packets_share_progmem_tables( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """Equal constant packets share one table; lambdas stay templates.""" + """Equal constant packets share one table; a lambda is sent from its vector.""" main_cpp = generate_main(component_config_path("packet_tables.yaml")) tables = dict( @@ -26,8 +26,10 @@ def test_constant_packets_share_progmem_tables( ["{0xC5, 0x51, 0x78, 0x82}", "{0x68, 0x69}"] ) shared = next(k for k, v in tables.items() if v == "{0xC5, 0x51, 0x78, 0x82}") - assert main_cpp.count(f"set_data_static({shared}, 4);") == 2 - assert "set_data_template(" in main_cpp + assert main_cpp.count(f"->transmit_packet(::{shared}, 4);") == 2 + assert ( + "->transmit_packet(esphome__bytes.data(), esphome__bytes.size());" in main_cpp + ) def test_packet_data_length_limit() -> None: diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py index a8f9420deac..8b2cf5147a6 100644 --- a/tests/component_tests/teleinfo/test_sensor.py +++ b/tests/component_tests/teleinfo/test_sensor.py @@ -1,9 +1,15 @@ """Tests for teleinfo sensor tag presets.""" +import logging + import pytest from esphome.components.teleinfo import CONF_TAG_NAME -from esphome.components.teleinfo.sensor import apply_tag_config +from esphome.components.teleinfo.sensor import ( + TIC_TAG_CONFIGS, + TIC_TEXT_TAGS, + apply_tag_config, +) from esphome.const import ( CONF_DEVICE_CLASS, CONF_ICON, @@ -11,6 +17,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_REACTIVE_ENERGY, @@ -22,6 +29,7 @@ from esphome.const import ( UNIT_KILOVOLT_AMPS, UNIT_KILOWATT_HOURS, UNIT_MILLIAMP, + UNIT_MINUTE, UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_VOLT_AMPS_REACTIVE_HOURS, @@ -60,7 +68,13 @@ from esphome.const import ( ("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), ("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), ("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT), - ("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ( + "PMAX", + UNIT_VOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ("PEJP", UNIT_MINUTE, DEVICE_CLASS_DURATION, STATE_CLASS_MEASUREMENT), ], ) def test_known_tag_preset( @@ -73,6 +87,42 @@ def test_known_tag_preset( assert CONF_ICON not in config +def test_no_prefix_is_prefix_of_another() -> None: + prefixes = [*TIC_TAG_CONFIGS, *TIC_TEXT_TAGS] + assert len(prefixes) == len(set(prefixes)) + for prefix in prefixes: + for other in prefixes: + assert prefix == other or not other.startswith(prefix) + + +@pytest.mark.parametrize("tag", ["NTARF", "NJOURF", "NJOURF+1", "RELAIS", "VTIC"]) +def test_unitless_tag_gets_no_unit(tag: str, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + config = apply_tag_config({CONF_TAG_NAME: tag}) + assert config == {CONF_TAG_NAME: tag} + assert not caplog.records + + +@pytest.mark.parametrize("tag", ["LTARF", "NGTF", "PTEC", "OPTARIF", "MSG1", "PPOT"]) +def test_text_tag_gets_no_unit_and_warns( + tag: str, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + config = apply_tag_config({CONF_TAG_NAME: tag}) + assert config == {CONF_TAG_NAME: tag} + assert "text_sensor" in caplog.text + + +def test_unitless_tag_keeps_user_settings() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "NTARF", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT} + ) + assert config == { + CONF_TAG_NAME: "NTARF", + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + } + + def test_unknown_tag_keeps_legacy_defaults() -> None: config = apply_tag_config({CONF_TAG_NAME: "MYTAG"}) assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS diff --git a/tests/component_tests/template/test_template_cover.py b/tests/component_tests/template/test_template_cover.py new file mode 100644 index 00000000000..0b3dd8eaf80 --- /dev/null +++ b/tests/component_tests/template/test_template_cover.py @@ -0,0 +1,25 @@ +"""Tests for template cover config validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.template.cover import CONF_STOP_TILT_ACTION, CONFIG_SCHEMA +from esphome.const import CONF_ID, CONF_NAME, CONF_TILT_ACTION +from esphome.types import ConfigType + + +def _cover_config(**extra: object) -> ConfigType: + return {CONF_ID: "test_cover", CONF_NAME: "Test Cover", **extra} + + +def test_stop_tilt_action_with_tilt_action_allowed() -> None: + config = CONFIG_SCHEMA( + _cover_config(**{CONF_TILT_ACTION: [], CONF_STOP_TILT_ACTION: []}) + ) + assert CONF_STOP_TILT_ACTION in config + + +def test_stop_tilt_action_without_tilt_action_rejected() -> None: + with pytest.raises(cv.Invalid, match="requires 'tilt_action'") as exc_info: + CONFIG_SCHEMA(_cover_config(**{CONF_STOP_TILT_ACTION: []})) + assert exc_info.value.path == [CONF_STOP_TILT_ACTION] diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index f5ac07c1cd1..a8f80049ec0 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -28,7 +28,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'App.register_text(it_1, "test 1 text",' in main_cpp + assert 'App.register_text(it_1, ESPHOME_PSTR("test 1 text"),' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 9eda24f7b94..431e12caf83 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -31,9 +31,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'App.register_text_sensor(ts_1, "Template Text Sensor 1",' in main_cpp - assert 'App.register_text_sensor(ts_2, "Template Text Sensor 2",' in main_cpp - assert 'App.register_text_sensor(ts_3, "Template Text Sensor 3",' in main_cpp + for i in (1, 2, 3): + name = f'ESPHOME_PSTR("Template Text Sensor {i}")' + assert f"App.register_text_sensor(ts_{i}, {name}," in main_cpp def test_text_sensor_config_value_internal_set(generate_main): diff --git a/tests/component_tests/uart/config/payload_tables_esp8266.yaml b/tests/component_tests/uart/config/payload_tables_esp8266.yaml new file mode 100644 index 00000000000..1f5359947de --- /dev/null +++ b/tests/component_tests/uart/config/payload_tables_esp8266.yaml @@ -0,0 +1,34 @@ +--- +esphome: + name: test + on_boot: + then: + - uart.write: [0x01, 0x02, 0x03] + - uart.write: !lambda return {0x09}; + - uart.write: "" + +esp8266: + board: d1_mini + +uart: + tx_pin: GPIO1 + rx_pin: GPIO3 + baud_rate: 9600 + +switch: + - platform: uart + id: shared_switch + name: Shared Switch + data: [0x01, 0x02, 0x03] + - platform: uart + id: on_off_switch + name: On Off Switch + data: + turn_on: "ON" + turn_off: [0x04] + +button: + - platform: uart + id: shared_button + name: Shared Button + data: [0x01, 0x02, 0x03] diff --git a/tests/component_tests/uart/test_payload_tables.py b/tests/component_tests/uart/test_payload_tables.py index 2ba341136a7..4464e4c5308 100644 --- a/tests/component_tests/uart/test_payload_tables.py +++ b/tests/component_tests/uart/test_payload_tables.py @@ -23,6 +23,19 @@ def test_constant_payloads_share_progmem_tables( ) assert f"shared_switch->set_data_on({shared}, 3);" in main_cpp assert f"shared_button->set_data({shared}, 3);" in main_cpp - assert f"set_data_static({shared}, 3);" in main_cpp - assert "set_data_static(nullptr, 0);" in main_cpp - assert "set_data_template(" in main_cpp + assert f"->write_array(::{shared}, 3);" in main_cpp + assert "->write_array(nullptr, 0);" in main_cpp + assert "->write_array(esphome__bytes.data(), esphome__bytes.size());" in main_cpp + + +def test_esp8266_write_action_reads_flash_directly( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """On ESP8266 the write action hands the table to write_array_progmem instead of copying it.""" + main_cpp = generate_main(component_config_path("payload_tables_esp8266.yaml")) + + assert "->write_array_progmem(::uart_data, 3);" in main_cpp + assert "->write_array_progmem(nullptr, 0);" in main_cpp + assert "->write_array(esphome__bytes.data(), esphome__bytes.size());" in main_cpp + assert "progmem_memcpy" not in main_cpp diff --git a/tests/component_tests/udp/test_udp_write.py b/tests/component_tests/udp/test_udp_write.py index d999891d958..2c991a7a34e 100644 --- a/tests/component_tests/udp/test_udp_write.py +++ b/tests/component_tests/udp/test_udp_write.py @@ -8,10 +8,17 @@ def test_write_payloads_share_a_progmem_table( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """Identical constant payloads share one flash table; an empty one needs none.""" + """Identical constant payloads share one flash table and are copied out of it on + ESP8266 before sending; an empty one needs none; a lambda is sent from its vector.""" main_cpp = generate_main(component_config_path("udp_write.yaml")) - assert main_cpp.count("udp_data[] PROGMEM = {0xAA, 0x55, 0x01, 0x02};") == 1 - assert main_cpp.count("->set_data_static(udp_data, 4);") == 2 - assert "->set_data_static(nullptr, 0);" in main_cpp - assert "->set_data_template(" in main_cpp + table = "esphome__udp_write__bytes" + assert main_cpp.count(f"{table}[] PROGMEM = {{0xAA, 0x55, 0x01, 0x02}};") == 1 + assert ( + main_cpp.count(f"esphome::progmem_memcpy(esphome__bytes, ::{table}, 4);") == 2 + ) + assert main_cpp.count("->send_packet(esphome__bytes, 4);") == 2 + assert "uint8_t esphome__bytes[1];" in main_cpp + assert "->send_packet(esphome__bytes, 0);" in main_cpp + assert "->send_packet(esphome__bytes.data(), esphome__bytes.size());" in main_cpp + assert main_cpp.count("->set_should_broadcast();") == 4 diff --git a/tests/component_tests/usb_host/test_fifo.py b/tests/component_tests/usb_host/test_fifo.py index 8bc12a3a183..e4b84cf05cd 100644 --- a/tests/component_tests/usb_host/test_fifo.py +++ b/tests/component_tests/usb_host/test_fifo.py @@ -168,5 +168,5 @@ def test_final_validate_needs_idf_5_5( ) -> None: _set_variant(set_core_config, VARIANT_ESP32S3, idf=idf) usb_host.request_periodic_fifo(out_bytes=196) - with pytest.raises(cv.Invalid, match="needs ESP-IDF 5.5.0 or later"): + with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.5.0 or newer"): usb_host.FINAL_VALIDATE_SCHEMA({}) diff --git a/tests/component_tests/usb_uart/test_channel_limit.py b/tests/component_tests/usb_uart/test_channel_limit.py new file mode 100644 index 00000000000..76370bf3210 --- /dev/null +++ b/tests/component_tests/usb_uart/test_channel_limit.py @@ -0,0 +1,38 @@ +"""Tests for the per-type channel limit, which depends on the target.""" + +from pathlib import Path +import subprocess +import sys + +import pytest + +from esphome.components.esp32.const import KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32P4 +from esphome.components.usb_uart import channel_schema, uart_types +import esphome.config_validation as cv +from esphome.const import CONF_BAUD_RATE, CONF_CHANNELS, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +CH34X = next(t for t in uart_types if t.name == "CH34X") + + +def test_import_needs_no_core_config() -> None: + """The C++ unit test runner imports every component before any config is loaded.""" + subprocess.run( + [sys.executable, "-c", "from esphome.components import usb_uart"], + cwd=Path(__file__).parents[3], + check=True, + ) + + +@pytest.mark.parametrize( + ("variant", "limit"), [(VARIANT_ESP32, 3), (VARIANT_ESP32P4, 4)] +) +def test_channel_limit_follows_the_target( + set_core_config: SetCoreConfigCallable, variant: str, limit: int +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + schema = channel_schema(CH34X) + channel = {CONF_BAUD_RATE: 9600} + assert len(schema({CONF_CHANNELS: [channel] * limit})[CONF_CHANNELS]) == limit + with pytest.raises(cv.Invalid, match=f"maximum of {limit} channels"): + schema({CONF_CHANNELS: [channel] * (limit + 1)}) diff --git a/tests/component_tests/vnc/__init__.py b/tests/component_tests/vnc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/vnc/test_init.py b/tests/component_tests/vnc/test_init.py new file mode 100644 index 00000000000..51c32d8bad1 --- /dev/null +++ b/tests/component_tests/vnc/test_init.py @@ -0,0 +1,406 @@ +"""Code generation and validation tests for the vnc component.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + +TEST_CARD = "show_test_card()" +UPDATE_NEVER = "set_update_interval(4294967295UL)" + +BASE = """ +esphome: + name: vnc-test + +esp32: + board: esp32dev + +logger: + +wifi: + ssid: MySSID + password: password1 + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 +""" + +IT8951 = """ +display: + - platform: it8951 + id: epd + model: m5stack-m5paper +""" + +VNC = """ +vnc: + - host: 192.168.1.20 +""" + +EPAPER_SPI = """ + - platform: epaper_spi + id: other + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 +""" + + +S3_BASE = """ +esphome: + name: vnc-test + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +wifi: + ssid: MySSID + password: password1 +""" + +RPI_DPI_RGB = """ +display: + - platform: rpi_dpi_rgb + id: rgb + dimensions: + width: 800 + height: 480 + de_pin: 40 + hsync_pin: 39 + vsync_pin: 41 + pclk_pin: 42 + data_pins: + red: [45, 48, 47, 21, 14] + green: [5, 6, 7, 15, 16, 4] + blue: [8, 3, 46, 9, 1] +""" + + +OPENTHREAD_BASE = """ +esphome: + name: vnc-test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +openthread: + tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8 + +spi: + clk_pin: GPIO6 + mosi_pin: GPIO7 +""" + +C6_EPAPER = """ +display: + - platform: epaper_spi + id: epd + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO4 + busy_pin: GPIO3 + reset_pin: GPIO2 +""" + + +@pytest.fixture(autouse=True) +def _clear_toolchain() -> None: + # A host build earlier in the same session leaves the host toolchain selected. + CORE.toolchain = None + + +@pytest.fixture +def write_yaml(tmp_path: Path) -> Callable[..., Path]: + def writer(*parts: str, base: str = BASE) -> Path: + path = tmp_path / "vnc.yaml" + path.write_text(base + "".join(parts), encoding="utf-8") + return path + + return writer + + +@pytest.fixture +def config_error( + write_yaml: Callable[..., Path], capsys: pytest.CaptureFixture[str] +) -> Callable[..., str]: + """Validate a config that must fail and return the printed errors.""" + + def validate(*parts: str, base: str = BASE) -> str: + CORE.config_path = write_yaml(*parts, base=base) + assert read_config({}) is None + return capsys.readouterr().out + + return validate + + +def test_it8951_owned_by_vnc( + generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path] +) -> None: + main_cpp = generate_main(write_yaml(IT8951, VNC)) + + assert TEST_CARD not in main_cpp + assert f"epd->{UPDATE_NEVER}" in main_cpp + assert re.search(r"new\(\w+\) vnc::VncClient\(epd, \w+\);", main_cpp) + assert 'set_host("192.168.1.20");' in main_cpp + assert "set_port(5900);" in main_cpp + assert "set_reconnect_interval(5000);" in main_cpp + assert "set_update_delay(200);" in main_cpp + assert "set_max_update_delay(1000);" in main_cpp + + +def test_epaper_spi_owned_by_vnc( + generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path] +) -> None: + main_cpp = generate_main( + write_yaml("display:", EPAPER_SPI, "\nvnc:\n - host: 192.168.1.20\n") + ) + + assert TEST_CARD not in main_cpp + assert f"other->{UPDATE_NEVER}" in main_cpp + assert re.search(r"new\(\w+\) vnc::VncClient\(other, \w+\);", main_cpp) + + +def test_display_with_generated_id( + generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path] +) -> None: + main_cpp = generate_main( + write_yaml("display:\n - platform: it8951\n model: m5stack-m5paper\n", VNC) + ) + + assert TEST_CARD not in main_cpp + assert UPDATE_NEVER in main_cpp + assert "vnc::VncClient(" in main_cpp + + +def test_only_the_vnc_display_loses_test_card( + generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path] +) -> None: + main_cpp = generate_main( + write_yaml( + IT8951, EPAPER_SPI, "\nvnc:\n - host: 192.168.1.20\n display_id: epd\n" + ) + ) + + assert f"other->{TEST_CARD}" in main_cpp + assert f"epd->{TEST_CARD}" not in main_cpp + assert f"epd->{UPDATE_NEVER}" in main_cpp + + +def test_options( + generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path] +) -> None: + main_cpp = generate_main( + write_yaml( + IT8951, + """ +vnc: + - host: vnc.local + port: 5901 + reconnect_interval: 2s + update_delay: 0ms + max_update_delay: 3s + on_connect: + - logger.log: up + on_disconnect: + - logger.log: down +""", + ) + ) + + assert 'set_host("vnc.local");' in main_cpp + assert "set_port(5901);" in main_cpp + assert "set_reconnect_interval(2000);" in main_cpp + assert "set_update_delay(0);" in main_cpp + assert "set_max_update_delay(3000);" in main_cpp + assert "add_on_connect_callback(" in main_cpp + assert "add_on_disconnect_callback(" in main_cpp + + +def test_two_clients_on_two_displays( + generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path] +) -> None: + main_cpp = generate_main( + write_yaml( + IT8951, + EPAPER_SPI, + """ +vnc: + - host: 192.168.1.20 + display_id: epd + - host: 192.168.1.21 + display_id: other +""", + ) + ) + + assert re.search(r"vnc::VncClient\(epd, \w+\);", main_cpp) + assert re.search(r"vnc::VncClient\(other, \w+\);", main_cpp) + assert TEST_CARD not in main_cpp + + +@pytest.mark.parametrize( + ("display_options", "error"), + [ + pytest.param( + " lambda: |-\n it.fill(Color::BLACK);\n", + "vnc draws the whole of display 'epd'", + id="lambda", + ), + pytest.param( + " pages:\n - id: page1\n lambda: |-\n" + " it.fill(Color::BLACK);\n", + "vnc draws the whole of display 'epd'", + id="pages", + ), + pytest.param( + " show_test_card: true\n", + "vnc draws the whole of display 'epd'", + id="show_test_card", + ), + pytest.param( + " auto_clear_enabled: true\n", + "vnc draws the whole of display 'epd'", + id="auto_clear_enabled", + ), + ], +) +def test_display_with_own_writer_rejected( + display_options: str, error: str, config_error: Callable[..., str] +) -> None: + assert error in config_error(IT8951, display_options, VNC) + + +def test_lvgl_on_same_display_rejected(config_error: Callable[..., str]) -> None: + out = config_error(IT8951, VNC, "\nlvgl:\n displays: [epd]\n") + + assert "Display 'epd' is also used by LVGL" in out + + +def test_lvgl_on_other_display_accepted( + write_yaml: Callable[..., Path], +) -> None: + CORE.config_path = write_yaml( + IT8951, + EPAPER_SPI, + "\nvnc:\n - host: 192.168.1.20\n display_id: epd\n", + "\nlvgl:\n displays: [other]\n", + ) + + assert read_config({}) is not None + + +def test_two_clients_on_one_display_rejected( + config_error: Callable[..., str], +) -> None: + out = config_error( + IT8951, "\nvnc:\n - host: 192.168.1.20\n - host: 192.168.1.21\n" + ) + + assert "Display 'epd' is drawn by more than one component: vnc, vnc" in out + + +def test_mipi_spi_rejected(config_error: Callable[..., str]) -> None: + out = config_error( + """ +display: + - platform: mipi_spi + id: epd + model: st7735 + cs_pin: GPIO15 + dc_pin: GPIO27 + reset_pin: GPIO26 +""", + VNC, + ) + + assert "Display 'epd' uses the mipi_spi platform" in out + + +def test_rotated_rpi_dpi_rgb_accepted(write_yaml: Callable[..., Path]) -> None: + CORE.config_path = write_yaml(RPI_DPI_RGB, " rotation: 90\n", VNC, base=S3_BASE) + + assert read_config({}) is not None + + +def test_ipv4_kept_on_with_openthread( + generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path] +) -> None: + generate_main(write_yaml(C6_EPAPER, VNC, base=OPENTHREAD_BASE)) + + define = next(d for d in CORE.defines if d.name == "USE_NETWORK_IPV4") + assert str(define.value) == "true" + + +def test_ipv4_disabled_rejected(config_error: Callable[..., str]) -> None: + out = config_error( + "network:\n enable_ipv4: false\n", C6_EPAPER, VNC, base=OPENTHREAD_BASE + ) + + assert "IPv4 is required by vnc" in out + + +def test_waveshare_epaper_rejected(config_error: Callable[..., str]) -> None: + out = config_error( + """ +display: + - platform: waveshare_epaper + id: epd + model: 1.54in + cs_pin: GPIO15 + dc_pin: GPIO27 + busy_pin: GPIO26 + reset_pin: GPIO25 +""", + VNC, + ) + + assert "Display 'epd' uses the waveshare_epaper platform" in out + assert "use epaper_spi" in out + + +@pytest.mark.parametrize( + ("vnc_options", "error"), + [ + pytest.param( + " reconnect_interval: 500ms\n", + "value must be at least 1s", + id="reconnect", + ), + pytest.param(" port: 0\n", "value must be at least 1", id="port"), + ], +) +def test_option_rejected( + vnc_options: str, error: str, config_error: Callable[..., str] +) -> None: + out = config_error(IT8951, VNC, vnc_options) + + assert error in out + + +@pytest.mark.parametrize("host", ["::1", "fe80::1", "2001:db8::10"]) +def test_ipv6_host_rejected(host: str, config_error: Callable[..., str]) -> None: + out = config_error(IT8951, f'\nvnc:\n - host: "{host}"\n') + + assert "IPv6 addresses are not supported" in out diff --git a/tests/component_tests/wifi/test_lease_cache.py b/tests/component_tests/wifi/test_lease_cache.py index 56a9f1b66bb..36d69d9dc0e 100644 --- a/tests/component_tests/wifi/test_lease_cache.py +++ b/tests/component_tests/wifi/test_lease_cache.py @@ -21,14 +21,24 @@ def _validate(lease_cache: dict) -> dict: ) -def test_storage_defaults_to_rtc(set_core_config: SetCoreConfigCallable) -> None: - set_core_config( - PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} - ) +@pytest.mark.parametrize( + "framework", [PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO] +) +def test_storage_defaults_to_rtc( + set_core_config: SetCoreConfigCallable, framework: PlatformFramework +) -> None: + """Both ESP32 frameworks share the backend the cache lives in.""" + set_core_config(framework, platform_data={KEY_VARIANT: VARIANT_ESP32}) config = _validate({"max_age": "12h"}) assert config["lease_cache"]["storage"] == "rtc" +def test_lease_cache_requires_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="only available on"): + _validate({"max_age": "12h"}) + + def test_no_rtc_variant_requires_flash(set_core_config: SetCoreConfigCallable) -> None: """A variant without RTC memory must say storage: flash; the error points at that key.""" set_core_config( diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index e64ba88f112..67f5ef62428 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -78,6 +78,7 @@ api: type: string description: Name to greet example: World + default: World then: - logger.log: format: Hello World %s! @@ -176,7 +177,7 @@ api: message: "{{ room }} is at {{ level }}" variables: room: !lambda 'return room.c_str();' - level: !lambda 'static char buf[12]; snprintf(buf, sizeof(buf), "%d", level); return buf;' + level: !lambda 'static char buf[12]; snprintf(buf, sizeof(buf), "%d", (int) level); return buf;' - homeassistant.action: action: notify.html5 data: diff --git a/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml b/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml index edb18c1ada7..5a7b56be777 100644 --- a/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml +++ b/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml @@ -8,7 +8,6 @@ bluetooth_proxy: connection_slots: 9 esp32_hosted: - active_high: true variant: ESP32C6 reset_pin: GPIO54 cmd_pin: GPIO19 diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index 3e305d921a4..c2621b18f5b 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -100,6 +100,14 @@ button: id: climate_test_thermostat custom_fan_mode: !lambda return "quiet"; custom_preset: !lambda return "Default"; + # Inlined lambdas returning a StringRef, the shape an api action string variable takes (issue #21327) + - platform: template + name: "Climate Control StringRef Lambdas" + on_press: + - climate.control: + id: climate_test_thermostat + custom_fan_mode: !lambda return id(climate_test_thermostat).get_custom_fan_mode(); + custom_preset: !lambda return id(climate_test_thermostat).get_custom_preset(); # Exercise climate.control inside a trigger with non-empty Ts (number on_value # passes float). diff --git a/tests/components/core/test_assign_bits.cpp b/tests/components/core/test_assign_bits.cpp new file mode 100644 index 00000000000..7cd7c2a11e1 --- /dev/null +++ b/tests/components/core/test_assign_bits.cpp @@ -0,0 +1,30 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static_assert([] { + uint8_t v = 0; + assign_bits(v, 1 << 3, true); + return v; +}() == 0x08); + +TEST(AssignBits, SetsAndClearsOnlyTheMask) { + uint8_t reg = 0b1010'0001; + assign_bits(reg, 0b0000'0110, true); + EXPECT_EQ(reg, 0b1010'0111); + assign_bits(reg, 0b1000'0010, false); + EXPECT_EQ(reg, 0b0010'0101); +} + +TEST(AssignBits, ClearsAtTheTargetWidth) { + uint16_t reg16 = 0xFFFF; + assign_bits(reg16, 1 << 15, false); + EXPECT_EQ(reg16, 0x7FFF); + uint64_t reg64 = UINT64_MAX; + assign_bits(reg64, uint32_t{1} << 31, false); + EXPECT_EQ(reg64, UINT64_MAX & ~(uint64_t{1} << 31)); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_entity_base_name.cpp b/tests/components/core/test_entity_base_name.cpp new file mode 100644 index 00000000000..ee7f7e47181 --- /dev/null +++ b/tests/components/core/test_entity_base_name.cpp @@ -0,0 +1,64 @@ +#include + +#include "esphome/core/entity_base.h" + +#include +#include +#include + +namespace esphome::core::testing { + +// External components return get_name() from `const StringRef &` getters, so it must stay a reference +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +static_assert(std::is_same_v().get_name()), const StringRef &>); +#pragma GCC diagnostic pop + +class TestEntity : public EntityBase { + public: + void configure(const char *name, uint32_t object_id_hash) { this->configure_entity_(name, object_id_hash, 0); } +}; + +TEST(EntityBaseName, ZeroHashIsComputedFromTheName) { + TestEntity entity; + entity.configure("Living Room Temp", 0); + EXPECT_EQ(entity.get_object_id_hash(), fnv1_hash_object_id("Living Room Temp", 16)); +} + +// The values come from the Python fnv1_hash_object_id() test table. Reading through the byte +// reader is the path a name in flash takes on ESP8266. +TEST(EntityBaseName, HashThroughByteReaderMatchesPlainHash) { + const char *name = "My Sensor Name"; + const size_t len = strlen(name); + const uint32_t expected = 0x2760962A; + EXPECT_EQ(fnv1_hash_object_id(name, len), expected); + EXPECT_EQ(fnv1_hash_object_id(len, [name](size_t i) { return name[i]; }), expected); + EXPECT_EQ(fnv1_hash_object_id(0, [](size_t) { return 'x'; }), 0x811C9DC5u); +} + +TEST(EntityBaseName, PrecomputedHashIsKept) { + TestEntity entity; + entity.configure("Living Room Temp", 0x1234); + EXPECT_EQ(entity.get_object_id_hash(), 0x1234u); +} + +// The getters return {} when the feature define is absent; get_*_to() must still hand back "", not null +TEST(EntityBaseStrings, DefaultProgmemStringRefIsEmptyNotNull) { + constexpr ProgmemStringRef ref{}; + ASSERT_NE(ref.progmem_ptr(), nullptr); + EXPECT_STREQ(ref.progmem_ptr(), ""); + EXPECT_TRUE(ref.empty()); +} + +TEST(EntityBaseStrings, UnsetIconAndDeviceClassAreEmpty) { + TestEntity entity; + entity.configure("Living Room Temp", 0x1234); + EXPECT_TRUE(entity.get_icon_progmem().empty()); + EXPECT_TRUE(entity.get_device_class_progmem().empty()); + char icon_buf[MAX_ICON_LENGTH]; + char dc_buf[MAX_DEVICE_CLASS_LENGTH]; + EXPECT_STREQ(entity.get_icon_to(icon_buf), ""); + EXPECT_STREQ(entity.get_device_class_to(dc_buf), ""); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_progmem_string_ref.cpp b/tests/components/core/test_progmem_string_ref.cpp index 1d996f36db7..1f40d53cabd 100644 --- a/tests/components/core/test_progmem_string_ref.cpp +++ b/tests/components/core/test_progmem_string_ref.cpp @@ -3,8 +3,35 @@ #include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" +#include +#include + namespace esphome::core::testing { +// ram_ref() must return a reference so callers can hand it out as `const StringRef &` +static_assert(std::is_same_v().ram_ref()), const StringRef &>); + +TEST(ProgmemStringRef, WriteToCopiesAndTerminates) { + ProgmemStringRef ref("Kitchen", 7); + char buf[16]; + EXPECT_EQ(ref.write_to(buf, sizeof(buf)), 7u); + EXPECT_STREQ(buf, "Kitchen"); +} + +TEST(ProgmemStringRef, WriteToTruncatesToFit) { + ProgmemStringRef ref("Kitchen", 7); + char buf[4]; + EXPECT_EQ(ref.write_to(buf, sizeof(buf)), 3u); + EXPECT_STREQ(buf, "Kit"); +} + +TEST(ProgmemStringRef, WriteToZeroSizeWritesNothing) { + ProgmemStringRef ref("Kitchen", 7); + char buf[1] = {'x'}; + EXPECT_EQ(ref.write_to(buf, 0), 0u); + EXPECT_EQ(buf[0], 'x'); +} + TEST(ProgmemStringRef, EqualsComparesLengthAndContent) { ProgmemStringRef ref("Kitchen", 7); EXPECT_TRUE(ref.equals(StringRef("Kitchen"))); diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index 6c6222ff875..83d7440e61b 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -14,3 +14,8 @@ esphome: then: - deep_sleep.enter: sleep_duration: 60min + - if: + condition: + lambda: 'return deep_sleep::woke_from_deep_sleep();' + then: + - logger.log: Woke from deep sleep diff --git a/tests/components/ds3231/common.yaml b/tests/components/ds3231/common.yaml index 9f087cb9411..fa8ae17a407 100644 --- a/tests/components/ds3231/common.yaml +++ b/tests/components/ds3231/common.yaml @@ -1,15 +1,74 @@ -ds3231: - - id: ds3231_component - i2c_id: i2c_bus - address: 0x68 - sensor: - platform: ds3231 ds3231_id: ds3231_component name: DS3231 Temperature update_interval: never +binary_sensor: + - platform: ds3231 + ds3231_id: ds3231_component + alarm_1: + name: DS3231 Alarm 1 + alarm_2: + name: DS3231 Alarm 2 + oscillator_stopped: + name: DS3231 Oscillator Stopped + +time: + - platform: ds3231 + id: ds3231_time + ds3231_id: ds3231_component + update_interval: never + interval: - interval: 60s then: - ds3231.force_temperature_conversion: ds3231_component + +button: + - platform: template + name: DS3231 Actions + on_press: + - ds3231.write_time: + id: ds3231_time + - ds3231.read_time: ds3231_time + - ds3231.set_alarm_1: + id: ds3231_component + mode: daily + hour: 7 + minute: 30 + second: 15 + - ds3231.set_alarm_1: + mode: every_second + enabled: false + - ds3231.set_alarm_1: + mode: monthly + day_of_month: 15 + hour: !lambda return 12; + - ds3231.set_alarm_2: + mode: weekly + day_of_week: 2 + hour: 6 + minute: !lambda return 45; + - ds3231.set_alarm_2: + mode: every_minute + - ds3231.enable_alarm: + alarm: 1 + - ds3231.disable_alarm: + alarm: 2 + - ds3231.clear_alarm: + alarm: !lambda return 1; + - if: + condition: + ds3231.is_alarm_triggered: + alarm: 2 + then: + - ds3231.clear_alarm: + alarm: 2 + - ds3231.enable_square_wave: + frequency: 1Hz + - ds3231.disable_square_wave: ds3231_component + - ds3231.set_32khz_output: + state: true + - ds3231.set_aging_offset: + offset: !lambda return 3; diff --git a/tests/components/ds3231/hub-interrupt.yaml b/tests/components/ds3231/hub-interrupt.yaml new file mode 100644 index 00000000000..45ff507aa71 --- /dev/null +++ b/tests/components/ds3231/hub-interrupt.yaml @@ -0,0 +1,15 @@ +ds3231: + - id: ds3231_component + i2c_id: i2c_bus + address: 0x68 + interrupt_pin: + number: ${interrupt_pin} + mode: + input: true + pullup: true + output_32khz: true + auto_clear_alarms: false + on_alarm_1: + - logger.log: "DS3231 alarm 1" + - ds3231.clear_alarm: + alarm: 1 diff --git a/tests/components/ds3231/hub-polling.yaml b/tests/components/ds3231/hub-polling.yaml new file mode 100644 index 00000000000..2018986af47 --- /dev/null +++ b/tests/components/ds3231/hub-polling.yaml @@ -0,0 +1,14 @@ +ds3231: + - id: ds3231_component + i2c_id: i2c_bus + address: 0x68 + square_wave_frequency: 1024Hz + battery_backed_square_wave: false + output_32khz: false + aging_offset: -2 + auto_clear_alarms: true + alarm_check_interval: 2s + on_alarm_1: + - logger.log: "DS3231 alarm 1" + on_alarm_2: + - logger.log: "DS3231 alarm 2" diff --git a/tests/components/ds3231/test-interrupt.esp8266-ard.yaml b/tests/components/ds3231/test-interrupt.esp8266-ard.yaml new file mode 100644 index 00000000000..5333ffc2d21 --- /dev/null +++ b/tests/components/ds3231/test-interrupt.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + interrupt_pin: GPIO14 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ds3231_hub: !include hub-interrupt.yaml + ds3231: !include common.yaml diff --git a/tests/components/ds3231/test.esp32-idf.yaml b/tests/components/ds3231/test.esp32-idf.yaml index fb7ac2fba5c..9e02e158dca 100644 --- a/tests/components/ds3231/test.esp32-idf.yaml +++ b/tests/components/ds3231/test.esp32-idf.yaml @@ -1,3 +1,7 @@ +substitutions: + interrupt_pin: GPIO13 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ds3231_hub: !include hub-interrupt.yaml ds3231: !include common.yaml diff --git a/tests/components/ds3231/test.esp8266-ard.yaml b/tests/components/ds3231/test.esp8266-ard.yaml index ca42d95738e..13414d52810 100644 --- a/tests/components/ds3231/test.esp8266-ard.yaml +++ b/tests/components/ds3231/test.esp8266-ard.yaml @@ -1,3 +1,4 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ds3231_hub: !include hub-polling.yaml ds3231: !include common.yaml diff --git a/tests/components/epaper_spi/display/test_goodisplay_3c_transfer.cpp b/tests/components/epaper_spi/display/test_goodisplay_3c_transfer.cpp new file mode 100644 index 00000000000..e3dfb39330e --- /dev/null +++ b/tests/components/epaper_spi/display/test_goodisplay_3c_transfer.cpp @@ -0,0 +1,164 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_goodisplay_3c.h" + +namespace esphome::epaper_spi::testing { +namespace { + +class TestableGoodDisplay3C : public EPaperGoodDisplay3C { + public: + TestableGoodDisplay3C(uint16_t width, uint16_t height) : EPaperGoodDisplay3C("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + /// A whole update with the frame already drawn, run through loop() as the main loop would. + void run_update() { + this->set_auto_clear(false); + this->state_ = EPaperState::UPDATE; + const uint32_t deadline = millis() + 2000; + while (this->state_ != EPaperState::IDLE) { + ASSERT_LT(millis(), deadline) << "update did not finish"; + this->loop(); + } + } + + using EPaperGoodDisplay3C::REFRESH_START_MS; + + RecordingPin dc; +}; + +/// Also notes when each command byte was sent. +class TimedCommandDelegate : public RecordingDelegate { + public: + using RecordingDelegate::RecordingDelegate; + + void write_array(const uint8_t *ptr, size_t length) override { + if (!this->dc_->level) { + for (size_t i = 0; i != length; i++) + this->sent.emplace_back(ptr[i], millis()); + } + RecordingDelegate::write_array(ptr, length); + } + + /// When the n-th (from 0) copy of a command was sent. + uint32_t time_of(uint8_t command, int n = 0) const { + for (const auto &[byte, time] : this->sent) { + if (byte == command && n-- == 0) + return time; + } + ADD_FAILURE() << "command " << int(command) << " not sent"; + return 0; + } + + std::vector> sent; +}; + +using Bytes = std::vector; + +const Color RED(255, 0, 0); + +/// One row of 16 pixels: white, black, red, then white. The red plane goes first as 1=red, then the black/white +/// plane as 1=white with red pixels sent as 0. +TEST(EPaperGoodDisplay3CTransfer, PixelsReachThePanel) { + TestableGoodDisplay3C display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.fill(Color::WHITE); + display.draw(1, 0, Color::BLACK); + display.draw(2, 0, RED); + EXPECT_EQ(display.run_push(), 1); + + EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24})); + EXPECT_EQ(bus.data[0x44], (Bytes{0x00, 0x01})); + EXPECT_EQ(bus.data[0x45], (Bytes{0x00, 0x00, 0x00, 0x00})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x9F, 0xFF})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x20, 0x00})); +} + +/// A push that yields to the main loop after every chunk sends the same commands and bytes as one that runs to +/// completion in one call, and a second push starts again from the window setup. +TEST(EPaperGoodDisplay3CTransfer, ResumedPushMatches) { + // 128x20 pixels is 320 bytes per plane, so each plane takes three chunks of up to MAX_TRANSFER_SIZE. + constexpr int width = 128; + constexpr int height = 20; + const Color colors[] = {Color::WHITE, Color::BLACK, RED}; + + TestableGoodDisplay3C reference(width, height); + RecordingDelegate reference_bus(&reference.dc); + reference.install(&reference_bus); + TestableGoodDisplay3C display(width, height); + // Every data write takes longer than MAX_TRANSFER_TIME, so the push yields after each chunk. + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + for (int y = 0; y != height; y++) { + for (int x = 0; x != width; x++) { + reference.draw(x, y, colors[(x * 7 + y * 3) % 3]); + display.draw(x, y, colors[(x * 7 + y * 3) % 3]); + } + } + + EXPECT_EQ(reference.run_push(), 1); + EXPECT_GT(display.run_push(), 2); + + EXPECT_EQ(bus.commands, reference_bus.commands); + EXPECT_EQ(bus.data[0x24].size(), 320u); + EXPECT_EQ(bus.data[0x24], reference_bus.data[0x24]); + EXPECT_EQ(bus.data[0x26], reference_bus.data[0x26]); + + bus.clear(); + display.run_push(); + EXPECT_EQ(bus.commands, reference_bus.commands); +} + +/// A whole update sends no power-on (0x22 0xF8) before the refresh (0x22 0xF7): with it, this panel does not +/// refresh. Power off (0x83) and deep sleep follow. +TEST(EPaperGoodDisplay3CTransfer, UpdateHasNoSeparatePowerOn) { + TestableGoodDisplay3C display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.fill(RED); + + display.run_update(); + + EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24, 0x4E, 0x4F, 0x22, 0x20, 0x22, 0x20, 0x10})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xF7, 0x83})); + EXPECT_EQ(bus.data[0x10], (Bytes{0x01})); +} + +/// Busy only goes high a little after master activation (0x20), so power off is not sent until REFRESH_START_MS +/// after the refresh starts. +TEST(EPaperGoodDisplay3CTransfer, WaitsForBusyAfterTheRefreshStarts) { + TestableGoodDisplay3C display(16, 1); + TimedCommandDelegate bus(&display.dc); + display.install(&bus); + display.fill(RED); + + display.run_update(); + + // The first 0x20 starts the refresh; the second 0x22 selects power off. + EXPECT_GE(bus.time_of(0x22, 1) - bus.time_of(0x20, 0), TestableGoodDisplay3C::REFRESH_START_MS); +} + +} // namespace +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 4a4424a8964..6f5a6b54daf 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -209,6 +209,27 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey029z95-2.9 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # WeAct 4.2" 3-color e-paper (400x300, SSD1683) - platform: epaper_spi spi_id: spi_bus diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index c16869d06b8..dfd900cdd42 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -30,7 +30,6 @@ wifi: esp32_hosted: variant: ESP32C6 slot: 1 - active_high: true reset_pin: GPIO15 cmd_pin: GPIO13 clk_pin: GPIO12 diff --git a/tests/components/esp32_hosted/common.yaml b/tests/components/esp32_hosted/common.yaml index 332fe5b070b..7c27c1760c1 100644 --- a/tests/components/esp32_hosted/common.yaml +++ b/tests/components/esp32_hosted/common.yaml @@ -1,7 +1,6 @@ esp32_hosted: variant: ESP32C6 slot: 1 - active_high: true reset_pin: GPIO15 use_psram: true cmd_pin: GPIO13 diff --git a/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml index 2f57abd296e..77a83aaeb6a 100644 --- a/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml @@ -2,7 +2,6 @@ esp32_hosted: variant: ESP32C6 slot: 1 - active_high: true reset_pin: GPIO15 cmd_pin: GPIO13 clk_pin: GPIO12 diff --git a/tests/components/esp32_hosted/test-sdio-1bit.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-sdio-1bit.esp32-p4-idf.yaml index 80f166c0575..00f054aff73 100644 --- a/tests/components/esp32_hosted/test-sdio-1bit.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-sdio-1bit.esp32-p4-idf.yaml @@ -2,11 +2,11 @@ esp32_hosted: variant: ESP32C6 slot: 1 bus_width: 1 - active_high: true reset_pin: GPIO15 cmd_pin: GPIO13 clk_pin: GPIO12 d0_pin: GPIO11 + d1_pin: GPIO10 wifi: ssid: MySSID diff --git a/tests/components/esp32_hosted/test-sdio-speed.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-sdio-speed.esp32-p4-idf.yaml index 9268c9ae415..a1b196877ee 100644 --- a/tests/components/esp32_hosted/test-sdio-speed.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-sdio-speed.esp32-p4-idf.yaml @@ -1,7 +1,6 @@ esp32_hosted: variant: ESP32C6 slot: 1 - active_high: true reset_pin: GPIO15 cmd_pin: GPIO13 clk_pin: GPIO12 diff --git a/tests/components/esp32_hosted/test-spi.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-spi.esp32-p4-idf.yaml index a4423140de0..3b964bba98d 100644 --- a/tests/components/esp32_hosted/test-spi.esp32-p4-idf.yaml +++ b/tests/components/esp32_hosted/test-spi.esp32-p4-idf.yaml @@ -1,7 +1,6 @@ esp32_hosted: type: spi variant: ESP32C6 - active_high: true reset_pin: GPIO15 handshake_pin: GPIO54 data_ready_pin: GPIO14 diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index fd3d6a0efec..cb897f70c55 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -37,11 +37,14 @@ esphome: - id: other_device name: Another device area_id: another_area + relation: child - id: test_device name: Test device in main area area_id: testing_area # Reference the main area (not in areas) + relation: child - id: no_area_device name: Device without area # This device has no area_id + relation: child binary_sensor: - platform: template diff --git a/tests/components/ethernet/test-wisp.esp32-idf.yaml b/tests/components/ethernet/test-wisp.esp32-idf.yaml new file mode 100644 index 00000000000..e5d09b90706 --- /dev/null +++ b/tests/components/ethernet/test-wisp.esp32-idf.yaml @@ -0,0 +1,15 @@ +ethernet: + type: LAN8720 + mdc_pin: 23 + mdio_pin: 32 + clk: + pin: 0 + mode: CLK_EXT_IN + phy_addr: 0 + power_pin: 33 + +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp diff --git a/tests/components/http_request/common.yaml b/tests/components/http_request/common.yaml index f20bd1a23f0..ff7602e0afc 100644 --- a/tests/components/http_request/common.yaml +++ b/tests/components/http_request/common.yaml @@ -57,6 +57,21 @@ button: url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.bin");' - logger.log: "This message should be not displayed (reboot)" + # Device firmware switches the manifest from lambdas; every shape below must compile and + # the stored URL must outlive the lambda (EverythingSmartHome/everything-presence-one#341). + - platform: template + name: Switch manifest + on_press: + then: + - lambda: |- + id(ota_update).set_source_url("http://my.ha.net:8123/local/esphome/beta-manifest.json"); + std::string url = "http://my.ha.net:8123/local/esphome/"; + url += "manifest.json"; + id(ota_update).set_source_url(url); + id(ota_update).set_source_url(url.c_str()); + id(ota_update).set_source_url(std::string("http://my.ha.net:8123/local/esphome/manifest.json")); + ESP_LOGI("test", "Manifest is now %s", id(ota_update).get_source_url()); + - component.update: ota_update update: - platform: http_request diff --git a/tests/components/http_request/ota/__init__.py b/tests/components/http_request/ota/__init__.py new file mode 100644 index 00000000000..506c1ae2c2b --- /dev/null +++ b/tests/components/http_request/ota/__init__.py @@ -0,0 +1,8 @@ +import esphome.config_validation as cv +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The real schema rejects the host target by framework version; the update platform's + # unit tests only need this platform's sources on the host. + manifest.config_schema = cv.Schema({}, extra=cv.ALLOW_EXTRA) diff --git a/tests/components/http_request/test_http_request_update.cpp b/tests/components/http_request/test_http_request_update.cpp new file mode 100644 index 00000000000..ecb1364595f --- /dev/null +++ b/tests/components/http_request/test_http_request_update.cpp @@ -0,0 +1,141 @@ +#include + +#include + +#include "esphome/components/http_request/update/http_request_update.h" + +#if defined(__SANITIZE_ADDRESS__) +#define HAS_ASAN +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) +#define HAS_ASAN +#endif +#endif +#ifdef HAS_ASAN +#include +#endif + +namespace esphome::http_request::testing { + +namespace { + +constexpr char LITERAL[] = "http://example.invalid/manifest.json"; + +// Freed heap memory is poisoned under AddressSanitizer, which CI builds with; without it the +// freed-exactly-once checks cannot be observed and are skipped. +void expect_freed(const char *buffer) { +#ifdef HAS_ASAN + EXPECT_TRUE(__asan_address_is_poisoned(buffer)); +#endif +} + +void expect_live(const char *buffer, const char *content) { +#ifdef HAS_ASAN + EXPECT_FALSE(__asan_address_is_poisoned(buffer)); +#endif + EXPECT_STREQ(buffer, content); +} + +} // namespace + +// Friend of HttpRequestUpdate, so a check can be begun and ended without an HTTP fetch. +class HttpRequestUpdateTest : public ::testing::Test { + protected: + static void begin_check(HttpRequestUpdate &update, TaskResult &result) { update.begin_check_(&result); } + static void end_check(HttpRequestUpdate &update, TaskResult &result) { update.end_check_(&result); } +}; + +TEST(HttpRequestUpdateSourceUrl, ConfigLiteralIsKeptByPointer) { + HttpRequestUpdate update; + update.set_source_url_static(LITERAL); + EXPECT_EQ(update.get_source_url(), LITERAL); +} + +TEST(HttpRequestUpdateSourceUrl, RuntimeStringIsCopied) { + HttpRequestUpdate update; + std::string url = "http://example.invalid/from-string-manifest.json"; + update.set_source_url(url); + EXPECT_NE(update.get_source_url(), url.c_str()); + EXPECT_STREQ(update.get_source_url(), url.c_str()); +} + +// Device firmware passes base_url.c_str() of a local and runs the update after the lambda returned. +TEST(HttpRequestUpdateSourceUrl, PointerIntoDeadLocalStaysReadable) { + HttpRequestUpdate update; + { + std::string local = "http://example.invalid/from-local-manifest.json"; + update.set_source_url(local.c_str()); + } + EXPECT_STREQ(update.get_source_url(), "http://example.invalid/from-local-manifest.json"); +} + +TEST(HttpRequestUpdateSourceUrl, PassingTheCurrentUrlBackIsSafe) { + HttpRequestUpdate update; + update.set_source_url("http://example.invalid/current-manifest.json"); + const char *previous = update.get_source_url(); + update.set_source_url(update.get_source_url()); + EXPECT_STREQ(update.get_source_url(), "http://example.invalid/current-manifest.json"); + expect_freed(previous); +} + +TEST(HttpRequestUpdateSourceUrl, ConfigLiteralAfterRuntimeUrlDropsTheCopy) { + HttpRequestUpdate update; + update.set_source_url("http://example.invalid/runtime-manifest.json"); + const char *copy = update.get_source_url(); + update.set_source_url_static(LITERAL); + EXPECT_EQ(update.get_source_url(), LITERAL); + expect_freed(copy); +} + +// The URL a check started with stays readable until the check ends, however often it is replaced in +// the meantime; the replacements it never saw are freed at once and the original exactly once. +TEST_F(HttpRequestUpdateTest, UrlReplacedDuringACheckOutlivesIt) { + HttpRequestUpdate update; + update.set_source_url("http://example.invalid/first-manifest.json"); + const char *first = update.get_source_url(); + TaskResult result; + begin_check(update, result); + EXPECT_EQ(result.source_url, first); + + update.set_source_url("http://example.invalid/second-manifest.json"); + const char *second = update.get_source_url(); + expect_live(first, "http://example.invalid/first-manifest.json"); + + update.set_source_url("http://example.invalid/third-manifest.json"); + const char *third = update.get_source_url(); + expect_live(first, "http://example.invalid/first-manifest.json"); + expect_freed(second); + + end_check(update, result); + expect_freed(first); + expect_live(third, "http://example.invalid/third-manifest.json"); + // The entity owns the URL set during the check: switching away frees it. + update.set_source_url_static(LITERAL); + expect_freed(third); +} + +TEST_F(HttpRequestUpdateTest, UrlUnchangedDuringACheckReturnsToTheEntity) { + HttpRequestUpdate update; + update.set_source_url("http://example.invalid/kept-manifest.json"); + const char *kept = update.get_source_url(); + TaskResult result; + begin_check(update, result); + end_check(update, result); + expect_live(kept, "http://example.invalid/kept-manifest.json"); + // Ownership came back, so the next change frees it instead of leaking it. + update.set_source_url_static(LITERAL); + expect_freed(kept); +} + +TEST_F(HttpRequestUpdateTest, ConfigLiteralDuringACheckIsNeverFreed) { + HttpRequestUpdate update; + update.set_source_url_static(LITERAL); + TaskResult result; + begin_check(update, result); + update.set_source_url("http://example.invalid/runtime-manifest.json"); + end_check(update, result); + EXPECT_STREQ(LITERAL, "http://example.invalid/manifest.json"); + EXPECT_STREQ(update.get_source_url(), "http://example.invalid/runtime-manifest.json"); +} + +} // namespace esphome::http_request::testing diff --git a/tests/components/http_request/update/__init__.py b/tests/components/http_request/update/__init__.py new file mode 100644 index 00000000000..7f75dd4fa15 --- /dev/null +++ b/tests/components/http_request/update/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # setup() registers the entity as an OTA state listener, which the real to_code + # requests through ota.request_ota_state_listeners(). + async def to_code_testing(config): + cg.add_define("USE_OTA_STATE_LISTENER") + + manifest.to_code = to_code_testing diff --git a/tests/components/ledc/test.esp32-p4-idf.yaml b/tests/components/ledc/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e99b98d4dfe --- /dev/null +++ b/tests/components/ledc/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + ledc: !include common.yaml diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 95b587df45d..03e1cbb5a55 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -145,6 +145,10 @@ esphome: - light.turn_on: id: test_monochromatic_light effect: !lambda 'return "Strobe";' + # Inlined lambda returning a StringRef, the shape an api action string variable takes (issue #21327) + - light.turn_on: + id: test_monochromatic_light + effect: !lambda 'return light::EFFECT_NONE_REF;' # Test effect lambda with non-empty args (repeat passes uint32_t iteration) - repeat: count: 3 @@ -300,7 +304,7 @@ light: if (state == 4) state = 0; if (initial_run) { - ESP_LOGD("custom_effect", "Effect %s started", it.get_name().c_str()); + ESP_LOGD("custom_effect", "Effect %s started", LOG_STR_ARG(it.get_log_name())); } - pulse: transition_length: 10s diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 5ed997bfedb..7996db3c459 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -257,6 +257,8 @@ cover: - logger.log: position_action tilt_action: - logger.log: tilt_action + stop_tilt_action: + - logger.log: stop_tilt_action open_action: - logger.log: open_action close_action: diff --git a/tests/components/mqtt/test.esp32-idf.yaml b/tests/components/mqtt/test.esp32-idf.yaml index d19609b55e9..871b67bd361 100644 --- a/tests/components/mqtt/test.esp32-idf.yaml +++ b/tests/components/mqtt/test.esp32-idf.yaml @@ -1,3 +1,6 @@ packages: common: !include common.yaml update: !include common-update.yaml + +mqtt: + tls: true diff --git a/tests/components/mqtt/validate-tls-ca.esp32-idf.yaml b/tests/components/mqtt/validate-tls-ca.esp32-idf.yaml new file mode 100644 index 00000000000..61f26760649 --- /dev/null +++ b/tests/components/mqtt/validate-tls-ca.esp32-idf.yaml @@ -0,0 +1,10 @@ +packages: + common: !include common.yaml + +mqtt: + broker: test.mosquitto.org + skip_cert_cn_check: true + certificate_authority: | + -----BEGIN CERTIFICATE----- + MIIBszCCAVoCCQDAAAAAAAAAADAKBggqhkjOPQQDAjAfMR0wGwYDVQQDDBRtcXR0 + -----END CERTIFICATE----- diff --git a/tests/components/mqtt/validate-tls-insecure.esp32-idf.yaml b/tests/components/mqtt/validate-tls-insecure.esp32-idf.yaml new file mode 100644 index 00000000000..4bb6560b94a --- /dev/null +++ b/tests/components/mqtt/validate-tls-insecure.esp32-idf.yaml @@ -0,0 +1,6 @@ +packages: + common: !include common.yaml + +mqtt: + broker: test.mosquitto.org + verify_ssl: false diff --git a/tests/components/openthread/test-voice-assistant.esp32-c6-idf.yaml b/tests/components/openthread/test-voice-assistant.esp32-c6-idf.yaml new file mode 100644 index 00000000000..6466c9e396d --- /dev/null +++ b/tests/components/openthread/test-voice-assistant.esp32-c6-idf.yaml @@ -0,0 +1,36 @@ +# voice_assistant over Thread without the IPv4 stack +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-c6-idf.yaml + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 + +network: + enable_ipv6: true + enable_ipv4: false + +api: + +microphone: + - platform: i2s_audio + id: va_mic + i2s_audio_id: i2s_audio_bus + i2s_din_pin: GPIO7 + adc_type: external + +speaker: + - platform: i2s_audio + id: va_speaker + i2s_audio_id: i2s_audio_bus + dac_type: external + i2s_dout_pin: GPIO10 + +voice_assistant: + microphone: va_mic + speaker: va_speaker diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 746b7151f30..c4f60d6006e 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -11,3 +11,6 @@ openthread: output_power: 1dBm core_options: OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" + +ota: + - platform: esphome diff --git a/tests/components/pulse_counter/common.yaml b/tests/components/pulse_counter/common.yaml index 2b27d5467b0..75357c59428 100644 --- a/tests/components/pulse_counter/common.yaml +++ b/tests/components/pulse_counter/common.yaml @@ -15,3 +15,8 @@ sensor: - pulse_counter.set_total_pulses: id: pulse_counter_sensor value: !lambda return (uint32_t) x; + - platform: pulse_counter + name: Pulse Counter Rising + pin: 5 + internal_filter: 0us + update_interval: 15s diff --git a/tests/components/pulse_counter/test-no-pcnt.esp32-idf.yaml b/tests/components/pulse_counter/test-no-pcnt.esp32-idf.yaml index cd15cc781d6..6d718fa7e69 100644 --- a/tests/components/pulse_counter/test-no-pcnt.esp32-idf.yaml +++ b/tests/components/pulse_counter/test-no-pcnt.esp32-idf.yaml @@ -8,3 +8,9 @@ sensor: falling_edge: DECREMENT internal_filter: 13us update_interval: 15s + - platform: pulse_counter + name: Pulse Counter Rising + pin: 5 + use_pcnt: false + internal_filter: 0us + update_interval: 15s diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index b37f838b811..49918f40b97 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -37,7 +37,7 @@ on_coolix: then: - logger.log: format: "on_coolix: %lu %lu" - args: ["long(x.first)", "long(x.second)"] + args: ["(unsigned long) x.first", "(unsigned long) x.second"] on_dish: then: - logger.log: @@ -67,12 +67,12 @@ on_jvc: then: - logger.log: format: "on_jvc: %lu" - args: ["long(x.data)"] + args: ["(unsigned long) x.data"] on_keeloq: then: - logger.log: format: "on_keeloq: %lu %lu %u %u %u %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits", "x.suffix_bits == 6 ? x.group() : 0"] + args: ["(unsigned long) x.encrypted", "(unsigned long) x.address", "x.command", "x.suffix", "x.suffix_bits", "x.suffix_bits == 6 ? x.group() : 0"] on_haier: then: - logger.log: @@ -82,12 +82,12 @@ on_lg: then: - logger.log: format: "on_lg: %lu %u" - args: ["long(x.data)", "x.nbits"] + args: ["(unsigned long) x.data", "x.nbits"] on_magiquest: then: - logger.log: format: "on_magiquest: %u %lu" - args: ["x.magnitude", "long(x.wand_id)"] + args: ["x.magnitude", "(unsigned long) x.wand_id"] on_midea: then: - logger.log: @@ -102,7 +102,7 @@ on_nexa: then: - logger.log: format: "on_nexa: %lu %u %u %u %u" - args: ["long(x.device)", "x.group", "x.state", "x.channel", "x.level"] + args: ["(unsigned long) x.device", "x.group", "x.state", "x.channel", "x.level"] on_nexus: then: - logger.log: @@ -117,7 +117,7 @@ on_panasonic: then: - logger.log: format: "on_panasonic: %u %u %u %lu" - args: ["x.nbits", "x.address", "x.address_2", "long(x.command)"] + args: ["x.nbits", "x.address", "x.address_2", "(unsigned long) x.command"] on_pioneer: then: - logger.log: @@ -131,8 +131,8 @@ on_pronto: on_raw: then: - logger.log: - format: "on_raw: %lu" - args: ["long(x.front())"] + format: "on_raw: %ld" + args: ["(long) x.front()"] on_rc5: then: - logger.log: @@ -162,17 +162,17 @@ on_samsung36: then: - logger.log: format: "on_samsung36: %u %lu" - args: ["x.address", "long(x.command)"] + args: ["x.address", "(unsigned long) x.command"] on_sony: then: - logger.log: format: "on_sony: %lu %u" - args: ["long(x.data)", "x.nbits"] + args: ["(unsigned long) x.data", "x.nbits"] on_symphony: then: - logger.log: format: "on_symphony: 0x%lX %u" - args: ["long(x.data)", "x.nbits"] + args: ["(unsigned long) x.data", "x.nbits"] on_toshiba_ac: then: - logger.log: @@ -190,8 +190,8 @@ on_toto: on_aok: then: - logger.log: - format: "on_aok: %u %u %u" - args: ["x.remote_id", "x.channels", "x.command"] + format: "on_aok: %lu %u %u" + args: ["(unsigned long) x.remote_id", "x.channels", "x.command"] on_hob2hood: then: - logger.log: diff --git a/tests/components/runtime_image/test_jpeg_grayscale.cpp b/tests/components/runtime_image/test_jpeg_grayscale.cpp new file mode 100644 index 00000000000..77f72e7cda1 --- /dev/null +++ b/tests/components/runtime_image/test_jpeg_grayscale.cpp @@ -0,0 +1,83 @@ +#include + +#include +#include + +#include "esphome/components/runtime_image/runtime_image.h" + +#ifdef USE_RUNTIME_IMAGE_JPEG + +namespace esphome::runtime_image::testing { + +// 16x8 grayscale JPEG (quality 95) of a smooth gradient, from 60 at the top left to 185 at the bottom right +static const uint8_t JPEG_GRAY[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x04, 0x03, 0x02, 0x02, 0x02, 0x02, 0x05, 0x04, 0x04, 0x03, 0x04, 0x06, 0x05, 0x06, 0x06, 0x06, 0x05, + 0x06, 0x06, 0x06, 0x07, 0x09, 0x08, 0x06, 0x07, 0x09, 0x07, 0x06, 0x06, 0x08, 0x0B, 0x08, 0x09, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x06, 0x08, 0x0B, 0x0C, 0x0B, 0x0A, 0x0C, 0x09, 0x0A, 0x0A, 0x0A, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, + 0x08, 0x00, 0x10, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, + 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, + 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, + 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, + 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, + 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, + 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, + 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, + 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, + 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, + 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, + 0x01, 0x00, 0x00, 0x3F, 0x00, 0xF0, 0x9F, 0x82, 0x5F, 0x09, 0x3F, 0xD4, 0xFF, 0x00, 0xA2, 0xFA, 0x76, 0xAF, 0xB0, + 0x3E, 0x09, 0x7C, 0x24, 0xFF, 0x00, 0x53, 0xFE, 0x8B, 0xE9, 0xDA, 0xBF, 0xFF, 0xD9, +}; + +// The pixels Pillow decodes from it +static const uint8_t JPEG_GRAY_PIXELS[8 * 16] = { + 60, 66, 72, 78, 84, 90, 96, 102, 108, 114, 120, 126, 132, 138, 144, 150, 65, 71, 77, 83, 89, 95, + 101, 107, 113, 119, 125, 131, 137, 143, 149, 155, 70, 76, 82, 88, 94, 100, 106, 112, 118, 124, 130, 136, + 142, 148, 154, 160, 75, 81, 87, 93, 99, 105, 111, 117, 123, 129, 135, 141, 147, 153, 159, 165, 80, 86, + 92, 98, 104, 110, 116, 122, 128, 134, 140, 146, 152, 158, 164, 170, 85, 91, 97, 103, 109, 115, 121, 127, + 133, 139, 145, 151, 157, 163, 169, 175, 90, 96, 102, 108, 114, 120, 126, 132, 138, 144, 150, 156, 162, 168, + 174, 180, 95, 101, 107, 113, 119, 125, 131, 137, 143, 149, 155, 161, 167, 173, 179, 185, +}; + +// JPEGDEC's faster inverse DCT is up to 4 steps from Pillow's on this image; the garbled output was off by hundreds +static constexpr int TOLERANCE = 5; + +static bool decode(RuntimeImage &img) { + std::vector data(JPEG_GRAY, JPEG_GRAY + sizeof(JPEG_GRAY)); + if (!img.begin_decode(data.size())) + return false; + if (img.feed_data(data.data(), data.size()) != static_cast(data.size())) + return false; + return img.end_decode(); +} + +// A grayscale JPEG decoded for a colour image goes through JPEGDEC's RGB8888 output, which used to be garbled +TEST(JpegGrayscale, DecodesToRgb) { + RuntimeImage img(JPEG, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_ALPHA_CHANNEL); + ASSERT_TRUE(decode(img)); + ASSERT_EQ(img.get_width(), 16); + ASSERT_EQ(img.get_height(), 8); + const uint8_t *data = img.get_data_start(); + for (int i = 0; i < 16 * 8; i++) { + const uint8_t *pixel = data + i * 4; // blue, green, red, alpha + EXPECT_NEAR(pixel[0], JPEG_GRAY_PIXELS[i], TOLERANCE) << "blue of pixel " << i; + EXPECT_NEAR(pixel[1], JPEG_GRAY_PIXELS[i], TOLERANCE) << "green of pixel " << i; + EXPECT_NEAR(pixel[2], JPEG_GRAY_PIXELS[i], TOLERANCE) << "red of pixel " << i; + EXPECT_EQ(pixel[3], 255) << "alpha of pixel " << i; + } +} + +TEST(JpegGrayscale, DecodesToGrayscale) { + RuntimeImage img(JPEG, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE); + ASSERT_TRUE(decode(img)); + const uint8_t *data = img.get_data_start(); + for (int i = 0; i < 16 * 8; i++) + EXPECT_NEAR(data[i], JPEG_GRAY_PIXELS[i], TOLERANCE) << "pixel " << i; +} + +} // namespace esphome::runtime_image::testing + +#endif // USE_RUNTIME_IMAGE_JPEG diff --git a/tests/components/runtime_image/test_jpeg_source.cpp b/tests/components/runtime_image/test_jpeg_source.cpp new file mode 100644 index 00000000000..01dccfcc7c4 --- /dev/null +++ b/tests/components/runtime_image/test_jpeg_source.cpp @@ -0,0 +1,434 @@ +#include + +#include +#include +#include + +#include "esphome/components/runtime_image/image_decoder.h" +#include "esphome/components/runtime_image/runtime_image.h" + +#ifdef USE_RUNTIME_IMAGE_JPEG + +namespace esphome::runtime_image::testing { + +// 48x40 4:2:0 JPEG of a noisy gradient (quality 95): three rows of 16 pixel MCUs, so a stepped decode takes +// three steps. At 2344 bytes it is larger than JPEGDEC's 2048 byte read buffer, so the source is read again +// part way through the image. +static const uint8_t JPEG_ROWS[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x04, 0x03, 0x02, 0x02, 0x02, 0x02, 0x05, 0x04, 0x04, 0x03, 0x04, 0x06, 0x05, 0x06, 0x06, 0x06, 0x05, + 0x06, 0x06, 0x06, 0x07, 0x09, 0x08, 0x06, 0x07, 0x09, 0x07, 0x06, 0x06, 0x08, 0x0B, 0x08, 0x09, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x06, 0x08, 0x0B, 0x0C, 0x0B, 0x0A, 0x0C, 0x09, 0x0A, 0x0A, 0x0A, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x05, 0x03, 0x03, 0x05, 0x0A, 0x07, 0x06, 0x07, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x28, 0x00, 0x30, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xFC, 0xAA, 0xB8, 0xB7, + 0xD3, 0xB5, 0xD8, 0x1E, 0xD6, 0xD1, 0xA5, 0x5B, 0x4B, 0x46, 0x10, 0xC1, 0x06, 0xA1, 0x29, 0x69, 0x25, 0xB7, 0xC2, + 0xC6, 0x0C, 0x71, 0x26, 0x5E, 0x50, 0x15, 0x5D, 0xB6, 0x9C, 0x9E, 0x00, 0xC1, 0x67, 0x67, 0x4D, 0xCF, 0x84, 0xF7, + 0x57, 0xA6, 0xDA, 0x08, 0x52, 0xF5, 0xE5, 0xBA, 0xB8, 0x7B, 0x5B, 0x68, 0xEE, 0x2F, 0x66, 0x89, 0xB7, 0xB7, 0x9B, + 0xE5, 0x9C, 0x23, 0xAA, 0xFE, 0xF7, 0x68, 0x11, 0x9E, 0x5D, 0x18, 0x12, 0x48, 0x56, 0x5C, 0x9F, 0x57, 0xB6, 0xF8, + 0x33, 0xF0, 0xE2, 0x5B, 0xFB, 0xCF, 0xF4, 0x51, 0x0C, 0xC6, 0x64, 0x30, 0x49, 0x71, 0x7C, 0xE2, 0x16, 0x90, 0x2E, + 0xF4, 0x72, 0xEA, 0xE0, 0x34, 0x81, 0x77, 0x93, 0x9C, 0x07, 0xC2, 0x93, 0xB9, 0x61, 0xC0, 0xD3, 0xF0, 0x17, 0xC1, + 0xBF, 0x03, 0xCD, 0xE1, 0xAD, 0x42, 0x3F, 0x0A, 0xD8, 0xC7, 0x2D, 0xAC, 0x36, 0xCB, 0xF6, 0x79, 0x22, 0x69, 0x84, + 0xC4, 0x93, 0x29, 0x2A, 0x89, 0xB9, 0x00, 0x50, 0x62, 0x7C, 0xFF, 0x00, 0x16, 0xD9, 0x65, 0x00, 0x16, 0x65, 0x07, + 0xFA, 0x5B, 0x36, 0xE2, 0xAC, 0xBF, 0x2D, 0x55, 0x79, 0x29, 0xB5, 0x26, 0x9B, 0xA9, 0x18, 0xB5, 0x15, 0x36, 0x9B, + 0x53, 0xD1, 0xCA, 0x10, 0x93, 0x69, 0xB6, 0x94, 0x27, 0x16, 0xAF, 0x19, 0x29, 0xB8, 0xBB, 0x3F, 0x9A, 0xE1, 0x1E, + 0x2A, 0xCB, 0x9E, 0x79, 0x4A, 0x92, 0x84, 0xDA, 0xD6, 0xED, 0xA4, 0xED, 0x7A, 0x72, 0xB5, 0xAC, 0xF6, 0xEB, 0xAD, + 0xD5, 0x97, 0x91, 0xC9, 0x4D, 0xA4, 0xE9, 0xD7, 0x56, 0x45, 0x66, 0xD3, 0x9D, 0x85, 0xB1, 0x86, 0x59, 0x7E, 0xD9, + 0x7E, 0xA0, 0xE0, 0xA8, 0x02, 0x17, 0x02, 0x3C, 0x36, 0x59, 0x18, 0xE2, 0x42, 0x1D, 0x40, 0x56, 0x0A, 0x77, 0x30, + 0xAE, 0xFA, 0x56, 0xD4, 0xA4, 0x8A, 0xD9, 0xAD, 0xD6, 0x38, 0x1D, 0x7F, 0x77, 0x77, 0x1C, 0xB2, 0x4C, 0x22, 0x8C, + 0x2B, 0xF3, 0x14, 0x64, 0x45, 0x85, 0x52, 0x47, 0x92, 0x7E, 0xF2, 0x0F, 0x34, 0x1D, 0xBC, 0x56, 0xDE, 0x85, 0xF0, + 0x4B, 0xE1, 0x0A, 0xE9, 0xB0, 0xDB, 0x2D, 0x8C, 0x4C, 0xCD, 0x74, 0xF1, 0x42, 0x96, 0x93, 0xCF, 0xF6, 0x54, 0x0C, + 0xB3, 0x44, 0x03, 0xAA, 0x6C, 0x49, 0x10, 0x87, 0x00, 0x88, 0xC2, 0xEE, 0x66, 0x6F, 0x9B, 0xAB, 0x57, 0xA4, 0x68, + 0xDF, 0x0A, 0x3E, 0x1B, 0xDA, 0xE9, 0xDF, 0xD9, 0x96, 0xFE, 0x03, 0xB2, 0x8E, 0xD9, 0x8A, 0xDD, 0xC1, 0x77, 0x14, + 0x97, 0x50, 0x2E, 0xE3, 0x20, 0x62, 0xA0, 0x33, 0x0C, 0xC6, 0x58, 0x48, 0x36, 0x6D, 0xC1, 0x20, 0xA2, 0x2B, 0x00, + 0xB5, 0xF1, 0x99, 0xCF, 0x19, 0x65, 0x79, 0xAD, 0x0C, 0x3C, 0x2B, 0x61, 0xF9, 0x28, 0xD6, 0x52, 0x97, 0x35, 0x95, + 0xD4, 0x61, 0x28, 0x5B, 0x99, 0xB8, 0xA9, 0xA9, 0x47, 0xD9, 0x3A, 0x69, 0xA9, 0xC6, 0x51, 0x52, 0x6A, 0x0D, 0x4A, + 0x74, 0xA9, 0xC3, 0xFA, 0x7F, 0xC3, 0xFE, 0x25, 0xC1, 0x4E, 0xAE, 0x26, 0x2E, 0x13, 0x71, 0x5C, 0x8D, 0xFB, 0xB1, + 0xD6, 0xEA, 0x6F, 0xF9, 0xAF, 0xBE, 0xB7, 0x4B, 0x55, 0x1D, 0x75, 0x4C, 0xF3, 0xDF, 0x05, 0xD9, 0xF8, 0x7B, 0xC3, + 0xBA, 0xB5, 0xF1, 0xBC, 0x8D, 0x63, 0xDF, 0x1C, 0x32, 0x88, 0x1A, 0xF3, 0x74, 0x73, 0x89, 0x15, 0xC4, 0x8A, 0x25, + 0x66, 0xDC, 0xAA, 0x98, 0x60, 0x06, 0x7F, 0xBD, 0xF2, 0x81, 0xF3, 0x36, 0xFE, 0x9D, 0xAF, 0x78, 0x8F, 0x54, 0xD3, + 0xAD, 0xA6, 0x9E, 0xC5, 0xE5, 0x9C, 0x96, 0xDC, 0xB7, 0xE8, 0x84, 0x47, 0xFB, 0xB4, 0x54, 0x42, 0x0F, 0x03, 0xF8, + 0xD7, 0xE5, 0x39, 0x60, 0xF8, 0x04, 0x79, 0x7B, 0xE3, 0xF4, 0x9F, 0x85, 0x5F, 0x08, 0xBE, 0x0D, 0x2C, 0x57, 0xCE, + 0x6C, 0xA5, 0x2B, 0xA8, 0x08, 0x20, 0x82, 0x04, 0xB9, 0xB8, 0x82, 0x43, 0x28, 0x72, 0x55, 0xD5, 0x83, 0xB9, 0x05, + 0xC2, 0x44, 0x55, 0x77, 0x8C, 0xFC, 0xEC, 0xD8, 0xCB, 0xE7, 0xBE, 0x9B, 0xE0, 0x5F, 0xC3, 0x9D, 0x32, 0xD4, 0x45, + 0x26, 0x8B, 0x2C, 0x93, 0xC0, 0xF3, 0x95, 0x49, 0xEE, 0x2E, 0x0C, 0x26, 0x26, 0x31, 0xC9, 0xF3, 0xED, 0x95, 0x96, + 0x32, 0xEC, 0x10, 0xE1, 0xB8, 0x60, 0x3A, 0x90, 0x1B, 0x3F, 0x9E, 0x66, 0x58, 0xFC, 0x14, 0x31, 0x15, 0xFD, 0x9C, + 0x79, 0x57, 0x3A, 0x92, 0x84, 0x1C, 0x3D, 0xC9, 0xCD, 0x27, 0x6D, 0x12, 0xE5, 0xE7, 0xA6, 0xD5, 0x4D, 0x64, 0x93, + 0xD1, 0x45, 0xC6, 0xAD, 0xE7, 0x2F, 0xE9, 0xEE, 0x17, 0xE3, 0x0C, 0x0A, 0xE1, 0x9C, 0x37, 0x34, 0x67, 0x2B, 0x73, + 0x2B, 0xB4, 0xBA, 0x49, 0xF4, 0x72, 0x57, 0x51, 0xDD, 0x24, 0x9B, 0x5A, 0x36, 0x9A, 0x49, 0x1E, 0x15, 0xA7, 0x7E, + 0xCE, 0xF6, 0x89, 0x6A, 0x96, 0x43, 0x55, 0xB5, 0xB3, 0xB6, 0xB8, 0x96, 0x2B, 0x9B, 0x78, 0xA0, 0xB4, 0x9E, 0xE5, + 0x61, 0x90, 0xE0, 0xC2, 0xEC, 0x61, 0x91, 0x47, 0xCC, 0xDB, 0xF1, 0x80, 0xC0, 0x98, 0xD4, 0xE5, 0xDC, 0x64, 0xEC, + 0x7C, 0x29, 0xFD, 0x98, 0x6C, 0xAF, 0xEC, 0xF5, 0x76, 0xB7, 0xF1, 0x36, 0x9A, 0x5D, 0x6F, 0x96, 0xE1, 0x74, 0xB5, + 0xD3, 0x44, 0x91, 0xA8, 0x45, 0x0C, 0xE2, 0x3D, 0xAC, 0x85, 0xF9, 0x3B, 0x5B, 0x19, 0x8A, 0x43, 0x1F, 0xCA, 0x50, + 0x9D, 0x87, 0xD2, 0x0F, 0xC2, 0x1F, 0x89, 0x4F, 0x6D, 0x04, 0x0F, 0xE1, 0x61, 0xAA, 0xEE, 0xB4, 0x8E, 0x56, 0xFB, + 0x06, 0xAF, 0x04, 0xF3, 0x40, 0x9B, 0x55, 0x4E, 0xCB, 0x66, 0x91, 0xCB, 0x18, 0xC2, 0x88, 0xB6, 0xB1, 0x65, 0x60, + 0xA4, 0x6E, 0xDC, 0xA4, 0x9E, 0xAF, 0xE1, 0xBF, 0xC2, 0x2F, 0x89, 0xA9, 0xE1, 0xFB, 0x8D, 0x6E, 0xC2, 0x3B, 0x4B, + 0x30, 0xD7, 0x60, 0xDA, 0x41, 0x2D, 0xCC, 0x6C, 0x2E, 0x49, 0xF3, 0x1E, 0x42, 0x19, 0x5D, 0x58, 0x3C, 0x8A, 0x81, + 0x82, 0xB8, 0x0C, 0xC9, 0xF2, 0xE5, 0x8F, 0x03, 0x8F, 0x36, 0xE3, 0x8A, 0x98, 0xCC, 0x14, 0xAB, 0x62, 0x2B, 0xC2, + 0x34, 0xA2, 0xA5, 0x78, 0x37, 0x42, 0x12, 0x70, 0x8C, 0x62, 0xD2, 0x6D, 0x72, 0x7B, 0xAF, 0x9B, 0xDD, 0xFD, 0xE5, + 0x4E, 0x79, 0x49, 0x45, 0x73, 0xA9, 0x72, 0x2F, 0xF0, 0x73, 0x83, 0xF8, 0xCE, 0xB4, 0x33, 0xE8, 0x3F, 0xAE, 0x42, + 0xF7, 0x76, 0xB3, 0x83, 0x57, 0xE5, 0x95, 0xBE, 0xCE, 0x8F, 0x7B, 0xDE, 0xD7, 0x4B, 0x6B, 0x9C, 0x1E, 0x8D, 0xF0, + 0x56, 0x7B, 0xDD, 0x26, 0x34, 0x5F, 0x1F, 0xDB, 0x9D, 0x37, 0x53, 0xB8, 0x96, 0xED, 0xA0, 0x16, 0xB1, 0xC9, 0x2C, + 0x8B, 0xF2, 0x26, 0x58, 0x89, 0x37, 0xEE, 0x04, 0xB2, 0x97, 0x54, 0x2D, 0x88, 0xC0, 0x60, 0x0B, 0x03, 0x5E, 0x9B, + 0x67, 0xFB, 0x36, 0xE9, 0x31, 0x2D, 0x9C, 0x37, 0x9E, 0x32, 0x84, 0x5D, 0x2C, 0x4A, 0xD6, 0xAD, 0x73, 0x63, 0x28, + 0x36, 0xAA, 0x53, 0xF7, 0x90, 0x29, 0x8E, 0x5C, 0x92, 0xBE, 0x41, 0xDC, 0xC0, 0xB3, 0x2B, 0x46, 0x32, 0x4B, 0x96, + 0x35, 0xAD, 0x1F, 0xC2, 0xDF, 0x89, 0x17, 0xF6, 0xB6, 0xFA, 0xB5, 0xED, 0xB8, 0xB7, 0x8E, 0x78, 0x66, 0x96, 0x09, + 0x6C, 0x6E, 0x23, 0x32, 0x28, 0x08, 0xE8, 0xF2, 0xBA, 0xF9, 0x86, 0x48, 0xC0, 0x68, 0xFE, 0x63, 0xC8, 0x23, 0x82, + 0xCC, 0xA5, 0x4A, 0xFA, 0x66, 0x8F, 0xF0, 0x37, 0xE2, 0x6D, 0xE5, 0xC9, 0x3A, 0x87, 0x86, 0xB4, 0xFB, 0x28, 0xAE, + 0x61, 0x0F, 0x0B, 0x59, 0xDF, 0xC0, 0xD1, 0xAC, 0xE0, 0x24, 0x41, 0x42, 0xC4, 0x49, 0x1B, 0x10, 0x21, 0x50, 0xC0, + 0x09, 0x11, 0x8B, 0xEE, 0x6E, 0x71, 0xE2, 0xE2, 0x78, 0xBB, 0x33, 0xFA, 0xC5, 0x1A, 0xB4, 0xEA, 0xC6, 0x74, 0xD2, + 0x8C, 0x24, 0xA5, 0x2A, 0x3C, 0xB5, 0x2E, 0xB9, 0x9B, 0x71, 0x97, 0x34, 0xE0, 0xAA, 0x54, 0x93, 0x71, 0x56, 0x49, + 0xB9, 0xC6, 0x4E, 0x9A, 0x9B, 0xD3, 0xFA, 0x7F, 0x81, 0xB8, 0xD6, 0xAC, 0xDD, 0x6F, 0x65, 0x8D, 0x82, 0x77, 0x85, + 0x95, 0xE1, 0x7D, 0x1C, 0xD2, 0x8B, 0x7A, 0x27, 0xA2, 0xF4, 0xED, 0xEE, 0x9E, 0x67, 0xE0, 0x8F, 0x81, 0x37, 0xBA, + 0xEF, 0x87, 0x67, 0xBF, 0x8F, 0xC4, 0xD2, 0xDD, 0x4F, 0x05, 0xA8, 0x61, 0x7B, 0x1E, 0x9E, 0xE1, 0x8C, 0xEA, 0xF3, + 0x18, 0x99, 0xA3, 0xDE, 0x76, 0x66, 0x36, 0x2A, 0x30, 0xFB, 0x9B, 0x0F, 0xC3, 0x1C, 0xA3, 0x76, 0x4B, 0xFB, 0x3B, + 0xE8, 0xBA, 0x66, 0xBF, 0x0E, 0x93, 0xFD, 0xAB, 0x23, 0xD9, 0xA5, 0xCE, 0x62, 0xF3, 0x34, 0xEB, 0x7F, 0x29, 0x0A, + 0xA7, 0x04, 0x48, 0xF2, 0x79, 0x82, 0x42, 0x1D, 0x46, 0x4B, 0xA9, 0x1B, 0xD1, 0xBE, 0x52, 0x54, 0xD7, 0xA8, 0xF8, + 0x33, 0xE1, 0xAF, 0xC4, 0xA8, 0x24, 0xD6, 0x6D, 0xED, 0xBC, 0x0A, 0xD3, 0x5A, 0x5C, 0xC4, 0xDF, 0x64, 0x17, 0x97, + 0x36, 0xF0, 0x65, 0x52, 0x39, 0xD4, 0x23, 0x22, 0x9D, 0xD8, 0x64, 0x83, 0x04, 0x85, 0x38, 0xEF, 0xB8, 0x49, 0x91, + 0xBB, 0xFF, 0x00, 0x0A, 0xDF, 0xE3, 0x77, 0x84, 0xAC, 0xD3, 0x4A, 0xBC, 0xF0, 0x3A, 0x5A, 0x5B, 0x4F, 0x72, 0x5E, + 0xD7, 0x53, 0x8E, 0xE2, 0x12, 0x6D, 0xC6, 0x40, 0x20, 0x47, 0xE6, 0x3E, 0x71, 0x26, 0x70, 0x24, 0xE4, 0xB4, 0x63, + 0x2A, 0xC0, 0xE0, 0x7E, 0x71, 0x98, 0x71, 0x35, 0x5C, 0x4E, 0x69, 0x5A, 0xBD, 0x7C, 0x7A, 0x9C, 0xE3, 0xC9, 0xCF, + 0x38, 0x46, 0x8D, 0xA4, 0xDC, 0x63, 0x38, 0x4A, 0xE9, 0xED, 0x5A, 0x4E, 0x4A, 0x6D, 0xF3, 0xCA, 0x2D, 0xB9, 0x4D, + 0xD3, 0x77, 0x8C, 0x7F, 0xA8, 0xF8, 0x53, 0x8D, 0x31, 0x32, 0xC8, 0xA9, 0xDB, 0x15, 0x04, 0x9F, 0xB4, 0xD2, 0xF4, + 0xD3, 0xF8, 0xA5, 0x77, 0xCD, 0xB3, 0x49, 0x6C, 0xB4, 0xEA, 0x97, 0x46, 0x78, 0x45, 0xC4, 0x16, 0x5A, 0xDD, 0xA5, + 0xA6, 0x8D, 0xA7, 0xEA, 0x77, 0x4A, 0x54, 0x47, 0x3C, 0x31, 0x41, 0x20, 0x06, 0x68, 0x00, 0x9A, 0x4F, 0x2A, 0x40, + 0x5D, 0x5F, 0x69, 0xF3, 0x15, 0xCB, 0x32, 0x96, 0xDC, 0xC0, 0x90, 0xA5, 0xF0, 0xBD, 0xAF, 0xC3, 0x96, 0x83, 0x51, + 0xB8, 0xD6, 0xA7, 0x48, 0xD2, 0xE8, 0x2B, 0x2C, 0x2D, 0x6B, 0xF6, 0xA4, 0x55, 0x8E, 0x45, 0x5F, 0xBA, 0x54, 0x2A, + 0x36, 0x77, 0x12, 0x72, 0xE5, 0x9C, 0x14, 0x3B, 0x63, 0x6C, 0x62, 0x8A, 0x2B, 0xDE, 0xCD, 0x70, 0x74, 0xA7, 0x92, + 0xE2, 0x31, 0x35, 0x2D, 0x29, 0x54, 0xBB, 0x97, 0xBB, 0x18, 0xF3, 0x38, 0x52, 0xA5, 0x38, 0xDF, 0x92, 0x31, 0x7E, + 0xEB, 0x9B, 0x6A, 0xCD, 0x6B, 0xEF, 0x7C, 0x6E, 0x52, 0x7F, 0xE1, 0xF7, 0x0E, 0x54, 0x96, 0x1B, 0x3D, 0x52, 0x86, + 0xF0, 0x73, 0x4B, 0xEE, 0x92, 0xD6, 0xD6, 0xBF, 0xC2, 0x8D, 0x9F, 0x0B, 0xDE, 0xEB, 0xF3, 0x69, 0x6B, 0x06, 0x9B, + 0xA9, 0x5B, 0xE9, 0xF6, 0x6F, 0x76, 0x1E, 0x45, 0x8E, 0xE1, 0x54, 0xC8, 0xCD, 0xF3, 0x28, 0x8B, 0x1C, 0x9E, 0x25, + 0x55, 0xDA, 0xA5, 0x58, 0x80, 0xCA, 0x46, 0x42, 0xA8, 0xF4, 0x1B, 0xEB, 0xBB, 0xBB, 0x8B, 0x69, 0xAF, 0xA5, 0x8E, + 0xD0, 0x49, 0x25, 0x9C, 0x37, 0x73, 0xE9, 0xD7, 0xF2, 0x14, 0x8E, 0x55, 0x91, 0x64, 0x90, 0x46, 0xA8, 0xF9, 0x50, + 0x57, 0x73, 0x2E, 0xE0, 0x55, 0xF6, 0x87, 0x45, 0x20, 0x2E, 0xC7, 0x28, 0xAF, 0xCD, 0x78, 0xA6, 0xA5, 0x5C, 0x26, + 0x33, 0x01, 0x35, 0x2E, 0x67, 0x52, 0x35, 0xA0, 0xEE, 0x93, 0x56, 0x70, 0x4F, 0x48, 0xDB, 0x95, 0x6B, 0x76, 0xF4, + 0xB4, 0xA5, 0x27, 0x29, 0x29, 0x35, 0x16, 0xBF, 0xA8, 0x7C, 0x3E, 0x92, 0xF6, 0x98, 0xA8, 0x5B, 0x4B, 0xC5, 0xDB, + 0x5E, 0x8E, 0x4F, 0x7D, 0xFA, 0xDB, 0x57, 0xB2, 0x56, 0xD6, 0xED, 0xF7, 0x3E, 0x10, 0xB2, 0x82, 0xF6, 0xC2, 0xF2, + 0xF3, 0x4E, 0xD4, 0xAF, 0xEE, 0x5A, 0xCA, 0xCE, 0xD2, 0xF1, 0xA7, 0xB8, 0x9D, 0xD4, 0x42, 0x8C, 0xF2, 0x3C, 0x8E, + 0x42, 0x02, 0x24, 0xFD, 0xDA, 0x96, 0x60, 0xAD, 0xB0, 0xC9, 0x0E, 0x00, 0xC8, 0xC8, 0xEA, 0xEE, 0x12, 0x7B, 0x09, + 0x6C, 0x6E, 0xCE, 0xA2, 0x0B, 0xAA, 0x32, 0xD9, 0xB9, 0x49, 0x66, 0xDF, 0x74, 0xCC, 0x31, 0x1B, 0xE4, 0xE6, 0x63, + 0xF2, 0x91, 0x95, 0x42, 0x15, 0x8B, 0x7D, 0xE0, 0xAC, 0xB4, 0x51, 0x5F, 0x33, 0x3A, 0xD8, 0x8C, 0x46, 0x7A, 0x9C, + 0xA6, 0xD7, 0x2C, 0x13, 0x49, 0x25, 0x6E, 0x6A, 0x73, 0xE4, 0x84, 0x9E, 0x9A, 0xC9, 0x29, 0x24, 0x9E, 0xE9, 0x42, + 0x11, 0x8D, 0xA2, 0x9C, 0x5F, 0xF5, 0x37, 0x0D, 0x36, 0xF8, 0x7E, 0x9A, 0xEC, 0xE5, 0xE5, 0x7E, 0x67, 0x77, 0x7B, + 0x7A, 0xBF, 0x5E, 0xB7, 0x3F, 0xFF, 0xD9, +}; + +// 8x8 progressive JPEG, 521 bytes +static const uint8_t JPEG_PROGRESSIVE[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, + 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, + 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, + 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, + 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, + 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, + 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, + 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC2, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x15, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0xFF, 0xC4, 0x00, 0x15, 0x01, 0x01, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x05, 0xFF, 0xDA, 0x00, 0x0C, 0x03, + 0x01, 0x00, 0x02, 0x10, 0x03, 0x10, 0x00, 0x00, 0x01, 0x94, 0x05, 0x3F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, + 0x01, 0x01, 0x00, 0x01, 0x05, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, + 0x01, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x01, 0x7F, 0xFF, 0xC4, 0x00, + 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x06, 0x3F, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, + 0x01, 0x00, 0x01, 0x3F, 0x21, 0x7F, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x10, 0x0B, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, + 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, + 0x01, 0x00, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xD9, +}; + +class SourceImage : public RuntimeImage { + public: + explicit SourceImage(image::ImageType type = image::IMAGE_TYPE_RGB, int width = 0, int height = 0) + : RuntimeImage(JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {} + size_t decoded_bytes() const { return this->decoded_bytes_; } +}; + +/// The file in memory; fails reads once fail_at bytes have been read +class MemorySource : public DecodeSource { + public: + MemorySource(const uint8_t *data, size_t size, size_t fail_at = SIZE_MAX) + : data_(data), size_(size), fail_at_(fail_at) {} + int32_t read(uint8_t *buf, uint32_t len) override { + if (this->pos_ >= this->fail_at_) + return -1; + const size_t n = std::min(len, this->size_ - this->pos_); + memcpy(buf, this->data_ + this->pos_, n); + this->pos_ += n; + this->reads++; + return static_cast(n); + } + bool seek(uint32_t position) override { + if (position > this->size_) + return false; + this->pos_ = position; + return true; + } + int reads{0}; + + protected: + const uint8_t *data_; + size_t size_; + size_t pos_{0}; + size_t fail_at_; +}; + +/// Hands out only the bytes delivered so far, like a download that is still arriving +class TrickleSource : public DecodeSource { + public: + TrickleSource(const uint8_t *data, size_t size, size_t delivered) : data_(data), size_(size), delivered_(delivered) {} + int32_t read(uint8_t *buf, uint32_t len) override { + const size_t n = std::min(len, this->delivered_ - this->pos_); + memcpy(buf, this->data_ + this->pos_, n); + this->pos_ += n; + return static_cast(n); + } + bool seek(uint32_t position) override { + if (position > this->delivered_) + return false; + this->pos_ = position; + return true; + } + void deliver(size_t len) { this->delivered_ = std::min(this->size_, this->delivered_ + len); } + + protected: + const uint8_t *data_; + size_t size_; + size_t delivered_; + size_t pos_{0}; +}; + +static bool decode_fed(RuntimeImage &img) { + std::vector buffer(JPEG_ROWS, JPEG_ROWS + sizeof(JPEG_ROWS)); + if (!img.begin_decode(buffer.size())) + return false; + if (img.feed_data(buffer.data(), buffer.size()) != static_cast(buffer.size())) + return false; + return img.end_decode(); +} + +/// Decodes from a source a step at a time; returns the last result and counts the steps +static int decode_stepped(RuntimeImage &img, DecodeSource &source, int &steps) { + steps = 0; + if (!img.begin_decode(sizeof(JPEG_ROWS)) || !img.decoder_supports_source()) + return DECODE_ERROR_UNSUPPORTED_FORMAT; + int result = img.begin_source(&source); + if (result != 0) + return result; + do { + result = img.decode_step(); + steps++; + } while (result == DECODE_STEP_MORE); + if (result == DECODE_STEP_DONE && !img.end_decode()) + return DECODE_ERROR_INTERNAL_DECODER_ERROR; + return result; +} + +static std::vector pixels(const RuntimeImage &img) { + const uint8_t *start = img.get_data_start(); + return std::vector(start, start + img.get_width_stride() * img.get_height()); +} + +TEST(JpegSourceDecode, SteppedDecodeMatchesFedDecode) { + SourceImage fed; + ASSERT_TRUE(decode_fed(fed)); + + SourceImage stepped; + MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + int steps; + ASSERT_EQ(decode_stepped(stepped, source, steps), DECODE_STEP_DONE); + EXPECT_EQ(steps, 3); + ASSERT_EQ(stepped.get_width(), 48); + ASSERT_EQ(stepped.get_height(), 40); + EXPECT_EQ(pixels(stepped), pixels(fed)); +} + +TEST(JpegSourceDecode, SteppedDirectRgb565MatchesFedDecode) { + // A fixed size that is not a whole multiple of the source exercises the scaling tables across steps + SourceImage fed(image::IMAGE_TYPE_RGB565, 30, 25); + ASSERT_TRUE(decode_fed(fed)); + + SourceImage stepped(image::IMAGE_TYPE_RGB565, 30, 25); + MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + int steps; + ASSERT_EQ(decode_stepped(stepped, source, steps), DECODE_STEP_DONE); + EXPECT_EQ(pixels(stepped), pixels(fed)); +} + +TEST(JpegSourceDecode, WaitsForDataThatHasNotArrived) { + SourceImage fed; + ASSERT_TRUE(decode_fed(fed)); + + // Enough for the headers (the scan starts at byte 609), then 50 bytes between steps + SourceImage img; + TrickleSource source(JPEG_ROWS, sizeof(JPEG_ROWS), 700); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS))); + ASSERT_EQ(img.begin_source(&source), 0); + int waits = 0; + int result; + while ((result = img.decode_step()) == DECODE_STEP_MORE || result == DECODE_STEP_NEED_DATA) { + if (result == DECODE_STEP_NEED_DATA) + waits++; + source.deliver(50); + ASSERT_LT(waits, 1000); + } + ASSERT_EQ(result, DECODE_STEP_DONE); + ASSERT_TRUE(img.end_decode()); + EXPECT_GT(waits, 0); + EXPECT_EQ(pixels(img), pixels(fed)); +} + +TEST(JpegSourceDecode, ImageIsHiddenUntilTheLastStep) { + SourceImage img; + MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS))); + ASSERT_EQ(img.begin_source(&source), 0); + EXPECT_EQ(img.decode_step(), DECODE_STEP_MORE); + EXPECT_EQ(img.get_width(), 0); + EXPECT_EQ(img.get_data_start(), nullptr); +} + +TEST(JpegSourceDecode, ReadFailureIsReportedAndTheDecoderRecovers) { + SourceImage img; + // The first read fills JPEGDEC's buffer, so the failure comes part way through the image data + MemorySource failing(JPEG_ROWS, sizeof(JPEG_ROWS), 2048); + int steps; + EXPECT_EQ(decode_stepped(img, failing, steps), DECODE_ERROR_READ); + EXPECT_GE(steps, 1); + img.release(); + + MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + EXPECT_EQ(decode_stepped(img, source, steps), DECODE_STEP_DONE); +} + +TEST(JpegSourceDecode, ReleasePartWayThroughLetsTheNextDecodeStart) { + SourceImage img; + MemorySource first(JPEG_ROWS, sizeof(JPEG_ROWS)); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS))); + ASSERT_EQ(img.begin_source(&first), 0); + ASSERT_EQ(img.decode_step(), DECODE_STEP_MORE); + img.release(); + EXPECT_FALSE(img.is_decoding()); + + MemorySource second(JPEG_ROWS, sizeof(JPEG_ROWS)); + int steps; + EXPECT_EQ(decode_stepped(img, second, steps), DECODE_STEP_DONE); + EXPECT_EQ(steps, 3); +} + +TEST(JpegSourceDecode, FailedFirstReadIsAReadError) { + SourceImage img; + MemorySource failing(JPEG_ROWS, sizeof(JPEG_ROWS), 0); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS))); + EXPECT_EQ(img.begin_source(&failing), DECODE_ERROR_READ); +} + +TEST(JpegSourceDecode, FailedSeekToTheStartIsAReadError) { + class NoSeekSource : public MemorySource { + public: + using MemorySource::MemorySource; + bool seek(uint32_t position) override { return false; } + }; + SourceImage img; + NoSeekSource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS))); + EXPECT_EQ(img.begin_source(&source), DECODE_ERROR_READ); + EXPECT_EQ(img.decode_step(), DECODE_ERROR_INVALID_TYPE); +} + +TEST(JpegSourceDecode, ProgressiveImageIsUnsupported) { + SourceImage img; + MemorySource source(JPEG_PROGRESSIVE, sizeof(JPEG_PROGRESSIVE)); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_PROGRESSIVE))); + EXPECT_EQ(img.begin_source(&source), DECODE_ERROR_UNSUPPORTED_FORMAT); + EXPECT_EQ(img.decode_step(), DECODE_ERROR_INVALID_TYPE); +} + +TEST(JpegSourceDecode, CompletedDecodeCountsTheWholeImage) { + SourceImage img; + MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS))); + ASSERT_EQ(img.begin_source(&source), 0); + int result; + while ((result = img.decode_step()) == DECODE_STEP_MORE) { + EXPECT_EQ(img.decoded_bytes(), 0u); + } + ASSERT_EQ(result, DECODE_STEP_DONE); + EXPECT_EQ(img.decoded_bytes(), sizeof(JPEG_ROWS)); +} + +TEST(JpegSourceDecode, SourceIsReadAsTheDecodeNeedsIt) { + SourceImage img; + MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + int steps; + ASSERT_EQ(decode_stepped(img, source, steps), DECODE_STEP_DONE); + EXPECT_GE(source.reads, 2); +} + +TEST(JpegSourceDecode, DecodersThatCannotReadASourceSaySo) { + RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); + ASSERT_TRUE(img.begin_decode(100)); + EXPECT_FALSE(img.decoder_supports_source()); + MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + EXPECT_EQ(img.begin_source(&source), DECODE_ERROR_UNSUPPORTED_FORMAT); + EXPECT_EQ(img.decode_step(), DECODE_ERROR_UNSUPPORTED_FORMAT); +} + +TEST(JpegSourceDecode, SourceNeedsTheImageSize) { + SourceImage img; + MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS)); + // JPEGDEC needs the file size up front, and holds it in an int + ASSERT_TRUE(img.begin_decode(0)); + EXPECT_EQ(img.begin_source(&source), DECODE_ERROR_INVALID_TYPE); + img.release(); + ASSERT_TRUE(img.begin_decode(static_cast(INT32_MAX) + 1)); + EXPECT_EQ(img.begin_source(&source), DECODE_ERROR_INVALID_TYPE); +} + +TEST(JpegSourceDecode, StepWithoutADecodeIsRefused) { + SourceImage img; + EXPECT_EQ(img.decode_step(), DECODE_ERROR_INVALID_TYPE); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS))); + // begin_source() was never called + EXPECT_EQ(img.decode_step(), DECODE_ERROR_INVALID_TYPE); +} + +} // namespace esphome::runtime_image::testing + +#endif // USE_RUNTIME_IMAGE_JPEG diff --git a/tests/components/rx8111/common.yaml b/tests/components/rx8111/common.yaml index c53dcad6606..a0eeb3c3c87 100644 --- a/tests/components/rx8111/common.yaml +++ b/tests/components/rx8111/common.yaml @@ -3,8 +3,44 @@ esphome: then: - rx8111.read_time: rtc - rx8111.write_time: rtc + - rx8111.set_alarm: + id: rtc + second: 0 + minute: 30 + hour: 7 + weekday: 2 + - rx8111.set_alarm: + id: rtc + day: 15 + - rx8111.schedule_alarm_in: + id: rtc + duration: 2min + - rx8111.set_alarm: + id: rtc + minute: !lambda return uint16_t{15}; + - rx8111.schedule_alarm_in: + id: rtc + duration: !lambda return uint32_t{60}; + - rx8111.clear_alarm_flag: rtc + - rx8111.clear_timer_flag: rtc + - rx8111.stop_timer: rtc + - rx8111.start_timer: rtc + - rx8111.unset_alarm: rtc time: - platform: rx8111 id: rtc i2c_id: i2c_bus + alarm_enabled: true + alarm_second: 0 + alarm_minute: 30 + alarm_hour: 7 + timer_count: 2 + timer_clock: 1hz + event_poll_interval: 500ms + on_alarm: + then: + - rx8111.clear_alarm_flag: rtc + on_timer: + then: + - rx8111.clear_timer_flag: rtc diff --git a/tests/components/rx8111/test_rx8111.cpp b/tests/components/rx8111/test_rx8111.cpp index 15a7b75c5db..938132ea7b0 100644 --- a/tests/components/rx8111/test_rx8111.cpp +++ b/tests/components/rx8111/test_rx8111.cpp @@ -5,6 +5,7 @@ #include #include "esphome/components/rx8111/rx8111.h" +#include "esphome/core/application.h" namespace esphome::rx8111::testing { @@ -15,13 +16,18 @@ static constexpr uint8_t DAY = 0x14; static constexpr uint8_t MONTH = 0x15; static constexpr uint8_t FLAG = 0x1E; static constexpr uint8_t CONTROL = 0x1F; +static constexpr uint8_t EXTENSION = 0x1D; +static constexpr uint8_t SEC_ALARM = 0x2C; +static constexpr uint8_t MIN_ALARM = 0x17; class RegisterBus final : public i2c::I2CBus { public: std::array regs{}; std::vector writes; int fail_read{-1}; + int fail_read_once{-1}; int fail_write{-1}; + unsigned flag_reads{0}; bool latch_event_before_flag_write{false}; i2c::ErrorCode write_readv(uint8_t address, const uint8_t *out, size_t out_size, uint8_t *in, @@ -32,8 +38,14 @@ class RegisterBus final : public i2c::I2CBus { return i2c::ERROR_TIMEOUT; const uint8_t reg = out[0]; if (in_size > 0) { + if (reg == FLAG) + this->flag_reads++; if (reg == this->fail_read) return i2c::ERROR_TIMEOUT; + if (reg == this->fail_read_once) { + this->fail_read_once = -1; + return i2c::ERROR_TIMEOUT; + } for (size_t i = 0; i < in_size; i++) in[i] = this->regs[reg + i]; } else { @@ -59,9 +71,27 @@ class RX8111Test : public ::testing::Test { void SetUp() override { this->rtc_.set_i2c_bus(&this->bus_); this->rtc_.set_i2c_address(ADDRESS); + EXPECT_EQ(this->rtc_.boot_retry_delay_ms_, 1000U); // Production default remains one second. + this->rtc_.boot_retry_delay_ms_ = 5; this->rtc_.add_on_time_sync_callback([this]() { this->syncs_++; }); } + void TearDown() override { + App.scheduler.cancel_interval(&this->rtc_, uint32_t{0}); + App.scheduler.cancel_timeout(&this->rtc_, uint32_t{1}); + App.scheduler.cancel_timeout(&this->rtc_, uint32_t{2}); + App.scheduler.call(millis()); // Reap cancelled timers while the fixture is still alive. + } + + void advance_retry_() { + delay(20); + App.scheduler.call(millis()); + } + + // TEST_F bodies are subclasses, so the friendship stops here + bool polling_started_() const { return this->rtc_.event_polling_started_; } + bool timer_enabled_() const { return this->rtc_.timer_enabled_; } + RegisterBus bus_; RX8111Component rtc_; unsigned syncs_{0}; @@ -157,4 +187,1022 @@ TEST_F(RX8111Test, I2CFailuresDoNotSyncOrLeaveTheCounterStopped) { EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); } +TEST_F(RX8111Test, WriteFailureClearsOnSuccessfulWriteButPreservesEventPollWarning) { + this->bus_.fail_write = SEC; + this->rtc_.write_time(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_write = -1; + this->rtc_.write_time(); + EXPECT_FALSE(this->rtc_.status_has_warning()); + + this->rtc_.set_alarm_enabled(true); + ASSERT_TRUE(this->rtc_.set_alarm(1, 0xFF, 0xFF, 0xFF, 0xFF)); + this->bus_.fail_read = FLAG; + this->rtc_.update(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = -1; + this->bus_.fail_write = FLAG; + this->rtc_.write_time(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_write = -1; + this->rtc_.write_time(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->rtc_.update(); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, RuntimeAlarmFailureRecoversOnlyAfterSuccessfulAlarmRequest) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->bus_.fail_write = MIN_ALARM; + EXPECT_FALSE(this->rtc_.set_alarm(1, 0xFF, 0xFF, 0xFF, 0xFF)); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_write = -1; + this->rtc_.read_time(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + ASSERT_TRUE(this->rtc_.set_alarm(2, 0xFF, 0xFF, 0xFF, 0xFF)); + EXPECT_FALSE(this->rtc_.status_has_warning()); + + this->bus_.regs[FLAG] = 0x80; + this->rtc_.read_time(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = CONTROL; + EXPECT_FALSE(this->rtc_.unset_alarm()); + this->bus_.fail_read = -1; + ASSERT_TRUE(this->rtc_.unset_alarm()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->rtc_.write_time(); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, RuntimeTimerAndFlagClearFailuresRecoverIndependently) { + this->rtc_.write_time(); + this->rtc_.set_timer_count(2); + this->bus_.fail_read = EXTENSION; + EXPECT_FALSE(this->rtc_.start_timer()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = -1; + ASSERT_TRUE(this->rtc_.start_timer()); + EXPECT_FALSE(this->rtc_.status_has_warning()); + + this->bus_.fail_write = FLAG; + EXPECT_FALSE(this->rtc_.clear_alarm_flag()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_write = -1; + ASSERT_TRUE(this->rtc_.stop_timer()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + ASSERT_TRUE(this->rtc_.clear_timer_flag()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + ASSERT_TRUE(this->rtc_.clear_alarm_flag()); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, TimerFlagClearWarningRecoversOnlyAfterSuccessfulClear) { + this->rtc_.write_time(); + this->bus_.regs[FLAG] = 0x10; + this->bus_.fail_write = FLAG; + EXPECT_FALSE(this->rtc_.clear_timer_flag()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x10, 0x10); + + this->bus_.fail_write = -1; + ASSERT_TRUE(this->rtc_.stop_timer()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + ASSERT_TRUE(this->rtc_.clear_timer_flag()); + EXPECT_FALSE(this->rtc_.status_has_warning()); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x10, 0); +} + +TEST_F(RX8111Test, FailedBootTimerProgrammingRecoversOnRetry) { + this->rtc_.write_time(); + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_enabled(true); + this->rtc_.set_timer_configured(true); + this->bus_.fail_write = 0x1A; // Timer reload register; boot does not clear a retained TF. + this->rtc_.setup(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_write = -1; + this->advance_retry_(); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, AlarmProgramsMaskedFieldsAndPreservesOtherControls) { + this->bus_.regs[CONTROL] = 0x54; + this->bus_.regs[EXTENSION] = 0xC8; + this->rtc_.set_alarm_enabled(true); + ASSERT_TRUE(this->rtc_.set_alarm(0, 30, 7, 2, 0xFF)); + EXPECT_EQ(this->bus_.regs[SEC_ALARM], 0x00); + EXPECT_EQ(this->bus_.regs[MIN_ALARM], 0x30); + EXPECT_EQ(this->bus_.regs[MIN_ALARM + 1], 0x07); + EXPECT_EQ(this->bus_.regs[MIN_ALARM + 2], 0x02); + EXPECT_EQ(this->bus_.regs[EXTENSION], 0xC0); + EXPECT_EQ(this->bus_.regs[CONTROL], 0x5C); + ASSERT_TRUE(this->rtc_.set_alarm(0xFF, 5, 0xFF, 0xFF, 31)); + EXPECT_EQ(this->bus_.regs[SEC_ALARM], 0x80); + EXPECT_EQ(this->bus_.regs[MIN_ALARM + 2], 0x31); + EXPECT_EQ(this->bus_.regs[EXTENSION], 0xC8); + EXPECT_EQ(this->bus_.regs[FLAG], 0); + EXPECT_FALSE(this->rtc_.set_alarm(60, 0, 0, 0xFF, 0xFF)); + EXPECT_FALSE(this->rtc_.set_alarm(0, 0, 0, 1, 1)); + EXPECT_EQ(this->bus_.regs[SEC_ALARM], 0x80); +} + +TEST_F(RX8111Test, FlagIsLatchedUntilExplicitClearAndOnlyEdgesTrigger) { + unsigned alarms = 0; + this->rtc_.set_alarm_enabled(true); + this->rtc_.add_on_alarm_callback([&alarms]() { alarms++; }); + this->bus_.regs[FLAG] = 0x08; // Event already latched before alarm configuration. + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + this->rtc_.update(); // A preexisting battery-backed event is not a new edge. + EXPECT_EQ(alarms, 0); + this->rtc_.update(); + EXPECT_EQ(alarms, 0); + this->bus_.regs[FLAG] = 0x3C; + this->bus_.latch_event_before_flag_write = true; + ASSERT_TRUE(this->rtc_.clear_alarm_flag()); + EXPECT_EQ(this->bus_.regs[FLAG], 0x34); + EXPECT_EQ(this->bus_.writes.back(), FLAG); + this->bus_.regs[FLAG] |= 0x08; + this->rtc_.update(); + EXPECT_EQ(alarms, 1); + this->rtc_.update(); + EXPECT_EQ(alarms, 1); + ASSERT_TRUE(this->rtc_.unset_alarm()); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x08, 0x08); + EXPECT_EQ(this->bus_.regs[SEC_ALARM], 0x80); +} + +TEST_F(RX8111Test, AlarmI2CFailureLeavesInterruptDisabled) { + this->rtc_.set_alarm_enabled(true); + this->bus_.regs[CONTROL] = 0x08; + this->bus_.fail_write = SEC_ALARM; + EXPECT_FALSE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, FailedAlarmReprogramOrUnsetKeepsArmedAlarmObservable) { + this->rtc_.set_alarm_enabled(true); + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([this, &calls]() { + calls++; + EXPECT_TRUE(this->rtc_.clear_alarm_flag()); + }); + this->rtc_.update(); // Establish AF low before each attempt. + + for (const uint8_t failed_register : {EXTENSION, CONTROL}) { + this->bus_.fail_read = failed_register; + EXPECT_FALSE(this->rtc_.set_alarm(1, 0, 0, 0xFF, 0xFF)); + this->bus_.fail_read = -1; + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0x08); + this->bus_.regs[FLAG] = 0x08; + this->rtc_.update(); + EXPECT_EQ(calls, failed_register == EXTENSION ? 1 : 2); + } + for (const bool fail_write : {false, true}) { + if (fail_write) { + this->bus_.fail_write = CONTROL; + } else { + this->bus_.fail_read = CONTROL; + } + EXPECT_FALSE(this->rtc_.unset_alarm()); + this->bus_.fail_read = -1; + this->bus_.fail_write = -1; + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0x08); + this->bus_.regs[FLAG] = 0x08; + this->rtc_.update(); + EXPECT_EQ(calls, fail_write ? 4 : 3); + } +} + +TEST_F(RX8111Test, FailedAlarmReprogramAfterDisableDoesNotDeliverOldAlarm) { + this->rtc_.set_alarm_enabled(true); + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([&calls]() { calls++; }); + this->rtc_.update(); + this->bus_.fail_write = SEC_ALARM; + EXPECT_FALSE(this->rtc_.set_alarm(1, 0, 0, 0xFF, 0xFF)); + this->bus_.fail_write = -1; + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0); + this->bus_.regs[FLAG] = 0x08; + this->rtc_.update(); + EXPECT_EQ(calls, 0); +} + +TEST_F(RX8111Test, ClearFromAlarmAutomationAllowsNextEvent) { + this->rtc_.set_alarm_enabled(true); + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([this, &calls]() { + calls++; + EXPECT_TRUE(this->rtc_.clear_alarm_flag()); + }); + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + this->rtc_.update(); // Initialize the edge detector with AF low. + this->bus_.regs[FLAG] = 0x08; + this->rtc_.update(); + EXPECT_EQ(calls, 1); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x08, 0); + this->bus_.regs[FLAG] = 0x08; + this->rtc_.update(); + EXPECT_EQ(calls, 2); +} + +TEST_F(RX8111Test, RelativeAlarmUsesUTCAndRejectsInvalidDurations) { + EXPECT_FALSE(this->rtc_.schedule_alarm_in(1)); // Opt-in required. + this->rtc_.set_alarm_enabled(true); + EXPECT_FALSE(this->rtc_.schedule_alarm_in(0)); + EXPECT_FALSE(this->rtc_.schedule_alarm_in(28U * 86400U)); + this->rtc_.write_time(); + const auto before = ESPTime::from_epoch_utc(::time(nullptr) + 60); + ASSERT_TRUE(this->rtc_.schedule_alarm_in(60)); + const auto after = ESPTime::from_epoch_utc(::time(nullptr) + 60); + const auto matches = [this](const ESPTime &time) { + const auto bcd = [](int value) { return (value / 10) * 16 + value % 10; }; + return this->bus_.regs[SEC_ALARM] == bcd(time.second) && this->bus_.regs[MIN_ALARM] == bcd(time.minute) && + this->bus_.regs[MIN_ALARM + 1] == bcd(time.hour) && this->bus_.regs[MIN_ALARM + 2] == bcd(time.day_of_month); + }; + EXPECT_TRUE(matches(before) || matches(after)); +} + +TEST_F(RX8111Test, RelativeAlarmReadsRTCWithoutSynchronizingSystemClock) { + this->rtc_.set_alarm_enabled(true); + this->bus_.regs[SEC] = 0x30; + this->bus_.regs[SEC + 1] = 0x59; + this->bus_.regs[SEC + 2] = 0x23; + this->bus_.regs[WEEK] = 0x04; // Tuesday, 2024-01-02. + this->bus_.regs[DAY] = 0x02; + this->bus_.regs[MONTH] = 0x01; + this->bus_.regs[MONTH + 1] = 0x24; + ASSERT_TRUE(this->rtc_.schedule_alarm_in(60)); + EXPECT_EQ(this->syncs_, 0); + EXPECT_EQ(this->bus_.regs[SEC_ALARM], 0x30); + EXPECT_EQ(this->bus_.regs[MIN_ALARM], 0x00); + EXPECT_EQ(this->bus_.regs[MIN_ALARM + 1], 0x00); + EXPECT_EQ(this->bus_.regs[MIN_ALARM + 2], 0x03); + this->bus_.regs[FLAG] = 0x80; + this->bus_.writes.clear(); + EXPECT_FALSE(this->rtc_.schedule_alarm_in(60)); + EXPECT_TRUE(this->bus_.writes.empty()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[FLAG] = 0; + this->bus_.regs[WEEK] = 0; + EXPECT_FALSE(this->rtc_.schedule_alarm_in(60)); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +TEST_F(RX8111Test, TimerPrograms24BitCountAndPreservesAlarmAndOtherExtensionBits) { + this->bus_.regs[EXTENSION] = 0xC8; + this->bus_.regs[CONTROL] = 0x09; + this->bus_.regs[FLAG] = 0x1C; + this->rtc_.set_timer_enabled(true); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_count(0x123456); + this->rtc_.set_timer_clock(1); + this->rtc_.setup(); + EXPECT_EQ(this->bus_.regs[0x1A], 0x56); + EXPECT_EQ(this->bus_.regs[0x1B], 0x34); + EXPECT_EQ(this->bus_.regs[0x1C], 0x12); + EXPECT_EQ(this->bus_.regs[EXTENSION], 0xD9); + EXPECT_EQ(this->bus_.regs[CONTROL], 0x19); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x1C, 0x1C); + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + this->rtc_.update(); // Report a retained wake-up event once. + EXPECT_EQ(calls, 1); + ASSERT_TRUE(this->rtc_.clear_timer_flag()); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x1C, 0x0C); + this->bus_.regs[FLAG] |= 0x10; + this->rtc_.update(); + this->rtc_.update(); + EXPECT_EQ(calls, 2); + EXPECT_TRUE(this->rtc_.clear_timer_flag()); + this->bus_.regs[FLAG] |= 0x10; + this->rtc_.update(); + EXPECT_EQ(calls, 3); +} + +TEST_F(RX8111Test, ExplicitDisabledTimerStopsBatteryBackedCounter) { + this->bus_.regs[EXTENSION] = 0xD9; + this->bus_.regs[CONTROL] = 0xB7; + this->bus_.regs[FLAG] = 0x10; + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(false); + this->rtc_.setup(); + EXPECT_EQ(this->bus_.regs[EXTENSION], 0xC9); + EXPECT_EQ(this->bus_.regs[CONTROL], 0xA7); + EXPECT_EQ(this->bus_.regs[FLAG], 0x10); + EXPECT_TRUE(this->rtc_.stop_timer()); + EXPECT_EQ(this->bus_.regs[EXTENSION], 0xC9); + this->bus_.fail_write = CONTROL; + EXPECT_FALSE(this->rtc_.stop_timer()); + EXPECT_TRUE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, ExplicitDisabledAlarmClearsOnlyAIEAndPreservesAF) { + this->bus_.regs[CONTROL] = 0x5C; + this->bus_.regs[FLAG] = 0x08; + this->rtc_.set_alarm_explicitly_configured(true); + this->rtc_.set_alarm_enabled(false); + this->rtc_.setup(); + EXPECT_EQ(this->bus_.regs[CONTROL], 0x54); + EXPECT_EQ(this->bus_.regs[FLAG], 0x08); + this->bus_.regs[CONTROL] = 0x5C; + this->rtc_.set_alarm_explicitly_configured(false); + this->rtc_.setup(); + EXPECT_EQ(this->bus_.regs[CONTROL], 0x5C); +} + +TEST_F(RX8111Test, DisabledTimerReportsRetainedFlagOnceViaScheduler) { + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(false); + this->rtc_.set_event_poll_interval(1); + this->bus_.regs[FLAG] = 0x10; + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + this->rtc_.setup(); + App.scheduler.call(millis()); + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(calls, 1); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x10, 0); + const unsigned flag_reads = this->bus_.flag_reads; + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(this->bus_.flag_reads, flag_reads); // No repeated FLAG reads after the retained event. + this->rtc_.update(); + EXPECT_EQ(calls, 1); + EXPECT_TRUE(this->rtc_.start_timer()); // Restart polling when the timer is activated later. + this->bus_.regs[FLAG] = 0x10; + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(calls, 2); +} + +TEST_F(RX8111Test, DisabledTimerStopsPollingAfterCheckingForRetainedFlag) { + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(false); + this->rtc_.set_event_poll_interval(1); + this->rtc_.setup(); + App.scheduler.call(millis()); + delay(20); + App.scheduler.call(millis()); + const unsigned flag_reads = this->bus_.flag_reads; + EXPECT_GT(flag_reads, 0); + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(this->bus_.flag_reads, flag_reads); +} + +TEST_F(RX8111Test, DisabledTimerRetriesFailedRetainedFlagReadBeforeStoppingPoll) { + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(false); + this->rtc_.set_event_poll_interval(1); + this->rtc_.setup(); + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + this->bus_.fail_read = FLAG; + delay(20); + App.scheduler.call(millis()); + this->bus_.fail_read = -1; + this->bus_.regs[FLAG] = 0x10; + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(calls, 1); + const unsigned flag_reads = this->bus_.flag_reads; + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(this->bus_.flag_reads, flag_reads); +} + +TEST_F(RX8111Test, RearmingAlarmAfterCallbackStopsPollingRestartsInterval) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_event_poll_interval(1); + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + this->rtc_.setup(); + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([this, &calls]() { + calls++; + EXPECT_TRUE(this->rtc_.clear_alarm_flag()); + EXPECT_TRUE(this->rtc_.unset_alarm()); + }); + this->bus_.regs[FLAG] = 0x08; + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(calls, 1); + ASSERT_TRUE(this->rtc_.set_alarm(0, 1, 0, 0xFF, 0xFF)); + this->bus_.regs[FLAG] = 0x08; + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(calls, 2); +} + +TEST_F(RX8111Test, EventPollWarningRecoversButCalendarWarningRemains) { + this->rtc_.set_timer_count(1); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(true); + this->rtc_.setup(); + // Provide a valid calendar so a failed FLAG poll is the only warning source. + this->rtc_.write_time(); + this->rtc_.read_time(); + this->bus_.fail_read = FLAG; + this->rtc_.update(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = -1; + this->rtc_.update(); + EXPECT_FALSE(this->rtc_.status_has_warning()); + this->bus_.regs[FLAG] = 0x80; + this->rtc_.update(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = FLAG; + this->rtc_.update(); + this->bus_.fail_read = -1; + // Event polling succeeds, but the invalid calendar still owns the warning. + this->rtc_.update(); + EXPECT_TRUE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, TrustedWriteClearsCalendarWarningButNotFailedEventPoll) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_event_poll_interval(1); + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + this->rtc_.setup(); + this->bus_.regs[FLAG] = 0x80; // Calendar is invalid until trusted write. + this->rtc_.read_time(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = FLAG; + delay(20); + App.scheduler.call(millis()); // Failed FLAG poll while calendar owns the warning. + this->bus_.fail_read = -1; + this->rtc_.write_time(); + EXPECT_TRUE(this->rtc_.status_has_warning()); // Outstanding poll failure survives the write. + this->bus_.fail_read = FLAG; + delay(20); + App.scheduler.call(millis()); // Another failed FLAG poll, after trusted write. + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = -1; + delay(20); + App.scheduler.call(millis()); + EXPECT_FALSE(this->rtc_.status_has_warning()); + this->bus_.regs[FLAG] = 0x80; + this->rtc_.read_time(); + this->rtc_.write_time(); + EXPECT_FALSE(this->rtc_.status_has_warning()); + this->bus_.fail_read = FLAG; + delay(20); + App.scheduler.call(millis()); // New poll failure is visible without another calendar read. + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = -1; + delay(20); + App.scheduler.call(millis()); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, OmittedTimerConfigurationLeavesBatteryBackedStateAlone) { + this->bus_.regs[EXTENSION] = 0xD9; + this->bus_.regs[CONTROL] = 0xB7; + this->rtc_.setup(); + EXPECT_EQ(this->bus_.regs[EXTENSION], 0xD9); + EXPECT_EQ(this->bus_.regs[CONTROL], 0xB7); +} + +TEST_F(RX8111Test, StartTimerRestartsFullCountAndClearsOnlyStaleTimerFlag) { + this->rtc_.set_timer_count(60); + this->rtc_.set_timer_clock(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(true); + this->rtc_.setup(); + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + this->bus_.regs[FLAG] = 0x1C; + ASSERT_TRUE(this->rtc_.stop_timer()); + this->bus_.writes.clear(); + ASSERT_TRUE(this->rtc_.start_timer()); + EXPECT_EQ(this->bus_.regs[FLAG], 0x0C); + EXPECT_EQ(this->bus_.regs[0x1A], 60); + EXPECT_EQ(this->bus_.regs[0x1B], 0); + EXPECT_EQ(this->bus_.regs[0x1C], 0); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x10, 0x10); + EXPECT_EQ(this->bus_.writes, (std::vector{EXTENSION, CONTROL, 0x1A, EXTENSION, FLAG, CONTROL, EXTENSION})); + this->rtc_.update(); + EXPECT_EQ(calls, 0); + this->bus_.regs[FLAG] |= 0x10; + this->rtc_.update(); + EXPECT_EQ(calls, 1); + ASSERT_TRUE(this->rtc_.stop_timer()); + ASSERT_TRUE(this->rtc_.start_timer()); + this->bus_.regs[FLAG] |= 0x10; + this->rtc_.update(); + EXPECT_EQ(calls, 2); +} + +TEST_F(RX8111Test, StartTimerAfterDisabledBootAndRejectsMissingOrFailedConfiguration) { + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(false); + this->rtc_.set_event_poll_interval(1); + this->rtc_.setup(); + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + this->bus_.regs[FLAG] = 0x10; + ASSERT_TRUE(this->rtc_.start_timer()); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x10, 0); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10); + this->bus_.regs[FLAG] = 0x10; + App.scheduler.call(millis()); + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(calls, 1); + ASSERT_TRUE(this->rtc_.stop_timer()); + this->bus_.fail_write = FLAG; + EXPECT_FALSE(this->rtc_.start_timer()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x10, 0); + this->bus_.fail_write = -1; + EXPECT_TRUE(this->rtc_.start_timer()); +} + +TEST_F(RX8111Test, StartTimerWithoutCountDoesNotTouchHardware) { + this->rtc_.setup(); + this->rtc_.status_clear_warning(); + this->bus_.writes.clear(); + EXPECT_FALSE(this->rtc_.start_timer()); + EXPECT_FALSE(this->rtc_.status_has_warning()); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +TEST_F(RX8111Test, InitialTimeSyncAlarmActionOverridesBootAlarm) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + unsigned actions = 0; + this->rtc_.add_on_time_sync_callback([this, &actions]() { + actions++; + EXPECT_TRUE(this->rtc_.set_alarm(12, 34, 6, 0xFF, 0xFF)); + }); + this->rtc_.setup(); + EXPECT_EQ(actions, 1); + EXPECT_EQ(this->bus_.regs[SEC_ALARM], 0x12); + EXPECT_EQ(this->bus_.regs[MIN_ALARM], 0x34); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0x08); +} + +TEST_F(RX8111Test, InitialTimeSyncUnsetAlarmActionOverridesBootAlarm) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + unsigned actions = 0; + this->rtc_.add_on_time_sync_callback([this, &actions]() { + actions++; + EXPECT_TRUE(this->rtc_.unset_alarm()); + }); + this->rtc_.setup(); + EXPECT_EQ(actions, 1); + EXPECT_EQ(this->bus_.regs[SEC_ALARM], 0x80); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0); +} + +TEST_F(RX8111Test, BootAlarmIsPreservedAndLatchedEventIsDelivered) { + this->bus_.regs[CONTROL] = 0x08; + this->bus_.regs[FLAG] = 0x08; + this->rtc_.set_alarm_enabled(true); + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([this, &calls]() { + calls++; + EXPECT_TRUE(this->rtc_.clear_alarm_flag()); + }); + this->rtc_.setup(); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0x08); + this->rtc_.update(); + EXPECT_EQ(calls, 1); + this->bus_.regs[FLAG] |= 0x08; + this->rtc_.update(); + EXPECT_EQ(calls, 2); +} + +TEST_F(RX8111Test, BootAlarmRetriesPartialWriteAndPreservesLatchedFlag) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->bus_.regs[FLAG] = 0x88; + this->bus_.fail_write = MIN_ALARM; + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([&calls]() { calls++; }); + this->rtc_.setup(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0); + this->bus_.fail_write = -1; + this->advance_retry_(); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0x08); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x08, 0x08); + EXPECT_TRUE(this->rtc_.status_has_warning()); // POR still owns the calendar warning. + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, BootAlarmRetryDoesNotRedeliverWakeFlag) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->bus_.regs[CONTROL] = 0x08; + this->bus_.regs[FLAG] = 0x08; + this->bus_.fail_write = CONTROL; + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([&calls]() { calls++; }); + this->rtc_.setup(); + this->bus_.fail_write = -1; + this->advance_retry_(); + this->rtc_.update(); + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, BootAlarmRetryNeverHidesFirstWakeFlag) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->bus_.regs[FLAG] = 0x08; + this->bus_.fail_write = MIN_ALARM; + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([&calls]() { calls++; }); + this->rtc_.setup(); + this->bus_.fail_write = -1; + this->advance_retry_(); + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, AlarmWakeFlagObservedBeforeRetryDoesNotRepeat) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + ASSERT_TRUE(this->rtc_.set_alarm(5, 0xFF, 0xFF, 0xFF, 0xFF)); + this->bus_.regs[FLAG] = 0x08; + this->bus_.fail_read = EXTENSION; + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([&calls]() { calls++; }); + this->rtc_.setup(); + this->rtc_.update(); + EXPECT_EQ(calls, 1); + this->bus_.fail_read = -1; + this->advance_retry_(); + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, BootAlarmRetriesExhaustAndCalendarReadDoesNotHideFailure) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->bus_.fail_write = MIN_ALARM; + this->rtc_.setup(); + this->rtc_.update(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->advance_retry_(); + this->advance_retry_(); + this->bus_.fail_write = -1; + this->advance_retry_(); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, BootAlarmRetryClearsOnlyItsOwnWarning) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->bus_.fail_write = MIN_ALARM; + this->rtc_.setup(); + this->bus_.fail_write = -1; + this->rtc_.read_time(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->advance_retry_(); + EXPECT_FALSE(this->rtc_.status_has_warning()); + this->bus_.regs[FLAG] = 0x80; + this->rtc_.read_time(); + this->bus_.fail_write = MIN_ALARM; + // A runtime failure must not consume another boot retry or erase the calendar warning. + EXPECT_FALSE(this->rtc_.set_alarm(6, 0xFF, 0xFF, 0xFF, 0xFF)); + this->bus_.fail_write = -1; + this->advance_retry_(); + EXPECT_TRUE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, RuntimeAlarmClearsSupersededBootWarningAndCancelsRetry) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->bus_.fail_write = MIN_ALARM; + this->rtc_.setup(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_write = -1; + ASSERT_TRUE(this->rtc_.set_alarm(6, 0xFF, 0xFF, 0xFF, 0xFF)); + EXPECT_FALSE(this->rtc_.status_has_warning()); + this->bus_.writes.clear(); + this->advance_retry_(); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +TEST_F(RX8111Test, RelativeAlarmClearsSupersededBootWarningAndCancelsRetry) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->bus_.fail_write = MIN_ALARM; + this->rtc_.setup(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_write = -1; + ASSERT_TRUE(this->rtc_.schedule_alarm_in(60)); + EXPECT_FALSE(this->rtc_.status_has_warning()); + this->bus_.writes.clear(); + this->advance_retry_(); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +TEST_F(RX8111Test, RuntimeAlarmClearsOnlyBootAlarmWarning) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(true); + this->bus_.fail_read = EXTENSION; + this->rtc_.setup(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = -1; + ASSERT_TRUE(this->rtc_.set_alarm(6, 0xFF, 0xFF, 0xFF, 0xFF)); + EXPECT_TRUE(this->rtc_.status_has_warning()); // The timer still owns a boot warning. + this->advance_retry_(); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, BootAlarmAndTimerHaveIndependentRetryBudgets) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(true); + this->bus_.fail_read = EXTENSION; + this->rtc_.setup(); + this->bus_.fail_read = -1; + this->advance_retry_(); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x18, 0x18); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, TimerPollBeforeRetainedAlarmInspectionRetryDeliversWakeFlag) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(true); + this->bus_.regs[CONTROL] = 0x08; + this->bus_.fail_read_once = CONTROL; + this->rtc_.setup(); + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([&calls]() { calls++; }); + this->bus_.regs[FLAG] = 0x08; + this->rtc_.update(); // Timer polling must not consume an alarm that is not armed yet. + EXPECT_EQ(calls, 0); + this->advance_retry_(); + this->rtc_.update(); + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, AlarmPollBeforeDisabledTimerStopRetryDeliversRetainedFlag) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->bus_.regs[CONTROL] = 0x08; + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(false); + this->bus_.fail_read_once = EXTENSION; + this->rtc_.setup(); + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + this->bus_.regs[FLAG] = 0x10; + this->rtc_.update(); // Alarm polling must not consume an ineligible timer flag. + EXPECT_EQ(calls, 0); + this->advance_retry_(); + this->rtc_.update(); + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, RetainedAlarmInspectionRetriesAndDeliversWakeFlag) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->bus_.regs[CONTROL] = 0x08; + this->bus_.fail_read = CONTROL; + this->rtc_.setup(); + this->bus_.fail_read = -1; + unsigned calls = 0; + this->rtc_.add_on_alarm_callback([&calls]() { calls++; }); + this->bus_.regs[FLAG] = 0x08; + this->advance_retry_(); + EXPECT_FALSE(this->rtc_.status_has_warning()); + this->rtc_.update(); + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, BootRetryDoesNotClearOutstandingEventPollWarning) { + this->rtc_.write_time(); + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_alarm_second(5); + this->bus_.fail_write = MIN_ALARM; + this->rtc_.setup(); + this->bus_.fail_read = FLAG; + this->rtc_.update(); + this->bus_.fail_read = -1; + this->bus_.fail_write = -1; + this->advance_retry_(); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->rtc_.update(); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, BootTimerRetriesPartialWriteAndRetainedFlagOnce) { + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(true); + this->bus_.regs[FLAG] = 0x10; + this->bus_.fail_write = 0x1A; + this->rtc_.setup(); + this->bus_.fail_write = -1; + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + this->advance_retry_(); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x10, 0x10); + this->rtc_.update(); + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, ExplicitBootDisableRetriesAndRuntimeRequestSupersedesRetry) { + this->rtc_.set_alarm_enabled(false); + this->rtc_.set_alarm_explicitly_configured(true); + this->bus_.regs[CONTROL] = 0x08; + this->bus_.fail_write = CONTROL; + this->rtc_.setup(); + this->bus_.fail_write = -1; + this->advance_retry_(); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x08, 0); + this->rtc_.set_timer_count(2); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_enabled(false); + this->bus_.fail_read = EXTENSION; + this->rtc_.setup(); + this->bus_.fail_read = -1; + ASSERT_TRUE(this->rtc_.start_timer()); + this->bus_.writes.clear(); + this->advance_retry_(); + EXPECT_TRUE(this->bus_.writes.empty()); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10); +} + +TEST_F(RX8111Test, AlarmAndTimerFlagsAreReadTogetherAndCallbacksCanClearBoth) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_timer_enabled(true); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_count(2); + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + this->rtc_.setup(); + unsigned alarms = 0; + unsigned timers = 0; + this->rtc_.add_on_alarm_callback([this, &alarms]() { + alarms++; + EXPECT_TRUE(this->rtc_.clear_alarm_flag()); + }); + this->rtc_.add_on_timer_callback([this, &timers]() { + timers++; + EXPECT_TRUE(this->rtc_.clear_timer_flag()); + }); + this->bus_.regs[FLAG] = 0x18; + this->rtc_.update(); + EXPECT_EQ(alarms, 1); + EXPECT_EQ(timers, 1); + EXPECT_EQ(this->bus_.regs[FLAG], 0); + this->bus_.regs[FLAG] = 0x18; + this->rtc_.update(); + EXPECT_EQ(alarms, 2); + EXPECT_EQ(timers, 2); +} + +TEST_F(RX8111Test, AlarmCallbackCanStopTimerBeforeTimerCallback) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_timer_enabled(true); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_count(2); + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + this->rtc_.setup(); + unsigned timers = 0; + this->rtc_.add_on_alarm_callback([this]() { EXPECT_TRUE(this->rtc_.stop_timer()); }); + this->rtc_.add_on_timer_callback([&timers]() { timers++; }); + this->bus_.regs[FLAG] = 0x18; + this->rtc_.update(); + EXPECT_EQ(timers, 0); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x10, 0); +} + +TEST_F(RX8111Test, AlarmCallbackRestartInvalidatesPendingTimerEvent) { + this->rtc_.set_alarm_enabled(true); + this->rtc_.set_timer_enabled(true); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_count(2); + ASSERT_TRUE(this->rtc_.set_alarm(0, 0, 0, 0xFF, 0xFF)); + this->rtc_.setup(); + unsigned timers = 0; + this->rtc_.add_on_alarm_callback([this]() { EXPECT_TRUE(this->rtc_.start_timer()); }); + this->rtc_.add_on_timer_callback([&timers]() { timers++; }); + this->bus_.regs[FLAG] = 0x18; + this->rtc_.update(); + EXPECT_EQ(timers, 0); + EXPECT_EQ(this->bus_.regs[FLAG] & 0x10, 0); + this->bus_.regs[FLAG] |= 0x10; + this->rtc_.update(); + EXPECT_EQ(timers, 1); +} + +TEST_F(RX8111Test, TimerCallbackCanClearTFAndFailedProgrammingDoesNotStartTimer) { + this->rtc_.set_timer_enabled(true); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_count(1); + this->bus_.fail_write = 0x1A; + this->rtc_.setup(); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0); + EXPECT_EQ(this->bus_.regs[CONTROL] & 0x10, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + + this->bus_.fail_write = -1; + this->bus_.regs[FLAG] = 0; + unsigned calls = 0; + this->rtc_.add_on_timer_callback([this, &calls]() { + calls++; + EXPECT_TRUE(this->rtc_.clear_timer_flag()); + }); + this->rtc_.update(); + this->bus_.regs[FLAG] = 0x10; + this->rtc_.update(); + EXPECT_EQ(calls, 1); + EXPECT_EQ(this->bus_.regs[FLAG], 0); + this->bus_.regs[FLAG] = 0x10; + this->rtc_.update(); + EXPECT_EQ(calls, 2); +} + +TEST_F(RX8111Test, StopTimerOutsideCallbackCancelsEventPolling) { + this->rtc_.set_timer_enabled(true); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_count(2); + this->rtc_.set_event_poll_interval(1); + this->rtc_.setup(); + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + App.scheduler.call(millis()); + delay(20); + App.scheduler.call(millis()); + EXPECT_GT(this->bus_.flag_reads, 0); + EXPECT_TRUE(this->polling_started_()); + ASSERT_TRUE(this->rtc_.stop_timer()); + delay(20); + App.scheduler.call(millis()); + EXPECT_FALSE(this->polling_started_()); + const unsigned flag_reads = this->bus_.flag_reads; + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(this->bus_.flag_reads, flag_reads); + ASSERT_TRUE(this->rtc_.start_timer()); + EXPECT_TRUE(this->polling_started_()); + this->bus_.regs[FLAG] = 0x10; + delay(20); + App.scheduler.call(millis()); + EXPECT_EQ(calls, 1); +} + +TEST_F(RX8111Test, FailedRestartWithUnreadableBusKeepsRunningTimerWatched) { + this->rtc_.set_timer_enabled(true); + this->rtc_.set_timer_configured(true); + this->rtc_.set_timer_count(2); + this->rtc_.setup(); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10); + unsigned calls = 0; + this->rtc_.add_on_timer_callback([&calls]() { calls++; }); + this->bus_.fail_read = EXTENSION; + EXPECT_FALSE(this->rtc_.start_timer()); + EXPECT_TRUE(this->rtc_.status_has_warning()); + EXPECT_TRUE(this->timer_enabled_()); + EXPECT_EQ(this->bus_.regs[EXTENSION] & 0x10, 0x10); + this->bus_.fail_read = -1; + this->bus_.regs[FLAG] = 0x10; + this->rtc_.update(); + EXPECT_EQ(calls, 1); +} + } // namespace esphome::rx8111::testing diff --git a/tests/components/sensor/__init__.py b/tests/components/sensor/__init__.py new file mode 100644 index 00000000000..4a50a561ca5 --- /dev/null +++ b/tests/components/sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # filter.cpp only compiles with the define the real to_code emits for configured filters + cg.add_define("USE_SENSOR_FILTER") + + manifest.to_code = to_code_testing diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index 08d1ec0c196..c908a3e5e05 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -254,3 +254,21 @@ sensor: lambda: return 42.0; update_interval: 1s state_class: "measurement_angle" + + # Testing rh_correction filter + - platform: copy + source_id: source_sensor + name: "RH Correction Filter Fahrenheit" + filters: + - rh_correction: + sensor_id: source_sensor + temperature_offset: -10.0 + use_fahrenheit: true + + - platform: copy + source_id: source_sensor + name: "RH Correction Filter Template" + filters: + - rh_correction: + sensor_id: source_sensor + temperature_offset: !lambda return -10.0; diff --git a/tests/components/sensor/sensor_tests.cpp b/tests/components/sensor/sensor_tests.cpp new file mode 100644 index 00000000000..5e937621005 --- /dev/null +++ b/tests/components/sensor/sensor_tests.cpp @@ -0,0 +1,66 @@ +#include +#include + +#include "esphome/components/sensor/filter.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::sensor::testing { + +// 40 % read 7.3 °C warm at a true 25 °C, in single precision Magnus arithmetic +static constexpr float CORRECTED = 61.13f; + +struct RhFixture { + Sensor temperature; + RHCorrectionFilter filter; + + RhFixture(float (*offset)(), bool use_fahrenheit = false) : filter(offset, &this->temperature, use_fahrenheit) {} +}; + +TEST(RHCorrectionFilter, CorrectsForTheOffset) { + RhFixture f([]() -> float { return -7.3f; }); + f.temperature.publish_state(25.0f); + EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f); +} + +TEST(RHCorrectionFilter, PositiveOffsetLowersTheHumidity) { + RhFixture f([]() -> float { return 7.3f; }); + f.temperature.publish_state(25.0f); + EXPECT_LT(f.filter.new_value(40.0f).value_or(NAN), 40.0f); +} + +TEST(RHCorrectionFilter, FahrenheitReadingsGiveTheSameResult) { + // 77 °F is 25 °C and -13.14 °F is -7.3 °C of offset + RhFixture f([]() -> float { return -13.14f; }, true); + f.temperature.publish_state(77.0f); + EXPECT_NEAR(f.filter.new_value(40.0f).value_or(NAN), CORRECTED, 0.05f); +} + +TEST(RHCorrectionFilter, ClampsToOneHundredPercent) { + RhFixture f([]() -> float { return -7.3f; }); + f.temperature.publish_state(25.0f); + EXPECT_FLOAT_EQ(f.filter.new_value(90.0f).value_or(NAN), 100.0f); +} + +TEST(RHCorrectionFilter, PassesThroughWithoutAUsableTemperature) { + RhFixture f([]() -> float { return -7.3f; }); + // No temperature reading yet + EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f); + // Out of the -40..50 °C band + f.temperature.publish_state(60.0f); + EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f); + // The band also covers the reconstructed enclosure temperature + f.temperature.publish_state(45.0f); + EXPECT_FLOAT_EQ(f.filter.new_value(40.0f).value_or(NAN), 40.0f); +} + +TEST(RHCorrectionFilter, DropsTheUpdateOnNaN) { + RhFixture f([]() -> float { return -7.3f; }); + f.temperature.publish_state(NAN); + EXPECT_FALSE(f.filter.new_value(40.0f).has_value()); + + RhFixture nan_offset([]() -> float { return NAN; }); + nan_offset.temperature.publish_state(25.0f); + EXPECT_FALSE(nan_offset.filter.new_value(40.0f).has_value()); +} + +} // namespace esphome::sensor::testing diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index f45e3fc232c..a32b4c5017b 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -35,7 +35,8 @@ media_player: announcement_volume: 60% announcement_pipeline: speaker: announcement_mixer_speaker_id - format: FLAC + format: MP3 + bitrate: 48 num_channels: 1 sources: - announcement_audio_file_source diff --git a/tests/components/switch/__init__.py b/tests/components/switch/__init__.py new file mode 100644 index 00000000000..a9b74e823b6 --- /dev/null +++ b/tests/components/switch/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The gtest binary has no YAML switch to emit USE_SWITCH_STORAGE, so define it here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_SWITCH_STORAGE") + + manifest.to_code = to_code_testing diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index ca187b30f1e..d27537146b2 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -21,6 +21,13 @@ switch: - logger.log: "Switch is now ON" on_turn_off: - logger.log: "Switch is now OFF" + - platform: template + name: "Flash Storage Switch" + id: flash_storage_switch + optimistic: true + restore_state: + state: KEEP + storage: flash esphome: on_boot: diff --git a/tests/components/switch/restore_storage_test.cpp b/tests/components/switch/restore_storage_test.cpp new file mode 100644 index 00000000000..4c0fe6b2e8d --- /dev/null +++ b/tests/components/switch/restore_storage_test.cpp @@ -0,0 +1,66 @@ +#ifdef USE_HOST +#include +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/components/switch/switch.h" +#include "esphome/core/application.h" + +namespace esphome::switch_::testing { +namespace fs = std::filesystem; + +/// Switch is abstract; this is the minimum needed to instantiate one. +class TestSwitch : public Switch { + public: + void write_state(bool state) override {} + /// The preference key is derived from object_id_hash_; two instances with the same value share stored state. + void set_test_id(uint32_t hash) { this->object_id_hash_ = hash; } +}; + +class SwitchRestoreStorageTest : public ::testing::Test { + protected: + void SetUp() override { + this->temp_dir_ = fs::temp_directory_path() / "esphome_switch_storage_test"; + fs::remove_all(this->temp_dir_); + fs::create_directories(this->temp_dir_); + setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1); + App.pre_setup("test_switch_storage", strlen("test_switch_storage"), "", 0); + host::setup_preferences(); + // Host preferences also keep an in-memory store, which would leak between tests and GTEST_REPEAT iterations. + host::host_preferences->reset(); + } + + void TearDown() override { + unsetenv("ESPHOME_PREFDIR"); + fs::remove_all(this->temp_dir_); + } + + fs::path temp_dir_; +}; + +// Host ignores flash vs RTC, so this checks that a switch with a storage bit in its restore mode still binds, +// saves and restores its preference; which memory it lands in is checked by the ESP32/ESP8266 compile tests. +TEST_F(SwitchRestoreStorageTest, SwitchWithAStorageChoiceStillRestoresItsState) { + static constexpr uint32_t ID = 0x5001; + const auto mode = static_cast(SWITCH_RESTORE_DEFAULT_OFF | RESTORE_MODE_STORAGE_RTC_MASK); + { + TestSwitch sw; + sw.set_test_id(ID); + sw.set_restore_mode(mode); + auto first = sw.get_initial_state_with_restore_mode(); + ASSERT_TRUE(first.has_value()); + EXPECT_FALSE(first.value()) << "nothing stored yet, so RESTORE_DEFAULT_OFF gives OFF"; + sw.publish_state(true); + global_preferences->sync(); + } + TestSwitch sw2; + sw2.set_test_id(ID); + sw2.set_restore_mode(mode); + auto restored = sw2.get_initial_state_with_restore_mode(); + ASSERT_TRUE(restored.has_value()); + EXPECT_TRUE(restored.value()) << "the saved state was not restored"; +} + +} // namespace esphome::switch_::testing +#endif // USE_HOST diff --git a/tests/components/switch/restore_test.cpp b/tests/components/switch/restore_test.cpp new file mode 100644 index 00000000000..62fe8d44730 --- /dev/null +++ b/tests/components/switch/restore_test.cpp @@ -0,0 +1,49 @@ +#include + +#include + +#include "esphome/components/switch/switch.h" + +namespace esphome::switch_::testing { + +class RecordingSwitch : public Switch { + public: + std::vector writes; + + protected: + void write_state(bool state) override { + this->writes.push_back(state); + this->publish_state(state); + } +}; + +TEST(SwitchRestore, DrivesTheRestoreModeStateThroughInversion) { + RecordingSwitch on, off, inverted; + on.set_restore_mode(SWITCH_ALWAYS_ON); + off.set_restore_mode(SWITCH_ALWAYS_OFF); + inverted.set_restore_mode(SWITCH_ALWAYS_ON); + inverted.set_inverted(true); + on.restore(); + off.restore(); + inverted.restore(); + EXPECT_EQ(on.writes, std::vector{true}); + EXPECT_EQ(off.writes, std::vector{false}); + EXPECT_EQ(inverted.writes, std::vector{false}); // the hardware level + EXPECT_TRUE(on.state); + EXPECT_FALSE(off.state); + EXPECT_TRUE(inverted.state); +} + +TEST(SwitchRestore, DefaultAppliesOnlyWhenTheModeIsDisabled) { + RecordingSwitch disabled, on; + disabled.set_restore_mode(SWITCH_RESTORE_DISABLED); + on.set_restore_mode(SWITCH_ALWAYS_ON); + disabled.restore(); + EXPECT_TRUE(disabled.writes.empty()); + disabled.restore(true); + on.restore(false); + EXPECT_EQ(disabled.writes, std::vector{true}); + EXPECT_EQ(on.writes, std::vector{true}); +} + +} // namespace esphome::switch_::testing diff --git a/tests/components/switch/rtc_storage.yaml b/tests/components/switch/rtc_storage.yaml new file mode 100644 index 00000000000..0a6af7ab77a --- /dev/null +++ b/tests/components/switch/rtc_storage.yaml @@ -0,0 +1,8 @@ +switch: + - platform: template + name: "RTC Storage Switch" + id: rtc_storage_switch + optimistic: true + initial_state: ON + restore_state: + storage: rtc diff --git a/tests/components/switch/test.esp32-idf.yaml b/tests/components/switch/test.esp32-idf.yaml index 25cb37a0b42..5a68a83760b 100644 --- a/tests/components/switch/test.esp32-idf.yaml +++ b/tests/components/switch/test.esp32-idf.yaml @@ -1,2 +1,3 @@ packages: common: !include common.yaml + rtc_storage: !include rtc_storage.yaml diff --git a/tests/components/switch/test.esp8266-ard.yaml b/tests/components/switch/test.esp8266-ard.yaml index 25cb37a0b42..5a68a83760b 100644 --- a/tests/components/switch/test.esp8266-ard.yaml +++ b/tests/components/switch/test.esp8266-ard.yaml @@ -1,2 +1,3 @@ packages: common: !include common.yaml + rtc_storage: !include rtc_storage.yaml diff --git a/tests/components/teleinfo/common.yaml b/tests/components/teleinfo/common.yaml index 7f73bca62c2..82082b39fac 100644 --- a/tests/components/teleinfo/common.yaml +++ b/tests/components/teleinfo/common.yaml @@ -100,6 +100,14 @@ sensor: name: pcoup tag_name: PCOUP teleinfo_id: test_teleinfo + - platform: teleinfo + name: ntarf + tag_name: NTARF + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pejp + tag_name: PEJP + teleinfo_id: test_teleinfo # Explicit override - user-specified unit should not be overwritten - platform: teleinfo name: hchp_override @@ -117,3 +125,7 @@ text_sensor: name: optarif tag_name: OPTARIF teleinfo_id: test_teleinfo + - platform: teleinfo + name: ltarf + tag_name: LTARF + teleinfo_id: test_teleinfo diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 90668bdf802..49d6c7e1f75 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -409,6 +409,10 @@ cover: id: template_cover_actions position: 0.0 tilt: 0.0 + toggle_action: + - cover.template.publish: + id: template_cover_actions + current_operation: IDLE stop_action: - cover.template.publish: id: template_cover_actions @@ -417,6 +421,10 @@ cover: - lambda: |- id(template_cover_actions).tilt = tilt; id(template_cover_actions).publish_state(); + stop_tilt_action: + - cover.template.publish: + id: template_cover_actions + current_operation: IDLE on_idle: # position only - cover.control: @@ -435,9 +443,14 @@ cover: - cover.control: id: template_cover_actions stop: true + # stop tilt + - cover.control: + id: template_cover_actions + stop_tilt: true - cover.open: template_cover_actions - cover.close: template_cover_actions - cover.stop: template_cover_actions + - cover.stop_tilt: template_cover_actions - cover.toggle: template_cover_actions # CONF_STATE alias for position - cover.control: diff --git a/tests/components/time/bcd_calendar.cpp b/tests/components/time/bcd_calendar.cpp index c66454fe1b6..c8bcef06103 100644 --- a/tests/components/time/bcd_calendar.cpp +++ b/tests/components/time/bcd_calendar.cpp @@ -1,16 +1,38 @@ #include #include +#include #include "esphome/components/time/real_time_clock.h" namespace esphome::time::testing { -// 2026-04-16 23:59:58, a Thursday (day_of_week 5, one-hot bit 4). -static constexpr uint8_t RAW[7] = {0x58, 0x59, 0x23, 0x10, 0x16, 0x04, 0x26}; +// 2026-04-16 23:59:58, a Thursday (day_of_week 5), in each layout. +static constexpr uint32_t TIMESTAMP = 1776383998; +static constexpr uint8_t EPSON_RAW[7] = {0x58, 0x59, 0x23, 0x10, 0x16, 0x04, 0x26}; +static constexpr uint8_t DS_RAW[7] = {0x58, 0x59, 0x23, 0x05, 0x16, 0x04, 0x26}; +static constexpr uint8_t PCF_RAW[7] = {0x58, 0x59, 0x23, 0x16, 0x04, 0x04, 0x26}; + +static std::array patched(const uint8_t (&src)[7], size_t index, uint8_t value) { + std::array raw; + std::copy(src, src + 7, raw.begin()); + raw[index] = value; + return raw; +} + +template static void expect_round_trip(const uint8_t (&raw)[7]) { + const ESPTime time = decode_bcd_calendar(raw); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.timestamp, TIMESTAMP); + uint8_t out[7]; + encode_bcd_calendar(time, out); + for (size_t i = 0; i < 7; i++) + EXPECT_EQ(out[i], raw[i]) << "byte " << i; +} TEST(BcdCalendar, DecodesEveryField) { - const ESPTime time = decode_bcd_calendar(RAW); + const ESPTime time = decode_bcd_calendar(EPSON_RAW); EXPECT_EQ(time.second, 58); EXPECT_EQ(time.minute, 59); EXPECT_EQ(time.hour, 23); @@ -18,14 +40,14 @@ TEST(BcdCalendar, DecodesEveryField) { EXPECT_EQ(time.day_of_month, 16); EXPECT_EQ(time.month, 4); EXPECT_EQ(time.year, 2026); - EXPECT_EQ(time.timestamp, 1776383998); + EXPECT_EQ(time.timestamp, TIMESTAMP); EXPECT_TRUE(time.is_valid(true, false)); } TEST(BcdCalendar, MasksStatusBits) { // Bit 7 of seconds/minutes, the top two bits of hour and day and the top three of month are not time. const uint8_t raw[7] = {0xD8, 0xD9, 0xE3, 0x90, 0xD6, 0xE4, 0x26}; - const ESPTime time = decode_bcd_calendar(raw); + const ESPTime time = decode_bcd_calendar(raw); EXPECT_EQ(time.second, 58); EXPECT_EQ(time.minute, 59); EXPECT_EQ(time.hour, 23); @@ -35,17 +57,34 @@ TEST(BcdCalendar, MasksStatusBits) { } TEST(BcdCalendar, EmptyWeekdayDecodesAsSunday) { - uint8_t raw[7]; - std::copy(RAW, RAW + 7, raw); - raw[3] = 0; - EXPECT_EQ(decode_bcd_calendar(raw).day_of_week, 1); + EXPECT_EQ(decode_bcd_calendar(patched(EPSON_RAW, 3, 0).data()).day_of_week, 1); } -TEST(BcdCalendar, EncodeRoundTrips) { - uint8_t raw[7]; - encode_bcd_calendar(decode_bcd_calendar(RAW), raw); - for (size_t i = 0; i < 7; i++) - EXPECT_EQ(raw[i], RAW[i]) << "byte " << i; +TEST(BcdCalendar, EveryLayoutRoundTrips) { + expect_round_trip(EPSON_RAW); + expect_round_trip(DS_RAW); + expect_round_trip(PCF_RAW); +} + +TEST(BcdCalendar, DsLayoutTwelveHourMode) { + EXPECT_EQ(decode_bcd_calendar(patched(DS_RAW, 2, 0x40 | 0x20 | 0x11).data()).hour, 23); // 11 PM + EXPECT_EQ(decode_bcd_calendar(patched(DS_RAW, 2, 0x40 | 0x12).data()).hour, 0); // 12 AM + EXPECT_EQ(decode_bcd_calendar(patched(DS_RAW, 2, 0x40 | 0x20 | 0x12).data()).hour, 12); // 12 PM +} + +TEST(BcdCalendar, DsLayoutCenturyBit) { + const ESPTime time = decode_bcd_calendar(patched(DS_RAW, 5, 0x84).data()); + EXPECT_EQ(time.year, 2126); + // 2100 is not a leap year, so the timestamp is one day earlier than a plain every-four-years count gives. + EXPECT_EQ(time.timestamp, 4932057598); + uint8_t out[7]; + encode_bcd_calendar(time, out); + EXPECT_EQ(out[5], 0x84); + EXPECT_EQ(out[6], 0x26); +} + +TEST(BcdCalendar, PcfLayoutIgnoresMonthBit7) { + EXPECT_EQ(decode_bcd_calendar(patched(PCF_RAW, 5, 0x84).data()).year, 2026); } } // namespace esphome::time::testing diff --git a/tests/components/time/increment.cpp b/tests/components/time/increment.cpp new file mode 100644 index 00000000000..369eec8b639 --- /dev/null +++ b/tests/components/time/increment.cpp @@ -0,0 +1,62 @@ +// Year roll-over in ESPTime::increment_second() and increment_day(): 2100 is a common year, 2000 and 2024 are leap +// years, so the day of the year must wrap after 365 or 366 days accordingly. + +#include +#include "esphome/core/time.h" + +namespace esphome::testing { + +static ESPTime last_day_of(uint16_t year, uint16_t day_of_year) { + ESPTime t{}; + t.second = 59; + t.minute = 59; + t.hour = 23; + t.day_of_week = 5; + t.day_of_month = 31; + t.day_of_year = day_of_year; + t.month = 12; + t.year = year; + return t; +} + +static void expect_new_year(const ESPTime &t, uint16_t year) { + EXPECT_EQ(t.year, year); + EXPECT_EQ(t.month, 1); + EXPECT_EQ(t.day_of_month, 1); + EXPECT_EQ(t.day_of_year, 1); +} + +TEST(ESPTimeIncrement, DayRollsOverCommonCenturyYear) { + ESPTime t = last_day_of(2100, 365); + t.increment_day(); + expect_new_year(t, 2101); +} + +TEST(ESPTimeIncrement, SecondRollsOverCommonCenturyYear) { + ESPTime t = last_day_of(2100, 365); + t.increment_second(); + expect_new_year(t, 2101); + EXPECT_EQ(t.hour, 0); + EXPECT_EQ(t.minute, 0); + EXPECT_EQ(t.second, 0); +} + +TEST(ESPTimeIncrement, DayRollsOverLeapYears) { + ESPTime t = last_day_of(2000, 366); + t.increment_day(); + expect_new_year(t, 2001); + t = last_day_of(2024, 366); + t.increment_day(); + expect_new_year(t, 2025); +} + +TEST(ESPTimeIncrement, DayStaysInLeapYearOnDay365) { + ESPTime t = last_day_of(2024, 365); + t.day_of_month = 30; + t.increment_day(); + EXPECT_EQ(t.year, 2024); + EXPECT_EQ(t.day_of_year, 366); + EXPECT_EQ(t.day_of_month, 31); +} + +} // namespace esphome::testing diff --git a/tests/components/tmp1075/__init__.py b/tests/components/tmp1075/__init__.py new file mode 100644 index 00000000000..e2b696575b5 --- /dev/null +++ b/tests/components/tmp1075/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # tmp1075 declares i2c on its sensor platform, which the harness does not walk + manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] diff --git a/tests/components/tmp1075/test_config_register.cpp b/tests/components/tmp1075/test_config_register.cpp new file mode 100644 index 00000000000..1258d855d2a --- /dev/null +++ b/tests/components/tmp1075/test_config_register.cpp @@ -0,0 +1,83 @@ +#include + +#include "esphome/components/tmp1075/tmp1075.h" + +namespace esphome::tmp1075::testing { + +// Upper byte of CFGR per the TMP1075 datasheet: OS R1 R0 F1 F0 POL TM SD + +TEST(TMP1075Config, DefaultsAreZero) { EXPECT_EQ(TMP1075Config{}.to_byte(), 0x00); } + +TEST(TMP1075Config, EachFieldLandsOnItsDatasheetBit) { + TMP1075Config cfg{}; + cfg.shutdown = true; + EXPECT_EQ(cfg.to_byte(), 0x01); + + cfg = {}; + cfg.alert_mode = true; + EXPECT_EQ(cfg.to_byte(), 0x02); + + cfg = {}; + cfg.polarity = true; + EXPECT_EQ(cfg.to_byte(), 0x04); + + cfg = {}; + cfg.faults = 3; + EXPECT_EQ(cfg.to_byte(), 0x18); + + cfg = {}; + cfg.rate = 3; + EXPECT_EQ(cfg.to_byte(), 0x60); + + cfg = {}; + cfg.oneshot = true; + EXPECT_EQ(cfg.to_byte(), 0x80); +} + +TEST(TMP1075Config, ActiveHighComparatorAt220ms) { + // The configuration from issue 21381: 220 ms, 1 fault, active high, comparator + TMP1075Config cfg{}; + cfg.rate = 3; + cfg.polarity = true; + EXPECT_EQ(cfg.to_byte(), 0x64); +} + +// Records what the driver writes to the configuration register +class CaptureBus : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + if (read_count != 0) { + for (size_t i = 0; i < read_count; i++) { + read_buffer[i] = 0; + } + return i2c::ERROR_OK; + } + if (write_count == 2 && write_buffer[0] == 0x01) { + this->cfgr = write_buffer[1]; + this->cfgr_writes++; + } + return i2c::ERROR_OK; + } + + uint8_t cfgr{0xFF}; + int cfgr_writes{0}; +}; + +TEST(TMP1075Sensor, WriteConfigSendsDatasheetLayout) { + CaptureBus bus; + TMP1075Sensor sensor; + sensor.set_i2c_bus(&bus); + sensor.set_i2c_address(0x48); + + sensor.set_conversion_rate(CONV_RATE_220_MS); + sensor.set_fault_count(1); + sensor.set_alert_polarity(true); + sensor.set_alert_function(ALERT_COMPARATOR); + sensor.write_config(); + + EXPECT_EQ(bus.cfgr_writes, 1); + EXPECT_EQ(bus.cfgr, 0x64); +} + +} // namespace esphome::tmp1075::testing diff --git a/tests/components/unicode/test_unicode_host.cpp b/tests/components/unicode/test_unicode_host.cpp new file mode 100644 index 00000000000..abee0e4bc70 --- /dev/null +++ b/tests/components/unicode/test_unicode_host.cpp @@ -0,0 +1,75 @@ +#include "esphome/components/unicode/unicode.h" + +#include + +#include +#include + +namespace esphome::unicode::testing { + +struct Decoded { + uint32_t code_point; + size_t length; +}; + +// Decodes the first sequence of text from an allocation of exactly its size, so a read past the +// terminator is a heap overflow the sanitizer reports instead of a silent pass +static Decoded decode(const char *text) { + const size_t size = std::strlen(text) + 1; + auto buffer = std::make_unique(size); + std::memcpy(buffer.get(), text, size); + Decoded result{}; + result.code_point = extract_unicode_codepoint(buffer.get(), &result.length); + return result; +} + +TEST(Unicode, DecodesEachSequenceLength) { + EXPECT_EQ(decode("A").code_point, 0x41u); + EXPECT_EQ(decode("A").length, 1u); + EXPECT_EQ(decode("\xC3\xA9").code_point, 0xE9u); // é + EXPECT_EQ(decode("\xC3\xA9").length, 2u); + EXPECT_EQ(decode("\xE2\x82\xAC").code_point, 0x20ACu); // € + EXPECT_EQ(decode("\xE2\x82\xAC").length, 3u); + EXPECT_EQ(decode("\xF0\x9F\x98\x80").code_point, 0x1F600u); // 😀 + EXPECT_EQ(decode("\xF0\x9F\x98\x80").length, 4u); +} + +TEST(Unicode, AcceptsTheEdgeOfEachRange) { + // The smallest value of each length, both neighbours of the surrogate range and the last code point + EXPECT_EQ(decode("\xC2\x80").code_point, 0x80u); + EXPECT_EQ(decode("\xC2\x80").length, 2u); + EXPECT_EQ(decode("\xE0\xA0\x80").code_point, 0x800u); + EXPECT_EQ(decode("\xE0\xA0\x80").length, 3u); + EXPECT_EQ(decode("\xED\x9F\xBF").code_point, 0xD7FFu); + EXPECT_EQ(decode("\xEE\x80\x80").code_point, 0xE000u); + EXPECT_EQ(decode("\xF0\x90\x80\x80").code_point, 0x10000u); + EXPECT_EQ(decode("\xF0\x90\x80\x80").length, 4u); + EXPECT_EQ(decode("\xF4\x8F\xBF\xBF").code_point, 0x10FFFFu); + EXPECT_EQ(decode("\xF4\x8F\xBF\xBF").length, 4u); +} + +TEST(Unicode, EmptyStringGivesNothing) { + EXPECT_EQ(decode("").code_point, 0u); + EXPECT_EQ(decode("").length, 0u); +} + +TEST(Unicode, TruncatedSequenceStopsAtTheTerminator) { + for (const char *text : {"\xC3", "\xE2\x82", "\xE2", "\xF0\x9F\x98", "\xF0\x9F", "\xF0"}) { + EXPECT_EQ(decode(text).code_point, 0u) << text; + EXPECT_EQ(decode(text).length, 0u) << text; + } +} + +TEST(Unicode, RejectsMalformedSequences) { + EXPECT_EQ(decode("\x80").length, 0u); // lone continuation byte + EXPECT_EQ(decode("\xF8\x88\x80\x80\x80").length, 0u); // 5-byte lead + EXPECT_EQ(decode("\xC0\xAF").length, 0u); // overlong '/' + EXPECT_EQ(decode("\xE0\x80\xAF").length, 0u); // overlong '/' + EXPECT_EQ(decode("\xF0\x80\x80\xAF").length, 0u); // overlong '/' + EXPECT_EQ(decode("\xED\xA0\x80").length, 0u); // surrogate U+D800 + EXPECT_EQ(decode("\xF4\x90\x80\x80").length, 0u); // U+110000, past the last code point + EXPECT_EQ(decode("\xF5\x80\x80\x80").length, 0u); // lead byte past U+10FFFF + EXPECT_EQ(decode("\xC3\x41").length, 0u); // second byte is not a continuation byte +} + +} // namespace esphome::unicode::testing diff --git a/tests/components/veml6040/common.yaml b/tests/components/veml6040/common.yaml new file mode 100644 index 00000000000..c563a3a58ba --- /dev/null +++ b/tests/components/veml6040/common.yaml @@ -0,0 +1,18 @@ +sensor: + - platform: veml6040 + i2c_id: i2c_bus + integration_time: 160ms + glass_attenuation_factor: 1.2 + color_temperature_offset: 0.42 + red_channel: + name: VEML6040 Red + green_channel: + name: VEML6040 Green + blue_channel: + name: VEML6040 Blue + white_channel: + name: VEML6040 White + illuminance: + name: VEML6040 Illuminance + color_temperature: + name: VEML6040 Color Temperature diff --git a/tests/components/veml6040/test.esp32-idf.yaml b/tests/components/veml6040/test.esp32-idf.yaml new file mode 100644 index 00000000000..e454bc981fc --- /dev/null +++ b/tests/components/veml6040/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + veml6040: !include common.yaml diff --git a/tests/components/veml6040/test.esp8266-ard.yaml b/tests/components/veml6040/test.esp8266-ard.yaml new file mode 100644 index 00000000000..46e499faabd --- /dev/null +++ b/tests/components/veml6040/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + veml6040: !include common.yaml diff --git a/tests/components/veml6040/test.rp2040-ard.yaml b/tests/components/veml6040/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1c60e875d0b --- /dev/null +++ b/tests/components/veml6040/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + veml6040: !include common.yaml diff --git a/tests/components/vnc/__init__.py b/tests/components/vnc/__init__.py new file mode 100644 index 00000000000..64d80b5cd01 --- /dev/null +++ b/tests/components/vnc/__init__.py @@ -0,0 +1,15 @@ +# This file's presence makes pytest treat this directory as a package named +# "vnc"; required for cpp unit testing. +from esphome.components import socket as socket_component +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # MULTI_CONF components get no config in a unit test build, so to_code would never run. + manifest.multi_conf = False + + async def to_code_testing(config: ConfigType) -> None: + socket_component.require_tcp_client_link() + + manifest.to_code = to_code_testing diff --git a/tests/components/vnc/common.yaml b/tests/components/vnc/common.yaml new file mode 100644 index 00000000000..1181b6956a0 --- /dev/null +++ b/tests/components/vnc/common.yaml @@ -0,0 +1,27 @@ +vnc: + - id: vnc_client + display_id: test_display_screen + host: 192.0.2.10 + port: 5901 + reconnect_interval: 10s + update_delay: 300ms + max_update_delay: 2s + on_connect: + - logger.log: VNC connected + on_disconnect: + - logger.log: VNC disconnected + +interval: + - interval: 60s + then: + - if: + condition: + vnc.is_connected: vnc_client + then: + - logger.log: VNC is connected + - if: + condition: + vnc.is_connected: + id: vnc_client + then: + - logger.log: VNC is still connected diff --git a/tests/components/vnc/test.bk72xx-ard.yaml b/tests/components/vnc/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..667e7cd8721 --- /dev/null +++ b/tests/components/vnc/test.bk72xx-ard.yaml @@ -0,0 +1,7 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + vnc: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/vnc/test.esp32-idf.yaml b/tests/components/vnc/test.esp32-idf.yaml new file mode 100644 index 00000000000..667e7cd8721 --- /dev/null +++ b/tests/components/vnc/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + vnc: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/vnc/test.esp8266-ard.yaml b/tests/components/vnc/test.esp8266-ard.yaml new file mode 100644 index 00000000000..667e7cd8721 --- /dev/null +++ b/tests/components/vnc/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + vnc: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/vnc/test.host.yaml b/tests/components/vnc/test.host.yaml new file mode 100644 index 00000000000..0b83205a634 --- /dev/null +++ b/tests/components/vnc/test.host.yaml @@ -0,0 +1,3 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + vnc: !include common.yaml diff --git a/tests/components/vnc/test.rp2040-ard.yaml b/tests/components/vnc/test.rp2040-ard.yaml new file mode 100644 index 00000000000..667e7cd8721 --- /dev/null +++ b/tests/components/vnc/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + vnc: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/vnc/test_backoff_host.cpp b/tests/components/vnc/test_backoff_host.cpp new file mode 100644 index 00000000000..5bf17ae8e8a --- /dev/null +++ b/tests/components/vnc/test_backoff_host.cpp @@ -0,0 +1,164 @@ +#include "vnc_test_helpers.h" + +#ifdef USE_HOST + +namespace esphome::vnc::testing { + +/// Runs one pass at time t, then lets an asynchronous connect finish before time moves on. +static void settle_at(Session &s, uint32_t t) { + set_now(t); + s.loop(); + for (int i = 0; i < 10000 && s.client.link().connecting(); i++) { + usleep(100); + s.loop(); + } +} + +/// Times at which refused connect attempts ended, starting at the current time. +static std::vector refused_attempts(Session &s, size_t count, uint32_t step = 100) { + std::vector times; + uint32_t last_start = s.client.hold_start_ms(); + const uint32_t start = now_ms(); + for (uint32_t t = start; times.size() < count && t - start < 3600000; t += step) { + settle_at(s, t); + if (s.client.hold_start_ms() != last_start) { + last_start = s.client.hold_start_ms(); + times.push_back(t); + } + } + return times; +} + +static std::vector gaps(const std::vector ×) { + std::vector out; + for (size_t i = 1; i < times.size(); i++) { + out.push_back(times[i] - times[i - 1]); + } + return out; +} + +TEST(VncBackoff, RefusedConnectsBackOff) { + FakeDisplay d; + Session s(&d); + auto times = refused_attempts(s, 7); + ASSERT_EQ(times.size(), 7u); + EXPECT_EQ(times[0], 1000u); + EXPECT_EQ(gaps(times), std::vector({5000, 10000, 20000, 40000, 60000, 60000})); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_TRUE(s.client.status_has_warning()); + EXPECT_FALSE(s.client.status_has_error()); +} + +TEST(VncBackoff, ShortReconnectInterval) { + FakeDisplay d; + Session s(&d); + s.client.set_reconnect_interval(1000); + auto times = refused_attempts(s, 9); + EXPECT_EQ(gaps(times), std::vector({1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000})); +} + +TEST(VncBackoff, ReconnectIntervalAboveTheCap) { + FakeDisplay d; + Session s(&d); + s.client.set_reconnect_interval(90000); + auto times = refused_attempts(s, 4, 1000); + EXPECT_EQ(gaps(times), std::vector({90000, 90000, 90000})); +} + +TEST(VncBackoff, StableSessionResetsBackoff) { + FakeDisplay d; + Session s(&d); + auto times = refused_attempts(s, 3); + ASSERT_EQ(s.client.backoff_ms(), 20000u); + + set_now(times.back() + 1000); + s.start(64, 48); + s.run_for(60000, 1000); + ASSERT_TRUE(s.client.is_connected()); + int disconnects = 0; + s.client.add_on_disconnect_callback([&disconnects]() { disconnects++; }); + s.close_peer(); + s.loop(); + EXPECT_EQ(disconnects, 1); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.backoff_ms(), 0u); + EXPECT_EQ(s.client.hold_ms(), 0u); + + const uint32_t dropped_at = now_ms(); + auto after = refused_attempts(s, 2); + ASSERT_EQ(after.size(), 2u); + // The link's own interval spaces the first retry; the backoff then starts over. + EXPECT_EQ(after[0] - dropped_at, 5000u); + EXPECT_EQ(after[1] - after[0], 5000u); +} + +TEST(VncBackoff, ShortSessionKeepsEscalating) { + FakeDisplay d; + Session s(&d); + auto times = refused_attempts(s, 3); + ASSERT_EQ(s.client.backoff_ms(), 20000u); + + set_now(times.back() + 1000); + s.start(64, 48); + s.run_for(10000, 1000); + s.close_peer(); + s.loop(); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 40000u); +} + +TEST(VncBackoff, PeerCloseWhileRunningRetries) { + FakeDisplay d; + Session s(&d); + int disconnects = 0; + s.client.add_on_disconnect_callback([&disconnects]() { disconnects++; }); + s.start(64, 48); + s.close_peer(); + s.loop(); + EXPECT_FALSE(s.client.is_connected()); + EXPECT_EQ(disconnects, 1); + EXPECT_EQ(s.client.hold_ms(), 5000u); + EXPECT_TRUE(s.client.status_has_warning()); + const uint32_t dropped_at = now_ms(); + auto after = refused_attempts(s, 1); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0] - dropped_at, 5000u); +} + +TEST(VncBackoff, NextSessionStartsClean) { + FakeDisplay d; + Session s(&d); + s.start(64, 48); + set_now(now_ms() + 100); + // Leave a frame half parsed and a refresh pending, then lose the link. + s.deliver(Rfb().fbu(1).raw_rect(0, 0, 2, 1, {1, 2})); + s.deliver(Rfb().fbu(1).rect(0, 0, 4, 4, 0).u8(0x02)); + ASSERT_TRUE(s.client.in_update()); + s.close_peer(); + s.loop(); + EXPECT_FALSE(s.client.in_update()); + EXPECT_FALSE(s.client.refresh_pending()); + + s.attach(); + EXPECT_EQ(s.client.step(), VncStep::VNC_STEP_VERSION); + EXPECT_EQ(s.client.rx_buffered(), 0); + s.deliver(handshake_38(64, 48)); + ASSERT_TRUE(s.client.is_connected()); + s.take(); + set_now(now_ms() + 100); + s.deliver(Rfb().fbu(1).raw_rect(3, 3, 1, 1, {0x7777})); + EXPECT_EQ(d.pixel(3, 3), expect_px(0x7777, 2)); + EXPECT_EQ(hex(s.take()), hex(fbur(true, 64, 48))); +} + +TEST(VncBackoff, ShutdownClosesTheLink) { + FakeDisplay d; + Session s(&d); + s.start(64, 48); + s.client.on_shutdown(); + EXPECT_FALSE(s.client.link().connected()); +} + +} // namespace esphome::vnc::testing + +#endif // USE_HOST diff --git a/tests/components/vnc/test_decode_host.cpp b/tests/components/vnc/test_decode_host.cpp new file mode 100644 index 00000000000..df65737e905 --- /dev/null +++ b/tests/components/vnc/test_decode_host.cpp @@ -0,0 +1,586 @@ +#include "vnc_test_helpers.h" + +#include + +#ifdef USE_HOST + +namespace esphome::vnc::testing { + +static constexpr int32_t RAW = 0; + +static std::vector pattern(size_t count, uint16_t seed) { + std::vector out(count); + for (size_t i = 0; i < count; i++) { + out[i] = static_cast(seed + i * 0x0841u); + } + return out; +} + +/// Checks that a w x h block at x,y shows the given wire pixels, row-major. +static void expect_block(const FakeDisplay &d, int x, int y, int w, int h, const std::vector &px, + uint8_t bpp) { + for (int row = 0; row < h; row++) { + for (int col = 0; col < w; col++) { + ASSERT_EQ(d.pixel(x + col, y + row), expect_px(px[row * w + col], bpp)) << "at " << x + col << "," << y + row; + } + } +} + +static void expect_solid(const FakeDisplay &d, int x, int y, int w, int h, uint16_t px, uint8_t bpp) { + expect_block(d, x, y, w, h, std::vector(static_cast(w) * h, px), bpp); +} + +class VncDecode : public ::testing::Test { + protected: + void start(FakeDisplay &display, uint16_t fb_w, uint16_t fb_h) { + this->session_ = std::make_unique(&display); + this->session_->start(fb_w, fb_h); + // Past the 50 ms request spacing, so the next request goes out as soon as a frame ends. + set_now(now_ms() + 100); + } + Session &s() { return *this->session_; } + std::unique_ptr session_; +}; + +TEST_F(VncDecode, Raw16bpp) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + auto px = pattern(12, 0x1234); + s().deliver(Rfb().fbu(1).raw_rect(3, 2, 4, 3, px)); + expect_block(d, 3, 2, 4, 3, px, 2); + EXPECT_EQ(d.drawn_count(), 12u); + EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48))); + EXPECT_EQ(d.updates(), 0); + s().run_for(300); + EXPECT_EQ(d.updates(), 1); +} + +TEST_F(VncDecode, Raw8bpp) { + FakeDisplay d(64, 48, display::DISPLAY_TYPE_BINARY); + this->start(d, 64, 48); + std::vector px = {0x00, 0xFF, 0xE0, 0x1C, 0x03, 0x92, 0x49, 0x24, 0xB6, 0x6D}; + s().deliver(Rfb().fbu(1).raw_rect(10, 20, 5, 2, px, 1)); + expect_block(d, 10, 20, 5, 2, px, 1); + EXPECT_EQ(d.drawn_count(), 10u); + EXPECT_TRUE(s().client.is_connected()); +} + +TEST_F(VncDecode, RawSeveralRectsInOneUpdate) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + auto a = pattern(6, 0x0101); + auto b = pattern(4, 0xF00F); + s().deliver(Rfb().fbu(2).raw_rect(0, 0, 3, 2, a).raw_rect(60, 46, 4, 1, b)); + expect_block(d, 0, 0, 3, 2, a, 2); + expect_block(d, 60, 46, 4, 1, b, 2); + EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48))); +} + +TEST_F(VncDecode, EmptyUpdateRequestsAgainWithoutRefresh) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + s().deliver(Rfb().fbu(0)); + EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48))); + s().run_for(2000); + EXPECT_EQ(d.updates(), 0); +} + +TEST_F(VncDecode, RichCursorIsSkippedAndNeverRefreshes) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + s().deliver(Rfb().fbu(1).cursor(3, 4, 5, 4)); + EXPECT_EQ(d.draw_calls(), 0); + EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48))); + s().run_for(3000); + EXPECT_EQ(d.updates(), 0); + EXPECT_TRUE(s().client.is_connected()); +} + +class VncCursorSkip : public ::testing::TestWithParam {}; + +TEST_P(VncCursorSkip, PayloadLengthMatchesThePixelFormat) { + FakeDisplay d(64, 48, GetParam()); + Session s(&d); + s.start(64, 48); + const uint8_t bpp = s.client.bpp(); + // 9 wide needs a 2-byte mask row; the raw rect after it only lands right if the skip was exact. + s.deliver(Rfb().fbu(3).cursor(0, 0, 9, 3, bpp).raw_rect(7, 7, 2, 1, {0x00E0, 0x001C}, bpp).cursor(1, 1, 0, 0, bpp)); + EXPECT_EQ(d.pixel(7, 7), expect_px(0x00E0, bpp)); + EXPECT_EQ(d.pixel(8, 7), expect_px(0x001C, bpp)); + EXPECT_EQ(d.drawn_count(), 2u); + EXPECT_TRUE(s.client.is_connected()); + EXPECT_FALSE(s.client.in_update()); +} + +INSTANTIATE_TEST_SUITE_P(Formats, VncCursorSkip, + ::testing::Values(display::DISPLAY_TYPE_COLOR, display::DISPLAY_TYPE_BINARY)); + +TEST_F(VncDecode, ZeroSizeCursorEndsTheFrame) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + s().deliver(Rfb().fbu(1).cursor(0, 0, 0, 0)); + EXPECT_FALSE(s().client.in_update()); + EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48))); +} + +TEST_F(VncDecode, OversizedCursorReconnects) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + s().deliver(Rfb().fbu(1).rect(0, 0, 1025, 1, -239)); + EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE); +} + +TEST_F(VncDecode, LargeCutTextIsSkipped) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + s().deliver(Rfb().cut_text(200 * 1024).fbu(1).raw_rect(1, 1, 1, 1, {0x4321})); + EXPECT_TRUE(s().client.is_connected()); + EXPECT_EQ(d.pixel(1, 1), expect_px(0x4321, 2)); + EXPECT_EQ(d.drawn_count(), 1u); +} + +TEST_F(VncDecode, BellAndColourMapAreSkipped) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + s().deliver(Rfb().bell().colour_map(5, 3).bell().colour_map(0, 0).fbu(1).raw_rect(2, 2, 1, 1, {0x0F0F})); + EXPECT_TRUE(s().client.is_connected()); + EXPECT_EQ(d.pixel(2, 2), expect_px(0x0F0F, 2)); + EXPECT_EQ(d.drawn_count(), 1u); +} + +TEST(VncDecodeClip, ServerLargerThanDisplay) { + FakeDisplay d(64, 48); + Session s(&d); + s.attach(); + s.deliver(handshake_38(100, 80)); + ASSERT_TRUE(s.client.is_connected()); + Bytes tx = s.take(); + EXPECT_EQ(hex(Bytes(tx.end() - 10, tx.end())), hex(fbur(false, 64, 48))); + set_now(now_ms() + 100); + + auto raw = pattern(90 * 70, 0x0021); + Rfb f; + f.fbu(3).raw_rect(5, 5, 90, 70, raw); + // A raw rect straddling both edges, and one entirely off screen. + f.raw_rect(56, 40, 20, 20, std::vector(20 * 20, 0x5000)); + f.raw_rect(70, 50, 16, 16, pattern(256, 1)); + s.deliver(f); + EXPECT_EQ(d.out_of_bounds(), 0); + for (int y = 5; y < 40; y++) { + for (int x = 5; x < 56; x++) { + ASSERT_EQ(d.pixel(x, y), expect_px(raw[(y - 5) * 90 + (x - 5)], 2)) << x << "," << y; + } + } + expect_solid(d, 56, 40, 8, 8, 0x5000, 2); + EXPECT_EQ(d.pixel(4, 4), UNSET); + EXPECT_TRUE(s.client.is_connected()); + EXPECT_EQ(hex(s.take()), hex(fbur(true, 64, 48))); +} + +TEST(VncDecodeClip, ServerSmallerThanDisplay) { + FakeDisplay d(64, 48); + Session s(&d); + s.start(20, 10); + EXPECT_EQ(s.client.vis_w(), 20); + EXPECT_EQ(s.client.vis_h(), 10); + s.deliver(Rfb().fbu(1).raw_rect(19, 9, 1, 1, {0x1111})); + EXPECT_EQ(d.pixel(19, 9), expect_px(0x1111, 2)); + s.deliver(Rfb().fbu(1).raw_rect(20, 0, 1, 1, {0x1111})); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); +} + +TEST_F(VncDecode, UnknownMessageTypeReconnects) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + int disconnects = 0; + s().client.add_on_disconnect_callback([&disconnects]() { disconnects++; }); + s().deliver(Rfb().u8(7)); + EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE); + EXPECT_FALSE(s().client.link().connected()); + EXPECT_EQ(s().client.hold_ms(), 5000u); + EXPECT_EQ(disconnects, 1); +} + +TEST_F(VncDecode, UnsupportedEncodingReconnects) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + // CopyRect, which the client never asked for. + s().deliver(Rfb().fbu(1).rect(0, 0, 4, 4, 1).u16(0).u16(0)); + EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE); +} + +struct OutsideCase { + uint16_t x, y, w, h; + int32_t encoding; +}; + +class VncRectOutside : public ::testing::TestWithParam {}; + +TEST_P(VncRectOutside, Reconnects) { + FakeDisplay d(64, 48); + Session s(&d); + s.start(64, 48); + const auto &c = GetParam(); + s.deliver(Rfb().fbu(1).rect(c.x, c.y, c.w, c.h, c.encoding).fill(64, 0)); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(d.draw_calls(), 0); +} + +INSTANTIATE_TEST_SUITE_P(Rects, VncRectOutside, + ::testing::Values(OutsideCase{60, 0, 5, 1, RAW}, OutsideCase{0, 47, 1, 2, RAW}, + OutsideCase{65535, 0, 2, 1, RAW}, OutsideCase{0, 65535, 1, 2, RAW}, + OutsideCase{64, 0, 1, 1, RAW}, OutsideCase{0, 0, 65, 1, RAW})); + +// --- A frame whose last step needs no bytes must still finish when its last byte arrives. + +class VncZeroByteEnd : public VncDecode { + protected: + void deliver_bytewise(const Bytes &stream, size_t frame_end) { + for (size_t i = 0; i < stream.size(); i++) { + s().deliver(Bytes{stream[i]}); + Bytes tx = s().take(); + if (i + 1 == frame_end) { + EXPECT_EQ(hex(tx), hex(fbur(true, 64, 48))) << "no request after the final byte"; + } else { + EXPECT_EQ(tx.size(), 0u) << "unexpected bytes after byte " << i; + } + } + EXPECT_FALSE(s().client.in_update()); + EXPECT_EQ(s().client.step(), VncStep::VNC_STEP_MESSAGE); + EXPECT_EQ(s().client.rx_buffered(), 0); + } +}; + +TEST_F(VncZeroByteEnd, ZeroSizeCursor) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + Rfb f; + f.fbu(2).raw_rect(0, 0, 2, 1, {0x7001, 0x7002}).cursor(4, 4, 0, 0); + this->deliver_bytewise(f.bytes, f.bytes.size()); + s().run_for(300); + EXPECT_EQ(d.updates(), 1); +} + +TEST_F(VncZeroByteEnd, ZeroSizeRawRect) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + Rfb f; + f.fbu(2).raw_rect(0, 0, 1, 1, {0x7001}).rect(64, 48, 0, 0, RAW); + this->deliver_bytewise(f.bytes, f.bytes.size()); + s().run_for(300); + EXPECT_EQ(d.updates(), 1); +} + +TEST_F(VncZeroByteEnd, EmptyCutTextAfterFrame) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + Rfb f; + f.fbu(1).raw_rect(0, 0, 1, 1, {0x7001}); + const size_t frame_end = f.bytes.size(); + f.cut_text(0).colour_map(0, 0); + this->deliver_bytewise(f.bytes, frame_end); + s().run_for(300); + EXPECT_EQ(d.updates(), 1); + s().deliver(Rfb().fbu(1).raw_rect(1, 0, 1, 1, {0x7002})); + EXPECT_EQ(d.pixel(1, 0), expect_px(0x7002, 2)); +} + +// --- The 20 ms budget: with a slow display, one row per pass. + +TEST_F(VncDecode, BudgetPausesBetweenRectsAndFinishesOnTheLastOne) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + d.set_draw_delay_us(25000); + Rfb f; + f.fbu(3) + .raw_rect(0, 0, 16, 1, std::vector(16, 0x0A0A)) + .raw_rect(16, 0, 16, 1, std::vector(16, 0x0B0B)) + .raw_rect(32, 0, 16, 1, std::vector(16, 0x0C0C)); + s().send(f); + s().loop(); + EXPECT_EQ(d.draw_calls(), 1); + EXPECT_TRUE(s().client.in_update()); + EXPECT_TRUE(s().client.rx_pending()); + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); + s().loop(); + EXPECT_EQ(d.draw_calls(), 2); + s().loop(); + EXPECT_EQ(d.draw_calls(), 3); + EXPECT_FALSE(s().client.in_update()); + EXPECT_EQ(hex(s().take()), hex(fbur(true, 64, 48))); + d.set_draw_delay_us(0); + s().loop(2); + EXPECT_FALSE(s().client.rx_pending()); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + expect_solid(d, 32, 0, 16, 1, 0x0C0C, 2); +} + +TEST_F(VncDecode, BudgetPausesInsideARawRect) { + FakeDisplay d(64, 48); + this->start(d, 64, 48); + d.set_draw_delay_us(25000); + auto px = pattern(4 * 3, 0x2000); + s().send(Rfb().fbu(1).raw_rect(0, 0, 4, 3, px)); + s().loop(); + EXPECT_EQ(d.draw_calls(), 1); + EXPECT_TRUE(s().client.in_update()); + s().loop(2); + EXPECT_EQ(d.draw_calls(), 3); + EXPECT_FALSE(s().client.in_update()); + d.set_draw_delay_us(0); + expect_block(d, 0, 0, 4, 3, px, 2); +} + +// --- Split-feed property: however the bytes are cut, the result is the same and matches a model. + +struct Model { + int fb_w{0}; + int fb_h{0}; + uint8_t bpp{2}; + std::vector px; + void set(int x, int y, uint16_t v) { this->px[static_cast(y) * this->fb_w + x] = v; } +}; + +struct Generated { + int disp_w{0}; + int disp_h{0}; + display::DisplayType type{display::DISPLAY_TYPE_COLOR}; + uint16_t vis_w{0}; + uint16_t vis_h{0}; + Bytes handshake; + Bytes handshake_tx; + std::vector frames; + Model model; +}; + +static Generated generate(uint32_t seed) { + std::mt19937 rng(seed); + auto rnd = [&rng](int lo, int hi) { return std::uniform_int_distribution(lo, hi)(rng); }; + Generated g; + g.disp_w = rnd(4, 70); + g.disp_h = rnd(4, 50); + const int type = rnd(0, 2); + g.type = type == 0 ? display::DISPLAY_TYPE_COLOR + : (type == 1 ? display::DISPLAY_TYPE_GRAYSCALE : display::DISPLAY_TYPE_BINARY); + const uint8_t bpp = g.type == display::DISPLAY_TYPE_BINARY ? 1 : 2; + const int fb_w = rnd(1, 90); + const int fb_h = rnd(1, 64); + g.vis_w = std::min(fb_w, g.disp_w); + g.vis_h = std::min(fb_h, g.disp_h); + g.model.fb_w = fb_w; + g.model.fb_h = fb_h; + g.model.bpp = bpp; + g.model.px.assign(static_cast(fb_w) * fb_h, -1); + auto pixel = [&]() { return static_cast(bpp == 2 ? rnd(0, 0xFFFF) : rnd(0, 0xFF)); }; + + Rfb hs; + switch (rnd(0, 2)) { + case 0: + hs.version("RFB 003.003\n").security_33(1); + g.handshake_tx = cat({version_reply('3'), {1}}); + break; + case 1: + hs.version("RFB 003.007\n").security_list({1}); + g.handshake_tx = cat({version_reply('7'), {1}, {1}}); + break; + default: + hs.version().security_list({16, 1}).security_result(0); + g.handshake_tx = cat({version_reply('8'), {1}, {1}}); + break; + } + hs.server_init(fb_w, fb_h, std::string(rnd(0, 80), 'n')); + g.handshake = hs.bytes; + g.handshake_tx = cat({g.handshake_tx, bpp == 2 ? set_pixel_format_565() : set_pixel_format_332(), set_encodings(), + fbur(false, g.vis_w, g.vis_h)}); + + const int frames = rnd(3, 8); + for (int frame = 0; frame < frames; frame++) { + Rfb f; + for (int extra = rnd(0, 2); extra > 0; extra--) { + switch (rnd(0, 2)) { + case 0: + f.bell(); + break; + case 1: + f.cut_text(rnd(0, 700)); + break; + default: + f.colour_map(rnd(0, 255), rnd(0, 4)); + break; + } + } + const int rects = rnd(0, 5); + f.fbu(rects); + for (int r = 0; r < rects; r++) { + const int kind = rnd(0, 9); + if (kind == 8) { + f.cursor(rnd(0, 100), rnd(0, 100), rnd(0, 9), rnd(0, 9), bpp); + continue; + } + if (kind == 9) { + const int x = rnd(0, fb_w); + const int y = rnd(0, fb_h); + f.rect(x, y, rnd(0, 1) ? 0 : rnd(0, fb_w - x), 0, RAW); + continue; + } + const int x = rnd(0, fb_w - 1); + const int y = rnd(0, fb_h - 1); + const int w = rnd(1, std::min(fb_w - x, 48)); + const int h = rnd(1, std::min(fb_h - y, 40)); + f.rect(x, y, w, h, RAW); + for (int yy = 0; yy < h; yy++) { + for (int xx = 0; xx < w; xx++) { + uint16_t p = pixel(); + f.px(p, bpp); + g.model.set(x + xx, y + yy, p); + } + } + } + g.frames.push_back(f.bytes); + } + return g; +} + +static void expect_matches_model(const FakeDisplay &d, const Generated &g, uint32_t seed) { + for (int y = 0; y < g.disp_h; y++) { + for (int x = 0; x < g.disp_w; x++) { + uint32_t expected = UNSET; + if (x < g.vis_w && y < g.vis_h) { + const int32_t v = g.model.px[static_cast(y) * g.model.fb_w + x]; + if (v >= 0) { + expected = expect_px(static_cast(v), g.model.bpp); + } + } + ASSERT_EQ(d.pixel(x, y), expected) << "seed " << seed << " at " << x << "," << y; + } + } +} + +enum class Cut { WHOLE, BYTES, RANDOM }; + +static std::vector cut(const Bytes &stream, Cut how, std::mt19937 &rng) { + std::vector chunks; + size_t pos = 0; + while (pos < stream.size()) { + size_t len = stream.size() - pos; + if (how == Cut::BYTES) { + len = 1; + } else if (how == Cut::RANDOM) { + len = std::min(len, std::uniform_int_distribution(1, 700)(rng)); + } + chunks.emplace_back(stream.begin() + pos, stream.begin() + pos + len); + pos += len; + } + return chunks; +} + +struct FeedResult { + std::vector pixels; + Bytes tx; +}; + +static FeedResult run_parser(const Generated &g, Cut how, uint32_t seed) { + FakeDisplay d(g.disp_w, g.disp_h, g.type); + Session s(&d); + s.attach(); + Bytes stream = g.handshake; + for (const auto &frame : g.frames) { + stream.insert(stream.end(), frame.begin(), frame.end()); + } + std::mt19937 rng(seed * 7919u + static_cast(how)); + for (const auto &chunk : cut(stream, how, rng)) { + EXPECT_EQ(s.client.feed(chunk), VncParse::VNC_PARSE_MORE) << "seed " << seed; + } + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_RUNNING) << "seed " << seed; + EXPECT_EQ(s.client.step(), VncStep::VNC_STEP_MESSAGE) << "seed " << seed; + EXPECT_EQ(s.client.rx_buffered(), 0) << "seed " << seed; + EXPECT_FALSE(s.client.in_update()) << "seed " << seed; + EXPECT_EQ(d.out_of_bounds(), 0); + expect_matches_model(d, g, seed); + return {d.pixels(), s.take()}; +} + +TEST(VncSplitFeed, ParserGivesTheSameResultHoweverTheBytesAreCut) { + for (uint32_t seed = 1; seed <= 50; seed++) { + const Generated g = generate(seed); + const FeedResult whole = run_parser(g, Cut::WHOLE, seed); + EXPECT_EQ(hex(whole.tx), hex(g.handshake_tx)) << "seed " << seed; + const FeedResult bytes = run_parser(g, Cut::BYTES, seed); + const FeedResult random = run_parser(g, Cut::RANDOM, seed); + EXPECT_EQ(whole.pixels, bytes.pixels) << "seed " << seed; + EXPECT_EQ(whole.pixels, random.pixels) << "seed " << seed; + EXPECT_EQ(hex(whole.tx), hex(bytes.tx)) << "seed " << seed; + EXPECT_EQ(hex(whole.tx), hex(random.tx)) << "seed " << seed; + if (::testing::Test::HasFailure()) { + break; + } + } +} + +/// End to end over the socket: the server sends each frame only after the client asked for it. +static FeedResult run_socket(const Generated &g, Cut how, uint32_t seed) { + FakeDisplay d(g.disp_w, g.disp_h, g.type); + Session s(&d); + s.attach(); + s.deliver(g.handshake); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_RUNNING) << "seed " << seed; + EXPECT_EQ(hex(s.take()), hex(g.handshake_tx)) << "seed " << seed; + std::mt19937 rng(seed * 104729u + static_cast(how)); + Bytes tx; + auto collect = [&]() { + Bytes more = s.take(); + tx.insert(tx.end(), more.begin(), more.end()); + }; + auto wait_for_requests = [&](int count) { + for (int i = 0; i < 100 && count_fburs(tx, true) < count; i++) { + s.tick(10); + collect(); + } + EXPECT_EQ(count_fburs(tx, true), count) << "seed " << seed; + }; + for (size_t k = 0; k < g.frames.size(); k++) { + if (k > 0) { + wait_for_requests(static_cast(k)); + } + for (const auto &chunk : cut(g.frames[k], how, rng)) { + s.send(chunk); + for (int i = 0; i < 1000 && (s.server_bytes_waiting() || s.unread() != 0 || s.client.rx_pending()); i++) { + s.tick(1); + } + collect(); + } + } + wait_for_requests(static_cast(g.frames.size())); + s.run_for(2000); + collect(); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_RUNNING) << "seed " << seed; + EXPECT_EQ(d.out_of_bounds(), 0); + expect_matches_model(d, g, seed); + if (d.drawn_count() != 0) { + EXPECT_GE(d.updates(), 1) << "seed " << seed; + } + Bytes expected_tx; + for (size_t i = 0; i < g.frames.size(); i++) { + expected_tx = cat({expected_tx, fbur(true, g.vis_w, g.vis_h)}); + } + EXPECT_EQ(hex(tx), hex(expected_tx)) << "seed " << seed; + return {d.pixels(), tx}; +} + +TEST(VncSplitFeed, SocketGivesTheSameResultHoweverTheBytesAreCut) { + for (uint32_t seed = 101; seed <= 130; seed++) { + const Generated g = generate(seed); + const FeedResult whole = run_socket(g, Cut::WHOLE, seed); + const FeedResult bytes = run_socket(g, Cut::BYTES, seed); + const FeedResult random = run_socket(g, Cut::RANDOM, seed); + EXPECT_EQ(whole.pixels, bytes.pixels) << "seed " << seed; + EXPECT_EQ(whole.pixels, random.pixels) << "seed " << seed; + if (::testing::Test::HasFailure()) { + break; + } + } +} + +} // namespace esphome::vnc::testing + +#endif // USE_HOST diff --git a/tests/components/vnc/test_handshake_host.cpp b/tests/components/vnc/test_handshake_host.cpp new file mode 100644 index 00000000000..50f6cb98714 --- /dev/null +++ b/tests/components/vnc/test_handshake_host.cpp @@ -0,0 +1,342 @@ +#include "vnc_test_helpers.h" + +#ifdef USE_HOST + +namespace esphome::vnc::testing { + +static Bytes running_tail(const Bytes &pixel_format, uint16_t w, uint16_t h) { + return cat({pixel_format, set_encodings(), fbur(false, w, h)}); +} + +TEST(VncHandshake, Rfb38NoneExactBytes) { + FakeDisplay display(64, 48); + Session s(&display); + int connects = 0; + s.client.add_on_connect_callback([&connects]() { connects++; }); + s.attach(); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_HANDSHAKE); + + s.deliver(Rfb().version()); + EXPECT_EQ(hex(s.take()), hex(version_reply('8'))); + s.deliver(Rfb().security_list({1})); + EXPECT_EQ(hex(s.take()), hex({1})); + s.deliver(Rfb().security_result(0)); + EXPECT_EQ(hex(s.take()), hex({1})); // ClientInit, shared + EXPECT_FALSE(s.client.is_connected()); + s.deliver(Rfb().server_init(80, 40)); + // The first request covers only what the display can show. + EXPECT_EQ(hex(s.take()), hex(running_tail(set_pixel_format_565(), 64, 40))); + EXPECT_TRUE(s.client.is_connected()); + EXPECT_EQ(connects, 1); +} + +TEST(VncHandshake, Rfb38NonePipelined) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(handshake_38(32, 100)); + EXPECT_EQ(hex(s.take()), hex(cat({version_reply('8'), {1}, {1}, running_tail(set_pixel_format_565(), 32, 48)}))); + EXPECT_TRUE(s.client.is_connected()); +} + +struct PixelFormatCase { + display::DisplayType type; + bool rgb565; +}; + +class VncPixelFormatSelection : public ::testing::TestWithParam {}; + +TEST_P(VncPixelFormatSelection, SetPixelFormatMatchesTheDisplay) { + const auto ¶m = GetParam(); + FakeDisplay display(64, 48, param.type); + Session s(&display); + s.attach(); + s.deliver(handshake_38(64, 48)); + Bytes tx = s.take(); + const Bytes expected = running_tail(param.rgb565 ? set_pixel_format_565() : set_pixel_format_332(), 64, 48); + ASSERT_GE(tx.size(), expected.size()); + EXPECT_EQ(hex(Bytes(tx.end() - expected.size(), tx.end())), hex(expected)); + EXPECT_EQ(s.client.bpp(), param.rgb565 ? 2 : 1); +} + +INSTANTIATE_TEST_SUITE_P(Formats, VncPixelFormatSelection, + ::testing::Values(PixelFormatCase{display::DISPLAY_TYPE_COLOR, true}, + PixelFormatCase{display::DISPLAY_TYPE_GRAYSCALE, true}, + PixelFormatCase{display::DISPLAY_TYPE_BINARY, false})); + +TEST(VncHandshake, Rfb37NoneHasNoSecurityResult) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version("RFB 003.007\n")); + EXPECT_EQ(hex(s.take()), hex(version_reply('7'))); + s.deliver(Rfb().security_list({1})); + // Type and ClientInit go out together: 3.7 sends no SecurityResult for None. + EXPECT_EQ(hex(s.take()), hex({1, 1})); + s.deliver(Rfb().server_init(64, 48)); + EXPECT_EQ(hex(s.take()), hex(running_tail(set_pixel_format_565(), 64, 48))); + EXPECT_TRUE(s.client.is_connected()); +} + +TEST(VncHandshake, Rfb33None) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version("RFB 003.003\n")); + EXPECT_EQ(hex(s.take()), hex(version_reply('3'))); + s.deliver(Rfb().security_33(1)); + EXPECT_EQ(hex(s.take()), hex({1})); + s.deliver(Rfb().server_init(64, 48)); + EXPECT_EQ(hex(s.take()), hex(running_tail(set_pixel_format_565(), 64, 48))); + EXPECT_TRUE(s.client.is_connected()); +} + +TEST(VncHandshake, Rfb33RefusalIsTransient) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version("RFB 003.003\n").security_33(0).reason("Too many security failures")); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 5000u); + EXPECT_FALSE(s.client.status_has_error()); +} + +TEST(VncHandshake, Rfb33PasswordWithoutKeyIsConfig) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version("RFB 003.003\n")); + EXPECT_EQ(hex(s.take()), hex(version_reply('3'))); + s.deliver(Rfb().security_33(2).challenge()); + EXPECT_EQ(s.take().size(), 0u); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 300000u); + EXPECT_TRUE(s.client.status_has_error()); +} + +struct VersionCase { + const char *server; + char reply_minor; +}; + +class VncVersionMapping : public ::testing::TestWithParam {}; + +TEST_P(VncVersionMapping, RepliesWithTheMappedVersion) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(text(GetParam().server)); + EXPECT_EQ(hex(s.take()), hex(version_reply(GetParam().reply_minor))); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_HANDSHAKE); + EXPECT_EQ(s.client.step(), + GetParam().reply_minor == '3' ? VncStep::VNC_STEP_SECURITY_TYPE_33 : VncStep::VNC_STEP_SECURITY_COUNT); +} + +INSTANTIATE_TEST_SUITE_P(Versions, VncVersionMapping, + ::testing::Values(VersionCase{"RFB 003.008\n", '8'}, VersionCase{"RFB 003.889\n", '8'}, + VersionCase{"RFB 004.001\n", '8'}, VersionCase{"RFB 005.000\n", '8'}, + VersionCase{"RFB 003.007\n", '7'}, VersionCase{"RFB 003.005\n", '3'}, + VersionCase{"RFB 003.003\n", '3'})); + +class VncBadVersion : public ::testing::TestWithParam {}; + +TEST_P(VncBadVersion, IsAConfigFailureHeldFor300s) { + FakeDisplay display(64, 48); + Listener listener; + Session s(&display); + s.client.set_port(listener.port()); + s.attach(); + s.deliver(text(GetParam())); + EXPECT_EQ(s.take().size(), 0u); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 300000u); + EXPECT_TRUE(s.client.status_has_error()); + const uint32_t failed_at = now_ms(); + // No attempt while the hold runs. + while (now_ms() - failed_at < 299000) { + s.tick(1000); + } + EXPECT_EQ(listener.accepted(), 0); + EXPECT_FALSE(s.client.link().connecting()); + set_now(failed_at + 300000); + EXPECT_TRUE(s.wait_until([&]() { return listener.accepted() == 1; })); +} + +INSTANTIATE_TEST_SUITE_P(Versions, VncBadVersion, + ::testing::Values("HTTP/1.1 400", "RFB 002.000\n", "RFB 003,008\n", "RFB 0x3.008\n", + "SSH-2.0-Open")); + +TEST(VncHandshake, PasswordRequiredWithoutKeyIsConfig) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version()); + EXPECT_EQ(hex(s.take()), hex(version_reply('8'))); + s.deliver(Rfb().security_list({2, 16})); + // Never pick 16, never answer type 2 without a key. + EXPECT_EQ(s.take().size(), 0u); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 300000u); + EXPECT_TRUE(s.client.status_has_error()); + EXPECT_FALSE(s.client.link().connected()); +} + +TEST(VncHandshake, OnlyUnsupportedTypesIsConfig) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version()); + EXPECT_EQ(hex(s.take()), hex(version_reply('8'))); + s.deliver(Rfb().security_list({16})); + EXPECT_EQ(s.take().size(), 0u); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 300000u); + EXPECT_TRUE(s.client.status_has_error()); +} + +TEST(VncHandshake, EmptyListWithReasonIsTransient) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version()); + EXPECT_EQ(hex(s.take()), hex(version_reply('8'))); + s.deliver(Rfb().security_list({}).reason("Too many authentication failures")); + EXPECT_EQ(s.take().size(), 0u); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 5000u); + EXPECT_EQ(s.client.backoff_ms(), 5000u); + EXPECT_TRUE(s.client.status_has_warning()); + EXPECT_FALSE(s.client.status_has_error()); +} + +TEST(VncHandshake, EmptyReasonEndsAtOnce) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version()); + s.deliver(Rfb().security_list({}).u32(0)); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 5000u); +} + +TEST(VncHandshake, LongReasonIsCutWithoutWaitingForAllOfIt) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version()); + // Announces 100000 bytes but sends only the first 63 the client logs. + s.deliver(Rfb().security_list({}).u32(100000).fill(63, 'r')); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 5000u); +} + +TEST(VncHandshake, FullSecurityListWithoutAUsableType) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version()); + Rfb list; + list.u8(255); + for (int i = 0; i < 255; i++) { + list.u8(static_cast(i == 1 || i == 2 ? 16 : i)); + } + s.deliver(list); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 300000u); +} + +TEST(VncHandshake, DumpConfig) { + FakeDisplay display(64, 48); + Session s(&display); + s.client.dump_config(); +} + +TEST(VncHandshake, HandshakeTimeout) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + const uint32_t start = now_ms(); + s.deliver(Rfb().version()); + s.at(start + 14999); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_HANDSHAKE); + EXPECT_TRUE(s.client.link().connected()); + s.at(start + 15000); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 5000u); + EXPECT_FALSE(s.client.link().connected()); + EXPECT_FALSE(s.client.status_has_error()); +} + +TEST(VncHandshake, NoneSecurityFailureIsConfig) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version().security_list({1, 16})); + EXPECT_EQ(hex(s.take()), hex(cat({version_reply('8'), {1}}))); + s.deliver(Rfb().security_result(1).reason("Server is busy")); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 300000u); +} + +TEST(VncHandshake, EmptyServerName) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version().security_list({1}).security_result(0).server_init(64, 48, "")); + EXPECT_TRUE(s.client.is_connected()); + EXPECT_EQ(hex(s.take()), hex(cat({version_reply('8'), {1}, {1}, running_tail(set_pixel_format_565(), 64, 48)}))); +} + +TEST(VncHandshake, LongServerNameIsSkipped) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version().security_list({1}).security_result(0).server_init(64, 48, std::string(700, 'n'))); + ASSERT_TRUE(s.client.is_connected()); + s.take(); + s.deliver(Rfb().fbu(1).raw_rect(0, 0, 1, 1, {0xF800})); + EXPECT_EQ(display.pixel(0, 0), expect_px(0xF800, 2)); + EXPECT_TRUE(s.client.is_connected()); +} + +TEST(VncHandshake, RunningOnlyAfterTheWholeServerName) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + Bytes init = Rfb().version().security_list({1}).security_result(0).server_init(64, 48, std::string(700, 'n')).bytes; + s.deliver(Bytes(init.begin(), init.end() - 1)); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_HANDSHAKE); + s.deliver(Bytes(init.end() - 1, init.end())); + EXPECT_TRUE(s.client.is_connected()); +} + +TEST(VncHandshake, EmptyServerScreenIsTransient) { + FakeDisplay display(64, 48); + Session s(&display); + s.attach(); + s.deliver(Rfb().version().security_list({1}).security_result(0).server_init(0, 48)); + EXPECT_EQ(s.client.state(), VncState::VNC_STATE_IDLE); + EXPECT_EQ(s.client.hold_ms(), 5000u); +} + +TEST(VncHandshake, DisconnectCallbackOnlyAfterRunning) { + FakeDisplay display(64, 48); + Session s(&display); + int disconnects = 0; + s.client.add_on_disconnect_callback([&disconnects]() { disconnects++; }); + s.attach(); + s.deliver(Rfb().version()); + s.close_peer(); + s.loop(2); + EXPECT_EQ(disconnects, 0); + + s.start(64, 48); + s.close_peer(); + s.loop(2); + EXPECT_EQ(disconnects, 1); + EXPECT_FALSE(s.client.is_connected()); +} + +} // namespace esphome::vnc::testing + +#endif // USE_HOST diff --git a/tests/components/vnc/test_pacing_host.cpp b/tests/components/vnc/test_pacing_host.cpp new file mode 100644 index 00000000000..2fc688b636c --- /dev/null +++ b/tests/components/vnc/test_pacing_host.cpp @@ -0,0 +1,385 @@ +#include "vnc_test_helpers.h" + +#ifdef USE_HOST + +namespace esphome::vnc::testing { + +static Bytes frame(uint16_t x = 0, uint16_t pixel = 0x1234) { + return Rfb().fbu(1).raw_rect(x, 0, 2, 2, {pixel, pixel, pixel, pixel}).bytes; +} + +static const Bytes INCREMENTAL = fbur(true, 64, 48); + +// The session starts at t=1000, when the first (full) request goes out. +class VncPacing : public ::testing::Test { + protected: + void SetUp() override { + this->s_ = std::make_unique(&this->d_); + this->s_->start(64, 48); + } + Session &s() { return *this->s_; } + FakeDisplay d_; + std::unique_ptr s_; +}; + +TEST_F(VncPacing, UpdateOnlyAfterTheScreenStopsChanging) { + set_now(1100); + s().deliver(frame()); + EXPECT_EQ(d_.updates(), 0); + s().at(1299); + EXPECT_EQ(d_.updates(), 0); + s().at(1300); + EXPECT_EQ(d_.updates(), 1); + s().run_for(5000); + EXPECT_EQ(d_.updates(), 1); +} + +TEST_F(VncPacing, UpdateHasItsOwnPass) { + set_now(1100); + s().deliver(frame(0)); + s().send(frame(10, 0x4321)); + ASSERT_GT(s().unread(), 0); + s().at(1300); + EXPECT_EQ(d_.updates(), 1); + // Nothing was read in the pass that refreshed the display. + EXPECT_GT(s().unread(), 0); + EXPECT_EQ(d_.pixel(10, 0), UNSET); + s().loop(); + EXPECT_EQ(s().unread(), 0); + EXPECT_EQ(d_.pixel(10, 0), expect_px(0x4321, 2)); +} + +TEST_F(VncPacing, FastFramesPostponeTheUpdateUntilTheMaximumDelay) { + // A frame every 100 ms: never quiet for 200 ms, so only the 1 s limit forces a refresh. + std::vector update_times; + int last = 0; + for (uint32_t t = 1100; t <= 2500; t += 100) { + s().send(frame(t % 60)); + s().at(t); + if (d_.updates() != last) { + update_times.push_back(t); + last = d_.updates(); + s().loop(); + } + EXPECT_EQ(s().unread(), 0); + } + EXPECT_EQ(update_times, std::vector({2100})); + s().at(2699); + EXPECT_EQ(d_.updates(), 1); + s().at(2700); + EXPECT_EQ(d_.updates(), 2); +} + +TEST_F(VncPacing, NoUpdateInTheMiddleOfAFrameEvenPastTheMaximumDelay) { + set_now(1100); + s().deliver(frame()); + Bytes second = Rfb().fbu(1).raw_rect(0, 10, 4, 1, {1, 2, 3, 4}).bytes; + set_now(1200); + s().deliver(Bytes(second.begin(), second.end() - 3)); + ASSERT_TRUE(s().client.in_update()); + s().run_for(1400); + EXPECT_EQ(d_.updates(), 0); + s().deliver(Bytes(second.end() - 3, second.end())); + EXPECT_FALSE(s().client.in_update()); + EXPECT_EQ(d_.updates(), 0); + s().tick(10); + EXPECT_EQ(d_.updates(), 1); +} + +TEST(VncPacingDelays, CustomDelays) { + FakeDisplay d; + Session s(&d); + s.client.set_update_delay(50); + s.client.set_max_update_delay(300); + s.start(64, 48); + set_now(1100); + s.deliver(frame()); + s.at(1149); + EXPECT_EQ(d.updates(), 0); + s.at(1150); + EXPECT_EQ(d.updates(), 1); + // Frames every 40 ms: the 300 ms limit applies. + int updates = 1; + uint32_t first_forced = 0; + for (uint32_t t = 1200; t < 1700; t += 40) { + s.send(frame(t % 60)); + s.at(t); + if (d.updates() != updates && first_forced == 0) { + first_forced = t; + } + updates = d.updates(); + s.loop(); + } + EXPECT_EQ(first_forced, 1520u); +} + +TEST(VncPacingDelays, ZeroDelayRefreshesOnTheNextPass) { + FakeDisplay d; + Session s(&d); + s.client.set_update_delay(0); + s.start(64, 48); + set_now(1100); + s.deliver(frame()); + s.loop(); + EXPECT_EQ(d.updates(), 1); +} + +TEST_F(VncPacing, NextRequestRightAfterEachFrameWithA50msFloor) { + set_now(1010); + s().deliver(frame()); + EXPECT_EQ(s().take().size(), 0u); + s().at(1049); + EXPECT_EQ(s().take().size(), 0u); + s().at(1050); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); + set_now(1500); + s().deliver(frame()); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); + // One request stays outstanding; nothing more until the next frame or the heartbeat. + s().run_for(50000, 100); + EXPECT_EQ(s().take().size(), 0u); +} + +TEST_F(VncPacing, CursorOnlyFrameNeverRefreshes) { + set_now(1100); + s().deliver(Rfb().fbu(1).cursor(1, 1, 4, 4)); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); + s().run_for(5000); + EXPECT_EQ(d_.updates(), 0); +} + +TEST_F(VncPacing, HeartbeatAfterAMinuteOfSilence) { + set_now(1100); + s().deliver(frame()); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); + while (now_ms() + 1000 < 61099) { + s().tick(1000); + } + EXPECT_EQ(s().take().size(), 0u); + s().at(61099); + EXPECT_EQ(s().take().size(), 0u); + s().at(61100); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); + s().at(121099); + EXPECT_EQ(s().take().size(), 0u); + s().at(121100); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); +} + +TEST_F(VncPacing, DataResetsTheHeartbeat) { + set_now(1100); + s().deliver(frame()); + s().take(); + set_now(40000); + s().deliver(Rfb().bell()); + s().at(61100); + EXPECT_EQ(s().take().size(), 0u); + s().at(100000); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); +} + +TEST_F(VncPacing, ServerSilentMidFrameEndsTheSession) { + set_now(1100); + s().deliver(frame()); + s().take(); + Bytes partial = frame(); + partial.pop_back(); + s().deliver(partial); + ASSERT_TRUE(s().client.in_update()); + s().at(1100 + 59999); + EXPECT_EQ(s().client.state(), VncState::VNC_STATE_RUNNING); + s().at(1100 + 60000); + EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE); + EXPECT_FALSE(s().client.link().connected()); +} + +// --- A display that parks its loop when idle (it8951, epaper_spi). + +class VncPacingParked : public ::testing::Test { + protected: + void SetUp() override { + this->s_ = std::make_unique(&this->d_); + this->s_->start(64, 48); + } + Session &s() { return *this->s_; } + FakeParkingDisplay d_; + std::unique_ptr s_; +}; + +TEST_F(VncPacingParked, BusyDisplayHoldsDrawsAndRequests) { + set_now(1100); + s().deliver(frame()); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); + s().at(1300); + ASSERT_EQ(d_.updates(), 1); + ASSERT_TRUE(d_.busy()); + + // The server answers the outstanding request while the panel refreshes. + s().send(frame(20, 0x5555)); + s().at(1310); + EXPECT_TRUE(s().client.display_busy()); + EXPECT_TRUE(s().client.in_update()); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + s().run_for(3000); + EXPECT_EQ(d_.draws_while_busy(), 0); + EXPECT_EQ(d_.pixel(20, 0), UNSET); + EXPECT_EQ(s().take().size(), 0u); + + d_.finish(); + s().tick(10); + EXPECT_EQ(d_.pixel(20, 0), expect_px(0x5555, 2)); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); + s().run_for(300); + EXPECT_EQ(d_.updates(), 2); +} + +TEST(VncPacingParkedDelay, NoRequestInTheRefreshPassWithZeroDelay) { + FakeParkingDisplay d; + Session s(&d); + s.client.set_update_delay(0); + s.start(64, 48); + // The frame ends 20 ms after the request, inside the 50 ms floor, so the next request waits. + set_now(1020); + s.deliver(frame()); + EXPECT_EQ(s.take().size(), 0u); + s.at(1060); + ASSERT_EQ(d.updates(), 1); + ASSERT_TRUE(d.busy()); + EXPECT_EQ(s.take().size(), 0u) << "request sent while the panel refreshes"; + d.finish(); + s.tick(10); + EXPECT_EQ(hex(s.take()), hex(INCREMENTAL)); +} + +TEST_F(VncPacingParked, FailedDisplayEndsTheSessionAndStops) { + set_now(1100); + s().deliver(frame()); + s().take(); + s().at(1300); + ASSERT_EQ(d_.updates(), 1); + d_.mark_failed(); + s().tick(10); + EXPECT_EQ(s().client.state(), VncState::VNC_STATE_IDLE); + EXPECT_FALSE(s().client.link().connected()); + EXPECT_TRUE(s().client.status_has_error()); + EXPECT_TRUE(s().client.is_idle()); +} + +TEST_F(VncPacingParked, NoRequestWhileBusyBetweenFrames) { + // Inside the 50 ms floor, so the next request is still due when the panel turns busy. + set_now(1010); + s().deliver(Rfb().fbu(0)); + EXPECT_EQ(s().take().size(), 0u); + d_.external_update(); + s().run_for(2000); + EXPECT_EQ(s().take().size(), 0u); + d_.finish(); + s().tick(10); + EXPECT_EQ(hex(s().take()), hex(INCREMENTAL)); +} + +TEST_F(VncPacingParked, ExternalUpdateMidFrameStopsReading) { + Bytes f = Rfb().fbu(2).raw_rect(0, 0, 2, 1, {1, 2}).raw_rect(0, 5, 2, 1, {3, 4}).bytes; + set_now(1100); + s().deliver(Bytes(f.begin(), f.begin() + 4 + 12 + 4 + 6)); + ASSERT_TRUE(s().client.in_update()); + d_.external_update(); + s().send(Bytes(f.begin() + 4 + 12 + 4 + 6, f.end())); + s().run_for(1000); + EXPECT_GT(s().unread(), 0); + EXPECT_EQ(d_.draws_while_busy(), 0); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + d_.finish(); + s().tick(10); + EXPECT_EQ(s().unread(), 0); + EXPECT_EQ(d_.pixel(1, 5), expect_px(4, 2)); + EXPECT_FALSE(s().client.in_update()); +} + +// --- Displays that never report busy must never stall the client. + +template static void runs_frames_without_deadlock() { + D d; + Session s(&d); + s.start(64, 48); + for (int i = 0; i < 5; i++) { + set_now(now_ms() + 300); + s.deliver(frame(i * 4, 0x1000 + i)); + EXPECT_EQ(hex(s.take()), hex(INCREMENTAL)) << "frame " << i; + s.run_for(300); + EXPECT_EQ(d.updates(), i + 1) << "frame " << i; + EXPECT_EQ(d.pixel(i * 4, 0), expect_px(0x1000 + i, 2)); + } +} + +TEST(VncPacingDisplays, PlainDisplay) { runs_frames_without_deadlock(); } +TEST(VncPacingStart, FirstRefreshWaitsForAStartingDisplayToPark) { + FakeStartingDisplay d; + Session s(&d); + s.start(64, 48); + set_now(1100); + s.deliver(frame()); + s.at(1300); + EXPECT_EQ(d.updates(), 0); + d.finish_start(); + s.tick(10); + ASSERT_EQ(d.updates(), 1); + // Known to park now, so nothing is drawn until this refresh is over. + s.deliver(frame(10)); + EXPECT_EQ(d.draws_while_busy(), 0); + d.finish(); + s.tick(10); + EXPECT_EQ(d.pixel(10, 0), expect_px(0x1234, 2)); +} + +TEST(VncPacingStart, LoopingDisplayRefreshesAfterTheStartWindow) { + FakeLoopingDisplay d; + Session s(&d); + s.start(64, 48); + set_now(1100); + s.deliver(frame()); + s.at(1000 + 4999); + EXPECT_EQ(d.updates(), 0); + s.at(1000 + 5000); + EXPECT_EQ(d.updates(), 1); +} + +TEST(VncPacingDisplays, FreeRunningDisplay) { runs_frames_without_deadlock(); } +TEST(VncPacingDisplays, SynchronousParkedDisplay) { runs_frames_without_deadlock(); } + +TEST_F(VncPacingParked, FailedDisplayClosesAConnectStillInFlight) { + s().client.forget_session(); + ASSERT_TRUE(s().client.link().connected()); + d_.mark_failed(); + s().tick(10); + EXPECT_FALSE(s().client.link().connected()); + EXPECT_TRUE(s().client.status_has_error()); +} + +TEST_F(VncPacingParked, DisplayBusyTooLongSetsAWarningUntilItParks) { + d_.external_update(); + s().run_for(120000, 1000); + EXPECT_TRUE(s().client.status_has_warning()); + // A new session does not hide the stuck display + s().start(64, 48); + EXPECT_TRUE(s().client.status_has_warning()); + d_.finish(); + s().tick(10); + EXPECT_FALSE(s().client.status_has_warning()); +} + +TEST_F(VncPacingParked, DisplayParkingKeepsAConnectionWarning) { + d_.external_update(); + s().run_for(120000, 1000); + s().close_peer(); + s().tick(10); + ASSERT_EQ(s().client.state(), VncState::VNC_STATE_IDLE); + EXPECT_TRUE(s().client.status_has_warning()); + d_.finish(); + s().tick(10); + EXPECT_TRUE(s().client.status_has_warning()); +} + +} // namespace esphome::vnc::testing + +#endif // USE_HOST diff --git a/tests/components/vnc/validate-implicit-id.host.yaml b/tests/components/vnc/validate-implicit-id.host.yaml new file mode 100644 index 00000000000..d45cf0d635a --- /dev/null +++ b/tests/components/vnc/validate-implicit-id.host.yaml @@ -0,0 +1,13 @@ +packages: + test_display: !include ../../test_build_components/common/test_display/test_display.yaml + +vnc: + - host: 192.0.2.10 + +interval: + - interval: 60s + then: + - if: + condition: vnc.is_connected + then: + - logger.log: VNC is connected diff --git a/tests/components/vnc/validate-it8951.esp32-idf.yaml b/tests/components/vnc/validate-it8951.esp32-idf.yaml new file mode 100644 index 00000000000..e1230ea7f1a --- /dev/null +++ b/tests/components/vnc/validate-it8951.esp32-idf.yaml @@ -0,0 +1,18 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +wifi: + ssid: MySSID + password: password1 + +display: + - platform: it8951 + id: m5paper_display + model: m5stack-m5paper + cs_pin: GPIO22 + reset_pin: GPIO21 + busy_pin: GPIO27 + sleep_when_done: false + +vnc: + - host: vnc.local diff --git a/tests/components/vnc/vnc_test_helpers.h b/tests/components/vnc/vnc_test_helpers.h new file mode 100644 index 00000000000..f5cc1086247 --- /dev/null +++ b/tests/components/vnc/vnc_test_helpers.h @@ -0,0 +1,581 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/components/socket/socket.h" +#include "esphome/components/vnc/vnc_client.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +#ifdef USE_HOST + +namespace esphome::vnc::testing { + +using Bytes = std::vector; + +inline void set_now(uint32_t ms) { LoopBlockingGuard guard(nullptr, nullptr, ms); } +inline uint32_t now_ms() { return App.get_loop_component_start_time(); } + +inline std::string hex(const Bytes &bytes) { + std::string out; + char buf[4]; + for (size_t i = 0; i < bytes.size(); i++) { + snprintf(buf, sizeof(buf), i == 0 ? "%02X" : " %02X", bytes[i]); + out += buf; + } + return out; +} + +inline Bytes cat(std::initializer_list parts) { + Bytes out; + for (const auto &part : parts) { + out.insert(out.end(), part.begin(), part.end()); + } + return out; +} + +inline Bytes text(const std::string &s) { return Bytes(s.begin(), s.end()); } + +// --------------------------------------------------------------------------- +// Displays + +inline constexpr uint32_t UNSET = 0xFF000000; + +inline uint32_t pack(Color c) { return (static_cast(c.r) << 16) | (static_cast(c.g) << 8) | c.b; } + +/// The colour a wire pixel must show as, decoded the way the display API decodes it. +inline uint32_t expect_px(uint16_t wire, uint8_t bpp) { + return pack(display::ColorUtil::to_color(wire, bpp == 2 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_332)); +} + +class FakeDisplay : public display::Display { + public: + FakeDisplay(int width = 64, int height = 48, display::DisplayType type = display::DISPLAY_TYPE_COLOR) + : width_(width), height_(height), type_(type), pixels_(static_cast(width) * height, UNSET) {} + + using display::Display::draw_pixels_at; + + void update() override { this->updates_++; } + display::DisplayType get_display_type() override { return this->type_; } + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + + void draw_pixel_at(int x, int y, Color color) override { + if (x < 0 || y < 0 || x >= this->width_ || y >= this->height_) { + this->out_of_bounds_++; + ADD_FAILURE() << "pixel drawn outside the display at " << x << "," << y; + return; + } + this->pixels_[static_cast(y) * this->width_ + x] = pack(color); + } + + void draw_pixels_internal(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorBitness bitness, + bool big_endian, int x_offset, int y_offset, int x_pad) override { + this->draw_calls_++; + if (w <= 0 || h <= 0 || x_start < 0 || y_start < 0 || x_start + w > this->width_ || y_start + h > this->height_) { + this->out_of_bounds_++; + ADD_FAILURE() << "block " << w << "x" << h << " at " << x_start << "," << y_start << " does not fit " + << this->width_ << "x" << this->height_; + } + if (this->busy()) { + this->draws_while_busy_++; + ADD_FAILURE() << "drawn while the display is busy"; + } + if (this->draw_delay_us_ != 0) { + usleep(this->draw_delay_us_); + } + display::Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); + } + + virtual bool busy() const { return false; } + + uint32_t pixel(int x, int y) const { return this->pixels_[static_cast(y) * this->width_ + x]; } + const std::vector &pixels() const { return this->pixels_; } + size_t drawn_count() const { + return static_cast( + std::count_if(this->pixels_.begin(), this->pixels_.end(), [](uint32_t p) { return p != UNSET; })); + } + int updates() const { return this->updates_; } + int draw_calls() const { return this->draw_calls_; } + int out_of_bounds() const { return this->out_of_bounds_; } + int draws_while_busy() const { return this->draws_while_busy_; } + void set_draw_delay_us(uint32_t us) { this->draw_delay_us_ = us; } + + protected: + int width_; + int height_; + display::DisplayType type_; + std::vector pixels_; + int updates_{0}; + int draw_calls_{0}; + int out_of_bounds_{0}; + int draws_while_busy_{0}; + uint32_t draw_delay_us_{0}; +}; + +/// Behaves like it8951 and epaper_spi: parked (loop disabled) while idle, +/// update() starts a refresh that runs in its loop until finish(). +class FakeParkingDisplay : public FakeDisplay { + public: + FakeParkingDisplay(int width = 64, int height = 48, display::DisplayType type = display::DISPLAY_TYPE_GRAYSCALE) + : FakeDisplay(width, height, type) { + this->disable_loop(); + } + void update() override { + this->updates_++; + this->enable_loop(); + } + /// The refresh is over and the driver parks its loop again. + void finish() { this->disable_loop(); } + /// Something other than the VNC client started a refresh. + void external_update() { this->enable_loop(); } + bool busy() const override { return !this->is_idle(); } +}; + +/// Starts with its loop running and parks once its start-up is done, like it8951 at boot. +class FakeStartingDisplay : public FakeParkingDisplay { + public: + FakeStartingDisplay() { + this->component_state_ |= COMPONENT_HAS_LOOP; + this->enable_loop(); + } + void finish_start() { + this->started_ = true; + this->finish(); + } + bool busy() const override { return this->started_ && FakeParkingDisplay::busy(); } + + protected: + bool started_{false}; +}; + +/// A display whose loop always runs (never reports idle). +class FakeFreeRunDisplay : public FakeDisplay { + public: + FakeFreeRunDisplay(int width = 64, int height = 48) : FakeDisplay(width, height) { + this->disable_loop(); + this->enable_loop(); + } +}; + +/// A display with its own loop that never parks. +class FakeLoopingDisplay : public FakeFreeRunDisplay { + public: + FakeLoopingDisplay() { this->component_state_ |= COMPONENT_HAS_LOOP; } +}; + +/// Parked from setup and refreshes synchronously inside update(), like headless SDL. +class FakeSyncParkedDisplay : public FakeDisplay { + public: + FakeSyncParkedDisplay(int width = 64, int height = 48) : FakeDisplay(width, height) { this->disable_loop(); } +}; + +// --------------------------------------------------------------------------- +// Client under test + +class TestVncClient : public VncClient { + public: + using VncClient::VncClient; + ~TestVncClient() { + this->high_freq_.stop(); + this->link_.close(); + } + + /// The state a connect still in flight has: a socket, but no session yet. + void forget_session() { this->state_ = VncState::VNC_STATE_IDLE; } + + /// Hand the client a connected stream, as connect_step_() does after a successful connect. + void adopt(int fd) { + // connect_step_() has seen the network up before any connect succeeds. + this->network_was_up_ = true; + this->link_.adopt(std::make_unique(fd)); + this->start_handshake_(now_ms()); + } + + /// Parse bytes straight from memory, the way service_rx_() would, without a socket read. + VncParse feed(const uint8_t *data, size_t len) { + VncParse result = VncParse::VNC_PARSE_MORE; + size_t offset = 0; + do { + const size_t room = RX_BUFFER_SIZE - this->rx_len_; + const size_t count = std::min(room, len - offset); + std::memcpy(this->rx_ + this->rx_len_, data + offset, count); + this->rx_len_ += static_cast(count); + this->rx_total_ += count; + offset += count; + for (int guard = 0;; guard++) { + this->pass_start_ms_ = millis(); + result = this->parse_(); + if (result != VncParse::VNC_PARSE_PAUSE || this->display_busy_ || guard > 100000) { + break; + } + } + if (result != VncParse::VNC_PARSE_MORE) { + return result; + } + if (this->rx_pos_ != 0) { + this->rx_len_ -= this->rx_pos_; + std::memmove(this->rx_, this->rx_ + this->rx_pos_, this->rx_len_); + this->rx_pos_ = 0; + } else if (count == 0) { + ADD_FAILURE() << "parser made no progress with a full buffer"; + return result; + } + } while (offset < len); + return result; + } + VncParse feed(const Bytes &bytes) { return this->feed(bytes.data(), bytes.size()); } + + socket::TcpClientLink &link() { return this->link_; } + VncState state() const { return this->state_; } + VncStep step() const { return this->step_; } + uint32_t hold_ms() const { return this->hold_ms_; } + uint32_t hold_start_ms() const { return this->hold_start_ms_; } + uint32_t backoff_ms() const { return this->backoff_ms_; } + bool rx_pending() const { return this->rx_pending_; } + bool in_update() const { return this->in_update_; } + bool refresh_pending() const { return this->refresh_pending_; } + bool display_busy() const { return this->display_busy_; } + uint16_t rx_buffered() const { return this->rx_len_ - this->rx_pos_; } + uint16_t vis_w() const { return this->vis_w_; } + uint16_t vis_h() const { return this->vis_h_; } + uint8_t bpp() const { return this->bpp_; } +}; + +// --------------------------------------------------------------------------- +// Server-side byte builder (RFB 3.x, server to client) + +class Rfb { + public: + Bytes bytes; + + Rfb &u8(uint8_t v) { + this->bytes.push_back(v); + return *this; + } + Rfb &u16(uint16_t v) { return this->u8(v >> 8).u8(v & 0xFF); } + Rfb &u32(uint32_t v) { return this->u16(v >> 16).u16(v & 0xFFFF); } + Rfb &s32(int32_t v) { return this->u32(static_cast(v)); } + Rfb &raw(const Bytes &b) { + this->bytes.insert(this->bytes.end(), b.begin(), b.end()); + return *this; + } + Rfb &str(const std::string &s) { return this->raw(text(s)); } + Rfb &fill(size_t count, uint8_t value) { + this->bytes.insert(this->bytes.end(), count, value); + return *this; + } + /// One pixel in the client's format: little-endian RGB565 (bpp 2) or RGB332 (bpp 1). + Rfb &px(uint16_t v, uint8_t bpp = 2) { + this->u8(v & 0xFF); + if (bpp == 2) { + this->u8(v >> 8); + } + return *this; + } + + Rfb &version(const std::string &v = "RFB 003.008\n") { return this->str(v); } + Rfb &security_list(std::initializer_list types) { + this->u8(static_cast(types.size())); + for (uint8_t t : types) { + this->u8(t); + } + return *this; + } + Rfb &security_33(uint32_t type) { return this->u32(type); } + Rfb &security_result(uint32_t result) { return this->u32(result); } + Rfb &reason(const std::string &s) { return this->u32(s.size()).str(s); } + Rfb &challenge() { + for (uint8_t i = 0; i < 16; i++) { + this->u8(i); + } + return *this; + } + Rfb &server_init(uint16_t w, uint16_t h, const std::string &name = "esphome-test") { + // The server's own pixel format; the client replaces it with SetPixelFormat. + this->u16(w).u16(h).u8(32).u8(24).u8(0).u8(1).u16(255).u16(255).u16(255).u8(16).u8(8).u8(0).fill(3, 0); + return this->u32(name.size()).str(name); + } + + Rfb &fbu(uint16_t rects) { return this->u8(0).u8(0).u16(rects); } + Rfb &rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, int32_t encoding) { + return this->u16(x).u16(y).u16(w).u16(h).s32(encoding); + } + Rfb &raw_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const std::vector &pixels, uint8_t bpp = 2) { + this->rect(x, y, w, h, 0); + for (uint16_t p : pixels) { + this->px(p, bpp); + } + return *this; + } + Rfb &cursor(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint8_t bpp = 2) { + this->rect(x, y, w, h, -239); + for (uint32_t i = 0; i < static_cast(w) * h; i++) { + this->px(0xA5A5, bpp); + } + return this->fill(((w + 7u) / 8u) * h, 0xFF); + } + Rfb &bell() { return this->u8(2); } + Rfb &cut_text(uint32_t len, uint8_t value = 'x') { return this->u8(3).fill(3, 0).u32(len).fill(len, value); } + Rfb &colour_map(uint16_t first, uint16_t count) { + this->u8(1).u8(0).u16(first).u16(count); + for (uint16_t i = 0; i < count; i++) { + this->u16(i).u16(i).u16(i); + } + return *this; + } +}; + +/// A full RFB 3.8 None handshake up to and including ServerInit. +inline Bytes handshake_38(uint16_t w, uint16_t h) { + return Rfb().version().security_list({1}).security_result(0).server_init(w, h).bytes; +} + +// --------------------------------------------------------------------------- +// Client-side messages the tests expect + +inline Bytes fbur(bool incremental, uint16_t w, uint16_t h) { + return Rfb().u8(3).u8(incremental ? 1 : 0).u16(0).u16(0).u16(w).u16(h).bytes; +} +inline Bytes set_pixel_format_565() { return {0, 0, 0, 0, 16, 16, 0, 1, 0, 31, 0, 63, 0, 31, 11, 5, 0, 0, 0, 0}; } +inline Bytes set_pixel_format_332() { return {0, 0, 0, 0, 8, 8, 0, 1, 0, 7, 0, 7, 0, 3, 5, 2, 0, 0, 0, 0}; } +inline Bytes set_encodings() { return {2, 0, 0, 2, 0, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0x11}; } +inline Bytes version_reply(char minor) { return text(std::string("RFB 003.00") + minor + "\n"); } + +/// Splits a stream of client messages that are all FramebufferUpdateRequests. +inline int count_fburs(const Bytes &tx, bool incremental) { + int count = 0; + for (size_t i = 0; i + 10 <= tx.size(); i += 10) { + if (tx[i] == 3 && (tx[i + 1] != 0) == incremental) { + count++; + } + } + return count; +} + +// --------------------------------------------------------------------------- +// Ports + +/// A loopback port that was free a moment ago, so a connect to it is refused. +inline uint16_t refused_port() { + int probe = ::socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + EXPECT_EQ(::bind(probe, reinterpret_cast(&addr), len), 0); + EXPECT_EQ(::getsockname(probe, reinterpret_cast(&addr), &len), 0); + ::close(probe); + return ntohs(addr.sin_port); +} + +/// A loopback listener that counts the connects that reach it. +class Listener { + public: + Listener() { + this->fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + EXPECT_EQ(::bind(this->fd_, reinterpret_cast(&addr), len), 0); + EXPECT_EQ(::listen(this->fd_, 16), 0); + EXPECT_EQ(::getsockname(this->fd_, reinterpret_cast(&addr), &len), 0); + fcntl(this->fd_, F_SETFL, fcntl(this->fd_, F_GETFL, 0) | O_NONBLOCK); + this->port_ = ntohs(addr.sin_port); + } + ~Listener() { + for (int fd : this->accepted_) { + if (fd >= 0) { + ::close(fd); + } + } + ::close(this->fd_); + } + uint16_t port() const { return this->port_; } + /// Connections accepted so far, including any waiting now. + int accepted() { + for (;;) { + int fd = ::accept(this->fd_, nullptr, nullptr); + if (fd >= 0) { + this->accepted_.push_back(fd); + } else if (errno == ECONNABORTED) { + // The client gave up on it already; it still was an attempt. + this->accepted_.push_back(-1); + } else if (errno != EINTR) { + break; + } + } + return static_cast(this->accepted_.size()); + } + + protected: + int fd_{-1}; + uint16_t port_{0}; + std::vector accepted_; +}; + +// --------------------------------------------------------------------------- +// Test harness: the test plays the server on the far end of a socketpair. + +class Session { + public: + explicit Session(display::Display *display, uint32_t start_ms = 1000) : client(display) { + signal(SIGPIPE, SIG_IGN); + set_now(start_ms); + this->client.set_host("127.0.0.1"); + this->client.set_port(refused_port()); + this->client.setup(); + } + ~Session() { this->close_peer(); } + + /// A new TCP session: the client gets one end, the test keeps the other. + void attach() { + this->close_peer(); + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + fcntl(fds[1], F_SETFL, fcntl(fds[1], F_GETFL, 0) | O_NONBLOCK); + this->peer_fd = fds[1]; + this->client_fd = fds[0]; + this->out_.clear(); + this->out_pos_ = 0; + this->client.adopt(fds[0]); + } + void close_peer() { + if (this->peer_fd >= 0) { + ::close(this->peer_fd); + this->peer_fd = -1; + } + } + + /// Queue server bytes and write as many as the socket takes now. + void send(const Bytes &bytes) { + this->out_.insert(this->out_.end(), bytes.begin(), bytes.end()); + this->pump(); + } + void send(const Rfb &rfb) { this->send(rfb.bytes); } + void pump() { + while (this->out_pos_ < this->out_.size() && this->peer_fd >= 0) { + ssize_t n = ::write(this->peer_fd, this->out_.data() + this->out_pos_, this->out_.size() - this->out_pos_); + if (n <= 0) { + break; + } + this->out_pos_ += n; + } + if (this->out_pos_ == this->out_.size()) { + this->out_.clear(); + this->out_pos_ = 0; + } + } + bool server_bytes_waiting() const { return this->out_pos_ < this->out_.size(); } + /// Bytes the client has not read from its socket yet. + int unread() { + int count = 0; + if (this->client.link().connected()) { + ioctl(this->client_fd, FIONREAD, &count); + } + return count; + } + + /// Everything the client sent since the last call. + Bytes take() { + this->client.link().flush_tx(); + Bytes out; + if (this->peer_fd < 0) { + return out; + } + uint8_t buf[4096]; + for (;;) { + ssize_t n = ::read(this->peer_fd, buf, sizeof(buf)); + if (n <= 0) { + break; + } + out.insert(out.end(), buf, buf + n); + } + return out; + } + + void loop(int count = 1) { + for (int i = 0; i < count; i++) { + this->pump(); + this->client.loop(); + } + } + /// Move the virtual clock to an absolute time and run one pass. + void at(uint32_t ms) { + set_now(ms); + this->loop(); + } + /// Advance the virtual clock and run one pass. + void tick(uint32_t ms) { this->at(now_ms() + ms); } + /// Advance in steps, running one pass per step. + void run_for(uint32_t ms, uint32_t step = 10) { + const uint32_t end = now_ms() + ms; + while (static_cast(end - now_ms()) > 0) { + this->tick(std::min(step, end - now_ms())); + } + } + bool loop_until(const std::function &done, int max_loops = 1000) { + for (int i = 0; i < max_loops && !done(); i++) { + this->loop(); + } + return done(); + } + /// Runs passes at the current virtual time until done, sleeping briefly so the kernel can finish a connect. + bool wait_until(const std::function &done, int max_ms = 2000) { + for (int i = 0; i < max_ms * 5 && !done(); i++) { + this->loop(); + if (!done()) { + usleep(200); + } + } + return done(); + } + /// Send and keep running passes at the current time until the client has read and parsed it all. + void deliver(const Bytes &bytes, int max_loops = 100000) { + this->send(bytes); + this->loop_until( + [this]() { return !this->server_bytes_waiting() && this->unread() == 0 && !this->client.rx_pending(); }, + max_loops); + } + void deliver(const Rfb &rfb) { this->deliver(rfb.bytes); } + + /// Full RFB 3.8 None handshake up to RUNNING; the client's handshake bytes are discarded. + void start(uint16_t fb_w, uint16_t fb_h) { + this->attach(); + this->deliver(handshake_38(fb_w, fb_h)); + ASSERT_EQ(this->client.state(), VncState::VNC_STATE_RUNNING); + this->take(); + } + + TestVncClient client; + int peer_fd{-1}; + int client_fd{-1}; + + protected: + Bytes out_; + size_t out_pos_{0}; +}; + +} // namespace esphome::vnc::testing + +#endif // USE_HOST diff --git a/tests/integration/README.md b/tests/integration/README.md index 088be2f1195..777cb8bb2ee 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -267,10 +267,9 @@ def on_state(state: EntityState) -> None: client.subscribe_states(on_state) # Wait for state with timeout -try: - result = await asyncio.wait_for(state_future, timeout=5.0) -except asyncio.TimeoutError: - pytest.fail(f"Expected state not received. Got: {list(states.values())}") +result = await wait_or_fail( + state_future, 5.0, "Expected state not received", states=states +) ``` ##### Service Execution Pattern @@ -350,6 +349,7 @@ esphome: - id: my_device name: "Test Device" area_id: living_room + relation: via # API services api: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ce2e7f5bbc0..40af45a1a9c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -236,6 +236,10 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s external_components_path = str(FIXTURES_DIR / "external_components") content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path) + # Replace the user includes path placeholder if present + if "INCLUDES_PATH" in content: + content = content.replace("INCLUDES_PATH", str(FIXTURES_DIR / "includes")) + if shared_name is not None: # _compile verifies the marked test compiles this content unmodified request.node._shared_yaml_content = content diff --git a/tests/integration/fixtures/api_action_metadata.yaml b/tests/integration/fixtures/api_action_metadata.yaml index 802b9651103..3fae73d861e 100644 --- a/tests/integration/fixtures/api_action_metadata.yaml +++ b/tests/integration/fixtures/api_action_metadata.yaml @@ -22,5 +22,18 @@ api: value: int then: - logger.log: "Plain action called" + - action: optional_args_action + variables: + song_str: + type: string + required: false + octave: + type: int + default: 5 + loud: + type: bool + default: true + then: + - logger.log: "Optional action called" logger: diff --git a/tests/integration/fixtures/api_cover_toggle.yaml b/tests/integration/fixtures/api_cover_toggle.yaml new file mode 100644 index 00000000000..b0840706ddd --- /dev/null +++ b/tests/integration/fixtures/api_cover_toggle.yaml @@ -0,0 +1,26 @@ +esphome: + name: api-cover-toggle-test +host: +api: +logger: + level: DEBUG + +cover: + - platform: template + name: "Toggle Cover" + id: toggle_cover + toggle_action: + - cover.template.publish: + id: toggle_cover + position: 0.5 + stop_action: + - cover.template.publish: + id: toggle_cover + position: 0.25 + - platform: template + name: "Plain Cover" + id: plain_cover + stop_action: + - cover.template.publish: + id: plain_cover + position: 0.25 diff --git a/tests/integration/fixtures/api_homeassistant_light.yaml b/tests/integration/fixtures/api_homeassistant_light.yaml index e2cf9e5fbe5..4dd64d40b0c 100644 --- a/tests/integration/fixtures/api_homeassistant_light.yaml +++ b/tests/integration/fixtures/api_homeassistant_light.yaml @@ -12,6 +12,13 @@ api: - globals.set: id: force_off value: !lambda return value; + # Sixteen calls in one loop pass, the last one to full brightness + - action: retarget_many + then: + - lambda: |- + for (int i = 0; i < 16; i++) { + id(ha_light).make_call().set_brightness(i == 15 ? 1.0f : 0.6f).set_transition_length(1000).perform(); + } logger: level: DEBUG diff --git a/tests/integration/fixtures/areas_and_devices.yaml b/tests/integration/fixtures/areas_and_devices.yaml index 08b02e6e1e0..445b878977e 100644 --- a/tests/integration/fixtures/areas_and_devices.yaml +++ b/tests/integration/fixtures/areas_and_devices.yaml @@ -15,15 +15,19 @@ esphome: - id: light_controller_device name: Light Controller area_id: living_room_area # Uses top-level area + relation: via - id: temp_sensor_device name: Temperature Sensor area_id: bedroom_area + relation: via - id: motion_detector_device name: Motion Detector area_id: living_room_area # Reuses top-level area + relation: child - id: smart_switch_device name: Smart Switch area_id: kitchen_area + relation: child host: api: diff --git a/tests/integration/fixtures/cover_control_action.yaml b/tests/integration/fixtures/cover_control_action.yaml index 085d6327963..29b240e5770 100644 --- a/tests/integration/fixtures/cover_control_action.yaml +++ b/tests/integration/fixtures/cover_control_action.yaml @@ -34,6 +34,10 @@ cover: // Manually set tilt and publish id(test_cover).tilt = tilt; id(test_cover).publish_state(); + stop_tilt_action: + - cover.template.publish: + id: test_cover + tilt: 0.4 button: # cover.control: position only @@ -109,3 +113,17 @@ button: - cover.control: id: test_cover stop: true + + - platform: template + id: btn_stop_tilt + name: "Stop Tilt" + on_press: + - cover.stop_tilt: test_cover + + - platform: template + id: btn_control_stop_tilt + name: "Control Stop Tilt" + on_press: + - cover.control: + id: test_cover + stop_tilt: true diff --git a/tests/integration/fixtures/cover_stop_tilt_cancel.yaml b/tests/integration/fixtures/cover_stop_tilt_cancel.yaml new file mode 100644 index 00000000000..94afdd82bdf --- /dev/null +++ b/tests/integration/fixtures/cover_stop_tilt_cancel.yaml @@ -0,0 +1,28 @@ +esphome: + name: cover-stop-tilt-cancel-test +host: +api: +logger: + level: DEBUG + +cover: + - platform: template + name: "Test Cover" + id: test_cover + optimistic: true + tilt_action: + - delay: 1s + - cover.template.publish: + id: test_cover + tilt: 0.9 + stop_tilt_action: + - delay: 1s + - cover.template.publish: + id: test_cover + tilt: 0.4 + +button: + - platform: template + name: "Stop Tilt" + on_press: + - cover.stop_tilt: test_cover diff --git a/tests/integration/fixtures/device_id_in_state.yaml b/tests/integration/fixtures/device_id_in_state.yaml index a5dfb7ee45c..4e6f06e40e1 100644 --- a/tests/integration/fixtures/device_id_in_state.yaml +++ b/tests/integration/fixtures/device_id_in_state.yaml @@ -11,12 +11,15 @@ esphome: - id: temperature_monitor name: Temperature Monitor area_id: living_room + relation: via - id: humidity_monitor name: Humidity Monitor area_id: bedroom + relation: via - id: motion_sensor name: Motion Sensor area_id: living_room + relation: via host: api: diff --git a/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml index ecc502ad280..5f9d9fbba0e 100644 --- a/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml +++ b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml @@ -4,10 +4,13 @@ esphome: devices: - id: controller_1 name: Controller 1 + relation: via - id: controller_2 name: Controller 2 + relation: via - id: controller_3 name: Controller 3 + relation: via host: api: # Port will be automatically injected diff --git a/tests/integration/fixtures/includes/lambda_order.h b/tests/integration/fixtures/includes/lambda_order.h new file mode 100644 index 00000000000..44f5c013c55 --- /dev/null +++ b/tests/integration/fixtures/includes/lambda_order.h @@ -0,0 +1,6 @@ +#pragma once + +static constexpr int HEADER_CONSTANT = 42; + +// Names the global by its id, so the header must come after the id declarations. +inline int header_reads_global() { return header_value->value() * 2; } diff --git a/tests/integration/fixtures/includes_lambda_order.yaml b/tests/integration/fixtures/includes_lambda_order.yaml new file mode 100644 index 00000000000..e6661e74a96 --- /dev/null +++ b/tests/integration/fixtures/includes_lambda_order.yaml @@ -0,0 +1,27 @@ +esphome: + name: includes-lambda-order + includes: + - INCLUDES_PATH/lambda_order.h + on_boot: + # An apply action value is rendered at file scope. + - globals.set: + id: header_value + value: !lambda return HEADER_CONSTANT; + +host: +api: +logger: + +globals: + - id: header_value + type: int + initial_value: "0" + +sensor: + - platform: template + name: Header Value + update_interval: 100ms + lambda: return header_reads_global(); + filters: + # A lambda in a flash table. + - filter_out: !lambda return HEADER_CONSTANT - 1; diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 01e4394559b..c036789f168 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -4,8 +4,10 @@ esphome: devices: - id: device_a name: Device A + relation: via - id: device_b name: Device B + relation: via host: api: # Port will be automatically injected diff --git a/tests/integration/fixtures/object_id_api_verification.yaml b/tests/integration/fixtures/object_id_api_verification.yaml index 386270fc2c1..877bf40f160 100644 --- a/tests/integration/fixtures/object_id_api_verification.yaml +++ b/tests/integration/fixtures/object_id_api_verification.yaml @@ -8,8 +8,10 @@ esphome: devices: - id: sub_device_1 name: Sub Device One + relation: via - id: sub_device_2 name: Sub Device Two + relation: via host: diff --git a/tests/integration/fixtures/sensor_filters_rh_correction.yaml b/tests/integration/fixtures/sensor_filters_rh_correction.yaml new file mode 100644 index 00000000000..291b9210b12 --- /dev/null +++ b/tests/integration/fixtures/sensor_filters_rh_correction.yaml @@ -0,0 +1,102 @@ +esphome: + name: test-rh-correction + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +sensor: + - platform: template + name: "Temperature C" + id: temperature_c + accuracy_decimals: 1 + + - platform: template + name: "Temperature F" + id: temperature_f + accuracy_decimals: 1 + + - platform: template + name: "Humidity Source" + id: humidity_source + accuracy_decimals: 1 + + - platform: copy + source_id: humidity_source + name: "RH Celsius" + id: rh_celsius + accuracy_decimals: 2 + filters: + - rh_correction: + sensor_id: temperature_c + temperature_offset: -7.3 + + - platform: copy + source_id: humidity_source + name: "RH Fahrenheit" + id: rh_fahrenheit + accuracy_decimals: 2 + filters: + - rh_correction: + sensor_id: temperature_f + temperature_offset: -13.14 + use_fahrenheit: true + +script: + - id: test_rh_correction + then: + # Both temperature sensors read 25 °C after a -7.3 °C (-13.14 °F) offset + - sensor.template.publish: + id: temperature_c + state: 25.0 + - sensor.template.publish: + id: temperature_f + state: 77.0 + - sensor.template.publish: + id: humidity_source + state: 40.0 + - delay: 20ms + # Clamped to 100 % + - sensor.template.publish: + id: humidity_source + state: 90.0 + - delay: 20ms + # Out of band: passes through unchanged + - sensor.template.publish: + id: temperature_c + state: 60.0 + - sensor.template.publish: + id: temperature_f + state: 140.0 + - sensor.template.publish: + id: humidity_source + state: 40.0 + - delay: 20ms + # NaN temperature: the update is dropped + - sensor.template.publish: + id: temperature_c + state: !lambda "return NAN;" + - sensor.template.publish: + id: temperature_f + state: !lambda "return NAN;" + - sensor.template.publish: + id: humidity_source + state: 50.0 + - delay: 20ms + - sensor.template.publish: + id: temperature_c + state: 25.0 + - sensor.template.publish: + id: temperature_f + state: 77.0 + - sensor.template.publish: + id: humidity_source + state: 40.0 + +button: + - platform: template + name: "Test RH Correction" + on_press: + - script.execute: test_rh_correction diff --git a/tests/integration/fixtures/varint_five_byte_device_id.yaml b/tests/integration/fixtures/varint_five_byte_device_id.yaml index 08259869ca7..d0164e871d3 100644 --- a/tests/integration/fixtures/varint_five_byte_device_id.yaml +++ b/tests/integration/fixtures/varint_five_byte_device_id.yaml @@ -9,9 +9,11 @@ esphome: - id: sub_device_one name: Sub Device One area_id: test_area + relation: via - id: sub_device_two name: Sub Device Two area_id: test_area + relation: via host: api: diff --git a/tests/integration/fixtures/vnc_client.yaml b/tests/integration/fixtures/vnc_client.yaml new file mode 100644 index 00000000000..123c41a2041 --- /dev/null +++ b/tests/integration/fixtures/vnc_client.yaml @@ -0,0 +1,40 @@ +esphome: + name: vnc-client-test + +host: + +api: + actions: + - action: take_snapshot + variables: + filename: string + then: + - snapshot.take: + id: disp + filename: !lambda return filename; + +logger: + level: DEBUG + +display: + - platform: snapshot + id: disp + update_interval: never + dimensions: + width: 64 + height: 48 + +vnc: + id: remote + display_id: disp + host: 127.0.0.1 + # Rewritten by the test to the fake server's port + port: 15900 + reconnect_interval: 1s + # Longer than the time the next request may take, so the two can be told apart + update_delay: 300ms + max_update_delay: 500ms + on_connect: + - logger.log: "VNC_TEST_CONNECTED" + on_disconnect: + - logger.log: "VNC_TEST_DISCONNECTED" diff --git a/tests/integration/raw_api_client.py b/tests/integration/raw_api_client.py index cc759f88e5a..0b7615d6063 100644 --- a/tests/integration/raw_api_client.py +++ b/tests/integration/raw_api_client.py @@ -1,7 +1,7 @@ """Minimal plaintext native-api client over a raw socket. Reads only when told to, so tests control when the TCP pipe backs up toward -the device; payloads are skipped and only message types are counted. +the device; message types are counted and each frame is kept for inspection. """ from __future__ import annotations @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio from collections import Counter import socket +import struct from typing import Self from aioesphomeapi import api_pb2 @@ -49,6 +50,36 @@ def decode_varint(buf: bytearray, pos: int) -> tuple[int, int] | None: return None +WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5 + + +def tag(field: int, wire_type: int) -> bytes: + return encode_varint((field << 3) | wire_type) + + +def decode_fields(payload: bytes) -> dict[int, list[int | bytes]]: + """Split a protobuf payload into field number -> raw values; a test reads fields the client lacks.""" + fields: dict[int, list[int | bytes]] = {} + buf = bytearray(payload) + pos = 0 + while pos < len(buf): + key, pos = decode_varint(buf, pos) + field, wire_type = key >> 3, key & 0x07 + if wire_type == WIRE_VARINT: + value, pos = decode_varint(buf, pos) + elif wire_type == WIRE_LENGTH: + length, pos = decode_varint(buf, pos) + value = bytes(buf[pos : pos + length]) + pos += length + elif wire_type == WIRE_FIXED32: + value = struct.unpack_from(" bytes: """Encode one plaintext api frame: 0x00, payload length, message type.""" return b"\x00" + encode_varint(len(payload)) + encode_varint(msg_type) + payload @@ -59,15 +90,17 @@ class FrameParser: def __init__(self) -> None: self._buf = bytearray() + self.frames: list[tuple[int, bytes]] = [] def feed(self, data: bytes) -> list[int]: self._buf.extend(data) types: list[int] = [] - while (msg_type := self._try_parse()) is not None: - types.append(msg_type) + while (frame := self._try_parse()) is not None: + self.frames.append(frame) + types.append(frame[0]) return types - def _try_parse(self) -> int | None: + def _try_parse(self) -> tuple[int, bytes] | None: buf = self._buf if not buf: return None @@ -80,8 +113,9 @@ class FrameParser: msg_type, pos = type_decoded if len(buf) - pos < size: return None + payload = bytes(buf[pos : pos + size]) del buf[: pos + size] - return msg_type + return msg_type, payload class RawApiClient: @@ -145,5 +179,9 @@ class RawApiClient: await asyncio.wait_for(_read_loop(), timeout) + def frames(self, msg_type: int) -> list[bytes]: + """Payloads of every frame of msg_type received so far.""" + return [payload for kind, payload in self._parser.frames if kind == msg_type] + def close(self) -> None: self._sock.close() diff --git a/tests/integration/rfb_fake_server.py b/tests/integration/rfb_fake_server.py new file mode 100644 index 00000000000..d7024831868 --- /dev/null +++ b/tests/integration/rfb_fake_server.py @@ -0,0 +1,629 @@ +"""A deterministic fake RFB (VNC) server for the vnc integration tests. + +It behaves like the TightVNC 1.3.10 Xvnc that the Home Assistant VNC add-on runs: the same +handshake for RFB 3.3, 3.7 and 3.8, the same security lists and failure messages, and a +connection that closes on any client message type it does not know. + +The framebuffer is an RGB888 image the test draws into. Updates are translated into the pixel +format the client asked for and encoded as Raw or a RichCursor shape. Every client +message is recorded, and the test decides when to answer: ``wait_for("fbur")`` then +``send_update(...)``. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Iterable, Sequence +import contextlib +from dataclasses import asdict, dataclass +import logging +import random +import re +import struct +from typing import Any, Literal, Self + +_LOGGER = logging.getLogger(__name__) + +SECURITY_NONE = 1 +SECURITY_TIGHT = 16 + +ENCODING_RAW = 0 +ENCODING_RICH_CURSOR = -239 + +#: ``chunk`` value that writes in random 1-7 byte pieces. +HOSTILE_CHUNK = -1 + +#: TightVNC closes the connection on a longer ClientCutText. +MAX_CLIENT_CUT_TEXT = 1 << 20 + + +RGB = tuple[int, int, int] +Encoding = Literal["raw", "rich-cursor"] +Rect = tuple[int, int, int, int, Encoding] +Message = dict[str, Any] + +_PIXEL_FORMAT = struct.Struct(">BBBBHHHBBB3x") +_VERSION_RE = re.compile(rb"RFB (\d{3})\.(\d{3})\n") + + +@dataclass(frozen=True) +class PixelFormat: + """An RFB PIXEL_FORMAT. Field names match the keys of a set_pixel_format message.""" + + bpp: int + depth: int + big_endian: bool + true_colour: bool + red_max: int + green_max: int + blue_max: int + red_shift: int + green_shift: int + blue_shift: int + + @classmethod + def unpack(cls, data: bytes) -> PixelFormat: + """Decode the 16 byte wire form.""" + bpp, depth, big_endian, true_colour, *rest = _PIXEL_FORMAT.unpack(data) + return cls(bpp, depth, bool(big_endian), bool(true_colour), *rest) + + def pack(self) -> bytes: + """Encode the 16 byte wire form.""" + return _PIXEL_FORMAT.pack(*asdict(self).values()) + + @property + def bytes_per_pixel(self) -> int: + """Bytes per pixel on the wire.""" + return self.bpp // 8 + + def value_of(self, rgb: RGB) -> int: + """The pixel value a real server sends for an RGB888 colour.""" + r, g, b = rgb + value = ( + (((r * self.red_max + 127) // 255) << self.red_shift) + | (((g * self.green_max + 127) // 255) << self.green_shift) + | (((b * self.blue_max + 127) // 255) << self.blue_shift) + ) + return value & ((1 << self.bpp) - 1) + + def to_bytes(self, value: int) -> bytes: + """A pixel value in wire byte order.""" + return value.to_bytes( + self.bytes_per_pixel, "big" if self.big_endian else "little" + ) + + +#: TightVNC's native format at ``-depth 16`` on a little-endian host. +RGB565_LE = PixelFormat(16, 16, False, True, 31, 63, 31, 11, 5, 0) +RGB565_BE = PixelFormat(16, 16, True, True, 31, 63, 31, 11, 5, 0) +RGB332 = PixelFormat(8, 8, False, True, 7, 7, 3, 5, 2, 0) + + +def _string(text: str) -> bytes: + data = text.encode() + return struct.pack(">I", len(data)) + data + + +class _ProtocolError(Exception): + """The client broke the protocol; TightVNC would close the connection.""" + + +@dataclass(eq=False) +class _Session: + reader: asyncio.StreamReader + writer: asyncio.StreamWriter + pixel_format: PixelFormat + task: asyncio.Task[None] | None + minor: int = 8 + + +class FakeRfbServer: + """An RFB server with a scripted framebuffer, serving one client at a time. + + A new connection closes the previous one, as the add-on's ``-nevershared`` does. + """ + + def __init__( + self, + width: int, + height: int, + *, + version: bytes = b"RFB 003.008\n", + security_types: list[int] | None = None, + name: str = "fake rfb", + native_format: PixelFormat = RGB565_LE, + refuse_with_reason: str | None = None, + handshake_chunk: int | None = None, + seed: int = 0, + ) -> None: + """Set up the server; nothing listens until ``start()``. + + Args: + width: Framebuffer width. + height: Framebuffer height. + version: The 12 byte ProtocolVersion the server sends. + security_types: The 3.7+ list. Defaults to TightVNC's ``[1, 16]``. + name: Desktop name in ServerInit. + native_format: Pixel format in ServerInit, used until SetPixelFormat. + refuse_with_reason: Refuse every connection before authentication with this + reason, as TightVNC does after too many failures. + handshake_chunk: ``chunk`` used for the server's handshake writes. + seed: Seed for the hostile chunk sizes. + """ + self.width = width + self.height = height + self.version = version + self.security_types = ( + list(security_types) + if security_types is not None + else [SECURITY_NONE, SECURITY_TIGHT] + ) + self.name = name + self.native_format = native_format + self.refuse_with_reason = refuse_with_reason + self.handshake_chunk = handshake_chunk + self.image = bytearray(width * height * 3) + #: Every parsed client message, from every connection, in arrival order. + self.messages: list[Message] = [] + self.protocol_errors: list[str] = [] + self.client_version: bytes | None = None + self.shared_flag: int | None = None + self.encodings: list[int] = [] + self._rng = random.Random(seed) + self._server: asyncio.Server | None = None + self._session: _Session | None = None + self._open: set[_Session] = set() + self._pending: list[Message] = [] + self._changed = asyncio.Event() + self._write_lock = asyncio.Lock() + self._port = 0 + self._connections = 0 + self._sessions = 0 + + async def __aenter__(self) -> Self: + """Start on a free loopback port if not started yet.""" + if self._server is None: + await self.start() + return self + + async def __aexit__(self, *exc_info: object) -> None: + """Stop the server.""" + await self.stop() + + @property + def port(self) -> int: + """The bound port, 0 before ``start()``.""" + return self._port + + @property + def connections(self) -> int: + """TCP connections accepted so far.""" + return self._connections + + @property + def sessions(self) -> int: + """Connections that got as far as ServerInit.""" + return self._sessions + + @property + def client_connected(self) -> bool: + """True while a client connection is open.""" + return self._session is not None + + @property + def pixel_format(self) -> PixelFormat: + """The current client's pixel format, or the native one with no client.""" + return self._session.pixel_format if self._session else self.native_format + + async def start(self, host: str = "127.0.0.1", port: int = 0) -> int: + """Listen and return the bound port.""" + self._server = await asyncio.start_server(self._handle, host, port) + self._port = self._server.sockets[0].getsockname()[1] + return self._port + + async def stop(self) -> None: + """Close the listener and every connection, and wait for their handlers.""" + if (server := self._server) is None: + return + self._server = None + server.close() + for session in list(self._open): + session.writer.transport.abort() + if tasks := [s.task for s in self._open if s.task is not None]: + _, pending = await asyncio.wait(tasks, timeout=5.0) + for task in pending: + task.cancel() + if pending: + await asyncio.wait(pending) + await server.wait_closed() + + def set_pixel(self, x: int, y: int, rgb: RGB) -> None: + """Set one framebuffer pixel.""" + self.fill_rect(x, y, 1, 1, rgb) + + def get_pixel(self, x: int, y: int) -> RGB: + """Read one framebuffer pixel.""" + self._check_rect(x, y, 1, 1) + offset = (y * self.width + x) * 3 + r, g, b = self.image[offset : offset + 3] + return r, g, b + + def fill_rect(self, x: int, y: int, w: int, h: int, rgb: RGB) -> None: + """Fill a framebuffer rectangle with one colour.""" + self._check_rect(x, y, w, h) + row = bytes(rgb) * w + for line in range(y, y + h): + offset = (line * self.width + x) * 3 + self.image[offset : offset + w * 3] = row + + async def wait_until( + self, + predicate: Callable[[], bool], + timeout: float = 5.0, + what: str = "condition", + ) -> None: + """Wait until ``predicate()`` is true; it is checked after every server event. + + Raises: + AssertionError: If it is still false after ``timeout``. + """ + if not await self._wait(lambda: True if predicate() else None, timeout): + raise AssertionError(f"{what} not met within {timeout}s; {self._summary()}") + + async def wait_for( + self, + type_name: str, + timeout: float = 5.0, + predicate: Callable[[Message], bool] | None = None, + ) -> Message: + """Return and consume the oldest unconsumed message of this type. + + Messages of other types stay queued, so the order of calls does not matter. Only the + current connection's messages are queued: a new connection drops the old ones. + + Raises: + AssertionError: If no matching message arrives within ``timeout``. + """ + + def take() -> Message | None: + for index, message in enumerate(self._pending): + if message["type"] == type_name and ( + predicate is None or predicate(message) + ): + return self._pending.pop(index) + return None + + if (message := await self._wait(take, timeout)) is None: + raise AssertionError( + f"no {type_name} message within {timeout}s; {self._summary()}" + ) + return message + + def pending(self) -> list[Message]: + """Messages not consumed by ``wait_for()`` yet.""" + return list(self._pending) + + def discard_pending(self) -> None: + """Drop every unconsumed message.""" + self._pending.clear() + + def build_update( + self, + rects: Sequence[Rect], + *, + pixel_format: PixelFormat | None = None, + ) -> bytes: + """Encode one FramebufferUpdate from the image. + + Each rect is ``(x, y, w, h, encoding)``. For ``rich-cursor`` x and y are the hotspot + and the shape is a fixed pattern. + + Args: + rects: The rectangles, in order. + pixel_format: Format to encode in; defaults to the current client's. + """ + fmt = pixel_format or self.pixel_format + out = bytearray(struct.pack(">BxH", 0, len(rects))) + for x, y, w, h, encoding in rects: + if encoding == "rich-cursor": + out += struct.pack(">HHHHi", x, y, w, h, ENCODING_RICH_CURSOR) + out += _cursor_shape(fmt, w, h) + continue + self._check_rect(x, y, w, h) + values = self._values(fmt, x, y, w, h) + if encoding == "raw": + out += struct.pack(">HHHHi", x, y, w, h, ENCODING_RAW) + out += _pixels(fmt, (v for row in values for v in row)) + else: + raise ValueError(f"unknown encoding {encoding!r}") + return bytes(out) + + async def send_update( + self, + rects: Sequence[Rect], + chunk: int | None = None, + *, + delay: float = 0.0, + ) -> None: + """Send one FramebufferUpdate built by ``build_update()``. + + Args: + rects: The rectangles, in order. + chunk: Split the write into pieces of at most this many bytes; ``HOSTILE_CHUNK`` + uses seeded random 1-7 byte pieces. + delay: Sleep between pieces. + """ + session = self._require_session() + data = self.build_update(rects, pixel_format=session.pixel_format) + await self._write(session, data, chunk, delay) + + async def send_cut_text( + self, text: bytes, chunk: int | None = None, *, delay: float = 0.0 + ) -> None: + """Send ServerCutText.""" + await self.send_raw( + struct.pack(">B3xI", 3, len(text)) + text, chunk, delay=delay + ) + + async def send_bell(self) -> None: + """Send Bell.""" + await self.send_raw(b"\x02") + + async def send_colour_map( + self, + first: int = 0, + colours: Sequence[RGB] = (), + chunk: int | None = None, + ) -> None: + """Send SetColourMapEntries; colours are 16 bit per channel.""" + data = struct.pack(">BxHH", 1, first, len(colours)) + data += b"".join(struct.pack(">HHH", *colour) for colour in colours) + await self.send_raw(data, chunk) + + async def send_raw( + self, data: bytes, chunk: int | None = None, *, delay: float = 0.0 + ) -> None: + """Send arbitrary bytes to the current client.""" + await self._write(self._require_session(), data, chunk, delay) + + async def close_client(self, *, abort: bool = False) -> None: + """Close the current client connection and wait for its handler to finish.""" + if (session := self._session) is None: + return + if abort: + session.writer.transport.abort() + else: + session.writer.close() + if session.task is not None: + await asyncio.wait([session.task], timeout=5.0) + + def _check_rect(self, x: int, y: int, w: int, h: int) -> None: + if min(x, y, w, h) < 0 or x + w > self.width or y + h > self.height: + raise ValueError( + f"rect {w}x{h}+{x}+{y} is outside the {self.width}x{self.height} framebuffer" + ) + + def _values( + self, fmt: PixelFormat, x: int, y: int, w: int, h: int + ) -> list[list[int]]: + cache: dict[bytes, int] = {} + rows = [] + for line in range(y, y + h): + offset = (line * self.width + x) * 3 + data = bytes(self.image[offset : offset + w * 3]) + row = [] + for i in range(0, len(data), 3): + rgb = data[i : i + 3] + if (value := cache.get(rgb)) is None: + value = cache[rgb] = fmt.value_of((rgb[0], rgb[1], rgb[2])) + row.append(value) + rows.append(row) + return rows + + def _require_session(self) -> _Session: + if self._session is None: + raise RuntimeError("no RFB client is connected") + return self._session + + def _summary(self) -> str: + return ( + f"connections={self._connections}, sessions={self._sessions}, " + f"pending={[m['type'] for m in self._pending]}, " + f"protocol_errors={self.protocol_errors}" + ) + + def _bump(self) -> None: + self._changed.set() + self._changed = asyncio.Event() + + async def _wait[T](self, check: Callable[[], T | None], timeout: float) -> T | None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while (result := check()) is None: + if (remaining := deadline - loop.time()) <= 0: + return None + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(self._changed.wait(), remaining) + return result + + async def _write( + self, session: _Session, data: bytes, chunk: int | None, delay: float = 0.0 + ) -> None: + if chunk is not None and chunk != HOSTILE_CHUNK and chunk < 1: + raise ValueError(f"bad chunk size {chunk}") + writer = session.writer + async with self._write_lock: + if chunk is None: + writer.write(data) + await writer.drain() + return + pos = 0 + while pos < len(data): + size = self._rng.randint(1, 7) if chunk == HOSTILE_CHUNK else chunk + writer.write(data[pos : pos + size]) + await writer.drain() + pos += size + await asyncio.sleep(delay) + + async def _reply(self, session: _Session, data: bytes) -> None: + await self._write(session, data, self.handshake_chunk) + + def _record(self, session: _Session, message: Message) -> None: + self.messages.append(message) + if session is self._session: + self._pending.append(message) + self._bump() + + async def _handle( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + session = _Session(reader, writer, self.native_format, asyncio.current_task()) + self._connections += 1 + if (previous := self._session) is not None: + previous.writer.close() + self._session = session + self._open.add(session) + self._pending.clear() + self._bump() + try: + await self._serve(session) + except (asyncio.IncompleteReadError, ConnectionError): + pass + except _ProtocolError as err: + self.protocol_errors.append(str(err)) + except Exception as err: # noqa: BLE001 - reported through protocol_errors + _LOGGER.exception("Fake RFB server failed") + self.protocol_errors.append(f"internal error: {err!r}") + finally: + if self._session is session: + self._session = None + writer.close() + with contextlib.suppress(OSError): + await writer.wait_closed() + self._open.discard(session) + self._bump() + + async def _serve(self, session: _Session) -> None: + await self._reply(session, self.version) + session.minor = self._negotiate_version(await session.reader.readexactly(12)) + if not await self._negotiate_security(session): + return + self.shared_flag = (await session.reader.readexactly(1))[0] + server_init = ( + struct.pack(">HH", self.width, self.height) + + self.native_format.pack() + + _string(self.name) + ) + await self._reply(session, server_init) + self._sessions += 1 + self._bump() + while True: + await self._read_message(session) + + def _negotiate_version(self, data: bytes) -> int: + self.client_version = data + if (match := _VERSION_RE.fullmatch(data)) is None: + raise _ProtocolError(f"bad client version {data!r}") + if int(match[1]) != 3: + raise _ProtocolError(f"unsupported client version {data!r}") + minor = int(match[2]) + if minor > 8: + return 8 + return minor if minor in (7, 8) else 3 + + async def _negotiate_security(self, session: _Session) -> bool: + reason = self.refuse_with_reason + if reason is None and not self.security_types: + reason = "" + if session.minor == 3: + if reason is not None: + await self._reply(session, struct.pack(">I", 0) + _string(reason)) + return False + chosen = next( + (t for t in self.security_types if t == SECURITY_NONE), + self.security_types[0], + ) + await self._reply(session, struct.pack(">I", chosen)) + else: + if reason is not None: + await self._reply(session, b"\0" + _string(reason)) + return False + offer = bytes([len(self.security_types), *self.security_types]) + await self._reply(session, offer) + chosen = (await session.reader.readexactly(1))[0] + if chosen not in self.security_types: + raise _ProtocolError( + f"client chose security type {chosen}, offered {self.security_types}" + ) + if chosen == SECURITY_NONE: + if session.minor >= 8: + await self._reply(session, struct.pack(">I", 0)) + return True + raise _ProtocolError(f"security type {chosen} is not emulated") + + async def _read_message(self, session: _Session) -> None: + read = session.reader.readexactly + match kind := (await read(1))[0]: + case 0: + fmt = PixelFormat.unpack((await read(19))[3:]) + self._record(session, {"type": "set_pixel_format", **asdict(fmt)}) + if fmt.bpp not in (8, 16, 32): + raise _ProtocolError(f"unsupported bits per pixel {fmt.bpp}") + if not fmt.true_colour: + raise _ProtocolError("colour map formats are not emulated") + session.pixel_format = fmt + case 2: + (count,) = struct.unpack(">xH", await read(3)) + self.encodings = list( + struct.unpack(f">{count}i", await read(4 * count)) + ) + self._record( + session, {"type": "set_encodings", "encodings": self.encodings} + ) + case 3: + incremental, x, y, w, h = struct.unpack(">BHHHH", await read(9)) + self._record( + session, + { + "type": "fbur", + "incremental": bool(incremental), + "x": x, + "y": y, + "w": w, + "h": h, + }, + ) + case 4: + down, keysym = struct.unpack(">B2xI", await read(7)) + self._record( + session, {"type": "key", "down": bool(down), "keysym": keysym} + ) + case 5: + mask, x, y = struct.unpack(">BHH", await read(5)) + self._record(session, {"type": "pointer", "mask": mask, "x": x, "y": y}) + case 6: + (length,) = struct.unpack(">3xI", await read(7)) + if length > MAX_CLIENT_CUT_TEXT: + raise _ProtocolError(f"client cut text of {length} bytes") + self._record(session, {"type": "cut_text", "text": await read(length)}) + case _: + raise _ProtocolError(f"unknown client message type {kind}") + + +def _pixels(fmt: PixelFormat, values: Iterable[int]) -> bytes: + cache: dict[int, bytes] = {} + out = bytearray() + for value in values: + if (data := cache.get(value)) is None: + data = cache[value] = fmt.to_bytes(value) + out += data + return bytes(out) + + +def _cursor_shape(fmt: PixelFormat, w: int, h: int) -> bytes: + light = fmt.to_bytes(fmt.value_of((255, 255, 255))) + dark = fmt.to_bytes(fmt.value_of((0, 0, 0))) + pixels = b"".join( + light if (x + y) % 2 else dark for y in range(h) for x in range(w) + ) + return pixels + b"\x5a" * (((w + 7) // 8) * h) diff --git a/tests/integration/test_action_concurrent_reentry.py b/tests/integration/test_action_concurrent_reentry.py index 0b8631f137e..3618f5b406f 100644 --- a/tests/integration/test_action_concurrent_reentry.py +++ b/tests/integration/test_action_concurrent_reentry.py @@ -2,12 +2,12 @@ from __future__ import annotations -import asyncio import collections import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -23,54 +23,41 @@ async def test_action_concurrent_reentry( This test runs a script in parallel with varying arguments and verifies if each script keeps its original argument throughout its execution """ - test_complete = asyncio.Event() + waiter = LineWaiter() expected = {0, 1, 2, 3, 4} - # Patterns to match in logs - after_wait_until_pattern = re.compile(r"AFTER wait_until ARG (\d+)") - after_script_wait_pattern = re.compile(r"AFTER script\.wait ARG (\d+)") - after_repeat_pattern = re.compile(r"AFTER repeat ARG (\d+)") - in_repeat_pattern = re.compile(r"IN repeat (\d+) ARG (\d+)") - after_while_pattern = re.compile(r"AFTER while ARG (\d+)") - in_while_pattern = re.compile(r"IN while ARG (\d+)") + def captures(pattern: str) -> list[tuple[int, ...]]: + return [ + tuple(map(int, m.groups())) + for line in waiter.lines + if (m := re.search(pattern, line)) + ] - after_wait_until_args = [] - after_script_wait_args = [] - after_while_args = [] - in_while_args = [] - after_repeat_args = [] - in_repeat_args = collections.defaultdict(list) - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if test_complete.is_set(): - return - - if mo := after_wait_until_pattern.search(line): - after_wait_until_args.append(int(mo.group(1))) - elif mo := after_script_wait_pattern.search(line): - after_script_wait_args.append(int(mo.group(1))) - elif mo := in_while_pattern.search(line): - in_while_args.append(int(mo.group(1))) - elif mo := after_while_pattern.search(line): - after_while_args.append(int(mo.group(1))) - elif mo := in_repeat_pattern.search(line): - in_repeat_args[int(mo.group(1))].append(int(mo.group(2))) - elif mo := after_repeat_pattern.search(line): - after_repeat_args.append(int(mo.group(1))) - if len(after_repeat_args) == len(expected): - test_complete.set() + def arg_list(pattern: str) -> list[int]: + return [arg for (arg,) in captures(pattern)] # Run with log monitoring async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info await assert_device_name(client, "action-concurrent-reentry") # Wait for tests to complete with timeout - await wait_or_fail(test_complete, 8.0, "test timed out") + await wait_or_fail( + waiter.wait_for_each(*(f"AFTER repeat ARG {arg}" for arg in expected)), + 8.0, + "test timed out", + ) + after_wait_until_args = arg_list(r"AFTER wait_until ARG (\d+)") + after_script_wait_args = arg_list(r"AFTER script\.wait ARG (\d+)") + after_repeat_args = arg_list(r"AFTER repeat ARG (\d+)") + after_while_args = arg_list(r"AFTER while ARG (\d+)") + in_while_args = arg_list(r"IN while ARG (\d+)") + in_repeat_args = collections.defaultdict(list) + for iteration, arg in captures(r"IN repeat (\d+) ARG (\d+)"): + in_repeat_args[iteration].append(arg) # order may change, but all args must be present for args in in_repeat_args.values(): diff --git a/tests/integration/test_api_action_metadata.py b/tests/integration/test_api_action_metadata.py index 74d40f141ba..2fdb5fc7563 100644 --- a/tests/integration/test_api_action_metadata.py +++ b/tests/integration/test_api_action_metadata.py @@ -2,13 +2,11 @@ from __future__ import annotations -import asyncio -import re - import pytest from esphome.helpers import fnv1_hash +from .log_utils import LineWaiter from .types import APIClientConnectedFactory, RunCompiledFunction @@ -19,26 +17,16 @@ async def test_api_action_metadata( api_client_connected: APIClientConnectedFactory, ) -> None: """Action and argument metadata reach the client and the actions still run.""" - loop = asyncio.get_running_loop() - buzzer_called = loop.create_future() - plain_called = loop.create_future() - buzzer_pattern = re.compile(r"Buzzer: two_short") - plain_pattern = re.compile(r"Plain action called") - - def check_output(line: str) -> None: - if not buzzer_called.done() and buzzer_pattern.search(line): - buzzer_called.set_result(True) - elif not plain_called.done() and plain_pattern.search(line): - plain_called.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): _, services = await client.list_entities_services() by_name = {service.name: service for service in services} - assert set(by_name) == {"play_buzzer", "plain_action"} + assert set(by_name) == {"play_buzzer", "plain_action", "optional_args_action"} # Keys are hashed at codegen time and must match what the client expects for name, service in by_name.items(): assert service.key == fnv1_hash(name), name @@ -57,9 +45,20 @@ async def test_api_action_metadata( assert plain.description == "" assert plain.args[0].description == "" + # Optional flags and defaults, as (optional, default) per argument + def flags(service) -> dict[str, tuple[bool, str]]: + return {arg.name: (arg.optional, arg.default_value) for arg in service.args} + + assert flags(plain) == {"value": (False, "")} + assert flags(by_name["optional_args_action"]) == { + "song_str": (True, ""), + "octave": (True, "5"), + "loud": (True, "true"), + } + await client.execute_service( buzzer, {"song_str": "two_short:d=4,o=5,b=100:16e6,16e6", "volume": 3} ) await client.execute_service(plain, {"value": 1}) - await asyncio.wait_for(buzzer_called, timeout=5.0) - await asyncio.wait_for(plain_called, timeout=5.0) + await waiter.wait_for("Buzzer: two_short", timeout=5.0) + await waiter.wait_for("Plain action called", timeout=5.0) diff --git a/tests/integration/test_api_action_responses.py b/tests/integration/test_api_action_responses.py index c703e17650e..64e5447109b 100644 --- a/tests/integration/test_api_action_responses.py +++ b/tests/integration/test_api_action_responses.py @@ -5,13 +5,12 @@ Tests the supports_response modes: none, status, optional, only. from __future__ import annotations -import asyncio import json -import re from aioesphomeapi import SupportsResponseType, UserServiceArgType import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction @@ -23,50 +22,11 @@ async def test_api_action_responses( api_client_connected: APIClientConnectedFactory, ) -> None: """Test API action response modes work correctly.""" - loop = asyncio.get_running_loop() - - # Track log messages for each action type - no_response_future = loop.create_future() - status_success_future = loop.create_future() - status_error_future = loop.create_future() - optional_response_future = loop.create_future() - only_response_future = loop.create_future() - nested_json_future = loop.create_future() - - # Patterns to match in logs - no_response_pattern = re.compile(r"ACTION_NO_RESPONSE called with: test_message") - status_success_pattern = re.compile( - r"ACTION_STATUS_RESPONSE success \(call_id=\d+\)" - ) - status_error_pattern = re.compile(r"ACTION_STATUS_RESPONSE error \(call_id=\d+\)") - optional_response_pattern = re.compile( - r"ACTION_OPTIONAL_RESPONSE \(call_id=\d+, return_response=\d+, value=42\)" - ) - only_response_pattern = re.compile( - r"ACTION_ONLY_RESPONSE \(call_id=\d+, name=World\)" - ) - nested_json_pattern = re.compile(r"ACTION_NESTED_JSON \(call_id=\d+\)") - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if not no_response_future.done() and no_response_pattern.search(line): - no_response_future.set_result(True) - elif not status_success_future.done() and status_success_pattern.search(line): - status_success_future.set_result(True) - elif not status_error_future.done() and status_error_pattern.search(line): - status_error_future.set_result(True) - elif not optional_response_future.done() and optional_response_pattern.search( - line - ): - optional_response_future.set_result(True) - elif not only_response_future.done() and only_response_pattern.search(line): - only_response_future.set_result(True) - elif not nested_json_future.done() and nested_json_pattern.search(line): - nested_json_future.set_result(True) + waiter = LineWaiter() # Run with log monitoring async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info @@ -146,7 +106,9 @@ async def test_api_action_responses( action_no_response, {"message": "test_message"} ) assert response is None, "action_no_response should not return a response" - await asyncio.wait_for(no_response_future, timeout=5.0) + await waiter.wait_for( + "ACTION_NO_RESPONSE called with: test_message", timeout=5.0 + ) # Test action_status_response with success (supports_response: status) response = await client.execute_service( @@ -154,7 +116,7 @@ async def test_api_action_responses( {"should_succeed": True}, return_response=True, ) - await asyncio.wait_for(status_success_future, timeout=5.0) + await waiter.wait_for("ACTION_STATUS_RESPONSE success (call_id=", timeout=5.0) assert response is not None, "Expected response for status action" assert response.success is True, ( f"Expected success=True, got {response.success}" @@ -169,7 +131,7 @@ async def test_api_action_responses( {"should_succeed": False}, return_response=True, ) - await asyncio.wait_for(status_error_future, timeout=5.0) + await waiter.wait_for("ACTION_STATUS_RESPONSE error (call_id=", timeout=5.0) assert response is not None, "Expected response for status action" assert response.success is False, ( f"Expected success=False, got {response.success}" @@ -185,7 +147,9 @@ async def test_api_action_responses( {"value": 42}, return_response=True, ) - await asyncio.wait_for(optional_response_future, timeout=5.0) + await waiter.wait_for( + "ACTION_OPTIONAL_RESPONSE (call_id=", "value=42)", timeout=5.0 + ) assert response is not None, "Expected response for optional action" assert response.success is True, ( f"Expected success=True, got {response.success}" @@ -205,7 +169,9 @@ async def test_api_action_responses( {"name": "World"}, return_response=True, ) - await asyncio.wait_for(only_response_future, timeout=5.0) + await waiter.wait_for( + "ACTION_ONLY_RESPONSE (call_id=", "name=World)", timeout=5.0 + ) assert response is not None, "Expected response for only action" assert response.success is True, ( f"Expected success=True, got {response.success}" @@ -224,7 +190,7 @@ async def test_api_action_responses( {}, return_response=True, ) - await asyncio.wait_for(nested_json_future, timeout=5.0) + await waiter.wait_for("ACTION_NESTED_JSON (call_id=", timeout=5.0) assert response is not None, "Expected response for nested json action" assert response.success is True, ( f"Expected success=True, got {response.success}" diff --git a/tests/integration/test_api_action_timeout.py b/tests/integration/test_api_action_timeout.py index dbe2d9029ce..6a81dcd9eca 100644 --- a/tests/integration/test_api_action_timeout.py +++ b/tests/integration/test_api_action_timeout.py @@ -8,10 +8,10 @@ from __future__ import annotations import asyncio import contextlib -import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction @@ -30,52 +30,11 @@ async def test_api_action_timeout( 2. Actions that exceed the timeout have their calls cleaned up 3. Late responses log a warning but don't crash """ - loop = asyncio.get_running_loop() - - # Track log messages - immediate_future = loop.create_future() - short_delay_responding_future = loop.create_future() - long_delay_starting_future = loop.create_future() - long_delay_responding_future = loop.create_future() - timeout_warning_future = loop.create_future() - - # Patterns to match in logs - immediate_pattern = re.compile(r"ACTION_IMMEDIATE responding") - short_delay_responding_pattern = re.compile(r"ACTION_SHORT_DELAY responding") - long_delay_starting_pattern = re.compile(r"ACTION_LONG_DELAY starting") - long_delay_responding_pattern = re.compile( - r"ACTION_LONG_DELAY responding \(after timeout\)" - ) - # This warning is logged when api.respond is called after the action call timed out - timeout_warning_pattern = re.compile( - r"Cannot send response: no active call found for action_call_id" - ) - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if not immediate_future.done() and immediate_pattern.search(line): - immediate_future.set_result(True) - elif ( - not short_delay_responding_future.done() - and short_delay_responding_pattern.search(line) - ): - short_delay_responding_future.set_result(True) - elif ( - not long_delay_starting_future.done() - and long_delay_starting_pattern.search(line) - ): - long_delay_starting_future.set_result(True) - elif ( - not long_delay_responding_future.done() - and long_delay_responding_pattern.search(line) - ): - long_delay_responding_future.set_result(True) - elif not timeout_warning_future.done() and timeout_warning_pattern.search(line): - timeout_warning_future.set_result(True) + waiter = LineWaiter() # Run with log monitoring async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info @@ -98,7 +57,7 @@ async def test_api_action_timeout( {}, return_response=True, ) - await asyncio.wait_for(immediate_future, timeout=1.0) + await waiter.wait_for("ACTION_IMMEDIATE responding", timeout=1.0) assert response is not None, "Expected response for immediate action" assert response.success is True @@ -108,7 +67,7 @@ async def test_api_action_timeout( {}, return_response=True, ) - await asyncio.wait_for(short_delay_responding_future, timeout=1.0) + await waiter.wait_for("ACTION_SHORT_DELAY responding", timeout=1.0) assert response is not None, "Expected response for short delay action" assert response.success is True @@ -128,13 +87,18 @@ async def test_api_action_timeout( ) # Wait for the action to start - await asyncio.wait_for(long_delay_starting_future, timeout=1.0) + await waiter.wait_for("ACTION_LONG_DELAY starting", timeout=1.0) # Wait for the action to try to respond (after 1s delay) - await asyncio.wait_for(long_delay_responding_future, timeout=2.0) + await waiter.wait_for( + "ACTION_LONG_DELAY responding (after timeout)", timeout=2.0 + ) # Wait for the warning log about no active call - await asyncio.wait_for(timeout_warning_future, timeout=1.0) + await waiter.wait_for( + "Cannot send response: no active call found for action_call_id", + timeout=1.0, + ) # The client task should complete (either with None response or timeout) # Client timing out is acceptable - the server-side timeout already cleaned up the call @@ -143,12 +107,6 @@ async def test_api_action_timeout( # Verify the system is still functional after the timeout # Call the immediate action again to prove cleanup worked - immediate_future_2 = loop.create_future() - - def check_output_2(line: str) -> None: - if not immediate_future_2.done() and immediate_pattern.search(line): - immediate_future_2.set_result(True) - response = await client.execute_service( action_immediate, {}, diff --git a/tests/integration/test_api_conditional_memory.py b/tests/integration/test_api_conditional_memory.py index 081b24623ed..317314e7117 100644 --- a/tests/integration/test_api_conditional_memory.py +++ b/tests/integration/test_api_conditional_memory.py @@ -2,12 +2,10 @@ from __future__ import annotations -import asyncio -import re - from aioesphomeapi import UserServiceArgType import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction @@ -19,45 +17,17 @@ async def test_api_conditional_memory( api_client_connected: APIClientConnectedFactory, ) -> None: """Test API triggers and services work correctly with conditional compilation.""" - loop = asyncio.get_running_loop() - - # Track log messages - connected_future = loop.create_future() - disconnected_future = loop.create_future() - no_clients_future = loop.create_future() - service_simple_future = loop.create_future() - service_args_future = loop.create_future() - - # Patterns to match in logs - connected_pattern = re.compile(r"Client .* connected from") - disconnected_pattern = re.compile(r"Client .* disconnected from") - no_clients_pattern = re.compile(r"No clients remaining") - service_simple_pattern = re.compile(r"Simple service called") - service_args_pattern = re.compile( - r"Service called with: test_string, 123, 1, 42\.50" - ) - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if not connected_future.done() and connected_pattern.search(line): - connected_future.set_result(True) - elif not disconnected_future.done() and disconnected_pattern.search(line): - disconnected_future.set_result(True) - elif not no_clients_future.done() and no_clients_pattern.search(line): - no_clients_future.set_result(True) - elif not service_simple_future.done() and service_simple_pattern.search(line): - service_simple_future.set_result(True) - elif not service_args_future.done() and service_args_pattern.search(line): - service_args_future.set_result(True) + waiter = LineWaiter() # Run with log monitoring - async with run_compiled(yaml_config, line_callback=check_output): + async with run_compiled(yaml_config, line_callback=waiter.callback): async with api_client_connected() as client: # Verify device info await assert_device_name(client, "api-conditional-memory-test") # Wait for connection log - await asyncio.wait_for(connected_future, timeout=5.0) + # The leading space keeps "disconnected from" from matching + await waiter.wait_for("Client", " connected from", timeout=5.0) # List services _, services = await client.list_entities_services() @@ -85,7 +55,7 @@ async def test_api_conditional_memory( await client.execute_service(simple_service, {}) # Wait for service log - await asyncio.wait_for(service_simple_future, timeout=5.0) + await waiter.wait_for("Simple service called", timeout=5.0) # Call service with arguments await client.execute_service( @@ -99,11 +69,13 @@ async def test_api_conditional_memory( ) # Wait for service with args log - await asyncio.wait_for(service_args_future, timeout=5.0) + await waiter.wait_for( + "Service called with: test_string, 123, 1, 42.50", timeout=5.0 + ) # Client disconnected here, wait for disconnect log - await asyncio.wait_for(disconnected_future, timeout=5.0) + await waiter.wait_for("Client", "disconnected from", timeout=5.0) # Verify fix for issue #11131: api.connected should be false in trigger # when the last client disconnects - await asyncio.wait_for(no_clients_future, timeout=5.0) + await waiter.wait_for("No clients remaining", timeout=5.0) diff --git a/tests/integration/test_api_cover_toggle.py b/tests/integration/test_api_cover_toggle.py new file mode 100644 index 00000000000..4a28b112d45 --- /dev/null +++ b/tests/integration/test_api_cover_toggle.py @@ -0,0 +1,108 @@ +"""The native API advertises cover toggle support and runs the toggle action on request.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import struct + +from aioesphomeapi import CoverInfo, CoverState, EntityState, api_pb2 +import pytest + +from .log_utils import LineWaiter +from .raw_api_client import ( + MESSAGE_TYPE_OF, + WIRE_FIXED32, + WIRE_VARINT, + RawApiClient, + decode_fields, + tag, +) +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +COVER_COMMAND = MESSAGE_TYPE_OF[api_pb2.CoverCommandRequest] +LIST_COVER = MESSAGE_TYPE_OF[api_pb2.ListEntitiesCoverResponse] +LIST_DONE = MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse] +# ListEntitiesCoverResponse fields; the installed client library does not know supports_toggle yet +FIELD_ENTITY_KEY, FIELD_SUPPORTS_TOGGLE = 2, 15 +# CoverCommandRequest fields +FIELD_KEY, FIELD_STOP, FIELD_TOGGLE = 1, 8, 11 + + +@pytest.mark.asyncio +async def test_api_cover_toggle( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + unused_tcp_port: int, +) -> None: + lines = LineWaiter() + async with ( + run_compiled(yaml_config, line_callback=lines.callback), + api_client_connected() as client, + RawApiClient(unused_tcp_port) as raw, + ): + entities, _ = await client.list_entities_services() + toggle_cover = require_entity(entities, "toggle_cover", CoverInfo, exact=True) + plain_cover = require_entity(entities, "plain_cover", CoverInfo, exact=True) + + positions: dict[int, list[float]] = {toggle_cover.key: [], plain_cover.key: []} + waiter = StateWaiter() + + def on_state(state: EntityState) -> None: + if isinstance(state, CoverState) and state.key in positions: + positions[state.key].append(state.position) + waiter.on_state(state) + + def position_is(key: int, value: float) -> Callable[[EntityState], bool]: + return lambda s: ( + isinstance(s, CoverState) + and s.key == key + and s.position == pytest.approx(value, abs=0.01) + ) + + initial = InitialStateHelper(entities) + client.subscribe_states(initial.on_state_wrapper(on_state)) + await initial.wait_for_initial_states() + + # Capability discovery: only the cover with a toggle_action advertises toggle + await raw.connect() + await raw.send_message(api_pb2.ListEntitiesRequest()) + await raw.read_until_frame(LIST_DONE) + supports_toggle = {} + for payload in raw.frames(LIST_COVER): + fields = decode_fields(payload) + key = fields[FIELD_ENTITY_KEY][0] + supports_toggle[key] = bool(fields.get(FIELD_SUPPORTS_TOGGLE, [0])[0]) + assert supports_toggle == {toggle_cover.key: True, plain_cover.key: False} + + def key_field(key: int) -> bytes: + return tag(FIELD_KEY, WIRE_FIXED32) + struct.pack(" None: """Test CustomAPIDevice services work correctly with custom_services: true.""" - loop = asyncio.get_running_loop() - - # Track log messages - yaml_service_future = loop.create_future() - yaml_args_future = loop.create_future() - yaml_many_args_future = loop.create_future() - custom_service_future = loop.create_future() - custom_args_future = loop.create_future() - custom_arrays_future = loop.create_future() - ha_state_future = loop.create_future() - - # Patterns to match in logs - yaml_service_pattern = re.compile(r"YAML service called") - yaml_args_pattern = re.compile(r"YAML service with args: 123, test_value") - yaml_many_args_pattern = re.compile(r"YAML service many args: 42, 3\.14, 1, hello") - custom_service_pattern = re.compile(r"Custom test service called!") - custom_args_pattern = re.compile( - r"Custom service called with: test_string, 456, 1, 78\.90" - ) - custom_arrays_pattern = re.compile( - r"Array service called with 2 bools, 3 ints, 2 floats, 2 strings" - ) - ha_state_pattern = re.compile( - r"This subscription uses std::string API for backward compatibility" - ) - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if not yaml_service_future.done() and yaml_service_pattern.search(line): - yaml_service_future.set_result(True) - elif not yaml_args_future.done() and yaml_args_pattern.search(line): - yaml_args_future.set_result(True) - elif not yaml_many_args_future.done() and yaml_many_args_pattern.search(line): - yaml_many_args_future.set_result(True) - elif not custom_service_future.done() and custom_service_pattern.search(line): - custom_service_future.set_result(True) - elif not custom_args_future.done() and custom_args_pattern.search(line): - custom_args_future.set_result(True) - elif not custom_arrays_future.done() and custom_arrays_pattern.search(line): - custom_arrays_future.set_result(True) - elif not ha_state_future.done() and ha_state_pattern.search(line): - ha_state_future.set_result(True) + waiter = LineWaiter() # Run with log monitoring async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info @@ -88,7 +45,7 @@ async def test_api_custom_services( # Test YAML service await client.execute_service(yaml_service, {}) - await asyncio.wait_for(yaml_service_future, timeout=5.0) + await waiter.wait_for("YAML service called", timeout=5.0) # Verify YAML service with args arguments assert len(yaml_args_service.args) == 2 @@ -104,7 +61,7 @@ async def test_api_custom_services( "my_string": "test_value", }, ) - await asyncio.wait_for(yaml_args_future, timeout=5.0) + await waiter.wait_for("YAML service with args: 123, test_value", timeout=5.0) # Verify YAML service with many args arguments assert len(yaml_many_args_service.args) == 4 @@ -126,11 +83,11 @@ async def test_api_custom_services( "arg4": "hello", }, ) - await asyncio.wait_for(yaml_many_args_future, timeout=5.0) + await waiter.wait_for("YAML service many args: 42, 3.14, 1, hello", timeout=5.0) # Test simple CustomAPIDevice service await client.execute_service(custom_service, {}) - await asyncio.wait_for(custom_service_future, timeout=5.0) + await waiter.wait_for("Custom test service called!", timeout=5.0) # Verify custom_args_service arguments assert len(custom_args_service.args) == 4 @@ -150,7 +107,9 @@ async def test_api_custom_services( "arg_float": 78.9, }, ) - await asyncio.wait_for(custom_args_future, timeout=5.0) + await waiter.wait_for( + "Custom service called with: test_string, 456, 1, 78.90", timeout=5.0 + ) # Verify array service arguments assert len(custom_arrays_service.args) == 4 @@ -170,9 +129,15 @@ async def test_api_custom_services( "string_array": ["hello", "world"], }, ) - await asyncio.wait_for(custom_arrays_future, timeout=5.0) + await waiter.wait_for( + "Array service called with 2 bools, 3 ints, 2 floats, 2 strings", + timeout=5.0, + ) # Test Home Assistant state subscription (std::string API backward compatibility) # This verifies that custom_api_device.h can still use std::string overloads client.send_home_assistant_state("sensor.custom_test", "", "42.5") - await asyncio.wait_for(ha_state_future, timeout=5.0) + await waiter.wait_for( + "This subscription uses std::string API for backward compatibility", + timeout=5.0, + ) diff --git a/tests/integration/test_api_decode_wire_types.py b/tests/integration/test_api_decode_wire_types.py index 5c8444273a5..530534d1648 100644 --- a/tests/integration/test_api_decode_wire_types.py +++ b/tests/integration/test_api_decode_wire_types.py @@ -16,16 +16,19 @@ from aioesphomeapi import ( ) import pytest -from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, encode_varint +from .raw_api_client import ( + MESSAGE_TYPE_OF, + WIRE_FIXED32, + WIRE_LENGTH, + WIRE_VARINT, + RawApiClient, + encode_varint, + tag, +) from .state_utils import InitialStateHelper, StateWaiter, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction SWITCH_COMMAND = MESSAGE_TYPE_OF[api_pb2.SwitchCommandRequest] -WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5 - - -def tag(field: int, wire_type: int) -> bytes: - return encode_varint((field << 3) | wire_type) @pytest.mark.asyncio diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index 42439e78bbe..b2882905f4f 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -13,11 +13,17 @@ from __future__ import annotations import asyncio import re -from aioesphomeapi import HomeassistantServiceCall +from aioesphomeapi import HomeassistantServiceCall, SensorInfo, SensorState import pytest -from .state_utils import assert_device_name, require_service +from .state_utils import ( + assert_device_name, + require_entity, + require_service, + wait_for_state, +) from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -224,182 +230,213 @@ async def test_api_homeassistant( client.send_home_assistant_state(entity, "step", "0.5") # List entities and services - _, services = await client.list_entities_services() + entities, services = await client.list_entities_services() # Find the trigger service trigger_service = require_service(services, "trigger_all_tests") + # The complex lambda service reads the template sensor, whose first update lands at + # a random point in its interval; until then its state is NAN, which the int cast + # turns into INT_MIN. Wait for the first reading before firing the tests. + test_sensor = require_entity(entities, "test_sensor", SensorInfo, exact=True) + await wait_for_state( + client, + lambda s: ( + isinstance(s, SensorState) + and s.key == test_sensor.key + and s.state == 42.0 + ), + ) + # Execute all tests await client.execute_service(trigger_service, {}) # Wait for all tests to complete with appropriate timeouts - try: - # Templated service test - the main bug fix - computed_value = await asyncio.wait_for(lambda_computed_future, timeout=5.0) - # Verify the computed value is reasonable (75 * 255 / 100 = 191.25 -> 191) - assert computed_value in ["191", "192"], ( - f"Unexpected computed value: {computed_value}" - ) + # Shown when a log line or service call does not arrive + ctx = { + "logged": log_lines, + "calls": lambda: [call.service for call in ha_service_calls], + } + # Templated service test - the main bug fix + computed_value = await wait_or_fail( + lambda_computed_future, 5.0, "no templated value", **ctx + ) + # Verify the computed value is reasonable (75 * 255 / 100 = 191.25 -> 191) + assert computed_value in ["191", "192"], ( + f"Unexpected computed value: {computed_value}" + ) - # Check state reads - verify we received the mocked values - temp_line = await asyncio.wait_for(ha_temp_state_future, timeout=5.0) - assert "Current HA Temperature: 22.5" in temp_line + # Check state reads - verify we received the mocked values + temp_line = await wait_or_fail( + ha_temp_state_future, 5.0, "no HA temperature", **ctx + ) + assert "Current HA Temperature: 22.5" in temp_line - humidity_line = await asyncio.wait_for( - ha_humidity_state_future, timeout=5.0 - ) - assert "Current HA Humidity: 65.0" in humidity_line + humidity_line = await wait_or_fail( + ha_humidity_state_future, 5.0, "no HA humidity", **ctx + ) + assert "Current HA Humidity: 65.0" in humidity_line - motion_line = await asyncio.wait_for(ha_motion_state_future, timeout=5.0) - assert "Current HA Motion: ON" in motion_line + motion_line = await wait_or_fail( + ha_motion_state_future, 5.0, "no HA motion", **ctx + ) + assert "Current HA Motion: ON" in motion_line - weather_line = await asyncio.wait_for(ha_weather_state_future, timeout=5.0) - assert "Current HA Weather: sunny" in weather_line + weather_line = await wait_or_fail( + ha_weather_state_future, 5.0, "no HA weather", **ctx + ) + assert "Current HA Weather: sunny" in weather_line - # Number test - number_value = await asyncio.wait_for(ha_number_future, timeout=5.0) - assert number_value == "42.5", f"Unexpected number value: {number_value}" + # Number test + number_value = await wait_or_fail( + ha_number_future, 5.0, "no HA number value", **ctx + ) + assert number_value == "42.5", f"Unexpected number value: {number_value}" - # Long attribute test - verify 300 chars weren't truncated to 255 - long_attr_len = await asyncio.wait_for(long_attr_future, timeout=5.0) - assert long_attr_len == 300, ( - f"Long attribute was truncated! Expected 300 chars, got {long_attr_len}. " - "This indicates the 256-byte truncation bug." - ) + # Long attribute test - verify 300 chars weren't truncated to 255 + long_attr_len = await wait_or_fail( + long_attr_future, 5.0, "no long attribute", **ctx + ) + assert long_attr_len == 300, ( + f"Long attribute was truncated! Expected 300 chars, got {long_attr_len}. " + "This indicates the 256-byte truncation bug." + ) - # Wait for completion - await asyncio.wait_for(tests_complete_future, timeout=5.0) + # Wait for completion + await wait_or_fail(tests_complete_future, 5.0, "tests did not complete", **ctx) - # Now verify the protobuf messages - # 1. Basic service call - basic_call = await asyncio.wait_for( - service_call_futures["light.turn_off"], timeout=2.0 - ) - assert basic_call.service == "light.turn_off" - assert "entity_id" in basic_call.data, ( - f"entity_id not found in data: {basic_call.data}" - ) - assert basic_call.data["entity_id"] == "light.test_light", ( - f"Wrong entity_id: {basic_call.data['entity_id']}" - ) + # Now verify the protobuf messages + # 1. Basic service call + basic_call = await wait_or_fail( + service_call_futures["light.turn_off"], 2.0, "no light.turn_off call", **ctx + ) + assert basic_call.service == "light.turn_off" + assert "entity_id" in basic_call.data, ( + f"entity_id not found in data: {basic_call.data}" + ) + assert basic_call.data["entity_id"] == "light.test_light", ( + f"Wrong entity_id: {basic_call.data['entity_id']}" + ) - # 2. Templated service call - verify the temporary string issue is fixed - templated_call = await asyncio.wait_for( - service_call_futures["light.turn_on"], timeout=2.0 - ) - assert templated_call.service == "light.turn_on" - # Check the computed brightness value - assert "brightness" in templated_call.data - assert templated_call.data["brightness"] in ["191", "192"] # 75 * 255 / 100 - # Check data_template - assert "color_name" in templated_call.data_template - assert templated_call.data_template["color_name"] == "test_value" - # Check variables - assert "transition" in templated_call.variables - assert templated_call.variables["transition"] == "2.5" + # 2. Templated service call - verify the temporary string issue is fixed + templated_call = await wait_or_fail( + service_call_futures["light.turn_on"], 2.0, "no light.turn_on call", **ctx + ) + assert templated_call.service == "light.turn_on" + # Check the computed brightness value + assert "brightness" in templated_call.data + assert templated_call.data["brightness"] in ["191", "192"] # 75 * 255 / 100 + # Check data_template + assert "color_name" in templated_call.data_template + assert templated_call.data_template["color_name"] == "test_value" + # Check variables + assert "transition" in templated_call.variables + assert templated_call.variables["transition"] == "2.5" - # 3. Empty string service call - empty_call = await asyncio.wait_for( - service_call_futures["notify.test"], timeout=2.0 - ) - assert empty_call.service == "notify.test" - # Verify empty strings are properly handled - assert "title" in empty_call.data and empty_call.data["title"] == "" - assert ( - "target" in empty_call.data_template - and empty_call.data_template["target"] == "" - ) - assert ( - "sound" in empty_call.variables and empty_call.variables["sound"] == "" - ) + # 3. Empty string service call + empty_call = await wait_or_fail( + service_call_futures["notify.test"], 2.0, "no notify.test call", **ctx + ) + assert empty_call.service == "notify.test" + # Verify empty strings are properly handled + assert "title" in empty_call.data and empty_call.data["title"] == "" + assert ( + "target" in empty_call.data_template + and empty_call.data_template["target"] == "" + ) + assert "sound" in empty_call.variables and empty_call.variables["sound"] == "" - # 4. Multiple fields service call - multi_call = await asyncio.wait_for( - service_call_futures["climate.set_temperature"], timeout=2.0 - ) - assert multi_call.service == "climate.set_temperature" - assert multi_call.data["temperature"] == "22" - assert multi_call.data["hvac_mode"] == "heat" - assert multi_call.data_template["target_temp_high"] == "24" - assert multi_call.variables["preset_mode"] == "comfort" + # 4. Multiple fields service call + multi_call = await wait_or_fail( + service_call_futures["climate.set_temperature"], + 2.0, + "no climate.set_temperature call", + **ctx, + ) + assert multi_call.service == "climate.set_temperature" + assert multi_call.data["temperature"] == "22" + assert multi_call.data["hvac_mode"] == "heat" + assert multi_call.data_template["target_temp_high"] == "24" + assert multi_call.variables["preset_mode"] == "comfort" - # 5. Complex lambda service call - complex_call = await asyncio.wait_for( - service_call_futures["script.test_script"], timeout=2.0 - ) - assert complex_call.service == "script.test_script" - assert complex_call.data["entity_id"] == "light.living_room" - assert complex_call.data["brightness_pct"] == "99" # 42 * 2.38 ≈ 99 - # Check message includes sensor value - assert "message" in complex_call.data_template - assert "Sensor: 42.0" in complex_call.data_template["message"] + # 5. Complex lambda service call + complex_call = await wait_or_fail( + service_call_futures["script.test_script"], + 2.0, + "no script.test_script call", + **ctx, + ) + assert complex_call.service == "script.test_script" + assert complex_call.data["entity_id"] == "light.living_room" + assert complex_call.data["brightness_pct"] == "99" # 42 * 2.38 ≈ 99 + # Check message includes sensor value + assert "message" in complex_call.data_template + assert "Sensor: 42.0" in complex_call.data_template["message"] - # 6. All empty service call - all_empty_call = await asyncio.wait_for( - service_call_futures["test.empty"], timeout=2.0 - ) - assert all_empty_call.service == "test.empty" - # All fields should be empty strings - assert all(v == "" for v in all_empty_call.data.values()) - assert all(v == "" for v in all_empty_call.data_template.values()) - assert all(v == "" for v in all_empty_call.variables.values()) + # 6. All empty service call + all_empty_call = await wait_or_fail( + service_call_futures["test.empty"], 2.0, "no test.empty call", **ctx + ) + assert all_empty_call.service == "test.empty" + # All fields should be empty strings + assert all(v == "" for v in all_empty_call.data.values()) + assert all(v == "" for v in all_empty_call.data_template.values()) + assert all(v == "" for v in all_empty_call.variables.values()) - # 7. HA Number service call - number_call = await asyncio.wait_for( - service_call_futures["input_number.set_value"], timeout=2.0 - ) - assert number_call.service == "input_number.set_value" - assert number_call.data["entity_id"] == "input_number.test_number" - # The value might be formatted with trailing zeros - assert float(number_call.data["value"]) == 42.5 + # 7. HA Number service call + number_call = await wait_or_fail( + service_call_futures["input_number.set_value"], + 2.0, + "no input_number.set_value call", + **ctx, + ) + assert number_call.service == "input_number.set_value" + assert number_call.data["entity_id"] == "input_number.test_number" + # The value might be formatted with trailing zeros + assert float(number_call.data["value"]) == 42.5 - # 7b. Real homeassistant numbers pick the action from the entity domain - real_number_call = await asyncio.wait_for( - ha_real_number_futures[("number.set_value", "number.test_real_number")], - timeout=2.0, - ) - assert float(real_number_call.data["value"]) == 12.5 + # 7b. Real homeassistant numbers pick the action from the entity domain + real_number_call = await wait_or_fail( + ha_real_number_futures[("number.set_value", "number.test_real_number")], + 2.0, + "no number.set_value call", + **ctx, + ) + assert float(real_number_call.data["value"]) == 12.5 - real_input_number_call = await asyncio.wait_for( - ha_real_number_futures[ - ("input_number.set_value", "input_number.test_real_input_number") - ], - timeout=2.0, - ) - assert float(real_input_number_call.data["value"]) == 7 + real_input_number_call = await wait_or_fail( + ha_real_number_futures[ + ("input_number.set_value", "input_number.test_real_input_number") + ], + 2.0, + "no input_number.set_value call for the real input number", + **ctx, + ) + assert float(real_input_number_call.data["value"]) == 7 - # 8. HA Switch service calls - switch_on_call = await asyncio.wait_for( - service_call_futures["switch.turn_on"], timeout=2.0 - ) - assert switch_on_call.service == "switch.turn_on" - assert switch_on_call.data["entity_id"] == "switch.test_switch" + # 8. HA Switch service calls + switch_on_call = await wait_or_fail( + service_call_futures["switch.turn_on"], 2.0, "no switch.turn_on call", **ctx + ) + assert switch_on_call.service == "switch.turn_on" + assert switch_on_call.data["entity_id"] == "switch.test_switch" - switch_off_call = await asyncio.wait_for( - service_call_futures["switch.turn_off"], timeout=2.0 - ) - assert switch_off_call.service == "switch.turn_off" - assert switch_off_call.data["entity_id"] == "switch.test_switch" + switch_off_call = await wait_or_fail( + service_call_futures["switch.turn_off"], + 2.0, + "no switch.turn_off call", + **ctx, + ) + assert switch_off_call.service == "switch.turn_off" + assert switch_off_call.data["entity_id"] == "switch.test_switch" - # 9. Action response error test (tests StringRef error message) - # The error response is sent automatically in on_service_call callback - # Wait for the error to be logged (proves StringRef -> std::string works) - error_log_line = await asyncio.wait_for( - action_error_received_future, timeout=2.0 - ) - test_error_message = "Test error: action not found" - assert test_error_message in error_log_line, ( - f"Expected error message '{test_error_message}' not found in: {error_log_line}" - ) - - except TimeoutError as e: - # Show recent log lines for debugging - recent_logs = "\n".join(log_lines[-20:]) - service_calls_summary = "\n".join( - f"- {call.service}" for call in ha_service_calls - ) - pytest.fail( - f"Test timed out waiting for expected log pattern or service call. Error: {e}\n\n" - f"Recent log lines:\n{recent_logs}\n\n" - f"Received service calls:\n{service_calls_summary}" - ) + # 9. Action response error test (tests StringRef error message) + # The error response is sent automatically in on_service_call callback + # Wait for the error to be logged (proves StringRef -> std::string works) + error_log_line = await wait_or_fail( + action_error_received_future, 2.0, "no action error logged", **ctx + ) + test_error_message = "Test error: action not found" + assert test_error_message in error_log_line, ( + f"Expected error message '{test_error_message}' not found in: {error_log_line}" + ) diff --git a/tests/integration/test_api_homeassistant_action_no_subscriber.py b/tests/integration/test_api_homeassistant_action_no_subscriber.py index ceecf4c51c8..deec513a602 100644 --- a/tests/integration/test_api_homeassistant_action_no_subscriber.py +++ b/tests/integration/test_api_homeassistant_action_no_subscriber.py @@ -13,6 +13,7 @@ import asyncio from aioesphomeapi import ButtonInfo, HomeassistantServiceCall import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_only_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -25,26 +26,9 @@ async def test_api_homeassistant_action_no_subscriber( ) -> None: """Undeliverable actions warn in the log; actions after subscribing arrive.""" loop = asyncio.get_running_loop() - - boot_warning_future = loop.create_future() - connected_warning_future = loop.create_future() + waiter = LineWaiter() button_action_future = loop.create_future() - def check_output(line: str) -> None: - if ( - not boot_warning_future.done() - and "Home Assistant action 'test.boot_action' dropped; no client connected" - in line - ): - boot_warning_future.set_result(True) - if ( - not connected_warning_future.done() - and "Home Assistant action 'test.connected_action' dropped; " - "client has not subscribed to actions (yet)" - in line - ): - connected_warning_future.set_result(True) - service_calls: list[HomeassistantServiceCall] = [] def on_service_call(service_call: HomeassistantServiceCall) -> None: @@ -55,16 +39,23 @@ async def test_api_homeassistant_action_no_subscriber( ): button_action_future.set_result(service_call) - async with run_compiled(yaml_config, line_callback=check_output): + async with run_compiled(yaml_config, line_callback=waiter.callback): # The on_boot action fires with no client connected at all. - await asyncio.wait_for(boot_warning_future, timeout=10.0) + await waiter.wait_for( + "Home Assistant action 'test.boot_action' dropped; no client connected", + timeout=10.0, + ) async with api_client_connected() as client: await assert_device_name(client, "test-ha-action-no-subscriber") # on_client_connected fired at authentication, before this client # subscribed to Home Assistant actions. - await asyncio.wait_for(connected_warning_future, timeout=5.0) + await waiter.wait_for( + "Home Assistant action 'test.connected_action' dropped; " + "client has not subscribed to actions (yet)", + timeout=5.0, + ) # After subscribing, actions must be delivered normally (and the # dropped ones must not suddenly show up). diff --git a/tests/integration/test_api_homeassistant_light.py b/tests/integration/test_api_homeassistant_light.py index 1ff9dd2153a..a136c58c8c7 100644 --- a/tests/integration/test_api_homeassistant_light.py +++ b/tests/integration/test_api_homeassistant_light.py @@ -23,6 +23,7 @@ import pytest from .log_utils import LineWaiter from .state_utils import require_entity, require_service from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail ENTITY = "light.kitchen" SUBSCRIPTIONS = { @@ -138,10 +139,12 @@ async def light_entity( client.subscribe_service_calls(calls.put_nowait) client.subscribe_states(on_state) client.subscribe_home_assistant_states(on_state_sub) - try: - await asyncio.wait_for(all_subscribed, timeout=5.0) - except TimeoutError: - pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}") + await wait_or_fail( + all_subscribed, + 5.0, + "never subscribed", + missing=lambda: SUBSCRIPTIONS - subscribed, + ) ha = FakeHomeAssistant(client, key, services, waiter, calls, states) ha.report("supported_color_modes", modes) ha.report("min_color_temp_kelvin", "2000") @@ -161,6 +164,7 @@ async def test_api_homeassistant_light( yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgb']" ) as ha: set_force_off = require_service(ha.services, "set_force_off") + retarget_many = require_service(ha.services, "retarget_many") # Normal while Home Assistant starts, so no warning ha.report(None, "unavailable") ha.report(None, "unknown") @@ -318,6 +322,16 @@ async def test_api_homeassistant_light( ha.report("brightness", "255") await ha.next_state(lambda s: s.brightness == 1.0) + # Or a transition to where the one the report was held for was already going + ha.command(brightness=0.5, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.command(brightness=0.5, transition_length=1.0) + call = await ha.expect_call() + assert call.data["brightness"] == "128" + ha.report("brightness", "128") + await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255)) + # And when it goes back to the state the transition started from ha.command(state=False) await ha.expect_call("light.turn_off") @@ -359,6 +373,35 @@ async def test_api_homeassistant_light( await ha.expect_call("light.turn_off") await ha.report_off() + # Calls made in one pass replace a held report however many there are + ha.command(state=True, brightness=0.5, transition_length=1.0) + ha.report(None, "on") + ha.report("brightness", "50") + await asyncio.sleep(0.1) + await ha.client.execute_service(retarget_many, {}) + call = await ha.expect_call() + assert call.data["brightness"] == "255" + ha.report("brightness", "255") + await ha.next_state(lambda s: s.state and s.brightness == 1.0) + ha.command(state=False) + await ha.expect_call("light.turn_off") + await ha.report_off() + + # A repeated report is not held, however many calls came since the last changed one + ha.report(None, "on") + await ha.next_state(lambda s: s.state) + await ha.report_off() + for _ in range(15): + ha.command(state=False) + ha.command(state=True, transition_length=1.0) + ha.report(None, "off") + await ha.expect_call() + ha.report(None, "on") + await ha.next_state(lambda s: s.state) + ha.command(state=False) + await ha.expect_call("light.turn_off") + await ha.report_off() + # A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told ha.command(state=True, flash_length=0.5) await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0) diff --git a/tests/integration/test_api_list_entities_backpressure.py b/tests/integration/test_api_list_entities_backpressure.py index b5769101cbb..84934243bf1 100644 --- a/tests/integration/test_api_list_entities_backpressure.py +++ b/tests/integration/test_api_list_entities_backpressure.py @@ -18,6 +18,7 @@ import asyncio from aioesphomeapi import api_pb2 import pytest +from .log_utils import LineWaiter from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction @@ -71,15 +72,10 @@ async def test_api_list_entities_backpressure( config = yaml_config.replace("# GENERATED_ACTIONS", _generated_actions()) config = config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF)) - pin_applied = asyncio.Event() - - def _on_log_line(line: str) -> None: - if SNDBUF_PIN_LOG in line: - pin_applied.set() - - async with run_compiled(config, line_callback=_on_log_line): + waiter = LineWaiter() + async with run_compiled(config, line_callback=waiter.callback): # Fails loudly if the pin never applied - await asyncio.wait_for(pin_applied.wait(), 10) + await waiter.wait_for(SNDBUF_PIN_LOG, timeout=10) async with RawApiClient( unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE diff --git a/tests/integration/test_api_message_size_batching.py b/tests/integration/test_api_message_size_batching.py index 51ce639969e..ef6e406da2c 100644 --- a/tests/integration/test_api_message_size_batching.py +++ b/tests/integration/test_api_message_size_batching.py @@ -9,6 +9,7 @@ import pytest from .state_utils import assert_device_name, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -128,15 +129,13 @@ async def test_api_message_size_batching( client.subscribe_states(on_state) # Wait for states with timeout - try: - await asyncio.wait_for(states_future, timeout=5.0) - except TimeoutError: - missing_keys = expected_keys - received_keys - pytest.fail( - f"Did not receive states from all entities within 5 seconds. " - f"Missing keys: {missing_keys}, " - f"Received {len(received_keys)} of {len(expected_keys)} expected states" - ) + await wait_or_fail( + states_future, + 5.0, + "Did not receive states from all entities", + missing_keys=lambda: expected_keys - received_keys, + received=lambda: f"{len(received_keys)} of {len(expected_keys)}", + ) # Verify we received states from all entities assert expected_keys.issubset(received_keys) diff --git a/tests/integration/test_api_reboot_timeout.py b/tests/integration/test_api_reboot_timeout.py index 91633661261..d7206323492 100644 --- a/tests/integration/test_api_reboot_timeout.py +++ b/tests/integration/test_api_reboot_timeout.py @@ -1,10 +1,8 @@ """Test API server reboot timeout functionality.""" -import asyncio -import re - import pytest +from .log_utils import LineWaiter from .types import RunCompiledFunction from .wait_utils import wait_or_fail @@ -15,21 +13,15 @@ async def test_api_reboot_timeout( run_compiled: RunCompiledFunction, ) -> None: """Test that the device reboots when no API clients connect within the timeout.""" - loop = asyncio.get_running_loop() - reboot_future = loop.create_future() - reboot_pattern = re.compile(r"No clients; rebooting") - - def check_output(line: str) -> None: - """Check output for reboot message.""" - if not reboot_future.done() and reboot_pattern.search(line): - reboot_future.set_result(True) + waiter = LineWaiter() # Run the device without connecting any API client - async with run_compiled(yaml_config, line_callback=check_output): - # Wait for reboot with timeout - # (0.5s reboot timeout + some margin for processing) + async with run_compiled(yaml_config, line_callback=waiter.callback): + # 0.5s reboot timeout + some margin for processing await wait_or_fail( - reboot_future, 2.0, "Device did not reboot within expected timeout" + waiter.wait_for("No clients; rebooting"), + 2.0, + "Device did not reboot within expected timeout", ) # Test passes if we get here - reboot was detected diff --git a/tests/integration/test_api_string_lambda.py b/tests/integration/test_api_string_lambda.py index 4caefb469f8..0199edd9186 100644 --- a/tests/integration/test_api_string_lambda.py +++ b/tests/integration/test_api_string_lambda.py @@ -2,11 +2,9 @@ from __future__ import annotations -import asyncio -import re - import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -19,34 +17,11 @@ async def test_api_string_lambda( api_client_connected: APIClientConnectedFactory, ) -> None: """Test Home Assistant action lambdas that return different types are sent as strings.""" - loop = asyncio.get_running_loop() - - # Track log messages for all four service calls - string_called_future = loop.create_future() - int_called_future = loop.create_future() - float_called_future = loop.create_future() - char_ptr_called_future = loop.create_future() - - # Patterns to match in logs - confirms the lambdas compiled and executed - string_pattern = re.compile(r"Service called with string: STRING_FROM_LAMBDA") - int_pattern = re.compile(r"Service called with int: 42") - float_pattern = re.compile(r"Service called with float: 3\.14") - char_ptr_pattern = re.compile(r"Service called with number for char\* test: 123") - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if not string_called_future.done() and string_pattern.search(line): - string_called_future.set_result(True) - if not int_called_future.done() and int_pattern.search(line): - int_called_future.set_result(True) - if not float_called_future.done() and float_pattern.search(line): - float_called_future.set_result(True) - if not char_ptr_called_future.done() and char_ptr_pattern.search(line): - char_ptr_called_future.set_result(True) + waiter = LineWaiter() # Run with log monitoring async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info @@ -74,15 +49,16 @@ async def test_api_string_lambda( char_ptr_service, {"input_number": 123, "input_string": "test_string"} ) - # Wait for all service log messages - # This confirms the lambdas compiled successfully and executed + # Confirms the lambdas compiled and executed await wait_or_fail( - asyncio.gather( - string_called_future, - int_called_future, - float_called_future, - char_ptr_called_future, + waiter.wait_for_each( + "Service called with string: STRING_FROM_LAMBDA", + "Service called with int: 42", + "Service called with float: 3.14", + "Service called with number for char* test: 123", + timeout=10.0, ), 5.0, - "One or more service log messages not received - lambda may have failed to compile or execute", + "Not every lambda service logged its call", + logged=waiter.lines, ) diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py index 7455ec7428d..135a112c366 100644 --- a/tests/integration/test_areas_and_devices.py +++ b/tests/integration/test_areas_and_devices.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from aioesphomeapi import EntityState, SwitchInfo, SwitchState +from aioesphomeapi import DeviceRelation, EntityState, SwitchInfo, SwitchState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction @@ -69,6 +69,20 @@ async def test_areas_and_devices( "Smart Switch should be in Kitchen" ) + # The fixture mixes relations, so each device stores and sends its own + assert light_controller.relation == DeviceRelation.VIA, ( + "Light Controller should be a via device" + ) + assert temp_sensor.relation == DeviceRelation.VIA, ( + "Temperature Sensor should be a via device" + ) + assert motion_detector.relation == DeviceRelation.CHILD, ( + "Motion Detector should be a child device" + ) + assert smart_switch.relation == DeviceRelation.CHILD, ( + "Smart Switch should be a child device" + ) + # Verify suggested_area is set to the top-level area name assert device_info.suggested_area == "Living Room", ( f"Expected suggested_area to be 'Living Room', got '{device_info.suggested_area}'" diff --git a/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py b/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py index e8b08f2265a..54767707c2d 100644 --- a/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py +++ b/tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py @@ -2,11 +2,11 @@ from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .types import APIClientConnectedFactory, RunCompiledFunction WARN_PATTERN = re.compile( @@ -23,25 +23,24 @@ async def test_blocking_warning_log_time_not_charged_to_next_operation( api_client_connected: APIClientConnectedFactory, ) -> None: """Synchronous warning-log delays must not be charged to the next operation.""" - loop = asyncio.get_running_loop() - complete = asyncio.Event() - warnings: list[tuple[str, int, int]] = [] - injected_delay_total = 0 - - def check_output(line: str) -> None: - nonlocal injected_delay_total - if match := WARN_PATTERN.search(line): - warnings.append((match.group(1), int(match.group(2)), int(match.group(3)))) - if match := COMPLETE_PATTERN.search(line): - injected_delay_total = int(match.group(1)) - loop.call_soon_threadsafe(complete.set) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): assert await client.device_info() is not None - await asyncio.wait_for(complete.wait(), timeout=10.0) + complete_line = await waiter.wait_for( + "BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=", timeout=10.0 + ) + + assert (match := COMPLETE_PATTERN.search(complete_line)), complete_line + injected_delay_total = int(match.group(1)) + warnings = [ + (m.group(1), int(m.group(2)), int(m.group(3))) + for line in waiter.lines + if (m := WARN_PATTERN.search(line)) + ] assert injected_delay_total == 270, ( f"Expected 270 ms of injected warning-log delay, got {injected_delay_total} ms" diff --git a/tests/integration/test_branch_resume_async.py b/tests/integration/test_branch_resume_async.py index f6fb5dbd457..75ed2b90289 100644 --- a/tests/integration/test_branch_resume_async.py +++ b/tests/integration/test_branch_resume_async.py @@ -7,6 +7,7 @@ import re import pytest +from .log_utils import LineWaiter from .types import APIClientConnectedFactory, RunCompiledFunction @@ -17,97 +18,76 @@ async def test_branch_resume_async( api_client_connected: APIClientConnectedFactory, ) -> None: """A branch that ends after a delay must resume its owner exactly once.""" - lines: list[str] = [] - waiters: list[tuple[str, asyncio.Future[None]]] = [] + waiter = LineWaiter() def has_marker(text: str, line: str) -> bool: # Whole markers only: "after-if" must not match "queued-after-if-1" return re.search(rf"(? None: - lines.append(line) - for text, future in waiters: - if has_marker(text, line) and not future.done(): - future.set_result(None) - - def wait_for(text: str) -> asyncio.Future[None]: - future: asyncio.Future[None] = asyncio.get_running_loop().create_future() - waiters.append((text, future)) - return future - def count(text: str) -> int: - return sum(has_marker(text, line) for line in lines) + return sum(has_marker(text, line) for line in waiter.lines) async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): _, services = await client.list_entities_services() service = {s.name: s for s in services} - done = wait_for("after-if") await client.execute_service(service["run_delay_in_if"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("after-if", timeout=2.0) assert count("if-branch-after-delay") == 1 assert count("if-else-should-not-run") == 0 assert count("after-if") == 1 - done = wait_for("after-else") await client.execute_service(service["run_delay_in_else"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("after-else", timeout=2.0) assert count("else-branch-after-delay") == 1 assert count("if-then-should-not-run") == 0 assert count("after-else") == 1 - done = wait_for("after-while") await client.execute_service(service["run_delay_in_while"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("after-while", timeout=2.0) assert [count(f"while-body-{i}") for i in (1, 2, 3)] == [1, 1, 1] assert count("while-body-4") == 0 assert count("after-while") == 1 - done = wait_for("stop-check-done") await client.execute_service(service["run_stop_in_branch"], {}) - await asyncio.wait_for(done, timeout=3.0) + await waiter.wait_for("stop-check-done", timeout=3.0) assert count("stop-running=0") == 1 assert count("stopped-branch-should-not-run") == 0 assert count("stopped-after-if-should-not-run") == 0 # A while condition that stops the script on a later pass must not start the body again - done = wait_for("later-check-done") await client.execute_service(service["run_stop_in_later_while_check"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("later-check-done", timeout=2.0) assert count("later-check-body-1") == 1 assert count("later-check-body-2") == 0 assert count("after-later-check-should-not-run") == 0 # An empty loop body cannot resume the loop, so the while finishes at once - done = wait_for("after-empty-while") await client.execute_service(service["run_empty_while"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("after-empty-while", timeout=2.0) assert count("after-empty-while") == 1 - done = wait_for("after-nested-while") await client.execute_service(service["run_nested_if_last_in_while"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("after-nested-while", timeout=2.0) assert [count(f"nested-if-in-while-{i}") for i in (1, 2)] == [1, 1] assert count("nested-if-in-while-3") == 0 assert count("after-nested-while") == 1 # Stop during the second iteration's delay: that iteration and the line after the loop never run; # a fresh run then completes - done = wait_for("stop-while-done") await client.execute_service(service["run_stop_mid_while"], {}) - await asyncio.wait_for(done, timeout=3.0) + await waiter.wait_for("stop-while-done", timeout=3.0) assert count("stop-while-running=0") == 1 assert count("stop-while-body-1") == 2 assert [count(f"stop-while-body-{i}") for i in (2, 3, 4, 5)] == [1, 1, 1, 1] assert count("stop-while-done") == 1 - done = wait_for("queued-after-if-3") await client.execute_service(service["run_queued_branch"], {}) - await asyncio.wait_for(done, timeout=2.0) - queued = [line for line in lines if "queued-" in line] + await waiter.wait_for("queued-after-if-3", timeout=2.0) + queued = [line for line in waiter.lines if "queued-" in line] order = [ "queued-branch-1", "queued-after-if-1", @@ -118,33 +98,29 @@ async def test_branch_resume_async( ] assert [next(o for o in order if o in line) for line in queued] == order - done = wait_for("restart-after-if-2") await client.execute_service(service["run_restart_branch"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("restart-after-if-2", timeout=2.0) await asyncio.sleep(0.2) assert count("restart-branch-1") == 0 assert count("restart-after-if-1") == 0 assert count("restart-branch-2") == 1 assert count("restart-after-if-2") == 1 - done = wait_for("after-delay-last") await client.execute_service(service["run_delay_last_in_branch"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("after-delay-last", timeout=2.0) assert count("delay-last-start") == 1 assert count("after-delay-last") == 1 - done = wait_for("parallel-after-if-3") await client.execute_service(service["run_parallel_branch"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("parallel-after-if-3", timeout=2.0) await asyncio.sleep(0.2) for run in (1, 2, 3): assert count(f"parallel-branch-done-{run}") == 1 assert count(f"parallel-after-if-{run}") == 1 # A condition that stops its own script must not start a branch or continue the chain - done = wait_for("stop-in-conditions-done") await client.execute_service(service["run_stop_in_conditions"], {}) - await asyncio.wait_for(done, timeout=2.0) + await waiter.wait_for("stop-in-conditions-done", timeout=2.0) assert count("if-stopped-by-condition-should-not-run") == 0 assert count("if-else-stopped-by-condition-should-not-run") == 0 assert count("after-if-stopped-by-condition-should-not-run") == 0 diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py index ec9a94bf4f0..80b7492e61f 100644 --- a/tests/integration/test_cover_control_action.py +++ b/tests/integration/test_cover_control_action.py @@ -90,3 +90,13 @@ async def test_cover_control_action( state = await press_and_wait("Stop Cover") # CoverOperation.IDLE == 0 assert state.current_operation == 0 + + # stop_tilt_action publishes tilt 0.4 + state = await press_and_wait("Stop Tilt") + assert state.tilt == pytest.approx(0.4, abs=0.01) + + state = await press_and_wait("Set Tilt") + assert state.tilt == pytest.approx(0.75, abs=0.01) + + state = await press_and_wait("Control Stop Tilt") + assert state.tilt == pytest.approx(0.4, abs=0.01) diff --git a/tests/integration/test_cover_stop_tilt_cancel.py b/tests/integration/test_cover_stop_tilt_cancel.py new file mode 100644 index 00000000000..e68bfdfea21 --- /dev/null +++ b/tests/integration/test_cover_stop_tilt_cancel.py @@ -0,0 +1,56 @@ +"""Integration test for how stop tilt and tilt commands cancel each other on a template cover.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, CoverInfo, CoverState, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +def _seen(tilts: list[float], value: float) -> bool: + return any(t == pytest.approx(value, abs=0.01) for t in tilts) + + +@pytest.mark.asyncio +async def test_cover_stop_tilt_cancel( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that stop tilt and tilt commands cancel each other's pending actions.""" + async with run_compiled(yaml_config), api_client_connected() as client: + tilts: list[float] = [] + + def on_state(state: EntityState) -> None: + if isinstance(state, CoverState): + tilts.append(state.tilt) + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + cover = require_entity(entities, "test_cover", CoverInfo) + button = require_entity(entities, "stop_tilt", ButtonInfo) + + # tilt_action publishes 0.9 after 1s, stop_tilt_action publishes 0.4 after 1s + tilts.clear() + client.cover_command(key=cover.key, tilt=0.75) + await asyncio.sleep(0.2) + client.button_command(button.key) + await asyncio.sleep(2.0) + assert not _seen(tilts, 0.9) + assert tilts[-1] == pytest.approx(0.4, abs=0.01) + + tilts.clear() + client.button_command(button.key) + await asyncio.sleep(0.2) + client.cover_command(key=cover.key, tilt=0.6) + await asyncio.sleep(2.0) + after_tilt = tilts[[round(t, 2) for t in tilts].index(0.6) :] + assert not _seen(after_tilt, 0.4) + assert tilts[-1] == pytest.approx(0.9, abs=0.01) diff --git a/tests/integration/test_crc8_helper.py b/tests/integration/test_crc8_helper.py index ae4d1f856cc..aa51f002bb9 100644 --- a/tests/integration/test_crc8_helper.py +++ b/tests/integration/test_crc8_helper.py @@ -2,13 +2,11 @@ from __future__ import annotations -import asyncio - import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -18,71 +16,30 @@ async def test_crc8_helper( api_client_connected: APIClientConnectedFactory, ) -> None: """Test the CRC8 helper function through integration testing.""" - # Track test completion with asyncio.Event - test_complete = asyncio.Event() + waiter = LineWaiter() - # Track test results - test_results = { - "dallas_maxim": False, - "sensirion": False, - "pec": False, - "parameter_equivalence": False, - "edge_cases": False, - "component_compatibility": False, - "setup_started": False, - } - - def on_log_line(line): - """Process log lines to track test progress and results.""" - # Track test start - if "CRC8 Helper Function Integration Test Starting" in line: - test_results["setup_started"] = True - - # Track test completion - elif "CRC8 Integration Test Complete" in line: - test_complete.set() - - # Track individual test results - elif "ALL TESTS PASSED" in line: - if "Dallas/Maxim CRC8" in line: - test_results["dallas_maxim"] = True - elif "Sensirion CRC8" in line: - test_results["sensirion"] = True - elif "PEC CRC8" in line: - test_results["pec"] = True - elif "Parameter equivalence" in line: - test_results["parameter_equivalence"] = True - elif "Edge cases" in line: - test_results["edge_cases"] = True - elif "Component compatibility" in line: - test_results["component_compatibility"] = True - - # Log failures for debugging - elif "TEST FAILED:" in line or "SUBTEST FAILED:" in line: - print(f"CRC8 Test Failure: {line}") + def passed(name: str) -> bool: + return any("ALL TESTS PASSED" in line and name in line for line in waiter.lines) # Compile and run the test async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info await assert_device_name(client, "crc8-helper-test") # Wait for tests to complete with timeout - await wait_or_fail( - test_complete, 5.0, "CRC8 integration test timed out after 5 seconds" - ) + await waiter.wait_for("CRC8 Integration Test Complete", timeout=5.0) # Verify all tests passed - assert test_results["setup_started"], "CRC8 test setup never started" - assert test_results["dallas_maxim"], "Dallas/Maxim CRC8 test failed" - assert test_results["sensirion"], "Sensirion CRC8 test failed" - assert test_results["pec"], "PEC CRC8 test failed" - assert test_results["parameter_equivalence"], ( - "Parameter equivalence test failed" - ) - assert test_results["edge_cases"], "Edge cases test failed" - assert test_results["component_compatibility"], ( - "Component compatibility test failed" - ) + assert any( + "CRC8 Helper Function Integration Test Starting" in line + for line in waiter.lines + ), "CRC8 test setup never started" + assert passed("Dallas/Maxim CRC8"), "Dallas/Maxim CRC8 test failed" + assert passed("Sensirion CRC8"), "Sensirion CRC8 test failed" + assert passed("PEC CRC8"), "PEC CRC8 test failed" + assert passed("Parameter equivalence"), "Parameter equivalence test failed" + assert passed("Edge cases"), "Edge cases test failed" + assert passed("Component compatibility"), "Component compatibility test failed" diff --git a/tests/integration/test_device_id_in_state.py b/tests/integration/test_device_id_in_state.py index 48de94a85a8..5acd1cf70ef 100644 --- a/tests/integration/test_device_id_in_state.py +++ b/tests/integration/test_device_id_in_state.py @@ -27,6 +27,7 @@ from aioesphomeapi import ( import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail # Mapping of entity name to device name for all entities with device_id ENTITY_TO_DEVICE = { @@ -143,20 +144,17 @@ async def test_device_id_in_state( client.subscribe_states(on_state) # Wait for states - try: - await asyncio.wait_for(states_future, timeout=10.0) - except TimeoutError: - received_names = {e.name for e in all_entities if e.key in states} - missing_names = ( + await wait_or_fail( + states_future, + 10.0, + "Did not receive all entity states", + received=lambda: len(states), + missing=lambda: ( (set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES) - - received_names + - {e.name for e in all_entities if e.key in states} - {"Doorbell"} - ) - pytest.fail( - f"Did not receive all entity states within 10 seconds. " - f"Received {len(states)} states. " - f"Missing: {missing_names}" - ) + ), + ) # Verify each state has the correct device_id verified_count = 0 diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index 9b2670c4ae8..6f46af50065 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -8,6 +8,7 @@ from aioesphomeapi import EntityInfo import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -174,10 +175,10 @@ async def test_duplicate_entities_on_different_devices( client.subscribe_states(on_state) # Wait for all entity states - try: - await asyncio.wait_for(states_future, timeout=10.0) - except TimeoutError: - pytest.fail( - f"Did not receive all entity states within 10 seconds. " - f"Expected {expected_count}, received {state_count}" - ) + await wait_or_fail( + states_future, + 10.0, + "Did not receive all entity states", + expected=expected_count, + received=lambda: state_count, + ) diff --git a/tests/integration/test_fnv1_hash_object_id.py b/tests/integration/test_fnv1_hash_object_id.py index b9d98b30cd8..32a4f6c7735 100644 --- a/tests/integration/test_fnv1_hash_object_id.py +++ b/tests/integration/test_fnv1_hash_object_id.py @@ -10,15 +10,19 @@ to be updated to match the other. from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m") +# Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED" +RESULT_PATTERN = re.compile(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)") + @pytest.mark.asyncio async def test_fnv1_hash_object_id( @@ -28,8 +32,7 @@ async def test_fnv1_hash_object_id( ) -> None: """Test that C++ fnv1_hash_object_id matches Python implementation.""" - test_results: dict[str, str] = {} - all_tests_complete = asyncio.Event() + waiter = LineWaiter() expected_tests = { "foo", "upper", @@ -41,34 +44,32 @@ async def test_fnv1_hash_object_id( "empty", } - def on_log_line(line: str) -> None: - """Capture log lines with test results.""" - # Strip ANSI escape codes - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - # Look for our test result messages - # Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED" - match = re.search(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line) - if match: - test_name = match.group(1) - result = match.group(2) - test_results[test_name] = result - if set(test_results.keys()) >= expected_tests: - all_tests_complete.set() + def test_results() -> dict[str, str]: + return { + m.group(1): m.group(2) + for line in waiter.lines + if (m := RESULT_PATTERN.search(ANSI_PATTERN.sub("", line))) + } + + async def wait_all_results() -> None: + for name in expected_tests: + await waiter.wait_for("[FNV1_OID:", f"]: {name} ") async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "fnv1-hash-object-id-test") # Wait for all tests to complete or timeout await wait_or_fail( - all_tests_complete, 2.0, "Tests timed out", test_results=test_results + wait_all_results(), 2.0, "Tests timed out", test_results=test_results ) + results = test_results() # Verify all tests passed for test_name in expected_tests: - assert test_name in test_results, f"{test_name} test not found" - assert test_results[test_name] == "PASSED", ( + assert test_name in results, f"{test_name} test not found" + assert results[test_name] == "PASSED", ( f"{test_name} test failed - C++ and Python hash mismatch" ) diff --git a/tests/integration/test_fnv1a_hash.py b/tests/integration/test_fnv1a_hash.py index 57212e92c87..324f8779997 100644 --- a/tests/integration/test_fnv1a_hash.py +++ b/tests/integration/test_fnv1a_hash.py @@ -2,15 +2,19 @@ from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m") +# Format: "[timestamp][level][FNV1A:line]: test_name PASSED" +RESULT_PATTERN = re.compile(r"\[FNV1A:\d+\]:\s+(\w+)\s+(PASSED|FAILED)") + @pytest.mark.asyncio async def test_fnv1a_hash( @@ -20,49 +24,44 @@ async def test_fnv1a_hash( ) -> None: """Test that FNV-1a hash functions work correctly.""" - test_results = {} - all_tests_complete = asyncio.Event() + waiter = LineWaiter() expected_tests = {"empty", "known_hello", "known_helloworld", "extend", "string"} - def on_log_line(line: str) -> None: - """Capture log lines with test results.""" - # Strip ANSI escape codes - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - # Look for our test result messages - # Format: "[timestamp][level][FNV1A:line]: test_name PASSED" - match = re.search(r"\[FNV1A:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line) - if match: - test_name = match.group(1) - result = match.group(2) - test_results[test_name] = result - if set(test_results.keys()) >= expected_tests: - all_tests_complete.set() + def test_results() -> dict[str, str]: + return { + m.group(1): m.group(2) + for line in waiter.lines + if (m := RESULT_PATTERN.search(ANSI_PATTERN.sub("", line))) + } + + async def wait_all_results() -> None: + for name in expected_tests: + await waiter.wait_for("[FNV1A:", f"]: {name} ") async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "fnv1a-hash-test") # Wait for all tests to complete or timeout await wait_or_fail( - all_tests_complete, 2.0, "Tests timed out", test_results=test_results + wait_all_results(), 2.0, "Tests timed out", test_results=test_results ) + results = test_results() # Verify all tests passed - assert "empty" in test_results, "empty string test not found" - assert test_results["empty"] == "PASSED", "empty string test failed" + assert "empty" in results, "empty string test not found" + assert results["empty"] == "PASSED", "empty string test failed" - assert "known_hello" in test_results, "known_hello test not found" - assert test_results["known_hello"] == "PASSED", "known_hello test failed" + assert "known_hello" in results, "known_hello test not found" + assert results["known_hello"] == "PASSED", "known_hello test failed" - assert "known_helloworld" in test_results, "known_helloworld test not found" - assert test_results["known_helloworld"] == "PASSED", ( - "known_helloworld test failed" - ) + assert "known_helloworld" in results, "known_helloworld test not found" + assert results["known_helloworld"] == "PASSED", "known_helloworld test failed" - assert "extend" in test_results, "fnv1a_hash_extend test not found" - assert test_results["extend"] == "PASSED", "fnv1a_hash_extend test failed" + assert "extend" in results, "fnv1a_hash_extend test not found" + assert results["extend"] == "PASSED", "fnv1a_hash_extend test failed" - assert "string" in test_results, "std::string test not found" - assert test_results["string"] == "PASSED", "std::string test failed" + assert "string" in results, "std::string test not found" + assert results["string"] == "PASSED", "std::string test failed" diff --git a/tests/integration/test_gpio_expander_cache.py b/tests/integration/test_gpio_expander_cache.py index 498e842fbae..64a94fefd91 100644 --- a/tests/integration/test_gpio_expander_cache.py +++ b/tests/integration/test_gpio_expander_cache.py @@ -2,15 +2,72 @@ from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m") +# Any variation of digital_read, anywhere in the message +READ_PATTERN = re.compile(r"(?:uint16_)?digital_read_(?:hw|cache) pin=\d+") +# The whole read message, so a prefixed read is reported as a mismatch +READ_MESSAGE_PATTERN = re.compile(r"^(\w+) pin=(\d+)") + +HW = "digital_read_hw" +CACHE = "digital_read_cache" +UINT16_HW = "uint16_digital_read_hw" +UINT16_CACHE = "uint16_digital_read_cache" + +EXPECTED_READS: list[tuple[str, int]] = [ + (HW, 0), + *[(CACHE, i) for i in range(8)], + (HW, 8), + *[(CACHE, i) for i in range(8, 16)], + (HW, 16), + *[(CACHE, i) for i in range(16, 24)], + (HW, 24), + *[(CACHE, i) for i in range(24, 32)], + (HW, 3), + (CACHE, 3), + (HW, 3), + (CACHE, 3), + (CACHE, 4), + (HW, 3), + (CACHE, 3), + (HW, 10), + (CACHE, 10), + # full cache reset here for testing + (HW, 15), + (CACHE, 15), + (CACHE, 14), + (HW, 14), + (CACHE, 14), + # uint16_t component tests (single bank of 16 pins) + (UINT16_HW, 0), # First pin triggers hw read + *[(UINT16_CACHE, i) for i in range(16)], # All 16 pins return via cache + # After cache reset + (UINT16_HW, 5), # First read after reset triggers hw + (UINT16_CACHE, 5), + (UINT16_CACHE, 10), # These use cache (same bank) + (UINT16_CACHE, 15), + (UINT16_CACHE, 0), +] + + +def _read(line: str) -> tuple[str, int] | str | None: + """Return (name, pin) for a read log line, the message if it is malformed, else None.""" + clean_line = ANSI_PATTERN.sub("", line) + msg = clean_line.split(": ", 1)[-1] + if not READ_PATTERN.search(msg): + return None + if match := READ_MESSAGE_PATTERN.search(msg): + return match.group(1), int(match.group(2)) + return msg + @pytest.mark.asyncio async def test_gpio_expander_cache( @@ -19,114 +76,22 @@ async def test_gpio_expander_cache( api_client_connected: APIClientConnectedFactory, ) -> None: """Test gpio_expander::CachedGpioExpander correctly calls hardware functions.""" - logs_done = asyncio.Event() + waiter = LineWaiter() - # Patterns to match in logs - match any variation of digital_read - read_hw_pattern = re.compile(r"(?:uint16_)?digital_read_hw pin=(\d+)") - read_cache_pattern = re.compile(r"(?:uint16_)?digital_read_cache pin=(\d+)") - - # Keep specific patterns for building the expected order - digital_read_hw_pattern = re.compile(r"^digital_read_hw pin=(\d+)") - digital_read_cache_pattern = re.compile(r"^digital_read_cache pin=(\d+)") - uint16_read_hw_pattern = re.compile(r"^uint16_digital_read_hw pin=(\d+)") - uint16_read_cache_pattern = re.compile(r"^uint16_digital_read_cache pin=(\d+)") - - # ensure logs are in the expected order - log_order = [ - (digital_read_hw_pattern, 0), - [(digital_read_cache_pattern, i) for i in range(8)], - (digital_read_hw_pattern, 8), - [(digital_read_cache_pattern, i) for i in range(8, 16)], - (digital_read_hw_pattern, 16), - [(digital_read_cache_pattern, i) for i in range(16, 24)], - (digital_read_hw_pattern, 24), - [(digital_read_cache_pattern, i) for i in range(24, 32)], - (digital_read_hw_pattern, 3), - (digital_read_cache_pattern, 3), - (digital_read_hw_pattern, 3), - (digital_read_cache_pattern, 3), - (digital_read_cache_pattern, 4), - (digital_read_hw_pattern, 3), - (digital_read_cache_pattern, 3), - (digital_read_hw_pattern, 10), - (digital_read_cache_pattern, 10), - # full cache reset here for testing - (digital_read_hw_pattern, 15), - (digital_read_cache_pattern, 15), - (digital_read_cache_pattern, 14), - (digital_read_hw_pattern, 14), - (digital_read_cache_pattern, 14), - # uint16_t component tests (single bank of 16 pins) - (uint16_read_hw_pattern, 0), # First pin triggers hw read - [ - (uint16_read_cache_pattern, i) for i in range(16) - ], # All 16 pins return via cache - # After cache reset - (uint16_read_hw_pattern, 5), # First read after reset triggers hw - (uint16_read_cache_pattern, 5), - (uint16_read_cache_pattern, 10), # These use cache (same bank) - (uint16_read_cache_pattern, 15), - (uint16_read_cache_pattern, 0), - ] - # Flatten the log order for easier processing - log_order: list[tuple[re.Pattern, int]] = [ - item - for sublist in log_order - for item in (sublist if isinstance(sublist, list) else [sublist]) - ] - - index = 0 - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - nonlocal index - if logs_done.is_set(): - return - - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - # Extract just the log message part (after the log level) - msg = clean_line.split(": ", 1)[-1] if ": " in clean_line else clean_line - - # Check if this line contains a read operation we're tracking - if read_hw_pattern.search(msg) or read_cache_pattern.search(msg): - if index >= len(log_order): - print(f"Received unexpected log line: {msg}") - logs_done.set() - return - - pattern, expected_pin = log_order[index] - match = pattern.search(msg) - - if not match: - print(f"Log line did not match next expected pattern: {msg}") - print(f"Expected pattern: {pattern.pattern}") - logs_done.set() - return - - pin = int(match.group(1)) - if pin != expected_pin: - print(f"Unexpected pin number. Expected {expected_pin}, got {pin}") - logs_done.set() - return - - index += 1 - - elif "DONE_UINT16" in clean_line: - # uint16 component is done, check if we've seen all expected logs - if index == len(log_order): - logs_done.set() - - # Run with log monitoring async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info await assert_device_name(client, "gpio-expander-cache") - await wait_or_fail(logs_done, 5.0, "Timeout waiting for logs to complete") - - assert index == len(log_order), ( - f"Expected {len(log_order)} log entries, but got {index}" + done_line = await wait_or_fail( + waiter.wait_for("DONE_UINT16"), + 5.0, + "Timeout waiting for logs to complete", ) + + # Only the reads logged before the uint16 component finished count + before_done = waiter.lines[: waiter.lines.index(done_line)] + reads = [read for line in before_done if (read := _read(line)) is not None] + assert reads == EXPECTED_READS diff --git a/tests/integration/test_host_logger_thread_safety.py b/tests/integration/test_host_logger_thread_safety.py index 9ba078cc743..69eabf61c5b 100644 --- a/tests/integration/test_host_logger_thread_safety.py +++ b/tests/integration/test_host_logger_thread_safety.py @@ -11,6 +11,7 @@ import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -45,18 +46,11 @@ async def test_host_logger_thread_safety( 3. Verifies no lines are corrupted (partially written or interleaved) 4. Verifies all expected messages were received """ - collected_lines: list[str] = [] - test_complete_event = asyncio.Event() - - def line_callback(line: str) -> None: - """Collect log lines and detect test completion.""" - collected_lines.append(line) - if "RACE_TEST_COMPLETE" in line: - test_complete_event.set() + waiter = LineWaiter() # Run the test binary and collect output async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify connection works @@ -70,10 +64,10 @@ async def test_host_logger_thread_safety( # Wait for test to complete (with timeout) await wait_or_fail( - test_complete_event, + waiter.wait_for("RACE_TEST_COMPLETE", timeout=40.0), 30.0, "Test did not complete within timeout", - collected_lines=collected_lines, + collected_lines=waiter.lines, ) # Give a bit more time for any remaining buffered messages @@ -86,7 +80,7 @@ async def test_host_logger_thread_safety( test_completed = False reported_total = 0 - for line in collected_lines: + for line in waiter.lines: # Check for test start start_match = TEST_START_PATTERN.search(line) if start_match: diff --git a/tests/integration/test_host_mode_many_entities.py b/tests/integration/test_host_mode_many_entities.py index da2ffd58ea3..b8e0d9f4409 100644 --- a/tests/integration/test_host_mode_many_entities.py +++ b/tests/integration/test_host_mode_many_entities.py @@ -22,6 +22,7 @@ import pytest from .state_utils import require_only_entity from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -65,28 +66,19 @@ async def test_host_mode_many_entities( client.subscribe_states(on_state) # Wait for minimum states with timeout - try: - await asyncio.wait_for(minimum_states_future, timeout=10.0) - except TimeoutError: - sensor_states = [ - s - for s in states.values() - if isinstance(s, SensorState) and isinstance(s.state, float) - ] - date_states = [s for s in states.values() if isinstance(s, DateState)] - time_states = [s for s in states.values() if isinstance(s, TimeState)] - datetime_states = [ - s for s in states.values() if isinstance(s, DateTimeState) - ] + def count(kind: type) -> int: + return sum(isinstance(s, kind) for s in states.values()) - pytest.fail( - f"Did not receive expected states within 10 seconds. " - f"Received: {len(sensor_states)} sensor states (expected >=50), " - f"{len(date_states)} date states (expected >=1), " - f"{len(time_states)} time states (expected >=1), " - f"{len(datetime_states)} datetime states (expected >=1). " - f"Total states: {len(states)}" - ) + await wait_or_fail( + minimum_states_future, + 10.0, + "Did not receive the expected states (>=50 sensor, >=1 date, time and datetime)", + sensor=lambda: count(SensorState), + date=lambda: count(DateState), + time=lambda: count(TimeState), + datetime=lambda: count(DateTimeState), + total=lambda: len(states), + ) # Verify we received a good number of entity states assert len(states) >= 50, ( diff --git a/tests/integration/test_host_mode_many_entities_multiple_connections.py b/tests/integration/test_host_mode_many_entities_multiple_connections.py index a7939bb277d..1fed76d74f5 100644 --- a/tests/integration/test_host_mode_many_entities_multiple_connections.py +++ b/tests/integration/test_host_mode_many_entities_multiple_connections.py @@ -8,6 +8,7 @@ from aioesphomeapi import EntityState import pytest from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -45,16 +46,13 @@ async def test_host_mode_many_entities_multiple_connections( client2.subscribe_states(on_state2) # Wait for both clients to receive states - try: - count1, count2 = await asyncio.gather( - asyncio.wait_for(client1_ready, timeout=10.0), - asyncio.wait_for(client2_ready, timeout=10.0), - ) - except TimeoutError: - pytest.fail( - f"One or both clients did not receive enough states within 10 seconds. " - f"Client1: {len(states1)}, Client2: {len(states2)}" - ) + count1, count2 = await wait_or_fail( + asyncio.gather(client1_ready, client2_ready), + 10.0, + "One or both clients did not receive enough states", + client1=lambda: len(states1), + client2=lambda: len(states2), + ) # Verify both clients received states successfully assert count1 >= 20, ( diff --git a/tests/integration/test_host_preferences.py b/tests/integration/test_host_preferences.py index abdda1dd38a..ae367eeb539 100644 --- a/tests/integration/test_host_preferences.py +++ b/tests/integration/test_host_preferences.py @@ -2,15 +2,16 @@ from __future__ import annotations -import asyncio import re from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo import pytest +from .log_utils import LineWaiter from .state_utils import require_entity from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail + +VALUE_PATTERN = re.compile(r"value=([0-9.]+)") @pytest.mark.asyncio @@ -20,56 +21,16 @@ async def test_host_preferences_save_load( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that preferences are correctly saved and loaded after our optimization fix.""" - loop = asyncio.get_running_loop() - log_lines: list[str] = [] - preferences_saved = loop.create_future() - preferences_loaded = loop.create_future() - values_match = loop.create_future() - final_load_complete = loop.create_future() + waiter = LineWaiter() - # Patterns to match preference logs - save_pattern = re.compile(r"Preference saved: key=(\w+), value=([0-9.]+)") - load_pattern = re.compile(r"Preference loaded: key=(\w+), value=([0-9.]+)") - verify_pattern = re.compile(r"Preferences verified: values match!") - final_load_success_pattern = re.compile( - r"Final load test: loaded \d+ preferences successfully" - ) - - saved_values: dict[str, float] = {} - loaded_values: dict[str, float] = {} - - def check_output(line: str) -> None: - """Check log output for preference operations.""" - log_lines.append(line) - - # Look for save operations - match = save_pattern.search(line) - if match: - key = match.group(1) - value = float(match.group(2)) - saved_values[key] = value - if len(saved_values) >= 2 and not preferences_saved.done(): - preferences_saved.set_result(True) - - # Look for load operations - match = load_pattern.search(line) - if match: - key = match.group(1) - value = float(match.group(2)) - loaded_values[key] = value - if len(loaded_values) >= 2 and not preferences_loaded.done(): - preferences_loaded.set_result(True) - - # Look for verification - if verify_pattern.search(line) and not values_match.done(): - values_match.set_result(True) - - # Look for final load test completion - if final_load_success_pattern.search(line) and not final_load_complete.done(): - final_load_complete.set_result(True) + async def logged_value(action: str, key: str) -> float: + line = await waiter.wait_for(f"Preference {action}: key={key},", timeout=5.0) + match = VALUE_PATTERN.search(line) + assert match is not None, f"Unexpected preference line: {line}" + return float(match.group(1)) async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Get entity list @@ -94,20 +55,10 @@ async def test_host_preferences_save_load( # Save preferences client.button_command(save_button.key) - # Wait for save to complete - await wait_or_fail( - preferences_saved, 5.0, "Preferences not saved within timeout" - ) - - # Verify we saved the expected values - assert "switch" in saved_values, f"Switch preference not saved: {saved_values}" - assert "number" in saved_values, f"Number preference not saved: {saved_values}" - assert saved_values["switch"] == 1.0, ( - f"Switch value incorrect: {saved_values['switch']}" - ) - assert saved_values["number"] == 42.5, ( - f"Number value incorrect: {saved_values['number']}" - ) + saved_switch = await logged_value("saved", "switch") + saved_number = await logged_value("saved", "number") + assert saved_switch == 1.0, f"Switch value incorrect: {saved_switch}" + assert saved_number == 42.5, f"Number value incorrect: {saved_number}" # Change the values to something else client.switch_command(test_switch.key, False) @@ -116,32 +67,20 @@ async def test_host_preferences_save_load( # Load preferences (should restore the saved values) client.button_command(load_button.key) - # Wait for load to complete - await wait_or_fail( - preferences_loaded, 5.0, "Preferences not loaded within timeout" + loaded_switch = await logged_value("loaded", "switch") + loaded_number = await logged_value("loaded", "number") + assert loaded_switch == saved_switch, ( + f"Loaded switch value {loaded_switch} doesn't match saved {saved_switch}" ) - - # Verify loaded values match saved values - assert "switch" in loaded_values, ( - f"Switch preference not loaded: {loaded_values}" - ) - assert "number" in loaded_values, ( - f"Number preference not loaded: {loaded_values}" - ) - assert loaded_values["switch"] == saved_values["switch"], ( - f"Loaded switch value {loaded_values['switch']} doesn't match saved {saved_values['switch']}" - ) - assert loaded_values["number"] == saved_values["number"], ( - f"Loaded number value {loaded_values['number']} doesn't match saved {saved_values['number']}" + assert loaded_number == saved_number, ( + f"Loaded number value {loaded_number} doesn't match saved {saved_number}" ) # Verify the values were actually restored client.button_command(verify_button.key) # Wait for verification - await wait_or_fail( - values_match, 5.0, "Preference verification failed within timeout" - ) + await waiter.wait_for("Preferences verified: values match!", timeout=5.0) # Test that non-existent preferences don't crash (tests our fix) # This will trigger load attempts for keys that don't exist @@ -149,6 +88,6 @@ async def test_host_preferences_save_load( client.button_command(load_button.key) # Wait for the final load test to complete - await wait_or_fail( - final_load_complete, 5.0, "Final load test did not complete within timeout" + await waiter.wait_for( + "Final load test: loaded", "preferences successfully", timeout=5.0 ) diff --git a/tests/integration/test_in_range_conditions.py b/tests/integration/test_in_range_conditions.py index bca0eb2b089..78a2289996a 100644 --- a/tests/integration/test_in_range_conditions.py +++ b/tests/integration/test_in_range_conditions.py @@ -2,11 +2,11 @@ from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import require_service from .types import APIClientConnectedFactory, RunCompiledFunction @@ -33,26 +33,23 @@ async def test_in_range_conditions( api_client_connected: APIClientConnectedFactory, ) -> None: """Each in_range condition gives the same result for sensors and numbers.""" - loop = asyncio.get_running_loop() - results: dict[tuple[str, str, str], int] = {} - done = loop.create_future() - - def check_output(line: str) -> None: - if match := RESULT_RE.search(line): - label, entity, condition, value = match.groups() - results[(label, entity, condition)] = int(value) - elif "INRANGE done" in line and not done.done(): - done.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): _, services = await client.list_entities_services() service = require_service(services, "run_cases") await client.execute_service(service, {}) - await asyncio.wait_for(done, timeout=10.0) + await waiter.wait_for("INRANGE done", timeout=10.0) + # Key: (label, entity, condition); a repeated result keeps the last value + results = { + match.groups()[:3]: int(match[4]) + for line in waiter.lines + if (match := RESULT_RE.search(line)) + } expected = { (label, entity, condition): row[index] for label, row in EXPECTED.items() diff --git a/tests/integration/test_includes_lambda_order.py b/tests/integration/test_includes_lambda_order.py new file mode 100644 index 00000000000..4f30b8c52d7 --- /dev/null +++ b/tests/integration/test_includes_lambda_order.py @@ -0,0 +1,31 @@ +"""Lambdas rendered at file scope can use the user's includes, which can name ids.""" + +from __future__ import annotations + +from aioesphomeapi import SensorInfo, SensorState +import pytest + +from .state_utils import require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_includes_lambda_order( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The apply action and the sensor filter use the header, the header reads the global.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + sensor = require_entity(entities, "header_value", SensorInfo) + state = await wait_for_state( + client, + lambda s: ( + isinstance(s, SensorState) + and s.key == sensor.key + and not s.missing_state + ), + ) + # globals.set stored HEADER_CONSTANT and the header read it back doubled. + assert state.state == 84.0 diff --git a/tests/integration/test_light_constant_brightness.py b/tests/integration/test_light_constant_brightness.py index ee8abb83852..ad353fac30f 100644 --- a/tests/integration/test_light_constant_brightness.py +++ b/tests/integration/test_light_constant_brightness.py @@ -19,10 +19,16 @@ from typing import Any from aioesphomeapi import EntityState, LightInfo, LightState import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +CB_CW_RE = re.compile(r"(? None: """Test constant_brightness true and false behavior with gamma correction.""" - # Track output values for both lights from log lines - cb_cw_pattern = re.compile(r"(? None: - for pattern, key in [ - (cb_cw_pattern, "cb_cw"), - (cb_ww_pattern, "cb_ww"), - (ncb_cw_pattern, "ncb_cw"), - (ncb_ww_pattern, "ncb_ww"), - ]: - match = pattern.search(line) - if match: - latest[key] = float(match.group(1)) + def latest(pattern: re.Pattern[str]) -> float: + """Return the last output value logged so far, or 0.0 if none.""" + for line in reversed(waiter.lines): + if match := pattern.search(line): + return float(match[1]) + return 0.0 loop = asyncio.get_running_loop() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -118,7 +109,7 @@ async def test_light_constant_brightness( await send_and_wait( cb_light.key, color_temperature=mireds, transition_length=0 ) - cb_totals.append((mireds, latest["cb_cw"], latest["cb_ww"])) + cb_totals.append((mireds, latest(CB_CW_RE), latest(CB_WW_RE))) # All totals should be approximately equal (constant brightness) reference_total = next((cw + ww for _, cw, ww in cb_totals if cw + ww > 0), 0) @@ -152,7 +143,7 @@ async def test_light_constant_brightness( await send_and_wait( ncb_light.key, color_temperature=mireds, transition_length=0 ) - ncb_totals.append((mireds, latest["ncb_cw"], latest["ncb_ww"])) + ncb_totals.append((mireds, latest(NCB_CW_RE), latest(NCB_WW_RE))) extreme_cw = ncb_totals[0] # 153 mireds - pure cold extreme_ww = ncb_totals[-1] # 500 mireds - pure warm diff --git a/tests/integration/test_lock_automations.py b/tests/integration/test_lock_automations.py index d7994168f09..b95e56a41e6 100644 --- a/tests/integration/test_lock_automations.py +++ b/tests/integration/test_lock_automations.py @@ -3,13 +3,11 @@ Tests that on_lock and on_unlock triggers work correctly. """ -import asyncio - import pytest +from .log_utils import LineWaiter from .state_utils import require_entity from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -19,21 +17,10 @@ async def test_lock_automations( api_client_connected: APIClientConnectedFactory, ) -> None: """Test lock on_lock and on_unlock triggers.""" - loop = asyncio.get_running_loop() - - # Futures for log line detection - on_lock_future: asyncio.Future[bool] = loop.create_future() - on_unlock_future: asyncio.Future[bool] = loop.create_future() - - def check_output(line: str) -> None: - """Check log output for trigger messages.""" - if "TRIGGER: on_lock fired" in line and not on_lock_future.done(): - on_lock_future.set_result(True) - elif "TRIGGER: on_unlock fired" in line and not on_unlock_future.done(): - on_unlock_future.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Import here to avoid import errors when aioesphomeapi is not installed @@ -46,9 +33,9 @@ async def test_lock_automations( # Test 1: Lock - should trigger on_lock client.lock_command(key=lock.key, command=LockCommand.LOCK) - await wait_or_fail(on_lock_future, 5.0, "on_lock trigger did not fire") + await waiter.wait_for("TRIGGER: on_lock fired", timeout=5.0) # Test 2: Unlock - should trigger on_unlock client.lock_command(key=lock.key, command=LockCommand.UNLOCK) - await wait_or_fail(on_unlock_future, 5.0, "on_unlock trigger did not fire") + await waiter.wait_for("TRIGGER: on_unlock fired", timeout=5.0) diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py index 29131a6c3f3..ad23ed2ad4e 100644 --- a/tests/integration/test_logger_buffered_recursion_guard.py +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -31,6 +31,7 @@ import pytest from .state_utils import assert_device_name, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail _ANSI = re.compile(r"\x1b\[[0-9;]*m") # THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index @@ -77,14 +78,14 @@ async def test_logger_buffered_recursion_guard( client.button_command(button.key) # Wait until every buffered thread message has been delivered over the API. - try: - await asyncio.wait_for(all_drained.wait(), timeout=30.0) - except TimeoutError: - received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) - pytest.fail( - f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " - "device likely crashed or hung." - ) + await wait_or_fail( + all_drained, + 30.0, + f"Not all {NUM_MESSAGES} thread messages arrived; device likely crashed or hung", + received=lambda: sum( + len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages + ), + ) intact: set[int] = set() contaminated: list[str] = [] diff --git a/tests/integration/test_loop_interval_decoupling.py b/tests/integration/test_loop_interval_decoupling.py index 534042182b0..cc7c1bf2166 100644 --- a/tests/integration/test_loop_interval_decoupling.py +++ b/tests/integration/test_loop_interval_decoupling.py @@ -16,15 +16,17 @@ requested cadence while the component phase stays gated at loop_interval_. from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +MEASUREMENT_RE = re.compile(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)") + @pytest.mark.asyncio async def test_loop_interval_decoupling( @@ -33,23 +35,22 @@ async def test_loop_interval_decoupling( api_client_connected: APIClientConnectedFactory, ) -> None: """Raised loop_interval_ must not clamp scheduler item cadence.""" - loop = asyncio.get_running_loop() - measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future() - - def on_log_line(line: str) -> None: - match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line) - if match and not measurement_done.done(): - measurement_done.set_result((int(match.group(1)), int(match.group(2)))) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "loop-interval-decouple") - loop_delta, sched_delta = await wait_or_fail( - measurement_done, 10.0, "MEASUREMENT_DONE marker never appeared" + line = await wait_or_fail( + waiter.wait_for("MEASUREMENT_DONE loop_delta=", timeout=20.0), + 10.0, + "MEASUREMENT_DONE marker never appeared", ) + match = MEASUREMENT_RE.search(line) + assert match, line + loop_delta, sched_delta = int(match[1]), int(match[2]) # Observation window = 2s, loop_interval_ = 500ms. # Component phase should fire ~4 times in 2s. The upper bound must be diff --git a/tests/integration/test_loop_interval_default_not_pulled_forward.py b/tests/integration/test_loop_interval_default_not_pulled_forward.py index cf5da3c082c..3c835ef9b38 100644 --- a/tests/integration/test_loop_interval_default_not_pulled_forward.py +++ b/tests/integration/test_loop_interval_default_not_pulled_forward.py @@ -16,15 +16,17 @@ phase stays at ~62 Hz regardless of scheduler activity. from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +MEASUREMENT_RE = re.compile(r"MEASUREMENT_DONE loop_delta=(\d+)") + @pytest.mark.asyncio async def test_loop_interval_default_not_pulled_forward( @@ -33,23 +35,22 @@ async def test_loop_interval_default_not_pulled_forward( api_client_connected: APIClientConnectedFactory, ) -> None: """Fast scheduler item must not pull component phase past default ~62 Hz.""" - loop = asyncio.get_running_loop() - measurement_done: asyncio.Future[int] = loop.create_future() - - def on_log_line(line: str) -> None: - match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+)", line) - if match and not measurement_done.done(): - measurement_done.set_result(int(match.group(1))) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "loop-default-not-pulled") - loop_delta = await wait_or_fail( - measurement_done, 10.0, "MEASUREMENT_DONE marker never appeared" + line = await wait_or_fail( + waiter.wait_for("MEASUREMENT_DONE loop_delta=", timeout=20.0), + 10.0, + "MEASUREMENT_DONE marker never appeared", ) + match = MEASUREMENT_RE.search(line) + assert match, line + loop_delta = int(match[1]) # Observation window = 2s, loop_interval_ default = 16ms → ~62 Hz → # ~125 component-phase iterations expected. diff --git a/tests/integration/test_micros_to_millis.py b/tests/integration/test_micros_to_millis.py index f20129fe04e..8af952b8978 100644 --- a/tests/integration/test_micros_to_millis.py +++ b/tests/integration/test_micros_to_millis.py @@ -2,11 +2,9 @@ from __future__ import annotations -import asyncio -import re - import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -20,24 +18,22 @@ async def test_micros_to_millis( ) -> None: """Test that micros_to_millis matches reference uint64 division.""" - all_passed = asyncio.Event() - failures: list[str] = [] + waiter = LineWaiter() - def on_log_line(line: str) -> None: - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - if "ALL_PASSED" in clean_line: - all_passed.set() - elif "FAILED" in clean_line and "[MTM" in clean_line: - failures.append(clean_line) + def failures() -> list[str]: + return [line for line in waiter.lines if "FAILED" in line and "[MTM" in line] async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "micros-to-millis-test") await wait_or_fail( - all_passed, 2.0, "micros_to_millis test timed out", failures=failures + waiter.wait_for("ALL_PASSED"), + 2.0, + "micros_to_millis test timed out", + failures=failures, ) - assert not failures, f"micros_to_millis failures: {failures}" + assert not failures(), f"micros_to_millis failures: {failures()}" diff --git a/tests/integration/test_multi_click_trigger.py b/tests/integration/test_multi_click_trigger.py index 30814fcaf26..4b88a4d5bee 100644 --- a/tests/integration/test_multi_click_trigger.py +++ b/tests/integration/test_multi_click_trigger.py @@ -8,14 +8,15 @@ orchestrated entirely in YAML. from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import require_service from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail + +COUNT_PATTERN = re.compile(r"count=(\d+)") @pytest.mark.asyncio @@ -25,29 +26,10 @@ async def test_multi_click_trigger( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that on_multi_click triggers for single, double, and long press patterns.""" - loop = asyncio.get_running_loop() - - single_click_pattern = re.compile(r"SINGLE_CLICK count=(\d+)") - double_click_pattern = re.compile(r"DOUBLE_CLICK count=(\d+)") - long_press_pattern = re.compile(r"LONG_PRESS count=(\d+)") - - single_click_future: asyncio.Future[int] = loop.create_future() - double_click_future: asyncio.Future[int] = loop.create_future() - long_press_future: asyncio.Future[int] = loop.create_future() - - def check_output(line: str) -> None: - """Check log output for multi-click trigger messages.""" - if m := single_click_pattern.search(line): - if not single_click_future.done(): - single_click_future.set_result(int(m.group(1))) - elif m := double_click_pattern.search(line): - if not double_click_future.done(): - double_click_future.set_result(int(m.group(1))) - elif (m := long_press_pattern.search(line)) and not long_press_future.done(): - long_press_future.set_result(int(m.group(1))) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): _entities, services = await client.list_entities_services() @@ -57,24 +39,9 @@ async def test_multi_click_trigger( # Kick off the entire test sequence (runs in YAML with delays) await client.execute_service(test_service, {}) - # Wait for all three triggers - count = await wait_or_fail( - single_click_future, - 5.0, - "Timeout waiting for SINGLE_CLICK - on_multi_click did not trigger.", - ) - assert count == 1, f"Expected single click count=1, got {count}" - - count = await wait_or_fail( - double_click_future, - 5.0, - "Timeout waiting for DOUBLE_CLICK - on_multi_click did not trigger.", - ) - assert count == 1, f"Expected double click count=1, got {count}" - - count = await wait_or_fail( - long_press_future, - 5.0, - "Timeout waiting for LONG_PRESS - on_multi_click did not trigger.", - ) - assert count == 1, f"Expected long press count=1, got {count}" + for label in ("SINGLE_CLICK", "DOUBLE_CLICK", "LONG_PRESS"): + line = await waiter.wait_for(f"{label} count=", timeout=5.0) + match = COUNT_PATTERN.search(line) + assert match is not None and match.group(1) == "1", ( + f"Expected {label} count=1, got: {line}" + ) diff --git a/tests/integration/test_multi_device_preferences.py b/tests/integration/test_multi_device_preferences.py index 0f5ee1ac61e..2e10a0d52df 100644 --- a/tests/integration/test_multi_device_preferences.py +++ b/tests/integration/test_multi_device_preferences.py @@ -2,16 +2,20 @@ from __future__ import annotations -import asyncio import re from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SwitchInfo import pytest +from .log_utils import LineWaiter from .state_utils import require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +# Keys are "A", "B" and "Main" +SWITCH_HASH_RE = re.compile(r"(?:Device )?\b([AB]|Main) Switch Pref Hash: (\d+)") +NUMBER_HASH_RE = re.compile(r"(?:Device )?\b([AB]|Main) Number Pref Hash: (\d+)") + @pytest.mark.asyncio async def test_multi_device_preferences( @@ -20,58 +24,17 @@ async def test_multi_device_preferences( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that entities with same names on different devices have unique preference storage.""" - loop = asyncio.get_running_loop() - log_lines: list[str] = [] - preferences_logged = loop.create_future() + waiter = LineWaiter() - # Patterns to match preference hash logs - switch_hash_pattern_device = re.compile(r"Device ([AB]) Switch Pref Hash: (\d+)") - switch_hash_pattern_main = re.compile(r"Main Switch Pref Hash: (\d+)") - number_hash_pattern_device = re.compile(r"Device ([AB]) Number Pref Hash: (\d+)") - number_hash_pattern_main = re.compile(r"Main Number Pref Hash: (\d+)") - switch_hashes: dict[str, int] = {} - number_hashes: dict[str, int] = {} - - def check_output(line: str) -> None: - """Check log output for preference hash information.""" - log_lines.append(line) - - # Look for device switch preference hash logs - match = switch_hash_pattern_device.search(line) - if match: - device = match.group(1) - hash_value = int(match.group(2)) - switch_hashes[device] = hash_value - - # Look for main switch preference hash - match = switch_hash_pattern_main.search(line) - if match: - hash_value = int(match.group(1)) - switch_hashes["Main"] = hash_value - - # Look for device number preference hash logs - match = number_hash_pattern_device.search(line) - if match: - device = match.group(1) - hash_value = int(match.group(2)) - number_hashes[device] = hash_value - - # Look for main number preference hash - match = number_hash_pattern_main.search(line) - if match: - hash_value = int(match.group(1)) - number_hashes["Main"] = hash_value - - # If we have all hashes, complete the future - if ( - len(switch_hashes) == 3 - and len(number_hashes) == 3 - and not preferences_logged.done() - ): - preferences_logged.set_result(True) + def hashes(pattern: re.Pattern[str]) -> dict[str, int]: + return { + match[1]: int(match[2]) + for line in waiter.lines + if (match := pattern.search(line)) + } async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Get entity list @@ -105,10 +68,14 @@ async def test_multi_device_preferences( # Press the button to trigger logging client.button_command(test_button.key) - # Wait for preference hashes to be logged + # The last of the six hash lines await wait_or_fail( - preferences_logged, 5.0, "Preference hashes not logged within timeout" + waiter.wait_for("Main Number Pref Hash:", timeout=20.0), + 5.0, + "Preference hashes not logged within timeout", ) + switch_hashes = hashes(SWITCH_HASH_RE) + number_hashes = hashes(NUMBER_HASH_RE) # Verify all switch preference hashes are unique assert len(switch_hashes) == 3, ( diff --git a/tests/integration/test_online_image_stalled_download.py b/tests/integration/test_online_image_stalled_download.py index 35c549afe60..2c3b22cb8cc 100644 --- a/tests/integration/test_online_image_stalled_download.py +++ b/tests/integration/test_online_image_stalled_download.py @@ -7,6 +7,7 @@ import struct import pytest +from .log_utils import LineWaiter from .online_image_utils import BMP_IMAGE from .types import APIClientConnectedFactory, RunCompiledFunction @@ -72,24 +73,7 @@ async def test_online_image_stalled_download( api_client_connected: APIClientConnectedFactory, ) -> None: """Esphome should report an error when a download can never complete.""" - loop = asyncio.get_running_loop() - expected_logs = { - "truncated_reason": "Download ended with 1 bytes the decoder could not use", - "truncated_error": "download error: truncated", - "large_header_reason": "Image does not fit in the 256 byte download buffer", - "large_header_error": "download error: large_header", - "empty_reason": "Server sent an empty response", - "empty_error": "download error: empty", - } - log_futures = {key: loop.create_future() for key in expected_logs} - finished_lines: list[str] = [] - - def check_output(line: str) -> None: - for key, text in expected_logs.items(): - if text in line and not log_futures[key].done(): - log_futures[key].set_result(True) - if "download finished" in line: - finished_lines.append(line) + waiter = LineWaiter() server = await asyncio.start_server(handle_http, "127.0.0.1", 0) http_server_port = server.sockets[0].getsockname()[1] @@ -97,7 +81,7 @@ async def test_online_image_stalled_download( async with ( server, - run_compiled(config, line_callback=check_output), + run_compiled(config, line_callback=waiter.callback), api_client_connected() as client, ): _, services = await client.list_entities_services() @@ -105,17 +89,23 @@ async def test_online_image_stalled_download( await client.execute_service(actions["fetch_truncated"], {}) async with asyncio.timeout(2.0): - await log_futures["truncated_reason"] - await log_futures["truncated_error"] + await waiter.wait_for_each( + "Download ended with 1 bytes the decoder could not use", + "download error: truncated", + ) await client.execute_service(actions["fetch_large_header"], {}) async with asyncio.timeout(2.0): - await log_futures["large_header_reason"] - await log_futures["large_header_error"] + await waiter.wait_for_each( + "Image does not fit in the 256 byte download buffer", + "download error: large_header", + ) await client.execute_service(actions["fetch_empty"], {}) async with asyncio.timeout(2.0): - await log_futures["empty_reason"] - await log_futures["empty_error"] + await waiter.wait_for_each( + "Server sent an empty response", "download error: empty" + ) + finished_lines = [line for line in waiter.lines if "download finished" in line] assert not finished_lines, f"Unexpected successful download: {finished_lines}" diff --git a/tests/integration/test_oversized_payloads.py b/tests/integration/test_oversized_payloads.py index ca41710d85c..b38105ff616 100644 --- a/tests/integration/test_oversized_payloads.py +++ b/tests/integration/test_oversized_payloads.py @@ -6,11 +6,18 @@ import asyncio import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedWithDisconnectFactory, RunCompiledFunction from .wait_utils import wait_or_fail +def _crashed(waiter: LineWaiter) -> bool: + return any( + "Segmentation fault" in line or "core dumped" in line for line in waiter.lines + ) + + @pytest.mark.asyncio async def test_oversized_payload_plaintext( yaml_config: str, @@ -18,23 +25,9 @@ async def test_oversized_payload_plaintext( api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, ) -> None: """Test that oversized payloads (>32768 bytes) from client cause disconnection without crashing.""" - process_exited = False - helper_log_event = asyncio.Event() + waiter = LineWaiter() - def check_logs(line: str) -> None: - nonlocal process_exited - # Check for signs that the process exited/crashed - if "Segmentation fault" in line or "core dumped" in line: - process_exited = True - # Check for HELPER_LOG message about message size exceeding maximum - if ( - "[VV]" in line - and "Bad packet: message size" in line - and "exceeds maximum" in line - ): - helper_log_event.set() - - async with run_compiled(yaml_config, line_callback=check_logs): + async with run_compiled(yaml_config, line_callback=waiter.callback): async with api_client_connected_with_disconnect() as (client, disconnect_event): # Verify basic connection works first await assert_device_name(client, "oversized-plaintext") @@ -53,10 +46,10 @@ async def test_oversized_payload_plaintext( await asyncio.wait_for(disconnect_event.wait(), timeout=5.0) # After disconnection, verify process didn't crash - assert not process_exited, "ESPHome process should not crash" + assert not _crashed(waiter), "ESPHome process should not crash" # Wait for the expected log message (may arrive after disconnect event) await wait_or_fail( - helper_log_event, + waiter.wait_for("[VV]", "Bad packet: message size", "exceeds maximum"), 2.0, "Expected to see HELPER_LOG about message size exceeding maximum", ) @@ -76,23 +69,9 @@ async def test_oversized_protobuf_message_id_plaintext( This tests the message type limit - message IDs must fit in a uint16_t (0-65535). """ - process_exited = False - helper_log_event = asyncio.Event() + waiter = LineWaiter() - def check_logs(line: str) -> None: - nonlocal process_exited - # Check for signs that the process exited/crashed - if "Segmentation fault" in line or "core dumped" in line: - process_exited = True - # Check for HELPER_LOG message about message type exceeding maximum - if ( - "[VV]" in line - and "Bad packet: message type" in line - and "exceeds maximum" in line - ): - helper_log_event.set() - - async with run_compiled(yaml_config, line_callback=check_logs): + async with run_compiled(yaml_config, line_callback=waiter.callback): async with api_client_connected_with_disconnect() as (client, disconnect_event): # Verify basic connection works first await assert_device_name(client, "oversized-protobuf-plaintext") @@ -111,10 +90,10 @@ async def test_oversized_protobuf_message_id_plaintext( await asyncio.wait_for(disconnect_event.wait(), timeout=5.0) # After disconnection, verify process didn't crash - assert not process_exited, "ESPHome process should not crash" + assert not _crashed(waiter), "ESPHome process should not crash" # Wait for the expected log message (may arrive after disconnect event) await wait_or_fail( - helper_log_event, + waiter.wait_for("[VV]", "Bad packet: message type", "exceeds maximum"), 2.0, "Expected to see HELPER_LOG about message type exceeding maximum", ) @@ -132,24 +111,9 @@ async def test_oversized_payload_noise( ) -> None: """Test that oversized payloads from client cause disconnection without crashing with noise encryption.""" noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" - process_exited = False - helper_log_event = asyncio.Event() + waiter = LineWaiter() - def check_logs(line: str) -> None: - nonlocal process_exited - # Check for signs that the process exited/crashed - if "Segmentation fault" in line or "core dumped" in line: - process_exited = True - # Check for HELPER_LOG message about message size exceeding maximum - # With our new protection, oversized messages are rejected at frame level - if ( - "[VV]" in line - and "Bad packet: message size" in line - and "exceeds maximum" in line - ): - helper_log_event.set() - - async with run_compiled(yaml_config, line_callback=check_logs): + async with run_compiled(yaml_config, line_callback=waiter.callback): async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( client, disconnect_event, @@ -172,10 +136,10 @@ async def test_oversized_payload_noise( await asyncio.wait_for(disconnect_event.wait(), timeout=5.0) # After disconnection, verify process didn't crash - assert not process_exited, "ESPHome process should not crash" + assert not _crashed(waiter), "ESPHome process should not crash" # Wait for the expected log message (may arrive after disconnect event) await wait_or_fail( - helper_log_event, + waiter.wait_for("[VV]", "Bad packet: message size", "exceeds maximum"), 2.0, "Expected to see HELPER_LOG about message size exceeding maximum", ) @@ -201,15 +165,9 @@ async def test_oversized_protobuf_message_id_noise( read the full message to maintain encryption stream continuity. """ noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" - process_exited = False + waiter = LineWaiter() - def check_logs(line: str) -> None: - nonlocal process_exited - # Check for signs that the process exited/crashed - if "Segmentation fault" in line or "core dumped" in line: - process_exited = True - - async with run_compiled(yaml_config, line_callback=check_logs): + async with run_compiled(yaml_config, line_callback=waiter.callback): async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( client, disconnect_event, @@ -241,7 +199,7 @@ async def test_oversized_protobuf_message_id_noise( await assert_device_name(client, "oversized-noise") # After test, verify process didn't crash - assert not process_exited, "ESPHome process should not crash" + assert not _crashed(waiter), "ESPHome process should not crash" # Verify we can still reconnect async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( diff --git a/tests/integration/test_safe_mode_loop_runs.py b/tests/integration/test_safe_mode_loop_runs.py index 3c5942b5797..306bf6bbbc2 100644 --- a/tests/integration/test_safe_mode_loop_runs.py +++ b/tests/integration/test_safe_mode_loop_runs.py @@ -23,13 +23,11 @@ This test uses ``run_binary`` directly to skip the port wait. from __future__ import annotations -import asyncio -import re - import pytest from .conftest import run_binary from .host_prefs import clear_host_prefs, force_safe_mode +from .log_utils import LineWaiter from .types import CompileFunction, ConfigWriter from .wait_utils import wait_or_fail @@ -54,27 +52,16 @@ async def test_safe_mode_loop_runs( force_safe_mode(DEVICE_NAME) try: - loop = asyncio.get_running_loop() - safe_mode_active = loop.create_future() - thread_log_seen = loop.create_future() - safe_mode_pattern = re.compile(r"SAFE MODE IS ACTIVE") - thread_log_pattern = re.compile(re.escape(THREAD_LOG_MARKER)) - - def on_log(line: str) -> None: - if not safe_mode_active.done() and safe_mode_pattern.search(line): - safe_mode_active.set_result(True) - if not thread_log_seen.done() and thread_log_pattern.search(line): - thread_log_seen.set_result(True) - - async with run_binary(binary_path, line_callback=on_log): + waiter = LineWaiter() + async with run_binary(binary_path, line_callback=waiter.callback): await wait_or_fail( - safe_mode_active, + waiter.wait_for("SAFE MODE IS ACTIVE", timeout=15.0), 15.0, "Did not observe 'SAFE MODE IS ACTIVE' -- safe mode " "didn't trigger, so this test isn't exercising the bug.", ) await wait_or_fail( - thread_log_seen, + waiter.wait_for(THREAD_LOG_MARKER), 10.0, f"Did not observe thread-logged marker {THREAD_LOG_MARKER!r} " "within timeout. Logger::loop() never drained the task " diff --git a/tests/integration/test_scheduler_background_wake.py b/tests/integration/test_scheduler_background_wake.py index 97af3222785..339475f4281 100644 --- a/tests/integration/test_scheduler_background_wake.py +++ b/tests/integration/test_scheduler_background_wake.py @@ -6,16 +6,20 @@ takes the separate defer queue on multi-threaded builds. from __future__ import annotations -import asyncio import re from aioesphomeapi import ButtonInfo import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +RESULT_RE = re.compile( + r"SCHEDULER_WAKE_RESULT delay=\d+ elapsed=(\d+) loop_delta=(-?\d+)" +) + @pytest.mark.asyncio async def test_scheduler_background_wake( @@ -23,25 +27,10 @@ async def test_scheduler_background_wake( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - loop = asyncio.get_running_loop() - results: dict[int, asyncio.Future[tuple[int, int]]] = { - 100: loop.create_future(), - 0: loop.create_future(), - } - - def on_log_line(line: str) -> None: - match = re.search( - r"SCHEDULER_WAKE_RESULT delay=(\d+) elapsed=(\d+) loop_delta=(-?\d+)", - line, - ) - if match is None: - return - result = results[int(match.group(1))] - if not result.done(): - result.set_result((int(match.group(2)), int(match.group(3)))) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "scheduler-background-wake") @@ -58,11 +47,16 @@ async def test_scheduler_background_wake( ) client.button_command(start_button.key) - elapsed, loop_delta = await wait_or_fail( - results[delay], + line = await wait_or_fail( + waiter.wait_for( + f"SCHEDULER_WAKE_RESULT delay={delay} elapsed=", timeout=20.0 + ), 10.0, f"background insert with delay={delay} did not fire", ) + match = RESULT_RE.search(line) + assert match, line + elapsed, loop_delta = int(match[1]), int(match[2]) assert elapsed < 1000, ( f"background insert with delay={delay} should interrupt the " diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py index 699a5bc746c..35592d694a8 100644 --- a/tests/integration/test_scheduler_blocking_warning.py +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -8,17 +8,18 @@ the owning script and report the real threshold (50 ms). from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .types import APIClientConnectedFactory, RunCompiledFunction # Matches: " took a long time for an operation (NN ms), max is NN ms" WARN_PATTERN = re.compile( r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" ) +WARN_NEEDLE = "took a long time for an operation" @pytest.mark.asyncio @@ -28,15 +29,10 @@ async def test_scheduler_blocking_warning( api_client_connected: APIClientConnectedFactory, ) -> None: """Deferred blocking work inside a script is attributed to the script, not "".""" - loop = asyncio.get_running_loop() - warning_future: asyncio.Future[str] = loop.create_future() - - def check_output(line: str) -> None: - if WARN_PATTERN.search(line) and not warning_future.done(): - warning_future.set_result(line) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): device_info = await client.device_info() @@ -44,7 +40,7 @@ async def test_scheduler_blocking_warning( # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the # continuation, tripping the blocking warning. - warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + warning_line = await waiter.wait_for(WARN_NEEDLE, timeout=10.0) # Must name the owning script, not "" and not the generic fallback. assert "" not in warning_line, ( @@ -69,19 +65,14 @@ async def test_scheduler_blocking_warning_generic_source( api_client_connected: APIClientConnectedFactory, ) -> None: """A delay in a plain (non-script) automation logs the generic label, not a script name.""" - loop = asyncio.get_running_loop() - warning_future: asyncio.Future[str] = loop.create_future() - - def check_output(line: str) -> None: - if WARN_PATTERN.search(line) and not warning_future.done(): - warning_future.set_result(line) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): assert await client.device_info() is not None - warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + warning_line = await waiter.wait_for(WARN_NEEDLE, timeout=10.0) assert "a scheduled task took a long time" in warning_line, ( f"Non-script deferred work should log the generic label, got: {warning_line}" @@ -104,17 +95,12 @@ async def test_scheduler_delay_runs_on_failed_component( Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's failed-component skip must not drop them. """ - loop = asyncio.get_running_loop() - fired: asyncio.Future[bool] = loop.create_future() - - def check_output(line: str) -> None: - if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): - fired.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): assert await client.device_info() is not None # If the failed host component wrongly dropped the delay, this times out. - await asyncio.wait_for(fired, timeout=10.0) + await waiter.wait_for("DELAY_FIRED_AFTER_FAIL", timeout=10.0) diff --git a/tests/integration/test_scheduler_bulk_cleanup.py b/tests/integration/test_scheduler_bulk_cleanup.py index c8e278f8cc9..5a9d0b5a6b0 100644 --- a/tests/integration/test_scheduler_bulk_cleanup.py +++ b/tests/integration/test_scheduler_bulk_cleanup.py @@ -5,10 +5,15 @@ import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +CANCELLED_RE = re.compile(r"Successfully cancelled (\d+) timeouts") +REMOVED_RE = re.compile(r"Bulk cleanup triggered: removed (\d+) items") +POST_CLEANUP_RE = re.compile(r"Post-cleanup timeout \d+ executed correctly") + @pytest.mark.asyncio async def test_scheduler_bulk_cleanup( @@ -18,52 +23,10 @@ async def test_scheduler_bulk_cleanup( ) -> None: """Test that bulk cleanup path is triggered when many items are cancelled.""" - # Create a future to signal test completion - loop = asyncio.get_event_loop() - test_complete_future: asyncio.Future[None] = loop.create_future() - bulk_cleanup_triggered = False - cleanup_stats: dict[str, int] = { - "removed": 0, - "before": 0, - "after": 0, - } - post_cleanup_executed = 0 - - def on_log_line(line: str) -> None: - nonlocal bulk_cleanup_triggered, post_cleanup_executed - - # Look for logs indicating bulk cleanup was triggered - # The actual cleanup happens silently, so we track the cancel operations - if "Successfully cancelled" in line and "timeouts" in line: - match = re.search(r"Successfully cancelled (\d+) timeouts", line) - if match and int(match.group(1)) > 10: - bulk_cleanup_triggered = True - - # Track cleanup statistics - match = re.search(r"Bulk cleanup triggered: removed (\d+) items", line) - if match: - cleanup_stats["removed"] = int(match.group(1)) - - match = re.search(r"Items before cleanup: (\d+), after: (\d+)", line) - if match: - cleanup_stats["before"] = int(match.group(1)) - cleanup_stats["after"] = int(match.group(2)) - - # Track post-cleanup timeout executions - if "Post-cleanup timeout" in line and "executed correctly" in line: - match = re.search(r"Post-cleanup timeout (\d+) executed correctly", line) - if match: - post_cleanup_executed += 1 - - # Check for final test completion - if ( - "All post-cleanup timeouts completed - test finished" in line - and not test_complete_future.done() - ): - test_complete_future.set_result(None) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify we can connect @@ -80,17 +43,34 @@ async def test_scheduler_bulk_cleanup( # Execute the test await client.execute_service(trigger_bulk_cleanup_service, {}) - # Wait for test completion - await wait_or_fail(test_complete_future, 10.0, "Bulk cleanup test timed out") + await wait_or_fail( + waiter.wait_for( + "All post-cleanup timeouts completed - test finished", timeout=20.0 + ), + 10.0, + "Bulk cleanup test timed out", + ) + + cancelled = [ + int(match[1]) + for line in waiter.lines + if (match := CANCELLED_RE.search(line)) + ] + removed = [ + int(match[1]) for line in waiter.lines if (match := REMOVED_RE.search(line)) + ] + post_cleanup_executed = sum( + POST_CLEANUP_RE.search(line) is not None for line in waiter.lines + ) # Verify bulk cleanup was triggered - assert bulk_cleanup_triggered, ( + assert any(count > 10 for count in cancelled), ( "Bulk cleanup path was not triggered - MAX_LOGICALLY_DELETED_ITEMS threshold not reached" ) # Verify cleanup statistics - assert cleanup_stats["removed"] > 10, ( - f"Expected more than 10 items removed, got {cleanup_stats['removed']}" + assert removed and removed[-1] > 10, ( + f"Expected more than 10 items removed, got {removed}" ) # Verify scheduler still works after bulk cleanup diff --git a/tests/integration/test_scheduler_defer_cancel_regular.py b/tests/integration/test_scheduler_defer_cancel_regular.py index 488acbd0c48..855f5110618 100644 --- a/tests/integration/test_scheduler_defer_cancel_regular.py +++ b/tests/integration/test_scheduler_defer_cancel_regular.py @@ -4,6 +4,7 @@ import asyncio import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -17,27 +18,13 @@ async def test_scheduler_defer_cancels_regular( ) -> None: """Test that set_timeout(name, 0) cancels a previously scheduled set_timeout(name, delay).""" - # Create a future to signal test completion - loop = asyncio.get_running_loop() - test_complete_future: asyncio.Future[None] = loop.create_future() + waiter = LineWaiter() - # Track log messages - log_messages: list[str] = [] - error_detected = False - - def on_log_line(line: str) -> None: - nonlocal error_detected - if "TEST" in line: - log_messages.append(line) - - if "ERROR: Regular timeout executed" in line: - error_detected = True - - if "Test complete" in line and not test_complete_future.done(): - test_complete_future.set_result(None) + def log_messages() -> list[str]: + return [line for line in waiter.lines if "TEST" in line] async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify we can connect @@ -54,29 +41,33 @@ async def test_scheduler_defer_cancels_regular( # Execute the test await client.execute_service(test_service, {}) - # Wait for test completion await wait_or_fail( - test_complete_future, 5.0, "Test timed out", log_messages=log_messages + waiter.wait_for("Test complete", timeout=20.0), + 5.0, + "Test timed out", + log_messages=log_messages, ) - # Verify results - assert not error_detected, ( - f"Regular timeout should have been cancelled but it executed! Logs: {log_messages}" + messages = log_messages() + assert not any( + "ERROR: Regular timeout executed" in line for line in waiter.lines + ), ( + f"Regular timeout should have been cancelled but it executed! Logs: {messages}" ) # Verify the deferred timeout executed - assert any( - "SUCCESS: Deferred timeout executed" in msg for msg in log_messages - ), f"Deferred timeout should have executed. Logs: {log_messages}" + assert any("SUCCESS: Deferred timeout executed" in msg for msg in messages), ( + f"Deferred timeout should have executed. Logs: {messages}" + ) # Verify the expected sequence of events assert any( - "Starting defer cancels regular timeout test" in msg for msg in log_messages + "Starting defer cancels regular timeout test" in msg for msg in messages ) assert any( - "Scheduled regular timeout with 100ms delay" in msg for msg in log_messages + "Scheduled regular timeout with 100ms delay" in msg for msg in messages ) assert any( "Scheduled deferred timeout - should cancel regular timeout" in msg - for msg in log_messages + for msg in messages ) diff --git a/tests/integration/test_scheduler_defer_stress.py b/tests/integration/test_scheduler_defer_stress.py index 7d2734c3027..498dd5444d4 100644 --- a/tests/integration/test_scheduler_defer_stress.py +++ b/tests/integration/test_scheduler_defer_stress.py @@ -7,6 +7,7 @@ import pytest from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -76,15 +77,13 @@ async def test_scheduler_defer_stress( await client.execute_service(run_stress_test_service, {}) # Wait for all defers to execute (should be quick) - try: - await asyncio.wait_for(test_complete_future, timeout=5.0) - except TimeoutError: - # Report how many we got - pytest.fail( - f"Stress test timed out. Only {len(executed_defers)} of " - f"1000 defers executed. Missing IDs: " - f"{sorted(set(range(1000)) - executed_defers)[:10]}..." - ) + await wait_or_fail( + test_complete_future, + 5.0, + "Stress test did not complete", + executed=lambda: f"{len(executed_defers)} of 1000 defers", + missing_ids=lambda: sorted(set(range(1000)) - executed_defers), + ) # Verify all defers executed assert len(executed_defers) == 1000, ( diff --git a/tests/integration/test_scheduler_heap_stress.py b/tests/integration/test_scheduler_heap_stress.py index 4edad277c9f..144f7732c2a 100644 --- a/tests/integration/test_scheduler_heap_stress.py +++ b/tests/integration/test_scheduler_heap_stress.py @@ -7,6 +7,7 @@ import pytest from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -81,16 +82,13 @@ async def test_scheduler_heap_stress( await client.execute_service(run_stress_test_service, {}) # Wait for all callbacks to execute (should be quick, but give more time for scheduling) - try: - await asyncio.wait_for(test_complete_future, timeout=10.0) - except TimeoutError: - # Report how many we got - missing_ids = sorted(set(range(1000)) - executed_callbacks) - pytest.fail( - f"Stress test timed out. Only {len(executed_callbacks)} of " - f"1000 callbacks executed. Missing IDs: " - f"{missing_ids[:20]}... (total missing: {len(missing_ids)})" - ) + await wait_or_fail( + test_complete_future, + 10.0, + "Stress test did not complete", + executed=lambda: f"{len(executed_callbacks)} of 1000 callbacks", + missing_ids=lambda: sorted(set(range(1000)) - executed_callbacks), + ) # Verify all callbacks executed assert len(executed_callbacks) == 1000, ( diff --git a/tests/integration/test_scheduler_internal_id_no_collision.py b/tests/integration/test_scheduler_internal_id_no_collision.py index 8bb1f5df440..d153a10cb9d 100644 --- a/tests/integration/test_scheduler_internal_id_no_collision.py +++ b/tests/integration/test_scheduler_internal_id_no_collision.py @@ -6,11 +6,9 @@ components) are in completely separate matching namespaces, even when the underlying uint32_t values are identical and on the same component. """ -import asyncio -import re - import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -23,103 +21,59 @@ async def test_scheduler_internal_id_no_collision( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that internal and numeric IDs with same value don't collide.""" - # Test 1: Both types fire independently with same ID - internal_timeout_0_fired = asyncio.Event() - numeric_timeout_0_fired = asyncio.Event() + waiter = LineWaiter() - # Test 2: Cancelling numeric doesn't cancel internal - internal_timeout_1_survived = asyncio.Event() - numeric_timeout_1_error = asyncio.Event() - - # Test 3: Cancelling internal doesn't cancel numeric - numeric_timeout_2_survived = asyncio.Event() - internal_timeout_2_error = asyncio.Event() - - # Test 4: Both interval types fire independently - internal_interval_3_done = asyncio.Event() - numeric_interval_3_done = asyncio.Event() - - # Test 5: String name doesn't collide with internal ID - string_timeout_fired = asyncio.Event() - internal_timeout_10_fired = asyncio.Event() - - # Completion - all_tests_complete = asyncio.Event() - - def on_log_line(line: str) -> None: - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - if "Internal timeout 0 fired" in clean_line: - internal_timeout_0_fired.set() - elif "Numeric timeout 0 fired" in clean_line: - numeric_timeout_0_fired.set() - elif "Internal timeout 1 survived cancel" in clean_line: - internal_timeout_1_survived.set() - elif "ERROR: Numeric timeout 1 should have been cancelled" in clean_line: - numeric_timeout_1_error.set() - elif "Numeric timeout 2 survived cancel" in clean_line: - numeric_timeout_2_survived.set() - elif "ERROR: Internal timeout 2 should have been cancelled" in clean_line: - internal_timeout_2_error.set() - elif "Internal interval 3 fired twice" in clean_line: - internal_interval_3_done.set() - elif "Numeric interval 3 fired twice" in clean_line: - numeric_interval_3_done.set() - elif "String timeout collision_test fired" in clean_line: - string_timeout_fired.set() - elif "Internal timeout 10 fired" in clean_line: - internal_timeout_10_fired.set() - elif "All collision tests complete" in clean_line: - all_tests_complete.set() + def seen(text: str) -> bool: + return any(text in line for line in waiter.lines) async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "scheduler-internal-id-test") await wait_or_fail( - all_tests_complete, + waiter.wait_for("All collision tests complete"), 5.0, "Not all collision tests completed within 5 seconds", ) # Test 1: Both timeout types with same ID 0 must fire - assert internal_timeout_0_fired.is_set(), ( + assert seen("Internal timeout 0 fired"), ( "Internal timeout with ID 0 should have fired" ) - assert numeric_timeout_0_fired.is_set(), ( + assert seen("Numeric timeout 0 fired"), ( "Numeric timeout with ID 0 should have fired" ) # Test 2: Cancelling numeric ID must NOT cancel internal ID - assert internal_timeout_1_survived.is_set(), ( + assert seen("Internal timeout 1 survived cancel"), ( "Internal timeout 1 should survive cancellation of numeric timeout 1" ) - assert not numeric_timeout_1_error.is_set(), ( + assert not seen("ERROR: Numeric timeout 1 should have been cancelled"), ( "Numeric timeout 1 should have been cancelled" ) # Test 3: Cancelling internal ID must NOT cancel numeric ID - assert numeric_timeout_2_survived.is_set(), ( + assert seen("Numeric timeout 2 survived cancel"), ( "Numeric timeout 2 should survive cancellation of internal timeout 2" ) - assert not internal_timeout_2_error.is_set(), ( + assert not seen("ERROR: Internal timeout 2 should have been cancelled"), ( "Internal timeout 2 should have been cancelled" ) # Test 4: Both interval types with same ID must fire independently - assert internal_interval_3_done.is_set(), ( + assert seen("Internal interval 3 fired twice"), ( "Internal interval 3 should have fired at least twice" ) - assert numeric_interval_3_done.is_set(), ( + assert seen("Numeric interval 3 fired twice"), ( "Numeric interval 3 should have fired at least twice" ) # Test 5: String name and internal ID don't collide - assert string_timeout_fired.is_set(), ( + assert seen("String timeout collision_test fired"), ( "String timeout 'collision_test' should have fired" ) - assert internal_timeout_10_fired.is_set(), ( + assert seen("Internal timeout 10 fired"), ( "Internal timeout 10 should have fired alongside string timeout" ) diff --git a/tests/integration/test_scheduler_interval_reschedule.py b/tests/integration/test_scheduler_interval_reschedule.py index b0bf3e05247..e581c70a327 100644 --- a/tests/integration/test_scheduler_interval_reschedule.py +++ b/tests/integration/test_scheduler_interval_reschedule.py @@ -147,12 +147,12 @@ async def test_scheduler_interval_reschedule( replace_original_future, 5.0, "Replace-test original interval did not fire" ) - try: - await asyncio.wait_for(replaced_stopped_future, timeout=5.0) - except TimeoutError: - pytest.fail( - f"Replaced interval did not stop. Fired {replaced_fire_count} times" - ) + await wait_or_fail( + replaced_stopped_future, + 5.0, + "Replaced interval did not stop", + fired=lambda: replaced_fire_count, + ) # Wait to ensure replacement doesn't fire again after cancellation await asyncio.sleep(0.3) diff --git a/tests/integration/test_scheduler_interval_zero_coerced.py b/tests/integration/test_scheduler_interval_zero_coerced.py index 96c55267b85..f5ff0497565 100644 --- a/tests/integration/test_scheduler_interval_zero_coerced.py +++ b/tests/integration/test_scheduler_interval_zero_coerced.py @@ -13,13 +13,11 @@ and the device stays responsive to API calls. from __future__ import annotations -import asyncio - import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -29,18 +27,10 @@ async def test_scheduler_interval_zero_coerced( api_client_connected: APIClientConnectedFactory, ) -> None: """interval=0ms must be coerced to 1ms and not starve the main loop.""" - loop = asyncio.get_running_loop() - reached_50: asyncio.Future[None] = loop.create_future() - coerce_warning: asyncio.Future[None] = loop.create_future() - - def on_log_line(line: str) -> None: - if "ZERO_INTERVAL_50_FIRES_REACHED" in line and not reached_50.done(): - reached_50.set_result(None) - if "would spin main loop" in line and not coerce_warning.done(): - coerce_warning.set_result(None) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # The API-client connection itself is evidence that the main loop @@ -49,19 +39,10 @@ async def test_scheduler_interval_zero_coerced( await assert_device_name(client, "sched-interval-zero") # Coerce warning must fire at registration - await wait_or_fail( - coerce_warning, - 5.0, - "Expected coerce warning 'would spin main loop' not seen", - ) + await waiter.wait_for("would spin main loop", timeout=5.0) # The coerced 1ms interval should fire 50 times quickly — this # confirms the callback actually runs (not just registered) and the # scheduler yields back to the main loop each time. - await wait_or_fail( - reached_50, - 5.0, - "Coerced interval=0→1ms did not reach 50 fires within 5s, " - "which would indicate either the coercion failed or the " - "main loop is still being starved.", - ) + # A timeout means the coercion failed or the main loop is still starved. + await waiter.wait_for("ZERO_INTERVAL_50_FIRES_REACHED", timeout=5.0) diff --git a/tests/integration/test_scheduler_loop_phase_timeout.py b/tests/integration/test_scheduler_loop_phase_timeout.py index 196f659735f..16f440ff913 100644 --- a/tests/integration/test_scheduler_loop_phase_timeout.py +++ b/tests/integration/test_scheduler_loop_phase_timeout.py @@ -2,15 +2,17 @@ from __future__ import annotations -import asyncio import re import pytest from .conftest import run_binary +from .log_utils import LineWaiter from .types import CompileFunction, ConfigWriter from .wait_utils import wait_or_fail +RESULT_RE = re.compile(r"SCHEDULER_LOOP_PHASE_RESULT elapsed=(\d+)") + @pytest.mark.asyncio async def test_scheduler_loop_phase_timeout( @@ -21,18 +23,17 @@ async def test_scheduler_loop_phase_timeout( config_path = await write_yaml_config(yaml_config) binary_path = await compile_esphome(config_path) - loop = asyncio.get_running_loop() - timeout_result: asyncio.Future[int] = loop.create_future() + waiter = LineWaiter() - def on_log_line(line: str) -> None: - match = re.search(r"SCHEDULER_LOOP_PHASE_RESULT elapsed=(\d+)", line) - if match and not timeout_result.done(): - timeout_result.set_result(int(match.group(1))) - - async with run_binary(binary_path, line_callback=on_log_line): - elapsed = await wait_or_fail( - timeout_result, 15.0, "loop-phase timeout did not fire within 15 seconds" + async with run_binary(binary_path, line_callback=waiter.callback): + line = await wait_or_fail( + waiter.wait_for("SCHEDULER_LOOP_PHASE_RESULT elapsed=", timeout=30.0), + 15.0, + "loop-phase timeout did not fire within 15 seconds", ) + match = RESULT_RE.search(line) + assert match, line + elapsed = int(match[1]) assert elapsed < 1000, ( f"loop-phase timeout should interrupt the five-second sleep, but fired after {elapsed}ms" diff --git a/tests/integration/test_scheduler_null_name.py b/tests/integration/test_scheduler_null_name.py index d5278d35c2c..92bd880024d 100644 --- a/tests/integration/test_scheduler_null_name.py +++ b/tests/integration/test_scheduler_null_name.py @@ -1,10 +1,10 @@ """Test that scheduler handles NULL names safely without crashing.""" import asyncio -import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -18,19 +18,10 @@ async def test_scheduler_null_name( ) -> None: """Test that scheduler handles NULL names safely without crashing.""" - loop = asyncio.get_running_loop() - test_complete_future: asyncio.Future[bool] = loop.create_future() - - # Pattern to match test completion - test_complete_pattern = re.compile(r"Test completed successfully") - - def check_output(line: str) -> None: - """Check log output for test completion.""" - if not test_complete_future.done() and test_complete_pattern.search(line): - test_complete_future.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify we can connect @@ -47,9 +38,8 @@ async def test_scheduler_null_name( # Execute the test await client.execute_service(test_null_name_service, {}) - # Wait for test completion await wait_or_fail( - test_complete_future, + waiter.wait_for("Test completed successfully", timeout=20.0), 10.0, "Test did not complete within timeout - likely crashed due to NULL name", ) diff --git a/tests/integration/test_scheduler_numeric_id_test.py b/tests/integration/test_scheduler_numeric_id_test.py index cde23ce3152..4315b8d07e3 100644 --- a/tests/integration/test_scheduler_numeric_id_test.py +++ b/tests/integration/test_scheduler_numeric_id_test.py @@ -1,14 +1,17 @@ """Test scheduler numeric ID (uint32_t) overloads.""" -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +INTERVAL_COUNT_PATTERN = re.compile(r"Numeric interval 2001 fired.*count: (\d+)") +FINAL_RESULTS_PATTERN = re.compile(r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)") + @pytest.mark.asyncio async def test_scheduler_numeric_id_test( @@ -17,203 +20,112 @@ async def test_scheduler_numeric_id_test( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that scheduler handles numeric IDs (uint32_t) correctly.""" - # Track counts - timeout_count = 0 - interval_count = 0 - defer_count = 0 + waiter = LineWaiter() - # Events for each test completion - numeric_timeout_1001_fired = asyncio.Event() - numeric_timeout_1002_fired = asyncio.Event() - numeric_interval_2001_fired = asyncio.Event() - numeric_interval_cancelled = asyncio.Event() - numeric_timeout_cancelled = asyncio.Event() - duplicate_timeout_fired = asyncio.Event() - component_timeout_fired = asyncio.Event() - component_interval_fired = asyncio.Event() - zero_id_timeout_fired = asyncio.Event() - max_id_timeout_fired = asyncio.Event() - numeric_defer_7001_fired = asyncio.Event() - numeric_defer_7002_fired = asyncio.Event() - numeric_defer_cancelled = asyncio.Event() - final_results_logged = asyncio.Event() - - # Track interval counts - numeric_interval_count = 0 - - def on_log_line(line: str) -> None: - nonlocal timeout_count, interval_count, defer_count - nonlocal numeric_interval_count - - # Strip ANSI color codes - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - # Check for numeric timeout completions - if "Numeric timeout 1001 fired" in clean_line: - numeric_timeout_1001_fired.set() - timeout_count += 1 - - elif "Numeric timeout 1002 fired" in clean_line: - numeric_timeout_1002_fired.set() - timeout_count += 1 - - # Check for numeric interval - elif "Numeric interval 2001 fired" in clean_line: - match = re.search(r"count: (\d+)", clean_line) - if match: - numeric_interval_count = int(match.group(1)) - numeric_interval_2001_fired.set() - - elif "Cancelled numeric interval 2001" in clean_line: - numeric_interval_cancelled.set() - - elif "Cancelled numeric timeout 3001" in clean_line: - numeric_timeout_cancelled.set() - - # Check for duplicate timeout (only last should fire) - elif "Duplicate numeric timeout" in clean_line: - match = re.search(r"timeout (\d+) fired", clean_line) - if match and match.group(1) == "4": - duplicate_timeout_fired.set() - timeout_count += 1 - - # Check for component method tests - elif "Component numeric timeout 5001 fired" in clean_line: - component_timeout_fired.set() - timeout_count += 1 - - elif "Component numeric interval 5002 fired" in clean_line: - component_interval_fired.set() - interval_count += 1 - - # Check for edge case tests - elif "Numeric timeout with ID 0 fired" in clean_line: - zero_id_timeout_fired.set() - timeout_count += 1 - - elif "Numeric timeout with max ID fired" in clean_line: - max_id_timeout_fired.set() - timeout_count += 1 - - # Check for numeric defer tests - elif "Component numeric defer 7001 fired" in clean_line: - numeric_defer_7001_fired.set() - - elif "Component numeric defer 7002 fired" in clean_line: - numeric_defer_7002_fired.set() - - elif "Cancelled numeric defer 8001: true" in clean_line: - numeric_defer_cancelled.set() - - # Check for final results - elif "Final results" in clean_line: - match = re.search( - r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)", - clean_line, - ) - if match: - timeout_count = int(match.group(1)) - interval_count = int(match.group(2)) - defer_count = int(match.group(3)) - final_results_logged.set() + async def expect(text: str, timeout: float, msg: str) -> str: + return await wait_or_fail(waiter.wait_for(text), timeout, msg) async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify we can connect await assert_device_name(client, "scheduler-numeric-id-test") # Wait for numeric timeout tests - await wait_or_fail( - numeric_timeout_1001_fired, + await expect( + "Numeric timeout 1001 fired", 0.5, "Numeric timeout 1001 did not fire within 0.5 seconds", ) - - await wait_or_fail( - numeric_timeout_1002_fired, + await expect( + "Numeric timeout 1002 fired", 0.5, "Numeric timeout 1002 did not fire within 0.5 seconds", ) - - await wait_or_fail( - numeric_interval_2001_fired, + await expect( + "Numeric interval 2001 fired", 1.0, "Numeric interval 2001 did not fire within 1 second", ) - - await wait_or_fail( - numeric_interval_cancelled, + await expect( + "Cancelled numeric interval 2001", 2.0, "Numeric interval 2001 was not cancelled within 2 seconds", ) # Verify numeric interval ran at least twice + interval_counts = [ + int(m.group(1)) + for line in waiter.lines + if (m := INTERVAL_COUNT_PATTERN.search(line)) + ] + numeric_interval_count = interval_counts[-1] if interval_counts else 0 assert numeric_interval_count >= 2, ( f"Expected numeric interval to run at least 2 times, got {numeric_interval_count}" ) # Verify numeric timeout was cancelled - assert numeric_timeout_cancelled.is_set(), ( + assert any("Cancelled numeric timeout 3001" in line for line in waiter.lines), ( "Numeric timeout 3001 should have been cancelled" ) # Wait for duplicate timeout (only last one should fire) - await wait_or_fail( - duplicate_timeout_fired, + await expect( + "Duplicate numeric timeout 4 fired", 1.0, "Duplicate numeric timeout did not fire within 1 second", ) # Wait for component method tests - await wait_or_fail( - component_timeout_fired, + await expect( + "Component numeric timeout 5001 fired", 0.5, "Component numeric timeout did not fire within 0.5 seconds", ) - - await wait_or_fail( - component_interval_fired, + await expect( + "Component numeric interval 5002 fired", 1.0, "Component numeric interval did not fire within 1 second", ) # Wait for edge case tests - await wait_or_fail( - zero_id_timeout_fired, + await expect( + "Numeric timeout with ID 0 fired", 0.5, "Zero ID timeout did not fire within 0.5 seconds", ) - - await wait_or_fail( - max_id_timeout_fired, 0.5, "Max ID timeout did not fire within 0.5 seconds" + await expect( + "Numeric timeout with max ID fired", + 0.5, + "Max ID timeout did not fire within 0.5 seconds", ) # Wait for numeric defer tests - await wait_or_fail( - numeric_defer_7001_fired, + await expect( + "Component numeric defer 7001 fired", 0.5, "Numeric defer 7001 did not fire within 0.5 seconds", ) - - await wait_or_fail( - numeric_defer_7002_fired, + await expect( + "Component numeric defer 7002 fired", 0.5, "Numeric defer 7002 did not fire within 0.5 seconds", ) # Verify numeric defer was cancelled - await wait_or_fail( - numeric_defer_cancelled, + await expect( + "Cancelled numeric defer 8001: true", 0.5, "Numeric defer 8001 cancel confirmation not received", ) # Wait for final results - await wait_or_fail( - final_results_logged, 3.0, "Final results were not logged within 3 seconds" + final_line = await expect( + "Final results", 3.0, "Final results were not logged within 3 seconds" ) + assert (match := FINAL_RESULTS_PATTERN.search(final_line)), final_line + timeout_count, interval_count, defer_count = map(int, match.groups()) # Verify results assert timeout_count >= 6, f"Expected at least 6 timeouts, got {timeout_count}" diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index 9b7f8c3940c..aaeba368313 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -9,6 +9,7 @@ import pytest from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -116,60 +117,55 @@ async def test_scheduler_pool( } complete_service = require_service(services, "run_complete") - try: - # Phase 1: Component lifecycle - await client.execute_service(phase_services[1], {}) - await asyncio.wait_for(phase_futures[1], timeout=1.0) - await asyncio.sleep(0.05) # Let timeouts complete + # Shown when a phase does not complete + pool = { + "phases_completed": lambda: [ + n for n, fut in phase_futures.items() if fut.done() + ], + "reuse": lambda: pool_reuse_count, + "recycle": lambda: pool_recycle_count, + "pool_full": lambda: pool_full_count, + "new_alloc": lambda: new_alloc_count, + "logged": log_lines, + } + # Phase 1: Component lifecycle + await client.execute_service(phase_services[1], {}) + await wait_or_fail(phase_futures[1], 1.0, "Phase 1 did not complete", **pool) + await asyncio.sleep(0.05) # Let timeouts complete - # Phase 2: Sensor polling - await client.execute_service(phase_services[2], {}) - await asyncio.wait_for(phase_futures[2], timeout=1.0) - await asyncio.sleep(0.1) # Let intervals run a bit + # Phase 2: Sensor polling + await client.execute_service(phase_services[2], {}) + await wait_or_fail(phase_futures[2], 1.0, "Phase 2 did not complete", **pool) + await asyncio.sleep(0.1) # Let intervals run a bit - # Phase 3: Communication patterns - await client.execute_service(phase_services[3], {}) - await asyncio.wait_for(phase_futures[3], timeout=1.0) - await asyncio.sleep(0.1) # Let heartbeat run + # Phase 3: Communication patterns + await client.execute_service(phase_services[3], {}) + await wait_or_fail(phase_futures[3], 1.0, "Phase 3 did not complete", **pool) + await asyncio.sleep(0.1) # Let heartbeat run - # Phase 4: Defer patterns - await client.execute_service(phase_services[4], {}) - await asyncio.wait_for(phase_futures[4], timeout=1.0) - await asyncio.sleep(0.2) # Let everything settle and recycle + # Phase 4: Defer patterns + await client.execute_service(phase_services[4], {}) + await wait_or_fail(phase_futures[4], 1.0, "Phase 4 did not complete", **pool) + await asyncio.sleep(0.2) # Let everything settle and recycle - # Phase 5: Pool reuse verification - await client.execute_service(phase_services[5], {}) - await asyncio.wait_for(phase_futures[5], timeout=1.0) - await asyncio.sleep(0.1) # Let Phase 5 timeouts complete and recycle + # Phase 5: Pool reuse verification + await client.execute_service(phase_services[5], {}) + await wait_or_fail(phase_futures[5], 1.0, "Phase 5 did not complete", **pool) + await asyncio.sleep(0.1) # Let Phase 5 timeouts complete and recycle - # Phase 6: Full pool reuse verification - await client.execute_service(phase_services[6], {}) - await asyncio.wait_for(phase_futures[6], timeout=1.0) - await asyncio.sleep(0.1) # Let Phase 6 timeouts complete + # Phase 6: Full pool reuse verification + await client.execute_service(phase_services[6], {}) + await wait_or_fail(phase_futures[6], 1.0, "Phase 6 did not complete", **pool) + await asyncio.sleep(0.1) # Let Phase 6 timeouts complete - # Phase 7: Same-named defer optimization - await client.execute_service(phase_services[7], {}) - await asyncio.wait_for(phase_futures[7], timeout=1.0) - await asyncio.sleep(0.05) # Let the single defer execute + # Phase 7: Same-named defer optimization + await client.execute_service(phase_services[7], {}) + await wait_or_fail(phase_futures[7], 1.0, "Phase 7 did not complete", **pool) + await asyncio.sleep(0.05) # Let the single defer execute - # Complete test - await client.execute_service(complete_service, {}) - await asyncio.wait_for(test_complete_future, timeout=0.5) - - except TimeoutError as e: - # Print debug info if test times out - recent_logs = "\n".join(log_lines[-30:]) - phases_completed = [num for num, fut in phase_futures.items() if fut.done()] - pytest.fail( - f"Test timed out waiting for phase/completion. Error: {e}\n" - f" Phases completed: {phases_completed}\n" - f" Pool stats:\n" - f" Reuse count: {pool_reuse_count}\n" - f" Recycle count: {pool_recycle_count}\n" - f" Pool full count: {pool_full_count}\n" - f" New alloc count: {new_alloc_count}\n" - f"Recent logs:\n{recent_logs}" - ) + # Complete test + await client.execute_service(complete_service, {}) + await wait_or_fail(test_complete_future, 0.5, "Test did not complete", **pool) # Verify all test phases ran for phase_num in range(1, 8): diff --git a/tests/integration/test_scheduler_removed_item_race.py b/tests/integration/test_scheduler_removed_item_race.py index 52bd83610c2..aa40975fb6d 100644 --- a/tests/integration/test_scheduler_removed_item_race.py +++ b/tests/integration/test_scheduler_removed_item_race.py @@ -5,10 +5,19 @@ import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +NORMAL_COUNT = re.compile(r"Normal items executed: (\d+)") +REMOVED_COUNT = re.compile(r"Removed items executed: (\d+)") + + +def _last_count(pattern: re.Pattern[str], lines: list[str]) -> int: + counts = [int(m.group(1)) for line in lines if (m := pattern.search(line))] + return counts[-1] if counts else 0 + @pytest.mark.asyncio async def test_scheduler_removed_item_race( @@ -24,48 +33,10 @@ async def test_scheduler_removed_item_race( 3. This causes cancelled timeouts to run when they shouldn't """ - loop = asyncio.get_running_loop() - test_complete_future: asyncio.Future[bool] = loop.create_future() - - # Track test results - test_passed = False - removed_executed = 0 - normal_executed = 0 - - # Patterns to match - race_pattern = re.compile(r"RACE: .* executed after being cancelled!") - passed_pattern = re.compile(r"TEST PASSED") - failed_pattern = re.compile(r"TEST FAILED") - complete_pattern = re.compile(r"=== Test Complete ===") - normal_count_pattern = re.compile(r"Normal items executed: (\d+)") - removed_count_pattern = re.compile(r"Removed items executed: (\d+)") - - def check_output(line: str) -> None: - """Check log output for test results.""" - nonlocal test_passed, removed_executed, normal_executed - - if race_pattern.search(line): - # Race condition detected - a cancelled item executed - test_passed = False - - if passed_pattern.search(line): - test_passed = True - elif failed_pattern.search(line): - test_passed = False - - normal_match = normal_count_pattern.search(line) - if normal_match: - normal_executed = int(normal_match.group(1)) - - removed_match = removed_count_pattern.search(line) - if removed_match: - removed_executed = int(removed_match.group(1)) - - if not test_complete_future.done() and complete_pattern.search(line): - test_complete_future.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify we can connect @@ -84,10 +55,15 @@ async def test_scheduler_removed_item_race( # Wait for test completion await wait_or_fail( - test_complete_future, 5.0, "Test did not complete within timeout" + waiter.wait_for("=== Test Complete ==="), + 5.0, + "Test did not complete within timeout", ) - # Verify results + # RACE lines come before the verdict, so the verdict alone decides + test_passed = any("TEST PASSED" in line for line in waiter.lines) + normal_executed = _last_count(NORMAL_COUNT, waiter.lines) + removed_executed = _last_count(REMOVED_COUNT, waiter.lines) assert test_passed, ( f"Test failed! Removed items executed: {removed_executed}, " f"Normal items executed: {normal_executed}" diff --git a/tests/integration/test_scheduler_self_keyed.py b/tests/integration/test_scheduler_self_keyed.py index acd646227d1..3f3f01ff71c 100644 --- a/tests/integration/test_scheduler_self_keyed.py +++ b/tests/integration/test_scheduler_self_keyed.py @@ -7,11 +7,9 @@ self-keyed and component-keyed namespaces are independent, and re-registering the same key replaces the existing timer. """ -import asyncio -import re - import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -24,74 +22,52 @@ async def test_scheduler_self_keyed( api_client_connected: APIClientConnectedFactory, ) -> None: """Test self-keyed scheduler API.""" - self_a_fired = asyncio.Event() - self_b_error = asyncio.Event() - self_c_fired = asyncio.Event() - self_d_fired = asyncio.Event() - self_shared_fired = asyncio.Event() - component_7777_fired = asyncio.Event() - self_interval_done = asyncio.Event() - self_f_first_error = asyncio.Event() - self_f_replacement_fired = asyncio.Event() - all_tests_complete = asyncio.Event() + waiter = LineWaiter() - def on_log_line(line: str) -> None: - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - if "Self timeout A fired" in clean_line: - self_a_fired.set() - elif "ERROR: Self timeout B" in clean_line: - self_b_error.set() - elif "Self timeout C fired" in clean_line: - self_c_fired.set() - elif "Self timeout D fired" in clean_line: - self_d_fired.set() - elif "Self timeout shared fired" in clean_line: - self_shared_fired.set() - elif "Component timeout 7777 fired" in clean_line: - component_7777_fired.set() - elif "Self interval E fired twice" in clean_line: - self_interval_done.set() - elif "ERROR: Self timeout F first registration" in clean_line: - self_f_first_error.set() - elif "Self timeout F replacement fired" in clean_line: - self_f_replacement_fired.set() - elif "All self-keyed tests complete" in clean_line: - all_tests_complete.set() + def seen(text: str) -> bool: + return any(text in line for line in waiter.lines) async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "scheduler-self-keyed-test") await wait_or_fail( - all_tests_complete, + waiter.wait_for("All self-keyed tests complete"), 5.0, "Not all self-keyed tests completed within 5 seconds", ) # Test 1: self-keyed timeout fires - assert self_a_fired.is_set(), "Self timeout A should have fired" + assert seen("Self timeout A fired"), "Self timeout A should have fired" # Test 2: cancel_timeout(self) actually cancels - assert not self_b_error.is_set(), "Self timeout B should have been cancelled" + assert not seen("ERROR: Self timeout B"), ( + "Self timeout B should have been cancelled" + ) # Test 3: distinct self keys don't collide - assert self_c_fired.is_set(), "Self timeout C should have fired" - assert self_d_fired.is_set(), "Self timeout D should have fired" + assert seen("Self timeout C fired"), "Self timeout C should have fired" + assert seen("Self timeout D fired"), "Self timeout D should have fired" # Test 4: self-keyed and component-keyed namespaces are independent - assert self_shared_fired.is_set(), "Self timeout shared should have fired" - assert component_7777_fired.is_set(), "Component timeout 7777 should have fired" + assert seen("Self timeout shared fired"), ( + "Self timeout shared should have fired" + ) + assert seen("Component timeout 7777 fired"), ( + "Component timeout 7777 should have fired" + ) # Test 5: self-keyed interval fires repeatedly and cancels cleanly - assert self_interval_done.is_set(), "Self interval E should have fired twice" + assert seen("Self interval E fired twice"), ( + "Self interval E should have fired twice" + ) # Test 6: re-registering same self-key replaces the previous timer - assert not self_f_first_error.is_set(), ( + assert not seen("ERROR: Self timeout F first registration"), ( "Self timeout F first registration should have been replaced" ) - assert self_f_replacement_fired.is_set(), ( + assert seen("Self timeout F replacement fired"), ( "Self timeout F replacement should have fired" ) diff --git a/tests/integration/test_scheduler_string_test.py b/tests/integration/test_scheduler_string_test.py index 87bc58b02fe..d461ef96733 100644 --- a/tests/integration/test_scheduler_string_test.py +++ b/tests/integration/test_scheduler_string_test.py @@ -1,14 +1,17 @@ """Test scheduler string optimization with static and dynamic strings.""" -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +INTERVAL_COUNT_PATTERN = re.compile(r"Static interval 1 fired.*count: (\d+)") +FINAL_RESULTS_PATTERN = re.compile(r"Timeouts: (\d+), Intervals: (\d+)") + @pytest.mark.asyncio async def test_scheduler_string_test( @@ -17,175 +20,99 @@ async def test_scheduler_string_test( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that scheduler handles both static and dynamic strings correctly.""" - # Track counts - timeout_count = 0 - interval_count = 0 + waiter = LineWaiter() - # Events for each test completion - static_timeout_1_fired = asyncio.Event() - static_timeout_2_fired = asyncio.Event() - static_interval_fired = asyncio.Event() - static_interval_cancelled = asyncio.Event() - empty_string_timeout_fired = asyncio.Event() - static_timeout_cancelled = asyncio.Event() - static_defer_1_fired = asyncio.Event() - static_defer_2_fired = asyncio.Event() - dynamic_timeout_fired = asyncio.Event() - dynamic_interval_fired = asyncio.Event() - dynamic_defer_fired = asyncio.Event() - cancel_test_done = asyncio.Event() - final_results_logged = asyncio.Event() + async def expect(text: str, timeout: float, msg: str) -> str: + return await wait_or_fail(waiter.wait_for(text), timeout, msg) - # Track interval counts - static_interval_count = 0 - dynamic_interval_count = 0 - - def on_log_line(line: str) -> None: - nonlocal \ - timeout_count, \ - interval_count, \ - static_interval_count, \ - dynamic_interval_count - - # Strip ANSI color codes - clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) - - # Check for static timeout completions - if "Static timeout 1 fired" in clean_line: - static_timeout_1_fired.set() - timeout_count += 1 - - elif "Static timeout 2 fired" in clean_line: - static_timeout_2_fired.set() - timeout_count += 1 - - # Check for static interval - elif "Static interval 1 fired" in clean_line: - match = re.search(r"count: (\d+)", clean_line) - if match: - static_interval_count = int(match.group(1)) - static_interval_fired.set() - - elif "Cancelled static interval 1" in clean_line: - static_interval_cancelled.set() - - # Check for empty string timeout - elif "Empty string timeout fired" in clean_line: - empty_string_timeout_fired.set() - - # Check for static timeout cancellation - elif "Cancelled static timeout using const char*" in clean_line: - static_timeout_cancelled.set() - - # Check for static defer tests - elif "Static defer 1 fired" in clean_line: - static_defer_1_fired.set() - timeout_count += 1 - - elif "Static defer 2 fired" in clean_line: - static_defer_2_fired.set() - timeout_count += 1 - - # Check for dynamic string tests - elif "Dynamic timeout fired" in clean_line: - dynamic_timeout_fired.set() - timeout_count += 1 - - elif "Dynamic interval fired" in clean_line: - dynamic_interval_count += 1 - dynamic_interval_fired.set() - - # Check for dynamic defer test - elif "Dynamic defer fired" in clean_line: - dynamic_defer_fired.set() - timeout_count += 1 - - # Check for cancel test - elif "Cancelled timeout using different buffer with same content" in clean_line: - cancel_test_done.set() - - # Check for final results - elif "Final results" in clean_line: - match = re.search(r"Timeouts: (\d+), Intervals: (\d+)", clean_line) - if match: - timeout_count = int(match.group(1)) - interval_count = int(match.group(2)) - final_results_logged.set() + def seen(text: str) -> bool: + return any(text in line for line in waiter.lines) async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify we can connect await assert_device_name(client, "scheduler-string-test") # Wait for static string tests - await wait_or_fail( - static_timeout_1_fired, + await expect( + "Static timeout 1 fired", 0.5, "Static timeout 1 did not fire within 0.5 seconds", ) - - await wait_or_fail( - static_timeout_2_fired, + await expect( + "Static timeout 2 fired", 0.5, "Static timeout 2 did not fire within 0.5 seconds", ) - - await wait_or_fail( - static_interval_fired, 1.0, "Static interval did not fire within 1 second" + await expect( + "Static interval 1 fired", + 1.0, + "Static interval did not fire within 1 second", ) - - await wait_or_fail( - static_interval_cancelled, + await expect( + "Cancelled static interval 1", 2.0, "Static interval was not cancelled within 2 seconds", ) # Verify static interval ran at least 3 times + interval_counts = [ + int(m.group(1)) + for line in waiter.lines + if (m := INTERVAL_COUNT_PATTERN.search(line)) + ] + static_interval_count = interval_counts[-1] if interval_counts else 0 assert static_interval_count >= 2, ( f"Expected static interval to run at least 3 times, got {static_interval_count + 1}" ) # Verify static timeout was cancelled - assert static_timeout_cancelled.is_set(), ( + assert seen("Cancelled static timeout using const char*"), ( "Static timeout should have been cancelled" ) # Wait for static defer tests - await wait_or_fail( - static_defer_1_fired, 0.5, "Static defer 1 did not fire within 0.5 seconds" + await expect( + "Static defer 1 fired", + 0.5, + "Static defer 1 did not fire within 0.5 seconds", ) - - await wait_or_fail( - static_defer_2_fired, 0.5, "Static defer 2 did not fire within 0.5 seconds" + await expect( + "Static defer 2 fired", + 0.5, + "Static defer 2 did not fire within 0.5 seconds", ) # Wait for dynamic string tests - await wait_or_fail( - dynamic_timeout_fired, 1.0, "Dynamic timeout did not fire within 1 second" + await expect( + "Dynamic timeout fired", 1.0, "Dynamic timeout did not fire within 1 second" ) - - await wait_or_fail( - dynamic_interval_fired, + await expect( + "Dynamic interval fired", 1.5, "Dynamic interval did not fire within 1.5 seconds", ) # Wait for dynamic defer test - await wait_or_fail( - dynamic_defer_fired, 1.0, "Dynamic defer did not fire within 1 second" + await expect( + "Dynamic defer fired", 1.0, "Dynamic defer did not fire within 1 second" ) # Wait for cancel test - await wait_or_fail( - cancel_test_done, 1.0, "Cancel test did not complete within 1 second" + await expect( + "Cancelled timeout using different buffer with same content", + 1.0, + "Cancel test did not complete within 1 second", ) # Wait for final results - await wait_or_fail( - final_results_logged, 4.0, "Final results were not logged within 4 seconds" + final_line = await expect( + "Final results", 4.0, "Final results were not logged within 4 seconds" ) + assert (match := FINAL_RESULTS_PATTERN.search(final_line)), final_line + timeout_count, interval_count = map(int, match.groups()) # Verify results assert timeout_count >= 6, ( @@ -196,4 +123,4 @@ async def test_scheduler_string_test( ) # Empty string timeout DOES fire (scheduler accepts empty names) - assert empty_string_timeout_fired.is_set(), "Empty string timeout should fire" + assert seen("Empty string timeout fired"), "Empty string timeout should fire" diff --git a/tests/integration/test_script_array_params.py b/tests/integration/test_script_array_params.py index d7406a00e01..880ab6eb5b7 100644 --- a/tests/integration/test_script_array_params.py +++ b/tests/integration/test_script_array_params.py @@ -9,15 +9,22 @@ braced initializer, causing compile failures. from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +PATTERNS = { + "ints": re.compile(r"ints size=(\d+) \[0\]=(-?\d+) \[1\]=(-?\d+)"), + "floats": re.compile(r"floats size=(\d+) \[0\]=(-?\d+\.\d+) \[1\]=(-?\d+\.\d+)"), + "bools": re.compile(r"bools size=(\d+) \[0\]=(\d+) \[1\]=(\d+)"), + "strings": re.compile(r"strings size=(\d+) \[0\]=(\w+) \[1\]=(\w+)"), +} + @pytest.mark.asyncio async def test_script_array_params( @@ -26,46 +33,32 @@ async def test_script_array_params( api_client_connected: APIClientConnectedFactory, ) -> None: """Execute a script with int[], float[], bool[], string[] parameters.""" - loop = asyncio.get_running_loop() - seen: dict[str, str] = {} - done = loop.create_future() - - patterns = { - "ints": re.compile(r"ints size=(\d+) \[0\]=(-?\d+) \[1\]=(-?\d+)"), - "floats": re.compile( - r"floats size=(\d+) \[0\]=(-?\d+\.\d+) \[1\]=(-?\d+\.\d+)" - ), - "bools": re.compile(r"bools size=(\d+) \[0\]=(\d+) \[1\]=(\d+)"), - "strings": re.compile(r"strings size=(\d+) \[0\]=(\w+) \[1\]=(\w+)"), - } - - def check_output(line: str) -> None: - for key, pat in patterns.items(): - if (m := pat.search(line)) and key not in seen: - seen[key] = m.group(0) - if len(seen) == len(patterns) and not done.done(): - done.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): _, services = await client.list_entities_services() service = require_service(services, "run_array_script") await client.execute_service(service, {}) + values: dict[str, tuple[str, ...]] = {} + + async def collect() -> None: + for key, pattern in PATTERNS.items(): + line = await waiter.wait_for(f"{key} size=", timeout=10.0) + assert (m := pattern.search(line)), line + values[key] = m.groups() + + # One deadline for all four lines, as the original future had await wait_or_fail( - done, 5.0, "Did not receive all expected log lines", seen=seen + collect(), 5.0, "Did not receive all expected log lines", values=values ) - assert (m := patterns["ints"].search(seen["ints"])) - assert m.group(1) == "2" and m.group(2) == "42" and m.group(3) == "100" - - assert (m := patterns["floats"].search(seen["floats"])) - assert m.group(1) == "2" and m.group(2) == "1.50" and m.group(3) == "2.50" - - assert (m := patterns["bools"].search(seen["bools"])) - assert m.group(1) == "2" and m.group(2) == "1" and m.group(3) == "0" - - assert (m := patterns["strings"].search(seen["strings"])) - assert m.group(1) == "2" and m.group(2) == "hello" and m.group(3) == "world" + assert values == { + "ints": ("2", "42", "100"), + "floats": ("2", "1.50", "2.50"), + "bools": ("2", "1", "0"), + "strings": ("2", "hello", "world"), + } diff --git a/tests/integration/test_script_delay_params.py b/tests/integration/test_script_delay_params.py index 82feae4a281..2223dd7ed52 100644 --- a/tests/integration/test_script_delay_params.py +++ b/tests/integration/test_script_delay_params.py @@ -15,11 +15,11 @@ These bugs manifested as: from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -36,47 +36,19 @@ async def test_script_delay_with_params( This reproduces issues #12043 and #12044 where scripts would hang or become zombies due to LIFO ordering bugs in ScriptWaitAction from PR #7972. """ - test_complete = asyncio.Event() + waiter = LineWaiter() - # Patterns to match in logs - father_calling_pattern = re.compile(r"Father iteration (\d+): calling son") - son_started_pattern = re.compile(r"Son script started with iteration (\d+)") - son_delaying_pattern = re.compile(r"Son script delaying for iteration (\d+)") - son_finished_pattern = re.compile(r"Son script finished with iteration (\d+)") - father_wait_returned_pattern = re.compile( - r"Father iteration (\d+): son finished, wait returned" - ) + def iterations(pattern: str) -> set[int]: + return { + int(m.group(1)) for line in waiter.lines if (m := re.search(pattern, line)) + } - # Track which iterations completed - father_calling = set() - son_started = set() - son_delaying = set() - son_finished = set() - wait_returned = set() - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if test_complete.is_set(): - return - - if mo := father_calling_pattern.search(line): - father_calling.add(int(mo.group(1))) - elif mo := son_started_pattern.search(line): - son_started.add(int(mo.group(1))) - elif mo := son_delaying_pattern.search(line): - son_delaying.add(int(mo.group(1))) - elif mo := son_finished_pattern.search(line): - son_finished.add(int(mo.group(1))) - elif mo := father_wait_returned_pattern.search(line): - iteration = int(mo.group(1)) - wait_returned.add(iteration) - # Test completes when iteration 9 finishes - if iteration == 9: - test_complete.set() + def wait_returned() -> set[int]: + return iterations(r"Father iteration (\d+): son finished, wait returned") # Run with log monitoring async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info @@ -91,7 +63,7 @@ async def test_script_delay_with_params( # Wait for test to complete (10 iterations * ~100ms each + margin) await wait_or_fail( - test_complete, + waiter.wait_for("Father iteration 9: son finished, wait returned"), 5.0, "Test timed out. " "This likely indicates the script became a zombie (issue #12044).", @@ -100,6 +72,10 @@ async def test_script_delay_with_params( # Verify all 10 iterations completed successfully expected_iterations = set(range(10)) + father_calling = iterations(r"Father iteration (\d+): calling son") + son_started = iterations(r"Son script started with iteration (\d+)") + son_delaying = iterations(r"Son script delaying for iteration (\d+)") + son_finished = iterations(r"Son script finished with iteration (\d+)") assert father_calling == expected_iterations, "Not all iterations started" assert son_started == expected_iterations, ( "Son script not started for all iterations" @@ -107,7 +83,7 @@ async def test_script_delay_with_params( assert son_finished == expected_iterations, ( "Son script not finished for all iterations" ) - assert wait_returned == expected_iterations, ( + assert wait_returned() == expected_iterations, ( "script.wait did not return for all iterations" ) diff --git a/tests/integration/test_script_on_stop_and_choose.py b/tests/integration/test_script_on_stop_and_choose.py index 07631450df3..b69625798a9 100644 --- a/tests/integration/test_script_on_stop_and_choose.py +++ b/tests/integration/test_script_on_stop_and_choose.py @@ -7,6 +7,7 @@ import asyncio from aioesphomeapi import UserService import pytest +from .log_utils import LineWaiter from .types import APIClientConnectedFactory, RunCompiledFunction @@ -17,25 +18,13 @@ async def test_script_on_stop_and_choose( api_client_connected: APIClientConnectedFactory, ) -> None: """Test on_stop, self-stopping scripts and choose.""" - lines: list[str] = [] - waiters: list[tuple[str, asyncio.Future[None]]] = [] - - def check_output(line: str) -> None: - lines.append(line) - for text, future in waiters: - if text in line and not future.done(): - future.set_result(None) - - def expect(text: str) -> asyncio.Future[None]: - future: asyncio.Future[None] = asyncio.get_running_loop().create_future() - waiters.append((text, future)) - return future + waiter = LineWaiter() def count(text: str) -> int: - return sum(text in line for line in lines) + return sum(text in line for line in waiter.lines) async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): _, services = await client.list_entities_services() @@ -49,9 +38,8 @@ async def test_script_on_stop_and_choose( (500, "large"), (5000, "default"), ): - done = expect(f"CHOOSE done {value}") await client.execute_service(service["run_choose"], {"value": value}) - await asyncio.wait_for(done, timeout=5.0) + await waiter.wait_for(f"CHOOSE done {value}", timeout=5.0) assert count(f"CHOOSE {label} {value}") == 1 assert count("CHOOSE small") == 1 assert count("CHOOSE medium") == 1 @@ -59,44 +47,36 @@ async def test_script_on_stop_and_choose( assert count("CHOOSE default") == 1 # A bare script.stop inside a script stops that script and fires on_stop - stopped = expect("SELF on_stop") await client.execute_service(service["run_self_stop"], {}) - await asyncio.wait_for(stopped, timeout=5.0) + await waiter.wait_for("SELF on_stop", timeout=5.0) # Stopping a running script from outside fires on_stop - stopped = expect("LONG on_stop") await client.execute_service(service["run_external_stop"], {}) - await asyncio.wait_for(stopped, timeout=5.0) + await waiter.wait_for("LONG on_stop", timeout=5.0) # Stopping a script that is not running does not fire on_stop - idle_done = expect("IDLE stop done") await client.execute_service(service["run_idle_stop"], {}) - await asyncio.wait_for(idle_done, timeout=5.0) + await waiter.wait_for("IDLE stop done", timeout=5.0) # Restarting a running script stops the old run early - finished = expect("RESTART finished") await client.execute_service(service["run_restart"], {}) - await asyncio.wait_for(finished, timeout=5.0) + await waiter.wait_for("RESTART finished", timeout=5.0) # A script that finishes normally does not fire on_stop - finished = expect("NORMAL finished") await client.execute_service(service["run_normal"], {}) - await asyncio.wait_for(finished, timeout=5.0) + await waiter.wait_for("NORMAL finished", timeout=5.0) # A restart script whose on_stop executes it again ends up with one run - finished = expect("REENTER finished") await client.execute_service(service["run_reenter"], {}) - await asyncio.wait_for(finished, timeout=5.0) + await waiter.wait_for("REENTER finished", timeout=5.0) # Stopping several parallel runs fires on_stop once - done = expect("PARALLEL stop done") await client.execute_service(service["run_parallel_stop"], {}) - await asyncio.wait_for(done, timeout=5.0) + await waiter.wait_for("PARALLEL stop done", timeout=5.0) # Stopping a queued script fires on_stop once and drops the queued run - done = expect("QUEUED stop done") await client.execute_service(service["run_queued_stop"], {}) - await asyncio.wait_for(done, timeout=5.0) + await waiter.wait_for("QUEUED stop done", timeout=5.0) await asyncio.sleep(0.2) assert count("SELF not reached") == 0 diff --git a/tests/integration/test_script_wait_on_boot.py b/tests/integration/test_script_wait_on_boot.py index 41382acd99f..29d60b9cb40 100644 --- a/tests/integration/test_script_wait_on_boot.py +++ b/tests/integration/test_script_wait_on_boot.py @@ -26,6 +26,7 @@ import pytest from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -91,33 +92,26 @@ async def test_script_wait_on_boot( # Wait for on_boot sequence to complete # The boot sequence should complete automatically # Timeout is generous to allow for delays in the scripts - try: - await asyncio.wait_for(test_complete.wait(), timeout=5.0) - except TimeoutError: - # Build a detailed error message showing where the boot sequence got stuck - progress = [] - if boot_started: - progress.append("boot started") - if first_script_started: - progress.append("show_start_page started") - if first_script_completed: - progress.append("show_start_page completed") - if first_wait_returned: - progress.append("first script.wait returned") - if second_script_started: - progress.append("flip_thru_pages started") - if second_script_completed: - progress.append("flip_thru_pages completed") + def progress() -> list[str]: + steps = ( + (boot_started, "boot started"), + (first_script_started, "show_start_page started"), + (first_script_completed, "show_start_page completed"), + (first_wait_returned, "first script.wait returned"), + (second_script_started, "flip_thru_pages started"), + (second_script_completed, "flip_thru_pages completed"), + ) + return [name for done, name in steps if done] - if not first_wait_returned and first_script_completed: - pytest.fail( - f"Test timed out - script.wait hung after show_start_page completed! " - f"This is the issue #12043 bug. Progress: {', '.join(progress)}" - ) - else: - pytest.fail( - f"Test timed out. Progress: {', '.join(progress) if progress else 'none'}" - ) + await wait_or_fail( + test_complete, + 5.0, + "Boot sequence did not complete", + progress=progress, + issue_12043_script_wait_hung=lambda: ( + first_script_completed and not first_wait_returned + ), + ) # Verify the complete boot sequence executed in order assert boot_started, "on_boot did not start" diff --git a/tests/integration/test_select_stringref_trigger.py b/tests/integration/test_select_stringref_trigger.py index c3428ffa4b7..f386bfa296d 100644 --- a/tests/integration/test_select_stringref_trigger.py +++ b/tests/integration/test_select_stringref_trigger.py @@ -9,6 +9,7 @@ import pytest from .state_utils import assert_device_name, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -109,27 +110,25 @@ async def test_select_stringref_trigger( client.select_command(action_entity.key, "Action B") # Wait for all log messages confirming StringRef operations work - try: - await asyncio.wait_for( - asyncio.gather( - value_logged_future, - concatenated_future, - comparison_future, - index_logged_future, - length_future, - find_substr_future, - find_char_future, - substr_future, - set_action_future, - stoi_future, - stol_future, - stof_future, - stod_future, - ), - timeout=5.0, - ) - except TimeoutError: - results = { + await wait_or_fail( + asyncio.gather( + value_logged_future, + concatenated_future, + comparison_future, + index_logged_future, + length_future, + find_substr_future, + find_char_future, + substr_future, + set_action_future, + stoi_future, + stol_future, + stof_future, + stod_future, + ), + 5.0, + "StringRef operations failed", + received=lambda: { "value_logged": value_logged_future.done(), "concatenated": concatenated_future.done(), "comparison": comparison_future.done(), @@ -143,5 +142,5 @@ async def test_select_stringref_trigger( "stol": stol_future.done(), "stof": stof_future.done(), "stod": stod_future.done(), - } - pytest.fail(f"StringRef operations failed - received: {results}") + }, + ) diff --git a/tests/integration/test_sensor_filters_rh_correction.py b/tests/integration/test_sensor_filters_rh_correction.py new file mode 100644 index 00000000000..e871623054b --- /dev/null +++ b/tests/integration/test_sensor_filters_rh_correction.py @@ -0,0 +1,55 @@ +"""Test the sensor rh_correction filter numerically.""" + +from __future__ import annotations + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction +from .wait_utils import wait_or_fail + +# 40 % at 25 °C, measured 7.3 °C warmer, in single precision Magnus arithmetic +CORRECTED = pytest.approx(61.13, abs=0.05) +# Corrected, clamped, passed through out of band, corrected again; the NaN step is dropped +EXPECTED = [CORRECTED, 100.0, 40.0, CORRECTED] + + +@pytest.mark.asyncio +async def test_sensor_filters_rh_correction( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + collector = SensorStateCollector(sensor_names=["rh_celsius", "rh_fahrenheit"]) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + await initial_state_helper.wait_for_initial_states() + + done = collector.add_waiter( + lambda: all( + len(values) == len(EXPECTED) + for values in collector.sensor_states.values() + ) + ) + button = require_entity(entities, "test_rh_correction", ButtonInfo, exact=True) + client.button_command(button.key) + await wait_or_fail( + done, + 2.0, + "Timed out waiting for the corrected humidity values", + values=collector.sensor_states, + ) + + assert collector.sensor_states["rh_celsius"] == EXPECTED + # The same readings in °F give the same corrected humidity + assert collector.sensor_states["rh_fahrenheit"] == EXPECTED diff --git a/tests/integration/test_sensor_raw_state.py b/tests/integration/test_sensor_raw_state.py index d0d2abe4f86..0af03cce141 100644 --- a/tests/integration/test_sensor_raw_state.py +++ b/tests/integration/test_sensor_raw_state.py @@ -7,30 +7,31 @@ one fixture with a filtered sensor and one with no filters at all. from __future__ import annotations -import asyncio import re from aioesphomeapi import APIClient, EntityInfo import pytest +from .log_utils import LineWaiter from .state_utils import require_entity from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail -NO_FILTER_PATTERN = re.compile(r"NO_FILTER: state=([\d.]+) raw_state=([\d.]+)") -WITH_FILTER_PATTERN = re.compile(r"WITH_FILTER: state=([\d.]+) raw_state=([\d.]+)") +STATE_PATTERN = re.compile(r"state=([\d.]+) raw_state=([\d.]+)") async def _press_and_read( client: APIClient, entities: list[EntityInfo], button_object_id: str, - future: asyncio.Future[tuple[float, float]], + waiter: LineWaiter, label: str, ) -> tuple[float, float]: button = require_entity(entities, button_object_id) client.button_command(button.key) - return await wait_or_fail(future, 5.0, f"Timeout waiting for {label} log message") + line = await waiter.wait_for(f"{label}: state=", timeout=5.0) + match = STATE_PATTERN.search(line) + assert match is not None, f"Unexpected {label} line: {line}" + return float(match.group(1)), float(match.group(2)) @pytest.mark.asyncio @@ -40,28 +41,16 @@ async def test_sensor_raw_state( api_client_connected: APIClientConnectedFactory, ) -> None: """With filters compiled in, raw state is stored separately from state.""" - loop = asyncio.get_running_loop() - no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() - with_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() - - def check_output(line: str) -> None: - if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): - no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) - if not with_filter_future.done() and ( - match := WITH_FILTER_PATTERN.search(line) - ): - with_filter_future.set_result( - (float(match.group(1)), float(match.group(2))) - ) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() state, raw_state = await _press_and_read( - client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + client, entities, "test_no_filter_button", waiter, "NO_FILTER" ) assert state == 21.5 assert raw_state == 21.5 @@ -70,7 +59,7 @@ async def test_sensor_raw_state( client, entities, "test_with_filter_button", - with_filter_future, + waiter, "WITH_FILTER", ) assert state == 43.0 @@ -84,21 +73,16 @@ async def test_sensor_raw_state_no_filter( api_client_connected: APIClientConnectedFactory, ) -> None: """Without filters compiled in, get_raw_state() returns state.""" - loop = asyncio.get_running_loop() - no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() - - def check_output(line: str) -> None: - if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): - no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() state, raw_state = await _press_and_read( - client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + client, entities, "test_no_filter_button", waiter, "NO_FILTER" ) assert state == 21.5 assert raw_state == 21.5 diff --git a/tests/integration/test_socket_ipv4_resolve.py b/tests/integration/test_socket_ipv4_resolve.py index 5b2a26fe1a1..1f8cf043aac 100644 --- a/tests/integration/test_socket_ipv4_resolve.py +++ b/tests/integration/test_socket_ipv4_resolve.py @@ -2,10 +2,9 @@ from __future__ import annotations -import asyncio - import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -25,24 +24,29 @@ async def test_socket_ipv4_resolve( api_client_connected: APIClientConnectedFactory, ) -> None: """Exercise Ipv4Resolve literals, forget, failure, and getaddrinfo on host.""" - test_complete = asyncio.Event() - results: dict[str, bool] = {} + waiter = LineWaiter() - def on_log_line(line: str) -> None: - if "IPv4 resolve test complete" in line: - test_complete.set() - return - for check in CHECKS: - if f"{check}:" in line: - results[check] = "PASSED" in line + def passed(check: str) -> bool: + # The last report of a check wins + reports = [ + line + for line in waiter.lines + if f"{check}:" in line and "IPv4 resolve test complete" not in line + ] + return bool(reports) and "PASSED" in reports[-1] async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "socket-ipv4-resolve-test") - await wait_or_fail(test_complete, 10.0, "IPv4 resolve test timed out") + # The outer bound carries the message; the inner one only has to outlast it + await wait_or_fail( + waiter.wait_for("IPv4 resolve test complete", timeout=20.0), + 10.0, + "IPv4 resolve test timed out", + ) for check in CHECKS: - assert results.get(check), f"{check} check failed or never ran" + assert passed(check), f"{check} check failed or never ran" diff --git a/tests/integration/test_socket_wake_gate_tcp.py b/tests/integration/test_socket_wake_gate_tcp.py index 3bddd5630af..236cbc0e04e 100644 --- a/tests/integration/test_socket_wake_gate_tcp.py +++ b/tests/integration/test_socket_wake_gate_tcp.py @@ -12,14 +12,13 @@ loop_interval_ expired. With the fix, the gate opens immediately. from __future__ import annotations -import asyncio import time import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -30,25 +29,15 @@ async def test_socket_wake_gate_tcp( ) -> None: """TCP socket receive must open the component-phase gate immediately, even with loop_interval_ raised to 2000ms.""" - loop = asyncio.get_running_loop() - boot_done: asyncio.Future[None] = loop.create_future() - pong: asyncio.Future[None] = loop.create_future() - - def on_log_line(line: str) -> None: - if "BOOT_DONE" in line and not boot_done.done(): - boot_done.set_result(None) - if "PONG" in line and not pong.done(): - pong.set_result(None) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "socket-wake-gate-tcp") - await wait_or_fail( - boot_done, 15.0, "BOOT_DONE never appeared — device did not complete boot" - ) + await waiter.wait_for("BOOT_DONE", timeout=15.0) _, services = await client.list_entities_services() ping_service = require_service(services, "ping") @@ -59,7 +48,7 @@ async def test_socket_wake_gate_tcp( t_send = time.monotonic() await client.execute_service(ping_service, {}) - await wait_or_fail(pong, 5.0, "PONG never appeared — service did not execute") + await waiter.wait_for("PONG", timeout=5.0) elapsed_ms = (time.monotonic() - t_send) * 1000 # Without the fix the gate stays closed for up to loop_interval_=2000ms. diff --git a/tests/integration/test_socket_wake_gate_udp.py b/tests/integration/test_socket_wake_gate_udp.py index f6c7941d5f6..5d6d21ae92e 100644 --- a/tests/integration/test_socket_wake_gate_udp.py +++ b/tests/integration/test_socket_wake_gate_udp.py @@ -2,14 +2,13 @@ from __future__ import annotations -import asyncio import socket import time import pytest +from .log_utils import LineWaiter from .types import RunCompiledFunction -from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -26,19 +25,11 @@ async def test_socket_wake_gate_udp( listen_port = hold_sock.getsockname()[1] config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) - loop = asyncio.get_running_loop() - boot_done: asyncio.Future[None] = loop.create_future() - pong: asyncio.Future[None] = loop.create_future() - - def on_log_line(line: str) -> None: - if "BOOT_DONE" in line and not boot_done.done(): - boot_done.set_result(None) - if "PONG" in line and not pong.done(): - pong.set_result(None) + waiter = LineWaiter() try: - async with run_compiled(config, line_callback=on_log_line): - await wait_or_fail(boot_done, 15.0, "BOOT_DONE never appeared") + async with run_compiled(config, line_callback=waiter.callback): + await waiter.wait_for("BOOT_DONE", timeout=15.0) # Datagrams go to the more specific bind, so release the hold before sending hold_sock.close() @@ -48,7 +39,7 @@ async def test_socket_wake_gate_udp( with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as send_sock: send_sock.sendto(b"PING", ("127.0.0.1", listen_port)) - await wait_or_fail(pong, 5.0, "PONG never appeared") + await waiter.wait_for("PONG", timeout=5.0) elapsed_ms = (time.monotonic() - t_send) * 1000 # Without the wake the receive waits for loop_interval_; 500ms leaves CI headroom diff --git a/tests/integration/test_status_momentary.py b/tests/integration/test_status_momentary.py index 0bf3a820bed..0731215de14 100644 --- a/tests/integration/test_status_momentary.py +++ b/tests/integration/test_status_momentary.py @@ -7,6 +7,7 @@ import asyncio from aioesphomeapi import BinarySensorState, EntityState import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction @@ -18,20 +19,10 @@ async def test_status_momentary( api_client_connected: APIClientConnectedFactory, ) -> None: loop = asyncio.get_running_loop() - warning_logged = loop.create_future() - error_logged = loop.create_future() - - def on_log_line(line: str) -> None: - if ( - "set Warning flag: probe warning reason" in line - and not warning_logged.done() - ): - warning_logged.set_result(True) - if "set Error flag: probe error reason" in line and not error_logged.done(): - error_logged.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, services = await client.list_entities_services() @@ -82,6 +73,6 @@ async def test_status_momentary( # A message replaces "unspecified" in the log line await client.execute_service(svc["momentary_warning_message"], {}) - await asyncio.wait_for(warning_logged, timeout=5) + await waiter.wait_for("set Warning flag: probe warning reason", timeout=5) await client.execute_service(svc["momentary_error_message"], {}) - await asyncio.wait_for(error_logged, timeout=5) + await waiter.wait_for("set Error flag: probe error reason", timeout=5) diff --git a/tests/integration/test_template_climate_custom_modes.py b/tests/integration/test_template_climate_custom_modes.py index 6c3314936aa..48d4047a5ac 100644 --- a/tests/integration/test_template_climate_custom_modes.py +++ b/tests/integration/test_template_climate_custom_modes.py @@ -6,13 +6,12 @@ the enum-based mode/preset fields, but for the custom string variants. from __future__ import annotations -import asyncio - import aioesphomeapi from aioesphomeapi import ButtonInfo, ClimateInfo import pytest from .host_prefs import clear_host_prefs +from .log_utils import LineWaiter from .state_utils import ( InitialStateHelper, require_entity, @@ -33,14 +32,10 @@ async def test_template_climate_custom_modes( """Custom fan mode/preset: traits, on_control forwarding, and publish precedence.""" clear_host_prefs(DEVICE_NAME) - log_lines: list[str] = [] - - def on_log_line(line: str) -> None: - if "on_control " in line: - log_lines.append(line) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): @@ -87,9 +82,11 @@ async def test_template_climate_custom_modes( state = await wait_for_climate_state() assert state.custom_preset == "power_save" - await asyncio.sleep(0.2) - assert any("on_control custom_fan_mode=turbo" in line for line in log_lines) - assert any("on_control custom_preset=power_save" in line for line in log_lines) + await waiter.wait_for_each( + "on_control custom_fan_mode=turbo", + "on_control custom_preset=power_save", + timeout=5.0, + ) # The device's report is authoritative and overrides what was commanded above. client.button_command(report_button.key) diff --git a/tests/integration/test_template_climate_nonoptimistic.py b/tests/integration/test_template_climate_nonoptimistic.py index 7bc03d14e15..cc68fa2f52d 100644 --- a/tests/integration/test_template_climate_nonoptimistic.py +++ b/tests/integration/test_template_climate_nonoptimistic.py @@ -21,6 +21,7 @@ from aioesphomeapi import ( import pytest from .host_prefs import clear_host_prefs +from .log_utils import LineWaiter from .state_utils import ( InitialStateHelper, require_entity, @@ -41,15 +42,11 @@ async def test_template_climate_nonoptimistic( """Nonoptimistic: a command doesn't change state until explicitly published.""" clear_host_prefs(DEVICE_NAME) - log_lines: list[str] = [] + waiter = LineWaiter() state_updates: list[aioesphomeapi.ClimateState] = [] - def on_log_line(line: str) -> None: - if "on_control " in line: - log_lines.append(line) - async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): @@ -84,13 +81,14 @@ async def test_template_climate_nonoptimistic( preset=ClimatePreset.AWAY, ) await asyncio.sleep(0.3) - assert any( - "on_control mode=3" in line for line in log_lines - ) # CLIMATE_MODE_HEAT - assert any("on_control target_temperature=22.5" in line for line in log_lines) - assert any("on_control fan_mode=" in line for line in log_lines) - assert any("on_control swing_mode=" in line for line in log_lines) - assert any("on_control preset=" in line for line in log_lines) + await waiter.wait_for_each( + "on_control mode=3", # CLIMATE_MODE_HEAT + "on_control target_temperature=22.5", + "on_control fan_mode=", + "on_control swing_mode=", + "on_control preset=", + timeout=5.0, + ) assert not state_updates, ( "optimistic: false must not publish a state until climate.template.publish reports it" ) diff --git a/tests/integration/test_template_climate_on_control_ordering.py b/tests/integration/test_template_climate_on_control_ordering.py index 2dc61c9eb5f..ae31576cbc0 100644 --- a/tests/integration/test_template_climate_on_control_ordering.py +++ b/tests/integration/test_template_climate_on_control_ordering.py @@ -7,13 +7,12 @@ proving the firing order is on_control, then control(), then on_state. from __future__ import annotations -import asyncio - import aioesphomeapi from aioesphomeapi import ClimateInfo, ClimateMode import pytest from .host_prefs import clear_host_prefs +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, require_only_entity, wait_for_state from .types import APIClientConnectedFactory, RunCompiledFunction @@ -29,14 +28,10 @@ async def test_template_climate_on_control_ordering( """on_control sees the requested value while the entity's own state is still the old one.""" clear_host_prefs(DEVICE_NAME) - log_lines: list[str] = [] - - def on_log_line(line: str) -> None: - if "on_control " in line or "on_state " in line: - log_lines.append(line) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): @@ -60,19 +55,15 @@ async def test_template_climate_on_control_ordering( state = await wait_for_climate_state() assert state.mode == ClimateMode.HEAT - await asyncio.sleep(0.2) - # on_control saw the new requested mode (3 == CLIMATE_MODE_HEAT) while the entity's own # state was still the old one (0 == CLIMATE_MODE_OFF) -- proving it fired before control(). - assert any( - "on_control requested_mode=3 current_mode_before_apply=0" in line - for line in log_lines + await waiter.wait_for( + "on_control requested_mode=3 current_mode_before_apply=0", timeout=5.0 ) # on_state fired afterward, reporting the now-applied mode. - assert any("on_state mode=3" in line for line in log_lines) + await waiter.wait_for("on_state mode=3", timeout=5.0) - control_index = next( - i for i, line in enumerate(log_lines) if "on_control " in line - ) - state_index = next(i for i, line in enumerate(log_lines) if "on_state " in line) + lines = waiter.lines + control_index = next(i for i, line in enumerate(lines) if "on_control " in line) + state_index = next(i for i, line in enumerate(lines) if "on_state " in line) assert control_index < state_index, "on_control must fire before on_state" diff --git a/tests/integration/test_template_climate_publish_all_fields.py b/tests/integration/test_template_climate_publish_all_fields.py index 092391e0d34..b44e780f252 100644 --- a/tests/integration/test_template_climate_publish_all_fields.py +++ b/tests/integration/test_template_climate_publish_all_fields.py @@ -24,6 +24,7 @@ from aioesphomeapi import ( import pytest from .host_prefs import clear_host_prefs +from .log_utils import LineWaiter from .state_utils import ( InitialStateHelper, require_entity, @@ -45,15 +46,10 @@ async def test_template_climate_publish_all_fields( clear_host_prefs(DEVICE_NAME) state_updates: list[aioesphomeapi.ClimateState] = [] - on_control_count = 0 - - def on_log_line(line: str) -> None: - nonlocal on_control_count - if "on_control fired" in line: - on_control_count += 1 + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): @@ -91,6 +87,6 @@ async def test_template_climate_publish_all_fields( assert len(state_updates) == 1, ( f"Expected exactly one ClimateState update, got {len(state_updates)}" ) - assert on_control_count == 0, ( + assert not any("on_control fired" in line for line in waiter.lines), ( "climate.template.publish must not trigger on_control" ) diff --git a/tests/integration/test_template_climate_set_actions.py b/tests/integration/test_template_climate_set_actions.py index a7904d10f88..4305623b871 100644 --- a/tests/integration/test_template_climate_set_actions.py +++ b/tests/integration/test_template_climate_set_actions.py @@ -6,8 +6,6 @@ device's actual state back, so the actions are the only thing that reacts to a c from __future__ import annotations -import asyncio - import aioesphomeapi from aioesphomeapi import ( ButtonInfo, @@ -20,6 +18,7 @@ from aioesphomeapi import ( import pytest from .host_prefs import clear_host_prefs +from .log_utils import LineWaiter from .state_utils import ( InitialStateHelper, require_entity, @@ -40,17 +39,13 @@ async def test_template_climate_set_actions( """Every set_*_action fires with the requested value; state waits for a publish.""" clear_host_prefs(DEVICE_NAME) - log_lines: list[str] = [] - - def on_log_line(line: str) -> None: - if "_action " in line or "Unsupported" in line: - log_lines.append(line) + waiter = LineWaiter() def logged(fragment: str) -> bool: - return any(fragment in line for line in log_lines) + return any(fragment in line for line in waiter.lines) async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -82,10 +77,7 @@ async def test_template_climate_set_actions( client.climate_command(test_climate.key, preset=ClimatePreset.ECO) client.climate_command(test_climate.key, custom_preset="eco_plus") - for _ in range(50): - await asyncio.sleep(0.1) - if logged("set_custom_preset_action eco_plus"): - break + await waiter.wait_for("set_custom_preset_action eco_plus", timeout=5.0) assert logged("set_mode_action 3") # CLIMATE_MODE_HEAT assert logged("set_target_temperature_low_action 18.0") @@ -107,8 +99,4 @@ async def test_template_climate_set_actions( # A publish naming a mode outside supported_modes warns instead of publishing it. client.button_command(unsupported_button.key) - for _ in range(50): - await asyncio.sleep(0.1) - if logged("Unsupported mode"): - break - assert logged("Unsupported mode") + await waiter.wait_for("Unsupported mode", timeout=5.0) diff --git a/tests/integration/test_template_climate_two_point_temperature.py b/tests/integration/test_template_climate_two_point_temperature.py index 7c16b05f082..3ec1a0d2bd2 100644 --- a/tests/integration/test_template_climate_two_point_temperature.py +++ b/tests/integration/test_template_climate_two_point_temperature.py @@ -8,13 +8,12 @@ changed via a physical remote). from __future__ import annotations -import asyncio - import aioesphomeapi from aioesphomeapi import ButtonInfo, ClimateInfo, ClimateMode import pytest from .host_prefs import clear_host_prefs +from .log_utils import LineWaiter from .state_utils import ( InitialStateHelper, require_entity, @@ -35,14 +34,10 @@ async def test_template_climate_two_point_temperature( """Two-point target temperature + humidity: booleans, on_control, and publish precedence.""" clear_host_prefs(DEVICE_NAME) - log_lines: list[str] = [] - - def on_log_line(line: str) -> None: - if "on_control " in line: - log_lines.append(line) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): @@ -93,19 +88,16 @@ async def test_template_climate_two_point_temperature( state = await wait_for_climate_state() assert state.target_temperature_low == pytest.approx(19.0, abs=0.1) assert state.target_temperature_high == pytest.approx(25.0, abs=0.1) - await asyncio.sleep(0.2) - assert any( - "on_control target_temperature_low=19.0" in line for line in log_lines - ) - assert any( - "on_control target_temperature_high=25.0" in line for line in log_lines + await waiter.wait_for_each( + "on_control target_temperature_low=19.0", + "on_control target_temperature_high=25.0", + timeout=5.0, ) client.climate_command(test_climate.key, target_humidity=45.0) state = await wait_for_climate_state() assert state.target_humidity == pytest.approx(45.0, abs=0.1) - await asyncio.sleep(0.2) - assert any("on_control target_humidity=45.0" in line for line in log_lines) + await waiter.wait_for("on_control target_humidity=45.0", timeout=5.0) # The device's next report is authoritative and overrides whatever was optimistically # applied above -- this is the whole point of climate.template.publish: a device that owns diff --git a/tests/integration/test_template_text_save.py b/tests/integration/test_template_text_save.py index 2149f277ddf..20ae0a56305 100644 --- a/tests/integration/test_template_text_save.py +++ b/tests/integration/test_template_text_save.py @@ -17,6 +17,7 @@ import pytest from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client from .host_prefs import clear_host_prefs +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, assert_device_name, require_entity from .types import CompileFunction, ConfigWriter @@ -44,15 +45,11 @@ async def test_template_text_save( port_socket.close() # --- First run: set a value and verify no spurious warnings --- - warning_lines: list[str] = [] - - def capture_warnings(line: str) -> None: - if "too long to save" in line.lower(): - warning_lines.append(line) + waiter = LineWaiter() async with ( run_binary_and_wait_for_port( - binary_path, "127.0.0.1", port, line_callback=capture_warnings + binary_path, "127.0.0.1", port, line_callback=waiter.callback ), wait_and_connect_api_client(port=port) as client, ): @@ -98,6 +95,9 @@ async def test_template_text_save( await asyncio.sleep(0.5) # No warnings should have appeared + warning_lines = [ + line for line in waiter.lines if "too long to save" in line.lower() + ] assert warning_lines == [], ( f"Unexpected 'too long to save' warning(s): {warning_lines}" ) diff --git a/tests/integration/test_text_sensor_raw_state.py b/tests/integration/test_text_sensor_raw_state.py index 313ab2afe45..9481e54a946 100644 --- a/tests/integration/test_text_sensor_raw_state.py +++ b/tests/integration/test_text_sensor_raw_state.py @@ -9,14 +9,15 @@ This tests: from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail + +STATE_PATTERN = re.compile(r"state='([^']*)'(?: raw_state='([^']*)')?") @pytest.mark.asyncio @@ -32,103 +33,16 @@ async def test_text_sensor_raw_state( 2. get_raw_state() with filters returns original (unfiltered) value 3. StringRef-based filters: append, prepend, substitute, map, chained """ - loop = asyncio.get_running_loop() + waiter = LineWaiter() - # Futures to track log messages - no_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() - with_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() - append_future: asyncio.Future[str] = loop.create_future() - prepend_future: asyncio.Future[str] = loop.create_future() - substitute_future: asyncio.Future[str] = loop.create_future() - map_on_future: asyncio.Future[str] = loop.create_future() - map_off_future: asyncio.Future[str] = loop.create_future() - map_unknown_future: asyncio.Future[str] = loop.create_future() - chained_future: asyncio.Future[str] = loop.create_future() - to_lower_future: asyncio.Future[str] = loop.create_future() - lambda_future: asyncio.Future[str] = loop.create_future() - lambda_pass_future: asyncio.Future[str] = loop.create_future() - lambda_skip_future: asyncio.Future[str] = loop.create_future() - lambda_raw_state_future: asyncio.Future[tuple[str, str]] = loop.create_future() - - # Patterns to match log output - # NO_FILTER: state='hello world' raw_state='hello world' - no_filter_pattern = re.compile(r"NO_FILTER: state='([^']*)' raw_state='([^']*)'") - # WITH_FILTER: state='HELLO WORLD' raw_state='hello world' - with_filter_pattern = re.compile( - r"WITH_FILTER: state='([^']*)' raw_state='([^']*)'" - ) - # StringRef-based filter patterns - append_pattern = re.compile(r"APPEND: state='([^']*)'") - prepend_pattern = re.compile(r"PREPEND: state='([^']*)'") - substitute_pattern = re.compile(r"SUBSTITUTE: state='([^']*)'") - map_on_pattern = re.compile(r"MAP_ON: state='([^']*)'") - map_off_pattern = re.compile(r"MAP_OFF: state='([^']*)'") - map_unknown_pattern = re.compile(r"MAP_UNKNOWN: state='([^']*)'") - chained_pattern = re.compile(r"CHAINED: state='([^']*)'") - to_lower_pattern = re.compile(r"TO_LOWER: state='([^']*)'") - lambda_pattern = re.compile(r"LAMBDA: state='([^']*)'") - lambda_pass_pattern = re.compile(r"LAMBDA_PASS: state='([^']*)'") - lambda_skip_pattern = re.compile(r"LAMBDA_SKIP: state='([^']*)'") - lambda_raw_state_pattern = re.compile( - r"LAMBDA_RAW_STATE: state='([^']*)' raw_state='([^']*)'" - ) - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if not no_filter_future.done() and (match := no_filter_pattern.search(line)): - no_filter_future.set_result((match.group(1), match.group(2))) - - if not with_filter_future.done() and ( - match := with_filter_pattern.search(line) - ): - with_filter_future.set_result((match.group(1), match.group(2))) - - if not append_future.done() and (match := append_pattern.search(line)): - append_future.set_result(match.group(1)) - - if not prepend_future.done() and (match := prepend_pattern.search(line)): - prepend_future.set_result(match.group(1)) - - if not substitute_future.done() and (match := substitute_pattern.search(line)): - substitute_future.set_result(match.group(1)) - - if not map_on_future.done() and (match := map_on_pattern.search(line)): - map_on_future.set_result(match.group(1)) - - if not map_off_future.done() and (match := map_off_pattern.search(line)): - map_off_future.set_result(match.group(1)) - - if not map_unknown_future.done() and ( - match := map_unknown_pattern.search(line) - ): - map_unknown_future.set_result(match.group(1)) - - if not chained_future.done() and (match := chained_pattern.search(line)): - chained_future.set_result(match.group(1)) - - if not to_lower_future.done() and (match := to_lower_pattern.search(line)): - to_lower_future.set_result(match.group(1)) - - if not lambda_future.done() and (match := lambda_pattern.search(line)): - lambda_future.set_result(match.group(1)) - - if not lambda_pass_future.done() and ( - match := lambda_pass_pattern.search(line) - ): - lambda_pass_future.set_result(match.group(1)) - - if not lambda_skip_future.done() and ( - match := lambda_skip_pattern.search(line) - ): - lambda_skip_future.set_result(match.group(1)) - - if not lambda_raw_state_future.done() and ( - match := lambda_raw_state_pattern.search(line) - ): - lambda_raw_state_future.set_result((match.group(1), match.group(2))) + async def logged_state(label: str) -> tuple[str, str | None]: + line = await waiter.wait_for(f"{label}: state='", timeout=5.0) + match = STATE_PATTERN.search(line) + assert match is not None, f"Unexpected {label} line: {line}" + return match.group(1), match.group(2) async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info @@ -146,9 +60,7 @@ async def test_text_sensor_raw_state( # get_raw_state() should return the same as state client.button_command(no_filter_button.key) - state, raw_state = await wait_or_fail( - no_filter_future, 5.0, "Timeout waiting for NO_FILTER log message" - ) + state, raw_state = await logged_state("NO_FILTER") assert state == "hello world", f"Expected state='hello world', got '{state}'" assert raw_state == "hello world", ( @@ -163,9 +75,7 @@ async def test_text_sensor_raw_state( # state should be filtered (uppercase), raw_state should be original client.button_command(with_filter_button.key) - state, raw_state = await wait_or_fail( - with_filter_future, 5.0, "Timeout waiting for WITH_FILTER log message" - ) + state, raw_state = await logged_state("WITH_FILTER") assert state == "HELLO WORLD", f"Expected state='HELLO WORLD', got '{state}'" assert raw_state == "hello world", ( @@ -181,9 +91,7 @@ async def test_text_sensor_raw_state( append_button = require_entity(entities, "test_append_button") client.button_command(append_button.key) - state = await wait_or_fail( - append_future, 5.0, "Timeout waiting for APPEND log message" - ) + state, _ = await logged_state("APPEND") assert state == "test suffix", ( f"Append failed: expected 'test suffix', got '{state}'" @@ -194,9 +102,7 @@ async def test_text_sensor_raw_state( prepend_button = require_entity(entities, "test_prepend_button") client.button_command(prepend_button.key) - state = await wait_or_fail( - prepend_future, 5.0, "Timeout waiting for PREPEND log message" - ) + state, _ = await logged_state("PREPEND") assert state == "prefix test", ( f"Prepend failed: expected 'prefix test', got '{state}'" @@ -207,9 +113,7 @@ async def test_text_sensor_raw_state( substitute_button = require_entity(entities, "test_substitute_button") client.button_command(substitute_button.key) - state = await wait_or_fail( - substitute_future, 5.0, "Timeout waiting for SUBSTITUTE log message" - ) + state, _ = await logged_state("SUBSTITUTE") assert state == "bar says world", ( f"Substitute failed: expected 'bar says world', got '{state}'" @@ -219,9 +123,7 @@ async def test_text_sensor_raw_state( map_on_button = require_entity(entities, "test_map_on_button") client.button_command(map_on_button.key) - state = await wait_or_fail( - map_on_future, 5.0, "Timeout waiting for MAP_ON log message" - ) + state, _ = await logged_state("MAP_ON") assert state == "Active", f"Map ON failed: expected 'Active', got '{state}'" @@ -229,9 +131,7 @@ async def test_text_sensor_raw_state( map_off_button = require_entity(entities, "test_map_off_button") client.button_command(map_off_button.key) - state = await wait_or_fail( - map_off_future, 5.0, "Timeout waiting for MAP_OFF log message" - ) + state, _ = await logged_state("MAP_OFF") assert state == "Inactive", ( f"Map OFF failed: expected 'Inactive', got '{state}'" @@ -242,9 +142,7 @@ async def test_text_sensor_raw_state( map_unknown_button = require_entity(entities, "test_map_unknown_button") client.button_command(map_unknown_button.key) - state = await wait_or_fail( - map_unknown_future, 5.0, "Timeout waiting for MAP_UNKNOWN log message" - ) + state, _ = await logged_state("MAP_UNKNOWN") assert state == "UNKNOWN", ( f"Map passthrough failed: expected 'UNKNOWN', got '{state}'" @@ -255,9 +153,7 @@ async def test_text_sensor_raw_state( chained_button = require_entity(entities, "test_chained_button") client.button_command(chained_button.key) - state = await wait_or_fail( - chained_future, 5.0, "Timeout waiting for CHAINED log message" - ) + state, _ = await logged_state("CHAINED") assert state == "[value]", f"Chained failed: expected '[value]', got '{state}'" @@ -266,9 +162,7 @@ async def test_text_sensor_raw_state( to_lower_button = require_entity(entities, "test_to_lower_button") client.button_command(to_lower_button.key) - state = await wait_or_fail( - to_lower_future, 5.0, "Timeout waiting for TO_LOWER log message" - ) + state, _ = await logged_state("TO_LOWER") assert state == "hello world", ( f"to_lower failed: expected 'hello world', got '{state}'" @@ -279,9 +173,7 @@ async def test_text_sensor_raw_state( lambda_button = require_entity(entities, "test_lambda_button") client.button_command(lambda_button.key) - state = await wait_or_fail( - lambda_future, 5.0, "Timeout waiting for LAMBDA log message" - ) + state, _ = await logged_state("LAMBDA") assert state == "[test]", f"Lambda failed: expected '[test]', got '{state}'" @@ -290,9 +182,7 @@ async def test_text_sensor_raw_state( lambda_pass_button = require_entity(entities, "test_lambda_pass_button") client.button_command(lambda_pass_button.key) - state = await wait_or_fail( - lambda_pass_future, 5.0, "Timeout waiting for LAMBDA_PASS log message" - ) + state, _ = await logged_state("LAMBDA_PASS") assert state == "value passed", ( f"Lambda pass failed: expected 'value passed', got '{state}'" @@ -303,9 +193,7 @@ async def test_text_sensor_raw_state( lambda_skip_button = require_entity(entities, "test_lambda_skip_button") client.button_command(lambda_skip_button.key) - state = await wait_or_fail( - lambda_skip_future, 5.0, "Timeout waiting for LAMBDA_SKIP log message" - ) + state, _ = await logged_state("LAMBDA_SKIP") # When lambda returns {}, value should NOT be published # State remains from previous successful publish ("value passed") @@ -321,11 +209,7 @@ async def test_text_sensor_raw_state( ) client.button_command(lambda_raw_state_button.key) - state, raw_state = await wait_or_fail( - lambda_raw_state_future, - 5.0, - "Timeout waiting for LAMBDA_RAW_STATE log message", - ) + state, raw_state = await logged_state("LAMBDA_RAW_STATE") assert state == "original MODIFIED", ( f"Lambda raw_state test failed: expected state='original MODIFIED', " diff --git a/tests/integration/test_uart_mock_dfrobot_sen0465.py b/tests/integration/test_uart_mock_dfrobot_sen0465.py index e7d7d422991..d8e7d69d0a1 100644 --- a/tests/integration/test_uart_mock_dfrobot_sen0465.py +++ b/tests/integration/test_uart_mock_dfrobot_sen0465.py @@ -14,6 +14,7 @@ import re from aioesphomeapi import EntityState, SensorInfo import pytest +from .log_utils import LineWaiter from .state_utils import require_entity from .types import APIClientConnectedFactory, RunCompiledFunction @@ -29,19 +30,12 @@ async def test_uart_mock_dfrobot_sen0465( api_client_connected: APIClientConnectedFactory, ) -> None: """Boot sets passive mode and the alarm before the first reading.""" - loop = asyncio.get_running_loop() - alarm_set: asyncio.Future[re.Match[str]] = loop.create_future() - lines: list[str] = [] - - def line_callback(line: str) -> None: - lines.append(line) - if (match := ALARM_RE.search(line)) and not alarm_set.done(): - alarm_set.set_result(match) - + waiter = LineWaiter() + lines = waiter.lines states: dict[int, EntityState] = {} async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -62,7 +56,10 @@ async def test_uart_mock_dfrobot_sen0465( except TimeoutError: pytest.fail(f"No reading; log: {lines[-20:]}") - match = await asyncio.wait_for(alarm_set, timeout=1.0) + line = await waiter.wait_for( + "Alarm switch ", "ms after the mode change", timeout=1.0 + ) + assert (match := ALARM_RE.search(line)), line switch, threshold, alarm_type, delay_ms = (int(x) for x in match.groups()) assert (switch, threshold, alarm_type) == (1, 35, 1) assert delay_ms >= 1000, ( diff --git a/tests/integration/test_uart_mock_dfrobot_sen0609.py b/tests/integration/test_uart_mock_dfrobot_sen0609.py index e9ea8b03a03..dc2bd1a6a36 100644 --- a/tests/integration/test_uart_mock_dfrobot_sen0609.py +++ b/tests/integration/test_uart_mock_dfrobot_sen0609.py @@ -11,9 +11,9 @@ import asyncio from aioesphomeapi import BinarySensorInfo, EntityState, NumberInfo, TextSensorInfo import pytest +from .log_utils import LineWaiter from .state_utils import require_entity from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail @pytest.mark.asyncio @@ -23,13 +23,7 @@ async def test_uart_mock_dfrobot_sen0609( api_client_connected: APIClientConnectedFactory, ) -> None: """Boot reads the module's settings; a number change is written and saved.""" - loop = asyncio.get_running_loop() - saved = loop.create_future() - - def line_callback(line: str) -> None: - if "Saved range 0.60 8.00" in line and not saved.done(): - saved.set_result(True) - + waiter = LineWaiter() states: dict[int, EntityState] = {} async def wait_for(condition, what: str) -> None: @@ -41,7 +35,7 @@ async def test_uart_mock_dfrobot_sen0609( pytest.fail(f"Timeout waiting for {what}; states: {states}") async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -64,7 +58,7 @@ async def test_uart_mock_dfrobot_sen0609( assert state_of(min_range) == pytest.approx(0.6) client.number_command(max_range.key, 8.0) - await wait_or_fail(saved, 10.0, "The new range was not written and saved") + await waiter.wait_for("Saved range 0.60 8.00", timeout=10.0) await wait_for( lambda: state_of(max_range) == pytest.approx(8.0), "new maximum range" ) diff --git a/tests/integration/test_uart_mock_ld2410.py b/tests/integration/test_uart_mock_ld2410.py index f09d1d26cea..5c2d5a55ef1 100644 --- a/tests/integration/test_uart_mock_ld2410.py +++ b/tests/integration/test_uart_mock_ld2410.py @@ -18,11 +18,10 @@ test_uart_mock_ld2410_engineering (engineering mode): from __future__ import annotations -import asyncio - from aioesphomeapi import ButtonInfo import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -35,20 +34,7 @@ async def test_uart_mock_ld2410( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2410 data parsing with happy path, garbage, overflow, and recovery.""" - loop = asyncio.get_running_loop() - - # Track overflow warning in logs - overflow_seen = loop.create_future() - - # Track TX data logged by the mock for assertions - tx_log_lines: list[str] = [] - - def line_callback(line: str) -> None: - if "Max command length exceeded" in line and not overflow_seen.done(): - overflow_seen.set_result(True) - # Capture all TX log lines from uart_mock - if "uart_mock" in line and "TX " in line: - tx_log_lines.append(line) + waiter = LineWaiter() collector = SensorStateCollector( sensor_names=[ @@ -78,7 +64,7 @@ async def test_uart_mock_ld2410( ) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -116,13 +102,16 @@ async def test_uart_mock_ld2410( ) # Verify overflow warning was logged - assert overflow_seen.done(), ( + assert any("Max command length exceeded" in line for line in waiter.lines), ( "Expected 'Max command length exceeded' warning in logs" ) # Verify LD2410 sent setup commands (TX logging) # LD2410 sends 7 commands during setup: FF (config on), A0 (version), # A5 (MAC), AB (distance res), AE (light), 61 (params), FE (config off) + tx_log_lines = [ + line for line in waiter.lines if "uart_mock" in line and "TX " in line + ] assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock" tx_data = " ".join(tx_log_lines) assert "FD:FC:FB:FA" in tx_data, ( diff --git a/tests/integration/test_uart_mock_ld2410_query_ack.py b/tests/integration/test_uart_mock_ld2410_query_ack.py index 1336b898ead..fcf5342ce64 100644 --- a/tests/integration/test_uart_mock_ld2410_query_ack.py +++ b/tests/integration/test_uart_mock_ld2410_query_ack.py @@ -20,6 +20,7 @@ from aioesphomeapi import ( ) import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, build_key_to_entity_mapping, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -55,16 +56,12 @@ async def test_uart_mock_ld2410_query_ack( ) -> None: """Test a data frame, then a query ACK with the footer bytes in its values.""" loop = asyncio.get_running_loop() - ack_errors: list[str] = [] - waited_for_frame_end = loop.create_future() + waiter = LineWaiter() - def line_callback(line: str) -> None: - if "[ld2410" not in line: - return - if "Invalid " in line: - ack_errors.append(line) - if "Ack Data incomplete" in line and not waited_for_frame_end.done(): - waited_for_frame_end.set_result(True) + def ack_errors() -> list[str]: + return [ + line for line in waiter.lines if "[ld2410" in line and "Invalid " in line + ] number_states: dict[str, list[float]] = {name: [] for name in EXPECTED} sensor_states: dict[str, list[float]] = {name: [] for name in SENSOR_EXPECTED} @@ -93,7 +90,7 @@ async def test_uart_mock_ld2410_query_ack( final_received.set_result(True) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -145,7 +142,7 @@ async def test_uart_mock_ld2410_query_ack( # Each number is published once per ACK: the seed value, then the value under test for name, (seed, value) in EXPECTED.items(): assert number_states[name] == pytest.approx([seed, value]), name - assert waited_for_frame_end.done(), ( - "Expected the parser to wait past the footer bytes in the values" - ) - assert not ack_errors, f"Unexpected ACK errors: {ack_errors}" + # The parser waits past the footer bytes in the values; the log line + # comes over stdout, which can trail the API states + await waiter.wait_for("[ld2410", "Ack Data incomplete", timeout=2.0) + assert not ack_errors(), f"Unexpected ACK errors: {ack_errors()}" diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index a8bf631f509..fcd6adb66a4 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -42,6 +42,7 @@ from aioesphomeapi import ( ) import pytest +from .log_utils import LineWaiter from .state_utils import ( InitialStateHelper, SensorStateCollector, @@ -139,20 +140,7 @@ async def test_uart_mock_ld2412( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2412 data parsing with happy path, garbage, overflow, and recovery.""" - loop = asyncio.get_running_loop() - - # Track overflow warning in logs - overflow_seen = loop.create_future() - - # Track TX data logged by the mock for assertions - tx_log_lines: list[str] = [] - - def line_callback(line: str) -> None: - if "Max command length exceeded" in line and not overflow_seen.done(): - overflow_seen.set_result(True) - # Capture all TX log lines from uart_mock - if _is_tx(line): - tx_log_lines.append(line) + waiter = LineWaiter() collector = SensorStateCollector( sensor_names=[ @@ -181,7 +169,7 @@ async def test_uart_mock_ld2412( ) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -213,11 +201,12 @@ async def test_uart_mock_ld2412( ) # Verify overflow warning was logged - assert overflow_seen.done(), ( + assert any("Max command length exceeded" in line for line in waiter.lines), ( "Expected 'Max command length exceeded' warning in logs" ) # Verify LD2412 sent setup commands (TX logging) + tx_log_lines = [line for line in waiter.lines if _is_tx(line)] assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock" tx_data = " ".join(tx_log_lines) assert "FD:FC:FB:FA" in tx_data, ( @@ -337,17 +326,7 @@ async def test_uart_mock_ld2412_engineering_truncated( (e.g. frame footer bytes 0xF8=248 as gate energy). """ - loop = asyncio.get_running_loop() - - # Track the truncated frame warning - truncated_warning_seen = loop.create_future() - - def line_callback(line: str) -> None: - if ( - "Engineering mode packet too short" in line - and not truncated_warning_seen.done() - ): - truncated_warning_seen.set_result(True) + waiter = LineWaiter() collector = SensorStateCollector( sensor_names=[ @@ -377,7 +356,7 @@ async def test_uart_mock_ld2412_engineering_truncated( ) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -405,9 +384,9 @@ async def test_uart_mock_ld2412_engineering_truncated( ) # Verify the truncated frame warning was logged - assert truncated_warning_seen.done(), ( - "Expected 'Engineering mode packet too short' warning in logs" - ) + assert any( + "Engineering mode packet too short" in line for line in waiter.lines + ), "Expected 'Engineering mode packet too short' warning in logs" # Phase 3 recovery: gate_0_move=50, light=42 assert pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] @@ -516,34 +495,25 @@ async def test_uart_mock_ld2412_truncated_answer( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that a threshold answer merged with the next frame is dropped and the parser recovers.""" - loop = asyncio.get_running_loop() - - dropped = loop.create_future() - warnings: list[str] = [] - - def line_callback(line: str) -> None: - if "[W][ld2412" in line: - warnings.append(line) - if "Dropping gate threshold answer" in line and not dropped.done(): - dropped.set_result(True) + waiter = LineWaiter() # Gate bytes of the truncated answer merged_values = set(range(0x30, 0x3C)) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() numbers = _threshold_numbers(entities) states: dict[int, list[float]] = {} - waiter = StateWaiter() - gate_0_read = waiter.expect( + state_waiter = StateWaiter() + gate_0_read = state_waiter.expect( _number_state_of(numbers["gate_0_move_threshold"].key), label="gate 0 move threshold", ) - gate_5_read = waiter.expect( + gate_5_read = state_waiter.expect( _number_state_of(numbers["gate_5_move_threshold"].key), label="gate 5 move threshold", ) @@ -551,7 +521,7 @@ async def test_uart_mock_ld2412_truncated_answer( def record(state: EntityState) -> None: if isinstance(state, NumberState) and not state.missing_state: states.setdefault(state.key, []).append(state.state) - waiter.on_state(state) + state_waiter.on_state(state) initial_state_helper = await _wait_initial(client, entities, record) @@ -561,12 +531,14 @@ async def test_uart_mock_ld2412_truncated_answer( try: _, gate_0_state, gate_5_state = await asyncio.gather( - asyncio.wait_for(dropped, timeout=5.0), gate_0_read, gate_5_read + waiter.wait_for("Dropping gate threshold answer", timeout=5.0), + gate_0_read, + gate_5_read, ) except TimeoutError: pytest.fail( "Timeout waiting for the threshold answers.\n" - f" ld2412 warnings: {warnings}\n" + f" ld2412 warnings: {[line for line in waiter.lines if '[W][ld2412' in line]}\n" f" states: {states}" ) diff --git a/tests/integration/test_uart_mock_ld2420.py b/tests/integration/test_uart_mock_ld2420.py index 2e6a8907fee..d9f15ec0b20 100644 --- a/tests/integration/test_uart_mock_ld2420.py +++ b/tests/integration/test_uart_mock_ld2420.py @@ -19,11 +19,10 @@ test_uart_mock_ld2420_simple (simple mode): from __future__ import annotations -import asyncio - from aioesphomeapi import ButtonInfo import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -36,27 +35,7 @@ async def test_uart_mock_ld2420( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2420 energy mode: happy path, truncated frame, overflow, and recovery.""" - loop = asyncio.get_running_loop() - - # Track overflow warning in logs - overflow_seen = loop.create_future() - - # Track "Energy frame too short" warning (PR #14458 bug #3 fix) - # This message ONLY exists after the fix. Pre-fix, handle_energy_mode_ - # silently reads past the buffer without any warning. - truncated_frame_warning_seen = loop.create_future() - - # Track TX data logged by the mock for assertions - tx_log_lines: list[str] = [] - - def line_callback(line: str) -> None: - if "Max command length exceeded" in line and not overflow_seen.done(): - overflow_seen.set_result(True) - if "Energy frame too short" in line and not truncated_frame_warning_seen.done(): - truncated_frame_warning_seen.set_result(True) - # Capture all TX log lines from uart_mock - if "uart_mock" in line and "TX " in line: - tx_log_lines.append(line) + waiter = LineWaiter() collector = SensorStateCollector( sensor_names=["moving_distance"], @@ -69,7 +48,7 @@ async def test_uart_mock_ld2420( ) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -104,20 +83,23 @@ async def test_uart_mock_ld2420( ) # Verify overflow warning was logged - assert overflow_seen.done(), ( + assert any("Max command length exceeded" in line for line in waiter.lines), ( "Expected 'Max command length exceeded' warning in logs" ) # Verify truncated frame warning was logged (PR #14458 bug #3) # This assertion FAILS before PR #14458 because the length check # and warning message did not exist. - assert truncated_frame_warning_seen.done(), ( + assert any("Energy frame too short" in line for line in waiter.lines), ( "Expected 'Energy frame too short' warning in logs. " "This indicates PR #14458 fix for handle_energy_mode_ length " "validation is missing." ) # Verify LD2420 sent setup commands (TX logging) + tx_log_lines = [ + line for line in waiter.lines if "uart_mock" in line and "TX " in line + ] assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock" tx_data = " ".join(tx_log_lines) # Verify command frame header appears (FD:FC:FB:FA) @@ -147,14 +129,7 @@ async def test_uart_mock_ld2420_simple( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2420 simple mode: happy path, overflow, and 16-digit bug trigger.""" - loop = asyncio.get_running_loop() - - # Track overflow warning in logs - overflow_seen = loop.create_future() - - def line_callback(line: str) -> None: - if "Max command length exceeded" in line and not overflow_seen.done(): - overflow_seen.set_result(True) + waiter = LineWaiter() collector = SensorStateCollector( sensor_names=["moving_distance"], @@ -170,7 +145,7 @@ async def test_uart_mock_ld2420_simple( ) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -203,7 +178,7 @@ async def test_uart_mock_ld2420_simple( ) # Verify overflow warning was logged - assert overflow_seen.done(), ( + assert any("Max command length exceeded" in line for line in waiter.lines), ( "Expected 'Max command length exceeded' warning in logs" ) diff --git a/tests/integration/test_uart_mock_ld2420_config_exit.py b/tests/integration/test_uart_mock_ld2420_config_exit.py index 1f4c0a2fd6c..f3adef7bf5a 100644 --- a/tests/integration/test_uart_mock_ld2420_config_exit.py +++ b/tests/integration/test_uart_mock_ld2420_config_exit.py @@ -7,6 +7,7 @@ import re from aioesphomeapi import ButtonInfo import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -32,17 +33,16 @@ async def test_uart_mock_ld2420_config_exit( expected_sends: int, ) -> None: """Test both energy reports arrive after the last config exit, and a failed exit is still sent 3 times.""" - exit_sends: list[int] = [] + waiter = LineWaiter() - def line_callback(line: str) -> None: - if match := EXIT_SENT.search(line): - exit_sends.append(int(match[1])) + def exit_sends() -> list[int]: + return [int(m[1]) for line in waiter.lines if (m := EXIT_SENT.search(line))] collector = SensorStateCollector(sensor_names=["moving_distance"]) yaml_config = yaml_config.replace("EXIT_REPLY_MODE", str(mode)) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -72,4 +72,4 @@ async def test_uart_mock_ld2420_config_exit( [100.0, 101.0] ) # A failed exit is resent and then given up; it is never treated as answered - assert exit_sends == list(range(1, expected_sends + 1)) + assert exit_sends() == list(range(1, expected_sends + 1)) diff --git a/tests/integration/test_uart_mock_ld2450.py b/tests/integration/test_uart_mock_ld2450.py index 13264d978a1..2dad47a4cc3 100644 --- a/tests/integration/test_uart_mock_ld2450.py +++ b/tests/integration/test_uart_mock_ld2450.py @@ -17,11 +17,10 @@ test_uart_mock_ld2450: from __future__ import annotations -import asyncio - from aioesphomeapi import ButtonInfo import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -34,20 +33,7 @@ async def test_uart_mock_ld2450( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2450 data parsing with happy path, garbage, overflow, and recovery.""" - loop = asyncio.get_running_loop() - - # Track overflow warning in logs - overflow_seen = loop.create_future() - - # Track TX data logged by the mock for assertions - tx_log_lines: list[str] = [] - - def line_callback(line: str) -> None: - if "Max command length exceeded" in line and not overflow_seen.done(): - overflow_seen.set_result(True) - # Capture all TX log lines from uart_mock - if "uart_mock" in line and "TX " in line: - tx_log_lines.append(line) + waiter = LineWaiter() collector = SensorStateCollector( sensor_names=[ @@ -91,7 +77,7 @@ async def test_uart_mock_ld2450( ) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -156,11 +142,14 @@ async def test_uart_mock_ld2450( ) # Verify overflow warning was logged - assert overflow_seen.done(), ( + assert any("Max command length exceeded" in line for line in waiter.lines), ( "Expected 'Max command length exceeded' warning in logs" ) # Verify LD2450 sent setup commands (TX logging) + tx_log_lines = [ + line for line in waiter.lines if "uart_mock" in line and "TX " in line + ] assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock" tx_data = " ".join(tx_log_lines) # Verify command frame header appears (FD:FC:FB:FA) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py index 2a289179d65..acabfd53cc5 100644 --- a/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py @@ -16,6 +16,7 @@ import pytest from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client from .host_prefs import clear_host_prefs, read_host_prefs, write_host_prefs +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, require_entity from .types import CompileFunction, ConfigWriter @@ -98,13 +99,10 @@ async def test_uart_mock_ld2450_polygon_zone_restore( prefs[keys[0]] = bytes(data) write_host_prefs(DEVICE_NAME, prefs) - warnings: list[str] = [] - - def capture_warning(line: str) -> None: - if "saved polygon is invalid" in line: - warnings.append(line) - - assert await initial_polygon(capture_warning) == "" - assert warnings, "expected a warning about the invalid saved polygon" + waiter = LineWaiter() + assert await initial_polygon(waiter.callback) == "" + assert any("saved polygon is invalid" in line for line in waiter.lines), ( + "expected a warning about the invalid saved polygon" + ) clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_uart_mock_ld2460.py b/tests/integration/test_uart_mock_ld2460.py index 7f2f4a28efa..eeeff99e3bf 100644 --- a/tests/integration/test_uart_mock_ld2460.py +++ b/tests/integration/test_uart_mock_ld2460.py @@ -14,11 +14,10 @@ test_uart_mock_ld2460: from __future__ import annotations -import asyncio - from aioesphomeapi import ButtonInfo import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -31,15 +30,7 @@ async def test_uart_mock_ld2460( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2460 frame parsing with the datasheet frames, garbage and overflow.""" - loop = asyncio.get_running_loop() - overflow_seen = loop.create_future() - tx_log_lines: list[str] = [] - - def line_callback(line: str) -> None: - if "Max frame length exceeded" in line and not overflow_seen.done(): - overflow_seen.set_result(True) - if "uart_mock" in line and "TX " in line: - tx_log_lines.append(line) + waiter = LineWaiter() collector = SensorStateCollector( sensor_names=[ @@ -75,7 +66,7 @@ async def test_uart_mock_ld2460( ) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -121,7 +112,9 @@ async def test_uart_mock_ld2460( "Timeout waiting for the recovery frame", sensor_states=collector.sensor_states, ) - assert overflow_seen.done(), "Expected 'Max frame length exceeded' in the log" + assert any("Max frame length exceeded" in line for line in waiter.lines), ( + "Expected 'Max frame length exceeded' in the log" + ) assert ( pytest.approx(3.606, abs=0.01) in collector.sensor_states["target_1_distance"] @@ -140,7 +133,9 @@ async def test_uart_mock_ld2460( ) # The hub sent the installation-mode query from the datasheet (table 11) - tx_data = " ".join(tx_log_lines) + tx_data = " ".join( + line for line in waiter.lines if "uart_mock" in line and "TX " in line + ) assert "FD:FC:FB:FA:0A:0C:00:01:04:03:02:01" in tx_data, ( f"Expected the installation-mode query in the TX log, got: {tx_data}" ) diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py index 2428e71d9c2..3c1c95bc448 100644 --- a/tests/integration/test_uart_mock_pylontech.py +++ b/tests/integration/test_uart_mock_pylontech.py @@ -16,6 +16,7 @@ import asyncio from aioesphomeapi import ButtonInfo import pytest +from .log_utils import LineWaiter from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -66,14 +67,7 @@ async def test_uart_mock_pylontech( api_client_connected: APIClientConnectedFactory, ) -> None: """Test pylontech parsing with happy path, garbage, overflow and recovery.""" - overflow_lines: list[str] = [] - rejected_lines: list[str] = [] - - def line_callback(line: str) -> None: - if "Line over 199 chars dropped" in line: - overflow_lines.append(line) - if "malformed lines" in line: - rejected_lines.append(line) + waiter = LineWaiter() collector = SensorStateCollector( sensor_names=SENSORS, text_sensor_names=TEXT_SENSORS @@ -88,7 +82,7 @@ async def test_uart_mock_pylontech( ) async with ( - run_compiled(yaml_config, line_callback=line_callback), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() @@ -119,6 +113,11 @@ async def test_uart_mock_pylontech( assert collector.text_sensor_states[name] == values, name # Phase 4 and the phase 5 header both overflow + overflow_lines = [ + line for line in waiter.lines if "Line over 199 chars dropped" in line + ] assert len(overflow_lines) == 2, overflow_lines # The phase 3 cut line is counted and reported once the UART is idle - assert rejected_lines, "no rejected-line summary logged" + assert any("malformed lines" in line for line in waiter.lines), ( + "no rejected-line summary logged" + ) diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py index ae6062796cb..37b89a1fd8d 100644 --- a/tests/integration/test_udp.py +++ b/tests/integration/test_udp.py @@ -11,9 +11,9 @@ import socket import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail @dataclass @@ -111,13 +111,7 @@ async def test_udp_send_receive( api_client_connected: APIClientConnectedFactory, ) -> None: """Test UDP component can send and receive messages.""" - log_lines: list[str] = [] - receive_event = asyncio.Event() - - def on_log_line(line: str) -> None: - log_lines.append(line) - if "Received UDP:" in line: - receive_event.set() + waiter = LineWaiter() async with udp_listener() as (broadcast_port, receiver): listen_port = _get_free_udp_port() @@ -125,7 +119,7 @@ async def test_udp_send_receive( config = config.replace("UDP_BROADCAST_PORT_PLACEHOLDER", str(broadcast_port)) async with ( - run_compiled(config, line_callback=on_log_line), + run_compiled(config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device is running @@ -165,7 +159,7 @@ async def test_udp_send_receive( # Verify dump_config logged all configured addresses # This tests that FixedVector stores addresses correctly - log_text = "\n".join(log_lines) + log_text = "\n".join(waiter.lines) assert "Address: 127.0.0.1" in log_text, ( f"Address 127.0.0.1 not found in dump_config. Log: {log_text[-2000:]}" ) @@ -181,9 +175,4 @@ async def test_udp_send_receive( finally: send_sock.close() - await wait_or_fail( - receive_event, - 5.0, - "on_receive did not fire. Expected 'Received UDP:' in logs", - log_lines=log_lines, - ) + await waiter.wait_for("Received UDP:", timeout=5.0) diff --git a/tests/integration/test_udp_multicast.py b/tests/integration/test_udp_multicast.py index 2834a2d3560..ff816653281 100644 --- a/tests/integration/test_udp_multicast.py +++ b/tests/integration/test_udp_multicast.py @@ -7,9 +7,9 @@ import socket import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail MULTICAST_GROUP = "239.1.2.3" @@ -30,36 +30,25 @@ async def test_udp_multicast_join( api_client_connected: APIClientConnectedFactory, ) -> None: """The group is joined from the poll after setup, once, and then receives.""" - log_lines: list[str] = [] - joined_event = asyncio.Event() - receive_event = asyncio.Event() - - def on_log_line(line: str) -> None: - log_lines.append(line) - if f"Joined multicast {MULTICAST_GROUP}" in line: - joined_event.set() - if "Received UDP:" in line: - receive_event.set() + waiter = LineWaiter() listen_port = _get_free_udp_port() config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) async with ( - run_compiled(config, line_callback=on_log_line), + run_compiled(config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "udp-multicast-test") - await wait_or_fail( - joined_event, 5.0, "Multicast group was not joined", log_lines=log_lines - ) + await waiter.wait_for(f"Joined multicast {MULTICAST_GROUP}", timeout=5.0) # The poll cancels itself after the join, so a second poll period must not join again await asyncio.sleep(2.5) - join_lines = [line for line in log_lines if "Joined multicast" in line] + join_lines = [line for line in waiter.lines if "Joined multicast" in line] assert len(join_lines) == 1, f"Expected one join, got: {join_lines}" - assert not any("IP_ADD_MEMBERSHIP" in line for line in log_lines), ( - f"Join reported a failure. Log: {log_lines[-20:]}" + assert not any("IP_ADD_MEMBERSHIP" in line for line in waiter.lines), ( + f"Join reported a failure. Log: {waiter.lines[-20:]}" ) send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -68,9 +57,4 @@ async def test_udp_multicast_join( finally: send_sock.close() - await wait_or_fail( - receive_event, - 5.0, - "Multicast datagram was not received", - log_lines=log_lines, - ) + await waiter.wait_for("Received UDP:", timeout=5.0) diff --git a/tests/integration/test_use_address_runtime.py b/tests/integration/test_use_address_runtime.py index e86a966c295..04c65f13a54 100644 --- a/tests/integration/test_use_address_runtime.py +++ b/tests/integration/test_use_address_runtime.py @@ -8,13 +8,11 @@ suffix, so it never matched the actual mDNS hostname. from __future__ import annotations -import asyncio - import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction -from .wait_utils import wait_or_fail # Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" MAC_SUFFIX = "abf679" @@ -27,21 +25,15 @@ async def test_use_address_runtime( api_client_connected: APIClientConnectedFactory, ) -> None: """The API dump_config logs ".local" built from the device name.""" - address_seen = asyncio.Event() - - def check_output(line: str) -> None: - if "Address: use-address-runtime.local:" in line: - address_seen.set() + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "use-address-runtime") - await wait_or_fail( - address_seen, 10.0, "Did not log 'Address: use-address-runtime.local:'" - ) + await waiter.wait_for("Address: use-address-runtime.local:", timeout=10.0) @pytest.mark.asyncio @@ -51,17 +43,14 @@ async def test_use_address_runtime_mac_suffix( api_client_connected: APIClientConnectedFactory, ) -> None: """With name_add_mac_suffix the logged address includes the MAC suffix.""" - address_seen = asyncio.Event() - expected = f"Address: use-address-mac-{MAC_SUFFIX}.local:" - - def check_output(line: str) -> None: - if expected in line: - address_seen.set() + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, f"use-address-mac-{MAC_SUFFIX}") - await wait_or_fail(address_seen, 10.0, f"Did not log '{expected}'") + await waiter.wait_for( + f"Address: use-address-mac-{MAC_SUFFIX}.local:", timeout=10.0 + ) diff --git a/tests/integration/test_vnc_client.py b/tests/integration/test_vnc_client.py new file mode 100644 index 00000000000..c8ea8c0d395 --- /dev/null +++ b/tests/integration/test_vnc_client.py @@ -0,0 +1,466 @@ +"""Integration test for the vnc component against a fake RFB server. + +One compiled device runs every step in order against one server: the first frame, request +pacing, a reconnect after the server closes the link, a stream cut into tiny pieces, and an +unknown message type that makes the client drop the link. + +The client asks for the next frame only once the previous one is fully drawn, so snapshots are +taken after that request arrives. The snapshot display draws as soon as pixels arrive, so the +picture does not depend on when the client refreshes the display. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import contextlib +from dataclasses import asdict +from itertools import pairwise +from pathlib import Path +import random +import re +import struct + +from aioesphomeapi import APIClient, UserService +import pytest + +from .bmp_utils import Bmp, wait_for_bmp +from .rfb_fake_server import ( + HOSTILE_CHUNK, + RGB, + RGB565_BE, + RGB565_LE, + FakeRfbServer, + Message, + PixelFormat, + Rect, +) +from .types import APIClientConnectedFactory, RunCompiledFunction + +DISPLAY_WIDTH = 64 +DISPLAY_HEIGHT = 48 +SERVER_WIDTH = 80 +SERVER_HEIGHT = 60 + +FIXTURE_PORT = "port: 15900" +CONNECTED = "VNC_TEST_CONNECTED" +DISCONNECTED = "VNC_TEST_DISCONNECTED" +FRAME_LOG = re.compile(r"Update: (\d+) bytes in \d+ ?ms") +LONG_OPERATION = "vnc took a long time" + +# Well under the fixture's 300 ms update_delay: the next request must not wait for the refresh. +QUICK_REQUEST_S = 0.25 +# Requests are at least 50 ms apart; leave room for jitter in when they arrive. +REQUEST_SPACING_S = 0.035 +# The fixture's reconnect_interval. +RECONNECT_S = 1.0 +# Long enough for a frame that drew something to be logged. +LOG_SETTLE_S = 0.5 + +# Raw rects that cross the right and bottom edges of the display. +FULL_FRAME: list[Rect] = [ + (0, 0, SERVER_WIDTH, 13, "raw"), + (0, 13, 37, SERVER_HEIGHT - 13, "raw"), + (37, 13, SERVER_WIDTH - 37, SERVER_HEIGHT - 13, "raw"), +] + +LogMatch = str | re.Pattern[str] | Callable[[str], bool] + + +class LogWatch: + """Device log lines with the time each one arrived.""" + + def __init__(self) -> None: + self.lines: list[tuple[float, str]] = [] + self._changed = asyncio.Event() + + def callback(self, line: str) -> None: + self.lines.append((asyncio.get_running_loop().time(), line)) + self._changed.set() + self._changed = asyncio.Event() + + def times(self, match: LogMatch) -> list[float]: + """When each matching line came.""" + if isinstance(match, str): + text = match + + def test(line: str) -> bool: + return text in line + + elif isinstance(match, re.Pattern): + pattern = match + + def test(line: str) -> bool: + return pattern.search(line) is not None + + else: + test = match + return [when for when, line in self.lines if test(line)] + + def count(self, match: LogMatch) -> int: + return len(self.times(match)) + + def frame_sizes(self) -> list[int]: + """The byte counts of the 'Update:' lines, in order.""" + return [ + int(found[1]) for _, line in self.lines if (found := FRAME_LOG.search(line)) + ] + + async def wait_count( + self, match: LogMatch, count: int, timeout: float = 10.0 + ) -> float: + """Wait until ``match`` was logged ``count`` times and return when the last one came.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while len(times := self.times(match)) < count: + if (remaining := deadline - loop.time()) <= 0: + raise AssertionError( + f"{match!r} logged {len(times)} times within {timeout}s, " + f"expected {count}" + ) + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(self._changed.wait(), remaining) + return times[count - 1] + + +def paint(server: FakeRfbServer, seed: int) -> None: + """Draw solid bands, boxes and noise. + + Noise also runs along the right and bottom edges of the display, so clipping errors show. + """ + rng = random.Random(seed) + + def colour() -> RGB: + return rng.randrange(256), rng.randrange(256), rng.randrange(256) + + width, height = server.width, server.height + for x in range(0, width, 20): + server.fill_rect(x, 0, min(20, width - x), height, colour()) + accent = colour() + for _ in range(10): + server.fill_rect( + rng.randrange(width - 8), + rng.randrange(height - 8), + rng.randint(1, 8), + rng.randint(1, 8), + accent, + ) + for _ in range(10): + server.fill_rect( + rng.randrange(width - 4), + rng.randrange(height - 4), + rng.randint(1, 4), + rng.randint(1, 4), + colour(), + ) + noise = [ + (x, y) + for x in range(DISPLAY_WIDTH - 6, min(width, DISPLAY_WIDTH + 6)) + for y in range(height) + ] + noise += [ + (x, y) + for y in range(DISPLAY_HEIGHT - 5, min(height, DISPLAY_HEIGHT + 5)) + for x in range(width) + ] + noise += [(x, y) for x in range(8, 24) for y in range(16, 32)] + for x, y in noise: + server.set_pixel(x, y, colour()) + + +def shown_colour(fmt: PixelFormat, rgb: RGB) -> RGB: + """The colour a snapshot holds for a server pixel, after the trip through RGB565.""" + value = fmt.value_of(rgb) + return ( + ((value >> fmt.red_shift) & fmt.red_max) * 255 // fmt.red_max, + ((value >> fmt.green_shift) & fmt.green_max) * 255 // fmt.green_max, + ((value >> fmt.blue_shift) & fmt.blue_max) * 255 // fmt.blue_max, + ) + + +def assert_shows(bmp: Bmp, server: FakeRfbServer) -> None: + """Assert the snapshot is the top-left of the server screen, pixel for pixel.""" + assert (bmp.width, bmp.height, bmp.bits) == (DISPLAY_WIDTH, DISPLAY_HEIGHT, 24) + mismatches = [] + for y in range(DISPLAY_HEIGHT): + row = (DISPLAY_HEIGHT - 1 - y) * DISPLAY_WIDTH * 3 + for x in range(DISPLAY_WIDTH): + b, g, r = bmp.pixels[row + x * 3 : row + x * 3 + 3] + expected = shown_colour(RGB565_LE, server.get_pixel(x, y)) + if (r, g, b) != expected: + mismatches.append(((x, y), (r, g, b), expected)) + assert not mismatches, ( + f"{len(mismatches)} pixels differ; (x, y), shown, expected: {mismatches[:10]}" + ) + + +def full_request(incremental: bool) -> Message: + return { + "type": "fbur", + "incremental": incremental, + "x": 0, + "y": 0, + "w": DISPLAY_WIDTH, + "h": DISPLAY_HEIGHT, + } + + +def cut_text(text: bytes) -> bytes: + return struct.pack(">B3xI", 3, len(text)) + text + + +class Device: + """The API actions of the fixture.""" + + def __init__( + self, client: APIClient, services: list[UserService], snapshot_dir: Path + ) -> None: + self.client = client + self.actions = {service.name: service for service in services} + self.snapshot_dir = snapshot_dir + + async def run(self, action: str, **data: int | str) -> None: + await self.client.execute_service(self.actions[action], data) + + async def snapshot(self, name: str) -> Bmp: + await self.run("take_snapshot", filename=name) + return await wait_for_bmp(self.snapshot_dir / f"{name}.bmp") + + +async def next_request( + server: FakeRfbServer, timeout: float = 5.0 +) -> tuple[Message, float]: + """Wait for the next FramebufferUpdateRequest and return it and when it came.""" + request = await server.wait_for("fbur", timeout=timeout) + return request, asyncio.get_running_loop().time() + + +async def exchange( + server: FakeRfbServer, + data: bytes, + *, + log: LogWatch | None = None, + frame: bytes | None = None, + chunk: int | None = None, + timeout: float = 5.0, +) -> tuple[float, float]: + """Send bytes that end a frame and wait for the incremental request that follows. + + With ``log``, also wait for the frame's 'Update:' line and check its byte count; ``frame`` + is the FramebufferUpdate inside ``data`` when other messages surround it. + + Returns how long after the write the request came, and when it came. + """ + logged = log.count(FRAME_LOG) if log is not None else 0 + await server.send_raw(data, chunk) + sent = asyncio.get_running_loop().time() + request, requested = await next_request(server, timeout) + assert request == full_request(incremental=True) + if log is not None: + await expect_logged(log, data if frame is None else frame, logged) + return requested - sent, requested + + +async def expect_logged(log: LogWatch, frame: bytes, before: int) -> None: + """Wait for the 'Update:' line after the first ``before`` and check its byte count.""" + await log.wait_count(FRAME_LOG, before + 1) + sizes = log.frame_sizes() + assert len(sizes) == before + 1, f"expected {before + 1} logged frames: {sizes}" + # The four byte message header may or may not be counted. + assert sizes[-1] in (len(frame) - 4, len(frame)), ( + f"logged {sizes[-1]} bytes for a {len(frame)} byte update" + ) + + +@pytest.mark.asyncio +async def test_vnc_client( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The client renders, paces and recovers from drops and bad streams.""" + snapshot_dir = tmp_path / "snapshots" + monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir)) + loop = asyncio.get_running_loop() + log = LogWatch() + server = FakeRfbServer(SERVER_WIDTH, SERVER_HEIGHT, name="vnc client test") + paint(server, seed=1) + + async with server: + assert FIXTURE_PORT in yaml_config + yaml_config = yaml_config.replace(FIXTURE_PORT, f"port: {server.port}") + async with ( + run_compiled(yaml_config, line_callback=log.callback), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + device = Device(client, services, snapshot_dir) + + # Render: the session setup is exact and the first frame is pixel-exact. + pixel_format = await server.wait_for("set_pixel_format", timeout=15.0) + # The snapshot display declares no byte order, so the client asks for big-endian pixels + assert pixel_format == {"type": "set_pixel_format", **asdict(RGB565_BE)} + encodings = await server.wait_for("set_encodings") + assert encodings["encodings"] == [0, -239] + request, _ = await next_request(server) + assert request == full_request(incremental=False) + assert [m["type"] for m in server.messages[:3]] == [ + "set_pixel_format", + "set_encodings", + "fbur", + ] + assert server.client_version == b"RFB 003.008\n" + assert server.shared_flag == 1 + await log.wait_count(CONNECTED, 1) + assert log.count("Server 'vnc client test', 80x60, RFB 3.8") == 1 + assert log.count("showing the top-left 64x48") == 1 + + elapsed, _ = await exchange( + server, server.build_update(FULL_FRAME), log=log + ) + assert elapsed < QUICK_REQUEST_S, f"next request after {elapsed:.3f}s" + assert_shows(await device.snapshot("render"), server) + + # Pacing: one request stays parked while nothing changes. + await asyncio.sleep(1.0) + assert not [m for m in server.pending() if m["type"] == "fbur"] + + # The next request goes out as soon as a frame is drawn, before the refresh. + server.fill_rect(30, 10, 10, 10, (250, 10, 10)) + server.fill_rect(58, 40, 22, 20, (10, 250, 10)) + frame = server.build_update( + [(30, 10, 10, 10, "raw"), (58, 40, 22, 20, "raw")] + ) + elapsed, _ = await exchange(server, frame, log=log) + assert elapsed < QUICK_REQUEST_S, f"next request after {elapsed:.3f}s" + assert_shows(await device.snapshot("pacing"), server) + + # A frame that only changes what the display cannot show draws nothing. + logged = log.count(FRAME_LOG) + server.fill_rect( + DISPLAY_WIDTH, 0, SERVER_WIDTH - DISPLAY_WIDTH, SERVER_HEIGHT, (1, 2, 3) + ) + server.fill_rect( + 0, + DISPLAY_HEIGHT, + SERVER_WIDTH, + SERVER_HEIGHT - DISPLAY_HEIGHT, + (200, 100, 50), + ) + hidden = server.build_update( + [ + ( + DISPLAY_WIDTH, + 0, + SERVER_WIDTH - DISPLAY_WIDTH, + SERVER_HEIGHT, + "raw", + ), + ( + 0, + DISPLAY_HEIGHT, + SERVER_WIDTH, + SERVER_HEIGHT - DISPLAY_HEIGHT, + "raw", + ), + ] + ) + elapsed, requested = await exchange(server, hidden) + assert elapsed < QUICK_REQUEST_S, f"next request after {elapsed:.3f}s" + + # Empty and cursor-only updates draw nothing either, and requests stay 50 ms + # apart. + requested_at = [requested] + for update in ( + server.build_update([]), + *( + server.build_update([(2 + i, 3, 10, 16, "rich-cursor")]) + for i in range(3) + ), + ): + elapsed, requested = await exchange(server, update) + assert elapsed < QUICK_REQUEST_S, f"next request after {elapsed:.3f}s" + requested_at.append(requested) + gaps = [later - earlier for earlier, later in pairwise(requested_at)] + assert min(gaps) >= REQUEST_SPACING_S, f"requests {gaps} apart" + await asyncio.sleep(LOG_SETTLE_S) + assert log.count(FRAME_LOG) == logged, ( + "a frame that drew nothing was logged" + ) + assert_shows(await device.snapshot("hidden"), server) + + # Reconnect: the server closes the link; the client comes back after the backoff + # and sets the session up again. + server.discard_pending() + closed = loop.time() + await server.close_client() + await log.wait_count(DISCONNECTED, 1) + await server.wait_until( + lambda: server.connections == 2, timeout=10.0, what="second connection" + ) + reconnected = loop.time() + assert reconnected - closed >= RECONNECT_S - 0.05, ( + f"reconnected after {reconnected - closed:.3f}s" + ) + await log.wait_count(CONNECTED, 2) + assert await server.wait_for("set_pixel_format") == pixel_format + assert (await server.wait_for("set_encodings"))["encodings"] == [0, -239] + request, _ = await next_request(server) + assert request == full_request(incremental=False) + assert server.sessions == 2 + + # Hostile stream: 1-7 byte pieces, a long cut text, bells, a colour map and + # cursor rects around a frame. + paint(server, seed=2) + frame = server.build_update( + [ + (3, 4, 12, 18, "rich-cursor"), + (0, 0, DISPLAY_WIDTH, 20, "raw"), + (DISPLAY_WIDTH, 0, SERVER_WIDTH - DISPLAY_WIDTH, 60, "raw"), + (0, 0, 0, 0, "rich-cursor"), + (0, 20, 30, SERVER_HEIGHT - 20, "raw"), + (30, 20, SERVER_WIDTH - 30, SERVER_HEIGHT - 20, "raw"), + (1, 1, 7, 9, "rich-cursor"), + ] + ) + colour_map = struct.pack(">BxHH", 1, 0, 2) + bytes(12) + long_cut = bytes( + random.Random(3).randrange(32, 127) for _ in range(200_000) + ) + payload = ( + cut_text(long_cut) + + b"\x02" + + colour_map + + frame + + b"\x02" + + cut_text(b"") + ) + await exchange( + server, payload, log=log, frame=frame, chunk=HOSTILE_CHUNK, timeout=30.0 + ) + assert_shows(await device.snapshot("hostile"), server) + + # An unknown message type drops the link. This session was too short to count as + # stable, so the backoff has doubled. + server.discard_pending() + sent = loop.time() + await server.send_raw(b"\x09") + await log.wait_count(DISCONNECTED, 2) + assert log.count("Unknown message type 9") == 1 + await server.wait_until( + lambda: server.connections == 3, timeout=15.0, what="third connection" + ) + assert loop.time() - sent >= 2 * RECONNECT_S - 0.1, ( + f"reconnected after {loop.time() - sent:.3f}s" + ) + await log.wait_count(CONNECTED, 3) + request, _ = await next_request(server) + assert request == full_request(incremental=False) + paint(server, seed=6) + await exchange(server, server.build_update(FULL_FRAME), log=log) + assert_shows(await device.snapshot("third"), server) + + assert server.protocol_errors == [] + assert log.count(LONG_OPERATION) == 0 diff --git a/tests/integration/test_wait_until_mid_loop_timing.py b/tests/integration/test_wait_until_mid_loop_timing.py index f155bd8d1fb..4e147185dd8 100644 --- a/tests/integration/test_wait_until_mid_loop_timing.py +++ b/tests/integration/test_wait_until_mid_loop_timing.py @@ -11,14 +11,16 @@ to underflow and timeout immediately instead of waiting the specified duration. from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import require_service from .types import APIClientConnectedFactory, RunCompiledFunction +DURATION = re.compile(r"wait_until completed after (\d+) ms") + @pytest.mark.asyncio async def test_wait_until_mid_loop_timing( @@ -33,50 +35,10 @@ async def test_wait_until_mid_loop_timing( 2. Then starts wait_until with 200ms timeout 3. Verifies timeout takes ~200ms, not <50ms (immediate timeout bug) """ - loop = asyncio.get_running_loop() - - # Track test results - test_results = { - "timeout_duration": None, - "passed": False, - "failed": False, - "bug_detected": False, - } - - # Patterns for log messages - timeout_duration = re.compile(r"wait_until completed after (\d+) ms") - test_pass = re.compile(r"✓ Timeout duration correct") - test_fail = re.compile(r"✗ Timeout duration WRONG") - bug_pattern = re.compile(r"Likely BUG: Immediate timeout") - test_passed = re.compile(r"✓ Test PASSED") - test_failed = re.compile(r"✗ Test FAILED") - - test_complete = loop.create_future() - - def check_output(line: str) -> None: - """Check log output for test results.""" - # Extract timeout duration - match = timeout_duration.search(line) - if match: - test_results["timeout_duration"] = int(match.group(1)) - - if test_pass.search(line): - test_results["passed"] = True - if test_fail.search(line): - test_results["failed"] = True - if bug_pattern.search(line): - test_results["bug_detected"] = True - - # Final test result - if ( - test_passed.search(line) - or test_failed.search(line) - and not test_complete.done() - ): - test_complete.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Get the test service @@ -86,24 +48,29 @@ async def test_wait_until_mid_loop_timing( # Execute the test await client.execute_service(test_service, {}) - # Wait for test to complete (100ms delay + 200ms timeout + margins = ~500ms) - await asyncio.wait_for(test_complete, timeout=5.0) + # Wait for the "Test PASSED" or "Test FAILED" verdict (100ms delay + 200ms timeout) + await waiter.wait_for("Test ", ": wait_until", timeout=5.0) - # Verify results - assert test_results["timeout_duration"] is not None, ( - "Timeout duration not reported" + durations = [ + int(m.group(1)) for line in waiter.lines if (m := DURATION.search(line)) + ] + duration = durations[-1] if durations else None + passed = any("✓ Timeout duration correct" in line for line in waiter.lines) + bug_detected = any( + "Likely BUG: Immediate timeout" in line for line in waiter.lines ) - assert test_results["passed"], ( - f"Test failed: wait_until took {test_results['timeout_duration']}ms, expected ~200ms. " - f"Bug detected: {test_results['bug_detected']}" + + assert duration is not None, "Timeout duration not reported" + assert passed, ( + f"Test failed: wait_until took {duration}ms, expected ~200ms. " + f"Bug detected: {bug_detected}" ) - assert not test_results["bug_detected"], ( - f"BUG DETECTED: wait_until timed out immediately ({test_results['timeout_duration']}ms) " + assert not bug_detected, ( + f"BUG DETECTED: wait_until timed out immediately ({duration}ms) " "instead of waiting 200ms. This indicates stale loop_component_start_time." ) # Additional validation: timeout should be ~200ms (150-250ms range) - duration = test_results["timeout_duration"] assert 150 <= duration <= 250, ( f"Timeout duration {duration}ms outside expected range (150-250ms). " f"This suggests timing regression from PR #11676." diff --git a/tests/integration/test_wait_until_on_boot.py b/tests/integration/test_wait_until_on_boot.py index 37b3e87d906..ce533179d77 100644 --- a/tests/integration/test_wait_until_on_boot.py +++ b/tests/integration/test_wait_until_on_boot.py @@ -13,10 +13,10 @@ disable_loop(), undoing the enable_loop() from play_complex(), causing wait_unti from __future__ import annotations import asyncio -import re import pytest +from .log_utils import LineWaiter from .state_utils import require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail @@ -29,38 +29,14 @@ async def test_wait_until_on_boot( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that wait_until works in on_boot automation with a condition that becomes true later.""" - loop = asyncio.get_running_loop() - - on_boot_started = False - on_boot_completed = False - - on_boot_started_pattern = re.compile(r"on_boot: Starting wait_until test") - on_boot_complete_pattern = re.compile(r"on_boot: wait_until completed successfully") - - on_boot_started_future = loop.create_future() - on_boot_complete_future = loop.create_future() - - def check_output(line: str) -> None: - """Check log output for test progress.""" - nonlocal on_boot_started, on_boot_completed - - if on_boot_started_pattern.search(line): - on_boot_started = True - if not on_boot_started_future.done(): - on_boot_started_future.set_result(True) - - if on_boot_complete_pattern.search(line): - on_boot_completed = True - if not on_boot_complete_future.done(): - on_boot_complete_future.set_result(True) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Wait for on_boot to start - await asyncio.wait_for(on_boot_started_future, timeout=10.0) - assert on_boot_started, "on_boot did not start" + await waiter.wait_for("on_boot: Starting wait_until test", timeout=10.0) # At this point, on_boot is blocked in wait_until waiting for test_flag to become true # If the bug exists, wait_until's loop is disabled and it will never complete @@ -78,12 +54,9 @@ async def test_wait_until_on_boot( # If the fix works, wait_until's loop() will check the condition and proceed # If the bug exists, wait_until is stuck with disabled loop and will timeout await wait_or_fail( - on_boot_complete_future, + waiter.wait_for("on_boot: wait_until completed successfully"), 2.0, "wait_until in on_boot did not complete within 2s after condition became true. " "This indicates the bug where WaitUntilAction::setup() disables the loop " "after play_complex() has already enabled it.", ) - assert on_boot_completed, ( - "on_boot wait_until did not complete after flag was set" - ) diff --git a/tests/integration/test_wait_until_ordering.py b/tests/integration/test_wait_until_ordering.py index b695162c4be..c587f520316 100644 --- a/tests/integration/test_wait_until_ordering.py +++ b/tests/integration/test_wait_until_ordering.py @@ -9,15 +9,17 @@ LIFO ordering which is incorrect. from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +COMPLETED_PATTERN = re.compile(r"Completed iteration (\d+)") + @pytest.mark.asyncio async def test_wait_until_fifo_ordering( @@ -30,34 +32,19 @@ async def test_wait_until_fifo_ordering( With the bug (using emplace_front), the order would be 4,3,2,1,0 (LIFO). With the fix (using emplace_back), the order should be 0,1,2,3,4 (FIFO). """ - test_complete = asyncio.Event() + waiter = LineWaiter() + expected_order = [0, 1, 2, 3, 4] - # Track completion order - completed_order = [] - - # Patterns to match - queuing_pattern = re.compile(r"Queueing iteration (\d+)") - completed_pattern = re.compile(r"Completed iteration (\d+)") - - def check_output(line: str) -> None: - """Check log output for completion order.""" - if test_complete.is_set(): - return - - if mo := queuing_pattern.search(line): - iteration = int(mo.group(1)) - - elif mo := completed_pattern.search(line): - iteration = int(mo.group(1)) - completed_order.append(iteration) - - # Test completes when all 5 have completed - if len(completed_order) == 5: - test_complete.set() + def completed_order() -> list[int]: + return [ + int(m.group(1)) + for line in waiter.lines + if (m := COMPLETED_PATTERN.search(line)) + ][: len(expected_order)] # Run with log monitoring async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): # Verify device info @@ -72,15 +59,14 @@ async def test_wait_until_fifo_ordering( # Wait for test to complete await wait_or_fail( - test_complete, + waiter.wait_for_each(*(f"Completed iteration {i}" for i in expected_order)), 5.0, "Test timed out. Expected 5 completions", completed_order=completed_order, ) # Verify FIFO order - expected_order = [0, 1, 2, 3, 4] - assert completed_order == expected_order, ( - f"Unexpected order: {completed_order}. " - f"Expected FIFO order: {expected_order}" + order = completed_order() + assert order == expected_order, ( + f"Unexpected order: {order}. Expected FIFO order: {expected_order}" ) diff --git a/tests/integration/test_wait_until_reentrant_restart.py b/tests/integration/test_wait_until_reentrant_restart.py index aba04c86308..e6f37fed6e1 100644 --- a/tests/integration/test_wait_until_reentrant_restart.py +++ b/tests/integration/test_wait_until_reentrant_restart.py @@ -15,10 +15,14 @@ import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name, require_service from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +ATTEMPT_RE = re.compile(r"attempt (\d+) done") +GATE_RE = re.compile(r"gate passed (\d+)") + @pytest.mark.asyncio async def test_wait_until_reentrant_restart( @@ -27,28 +31,16 @@ async def test_wait_until_reentrant_restart( api_client_connected: APIClientConnectedFactory, ) -> None: """Test that re-entering a wait_until from its own continuation is safe.""" - retry_complete = asyncio.Event() - stop_complete = asyncio.Event() + waiter = LineWaiter() - attempt_pattern = re.compile(r"attempt (\d+) done") - gate_pattern = re.compile(r"gate passed (\d+)") + def attempts() -> list[int]: + return [int(m[1]) for line in waiter.lines if (m := ATTEMPT_RE.search(line))] - attempts: list[int] = [] - gate_passed: list[int] = [] - - def check_output(line: str) -> None: - """Check log output for expected messages.""" - if mo := attempt_pattern.search(line): - attempts.append(int(mo.group(1))) - elif mo := gate_pattern.search(line): - gate_passed.append(int(mo.group(1))) - elif "retry test complete" in line: - retry_complete.set() - elif "stop test complete" in line: - stop_complete.set() + def gate_passed() -> list[int]: + return [int(m[1]) for line in waiter.lines if (m := GATE_RE.search(line))] async with ( - run_compiled(yaml_config, line_callback=check_output), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "wait-until-reentrant-restart") @@ -61,9 +53,12 @@ async def test_wait_until_reentrant_restart( # script five times, re-entering the same wait_until each time. await client.execute_service(self_restart_service, {}) await wait_or_fail( - retry_complete, 10.0, "Self-restart retry did not finish", attempts=attempts + waiter.wait_for("retry test complete", timeout=20.0), + 10.0, + "Self-restart retry did not finish", + attempts=attempts, ) - assert attempts == [1, 2, 3, 4, 5], attempts + assert attempts() == [1, 2, 3, 4, 5], attempts() # Scenario 2: the first waiter through the gate stops the script while # the other waiters are still queued in the same wait_until; both the @@ -71,14 +66,14 @@ async def test_wait_until_reentrant_restart( # blocker (0) must be cancelled, not fired. await client.execute_service(stop_service, {}) await wait_or_fail( - stop_complete, + waiter.wait_for("stop test complete", timeout=20.0), 10.0, "Stop-during-wait did not finish", gate_passed=gate_passed, ) - assert gate_passed == [1], gate_passed + assert gate_passed() == [1], gate_passed() # If the cancelled blocker had been kept, its 1s wait_until timeout # would still fire - give it the chance and check it stays silent. await asyncio.sleep(1.5) - assert gate_passed == [1], gate_passed + assert gate_passed() == [1], gate_passed() diff --git a/tests/integration/test_wake_loop_forces_phase_b.py b/tests/integration/test_wake_loop_forces_phase_b.py index 1528c033e96..46d2f9f271e 100644 --- a/tests/integration/test_wake_loop_forces_phase_b.py +++ b/tests/integration/test_wake_loop_forces_phase_b.py @@ -19,15 +19,17 @@ With the fix, delta>=1 (the wake forces Phase B within one tick of the wake). from __future__ import annotations -import asyncio import re import pytest +from .log_utils import LineWaiter from .state_utils import assert_device_name from .types import APIClientConnectedFactory, RunCompiledFunction from .wait_utils import wait_or_fail +WAKE_RESULT = re.compile(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)") + @pytest.mark.asyncio async def test_wake_loop_forces_phase_b( @@ -38,23 +40,21 @@ async def test_wake_loop_forces_phase_b( """A wake_loop_threadsafe() call from a background thread must trigger the component phase within the next tick, even when loop_interval_ is raised well above the observation window.""" - loop = asyncio.get_running_loop() - result: asyncio.Future[tuple[int, int]] = loop.create_future() - - def on_log_line(line: str) -> None: - match = re.search(r"WAKE_RESULT delta=(\d+) elapsed=(\d+)", line) - if match and not result.done(): - result.set_result((int(match.group(1)), int(match.group(2)))) + waiter = LineWaiter() async with ( - run_compiled(yaml_config, line_callback=on_log_line), + run_compiled(yaml_config, line_callback=waiter.callback), api_client_connected() as client, ): await assert_device_name(client, "wake-loop-phase-b") - delta, elapsed = await wait_or_fail( - result, 15.0, "WAKE_RESULT marker never appeared" + line = await wait_or_fail( + waiter.wait_for("WAKE_RESULT delta=", timeout=15.0), + 15.0, + "WAKE_RESULT marker never appeared", ) + assert (match := WAKE_RESULT.search(line)), line + delta, elapsed = int(match.group(1)), int(match.group(2)) # Without the fix, delta would be 0 — loop_interval_=2000ms held # Phase B off for the full 500ms observation window. With the fix diff --git a/tests/test_build_components/common/i2s_audio/esp32-c6-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-c6-idf.yaml new file mode 100644 index 00000000000..1f328bbc2f4 --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-c6-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-C6 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO4 + i2s_lrclk_pin: GPIO5 + i2s_mclk_pin: GPIO6 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index 9279ca52b2c..03eaa708f41 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -2,10 +2,13 @@ # pylint: disable=protected-access +import ctypes import json import logging import os from pathlib import Path +import shlex +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -128,6 +131,65 @@ def test_pick_entry_prefers_esphome_tu() -> None: assert idedata._pick_entry(entries)["file"].endswith("app.cpp") +def test_pick_entry_without_cxx_tu_raises() -> None: + """A compile DB holding only C files has no usable translation unit.""" + entries = [_entry(f"{ABS}build", f"{ABS}build/src/a.c", "gcc -c a.c")] + with pytest.raises(ValueError, match="no C\\+\\+ translation unit"): + idedata._pick_entry(entries) + + +def test_expand_response_files_absolute_path(tmp_path: Path) -> None: + """An absolute response file path is read as is, not joined to the directory.""" + rsp = tmp_path / "flags.rsp" + rsp.write_text("-DFROM_RSP=1") + other = tmp_path / "elsewhere" + other.mkdir() + + assert idedata._expand_response_files([f"@{rsp}"], other) == ["-DFROM_RSP=1"] + + +def test_parse_entry_drops_bare_input_files() -> None: + """A source file given without -c is not kept as a compiler flag.""" + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/x.cpp", + "g++ -DFOO x.cpp -std=gnu++20", + ) + + _, _, _, cxx_flags = idedata.parse_entry(entry) + + assert cxx_flags == ["-std=gnu++20"] + + +@pytest.mark.parametrize( + ("cxx_path", "expected"), + [ + pytest.param("g++", "gcc", id="bare"), + pytest.param("/usr/bin/g++", "/usr/bin/gcc", id="path"), + pytest.param("xtensa-esp32-elf-g++.exe", "xtensa-esp32-elf-gcc.exe", id="exe"), + pytest.param("C:\\tools\\g++.exe", "C:\\tools\\gcc.exe", id="backslash"), + pytest.param("/usr/bin/clang++", "/usr/bin/clang++", id="clang"), + ], +) +def test_cc_path_from_cxx(cxx_path: str, expected: str) -> None: + """Only a g++ program name is rewritten to gcc.""" + assert idedata._cc_path_from_cxx(cxx_path) == expected + + +@pytest.mark.parametrize( + ("token", "expected"), + [ + pytest.param("-includefoo.h", True, id="joined"), + pytest.param("-include", False, id="separate"), + pytest.param("-include-pch", False, id="pch"), + pytest.param("-DFOO", False, id="other"), + ], +) +def test_is_joined_include(token: str, expected: bool) -> None: + """Only the joined ``-includefoo.h`` spelling counts.""" + assert idedata.is_joined_include(token) is expected + + def test_pick_entry_falls_back_to_any_cxx_tu() -> None: """With no ``/src/esphome/`` TU present, the first C++ entry is the fallback.""" entries = [ @@ -345,6 +407,48 @@ def test_split_command_empty_returns_empty() -> None: assert idedata._split_command(" ") == [] +@pytest.fixture(name="fake_windows_argv") +def fixture_fake_windows_argv(monkeypatch: pytest.MonkeyPatch) -> list[bool]: + """Run ``_split_command`` down its Windows branch on any host. + + ``CommandLineToArgvW`` is replaced by a shlex based stand-in; the returned + list records whether ``LocalFree`` released the argv block. + """ + freed: list[bool] = [] + + def command_line_to_argv(command: str, argc_ref: object) -> list[str]: + tokens = shlex.split(command) + argc_ref._obj.value = len(tokens) # type: ignore[attr-defined] + return tokens + + windll = SimpleNamespace( + shell32=SimpleNamespace(CommandLineToArgvW=command_line_to_argv), + kernel32=SimpleNamespace(LocalFree=lambda _ptr: freed.append(True)), + ) + monkeypatch.setattr(ctypes, "windll", windll, raising=False) + monkeypatch.setattr(idedata.os, "name", "nt") + return freed + + +def test_split_command_windows_branch(fake_windows_argv: list[bool]) -> None: + """On Windows the command is tokenized by the OS parser and freed after.""" + assert idedata._split_command("g++ -DFOO=1 -c x.cpp") == [ + "g++", + "-DFOO=1", + "-c", + "x.cpp", + ] + assert fake_windows_argv == [True] + + +def test_split_command_windows_branch_blank_command( + fake_windows_argv: list[bool], +) -> None: + """A blank command yields no tokens without calling the OS parser.""" + assert idedata._split_command(" ") == [] + assert fake_windows_argv == [] + + @pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization") def test_parse_entry_normalizes_windows_cxx_path() -> None: """A backslash compiler path is emitted forward-slashed; define unescaped.""" diff --git a/tests/unit_tests/build_helpers/test_tool_runner.py b/tests/unit_tests/build_helpers/test_tool_runner.py index e2c7e44341a..5a55c8c2f17 100644 --- a/tests/unit_tests/build_helpers/test_tool_runner.py +++ b/tests/unit_tests/build_helpers/test_tool_runner.py @@ -176,6 +176,14 @@ def test_tool_output_filters_a_matching_progress_line() -> None: assert out.getvalue() == "\r[2/2] shown\x1b[K" +def test_tool_output_keeps_the_color_codes_of_a_filtered_progress_line() -> None: + """The hidden text and its newline go, the reset it carried stays.""" + out = io.StringIO() + output = ToolOutput(out, [r"\[1/2\]"], True) + output.write("[1/2] hidden\x1b[0m\n[2/2] shown\n") + assert out.getvalue() == "\x1b[0m\r[2/2] shown\x1b[K" + + def test_tool_output_without_progress_keeps_status_lines() -> None: output, out = _tool_output(progress=False) output.write("[1/2] Building a.o\n") diff --git a/tests/unit_tests/components/esp32_hosted/test_update.py b/tests/unit_tests/components/esp32_hosted/test_update.py new file mode 100644 index 00000000000..10b988ec4ab --- /dev/null +++ b/tests/unit_tests/components/esp32_hosted/test_update.py @@ -0,0 +1,279 @@ +"""Build-time firmware acquisition for embedded Hosted updates.""" + +from concurrent.futures import ThreadPoolExecutor +import hashlib +from pathlib import Path +import re +from threading import Barrier +from unittest.mock import AsyncMock, Mock + +import pytest + +from esphome import config_validation as cv +import esphome.codegen as cg +from esphome.components.esp32_hosted import update as hosted_update +from esphome.core import CORE +from esphome.types import ConfigType +from esphome.writer import clean_all, clean_build + +FIRMWARE = b"test coprocessor firmware" +DIGEST = hashlib.sha256(FIRMWARE).hexdigest() +URL = "https://example.com/c6.bin" + + +@pytest.fixture(autouse=True) +def firmware_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + CORE.config_path = tmp_path / "device.yaml" + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path / ".esphome")) + + +@pytest.fixture +def downloader(monkeypatch: pytest.MonkeyPatch) -> Mock: + def download(url: str, path: Path, **kwargs: object) -> bytes: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(FIRMWARE) + return FIRMWARE + + mock = Mock(side_effect=download) + monkeypatch.setattr("esphome.external_files.download_content", mock) + return mock + + +def remote_config(digest: str = DIGEST) -> ConfigType: + return {"type": "embedded", "url": URL, "sha256": digest} + + +def cache_file(digest: str = DIGEST) -> Path: + return CORE.data_dir / "esp32_hosted" / "firmware" / f"{digest.lower()}.bin" + + +def embedded_schema(fields: ConfigType) -> ConfigType: + return hosted_update.EMBEDDED_SCHEMA(fields | {"id": "c6_firmware"}) + + +def test_existing_local_image(tmp_path: Path, downloader: Mock) -> None: + image = tmp_path / "c6.bin" + image.write_bytes(FIRMWARE) + config = embedded_schema({"path": str(image), "sha256": DIGEST}) + hosted_update.FINAL_VALIDATE_SCHEMA(config | {"type": "embedded"}) + downloader.assert_not_called() + + +def test_url_schema_accepts_pinned_image() -> None: + config = embedded_schema({"url": URL, "sha256": DIGEST}) + assert config["url"] == URL + assert "path" not in config + + +@pytest.mark.parametrize("fields", [{}, {"path": "c6.bin", "url": URL}]) +def test_exactly_one_source(tmp_path: Path, fields: ConfigType) -> None: + (tmp_path / "c6.bin").write_bytes(FIRMWARE) + with pytest.raises(cv.Invalid, match="one of path, url"): + embedded_schema(fields | {"sha256": DIGEST}) + + +@pytest.mark.parametrize("url", ["not a URL", "ftp://example.com/c6.bin"]) +def test_invalid_url(url: str) -> None: + with pytest.raises(cv.Invalid): + embedded_schema({"url": url, "sha256": DIGEST}) + + +def test_url_requires_hash() -> None: + with pytest.raises(cv.Invalid): + embedded_schema({"url": URL}) + + +@pytest.mark.parametrize("digest", ["short", "g" * 64]) +def test_invalid_hash(digest: str) -> None: + with pytest.raises(cv.Invalid): + embedded_schema({"url": URL, "sha256": digest}) + + +def test_download_is_verified_and_cached(downloader: Mock) -> None: + config = remote_config() + hosted_update.FINAL_VALIDATE_SCHEMA(config) + assert cache_file().read_bytes() == FIRMWARE + assert config == remote_config() + assert downloader.call_count == 1 + assert downloader.call_args.args[0] == URL + + +@pytest.mark.parametrize("reset_environment", [False, True]) +def test_firmware_cache_cleanup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + downloader: Mock, + reset_environment: bool, +) -> None: + """The real Hosted cache survives clean and is removed by clean-all.""" + CORE.config_path.write_text("esphome:\n name: device\n") + CORE.build_path = tmp_path / "build" + CORE.build_path.mkdir() + + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config()) + cache = cache_file() + assert cache.read_bytes() == FIRMWARE + downloader.assert_called_once() + + if reset_environment: + # Keep clean-all's machine-wide cleanup confined to this test. + monkeypatch.setattr("esphome.writer.is_ha_addon", lambda: False) + monkeypatch.setattr( + "esphome.platformio.toolchain.clean_platformio_cache", lambda: None + ) + monkeypatch.setattr("esphome.build_helpers.tools_cache.TOOLS_CACHE_SPECS", ()) + monkeypatch.setattr( + "platformdirs.user_cache_dir", + lambda *args, **kwargs: str(tmp_path / "user-cache"), + ) + monkeypatch.delenv("ESPHOME_BUILD_PATH", raising=False) + clean_all([str(CORE.config_path)]) + assert not cache.exists() + else: + # An inherited skip would make clean_build return before touching anything + monkeypatch.delenv("ESPHOME_SKIP_CLEAN_BUILD", raising=False) + clean_build(clear_pio_cache=False, full=True) + assert cache.read_bytes() == FIRMWARE + + +def test_unwritable_cache_fails_validation( + monkeypatch: pytest.MonkeyPatch, downloader: Mock +) -> None: + """Codegen reads the cached file, so a cache that cannot be written is a validation error.""" + + def refuse(self: Path, target: Path) -> Path: + raise OSError("read-only cache") + + monkeypatch.setattr(Path, "replace", refuse) + with pytest.raises(cv.Invalid, match="Could not cache"): + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config()) + assert not cache_file().exists() + + +def test_verified_cache_never_accesses_network(downloader: Mock) -> None: + image = cache_file() + image.parent.mkdir(parents=True) + image.write_bytes(FIRMWARE) + downloader.side_effect = AssertionError("network must not be accessed") + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config(DIGEST.upper())) + downloader.assert_not_called() + + +def test_corrupt_cache_is_replaced_by_verified_download( + downloader: Mock, caplog: pytest.LogCaptureFixture +) -> None: + image = cache_file() + image.parent.mkdir(parents=True) + image.write_bytes(b"corrupted") + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config()) + assert image.read_bytes() == FIRMWARE + downloader.assert_called_once() + # A file named after its digest should never fail the check, so say that it did + assert "downloading it again" in caplog.text + + +def test_mismatched_download_is_not_published(downloader: Mock) -> None: + wrong_digest = "0" * 64 + with pytest.raises(cv.Invalid, match="SHA256 mismatch"): + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config(wrong_digest)) + assert not cache_file(wrong_digest).exists() + assert not list(cache_file().parent.glob("tmp*")) + + +@pytest.mark.parametrize("corrupt_cache", [False, True]) +def test_download_failure_without_verified_cache( + downloader: Mock, corrupt_cache: bool +) -> None: + if corrupt_cache: + image = cache_file() + image.parent.mkdir(parents=True) + image.write_bytes(b"truncated") + downloader.side_effect = cv.Invalid(f"Could not download from {URL}: offline") + with pytest.raises(cv.Invalid, match="offline"): + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config()) + + +def test_same_url_with_new_hash_does_not_reuse_old_image(downloader: Mock) -> None: + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config()) + with pytest.raises(cv.Invalid, match="SHA256 mismatch"): + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config("0" * 64)) + assert downloader.call_count == 2 + assert cache_file().read_bytes() == FIRMWARE + + +def test_local_hash_mismatch(tmp_path: Path) -> None: + image = tmp_path / "c6.bin" + image.write_bytes(b"corrupted") + with pytest.raises(cv.Invalid, match="SHA256 mismatch"): + hosted_update.FINAL_VALIDATE_SCHEMA( + {"type": "embedded", "path": image, "sha256": DIGEST} + ) + + +def test_interrupted_download_does_not_publish_partial_image(downloader: Mock) -> None: + def interrupted(url: str, path: Path, **kwargs: object) -> bytes: + path.write_bytes(FIRMWARE[:4]) + raise cv.Invalid("Download interrupted") + + downloader.side_effect = interrupted + with pytest.raises(cv.Invalid, match="interrupted"): + hosted_update.FINAL_VALIDATE_SCHEMA(remote_config()) + assert not cache_file().exists() + assert not list(cache_file().parent.iterdir()) + + +def test_concurrent_downloads_publish_only_verified_bytes(downloader: Mock) -> None: + barrier = Barrier(2) + + def download(url: str, path: Path, **kwargs: object) -> bytes: + path.write_bytes(FIRMWARE[:4]) + assert not cache_file().exists() + barrier.wait(timeout=5) + path.write_bytes(FIRMWARE) + return FIRMWARE + + downloader.side_effect = download + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(hosted_update.FINAL_VALIDATE_SCHEMA, remote_config()) + for _ in range(2) + ] + for future in futures: + future.result() + assert cache_file().read_bytes() == FIRMWARE + assert downloader.call_count == 2 + assert list(cache_file().parent.iterdir()) == [cache_file()] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["path", "url"]) +async def test_codegen_embeds_verified_bytes_without_runtime_http( + tmp_path: Path, downloader: Mock, monkeypatch: pytest.MonkeyPatch, source: str +) -> None: + if source == "path": + image = tmp_path / "c6.bin" + image.write_bytes(FIRMWARE) + fields = {"path": image} + else: + fields = {"url": URL} + config = embedded_schema(fields | {"sha256": DIGEST}) | {"type": "embedded"} + hosted_update.FINAL_VALIDATE_SCHEMA(config) + monkeypatch.setattr( + hosted_update.update, + "new_update", + AsyncMock(return_value=cg.MockObj("c6_firmware")), + ) + monkeypatch.setattr(hosted_update.cg, "register_component", AsyncMock()) + + await hosted_update.to_code(config) + + emitted_bytes = bytes( + int(value, 16) for value in re.findall(r"0x[0-9a-fA-F]+", CORE.cpp_main_section) + ) + assert emitted_bytes == FIRMWARE + bytes.fromhex(DIGEST) + assert f"set_firmware_size({len(FIRMWARE)})" in CORE.cpp_main_section + assert "set_firmware_sha256" in CORE.cpp_main_section + assert not any( + define.name == "USE_ESP32_HOSTED_HTTP_UPDATE" for define in CORE.defines + ) + assert downloader.call_count == (1 if source == "url" else 0) diff --git a/tests/unit_tests/components/light/test_restore_state.py b/tests/unit_tests/components/light/test_restore_state.py index aee3c5ec194..e4fd5857221 100644 --- a/tests/unit_tests/components/light/test_restore_state.py +++ b/tests/unit_tests/components/light/test_restore_state.py @@ -16,10 +16,6 @@ from esphome.components.light import ( from esphome.components.light.restore_state import ( _RESTORE_STATE_FIELDS_SCHEMA, LEGACY_RESTORE_MODES, - RESTORE_STATE_INITIAL, - RESTORE_STATE_INVERT, - RESTORE_STATE_KEEP, - RESTORE_STATE_NONE, RESTORE_STATE_SCHEMA, StateStatement, _initial_state_overridden_by_legacy_mode, @@ -33,6 +29,12 @@ from esphome.components.light.types import ColorMode import esphome.config_validation as cv from esphome.const import CONF_STATE from esphome.core import Lambda +from esphome.core.entity_helpers import ( + RESTORE_STATE_INITIAL, + RESTORE_STATE_INVERT, + RESTORE_STATE_KEEP, + RESTORE_STATE_NONE, +) from esphome.schema_extractors import SCHEMA_EXTRACT # (mode name, expected cold_boot_state, expected restore_action, expected save_enabled) diff --git a/tests/unit_tests/components/nrf52/test_compile_flags.py b/tests/unit_tests/components/nrf52/test_compile_flags.py new file mode 100644 index 00000000000..c3c73e37757 --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_compile_flags.py @@ -0,0 +1,39 @@ +"""The compile flags the zephyr platform adds reach the app target.""" + +from pathlib import Path +from unittest.mock import patch + +from esphome.components import nrf52 +from esphome.components.zephyr import zephyr_to_code +from esphome.core import CORE + + +def test_double_promotion_warning_is_off_for_the_app(tmp_path: Path) -> None: + """Zephyr's base warnings include -Wdouble-promotion, which every float logged would trip.""" + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.name = "livingroom" + with patch("esphome.components.zephyr.zephyr_add_prj_conf"): + zephyr_to_code({}) + assert "-Wno-double-promotion" in CORE.build_flags + + with ( + patch( + "esphome.components.zephyr.library.generate_zephyr_modules", + return_value=[], + ), + patch.object(nrf52, "get_project_link_flags", return_value=[]), + ): + nrf52._generate_cmake_lists(False) + text = (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text() + # The define may sit in the target options, which come before the ones inherited from + # zephyr_interface; the warning flag must not, or Zephyr's -Wdouble-promotion would win + target_options = text.split("target_compile_options(app PRIVATE", 1)[1] + assert '"-DUSE_ZEPHYR"' in target_options + assert ( + "-Wno-double-promotion" not in text.split("set_source_files_properties", 1)[0] + ) + assert ( + "set_source_files_properties(${APP_SOURCES} PROPERTIES\n" + ' COMPILE_OPTIONS "-Wno-double-promotion"\n)' + ) in text diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index e52ec99418a..2f73d3b98a8 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -4,7 +4,7 @@ from ipaddress import IPv4Address import pytest -from esphome.components.esp32 import const +from esphome.components.esp32 import KEY_ESP32, KEY_IDF_VERSION, const from esphome.components.network import ( KEY_HIGH_PERFORMANCE_NETWORKING, KEY_HIGH_PERFORMANCE_WIFI, @@ -236,11 +236,19 @@ def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) - CORE.data, KEY_CORE, { - KEY_TARGET_PLATFORM: Platform.ESP32, + KEY_TARGET_PLATFORM: params.get("platform", Platform.ESP32), KEY_TARGET_FRAMEWORK: params.get("framework", "esp-idf"), KEY_FRAMEWORK_VERSION: params.get("idf", cv.Version(5, 4, 0)), }, ) + # The ESP-IDF underneath, which an Arduino build maps from its own version; only an + # ESP32 build has it + if params.get("platform", Platform.ESP32) == Platform.ESP32: + monkeypatch.setitem( + CORE.data, + KEY_ESP32, + {KEY_IDF_VERSION: params.get("idf", cv.Version(5, 4, 0))}, + ) def _wisp_ap(**overrides: object) -> dict: @@ -300,37 +308,55 @@ def test_dns_servers_limited_to_two() -> None: @pytest.mark.parametrize( ("wisp_core", "valid"), - [({"idf": cv.Version(5, 5, 5)}, False), ({"idf": cv.Version(6, 0, 1)}, True)], + [ + ({"idf": cv.Version(5, 5, 5)}, False), + ({"idf": cv.Version(6, 0, 1)}, True), + ({"framework": "arduino", "idf": cv.Version(5, 5, 6)}, False), + ({"framework": "arduino", "idf": cv.Version(6, 0, 1)}, True), + ], indirect=["wisp_core"], ) def test_second_dns_server_requires_idf_6(wisp_core: None, valid: bool) -> None: + """The ESP-IDF underneath counts, so an Arduino build on ESP-IDF 6.0 gets it too.""" ap = _wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8"]) if valid: wifi_network_ap(ap) else: - with pytest.raises(cv.Invalid, match="6.0.0"): + with pytest.raises(cv.Invalid, match="ESP-IDF 6.0"): wifi_network_ap(ap) -@pytest.mark.parametrize("wisp_core", [{"framework": "arduino"}], indirect=True) -def test_wisp_requires_esp_idf(wisp_core: None) -> None: - """NAT is only available with the ESP-IDF framework.""" - with pytest.raises(cv.Invalid, match="incompatible"): - wifi_network_ap(_wisp_ap()) +@pytest.mark.parametrize( + "wisp_core", + [{"platform": Platform.ESP8266, "framework": "arduino"}], + indirect=True, +) +def test_second_dns_server_elsewhere_is_a_config_error(wisp_core: None) -> None: + """No ESP32 state to read a version from; the WISP platform check rejects it instead.""" + with pytest.raises(cv.Invalid, match="only available on"): + wifi_network_ap(_wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8"])) -@pytest.mark.parametrize("wisp_core", [{"idf": cv.Version(5, 0, 9)}], indirect=True) -def test_wisp_requires_idf_5_1(wisp_core: None) -> None: - """esp_netif_napt_enable() is missing before ESP-IDF 5.1.""" - with pytest.raises(cv.Invalid, match="5.1.0"): +@pytest.mark.parametrize( + "wisp_core", + [{"platform": Platform.ESP8266, "framework": "arduino"}], + indirect=True, +) +def test_wisp_requires_esp32(wisp_core: None) -> None: + """The bridge is part of the ESP32 backend; other platforms have no NAT.""" + with pytest.raises(cv.Invalid, match="only available on .*ESP32"): wifi_network_ap(_wisp_ap()) @pytest.mark.usefixtures("wisp_core") -def test_wisp_requires_sta_network() -> None: - """WISP has nothing to share without an upstream network.""" - with pytest.raises(cv.Invalid, match="at least one STA network"): - _final_validate({CONF_NETWORKS: [], CONF_AP: _wisp_ap()}) +def test_wisp_requires_sta_network_or_ethernet() -> None: + """WISP has nothing to share without an upstream network; ethernet is one too.""" + ap_only = {CONF_NETWORKS: [], CONF_AP: _wisp_ap()} + with pytest.raises(cv.Invalid, match="ethernet as the upstream"): + _final_validate(ap_only) + _final_validate(ap_only, {"ethernet": {}}) + with pytest.raises(cv.Invalid, match="ethernet manual_ip needs dns1"): + _final_validate(ap_only, {"ethernet": {CONF_MANUAL_IP: _manual_ip("0.0.0.0")}}) @pytest.mark.usefixtures("wisp_core") diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index bb565564c6e..f2fa1ce33fc 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -26,9 +26,12 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config +from esphome.core import CORE, ID, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config from esphome.core.config import ( + CONF_RELATION, CONF_SUSPEND_LOOP, + DEVICE_RELATIONS, + SHARED_DEVICE_RELATION_DEFINES, Area, make_app_name_cpp, preload_core_config, @@ -364,6 +367,83 @@ def test_device_without_area(yaml_file: Callable[[str], str]) -> None: assert "area_id" not in device +def test_device_relation(yaml_file: Callable[[str], str]) -> None: + """Test that device relation accepts via and child in any case.""" + result = load_config_from_fixture(yaml_file, "device_relation.yaml", FIXTURES_DIR) + assert result is not None + + devices = result[CONF_ESPHOME][CONF_DEVICES] + assert [device[CONF_RELATION] for device in devices] == ["via", "via", "child"] + + +def test_device_without_relation( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that a device without a relation fails validation.""" + result = load_config_from_fixture( + yaml_file, "device_missing_relation.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert "'relation' is a required option" in captured.out + + +def test_device_with_invalid_relation( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that an unknown device relation fails validation.""" + result = load_config_from_fixture( + yaml_file, "device_invalid_relation.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert "Unknown value 'parent', valid options are 'via', 'child'" in captured.out + + +def test_shared_device_relation_defines_cover_all_relations() -> None: + """Every relation needs an entry, so a new one cannot silently report VIA.""" + assert SHARED_DEVICE_RELATION_DEFINES.keys() == DEVICE_RELATIONS.keys() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("fixture", "expected_relations", "expected_define"), + [ + ("device_relation.yaml", ["DEVICE_RELATION_CHILD"], "USE_DEVICE_RELATION"), + ("device_child_only.yaml", [], "USE_DEVICE_RELATION_ALL_CHILD"), + ("device_via_only.yaml", [], None), + ], +) +async def test_device_relation_to_code( + yaml_file: Callable[[str], Path], + fixture: str, + expected_relations: list[str], + expected_define: str | None, +) -> None: + """A shared relation becomes a define; only a mix needs per-device setters.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + set_relation = mock_cg.new_Pvariable.return_value.set_relation + assert [ + str(c.args[0].enum_value).rsplit("::", 1)[-1] + for c in set_relation.call_args_list + ] == expected_relations + relation_defines = [ + c.args[0] + for c in mock_cg.add_define.call_args_list + if c.args[0].startswith("USE_DEVICE_RELATION") + ] + assert relation_defines == ([expected_define] if expected_define else []) + + def test_device_with_invalid_area_id( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: @@ -1034,6 +1114,40 @@ async def test_add_includes_with_single_file( assert any('#include "my_header.h"' in inc for inc in includes_added) +@pytest.mark.asyncio +async def test_add_includes_sit_between_declarations_and_generated_code( + tmp_path: Path, mock_copy_file_if_changed: Mock +) -> None: + """A header can name an id, and generated functions and tables can use the header.""" + CORE.config_path = tmp_path / "config.yaml" + CORE.build_path = tmp_path / "build" + CORE.build_path.mkdir(parents=True, exist_ok=True) + header = tmp_path / "my_header.h" + header.write_text("static constexpr int MY_CONSTANT = 42;") + CORE.config = {} + + value = cg.new_Pvariable(ID("my_value", is_declaration=True, type=cg.int32)) + cg.static_function( + "esphome__my_action__fn", cg.void, [], [f"*{value} = MY_CONSTANT;"] + ) + cg.shared_progmem_array("my_table", cg.int32, [cg.RawExpression("MY_CONSTANT")]) + await config.add_includes([str(header)]) + # A job that runs after the includes still lands before the generated code. + cg.add_global(cg.RawStatement("static constexpr int FINAL_JOB = 1;")) + + section = CORE.cpp_global_section + expected_order = [ + "*const my_value", + "static void esphome__my_action__fn();", + '#include "my_header.h"', + "static constexpr int FINAL_JOB = 1;", + "static void esphome__my_action__fn() {", + "my_table[] PROGMEM", + ] + positions = [section.index(text) for text in expected_order] + assert positions == sorted(positions), section + + @pytest.mark.asyncio @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") async def test_add_includes_with_directory_unix( diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index e4f2ec0b8e4..600d6cff087 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -7,8 +7,11 @@ from typing import Any import pytest +from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import button, number, select, switch +from esphome.config import Config +import esphome.config_validation as cv from esphome.config_validation import Invalid from esphome.const import ( CONF_DEVICE_CLASS, @@ -28,6 +31,7 @@ from esphome.core.entity_helpers import ( entity_duplicate_validator, finalize_entity_strings, get_base_entity_object_id, + inherit_property_from, register_device_class, register_icon, register_unit_of_measurement, @@ -40,11 +44,8 @@ from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture -# Pre-compiled regex pattern for extracting names from configure_entity_/set_name calls -# Matches: .configure_entity_("name", ...) or .set_name("name", ...) -ENTITY_NAME_PATTERN = re.compile( - r'\.(?:configure_entity_|set_name)\(["\']([^"\']*)["\']' -) +# Extracts the name from .configure_entity_(ESPHOME_PSTR("name"), ...) +ENTITY_NAME_PATTERN = re.compile(r'\.configure_entity_\(ESPHOME_PSTR\("([^"]*)"') FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" @@ -1078,6 +1079,8 @@ async def test_finalize_no_flags(setup_test_environment: list[str]) -> None: packed = _extract_packed_value(added_expressions) assert packed == 0 assert "//" not in added_expressions[0] + # Names go through ESPHOME_PSTR so they stay in flash on ESP8266 + assert 'ESPHOME_PSTR("Test")' in added_expressions[0] @pytest.mark.asyncio @@ -1534,13 +1537,87 @@ async def test_sub_entities_apply_errors( @pytest.mark.asyncio -async def test_sub_entities_apply_needs_parent( +async def test_sub_entities_apply_without_parent_needs_namespaced_call( + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], +) -> None: + """A hub method with no parent bound fails in Python, not as an undeclared name in C++.""" + calls: list[tuple[Any, ...]] = [] + conf = _apply_conf("k_id", _BUTTON_APPLY.cls) + sub = _apply_sub(calls, {"k": conf}, _BUTTON_APPLY, parent=None) + with pytest.raises(ValueError, match="namespaced free function"): + await sub("k", None, apply="restart()") + assert calls == [] + assert apply_functions == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("apply_entity", "type_", "publish", "params", "body"), + [ + ( + _BUTTON_APPLY, + _BUTTON_APPLY.cls, + None, + "button::Button * entity", + "restart::restart_device();", + ), + ( + _SWITCH_APPLY, + _APPLY_SWITCH_CLS, + "after", + "switch_::Switch * entity, bool state", + "restart::restart_device();\nentity->publish_state(state);", + ), + ], +) +async def test_sub_entities_apply_without_parent( + monkeypatch: pytest.MonkeyPatch, + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], + apply_entity: entity_helpers.ApplyEntity, + type_: MockObjClass, + publish: str | None, + params: str, + body: str, +) -> None: + """Without a parent the statement runs as written, with no parent lookup or deref.""" + + async def fail(*args: Any) -> None: + raise AssertionError("must not be called") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fail) + monkeypatch.setattr(entity_helpers, "get_variable", fail) + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {}, apply_entity, parent=None) + var = await sub.create( + _apply_conf("rst", type_), + None, + apply="restart::restart_device()", + publish=publish, + ) + assert str(var) == "rst" + assert calls == [("rst", "")] + assert apply_functions == [("esphome__rst__fn", params, body)] + assert setup_test_environment == [] + + +@pytest.mark.asyncio +async def test_sub_entities_apply_without_parent_shares_equal_functions( setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] ) -> None: + """Parentless entities with the same statement share one function.""" calls: list[tuple[Any, ...]] = [] - sub = _apply_sub(calls, {"k": _apply_conf("k_id")}, parent=None) - with pytest.raises(ValueError, match="needs a parent"): - await sub("k", None, apply="x()") + sub = _apply_sub(calls, {}, _BUTTON_APPLY, parent=None) + for name in ("a_btn", "b_btn"): + await sub.create(_apply_conf(name, _BUTTON_APPLY.cls), None, apply="x::go()") + await sub.create(_apply_conf("c_btn", _BUTTON_APPLY.cls), None, apply="x::stop()") + assert calls == [ + ("a_btn", ""), + ("b_btn", ""), + ("c_btn", ""), + ] + assert [body for _, _, body in apply_functions] == ["x::go();", "x::stop();"] @pytest.mark.parametrize( @@ -1608,3 +1685,87 @@ async def test_sub_entities_create_from_whole_config( assert str(var) == "rf_sw" assert calls == [("rf_sw", "")] assert setup_test_environment == ["hub->set_rf_switch(rf_sw)"] + + +def test_entity_duplicate_validator_without_name() -> None: + """A config with no name is returned untouched and not tracked.""" + validator = entity_duplicate_validator("sensor") + config = {CONF_ID: "no_name"} + + assert validator(config) is config + assert not CORE.unique_ids + + +def test_entity_duplicate_validator_testing_mode_allows_duplicates() -> None: + validator = entity_duplicate_validator("sensor") + config = {CONF_NAME: "Temperature"} + validator(dict(config)) + CORE.testing_mode = True + try: + assert validator(dict(config)) == config + finally: + CORE.testing_mode = False + + +def _inherit_config() -> tuple[Config, ID, dict[str, Any]]: + parent_id = ID("parent", is_declaration=True) + child_id = ID("child", is_declaration=True) + parent = {CONF_ID: parent_id, CONF_ICON: "mdi:parent"} + child = {CONF_ID: child_id, "source": ID("parent"), "inner": {"x": 1}} + config = Config() + config["sensor"] = [parent, child] + config.declare_ids.append((parent_id, ["sensor", 0, CONF_ID])) + config.declare_ids.append((child_id, ["sensor", 1, CONF_ID])) + return config, child_id, child + + +def test_inherit_property_from_path_inherits_value() -> None: + config, child_id, child = _inherit_config() + validator = inherit_property_from(["inner", CONF_ICON], "source") + token = fv.full_config.set(config) + try: + assert validator(child) is child + finally: + fv.full_config.reset(token) + + assert config["sensor"][1]["inner"][CONF_ICON] == "mdi:parent" + + +def test_inherit_property_from_path_missing_is_ignored() -> None: + config, _, child = _inherit_config() + validator = inherit_property_from(["absent", CONF_ICON], "source") + token = fv.full_config.set(config) + try: + assert validator(child) is child + finally: + fv.full_config.reset(token) + + assert "absent" not in config["sensor"][1] + + +_RESTORE_FIELDS = cv.Schema({cv.Optional("state", default="KEEP"): cv.string}) + + +def test_restore_state_schema_shorthands() -> None: + validate = entity_helpers.restore_state_schema( + _RESTORE_FIELDS, {"state": "INITIAL"} + ) + assert validate(" all ") == {"state": "KEEP"} + assert validate("None") == {"state": "INITIAL"} + assert validate({"state": "INVERT"}) == {"state": "INVERT"} + with pytest.raises(Invalid, match="expected 'all', 'none' or a mapping"): + validate("everything") + + +def test_restore_state_schema_keeps_a_keyword_for_none() -> None: + validate = entity_helpers.restore_state_schema( + _RESTORE_FIELDS, entity_helpers.RESTORE_STATE_NONE + ) + assert validate("none") == entity_helpers.RESTORE_STATE_NONE + + +def test_restore_state_keyword() -> None: + keywords = (entity_helpers.RESTORE_STATE_KEEP, entity_helpers.RESTORE_STATE_INVERT) + assert entity_helpers.restore_state_keyword(" invert ", keywords) == "INVERT" + assert entity_helpers.restore_state_keyword("INITIAL", keywords) is None + assert entity_helpers.restore_state_keyword(True, keywords) is None diff --git a/tests/unit_tests/fixtures/core/config/device_child_only.yaml b/tests/unit_tests/fixtures/core/config/device_child_only.yaml new file mode 100644 index 00000000000..0fb66adb710 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_child_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test-device-child-only + devices: + - id: zone_1_device + name: Zone 1 + relation: child + - id: zone_2_device + name: Zone 2 + relation: child + +host: diff --git a/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml b/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml index 2aa30556862..5ae2f03c33e 100644 --- a/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml +++ b/tests/unit_tests/fixtures/core/config/device_duplicate_id.yaml @@ -3,8 +3,10 @@ esphome: devices: - id: duplicate_device name: "Device 1" + relation: via - id: duplicate_device name: "Device 2" + relation: via esp32: board: esp32dev diff --git a/tests/unit_tests/fixtures/core/config/device_id_collision.yaml b/tests/unit_tests/fixtures/core/config/device_id_collision.yaml index 9cf04e0595c..cb5280faec1 100644 --- a/tests/unit_tests/fixtures/core/config/device_id_collision.yaml +++ b/tests/unit_tests/fixtures/core/config/device_id_collision.yaml @@ -3,8 +3,10 @@ esphome: devices: - id: test_2258 name: "Device 1" + relation: via - id: d6ka name: "Device 2" + relation: via esp32: board: esp32dev diff --git a/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml b/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml index 9a8ec0a1eb8..61c5a2c5d1d 100644 --- a/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml +++ b/tests/unit_tests/fixtures/core/config/device_invalid_area.yaml @@ -7,6 +7,7 @@ esphome: - id: test_device name: "Test Device" area_id: nonexistent_area + relation: via esp32: board: esp32dev diff --git a/tests/unit_tests/fixtures/core/config/device_invalid_relation.yaml b/tests/unit_tests/fixtures/core/config/device_invalid_relation.yaml new file mode 100644 index 00000000000..e2a2f7abba3 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_invalid_relation.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-device-invalid-relation + devices: + - id: test_device + name: Test Device + relation: parent + +host: diff --git a/tests/unit_tests/fixtures/core/config/device_missing_relation.yaml b/tests/unit_tests/fixtures/core/config/device_missing_relation.yaml new file mode 100644 index 00000000000..30454a9b3a6 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_missing_relation.yaml @@ -0,0 +1,7 @@ +esphome: + name: test-device-missing-relation + devices: + - id: test_device + name: Test Device + +host: diff --git a/tests/unit_tests/fixtures/core/config/device_relation.yaml b/tests/unit_tests/fixtures/core/config/device_relation.yaml new file mode 100644 index 00000000000..81955b8b027 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_relation.yaml @@ -0,0 +1,14 @@ +esphome: + name: test-device-relation + devices: + - id: modbus_meter_device + name: Modbus Meter + relation: via + - id: rf_sensor_device + name: RF Sensor + relation: via + - id: zone_device + name: Zone 1 + relation: CHILD + +host: diff --git a/tests/unit_tests/fixtures/core/config/device_via_only.yaml b/tests/unit_tests/fixtures/core/config/device_via_only.yaml new file mode 100644 index 00000000000..e020a65e333 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/device_via_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test-device-via-only + devices: + - id: modbus_meter_device + name: Modbus Meter + relation: via + - id: rf_sensor_device + name: RF Sensor + relation: via + +host: diff --git a/tests/unit_tests/fixtures/core/config/device_without_area.yaml b/tests/unit_tests/fixtures/core/config/device_without_area.yaml index 8464cf37df0..cf2f5eb3c58 100644 --- a/tests/unit_tests/fixtures/core/config/device_without_area.yaml +++ b/tests/unit_tests/fixtures/core/config/device_without_area.yaml @@ -3,5 +3,6 @@ esphome: devices: - id: test_device name: Test Device + relation: via host: diff --git a/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml b/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml index ef3b4f6e675..f5d0dd9baea 100644 --- a/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml +++ b/tests/unit_tests/fixtures/core/config/multiple_areas_devices.yaml @@ -12,11 +12,14 @@ esphome: - id: device1 name: Device 1 area_id: main_area + relation: via - id: device2 name: Device 2 area_id: area1 + relation: via - id: device3 name: Device 3 area_id: area2 + relation: via host: diff --git a/tests/unit_tests/fixtures/core/config/valid_area_device.yaml b/tests/unit_tests/fixtures/core/config/valid_area_device.yaml index fc978945864..2afd2aa521c 100644 --- a/tests/unit_tests/fixtures/core/config/valid_area_device.yaml +++ b/tests/unit_tests/fixtures/core/config/valid_area_device.yaml @@ -7,5 +7,6 @@ esphome: - id: test_device name: Test Device area_id: bedroom_area + relation: via host: diff --git a/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml index 42e16231a57..7ab2d4546d1 100644 --- a/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml +++ b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml @@ -3,8 +3,10 @@ esphome: devices: - id: device1 name: "Device 1" + relation: via - id: device2 name: "Device 2" + relation: via esp32: board: esp32dev diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 6f4a1ba83a8..219c7db0232 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -8,22 +8,31 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome.automation import ( + ACTION_REGISTRY, CONF_CHOICE_ID, CONF_CHOOSE, ApplyAction, + ApplyBytes, ApplyCall, ApplyCondition, ApplyField, CallbackAutomation, + ForCondition, LambdaAction, + LambdaCondition, + NotCondition, RepeatAction, + ResumeComponentAction, + StatelessLambdaCondition, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + WaitUntilAction, _shadow_iteration, build_action, build_callback_automation, build_callback_automations, + build_condition, build_parent_callback_automation, build_trigger_automations, build_trigger_callback, @@ -32,8 +41,10 @@ from esphome.automation import ( if_action_to_code, literal_with_length, maybe_simple_id, + new_lambda_pvariable, parent_ref, progmem_bytes, + register_action, register_apply_action, register_apply_condition, register_bare_action, @@ -45,7 +56,9 @@ from esphome.automation import ( string_ref_literal, templatable_bytes, validate_action, + validate_automation, validate_no_deferring_actions, + validate_potentially_or_condition, validate_synchronous_automation, ) import esphome.codegen as cg @@ -59,10 +72,14 @@ from esphome.const import ( CONF_ELSE, CONF_ID, CONF_THEN, + CONF_TIME, + CONF_TIMEOUT, CONF_TYPE_ID, + CONF_UPDATE_INTERVAL, ) from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda -from esphome.cpp_generator import MockObj, RawExpression +from esphome.cpp_generator import LambdaExpression, MockObj, RawExpression +from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.types import ConfigType from esphome.util import Registry, RegistryEntry @@ -769,8 +786,9 @@ class MockCodegen(NamedTuple): new_pvariable: MagicMock register_parented: AsyncMock add_global: MagicMock + add_global_after_includes: MagicMock add: MagicMock - calls: MagicMock # new_pvariable and add_global attached, to check their order + calls: MagicMock # the global emitters and new_pvariable attached, to check their order @pytest.fixture @@ -832,6 +850,9 @@ def mock_cg() -> Generator[MockCodegen]: "esphome.codegen.register_parented", new_callable=AsyncMock ) as register_parented, patch("esphome.cpp_generator.add_global") as add_global, + patch( + "esphome.cpp_generator.add_global_after_includes" + ) as add_global_after_includes, patch("esphome.codegen.add") as add, ): get_variable.return_value = PARENT_OBJ @@ -839,8 +860,15 @@ def mock_cg() -> Generator[MockCodegen]: calls = MagicMock() calls.attach_mock(new_pvariable, "new_pvariable") calls.attach_mock(add_global, "add_global") + calls.attach_mock(add_global_after_includes, "add_global_after_includes") yield MockCodegen( - get_variable, new_pvariable, register_parented, add_global, add, calls + get_variable, + new_pvariable, + register_parented, + add_global, + add_global_after_includes, + add, + calls, ) @@ -968,7 +996,7 @@ async def _run_apply_condition( def _apply_definition(mock_cg: MockCodegen) -> str: - return str(mock_cg.add_global.call_args.args[0]) + return str(mock_cg.add_global_after_includes.call_args.args[0]) @pytest.mark.asyncio @@ -982,8 +1010,16 @@ async def test_register_apply_action_entry( action_id, template_arg = mock_cg.new_pvariable.call_args.args assert action_id == ID("obj_1") assert str(template_arg) == "" - # The definition must precede the storage line that names the function. - assert [c[0] for c in mock_cg.calls.mock_calls] == ["add_global", "new_pvariable"] + # The declaration must precede the storage line that names the function; the body + # goes after the user includes. + assert [c[0] for c in mock_cg.calls.mock_calls] == [ + "add_global", + "add_global_after_includes", + "new_pvariable", + ] + assert str(mock_cg.add_global.call_args.args[0]) == ( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x);" + ) assert _apply_definition(mock_cg).startswith( "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" ) @@ -1281,6 +1317,172 @@ async def test_apply_string_ref_literal_stays_in_flash_on_esp8266( assert f"::{PARENT_OBJ}->set_payload({rendered});" in _apply_definition(mock_cg) +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +@pytest.mark.parametrize( + "body", ["x *= 2;\nreturn {0x01, x};", "return std::vector{++x};"] +) +async def test_apply_bytes_lambda_is_a_vector_in_a_block( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + platform: str, + body: str, +) -> None: + """A lambda payload is called once into a scoped vector and passed as data and size; + it takes the trigger args by their own types and is never reduced, so it may modify them.""" + fields = (ApplyBytes("data", "send({})"),) + config = {"data": Lambda(body)} + await _run_apply_action( + registries, fields, config, args=[(cg.uint8, "x")], platform=platform + ) + text = _apply_definition(mock_cg) + assert text.startswith( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) + assert ( + "const std::vector esphome__bytes = [](uint8_t x) -> std::vector {" + in text + ) + assert "}(x);" in text + assert ( + f"::{PARENT_OBJ}->send(esphome__bytes.data(), esphome__bytes.size());" in text + ) + assert "progmem_memcpy" not in text + + +@pytest.mark.asyncio +async def test_apply_bytes_keeps_a_mutable_reference_arg( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A reference trigger arg such as climate's `ClimateCall &` is passed through as is.""" + fields = (ApplyBytes("data", "send({})"),) + config = {"data": Lambda("return {0x01};")} + call_ref = cg.MockObj("ClimateCall").operator("ref") + await _run_apply_action(registries, fields, config, args=[(call_ref, "x")]) + text = _apply_definition(mock_cg) + assert text.startswith("static void esphome__obj_1__fn(ClimateCall & x) {") + assert "[](ClimateCall & x) -> std::vector {" in text + assert "}(x);" in text + + +@pytest.mark.asyncio +async def test_apply_bytes_constant_is_a_shared_table( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A constant payload is the shared PROGMEM table and its length, named after the action.""" + CORE.config = {} + fields = (ApplyBytes("data", "send({})"),) + await _run_apply_action(registries, fields, {"data": [0xA1, 0x02]}) + assert f"::{PARENT_OBJ}->send(::esphome__my_apply__bytes, 2);" in _apply_definition( + mock_cg + ) + + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields, {"data": b""}) + assert f"::{PARENT_OBJ}->send(nullptr, 0);" in _apply_definition(mock_cg) + + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields, {}) + assert "send(" not in _apply_definition(mock_cg) + + with pytest.raises(EsphomeError, match="maximum is 65535"): + await _run_apply_action(registries, fields, {"data": bytes(0x10000)}) + + +@pytest.mark.asyncio +async def test_apply_bytes_constant_is_copied_out_of_flash_on_esp8266( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """On ESP8266 a constant goes through a stack buffer, or straight to a flash-aware target.""" + CORE.config = {} + fields = (ApplyBytes("data", "send({})", stack=128),) + await _run_apply_action( + registries, fields, {"data": [0xA1, 0x02]}, platform="esp8266" + ) + text = _apply_definition(mock_cg) + lines = [ + "uint8_t esphome__bytes[2];", + "esphome::progmem_memcpy(esphome__bytes, ::esphome__my_apply__bytes, 2);", + f"::{PARENT_OBJ}->send(esphome__bytes, 2);", + ] + positions = [text.index(line) for line in lines] + assert positions == sorted(positions) + + # Past the stack budget the copy goes to the heap + mock_cg.new_pvariable.reset_mock() + fields = (ApplyBytes("data", "send({})", stack=1),) + await _run_apply_action( + registries, fields, {"data": [0xA1, 0x02]}, platform="esp8266" + ) + text = _apply_definition(mock_cg) + assert "auto esphome__bytes = std::make_unique(2);" in text + assert ( + "esphome::progmem_memcpy(esphome__bytes.get(), ::esphome__my_apply__bytes, 2);" + in text + ) + assert f"::{PARENT_OBJ}->send(esphome__bytes.get(), 2);" in text + + mock_cg.new_pvariable.reset_mock() + fields = (ApplyBytes("data", "send({})", progmem_target="send_progmem({})"),) + await _run_apply_action( + registries, fields, {"data": [0xA1, 0x02]}, platform="esp8266" + ) + text = _apply_definition(mock_cg) + assert f"::{PARENT_OBJ}->send_progmem(::esphome__my_apply__bytes, 2);" in text + assert "progmem_memcpy" not in text + + # An empty payload never touches flash but still passes a non-null buffer + mock_cg.new_pvariable.reset_mock() + fields = (ApplyBytes("data", "send({})"),) + await _run_apply_action(registries, fields, {"data": []}, platform="esp8266") + text = _apply_definition(mock_cg) + assert "uint8_t esphome__bytes[1];" in text + assert f"::{PARENT_OBJ}->send(esphome__bytes, 0);" in text + assert "progmem_memcpy" not in text + + +@pytest.mark.asyncio +async def test_apply_bytes_beside_other_fields_and_call_object( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + CORE.config = {} + fields = ( + ApplyField("channel", "set_channel", cg.uint8), + ApplyBytes("data", "write({})"), + ) + await _run_apply_action( + registries, fields, {"channel": 3, "data": [1]}, call="make_call" + ) + text = _apply_definition(mock_cg) + lines = [ + f"auto apply_call = ::{PARENT_OBJ}->make_call();", + "apply_call.set_channel(3);", + "apply_call.write(::esphome__my_apply__bytes, 1);", + "apply_call.perform();", + ] + positions = [text.index(line) for line in lines] + assert positions == sorted(positions) + + +def test_apply_bytes_checks_its_shape(registries: tuple[Registry, Registry]) -> None: + # Escaped braces are not placeholders + assert ApplyBytes("data", "send({{}}, {})").target == "send({{}}, {})" + with pytest.raises(ValueError, match="needs one {}"): + ApplyBytes("data", "send({{}})") + with pytest.raises(ValueError, match="needs one {}"): + ApplyBytes("data", "send()") + with pytest.raises(ValueError, match="needs one {}"): + ApplyBytes("data", "send({})", progmem_target="send_progmem({}, {})") + with pytest.raises(ValueError, match="'dat' is not in the schema"): + register_apply_action( + "my.bad", + cv.Schema( + {cv.Required(CONF_ID): cv.string, cv.Required("data"): cv.string} + ), + ApplyBytes("dat", "send({})"), + ) + + @pytest.mark.asyncio async def test_register_apply_condition_predicate( registries: tuple[Registry, Registry], mock_cg: MockCodegen @@ -1830,3 +2032,148 @@ async def test_nested_repeat_lambda_parameters_are_distinct() -> None: in text ) assert "uint32_t iteration, uint32_t iteration)" not in text + + +def test_maybe_simple_id_schema_extract() -> None: + validator = maybe_simple_id({cv.Required(CONF_ID): cv.string}) + _, key = validator(SCHEMA_EXTRACT) + assert key == CONF_ID + + +def test_register_action_without_synchronous_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + with patch.dict(ACTION_REGISTRY): + register_action("test.unspecified_sync", LambdaAction, cv.Schema({}))( + MagicMock() + ) + entry = ACTION_REGISTRY["test.unspecified_sync"] + assert entry.synchronous is False + assert "test.unspecified_sync" in caplog.text + assert "missing the synchronous= parameter" in caplog.text + + +@pytest.mark.parametrize("capture", ["", "="]) +@pytest.mark.parametrize("with_template_arg", [True, False]) +def test_new_lambda_pvariable(capture: str, with_template_arg: bool) -> None: + lambda_expr = LambdaExpression(["return true;"], [], capture) + id_obj = ID("lam", is_declaration=True, type=LambdaCondition) + new_lambda_pvariable( + id_obj, + lambda_expr, + StatelessLambdaCondition, + TEMPLATE_ARG if with_template_arg else None, + ) + text = CORE.cpp_main_section + expected_class = "StatelessLambdaCondition" if capture == "" else "LambdaCondition" + assert f"new(lam) {expected_class}" in text + assert ("<>(" in text) == with_template_arg + # The caller's id is left alone + assert id_obj.type is LambdaCondition + + +def test_validate_potentially_or_condition_single() -> None: + result = validate_potentially_or_condition({"lambda": Lambda("return true;")}) + assert "lambda" in result + + +def test_validate_potentially_or_condition_list() -> None: + result = validate_potentially_or_condition([{"lambda": Lambda("return true;")}]) + assert "or" in result + + +def test_validate_automation_list_of_invalid_actions_reports_both() -> None: + with pytest.raises(cv.MultipleInvalid) as exc_info: + validate_automation()([1]) + messages = [str(err) for err in exc_info.value.errors] + assert any("Action must consist of key-value mapping" in m for m in messages) + assert any("expected a dictionary" in m for m in messages) + + +def test_validate_automation_list_unknown_action_reports_automation_error() -> None: + with pytest.raises(cv.Invalid, match="no_such_action"): + validate_automation()([{CONF_THEN: [{"no_such_action": {}}]}]) + + +def test_validate_automation_non_list_non_dict() -> None: + with pytest.raises(cv.Invalid): + validate_automation()("not an automation") + + +def test_validate_automation_schema_extract() -> None: + schema = validate_automation({cv.Optional("extra"): cv.string})(SCHEMA_EXTRACT) + assert "extra" in [str(key) for key in schema.schema] + + +def test_validate_automation_single_rejects_multiple() -> None: + with pytest.raises(cv.Invalid, match="only a single automation"): + validate_automation(single=True)( + [{CONF_THEN: [{"delay": "1s"}]}, {CONF_THEN: [{"delay": "2s"}]}] + ) + + +def _lambda_condition(id_: str) -> ConfigType: + return { + "lambda": Lambda("return true;"), + CONF_TYPE_ID: ID(id_, is_declaration=True, type=LambdaCondition), + } + + +@pytest.mark.asyncio +async def test_not_condition_wraps_inner_condition() -> None: + CORE.config = {} + config: ConfigType = { + "not": _lambda_condition("inner"), + CONF_TYPE_ID: ID("outer", is_declaration=True, type=NotCondition), + } + await build_condition(config, TEMPLATE_ARG, []) + assert "new(outer) NotCondition<>(inner);" in CORE.cpp_main_section + + +@pytest.mark.asyncio +async def test_for_condition_sets_time() -> None: + CORE.config = {} + CORE.component_ids.add("for_cond") + config: ConfigType = { + "for": { + CONF_TIME: 1000, + CONF_CONDITION: _lambda_condition("inner"), + }, + CONF_TYPE_ID: ID("for_cond", is_declaration=True, type=ForCondition), + } + await build_condition(config, TEMPLATE_ARG, []) + assert "for_cond->set_time(" in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.parametrize("with_timeout", [True, False]) +async def test_wait_until_action_timeout_is_optional(with_timeout: bool) -> None: + CORE.config = {} + CORE.component_ids.add("wait") + body: ConfigType = {CONF_CONDITION: _lambda_condition("inner")} + if with_timeout: + body[CONF_TIMEOUT] = 500 + config: ConfigType = { + "wait_until": body, + CONF_TYPE_ID: ID("wait", is_declaration=True, type=WaitUntilAction), + } + await build_action(config, TEMPLATE_ARG, []) + assert ("wait->set_timeout_value(" in CORE.cpp_main_section) == with_timeout + + +@pytest.mark.asyncio +@pytest.mark.parametrize("with_interval", [True, False]) +async def test_component_resume_action_update_interval_is_optional( + with_interval: bool, +) -> None: + CORE.config = {} + cg.new_Pvariable(ID("comp", is_declaration=True, type=cg.PollingComponent)) + body: ConfigType = {CONF_ID: ID("comp", type=cg.PollingComponent)} + if with_interval: + body[CONF_UPDATE_INTERVAL] = 250 + config: ConfigType = { + "component.resume": body, + CONF_TYPE_ID: ID("resume", is_declaration=True, type=ResumeComponentAction), + } + await build_action(config, TEMPLATE_ARG, []) + assert ("resume->set_update_interval(" in CORE.cpp_main_section) == with_interval diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index fe4bf197157..7315ba6d906 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -22,6 +22,7 @@ from esphome.bundle import ( _add_bytes_to_tar, _default_target_dir, _find_used_secret_keys, + _validate_tar_members, add_bundle_file, add_secret_scan_dir, extract_bundle, @@ -1872,3 +1873,81 @@ def test_bundle_round_trip_with_secrets(tmp_path: Path) -> None: manifest = read_bundle_manifest(bundle_path) assert manifest.has_secrets is True + + +def test_add_secret_scan_dir_relative_path(tmp_path: Path) -> None: + """A relative scan directory is taken as relative to the config directory.""" + config_dir = _setup_config_dir(tmp_path) + (config_dir / "secrets.yaml").write_text("pw: hunter2\nunused: nope\n") + package_dir = config_dir / "pkg" + package_dir.mkdir() + (package_dir / "base.yml").write_text("password: !secret pw\n") + add_secret_scan_dir(Path("pkg")) + + result = ConfigBundleCreator({}).create_bundle() + + assert result.manifest[ManifestKey.HAS_SECRETS] is True + with tarfile.open(fileobj=io.BytesIO(result.data), mode="r:gz") as tar: + secrets_data = tar.extractfile("secrets.yaml").read().decode() + assert "hunter2" in secrets_data + assert "unused" not in secrets_data + + +def test_discover_files_skips_system_includes_c(tmp_path: Path) -> None: + """System includes in includes_c are not local files.""" + _setup_config_dir(tmp_path) + config: dict[str, Any] = {"esphome": {"includes_c": [""]}} + + files = ConfigBundleCreator(config).discover_files() + + assert [f.path for f in files] == ["test.yaml"] + + +def test_discover_files_ignores_missing_path_and_scalars(tmp_path: Path) -> None: + """Absolute Path values that are not files and non-path values are ignored.""" + _setup_config_dir(tmp_path) + config: dict[str, Any] = { + "missing": tmp_path / "does_not_exist.bin", + "number": 5, + "flag": True, + } + + files = ConfigBundleCreator(config).discover_files() + + assert [f.path for f in files] == ["test.yaml"] + + +def test_build_filtered_secrets_skips_files_outside_config_dir( + tmp_path: Path, +) -> None: + _setup_config_dir(tmp_path) + outside = tmp_path / "outside_secrets.yaml" + outside.write_text("k: v\n") + creator = ConfigBundleCreator({}) + creator._secrets_paths.add(outside.resolve()) + + assert creator._build_filtered_secrets({"k"}) == {} + + +def test_validate_tar_members_rejects_escape_through_symlinked_dir( + tmp_path: Path, +) -> None: + """A member resolving outside the target through an existing symlink is refused.""" + target = tmp_path / "out" + target.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + try: + (target / "link").symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not available") + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + _add_bytes_to_tar(tar, "link/file.txt", b"data") + buf.seek(0) + + with ( + tarfile.open(fileobj=buf, mode="r") as tar, + pytest.raises(EsphomeError, match="outside target"), + ): + _validate_tar_members(tar, target.resolve()) diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index e53016dfc39..7304a001dbf 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -6,6 +6,8 @@ from unittest.mock import patch import pytest from esphome.config_helpers import ( + Extend, + Remove, filter_source_files_from_defines, filter_source_files_from_platform, frameworks_for_platforms, @@ -172,3 +174,38 @@ def test_filter_source_files_from_defines() -> None: mock_core.defines = set() assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"] + + +def test_extend_str_repr_and_equality() -> None: + ext = Extend("my_id") + + assert str(ext) == "!extend my_id" + assert repr(ext) == "Extend(my_id)" + assert ext == Extend("my_id") + assert ext != Extend("other") + assert ext != "my_id" + + +def test_remove_str_repr_and_equality() -> None: + rem = Remove("my_id") + + assert str(rem) == "!remove my_id" + assert repr(rem) == "Remove(my_id)" + assert rem == Remove("my_id") + assert rem != Remove("other") + assert rem != "my_id" + + +def test_filter_source_files_from_platform_unknown_pair() -> None: + """A target platform/framework pair with no PlatformFramework yields no exclusions.""" + mock_core_data = { + KEY_CORE: { + KEY_TARGET_PLATFORM: "not_a_platform", + KEY_TARGET_FRAMEWORK: "arduino", + } + } + with patch("esphome.config_helpers.CORE.data", mock_core_data): + func = filter_source_files_from_platform( + {"a.cpp": {PlatformFramework.ESP32_ARDUINO}} + ) + assert func() == [] diff --git a/tests/unit_tests/test_coroutine.py b/tests/unit_tests/test_coroutine.py index 0a8fb59cb81..8467ebcadb4 100644 --- a/tests/unit_tests/test_coroutine.py +++ b/tests/unit_tests/test_coroutine.py @@ -1,10 +1,18 @@ """Tests for the coroutine module.""" +from collections.abc import AsyncGenerator, Generator import contextvars +from typing import Any import pytest -from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority +from esphome.coroutine import ( + CoroPriority, + FakeEventLoop, + _Task, + coroutine, + coroutine_with_priority, +) def test_coro_priority_enum_values() -> None: @@ -262,3 +270,77 @@ def test_context_inherits_ambient_value_at_schedule_time() -> None: my_var.reset(token) assert seen == {"value": "ambient"} + + +def test_coroutine_does_not_rewrap_esphome_coroutine() -> None: + @coroutine + def wrapped() -> Generator[None, None, None]: + yield + + assert coroutine(wrapped) is wrapped + + +def test_coroutine_rejects_async_generator() -> None: + async def agen() -> AsyncGenerator[int, None]: + yield 1 + + with pytest.raises(ValueError, match="Async generator functions are not allowed"): + coroutine(agen) + + +def test_coroutine_flattens_awaitable_and_generator_yields() -> None: + results: list[Any] = [] + + async def native() -> int: + return 3 + + def inner() -> Generator[None, None, int]: + yield + return 4 + + @coroutine + def outer() -> Generator[Any, Any, None]: + results.append((yield native())) + results.append((yield inner())) + + loop = FakeEventLoop() + loop.add_job(outer) + loop.flush_tasks() + + assert results == [3, 4] + + +def test_task_equality_and_ordering() -> None: + ctx = contextvars.copy_context() + first = _Task(0.0, 0, iter(()), None, ctx) + same = first.with_priority(0.0) + higher = _Task(1.0, 1, iter(()), None, ctx) + + assert first == same + assert (first != same) is False + assert first != higher + assert higher < first + + +def test_add_job_rejects_coroutine_object() -> None: + async def native() -> None: + """Never awaited; only used to build a coroutine object.""" + + coro_obj = native() + try: + with pytest.raises(ValueError, match="not coroutine objects"): + FakeEventLoop().add_job(coro_obj) + finally: + coro_obj.close() + + +def test_flush_tasks_detects_deadlock() -> None: + def forever() -> Generator[None, None, None]: + while True: + yield + + loop = FakeEventLoop() + loop.add_job(forever) + + with pytest.raises(RuntimeError, match="Circular dependency"): + loop.flush_tasks() diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index e78035f1f6c..f37cffd56a6 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -866,7 +866,8 @@ class TestSharedProgmemArray: c = cg.shared_progmem_array("table", ct.uint8, [4]) assert a is b assert str(a) != str(c) - assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2 + late = CORE.global_statements_after_includes + assert sum("PROGMEM" in str(st) for st in late) == 2 assert not any("PROGMEM" in str(st) for st in CORE.main_statements) def test_share_false_always_emits_a_new_array(self) -> None: @@ -874,7 +875,8 @@ class TestSharedProgmemArray: a = cg.shared_progmem_array("table", ct.uint8, [1], share=False) b = cg.shared_progmem_array("table", ct.uint8, [1], share=False) assert str(a) != str(b) - assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2 + late = CORE.global_statements_after_includes + assert sum("PROGMEM" in str(st) for st in late) == 2 def test_is_static_pointer_only_for_placement_new(self) -> None: CORE.config = {} @@ -910,7 +912,9 @@ class TestSharedProgmemArray: CORE.config = {} cg.shared_progmem_array("table", ct.uint8, [1], constexpr=False) (statement,) = ( - str(st) for st in CORE.global_statements if "PROGMEM" in str(st) + str(st) + for st in CORE.global_statements_after_includes + if "PROGMEM" in str(st) ) assert statement.startswith( "ESPHOME_FLASH_CONSTINIT static uint8_t const table[] PROGMEM = {1}" @@ -948,7 +952,7 @@ class TestProgmemString: assert a is b assert str(a) == "progmem_str" assert str(c) == "progmem_str_2" - assert [str(st) for st in CORE.global_statements] == [ + assert [str(st) for st in CORE.global_statements_after_includes] == [ 'static constexpr char progmem_str[] PROGMEM = "level";', 'static constexpr char progmem_str_2[] PROGMEM = "say \\042hi\\042";', ] @@ -957,6 +961,7 @@ class TestProgmemString: self._set_platform(PLATFORM_ESP32) assert str(cg.shared_progmem_string('say "hi"')) == '"say \\042hi\\042"' assert not CORE.global_statements + assert not CORE.global_statements_after_includes def test_extern_progmem_array_is_an_externally_linked_global() -> None: @@ -967,6 +972,22 @@ def test_extern_progmem_array_is_an_externally_linked_global() -> None: ] +def test_static_function_declares_early_and_defines_after_includes() -> None: + fn = cg.static_function("esphome__act__fn", ct.void, [(ct.int32, "x")], ["return;"]) + assert str(fn) == "esphome__act__fn" + assert [str(st) for st in CORE.global_statements] == [ + "static void esphome__act__fn(int32_t x);" + ] + assert [str(st) for st in CORE.global_statements_after_includes] == [ + "static void esphome__act__fn(int32_t x) {\n return;\n}" + ] + + +def test_add_global_after_includes_rejects_non_statements() -> None: + with pytest.raises(ValueError, match="must be expression or statement"): + CORE.add_global_after_includes("not a statement") + + class TestEnumOptions: def test_class_enum_with_prefix(self) -> None: mode = cg.MockObj("esphome::foo", "::").enum("Mode", is_class=True) diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index cda5ea89ad0..5be0d0c7068 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,5 +1,6 @@ from collections.abc import Awaitable, Callable import importlib.util +import inspect import logging from pathlib import Path import sys @@ -362,3 +363,43 @@ def test_add_optional_skips_none(monkeypatch: pytest.MonkeyPatch) -> None: assert ch.add_optional(setter, None) is None setter.assert_not_called() add_mock.assert_not_called() + + +def _register_component_mocks(monkeypatch: pytest.MonkeyPatch) -> tuple[Mock, Mock]: + base = Mock() + base.__str__ = lambda self: "foo.bar" + base.type = Mock() + base.type.__str__ = lambda self: "foo::Bar" + var = Mock(base=base) + app_mock = Mock(register_component_=Mock(return_value=var)) + monkeypatch.setattr(ch, "App", app_mock) + monkeypatch.setattr(ch, "CORE", Mock(component_ids=["foo.bar"], data={})) + monkeypatch.setattr(ch, "add", Mock()) + return var, app_mock + + +@pytest.mark.asyncio +async def test_register_component_stack_error_warns_and_skips_source( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + var, app_mock = _register_component_mocks(monkeypatch) + monkeypatch.setattr(inspect, "stack", Mock(side_effect=IndexError("no stack"))) + + with caplog.at_level(logging.WARNING): + assert await ch.register_component(var, {}) is var + + assert "Error while finding name of component" in caplog.text + app_mock.register_component_.assert_called_once_with(var) + + +@pytest.mark.asyncio +async def test_register_component_without_module_has_no_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + var, app_mock = _register_component_mocks(monkeypatch) + monkeypatch.setattr(inspect, "stack", Mock(return_value=[(None,), (None,)])) + monkeypatch.setattr(inspect, "getmodule", lambda _: None) + + assert await ch.register_component(var, {}) is var + + app_mock.register_component_.assert_called_once_with(var) diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 67da0f4f6df..1498ff5dc90 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -254,6 +254,20 @@ target_link_libraries(${COMPONENT_LIB} INTERFACE ) +def test_generate_cmakelists_txt_optimisation_flags_are_private( + tmp_component: IDFComponent, +) -> None: + # A library built with -O2 must not change how the components that use it are compiled + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + tmp_component.data = {"build": {"flags": ["-O2", "-DTEST"]}} + + content = generate_cmakelists_txt(tmp_component) + assert 'target_compile_options(${COMPONENT_LIB} PUBLIC\n "-DTEST"\n)' in content + assert 'target_compile_options(${COMPONENT_LIB} PRIVATE\n "-O2"\n)' in content + + def test_generate_cmakelists_txt_uses_forward_slashes_on_windows( tmp_component, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -367,6 +381,56 @@ def test_generate_cmakelists_txt_references_project_managed_components_variable( assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content +def test_generate_cmakelists_txt_uses_declared_src_dir( + tmp_component: IDFComponent, +) -> None: + """A library.json srcDir is used as is instead of probing src/Src/.""" + lib_dir = tmp_component.path / "lib" + lib_dir.mkdir() + (lib_dir / "main.c").write_text("int main() {}") + tmp_component.data = {"build": {"srcDir": "lib"}} + + content = generate_cmakelists_txt(tmp_component) + + assert "SRCS" in content + assert "main.c" in content + assert "INCLUDE_DIRS" in content + + +def test_generate_cmakelists_txt_without_include_dirs( + tmp_component: IDFComponent, +) -> None: + """No INCLUDE_DIRS line when none of the include directories exist.""" + tmp_component.data = {"build": {"srcDir": "missing"}} + + content = generate_cmakelists_txt(tmp_component) + + assert "INCLUDE_DIRS" not in content + + +def test_generate_idf_component_yml_with_several_dependencies( + tmp_component: IDFComponent, tmp_path: Path +) -> None: + """Every dependency lands under one shared dependencies section.""" + deps = [] + for name in ("dep_a", "dep_b"): + dep = IDFComponent(name, "1.0", source=URLSource("http://dummy.com")) + dep.path = tmp_path / name + deps.append(dep) + tmp_component.dependencies = deps + tmp_component.data = {} + + result = generate_idf_component_yml(tmp_component) + + assert result == ( + "dependencies:\n" + " dep_a:\n" + f" override_path: {deps[0].path}\n" + " dep_b:\n" + f" override_path: {deps[1].path}\n" + ) + + def test_generate_idf_component_yml_basic(tmp_component): tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}} result = generate_idf_component_yml(tmp_component) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index c28944024bc..ef7931cbb34 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -2055,3 +2055,28 @@ def test_perform_ota_tcp_send_network_error_unchanged(mock_socket: Mock) -> None ] with pytest.raises(espota2.OTANetworkError, match="sending frame"): espota2.perform_ota(mock_socket, None, io.BytesIO(b"x" * 100), "test.bin") + + +@pytest.mark.parametrize( + ("dashboard", "expect_device_hint"), [(False, True), (True, False)] +) +def test_run_ota_impl_resolve_failure_hint( + mock_resolve_ip: Mock, + firmware_file: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + dashboard: bool, + expect_device_hint: bool, +) -> None: + """The --device hint is only useful on the command line, not the dashboard.""" + from esphome.core import CORE + + monkeypatch.setattr(CORE, "dashboard", dashboard) + mock_resolve_ip.side_effect = EsphomeError("no such host") + + with caplog.at_level(logging.ERROR), pytest.raises(espota2.OTAError): + espota2.run_ota_impl_("test.local", 3232, None, firmware_file) + + assert "Is it connected to WiFi?" in caplog.text + assert ("try --device " in caplog.text) is expect_device_hint + assert "static IP address" in caplog.text diff --git a/tests/unit_tests/test_final_validate.py b/tests/unit_tests/test_final_validate.py index 834dc92ffbd..65199686676 100644 --- a/tests/unit_tests/test_final_validate.py +++ b/tests/unit_tests/test_final_validate.py @@ -2,7 +2,7 @@ import pytest -from esphome import final_validate as fv +from esphome import config_validation as cv, final_validate as fv from esphome.config import Config from esphome.const import CONF_ID from esphome.core import ID @@ -45,3 +45,30 @@ def test_non_dict_parent_raises_type_error() -> None: config.declare_ids.append((list_id, ["listed", 0])) with pytest.raises(TypeError): fv.get_declaring_config(ID("in_list"), config) + + +@pytest.mark.parametrize("pre_built", [False, True]) +def test_id_declaration_match_schema_validates_declaration(pre_built: bool) -> None: + config, hub_id, _ = _config() + schema = {cv.Required("mode"): cv.string} + if pre_built: + schema = cv.Schema(schema, extra=cv.ALLOW_EXTRA) + validator = fv.id_declaration_match_schema(schema) + token = fv.full_config.set(config) + try: + result = validator(ID("my_hub")) + finally: + fv.full_config.reset(token) + + assert result["mode"] == "fast" + + +def test_id_declaration_match_schema_rejects_mismatch() -> None: + config, _, _ = _config() + validator = fv.id_declaration_match_schema({cv.Required("mode"): cv.one_of("slow")}) + token = fv.full_config.set(config) + try: + with pytest.raises(cv.Invalid): + validator(ID("my_hub")) + finally: + fv.full_config.reset(token) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 0f7e0339c92..027d328e65e 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -3233,3 +3233,72 @@ def test_resolve_symlink_stub_returns_none_when_read_bytes_raises( result = git.resolve_symlink_stub(repo_dir, stub) assert result is None + + +def test_git_file_git_url() -> None: + gf = git.GitFile("github.com", "esphome", "repo", "file.yaml") + + assert gf.git_url == "https://github.com/esphome/repo.git" + + +@pytest.mark.parametrize( + ("domain", "expected"), + [ + ( + "codeberg.org", + "https://codeberg.org/owner/repo/raw/commit/main/dir/file.yaml", + ), + ( + "github.com", + "https://raw.githubusercontent.com/owner/repo/main/dir/file.yaml", + ), + ("gitlab.com", "https://gitlab.com/owner/repo/-/raw/main/dir/file.yaml"), + ], +) +def test_git_file_raw_url(domain: str, expected: str) -> None: + gf = git.GitFile(domain, "owner", "repo", "dir/file.yaml", ref="main") + + assert gf.raw_url == expected + + +def test_git_file_raw_url_requires_ref() -> None: + gf = git.GitFile("github.com", "owner", "repo", "file.yaml") + + with pytest.raises(ValueError, match="URL has no ref"): + _ = gf.raw_url + + +def test_git_file_raw_url_unsupported_domain() -> None: + gf = git.GitFile("example.com", "owner", "repo", "file.yaml", ref="main") + + with pytest.raises(NotImplementedError, match="example.com not supported"): + _ = gf.raw_url + + +def test_git_file_from_shorthand_full() -> None: + gf = git.GitFile.from_shorthand("gitlab://owner/repo/sub/file.yaml@v1.0?query") + + assert gf == git.GitFile( + "gitlab.com", "owner", "repo", "sub/file.yaml", ref="v1.0", query="query" + ) + + +def test_git_file_from_shorthand_without_ref_or_query() -> None: + gf = git.GitFile.from_shorthand("codeberg://owner/repo/file.yaml") + + assert gf == git.GitFile("codeberg.org", "owner", "repo", "file.yaml") + + +def test_git_file_from_shorthand_requires_string() -> None: + with pytest.raises(ValueError, match="must be a string"): + git.GitFile.from_shorthand(123) + + +def test_git_file_from_shorthand_bad_format() -> None: + with pytest.raises(ValueError, match="not in expected"): + git.GitFile.from_shorthand("not a url") + + +def test_git_file_from_shorthand_unknown_domain() -> None: + with pytest.raises(ValueError, match="Unknown git domain bitbucket"): + git.GitFile.from_shorthand("bitbucket://owner/repo/file.yaml") diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 74515e9d4c4..b4c34f58d05 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,23 +1,32 @@ """Unit tests for esphome.loader module.""" import ast +import logging from pathlib import Path import sys import textwrap +from types import ModuleType from unittest.mock import MagicMock, patch import pytest from esphome.component_aliases import COMPONENT_ALIASES from esphome.loader import ( + _COMPONENT_CACHE, AliasMeta, ComponentManifest, + ComponentMetaFinder, + FileResource, _AliasFinder, + _AliasLoader, _build_alias_map, + _install_alias_finder, _read_aliases, _replace_component_manifest, + clear_component_meta_finders, get_alias_metadata, get_component, + install_meta_finder, ) from tests.testing_helpers import ComponentManifestOverride @@ -685,3 +694,248 @@ def caplog_at_warning(): finally: logger.removeHandler(handler) logger.setLevel(prev_level) + + +def test_file_resource_path_yields_real_file() -> None: + """``FileResource.path`` resolves a packaged resource to a real path.""" + resource = FileResource("esphome.core", "config.py") + with resource.path() as path: + assert path.is_file() + assert path.name == "config.py" + + +def test_component_manifest_metadata_properties() -> None: + """Metadata properties read their module-level declarations.""" + instance_type = object() + module = ModuleType("esphome.components.fake") + module.CODEOWNERS = ["@owner"] + module.ALIASES = ["legacy"] + module.ALIAS_REMOVAL_VERSION = "2099.1.0" + module.INSTANCE_TYPE = instance_type + manifest = ComponentManifest(module) + assert manifest.codeowners == ["@owner"] + assert manifest.aliases == ["legacy"] + assert manifest.alias_removal_version == "2099.1.0" + assert manifest.instance_type is instance_type + + +def test_component_manifest_metadata_defaults() -> None: + """Missing declarations fall back to their defaults.""" + manifest = ComponentManifest(ModuleType("esphome.components.fake")) + assert manifest.codeowners == [] + assert manifest.aliases == [] + assert manifest.alias_removal_version is None + assert manifest.instance_type is None + + +def test_component_manifest_resources_recursive_skips_non_file_entries() -> None: + """Nested directories inside a walked subdirectory are ignored.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + manifest = ComponentManifest(mock_module, recursive_sources=True) + + group = _mock_dir( + "group", [_mock_dir("deeper", []), _mock_file("real.cpp")], has_init=False + ) + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = [group] + mock_files_func.return_value = pkg + names = [r.resource for r in manifest.resources] + + assert names == ["group/real.cpp"] + + +@pytest.fixture +def meta_finder_root(tmp_path: Path) -> Path: + """A components directory holding one real component package.""" + _write_component(tmp_path, "metacomp", "VALUE = 1\n") + return tmp_path + + +def test_component_meta_finder_finds_component(meta_finder_root: Path) -> None: + """A direct component present on disk resolves to a spec.""" + finder = ComponentMetaFinder(meta_finder_root) + spec = finder.find_spec("esphome.components.metacomp", None) + assert spec is not None + assert spec.name == "esphome.components.metacomp" + + +def test_component_meta_finder_returns_none_when_not_found( + meta_finder_root: Path, +) -> None: + """A component absent from the directory is left to other finders.""" + finder = ComponentMetaFinder(meta_finder_root) + assert finder.find_spec("esphome.components.absentcomp", None) is None + + +def test_component_meta_finder_ignores_other_names(meta_finder_root: Path) -> None: + """Non-component names and platform submodules are not handled.""" + finder = ComponentMetaFinder(meta_finder_root) + assert finder.find_spec("os.path", None) is None + assert finder.find_spec("esphome.components.metacomp.sensor", None) is None + + +def test_component_meta_finder_respects_allowed_components( + meta_finder_root: Path, +) -> None: + """Components outside the allow list are skipped.""" + finder = ComponentMetaFinder(meta_finder_root, allowed_components=["other"]) + assert finder.find_spec("esphome.components.metacomp", None) is None + allowed = ComponentMetaFinder(meta_finder_root, allowed_components=["metacomp"]) + assert allowed.find_spec("esphome.components.metacomp", None) is not None + + +def test_install_and_clear_component_meta_finders(meta_finder_root: Path) -> None: + """Installing adds a finder to ``sys.meta_path`` and clearing removes it.""" + saved = list(sys.meta_path) + try: + install_meta_finder(meta_finder_root, ["metacomp"]) + assert isinstance(sys.meta_path[0], ComponentMetaFinder) + clear_component_meta_finders() + assert not any(isinstance(f, ComponentMetaFinder) for f in sys.meta_path) + finally: + sys.meta_path[:] = saved + + +def test_lookup_module_import_error_missing_module_returns_none( + caplog: pytest.LogCaptureFixture, +) -> None: + """A missing component is logged at info level and returns ``None``.""" + with caplog.at_level(logging.INFO, logger="esphome.loader"): + assert get_component("definitely_missing_component_xyz") is None + assert "Unable to import component definitely_missing_component_xyz" in caplog.text + assert "definitely_missing_component_xyz" not in _COMPONENT_CACHE + + +def test_lookup_module_missing_module_raises_when_requested() -> None: + """With ``exception=True`` the ImportError propagates.""" + with pytest.raises(ImportError): + get_component("definitely_missing_component_xyz", exception=True) + + +def test_lookup_module_other_import_error_is_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + """An ImportError that is not "No module named" is logged as an exception.""" + with ( + patch( + "esphome.loader.importlib.import_module", side_effect=ImportError("boom") + ), + caplog.at_level(logging.ERROR, logger="esphome.loader"), + ): + assert get_component("importfailcomp") is None + with pytest.raises(ImportError, match="boom"): + get_component("importfailcomp", exception=True) + assert "Unable to import component importfailcomp:" in caplog.text + + +def test_lookup_module_generic_exception_is_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + """A non-import error while loading a component is logged and swallowed.""" + with ( + patch( + "esphome.loader.importlib.import_module", side_effect=RuntimeError("bad") + ), + caplog.at_level(logging.ERROR, logger="esphome.loader"), + ): + assert get_component("crashingcomp") is None + with pytest.raises(RuntimeError, match="bad"): + get_component("crashingcomp", exception=True) + assert "Unable to load component crashingcomp:" in caplog.text + + +def test_lookup_module_alias_to_missing_canonical_returns_none() -> None: + """An alias whose canonical component cannot load yields ``None``, uncached.""" + meta = {"legacyzz": AliasMeta(canonical="missingcanonicalzz", removal_version=None)} + with patch("esphome.loader.get_alias_metadata", return_value=meta): + assert get_component("legacyzz") is None + assert "legacyzz" not in _COMPONENT_CACHE + + +def test_build_alias_map_skips_dirs_without_init(tmp_path: Path) -> None: + """Directories lacking an ``__init__.py`` are not components.""" + (tmp_path / "not_a_package").mkdir() + _write_component(tmp_path, "real", "ALIASES = ['old']\n") + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, _ = _build_alias_map() + assert alias_map == {"old": "real"} + + +def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: + """An alias may not name a component package that still exists.""" + from esphome.core import EsphomeError + + _write_component(tmp_path, "newcomp", "ALIASES = ['oldcomp']\n") + _write_component(tmp_path, "oldcomp", "") + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shadows an existing component"), + ): + _build_alias_map() + + +def test_read_aliases_handles_unreadable_file( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A file that cannot be read is skipped with a warning.""" + missing = tmp_path / "__init__.py" + with caplog.at_level(logging.WARNING, logger="esphome.loader"): + assert _read_aliases(missing, ast) == ([], None) + assert "Could not read" in caplog.text + + +def test_read_aliases_ignores_non_name_targets(tmp_path: Path) -> None: + """Attribute or subscript assignment targets are skipped.""" + init = tmp_path / "__init__.py" + init.write_text("obj.ALIASES = ['x']\nd['ALIASES'] = ['y']\nALIASES = ['z']\n") + assert _read_aliases(init, ast) == (["z"], None) + + +def test_alias_finder_returns_none_for_missing_canonical() -> None: + """If the canonical module does not exist, defer to normal import errors.""" + meta = {"legacyzz": AliasMeta(canonical="missingcanonicalzz", removal_version=None)} + with patch("esphome.loader.get_alias_metadata", return_value=meta): + assert _AliasFinder().find_spec("esphome.components.legacyzz", None) is None + + +def test_alias_finder_reraises_unrelated_missing_module() -> None: + """A missing dependency inside the canonical module is not masked.""" + meta = {"legacyzz": AliasMeta(canonical="brokencanon", removal_version=None)} + err = ModuleNotFoundError("No module named 'dep'", name="dep") + with ( + patch("esphome.loader.get_alias_metadata", return_value=meta), + patch("esphome.loader.importlib.import_module", side_effect=err), + pytest.raises(ModuleNotFoundError, match="dep"), + ): + _AliasFinder().find_spec("esphome.components.legacyzz", None) + + +def test_install_alias_finder_is_idempotent() -> None: + """Re-running the installer leaves exactly one finder in place.""" + before = [f for f in sys.meta_path if isinstance(f, _AliasFinder)] + assert len(before) == 1 + _install_alias_finder() + after = [f for f in sys.meta_path if isinstance(f, _AliasFinder)] + assert after == before + + +def test_install_alias_finder_adds_when_absent() -> None: + """With no finder registered, one is appended.""" + saved = list(sys.meta_path) + try: + sys.meta_path[:] = [f for f in saved if not isinstance(f, _AliasFinder)] + _install_alias_finder() + assert sum(isinstance(f, _AliasFinder) for f in sys.meta_path) == 1 + finally: + sys.meta_path[:] = saved + + +def test_alias_loader_returns_canonical_module() -> None: + """The loader hands back the resolved module and executes nothing.""" + module = ModuleType("canon") + loader = _AliasLoader(module) + assert loader.create_module(MagicMock()) is module + assert loader.exec_module(module) is None diff --git a/tests/unit_tests/test_log.py b/tests/unit_tests/test_log.py index 40e3aa6d22e..bc03f16b840 100644 --- a/tests/unit_tests/test_log.py +++ b/tests/unit_tests/test_log.py @@ -11,7 +11,7 @@ import time import pytest from esphome.core import CORE -from esphome.log import AnsiFore, AnsiStyle, color, setup_log +from esphome.log import AnsiFore, AnsiStyle, ESPHomeLogFormatter, color, setup_log class _FakeTty(io.StringIO): @@ -339,3 +339,87 @@ def test_setup_log_win32_always_imports_colorama( monkeypatch.setattr(sys, "stderr", _FakeTty()) setup_log() assert "colorama" in sys.modules + + +def _record(level: int, message: str) -> logging.LogRecord: + return logging.LogRecord("esphome", level, __file__, 1, message, None, None) + + +@pytest.mark.parametrize( + ("level", "colour"), + [ + (logging.DEBUG, AnsiFore.CYAN), + (logging.INFO, AnsiFore.GREEN), + (logging.WARNING, AnsiFore.YELLOW), + (logging.ERROR, AnsiFore.RED), + (logging.CRITICAL, AnsiFore.RED), + ], +) +def test_formatter_colours_by_level( + monkeypatch: pytest.MonkeyPatch, level: int, colour: AnsiFore +) -> None: + monkeypatch.setattr(CORE, "dashboard", False) + formatter = ESPHomeLogFormatter(include_timestamp=False) + + result = formatter.format(_record(level, "hello")) + + name = logging.getLevelName(level) + assert result == f"{colour.value}{name} hello{AnsiStyle.RESET_ALL.value}" + + +def test_formatter_unknown_level_has_no_colour_prefix( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(CORE, "dashboard", False) + formatter = ESPHomeLogFormatter(include_timestamp=False) + + result = formatter.format(_record(25, "hello")) + + assert result == f"Level 25 hello{AnsiStyle.RESET_ALL.value}" + + +def test_formatter_includes_timestamp_when_requested( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(CORE, "dashboard", False) + formatter = ESPHomeLogFormatter(include_timestamp=True) + record = _record(logging.INFO, "hello") + + result = formatter.format(record) + + assert formatter.formatTime(record) in result + + +def test_formatter_dashboard_escapes_ansi(monkeypatch: pytest.MonkeyPatch) -> None: + """The dashboard prints the escape character as literal text.""" + monkeypatch.setattr(CORE, "dashboard", True) + formatter = ESPHomeLogFormatter(include_timestamp=False) + + result = formatter.format(_record(logging.INFO, "hello")) + + assert "\033" not in result + assert result.startswith("\\033[32mINFO hello") + + +def test_setup_log_debug_sets_verbose( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + monkeypatch.setattr(CORE, "dashboard", True) + logging.getLogger().handlers.clear() + + setup_log(logging.DEBUG) + + assert CORE.verbose is True + assert CORE.quiet is False + + +def test_setup_log_critical_sets_quiet( + monkeypatch: pytest.MonkeyPatch, colorama_probe: None +) -> None: + monkeypatch.setattr(CORE, "dashboard", True) + logging.getLogger().handlers.clear() + + setup_log(logging.CRITICAL) + + assert CORE.quiet is True + assert CORE.verbose is False diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 26fda90953d..34397302bb8 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -3,25 +3,43 @@ from __future__ import annotations import json +import logging +from pathlib import Path +import ssl import threading import time -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch +import paho.mqtt.client as mqtt +from paho.mqtt.packettypes import PacketTypes +from paho.mqtt.reasoncodes import ReasonCode import pytest +from esphome.components.const import CONF_VERIFY_SSL +from esphome.components.mqtt import CONF_TLS from esphome.const import ( CONF_BIRTH_MESSAGE, CONF_BROKER, + CONF_CERTIFICATE_AUTHORITY, + CONF_CLIENT_CERTIFICATE, + CONF_CLIENT_CERTIFICATE_KEY, CONF_ESPHOME, CONF_MQTT, CONF_NAME, CONF_PAYLOAD, CONF_PORT, CONF_RETAIN, + CONF_SKIP_CERT_CN_CHECK, CONF_TOPIC, ) from esphome.core import EsphomeError -from esphome.mqtt import get_esphome_device_ip, prepare, show_logs +from esphome.mqtt import ( + clear_topic, + get_esphome_device_ip, + prepare, + show_discover, + show_logs, +) @pytest.mark.parametrize( @@ -46,7 +64,7 @@ def test_show_logs_asks_on_connect( on_connect = mock_initialize.call_args.args[3] client = MagicMock() - on_connect(client, None, None, 0) + on_connect(client, None, None, 0, None) client.publish.assert_called_once_with( "esphome/logs/test-device", None, retain=False ) @@ -76,7 +94,7 @@ def test_show_logs_requests_crash_log_when_device_reports_online( on_message = mock_initialize.call_args.args[2] on_connect = mock_initialize.call_args.args[3] client = MagicMock() - on_connect(client, None, None, 0) + on_connect(client, None, None, 0, None) client.publish.assert_not_called() on_message(client, None, MagicMock(topic="test-device/status", payload=b"offline")) @@ -211,6 +229,10 @@ def _discovery_config() -> dict: } +def _disconnect_reason(name: str) -> ReasonCode: + return ReasonCode(PacketTypes.DISCONNECT, name) + + def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None: """Deliver a discovery answer as soon as the network loop starts.""" @@ -321,10 +343,15 @@ def test_get_esphome_device_ip_replaces_reconnect_handler( get_esphome_device_ip(_discovery_config(), timeout=0.25) assert client.on_disconnect is not prepare_handler - client.on_disconnect(client, None, 0) + flags = mqtt.DisconnectFlags(is_disconnect_packet_from_server=True) + client.on_disconnect( + client, None, flags, _disconnect_reason("Normal disconnection"), None + ) assert "Disconnected from MQTT broker" not in caplog.text - client.on_disconnect(client, None, 5) - assert "Disconnected from MQTT broker (5)" in caplog.text + client.on_disconnect( + client, None, flags, _disconnect_reason("Not authorized"), None + ) + assert "Disconnected from MQTT broker (Not authorized, rc=135)" in caplog.text def test_get_esphome_device_ip_answer_without_ip_fails_fast( @@ -372,7 +399,10 @@ def test_get_esphome_device_ip_broker_disconnect_fails_fast( with patch("esphome.mqtt.prepare", return_value=client): def drop_connection(*args, **kwargs): - client.on_disconnect(client, None, 5) + flags = mqtt.DisconnectFlags(is_disconnect_packet_from_server=True) + client.on_disconnect( + client, None, flags, _disconnect_reason("Not authorized"), None + ) client.loop_start.side_effect = drop_connection @@ -381,7 +411,7 @@ def test_get_esphome_device_ip_broker_disconnect_fails_fast( get_esphome_device_ip(_discovery_config(), timeout=5) assert time.monotonic() - start < 1 - assert "Disconnected from MQTT broker (5)" in caplog.text + assert "Disconnected from MQTT broker (Not authorized, rc=135)" in caplog.text def test_get_esphome_device_ip_sends_discovery_ping() -> None: @@ -392,7 +422,7 @@ def test_get_esphome_device_ip_sends_discovery_ping() -> None: def connect_then_answer(*args, **kwargs): on_connect = mock_prepare.call_args.args[3] - on_connect(client, None, None, 0) + on_connect(client, None, None, 0, None) msg = MagicMock() msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode() mock_prepare.call_args.args[2](client, None, msg) @@ -483,12 +513,17 @@ def test_prepare_follows_the_firmware_transport( config[CONF_MQTT]["transport"] = transport if ws_path is not None: config[CONF_MQTT]["ws_path"] = ws_path + if expect_tls: + # Validation switches tls on for wss + config[CONF_MQTT][CONF_TLS] = True client = mock_client_class.return_value assert prepare(config, [], None, None, None, None, None) is client mock_client_class.assert_called_once_with( - "", transport="websockets" if expect_path else "tcp" + mqtt.CallbackAPIVersion.VERSION2, + "", + transport="websockets" if expect_path else "tcp", ) if expect_path is None: client.ws_set_options.assert_not_called() @@ -500,3 +535,194 @@ def test_prepare_follows_the_firmware_transport( else: client.tls_set_context.assert_not_called() client.connect.assert_called_once_with("mqtt.local", 1883) + + +@patch("esphome.mqtt.mqtt.Client") +def test_prepare_subscribes_on_connect_then_runs_the_caller_hook( + mock_client_class: MagicMock, +) -> None: + """On CONNACK the client subscribes to every topic before the caller's on_connect runs.""" + config = _discovery_config() + config[CONF_MQTT][CONF_PORT] = 1883 + on_connect = MagicMock() + + client = prepare(config, ["a/#", "b"], None, on_connect, None, None, None) + + flags = mqtt.ConnectFlags(session_present=False) + reason = ReasonCode(PacketTypes.CONNACK, "Success") + client.on_connect(client, None, flags, reason, None) + assert client.subscribe.call_args_list == [call("a/#"), call("b")] + on_connect.assert_called_once_with(client, None, flags, reason, None) + + +@patch("esphome.mqtt.time.sleep") +@patch("esphome.mqtt.mqtt.Client") +def test_prepare_reconnects_with_backoff_after_an_unexpected_disconnect( + mock_client_class: MagicMock, + mock_sleep: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A clean disconnect is left alone; any other reason retries with backoff until the broker answers.""" + caplog.set_level(logging.INFO) + config = _discovery_config() + config[CONF_MQTT][CONF_PORT] = 1883 + client = prepare(config, [], None, None, None, None, None) + flags = mqtt.DisconnectFlags(is_disconnect_packet_from_server=False) + + client.on_disconnect( + client, None, flags, _disconnect_reason("Normal disconnection"), None + ) + client.reconnect.assert_not_called() + + client.reconnect.side_effect = [OSError("down"), 1, 0] + client.on_disconnect( + client, None, flags, _disconnect_reason("Unspecified error"), None + ) + assert client.reconnect.call_count == 3 + assert mock_sleep.call_args_list == [call(1), call(2)] + assert ( + caplog.text.count( + "Disconnected from MQTT (Unspecified error, rc=128). Trying to reconnect" + ) + == 2 + ) + assert "Successfully reconnected to the MQTT server" in caplog.text + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_discover_broadcasts_on_connect_and_prints_answers( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """The discover listener sends the broadcast once connected and prints every non-empty answer.""" + show_discover(_discovery_config()) + + assert mock_initialize.call_args.args[1] == ["esphome/discover/#"] + on_message = mock_initialize.call_args.args[2] + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + flags = mqtt.ConnectFlags(session_present=False) + on_connect(client, None, flags, ReasonCode(PacketTypes.CONNACK, "Success"), None) + client.publish.assert_called_once_with("esphome/discover", None, retain=False) + + on_message(client, None, MagicMock(payload=b"")) + mock_print.assert_not_called() + on_message(client, None, MagicMock(payload=b"test-device 10.0.0.5")) + assert mock_print.call_args.args[0].endswith(" test-device 10.0.0.5") + + +def _mqtt_config(options: dict | None = None) -> dict: + return { + CONF_MQTT: {CONF_BROKER: "broker.local", CONF_PORT: 8883, **(options or {})} + } + + +def _prepare_with_mocks(config: dict) -> tuple[MagicMock, MagicMock, MagicMock]: + """Runs prepare() against a mocked paho client and SSL context factory.""" + with ( + patch("esphome.mqtt.mqtt.Client") as client_cls, + patch("esphome.mqtt.ssl.create_default_context") as create_context, + ): + client = client_cls.return_value + context = create_context.return_value + # A real context verifies hostnames unless prepare() turns that off + context.check_hostname = True + prepare(config, [], None, None, None, None, None) + return client, create_context, context + + +def test_prepare_without_tls_connects_in_plaintext() -> None: + client, create_context, _ = _prepare_with_mocks(_mqtt_config({CONF_PORT: 1883})) + + create_context.assert_not_called() + client.tls_set_context.assert_not_called() + client.connect.assert_called_once_with("broker.local", 1883) + + +def test_prepare_tls_without_ca_uses_system_trust_store() -> None: + client, create_context, context = _prepare_with_mocks( + _mqtt_config({CONF_TLS: True}) + ) + + create_context.assert_called_once_with(cadata=None) + assert context.check_hostname is True + client.tls_set_context.assert_called_once_with(context) + + +def test_prepare_tls_with_ca_and_skip_cn_check() -> None: + client, create_context, context = _prepare_with_mocks( + _mqtt_config( + { + CONF_TLS: True, + CONF_CERTIFICATE_AUTHORITY: "---cert---", + CONF_SKIP_CERT_CN_CHECK: True, + } + ) + ) + + create_context.assert_called_once_with(cadata="---cert---") + assert context.check_hostname is False + client.tls_set_context.assert_called_once_with(context) + + +def test_prepare_verify_ssl_false_disables_verification() -> None: + client, create_context, context = _prepare_with_mocks( + _mqtt_config({CONF_TLS: True, CONF_VERIFY_SSL: False}) + ) + + create_context.assert_called_once_with() + assert context.check_hostname is False + assert context.verify_mode == ssl.CERT_NONE + client.tls_set_context.assert_called_once_with(context) + + +def test_prepare_loads_client_certificate() -> None: + _, _, context = _prepare_with_mocks( + _mqtt_config( + { + CONF_TLS: True, + CONF_CLIENT_CERTIFICATE: "---client cert---", + CONF_CLIENT_CERTIFICATE_KEY: "---client key---", + } + ) + ) + + context.load_cert_chain.assert_called_once() + cert_file, key_file = context.load_cert_chain.call_args.args + assert not Path(cert_file).exists() + assert not Path(key_file).exists() + + +@patch("esphome.mqtt.initialize") +def test_clear_topic_clears_retained_messages_under_the_device( + mock_initialize: MagicMock, +) -> None: + """Without a topic the device's discovery subtree is cleared, one empty retained publish per retained message.""" + clear_topic(_discovery_config(), None) + + assert mock_initialize.call_args.args[1] == ["homeassistant/+/test-device/#"] + on_message = mock_initialize.call_args.args[2] + client = MagicMock() + on_message( + client, + None, + MagicMock( + topic="homeassistant/sensor/test-device/t", payload=b"1", retain=False + ), + ) + on_message( + client, + None, + MagicMock(topic="homeassistant/sensor/test-device/t", payload=b"", retain=True), + ) + client.publish.assert_not_called() + on_message( + client, + None, + MagicMock( + topic="homeassistant/sensor/test-device/t", payload=b"1", retain=True + ), + ) + client.publish.assert_called_once_with( + "homeassistant/sensor/test-device/t", None, retain=True + ) diff --git a/tests/unit_tests/test_pins.py b/tests/unit_tests/test_pins.py index cd4a71d2c0b..06d0b5c9636 100644 --- a/tests/unit_tests/test_pins.py +++ b/tests/unit_tests/test_pins.py @@ -1,13 +1,26 @@ """Unit tests for esphome/pins.py.""" +from collections.abc import Iterator +import logging from unittest.mock import AsyncMock, MagicMock, patch import pytest from esphome import config_validation as cv, pins import esphome.codegen as cg -from esphome.const import CONF_ADDRESS -from esphome.core import ID +from esphome.config import Config, path_context +from esphome.const import ( + CONF_ADDRESS, + CONF_ALLOW_OTHER_USES, + CONF_ID, + CONF_IGNORE_STRAPPING_WARNING, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + KEY_CORE, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE, ID from esphome.schema_extractors import SCHEMA_EXTRACT SomeHub = cg.esphome_ns.class_("SomeHub") @@ -166,3 +179,181 @@ async def test_new_parented_pin(args: tuple[str, ...]) -> None: var.set_inverted.assert_called_once_with(True) var.set_flags.assert_called_once() assert add.call_count == 3 + + +@pytest.fixture +def registry() -> pins.PinRegistry: + reg = pins.PinRegistry() + reg.register("hub", lambda conf: conf)(lambda conf: conf) + return reg + + +@pytest.fixture +def pin_path() -> Iterator[None]: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + token = path_context.set(["pin"]) + try: + yield + finally: + path_context.reset(token) + + +@pytest.mark.usefixtures("pin_path") +def test_registry_validate_tracks_usage_and_get_count( + registry: pins.PinRegistry, +) -> None: + assert registry.get_count("hub", "h1", 4) == 0 + + registry.validate({"hub": "h1", CONF_NUMBER: 4}, "hub") + + assert registry.get_count("hub", "h1", 4) == 1 + assert registry.get_count("hub", "h1", 5) == 0 + + +@pytest.mark.usefixtures("pin_path") +def test_registry_validate_without_number_is_not_tracked( + registry: pins.PinRegistry, +) -> None: + assert registry.validate({"hub": "h1"}, "hub") == {"hub": "h1"} + assert not registry.pins_used + + +def test_registry_get_to_code(registry: pins.PinRegistry) -> None: + to_code = registry["hub"][0] + + assert registry.get_to_code("hub") is to_code + assert registry.get_to_code({"hub": "h1"}) is to_code + + +def _final_config(client_id: ID) -> Config: + config = Config() + config["hub"] = [{CONF_ID: client_id}] + config.declare_ids.append((client_id, ["hub", 0, CONF_ID])) + return config + + +@pytest.mark.parametrize( + ("uses", "allow_others", "testing_mode", "error"), + [ + (2, False, False, "used in multiple places"), + (2, True, False, None), + (2, False, True, None), + (1, False, False, None), + (1, True, False, "incorrectly sets allow_other_uses"), + (1, True, True, None), + ], +) +@pytest.mark.usefixtures("pin_path") +def test_registry_final_validate_reuse_rules( + registry: pins.PinRegistry, + monkeypatch: pytest.MonkeyPatch, + uses: int, + allow_others: bool, + testing_mode: bool, + error: str | None, +) -> None: + client_id = ID("h1", is_declaration=True) + extra = {CONF_ALLOW_OTHER_USES: True} if allow_others else {} + for _ in range(uses): + registry.validate({"hub": client_id, CONF_NUMBER: 4, **extra}, "hub") + monkeypatch.setattr(CORE, "testing_mode", testing_mode) + config = _final_config(client_id) + + registry.final_validate(config) + + if error is None: + assert not config.errors + else: + assert all(error in str(err) for err in config.errors) + assert config.errors + + +@pytest.mark.usefixtures("pin_path") +def test_registry_final_validate_runs_hook_with_parent_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + hook = MagicMock() + registry = pins.PinRegistry() + registry.register("hub", lambda conf: conf, hook)(lambda conf: conf) + client_id = ID("h1", is_declaration=True) + pin = registry.validate({"hub": client_id, CONF_NUMBER: 4}, "hub") + config = _final_config(client_id) + + registry.final_validate(config) + + hook.assert_called_once_with(pin, config["hub"][0]) + assert not config.errors + + +@pytest.mark.parametrize( + ("mode", "expected"), + [ + ("input", {"input": True}), + ("INPUT_PULLUP", {"input": True, "pullup": True}), + ("output_open_drain", {"output": True, "open_drain": True}), + ], +) +def test_set_mode_parses_string_modes(mode: str, expected: dict[str, bool]) -> None: + assert pins._set_mode({CONF_NUMBER: 1, CONF_MODE: mode}, {}) == { + CONF_NUMBER: 1, + CONF_MODE: expected, + } + + +def test_set_mode_rejects_unknown_string_mode() -> None: + with pytest.raises(cv.Invalid, match="Unknown pin mode bogus"): + pins._set_mode({CONF_MODE: "bogus"}, {}) + + +def test_set_mode_keeps_dict_mode_and_applies_default() -> None: + explicit = {CONF_MODE: {"input": True}} + assert pins._set_mode(explicit, {"output": True}) is explicit + assert pins._set_mode({}, {"output": True}) == {CONF_MODE: {"output": True}} + + +def test_gpio_flags_expr() -> None: + assert str(pins.gpio_flags_expr({"input": False})) == str(cg.gpio_Flags.FLAG_NONE) + flags = str(pins.gpio_flags_expr({"input": True, "pullup": True})) + assert "FLAG_INPUT" in flags + assert "FLAG_PULLUP" in flags + + +def test_gpio_validate_modes() -> None: + mode = {"input": True, "output": False} + assert pins.gpio_validate_modes(mode) is mode + with pytest.raises(cv.Invalid, match="Mode must be input or output"): + pins.gpio_validate_modes({"input": False, "output": False}) + + +INPUT_MODE = {"input": True} + + +def test_gpio_base_schema_rejects_placeholder_number() -> None: + schema = pins.gpio_base_schema(SomeHub, cv.int_) + + with pytest.raises(cv.Invalid, match="placeholder 'GPIOX'"): + schema({CONF_NUMBER: "GPIOX", CONF_MODE: INPUT_MODE}) + assert schema({CONF_NUMBER: 3, CONF_MODE: INPUT_MODE})[CONF_NUMBER] == 3 + + +@pytest.mark.parametrize("invertible", [True, False]) +def test_gpio_base_schema_invertible(invertible: bool) -> None: + schema = pins.gpio_base_schema(SomeHub, cv.int_, invertible=invertible) + + assert ( + CONF_INVERTED in schema({CONF_NUMBER: 3, CONF_MODE: INPUT_MODE}) + ) is invertible + + +def test_check_strapping_pin(caplog: pytest.LogCaptureFixture) -> None: + logger = logging.getLogger("test_pins") + with caplog.at_level(logging.WARNING, logger="test_pins"): + pins.check_strapping_pin({CONF_NUMBER: 2}, {2}, logger) + pins.check_strapping_pin( + {CONF_NUMBER: 2, CONF_IGNORE_STRAPPING_WARNING: True}, {2}, logger + ) + assert caplog.text.count("GPIO2 is a strapping pin") == 1 + with pytest.raises(cv.Invalid, match="not a strapping pin"): + pins.check_strapping_pin( + {CONF_NUMBER: 3, CONF_IGNORE_STRAPPING_WARNING: True}, {2}, logger + ) diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index b108cf298ab..39d3715a500 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -5,10 +5,12 @@ from __future__ import annotations from collections.abc import Callable import io from pathlib import Path +import runpy import sys -from types import ModuleType +from types import ModuleType, SimpleNamespace from unittest.mock import Mock +import platformio from platformio.registry.client import RegistryClient import pytest @@ -146,3 +148,73 @@ def test_patch_registry_private_packages_skips_account_probe( assert RegistryClient.allowed_private_packages() is False assert RegistryClient().allowed_private_packages() is False + + +def test_patch_file_downloader_retries_when_session_cleanup_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failing close of the old session must not stop the retry.""" + package_exception = type("PackageException", (Exception,), {}) + attempts: list[int] = [] + + def flaky_init(self: object, *args: object, **kwargs: object) -> None: + attempts.append(1) + if len(attempts) < 2: + raise package_exception("502") + + downloader = type("FileDownloader", (), {"__init__": flaky_init}) + monkeypatch.setitem( + sys.modules, + "platformio.package.download", + SimpleNamespace(FileDownloader=downloader), + ) + monkeypatch.setitem( + sys.modules, + "platformio.package.exception", + SimpleNamespace(PackageException=package_exception), + ) + monkeypatch.setattr(runner.time, "sleep", lambda _delay: None) + + runner.patch_file_downloader() + + instance = object.__new__(downloader) + instance._http_response = Mock(close=Mock(side_effect=OSError("boom"))) + downloader.__init__(instance) + + assert len(attempts) == 2 + instance._http_response.close.assert_called_once() + + +def test_runner_runs_main_when_executed_as_a_script( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Running the file as ``__main__`` exits with PlatformIO's return code.""" + monkeypatch.setattr(sys, "argv", ["runner", "run"]) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + # The script re-imports and applies the PlatformIO patches on its own + # module namespace, so give it stand-ins rather than the real internals. + monkeypatch.setitem(sys.modules, "platformio.run", Mock()) + monkeypatch.setitem( + sys.modules, + "platformio.package.download", + SimpleNamespace(FileDownloader=type("FileDownloader", (), {})), + ) + monkeypatch.setitem( + sys.modules, + "platformio.package.exception", + SimpleNamespace(PackageException=Exception), + ) + monkeypatch.setitem( + sys.modules, + "platformio.registry.client", + SimpleNamespace(RegistryClient=type("RegistryClient", (), {})), + ) + pio_main = SimpleNamespace(main=lambda: 3) + monkeypatch.setitem(sys.modules, "platformio.__main__", pio_main) + monkeypatch.setattr(platformio, "__main__", pio_main, raising=False) + + with pytest.raises(SystemExit) as exc_info: + runpy.run_path(str(Path(runner.__file__)), run_name="__main__") + + assert exc_info.value.code == 3 diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 60a5d1d4235..d74c1d2be3f 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -112,6 +112,22 @@ def test_idedata_cc_path(setup_core: Path) -> None: ) +@pytest.mark.parametrize( + ("raw_data", "expected"), + [ + pytest.param( + {"defines": ["USE_ESP32", "FOO=1"]}, ["USE_ESP32", "FOO=1"], id="set" + ), + pytest.param({}, [], id="missing"), + ], +) +def test_idedata_defines(raw_data: dict[str, list[str]], expected: list[str]) -> None: + """IDEData.defines returns the defines, or an empty list when absent.""" + idedata = toolchain.IDEData({"prog_path": "/path/to/firmware.elf", **raw_data}) + + assert idedata.defines == expected + + def test_flash_image_dataclass() -> None: """Test FlashImage dataclass stores path and offset correctly.""" image = FlashImage(path=Path("/path/to/image.bin"), offset="0x10000") @@ -758,6 +774,17 @@ def test_copy_ccache_script(setup_core: Path) -> None: assert dest.read_text() == source.read_text() +def test_copy_pch_script(setup_core: Path) -> None: + """The shared PCH pre-script is copied into the build dir.""" + CORE.build_path = setup_core / "build" / "test" + + toolchain.copy_pch_script() + + dest = setup_core / "build" / "test" / "pch.py" + source = Path(toolchain.__file__).parent / "pch.py.script" + assert dest.read_text() == source.read_text() + + class _FakeSConsEnv(dict): """Just enough of a SCons construction environment for ccache.py.""" @@ -1015,6 +1042,20 @@ def test_run_platformio_cli_run_builds_command( ) +def test_run_platformio_cli_run_without_verbose( + setup_core: Path, mock_run_platformio_cli: Mock +) -> None: + """Without verbose, no -v flag is passed to PlatformIO.""" + CORE.build_path = str(setup_core / "build" / "test") + mock_run_platformio_cli.return_value = 0 + + toolchain.run_platformio_cli_run({"name": "test"}, False, "extra") + + mock_run_platformio_cli.assert_called_once_with( + "run", "-d", CORE.build_path, "extra" + ) + + def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> None: """Test run_compile with process limit.""" from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME diff --git a/tests/unit_tests/test_resolver.py b/tests/unit_tests/test_resolver.py index 16294a38135..fa7f42cc36e 100644 --- a/tests/unit_tests/test_resolver.py +++ b/tests/unit_tests/test_resolver.py @@ -2,6 +2,8 @@ from __future__ import annotations +import importlib +import logging import re import socket from unittest.mock import ANY, patch @@ -10,6 +12,7 @@ from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr import pytest +from esphome import resolver from esphome.async_thread import AsyncDispatchTimeout from esphome.core import EsphomeError from esphome.resolver import RESOLVE_TIMEOUT, AsyncResolver @@ -162,3 +165,18 @@ def test_async_resolver_mixed_addresses( mock_resolve.assert_called_once_with( ["test.local", "192.168.1.100", "::1"], 6053, timeout=RESOLVE_TIMEOUT ) + + +def test_invalid_resolve_timeout_env_falls_back_to_default( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A non-numeric ESPHOME_RESOLVE_TIMEOUT warns and uses the default.""" + monkeypatch.setenv("ESPHOME_RESOLVE_TIMEOUT", "soon") + try: + with caplog.at_level(logging.WARNING, logger="esphome.resolver"): + importlib.reload(resolver) + assert resolver.RESOLVE_TIMEOUT == 20.0 + assert "ESPHOME_RESOLVE_TIMEOUT='soon' is not a valid number" in caplog.text + finally: + monkeypatch.delenv("ESPHOME_RESOLVE_TIMEOUT") + importlib.reload(resolver) diff --git a/tests/unit_tests/test_schema_extractors.py b/tests/unit_tests/test_schema_extractors.py new file mode 100644 index 00000000000..d536acc5c30 --- /dev/null +++ b/tests/unit_tests/test_schema_extractors.py @@ -0,0 +1,90 @@ +"""Tests for esphome.schema_extractors.""" + +from collections.abc import Callable, Iterator +from typing import Any + +import pytest + +from esphome import schema_extractors as se + + +@pytest.fixture +def extraction_enabled() -> Iterator[None]: + """Enable schema extraction and restore the module state afterwards.""" + stores = ( + se.extended_schemas, + se.list_schemas, + se.registry_schemas, + se.hidden_schemas, + se.typed_schemas, + ) + saved = [dict(store) for store in stores] + previous = se.EnableSchemaExtraction + se.EnableSchemaExtraction = True + try: + yield + finally: + se.EnableSchemaExtraction = previous + for store, old in zip(stores, saved, strict=True): + store.clear() + store.update(old) + + +def _validator(value: Any) -> Any: + return value + + +def test_decorators_are_passthrough_when_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(se, "EnableSchemaExtraction", False) + assert se.schema_extractor("name")(_validator) is _validator + assert se.schema_extractor_registry(object())(_validator) is _validator + assert se.schema_extractor_extended(_validator) is _validator + assert se.schema_extractor_list(_validator) is _validator + assert se.schema_extractor_typed(_validator) is _validator + + +@pytest.mark.usefixtures("extraction_enabled") +def test_schema_extractor_records_hidden_schema() -> None: + assert se.schema_extractor("my_validator")(_validator) is _validator + assert se.hidden_schemas[repr(_validator)] == "my_validator" + + +@pytest.mark.usefixtures("extraction_enabled") +def test_schema_extractor_registry_records_registry() -> None: + registry = object() + assert se.schema_extractor_registry(registry)(_validator) is _validator + assert se.registry_schemas[repr(_validator)] is registry + + +@pytest.mark.parametrize( + ("decorator", "store_name"), + [ + (se.schema_extractor_extended, "extended_schemas"), + (se.schema_extractor_list, "list_schemas"), + ], +) +@pytest.mark.usefixtures("extraction_enabled") +def test_args_recording_decorators( + decorator: Callable[[Callable[..., Any]], Callable[..., Any]], store_name: str +) -> None: + def build(*args: Any) -> list[Any]: + return list(args) + + wrapped = decorator(build) + result = wrapped(1, 2) + + assert result == [1, 2] + assert getattr(se, store_name)[repr(result)] == (1, 2) + + +@pytest.mark.usefixtures("extraction_enabled") +def test_schema_extractor_typed_records_args_and_kwargs() -> None: + def build(*args: Any, **kwargs: Any) -> list[Any]: + return [args, kwargs] + + result = se.schema_extractor_typed(build)(1, key="x") + + assert result == [(1,), {"key": "x"}] + assert se.typed_schemas[repr(result)] == ((1,), {"key": "x"}) diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index 245184f2d03..b65f4c91c5e 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -9,7 +9,11 @@ from unittest.mock import patch import pytest -from esphome.espidf.size_summary import print_summary +from esphome.espidf.size_summary import ( + _find_app_partition_size, + _parse_size, + print_summary, +) def _write_size_json(tmp_path: Path, data: dict) -> Path: @@ -317,3 +321,51 @@ def test_print_summary_skips_flash_on_bad_input( for r in caplog.records ) assert warned == with_partitions + + +@pytest.mark.parametrize( + ("token", "expected"), + [ + pytest.param("", 0, id="empty"), + pytest.param(" ", 0, id="blank"), + pytest.param("0x10000", 0x10000, id="hex"), + pytest.param("0X10", 0x10, id="hex_upper"), + pytest.param("1M", 1024 * 1024, id="mebibyte"), + pytest.param("512k", 512 * 1024, id="kibibyte_lower"), + pytest.param("4096", 4096, id="decimal"), + ], +) +def test_parse_size(token: str, expected: int) -> None: + """Partition sizes accept hex, K/M suffixes, decimals and blanks.""" + assert _parse_size(token) == expected + + +def test_find_app_partition_size_skips_unrelated_rows(tmp_path: Path) -> None: + """Comments, blank lines, short rows and non-app rows are passed over.""" + partitions = tmp_path / "partitions.csv" + partitions.write_text( + "# name, type, subtype, offset, size, flags\n" + "\n" + "short, app, factory\n" + "nvs, data, nvs, 0x9000, 0x6000,\n" + "app0, app, ota_0, 0x10000, 1M,\n" + ) + assert _find_app_partition_size(partitions) == 1024 * 1024 + + +def test_find_app_partition_size_without_app_partition_raises(tmp_path: Path) -> None: + """A table with no app+factory or app+ota_0 row is rejected.""" + partitions = tmp_path / "partitions.csv" + partitions.write_text("nvs, data, nvs, 0x9000, 0x6000,\n") + with pytest.raises(ValueError, match="No app\\+factory or app\\+ota_0"): + _find_app_partition_size(partitions) + + +def test_print_summary_skips_unparseable_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A truncated size json prints nothing instead of raising.""" + size_json = tmp_path / "esp_idf_size.json" + size_json.write_text("{not json") + print_summary(size_json, _write_partitions(tmp_path), tmp_path / "firmware.elf") + assert capsys.readouterr().out == "" diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 739a0e2ada3..0832d795268 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable import io import logging -from pathlib import Path +from pathlib import Path, PurePosixPath import subprocess import sys from typing import Any @@ -467,6 +467,25 @@ def test_redirect_text_keeps_output_after_an_exotic_break_character( assert buf.getvalue() == f"first{break_char}second\nthird\n" +def test_redirect_text_keeps_the_color_codes_of_a_filtered_line() -> None: + """A dropped line still delivers its color codes. + + With ``CLICOLOR_FORCE`` cmake prints a warning in yellow and puts the + reset on a line of its own after a blank line. The IDF build filters + blank lines, matched without ANSI codes, so the reset used to be dropped + and the output after the warning stayed yellow. + """ + redirect, buf = _make_redirect(filter_lines=[r"\s*$"]) + + redirect.write( + "\x1b[33mCMake Warning at a.cmake:3 (message):\n w\n\n\x1b[0m\n-- b\n" + ) + + assert buf.getvalue() == ( + "\x1b[33mCMake Warning at a.cmake:3 (message):\n w\n\x1b[0m-- b\n" + ) + + def test_redirect_text_treats_crlf_as_one_terminator() -> None: r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line.""" redirect, buf = _make_redirect(filter_lines=["ignore me"]) @@ -1200,3 +1219,146 @@ def test_read_secret_line_empty_pipe(capsys: pytest.CaptureFixture[str]) -> None stdin.readline.return_value = "" assert util.read_secret_line("Key: ") == "" assert capsys.readouterr().err == "Key: " + + +def test_safe_print_dashboard_ignores_unicode_encode_error( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """A message whose replace() raises UnicodeEncodeError is printed as is.""" + + class BadStr(str): + __slots__ = () + + def replace(self, *args: Any, **kwargs: Any) -> str: + raise UnicodeEncodeError("ascii", "x", 0, 1, "boom") + + monkeypatch.setattr(CORE, "dashboard", True) + util.safe_print(BadStr("hello")) + assert capsys.readouterr().out == "hello\n" + + +def test_shlex_quote_accepts_path() -> None: + """Path objects are converted to strings before quoting.""" + assert util.shlex_quote(Path("a b")) == "'a b'" + assert util.shlex_quote(Path("plain")) == "plain" + + +def test_redirect_text_delegates_unknown_attributes() -> None: + """Attributes RedirectText does not define come from the wrapped stream.""" + out = io.StringIO() + redirect = util.RedirectText(out) + assert redirect.getvalue == out.getvalue + assert redirect.isatty() is True + + +def test_redirect_text_write_decodes_bytes() -> None: + """Bytes written to RedirectText are decoded to str.""" + out = io.StringIO() + redirect = util.RedirectText(out) + assert redirect.write(b"bytes\n") == 6 # type: ignore[arg-type] + assert out.getvalue() == "bytes\n" + + +def test_run_external_command_reraises_keyboard_interrupt() -> None: + """KeyboardInterrupt is propagated and sys.argv is restored.""" + orig_argv = sys.argv + + def func() -> int: + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + util.run_external_command(func, "cmd") + assert sys.argv is orig_argv + + +def test_run_external_command_returns_one_on_exception( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unexpected exceptions are logged and turned into exit code 1.""" + + def func() -> int: + raise ValueError("kaboom") + + with caplog.at_level(logging.ERROR): + assert util.run_external_command(func, "cmd") == 1 + assert "Running command failed: kaboom" in caplog.text + + +def test_run_external_process_capture_stdout() -> None: + """capture_stdout pipes the child's stdout and returns it.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="captured", returncode=0) + result = util.run_external_process("echo", "x", capture_stdout=True) + assert result == "captured" + assert mock_run.call_args.kwargs["stdout"] == subprocess.PIPE + + +def test_run_external_process_keyboard_interrupt() -> None: + """KeyboardInterrupt from subprocess.run is propagated.""" + with ( + patch("subprocess.run", side_effect=KeyboardInterrupt), + pytest.raises(KeyboardInterrupt), + ): + util.run_external_process("echo") + + +def test_is_dev_esphome_version(monkeypatch: pytest.MonkeyPatch) -> None: + """Dev versions are detected by the 'dev' marker.""" + monkeypatch.setattr(util.const, "__version__", "2026.1.0-dev") + assert util.is_dev_esphome_version() is True + monkeypatch.setattr(util.const, "__version__", "2026.1.0") + assert util.is_dev_esphome_version() is False + + +def test_ordered_dict_repr_is_plain_dict() -> None: + """OrderedDict repr looks like a regular dict.""" + assert repr(util.OrderedDict([("a", 1), ("b", 2)])) == "{'a': 1, 'b': 2}" + + +@pytest.mark.parametrize("platform", ["linux", "darwin"]) +def test_get_serial_ports(platform: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Ports are filtered by VID:PID; by-id platform devices are added on Linux.""" + comports = [ + ("", "empty", "VID:PID=1:2"), + ("/dev/ttyUSB1", "usb", "VID:PID=1:2"), + ("/dev/ttyS0", "builtin", "n/a"), + ] + # POSIX paths so the strings and sort order do not depend on the host OS + by_id = [PurePosixPath("/dev/serial/by-id/b"), PurePosixPath("/dev/serial/by-id/a")] + subsystems = {"b": "platform", "a": "usb"} + + def make_device(path: Path) -> MagicMock: + return MagicMock(subsystem=subsystems[path.name]) + + fake_path = MagicMock() + fake_path.exists.return_value = True + fake_path.glob.return_value = by_id + with ( + patch("serial.tools.list_ports.comports", return_value=comports), + patch("serial.tools.list_ports_linux.SysFS", side_effect=make_device), + patch.object(util, "Path", return_value=fake_path), + ): + # Only after patch() has imported pyserial: it picks its backend from + # sys.platform at import time, and the macOS one fails on other hosts + monkeypatch.setattr(sys, "platform", platform) + ports = util.get_serial_ports() + + paths = [p.path for p in ports] + if platform == "linux": + assert paths == ["/dev/serial/by-id/b", "/dev/ttyUSB1"] + assert ports[0].description == "/" # info[1] of the last entry ("n/a") + else: + assert paths == ["/dev/ttyUSB1"] + assert ports[0].description == "usb" + + +def test_run_external_process_returns_one_on_exception( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unexpected subprocess failures are logged and turned into exit code 1.""" + with ( + patch("subprocess.run", side_effect=OSError("no such file")), + caplog.at_level(logging.ERROR), + ): + assert util.run_external_process("missing") == 1 + assert "Running command failed: no such file" in caplog.text diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 178fd840203..745be9bfc88 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -7,7 +7,7 @@ import esphome.config_validation as cv from esphome.core import EsphomeError -def _run_repl_test(input_data): +def _run_repl_test(input_data, *, ace=False): """Reusable test function for different input scenarios.""" input_data.append(_exit()) with ( @@ -15,7 +15,8 @@ def _run_repl_test(input_data): patch("sys.stdout") as mock_stdout, ): args = Mock([]) - args.ace = False + args.ace = ace + args.configuration = "config_dir" args.substitution = None vscode.read_config(args) @@ -264,3 +265,23 @@ logger: (validation_error,) = error["validation_errors"] assert validation_error["range"] is None assert "extra keys not allowed" in validation_error["message"] + + +def test_ace_mode_loads_config_relative_to_configuration_dir(): + output_lines = _run_repl_test( + [ + _validate("x.yaml"), + # read_file x.yaml + _file_response("""esphome: + name: test1 +esp8266: + board: d1_mini +"""), + ], + ace=True, + ) + + assert output_lines == [ + _read_file(str(Path("config_dir", "x.yaml"))), + RESULT_NO_ERROR, + ] diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index f57ae71ae6f..f79cc598bbe 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -1,5 +1,6 @@ """Tests for the wizard.py file.""" +import importlib.util from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -716,3 +717,74 @@ def test_fallback_psk_uses_secrets_choice( assert 'password: "XXXXXXXXXXXX"' in config assert mock_choice.call_count == 12 + + +def test_wizard_file_rpipico_has_no_wifi_or_api() -> None: + """The rpipico has no WiFi, so the config stops after the logger.""" + config = wz.wizard_file(name="pico", platform="RP2", board="rpipico") + + assert "logger:" in config + assert "api:" not in config + assert "wifi:" not in config + + +def test_wizard_file_secret_ssid_is_not_quoted( + default_config: dict[str, Any], +) -> None: + default_config["ssid"] = "!secret wifi_ssid" + default_config["psk"] = "!secret wifi_password" + + config = wz.wizard_file(**default_config) + + assert " ssid: !secret wifi_ssid\n" in config + assert " password: !secret wifi_password\n" in config + + +def test_wizard_file_without_ssid_comments_out_credentials( + default_config: dict[str, Any], +) -> None: + del default_config["ssid"] + del default_config["psk"] + + config = wz.wizard_file(**default_config) + + assert ' # ssid: "My SSID"' in config + assert ' # password: "mypassword"' in config + + +def test_wizard_write_rejects_unknown_board( + tmp_path: Path, default_config: dict[str, Any], monkeypatch: MonkeyPatch +) -> None: + del default_config["platform"] + default_config["board"] = "not_a_real_board" + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) + + result = wz.wizard_write(tmp_path / "test.yaml", **default_config) + + assert result is False + assert not (tmp_path / "test.yaml").exists() + + +def test_wizard_returns_error_when_write_fails( + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] +) -> None: + monkeypatch.setattr("builtins.input", MagicMock(side_effect=wizard_answers)) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock(return_value=False)) + + assert wz.wizard(tmp_path / "test.yaml") == 1 + + +def test_quickwizard_env_replaces_sleep_with_noop(monkeypatch: MonkeyPatch) -> None: + """With ESPHOME_QUICKWIZARD set the module defines a no-op sleep.""" + monkeypatch.setenv("ESPHOME_QUICKWIZARD", "1") + # Load a private copy so the real module is left untouched + spec = importlib.util.spec_from_file_location("_quick_wizard", wz.__file__) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + assert module.sleep(10) is None + assert module.sleep.__module__ == "_quick_wizard" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 4575371822a..3400ce30f9d 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -2838,3 +2838,312 @@ def test_build_info_stale_branches( elif isinstance(content, str) and content != "ABSENT": info.write_text(content) assert _build_info_stale(h, cpp, info, 1) is expected, case + + +def test_get_flags_unions_component_flags() -> None: + """get_flags merges the flag set from every component with the key.""" + from esphome.writer import get_flags + + comp_a = MagicMock() + comp_a.build_flags.return_value = {"-DA"} + comp_b = MagicMock() + comp_b.build_flags.return_value = {"-DB", "-DA"} + with ( + patch("esphome.writer.CORE"), + patch( + "esphome.writer.iter_component_configs", + return_value=[("a", comp_a, {"x": 1}), ("b", comp_b, {})], + ), + ): + assert get_flags("build_flags") == {"-DA", "-DB"} + + comp_a.build_flags.assert_called_once_with({"x": 1}) + + +def test_get_include_text_collects_component_includes() -> None: + """get_include_text handles missing, callable, list and empty includes.""" + from esphome.writer import get_include_text + + no_includes = MagicMock(spec=[]) + callable_includes = MagicMock() + callable_includes.includes = lambda conf: f'#include "{conf["name"]}.h"' + none_includes = MagicMock() + none_includes.includes = None + list_includes = MagicMock() + list_includes.includes = ['#include "one.h"', '#include "two.h"'] + empty_includes = MagicMock() + empty_includes.includes = "" + configs = [ + ("a", no_includes, {}), + ("b", callable_includes, {"name": "dyn"}), + ("c", none_includes, {}), + ("d", list_includes, {}), + ("e", empty_includes, {}), + ] + with ( + patch("esphome.writer.CORE"), + patch("esphome.writer.iter_component_configs", return_value=configs), + ): + text = get_include_text() + + assert text == ( + '#include "esphome.h"\nusing namespace esphome;\n' + '#include "dyn.h"\n' + '#include "one.h"\n#include "two.h"\n' + ) + + +def test_replace_file_content() -> None: + """replace_file_content substitutes per line and reports the count.""" + from esphome.writer import replace_file_content + + new, count = replace_file_content("a=1\nb=2\na=3\n", r"^a=\d", "a=0") + assert new == "a=0\nb=2\na=0\n" + assert count == 2 + + +@patch("esphome.writer.save_compiled_config") +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +def test_update_storage_json_unchanged_does_nothing( + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + mock_save_compiled_config: MagicMock, + create_storage: Callable[..., StorageJSON], +) -> None: + """With no config to cache and identical storage, nothing is written.""" + mock_core.config = None + mock_storage_path.return_value = "/test/path" + old_storage = create_storage(loaded_integrations=["api"]) + new_storage = create_storage(loaded_integrations=["api"]) + new_storage.save = MagicMock() + mock_storage_json_class.load.return_value = old_storage + mock_storage_json_class.from_esphome_core.return_value = new_storage + + update_storage_json() + + mock_save_compiled_config.assert_not_called() + mock_clean_build.assert_not_called() + new_storage.save.assert_not_called() + + +@patch("esphome.writer.clean_cmake_cache") +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +def test_update_storage_json_cleans_cmake_cache_when_integrations_added( + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + mock_clean_cmake_cache: MagicMock, + create_storage: Callable[..., StorageJSON], + caplog: pytest.LogCaptureFixture, +) -> None: + """Adding an integration on ESP32 cleans only the cmake cache.""" + mock_core.config = None + mock_storage_path.return_value = "/test/path" + old_storage = create_storage( + loaded_integrations=["api"], core_platform=PLATFORM_ESP32 + ) + new_storage = create_storage( + loaded_integrations=["api", "wifi"], core_platform=PLATFORM_ESP32 + ) + new_storage.save = MagicMock() + mock_storage_json_class.load.return_value = old_storage + mock_storage_json_class.from_esphome_core.return_value = new_storage + + with caplog.at_level("INFO"): + update_storage_json() + + mock_clean_build.assert_not_called() + mock_clean_cmake_cache.assert_called_once_with() + assert "Integrations changed, cleaning cmake cache..." in caplog.text + new_storage.save.assert_called_once_with("/test/path") + + +def test_find_begin_end_errors() -> None: + """find_begin_end rejects a missing begin and duplicate end markers.""" + from esphome.writer import find_begin_end + + with pytest.raises(EsphomeError, match="Could not find auto generated code begin"): + find_begin_end("no markers", "BEGIN", "END") + + with pytest.raises(EsphomeError, match="Found multiple auto generate code endings"): + find_begin_end("BEGIN x END y END", "BEGIN", "END") + + assert find_begin_end("pre BEGIN x END post", "BEGIN", "END") == ("pre ", " post") + + +def _setup_copy_src_core(mock_core: MagicMock, tmp_path: Path) -> Path: + """Point a mocked CORE at tmp_path trees for copy_src_tree; return src dir.""" + src_path = tmp_path / "src" + src_path.mkdir() + build_path = tmp_path / "build" + build_path.mkdir() + mock_core.relative_src_path.side_effect = src_path.joinpath + mock_core.relative_build_path.side_effect = build_path.joinpath + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.comment = "" + mock_core.target_platform = "test_platform" + mock_core.config = {} + return src_path + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +def test_copy_src_tree_skips_unchanged_non_source_resource( + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """A non-source resource is ignored by the tree walk and copied only once.""" + src_path = _setup_copy_src_core(mock_core, tmp_path) + resource_src = tmp_path / "data.txt" + resource_src.write_text("payload") + + @contextmanager + def _path() -> Any: + yield resource_src + + resource = MagicMock() + resource.package = "esphome.components.foo" + resource.resource = "data.txt" + resource.path = _path + component = MagicMock() + component.resources = [resource] + mock_iter_components.return_value = [("foo", component)] + + with patch("esphome.writer.importlib.import_module", side_effect=AttributeError): + copy_src_tree() + copy_src_tree() + + copied = src_path / "esphome" / "components" / "foo" / "data.txt" + assert copied.read_text() == "payload" + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +def test_copy_src_tree_calls_platform_copy_files( + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """The target platform module's copy_files hook is invoked.""" + _setup_copy_src_core(mock_core, tmp_path) + mock_iter_components.return_value = [] + + with patch("esphome.writer.importlib.import_module") as mock_import: + copy_src_tree() + + mock_import.assert_called_once_with("esphome.components.test_platform") + mock_import.return_value.copy_files.assert_called_once_with() + + +def test_generate_version_h_rejects_unparseable_version() -> None: + """An unparseable version string raises a clear error.""" + from esphome.writer import generate_version_h + + with ( + patch("esphome.writer.__version__", "not-a-version"), + pytest.raises(EsphomeError, match="Could not parse version not-a-version"), + ): + generate_version_h() + + +@patch("esphome.writer.CORE") +def test_clean_build_full_without_build_path( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """full=True with no build path set only clears the idedata caches.""" + mock_core.build_path = None + mock_core.name = "test" + mock_core.relative_internal_path.side_effect = tmp_path.joinpath + idedata_cache = tmp_path / "idedata" / "test.json" + idedata_cache.parent.mkdir() + idedata_cache.write_text("{}") + + clean_build(clear_pio_cache=False, full=True) + + assert not idedata_cache.exists() + + +@pytest.mark.parametrize( + "yaml_text", + ["- just\n- a list\n", "esphome: not-a-dict\n"], +) +def test_get_custom_build_dir_ignores_non_mapping( + tmp_path: Path, yaml_text: str +) -> None: + """YAML that is not a mapping, or has a non-mapping esphome key, has no build dir.""" + from esphome.writer import _get_custom_build_dir + + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text(yaml_text) + + assert _get_custom_build_dir(yaml_file, tmp_path / ".esphome") is None + + +@patch("esphome.writer.CORE") +def test_clean_all_includes_ha_addon_and_data_dir_env( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """clean_all cleans the add-on data dir and ESPHOME_DATA_DIR when set.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + env_data = tmp_path / "env_data" + env_data.mkdir() + (env_data / "leftover.txt").write_text("x") + + real_is_dir = Path.is_dir + + def _is_dir(self: Path) -> bool: + # Never touch a real /data on the machine running the tests + return False if self == Path("/data") else real_is_dir(self) + + with ( + patch("esphome.writer.is_ha_addon", return_value=True), + patch.object(Path, "is_dir", _is_dir), + patch.dict(os.environ, {"ESPHOME_DATA_DIR": str(env_data)}), + ): + clean_all([str(config_dir)]) + + assert env_data.exists() + assert not (env_data / "leftover.txt").exists() + + +@patch("esphome.writer.clean_cmake_cache") +@patch("esphome.writer.clean_build") +@patch("esphome.writer.StorageJSON") +@patch("esphome.writer.storage_path") +@patch("esphome.writer.CORE") +def test_update_storage_json_saves_without_cleaning_for_minor_change( + mock_core: MagicMock, + mock_storage_path: MagicMock, + mock_storage_json_class: MagicMock, + mock_clean_build: MagicMock, + mock_clean_cmake_cache: MagicMock, + create_storage: Callable[..., StorageJSON], +) -> None: + """A change that needs no cleaning is saved without any clean.""" + mock_core.config = None + mock_storage_path.return_value = "/test/path" + old_storage = create_storage(friendly_name="Old") + new_storage = create_storage(friendly_name="New") + new_storage.save = MagicMock() + mock_storage_json_class.load.return_value = old_storage + mock_storage_json_class.from_esphome_core.return_value = new_storage + + update_storage_json() + + mock_clean_build.assert_not_called() + mock_clean_cmake_cache.assert_not_called() + new_storage.save.assert_called_once_with("/test/path") diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 8ce554f0a52..d2de5d1cab5 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1,10 +1,14 @@ -from collections.abc import Callable +from collections.abc import Callable, Generator +import importlib.util import io from pathlib import Path import shutil +import sys +from types import ModuleType from unittest.mock import patch import pytest +import yaml from esphome import core, yaml_util from esphome.components import substitutions @@ -22,7 +26,6 @@ from esphome.yaml_util import ( format_path, make_data_base, make_literal, - track_yaml_loads, ) @@ -34,6 +37,57 @@ def clear_core_frontmatter() -> None: core.CORE.frontmatter = {} +@pytest.fixture(scope="module") +def pure_python_yaml_util() -> Generator[ModuleType]: + """A copy of esphome.yaml_util imported as if libyaml were not installed.""" + name = "esphome._yaml_util_pure" + spec = importlib.util.spec_from_file_location(name, yaml_util.__file__) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module # dataclass(slots=True) needs the module registered + try: + with pytest.MonkeyPatch.context() as mp: + # Without the attribute "from yaml import CSafeLoader" fails, as it + # does when PyYAML is built without libyaml + mp.delattr(yaml, "CSafeLoader", raising=False) + spec.loader.exec_module(module) + yield module + finally: + sys.modules.pop(name, None) + + +@pytest.fixture(params=["c", "pure_python"]) +def yaml_util_module(request: pytest.FixtureRequest) -> ModuleType: + """The real yaml_util, then the copy without libyaml, for each loading test.""" + if request.param == "c": + return yaml_util + module: ModuleType = request.getfixturevalue("pure_python_yaml_util") + # The copy has its own caches; start each test with them empty + module._SECRET_CACHE.clear() + module._SECRET_VALUES.clear() + module._LOADED_SECRET_NAMES.clear() + return module + + +def test_pure_python_fallback_uses_pure_python_loader( + pure_python_yaml_util: ModuleType, +) -> None: + """Without libyaml the loader falls back to the pure Python SafeLoader.""" + assert pure_python_yaml_util.FastestAvailableSafeLoader is yaml.SafeLoader + assert issubclass(pure_python_yaml_util.ESPHomeLoader, yaml.SafeLoader) + if hasattr(yaml, "CSafeLoader"): + assert not issubclass(pure_python_yaml_util.ESPHomeLoader, yaml.CSafeLoader) + assert pure_python_yaml_util is not yaml_util + assert sys.modules["esphome.yaml_util"] is yaml_util + + +def test_real_module_uses_fastest_available_loader() -> None: + """The real module is untouched by the pure Python copy.""" + fastest = getattr(yaml, "CSafeLoader", yaml.SafeLoader) + assert yaml_util.FastestAvailableSafeLoader is fastest + assert issubclass(yaml_util.ESPHomeLoader, fastest) + + def test_include(fixture_path: Path) -> None: """Test !include with and without vars, with and without conditions""" yaml_file = fixture_path / "yaml_util" / "includetest.yaml" @@ -47,33 +101,35 @@ def test_include(fixture_path: Path) -> None: assert actual["wifi"]["ssid"] == "my_custom_ssid" -def test_include_with_no_file(fixture_path: Path) -> None: +def test_include_with_no_file(fixture_path: Path, yaml_util_module: ModuleType) -> None: """Ensure that an error is emitted when the file field is missing.""" yaml_file = fixture_path / "yaml_util" / "includetest_no_file.yaml" with pytest.raises(EsphomeError, match=r"Must include 'file'"): - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) -def test_include_with_invalid_condition_type(fixture_path: Path) -> None: +def test_include_with_invalid_condition_type( + fixture_path: Path, yaml_util_module: ModuleType +) -> None: """Ensure that an error is emitted when the condition field is an invalid type.""" yaml_file = fixture_path / "yaml_util" / "includetest_invalid_condition_type.yaml" with pytest.raises( EsphomeError, match=r"Include 'condition' must be a boolean or string" ): - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) -def test_loading_a_missing_file(fixture_path): +def test_loading_a_missing_file(fixture_path, yaml_util_module: ModuleType) -> None: """We throw EsphomeError when loading a missing file.""" yaml_file = fixture_path / "yaml_util" / "missing.yaml" with pytest.raises(EsphomeError, match=r"missing.yaml"): - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) -def test_parsing_with_custom_loader(fixture_path): +def test_parsing_with_custom_loader(fixture_path: Path) -> None: """Test custom loader used for vscode connection Default loader is tested in test_include """ @@ -96,11 +152,13 @@ def test_parsing_with_custom_loader(fixture_path): assert loader_calls[3].parts[-2:] == ("includes", "scalar.yaml") -def test_construct_secret_simple(fixture_path: Path) -> None: +def test_construct_secret_simple( + fixture_path: Path, yaml_util_module: ModuleType +) -> None: """Test loading a YAML file with !secret tags.""" yaml_file = fixture_path / "yaml_util" / "test_secret.yaml" - actual = yaml_util.load_yaml(yaml_file) + actual = yaml_util_module.load_yaml(yaml_file) # Check that secrets were properly loaded assert actual["wifi"]["password"] == "super_secret_wifi" @@ -108,7 +166,9 @@ def test_construct_secret_simple(fixture_path: Path) -> None: assert actual["sensor"][0]["id"] == "my_secret_value" -def test_construct_secret_missing(fixture_path: Path, tmp_path: Path) -> None: +def test_construct_secret_missing( + fixture_path: Path, tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Test that missing secrets raise proper errors.""" # Create a YAML file with a secret that doesn't exist test_yaml = tmp_path / "test.yaml" @@ -127,10 +187,12 @@ wifi: secrets_yaml.write_text("some_other_secret: value") with pytest.raises(EsphomeError, match="Secret 'nonexistent_secret' not defined"): - yaml_util.load_yaml(test_yaml) + yaml_util_module.load_yaml(test_yaml) -def test_construct_secret_no_secrets_file(tmp_path: Path) -> None: +def test_construct_secret_no_secrets_file( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Test that missing secrets.yaml file raises proper error.""" # Create a YAML file with a secret but no secrets.yaml test_yaml = tmp_path / "test.yaml" @@ -146,11 +208,11 @@ wifi: patch.object(core.CORE, "config_path", tmp_path / "main.yaml"), pytest.raises(EsphomeError, match="secrets.yaml"), ): - yaml_util.load_yaml(test_yaml) + yaml_util_module.load_yaml(test_yaml) def test_construct_secret_fallback_to_main_config_dir( - fixture_path: Path, tmp_path: Path + fixture_path: Path, tmp_path: Path, yaml_util_module: ModuleType ) -> None: """Test fallback to main config directory for secrets.""" # Create a subdirectory with a YAML file that uses secrets @@ -171,11 +233,13 @@ wifi: # Mock CORE.config_path to point to main directory with patch.object(core.CORE, "config_path", tmp_path / "main.yaml"): - actual = yaml_util.load_yaml(test_yaml) + actual = yaml_util_module.load_yaml(test_yaml) assert actual["wifi"]["password"] == "main_secret_value" -def test_construct_include_dir_named(fixture_path: Path, tmp_path: Path) -> None: +def test_construct_include_dir_named( + fixture_path: Path, tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Test !include_dir_named directive.""" # Copy fixture directory to temporary location src_dir = fixture_path / "yaml_util" @@ -190,7 +254,7 @@ sensor: !include_dir_named named_dir """ ) - actual = yaml_util.load_yaml(test_yaml) + actual = yaml_util_module.load_yaml(test_yaml) actual_sensor = actual["sensor"] # Check that files were loaded with their names as keys @@ -214,7 +278,9 @@ sensor: !include_dir_named named_dir assert "not_yaml" not in actual_sensor -def test_construct_include_dir_named_empty_dir(tmp_path: Path) -> None: +def test_construct_include_dir_named_empty_dir( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Test !include_dir_named with empty directory.""" # Create empty directory empty_dir = tmp_path / "empty_dir" @@ -227,14 +293,16 @@ sensor: !include_dir_named empty_dir """ ) - actual = yaml_util.load_yaml(test_yaml) + actual = yaml_util_module.load_yaml(test_yaml) # Should return empty OrderedDict assert isinstance(actual["sensor"], OrderedDict) assert len(actual["sensor"]) == 0 -def test_construct_include_dir_named_with_dots(tmp_path: Path) -> None: +def test_construct_include_dir_named_with_dots( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Test that include_dir_named ignores files starting with dots.""" # Create directory with various files test_dir = tmp_path / "test_dir" @@ -261,7 +329,7 @@ test: !include_dir_named test_dir """ ) - actual = yaml_util.load_yaml(test_yaml) + actual = yaml_util_module.load_yaml(test_yaml) # Should only include visible file assert "visible" in actual["test"] @@ -272,7 +340,7 @@ test: !include_dir_named test_dir assert ".hidden_dir" not in actual["test"] -def test_include_dir_list(tmp_path: Path) -> None: +def test_include_dir_list(tmp_path: Path, yaml_util_module: ModuleType) -> None: """!include_dir_list loads every .yaml file in the directory as a list.""" test_dir = tmp_path / "test_dir" test_dir.mkdir() @@ -282,13 +350,13 @@ def test_include_dir_list(tmp_path: Path) -> None: test_yaml = tmp_path / "test.yaml" test_yaml.write_text("test: !include_dir_list test_dir\n") - actual = yaml_util.load_yaml(test_yaml) + actual = yaml_util_module.load_yaml(test_yaml) assert len(actual["test"]) == 2 assert {entry["key"] for entry in actual["test"]} == {"value_a", "value_b"} -def test_include_dir_merge_list(tmp_path: Path) -> None: +def test_include_dir_merge_list(tmp_path: Path, yaml_util_module: ModuleType) -> None: """!include_dir_merge_list concatenates the lists from every .yaml file.""" test_dir = tmp_path / "test_dir" test_dir.mkdir() @@ -298,12 +366,12 @@ def test_include_dir_merge_list(tmp_path: Path) -> None: test_yaml = tmp_path / "test.yaml" test_yaml.write_text("test: !include_dir_merge_list test_dir\n") - actual = yaml_util.load_yaml(test_yaml) + actual = yaml_util_module.load_yaml(test_yaml) assert sorted(actual["test"]) == ["item_a1", "item_a2", "item_b1"] -def test_include_dir_merge_named(tmp_path: Path) -> None: +def test_include_dir_merge_named(tmp_path: Path, yaml_util_module: ModuleType) -> None: """!include_dir_merge_named merges the mappings from every .yaml file.""" test_dir = tmp_path / "test_dir" test_dir.mkdir() @@ -313,12 +381,14 @@ def test_include_dir_merge_named(tmp_path: Path) -> None: test_yaml = tmp_path / "test.yaml" test_yaml.write_text("test: !include_dir_merge_named test_dir\n") - actual = yaml_util.load_yaml(test_yaml) + actual = yaml_util_module.load_yaml(test_yaml) assert actual["test"] == {"key_a": "value_a", "key_b": "value_b"} -def test_find_files_recursive(fixture_path: Path, tmp_path: Path) -> None: +def test_find_files_recursive( + fixture_path: Path, tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Test that find_files works recursively through include_dir_named.""" # Copy fixture directory to temporary location src_dir = fixture_path / "yaml_util" @@ -333,7 +403,7 @@ all_sensors: !include_dir_named named_dir """ ) - actual = yaml_util.load_yaml(test_yaml) + actual = yaml_util_module.load_yaml(test_yaml) # Should find sensor1.yaml, sensor2.yaml, and subdir/sensor3.yaml (all flattened) assert len(actual["all_sensors"]) == 3 @@ -342,17 +412,19 @@ all_sensors: !include_dir_named named_dir assert "sensor3" in actual["all_sensors"] -def test_secret_values_tracking(fixture_path: Path) -> None: +def test_secret_values_tracking( + fixture_path: Path, yaml_util_module: ModuleType +) -> None: """Test that secret values are properly tracked for dumping.""" yaml_file = fixture_path / "yaml_util" / "test_secret.yaml" - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) # Check that secret values are tracked - assert "super_secret_wifi" in yaml_util._SECRET_VALUES - assert yaml_util._SECRET_VALUES["super_secret_wifi"] == "wifi_password" - assert "0123456789abcdef" in yaml_util._SECRET_VALUES - assert yaml_util._SECRET_VALUES["0123456789abcdef"] == "api_key" + assert "super_secret_wifi" in yaml_util_module._SECRET_VALUES + assert yaml_util_module._SECRET_VALUES["super_secret_wifi"] == "wifi_password" + assert "0123456789abcdef" in yaml_util_module._SECRET_VALUES + assert yaml_util_module._SECRET_VALUES["0123456789abcdef"] == "api_key" def test_dump_sort_keys() -> None: @@ -388,27 +460,31 @@ def test_dump_sort_keys() -> None: # --------------------------------------------------------------------------- -def test_track_yaml_loads_records_files(tmp_path: Path) -> None: - """track_yaml_loads records every file loaded inside the context.""" +def test_track_yaml_loads_records_files( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """yaml_util_module.track_yaml_loads records every file loaded inside the context.""" yaml_file = tmp_path / "test.yaml" yaml_file.write_text("key: value\n") - with yaml_util.track_yaml_loads() as loaded: - yaml_util.load_yaml(yaml_file) + with yaml_util_module.track_yaml_loads() as loaded: + yaml_util_module.load_yaml(yaml_file) assert len(loaded) == 1 assert loaded[0] == yaml_file.resolve() -def test_track_yaml_loads_records_includes(tmp_path: Path) -> None: - """track_yaml_loads records nested !include files.""" +def test_track_yaml_loads_records_includes( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """yaml_util_module.track_yaml_loads records nested !include files.""" inc = tmp_path / "included.yaml" inc.write_text("included_key: 42\n") main = tmp_path / "main.yaml" main.write_text("child: !include included.yaml\n") - with yaml_util.track_yaml_loads() as loaded: - result = yaml_util.load_yaml(main) + with yaml_util_module.track_yaml_loads() as loaded: + result = yaml_util_module.load_yaml(main) # !include is deferred; resolve it to trigger the nested load result["child"].load() @@ -417,57 +493,61 @@ def test_track_yaml_loads_records_includes(tmp_path: Path) -> None: assert "included.yaml" in resolved -def test_track_yaml_loads_empty_outside_context(tmp_path: Path) -> None: +def test_track_yaml_loads_empty_outside_context( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Files loaded outside the context are not recorded.""" yaml_file = tmp_path / "test.yaml" yaml_file.write_text("key: value\n") - with yaml_util.track_yaml_loads() as loaded: + with yaml_util_module.track_yaml_loads() as loaded: pass # load nothing inside - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) assert loaded == [] -def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None: +def test_track_yaml_loads_cleanup_on_exception( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Listener is removed even if the body raises.""" - before = len(yaml_util.load_listeners) + before = len(yaml_util_module.load_listeners) - with pytest.raises(RuntimeError), yaml_util.track_yaml_loads(): + with pytest.raises(RuntimeError), yaml_util_module.track_yaml_loads(): raise RuntimeError("boom") - assert len(yaml_util.load_listeners) == before + assert len(yaml_util_module.load_listeners) == before def test_load_listener_removing_itself_does_not_skip_the_next( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, yaml_util_module: ModuleType ) -> None: """A listener that removes itself while notified does not skip the one after it.""" yaml_file = tmp_path / "test.yaml" yaml_file.write_text("key: value\n") listeners: list[Callable[[Path], None]] = [] - monkeypatch.setattr(yaml_util, "load_listeners", listeners) + monkeypatch.setattr(yaml_util_module, "load_listeners", listeners) seen: list[Path] = [] def first(fname: Path) -> None: listeners.remove(first) listeners.extend((first, seen.append)) - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) assert seen == [yaml_file.resolve()] assert listeners == [seen.append] def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure( - tmp_path: Path, + tmp_path: Path, yaml_util_module: ModuleType ) -> None: - """A failed top-level !include must not record any file twice in track_yaml_loads.""" + """A failed top-level !include must not record any file twice in yaml_util_module.track_yaml_loads.""" main = tmp_path / "main.yaml" main.write_text("!include missing.yaml\n") - with yaml_util.track_yaml_loads() as loaded, pytest.raises(EsphomeError): - yaml_util.load_yaml(main) + with yaml_util_module.track_yaml_loads() as loaded, pytest.raises(EsphomeError): + yaml_util_module.load_yaml(main) assert len(loaded) == len(set(loaded)), ( f"Files loaded more than once during a failed top-level include: {loaded}" @@ -818,12 +898,14 @@ def test_include_file_should_load( include.should_load() -def test_mapping_include_non_string_file_rejected(tmp_path: Path) -> None: +def test_mapping_include_non_string_file_rejected( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """The mapping !include form rejects a non-string 'file' with a clear error.""" entry = tmp_path / "entry.yaml" entry.write_text("wifi: !include\n file: [not, a, string]\n") with pytest.raises(EsphomeError, match="Include 'file' must be a string"): - yaml_util.load_yaml(entry) + yaml_util_module.load_yaml(entry) def test_include_file_templated_filename_stays_raw_string(tmp_path: Path) -> None: @@ -863,14 +945,16 @@ def test_include_in_list_context() -> None: assert config["values"] == ["alpha", "beta", "gamma"] -def test_top_level_include_resolved_by_load_yaml(tmp_path: Path) -> None: +def test_top_level_include_resolved_by_load_yaml( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """load_yaml resolves a top-level !include so callers always get a dict.""" child = tmp_path / "child.yaml" child.write_text("key: value\n") main = tmp_path / "main.yaml" main.write_text("!include child.yaml\n") - result = yaml_util.load_yaml(main) + result = yaml_util_module.load_yaml(main) assert isinstance(result, dict) assert result["key"] == "value" @@ -974,7 +1058,9 @@ def test_yaml_merge_chain_include_resolves() -> None: assert config["base"]["existing"] == "value" -def test_parse_yaml_custom_loader_resolves_root_include() -> None: +def test_parse_yaml_custom_loader_resolves_root_include( + yaml_util_module: ModuleType, +) -> None: """A document that is only `!include` is resolved for a custom loader too.""" parent = Path("/fake/main.yaml") loader_calls: list[Path] = [] @@ -983,7 +1069,7 @@ def test_parse_yaml_custom_loader_resolves_root_include() -> None: loader_calls.append(fname) return {"x": 1} - config = yaml_util.parse_yaml( + config = yaml_util_module.parse_yaml( parent, io.StringIO("!include inner.yaml\n"), custom_loader ) @@ -1723,14 +1809,16 @@ def test_discover_user_yaml_files_tolerates_templated_top_level_include( assert (tmp_path / "wrapper.yaml").resolve() in discovered.files -def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: - """`track_yaml_loads` is the building block — sanity-check it resolves +def test_track_yaml_loads_records_resolved_paths( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """`track_yaml_loads` is the building block - sanity-check it resolves symlinks so callers can dedupe by identity.""" target = _write(tmp_path, "actual.yaml", "esphome:\n name: t\n") link = tmp_path / "alias.yaml" link.symlink_to(target) - with track_yaml_loads() as loaded: - yaml_util.load_yaml(link) + with yaml_util_module.track_yaml_loads() as loaded: + yaml_util_module.load_yaml(link) assert target.resolve() in loaded @@ -1739,7 +1827,9 @@ def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None: +def test_frontmatter_parsed_and_stored_on_core( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """A leading `---`-separated YAML document is stored as frontmatter and stripped from the returned config.""" yaml_file = tmp_path / "main.yaml" @@ -1747,7 +1837,7 @@ def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None: "author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n" ) - config = yaml_util.load_yaml(yaml_file) + config = yaml_util_module.load_yaml(yaml_file) # Config does not contain frontmatter keys assert "author" not in config @@ -1760,27 +1850,33 @@ def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None: assert frontmatter["labels"] == ["office", "climate"] -def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None: +def test_frontmatter_absent_when_single_document( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """A YAML file with a single document does not populate CORE.frontmatter.""" yaml_file = tmp_path / "main.yaml" yaml_file.write_text("esphome:\n name: my_node\n") - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) assert yaml_file.resolve() not in core.CORE.frontmatter -def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None: +def test_frontmatter_absent_when_leading_doc_separator( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """A leading `---` with no content above it is just a document start marker, not frontmatter, and must not populate CORE.frontmatter.""" yaml_file = tmp_path / "main.yaml" yaml_file.write_text("---\nesphome:\n name: my_node\n") - config = yaml_util.load_yaml(yaml_file) + config = yaml_util_module.load_yaml(yaml_file) assert config["esphome"]["name"] == "my_node" assert yaml_file.resolve() not in core.CORE.frontmatter -def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None: +def test_frontmatter_supports_arbitrary_keys( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Frontmatter keys are not validated — any structure is accepted.""" yaml_file = tmp_path / "main.yaml" yaml_file.write_text( @@ -1795,14 +1891,16 @@ def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None: " name: t\n" ) - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) frontmatter = core.CORE.frontmatter[yaml_file.resolve()] assert frontmatter["any_key"] == "any_value" assert frontmatter["nested"]["count"] == 42 assert frontmatter["nested"]["items"] == ["a", "b"] -def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None: +def test_frontmatter_supports_deeply_nested_paths( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Frontmatter preserves deeply nested dict/list structures intact.""" yaml_file = tmp_path / "main.yaml" yaml_file.write_text( @@ -1827,7 +1925,7 @@ def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None: " name: t\n" ) - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) fm = core.CORE.frontmatter[yaml_file.resolve()] room = fm["device"]["metadata"]["location"]["room"] assert room["number"] == 302 @@ -1837,36 +1935,44 @@ def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None: assert room["occupants"][1]["role"]["title"] == "contributor" -def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None: +def test_frontmatter_more_than_two_documents_raises( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Three or more YAML documents is unsupported and must raise.""" yaml_file = tmp_path / "main.yaml" yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n") with pytest.raises(EsphomeError, match="at most two are supported"): - yaml_util.load_yaml(yaml_file) + yaml_util_module.load_yaml(yaml_file) -def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None: +def test_frontmatter_empty_frontmatter_doc_not_stored( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """An empty (null) frontmatter document is treated as no frontmatter.""" yaml_file = tmp_path / "main.yaml" yaml_file.write_text("---\n---\nesphome:\n name: t\n") - config = yaml_util.load_yaml(yaml_file) + config = yaml_util_module.load_yaml(yaml_file) assert config["esphome"]["name"] == "t" assert yaml_file.resolve() not in core.CORE.frontmatter -def test_frontmatter_empty_config_doc(tmp_path: Path) -> None: +def test_frontmatter_empty_config_doc( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """An empty config document after a frontmatter document yields an empty config.""" yaml_file = tmp_path / "main.yaml" yaml_file.write_text("only: frontmatter\n---\n") - config = yaml_util.load_yaml(yaml_file) + config = yaml_util_module.load_yaml(yaml_file) assert config == {} assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter" -def test_frontmatter_included_file_stored(tmp_path: Path) -> None: +def test_frontmatter_included_file_stored( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """Frontmatter on an !include'd file is also captured on CORE, keyed by that file's resolved path.""" inc = tmp_path / "child.yaml" @@ -1874,7 +1980,7 @@ def test_frontmatter_included_file_stored(tmp_path: Path) -> None: main = tmp_path / "main.yaml" main.write_text("esphome:\n name: t\nchild: !include child.yaml\n") - config = yaml_util.load_yaml(main) + config = yaml_util_module.load_yaml(main) # !include is deferred; force resolution so the child file actually loads force_load_include_files(config) assert config["child"].load()["child_key"] == "value" @@ -2141,7 +2247,9 @@ def clear_dropped_merge_keys() -> None: core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) -def test_merge_include_records_dropped_keys(tmp_path: Path) -> None: +def test_merge_include_records_dropped_keys( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """A `<<` merge that overlaps an existing key records it (shallow first-wins).""" (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") (tmp_path / "b.yaml").write_text("api:\n password: secret\n") @@ -2149,21 +2257,23 @@ def test_merge_include_records_dropped_keys(tmp_path: Path) -> None: test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") with patch.object(core.CORE, "config_path", test_yaml): - result = yaml_util.load_yaml(test_yaml) + result = yaml_util_module.load_yaml(test_yaml) # First definition wins; the second `api` block is dropped entirely. assert result["api"] == {"reboot_timeout": "5min"} - dropped = yaml_util.take_dropped_merge_keys() + dropped = yaml_util_module.take_dropped_merge_keys() assert len(dropped) == 1 key, location = dropped[0] assert key == "api" assert "b.yaml" in location # Queue is drained after being taken. - assert yaml_util.take_dropped_merge_keys() == [] + assert yaml_util_module.take_dropped_merge_keys() == [] -def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: +def test_merge_include_no_overlap_records_nothing( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: """A `<<` merge with distinct top-level keys drops nothing.""" (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") (tmp_path / "b.yaml").write_text("logger:\n level: DEBUG\n") @@ -2171,11 +2281,11 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") with patch.object(core.CORE, "config_path", test_yaml): - result = yaml_util.load_yaml(test_yaml) + result = yaml_util_module.load_yaml(test_yaml) assert result["api"] == {"reboot_timeout": "5min"} assert result["logger"] == {"level": "DEBUG"} - assert yaml_util.take_dropped_merge_keys() == [] + assert yaml_util_module.take_dropped_merge_keys() == [] def test_wrapper_representers_consult_is_secret( @@ -2248,19 +2358,24 @@ def fast_mode_config_dir(tmp_path: Path) -> Path: return tmp_path -def _resolve_includes(config: dict) -> dict: +def _resolve_includes(config: dict, module: ModuleType) -> dict: return { - key: value.load() if isinstance(value, yaml_util.IncludeFile) else value + key: value.load() if isinstance(value, module.IncludeFile) else value for key, value in config.items() } -def test_load_yaml_fast_mode_matches_default(fast_mode_config_dir: Path) -> None: +def test_load_yaml_fast_mode_matches_default( + fast_mode_config_dir: Path, yaml_util_module: ModuleType +) -> None: """Both modes produce equal values; only the metadata wrapping differs.""" yaml_file = fast_mode_config_dir / "main.yaml" - normal = _resolve_includes(yaml_util.load_yaml(yaml_file)) - fast = _resolve_includes(yaml_util.load_yaml(yaml_file, track_document_range=False)) + normal = _resolve_includes(yaml_util_module.load_yaml(yaml_file), yaml_util_module) + fast = _resolve_includes( + yaml_util_module.load_yaml(yaml_file, track_document_range=False), + yaml_util_module, + ) # Lambda has no __eq__; compare it by value and the rest structurally. fast_lambda = fast.pop("lambda_value") @@ -2274,37 +2389,37 @@ def test_load_yaml_fast_mode_matches_default(fast_mode_config_dir: Path) -> None assert fast["extend_value"] == Extend("some_id") assert fast["remove_value"] == Remove("some_id") # !literal wraps via make_literal, independent of range tracking. - assert isinstance(fast["literal_value"], ESPLiteralValue) + assert isinstance(fast["literal_value"], yaml_util_module.ESPLiteralValue) assert fast["literal_value"] == "keep_me_verbatim" # Fast mode returns plain values; default mode keeps the range metadata. - assert not isinstance(fast["number_value"], ESPHomeDataBase) - assert not isinstance(fast["float_value"], ESPHomeDataBase) + assert not isinstance(fast["number_value"], yaml_util_module.ESPHomeDataBase) + assert not isinstance(fast["float_value"], yaml_util_module.ESPHomeDataBase) assert all(type(key) is str for key in fast) - assert isinstance(normal["number_value"], ESPHomeDataBase) + assert isinstance(normal["number_value"], yaml_util_module.ESPHomeDataBase) assert normal["number_value"].esp_range is not None - assert all(isinstance(key, ESPHomeDataBase) for key in normal) + assert all(isinstance(key, yaml_util_module.ESPHomeDataBase) for key in normal) # Nested includes inherit fast mode through the recursive loader. included = fast["included"] - assert not isinstance(included["inner_num"], ESPHomeDataBase) + assert not isinstance(included["inner_num"], yaml_util_module.ESPHomeDataBase) assert all(type(key) is str for key in included) def test_load_yaml_fast_mode_survives_pure_python_fallback( - fast_mode_config_dir: Path, + fast_mode_config_dir: Path, yaml_util_module: ModuleType ) -> None: """The ESPHomePurePythonLoader retry must honour fast mode too.""" yaml_file = fast_mode_config_dir / "main.yaml" - class _AlwaysFailingLoader(yaml_util.ESPHomeLoader): + class _AlwaysFailingLoader(yaml_util_module.ESPHomeLoader): def __init__(self, *args, **kwargs) -> None: raise EsphomeError("forced fallback to the pure-Python loader") - with patch.object(yaml_util, "ESPHomeLoader", _AlwaysFailingLoader): - fast = yaml_util.load_yaml(yaml_file, track_document_range=False) + with patch.object(yaml_util_module, "ESPHomeLoader", _AlwaysFailingLoader): + fast = yaml_util_module.load_yaml(yaml_file, track_document_range=False) - assert not isinstance(fast["number_value"], ESPHomeDataBase) + assert not isinstance(fast["number_value"], yaml_util_module.ESPHomeDataBase) assert all(type(key) is str for key in fast) @@ -2320,7 +2435,7 @@ def test_load_yaml_fast_mode_rejects_custom_loader() -> None: def test_load_yaml_fast_mode_records_dropped_merge_keys( - fast_mode_config_dir: Path, + fast_mode_config_dir: Path, yaml_util_module: ModuleType ) -> None: """The duplicate-merge-key bookkeeping must not crash on plain str keys. @@ -2329,8 +2444,8 @@ def test_load_yaml_fast_mode_records_dropped_merge_keys( """ yaml_file = fast_mode_config_dir / "main.yaml" - yaml_util.load_yaml(yaml_file, track_document_range=False) - assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))] + yaml_util_module.load_yaml(yaml_file, track_document_range=False) + assert yaml_util_module.take_dropped_merge_keys() == [("port", str(yaml_file))] def test_find_secret_references_in_flow_collections() -> None: @@ -2355,3 +2470,261 @@ def test_content_offset_only_for_block_scalars( doc = yaml_util.parse_yaml(Path("test.yaml"), io.StringIO(yaml_text)) assert doc["value"].content_offset == expected_offset assert doc["value"].content_start.line == expected_offset + + +def test_config_context_copy_to_list_children() -> None: + """Context on a list is pushed down onto each of its items.""" + tagged = yaml_util.add_context(["a", {"k": "v"}], {"x": 1}) + tagged.copy_context_to_children() + assert all(isinstance(item, yaml_util.ConfigContext) for item in tagged) + assert tagged[0].vars == {"x": 1} + + +@pytest.mark.parametrize("track_document_range", [True, False]) +def test_load_binary_and_omap_tags( + tmp_path: Path, track_document_range: bool, yaml_util_module: ModuleType +) -> None: + """The !!binary and !!omap tags construct the same values in both modes.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("blob: !!binary aGk=\nordered: !!omap [{a: 1}, {b: 2}]\n") + config = yaml_util_module.load_yaml( + yaml_file, track_document_range=track_document_range + ) + assert bytes(config["blob"]) == b"hi" + assert config["ordered"] == [("a", 1), ("b", 2)] + + +def test_load_equals_key_is_a_string( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """A bare = key has the value tag and is loaded as a plain string.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("=: 1\n") + assert yaml_util_module.load_yaml(yaml_file) == {"=": 1} + + +def test_load_unhashable_key_rejected( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """A sequence used as a mapping key is reported as not hashable.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("? [a, b]\n: 1\n") + with pytest.raises(EsphomeError, match="not hashable"): + yaml_util_module.load_yaml(yaml_file) + + +def test_load_duplicate_key_rejected( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """A repeated mapping key is an error pointing at the first declaration.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("a: 1\na: 2\n") + with pytest.raises(EsphomeError, match='Duplicate key "a"'): + yaml_util_module.load_yaml(yaml_file) + + +def test_merge_key_empty_list_adds_nothing( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """Merging an empty list leaves the mapping as written.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("base:\n <<: []\n a: 1\n") + assert yaml_util_module.load_yaml(yaml_file) == {"base": {"a": 1}} + + +def test_merge_key_list_item_must_be_mapping( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """Every item of a merged sequence has to be a mapping.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("base:\n <<: [{a: 1}, 5]\n") + with pytest.raises(EsphomeError, match="Expected a mapping for merging"): + yaml_util_module.load_yaml(yaml_file) + + +def test_merge_key_value_must_be_mapping_or_list( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """A scalar cannot be merged.""" + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text("base:\n <<: 5\n") + with pytest.raises(EsphomeError, match="Expected a mapping or list of mappings"): + yaml_util_module.load_yaml(yaml_file) + + +def test_env_var_tag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, yaml_util_module: ModuleType +) -> None: + """!env_var reads the environment, with an optional default.""" + monkeypatch.setenv("ESPHOME_TEST_SET", "from_env") + monkeypatch.delenv("ESPHOME_TEST_UNSET", raising=False) + yaml_file = tmp_path / "main.yaml" + yaml_file.write_text( + "a: !env_var ESPHOME_TEST_SET\n" + "b: !env_var ESPHOME_TEST_UNSET some default\n" + "c: !env_var ESPHOME_TEST_UNSET\n" + ) + with pytest.raises(EsphomeError, match="'ESPHOME_TEST_UNSET' not defined"): + yaml_util_module.load_yaml(yaml_file) + + yaml_file.write_text( + "a: !env_var ESPHOME_TEST_SET\nb: !env_var ESPHOME_TEST_UNSET some default\n" + ) + assert yaml_util_module.load_yaml(yaml_file) == { + "a": "from_env", + "b": "some default", + } + + +def test_construct_secret_missing_file_in_main_config( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """When the main config itself has no secrets.yaml the error is raised as is.""" + main_yaml = tmp_path / "main.yaml" + main_yaml.write_text("wifi:\n password: !secret some_secret\n") + with ( + patch.object(core.CORE, "config_path", main_yaml), + pytest.raises(EsphomeError, match="secrets.yaml"), + ): + yaml_util_module.load_yaml(main_yaml) + + +def test_include_dir_merge_list_skips_non_lists( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """Files that do not hold a list are ignored by !include_dir_merge_list.""" + inc = tmp_path / "inc" + inc.mkdir() + (inc / "a.yaml").write_text("- 1\n- 2\n") + (inc / "b.yaml").write_text("key: value\n") + main_yaml = tmp_path / "main.yaml" + main_yaml.write_text("items: !include_dir_merge_list inc\n") + assert yaml_util_module.load_yaml(main_yaml) == {"items": [1, 2]} + + +def test_include_dir_merge_named_skips_non_mappings( + tmp_path: Path, yaml_util_module: ModuleType +) -> None: + """Files that do not hold a mapping are ignored by !include_dir_merge_named.""" + inc = tmp_path / "inc" + inc.mkdir() + (inc / "a.yaml").write_text("x: 1\n") + (inc / "b.yaml").write_text("- 1\n") + main_yaml = tmp_path / "main.yaml" + main_yaml.write_text("items: !include_dir_merge_named inc\n") + assert yaml_util_module.load_yaml(main_yaml) == {"items": {"x": 1}} + + +def test_construct_literal_of_unknown_node_type() -> None: + """A node that is not a scalar, sequence or mapping yields no literal value.""" + loader = yaml_util.ESPHomeLoader( + io.StringIO(""), + Path("main.yaml"), + yaml_util.load_yaml, + track_document_range=True, + ) + node = yaml.Node("!literal", "x", None, None) + assert loader.construct_literal(node) is None + + +def test_dump_aliased_mapping_and_pair_list() -> None: + """A shared mapping is anchored, and the dumper accepts a list of pairs.""" + shared = {"a": 1} + dumped = yaml_util.dump({"x": shared, "y": shared}) + assert "&id001" in dumped + assert "*id001" in dumped + + dumper = yaml_util.ESPHomeDumper(io.StringIO(), default_flow_style=None) + node = dumper.represent_mapping("tag:yaml.org,2002:map", [("k", 1)]) + assert node.flow_style is True + + +def test_dump_flow_style_of_nested_mapping() -> None: + """A mapping whose values are not plain scalars is dumped in block style.""" + dumped = yaml_util.dump({"outer": {"inner": [1, 2]}}) + assert dumped == "outer:\n inner:\n - 1\n - 2\n" + + +def test_dump_default_flow_style_overrides_best_style() -> None: + """An explicit default flow style on the dumper is applied to mappings.""" + dumper = yaml_util.ESPHomeDumper(io.StringIO(), default_flow_style=False) + node = dumper.represent_mapping("tag:yaml.org,2002:map", {"a": 1}) + assert node.flow_style is False + + +def test_dump_bool_representer() -> None: + """Booleans use the lowercase YAML spellings.""" + dumper = yaml_util.ESPHomeDumper(io.StringIO()) + assert dumper.represent_bool(True).value == "true" + assert dumper.represent_bool(False).value == "false" + + +def test_dump_float_subclass_spellings() -> None: + """Float subclasses reach the ESPHome float representer and use YAML spellings.""" + + class Number(float): + pass + + dumped = yaml_util.dump( + { + "nan": Number("nan"), + "inf": Number("inf"), + "ninf": Number("-inf"), + "big": Number(1e17), + "plain": Number(1.5), + } + ) + assert dumped == ("nan: .nan\ninf: .inf\nninf: -.inf\nbig: 1.0e+17\nplain: 1.5\n") + + +def test_dump_mapping_with_styled_key_is_block_style() -> None: + """A key that carries a scalar style rules out the compact flow style.""" + dumper = yaml_util.ESPHomeDumper(io.StringIO(), default_flow_style=None) + node = dumper.represent_mapping( + "tag:yaml.org,2002:map", [(core.Lambda("return 1;"), 1)] + ) + assert node.flow_style is False + + +def test_config_context_copy_to_children_ignores_strings() -> None: + """A tagged string has no children to push the context onto.""" + tagged = yaml_util.add_context("text", {"x": 1}) + tagged.copy_context_to_children() + assert tagged.vars == {"x": 1} + + +def test_add_data_ref_drains_generator_constructors() -> None: + """A constructor that is a generator is run to completion, not just started.""" + finished: list[bool] = [] + + @yaml_util._add_data_ref + def construct(loader: object, node: yaml.Node) -> Generator[str]: + yield "value" + yield "extra" + finished.append(True) + + class FakeLoader: + track_document_range = False + + assert construct(FakeLoader(), yaml.ScalarNode("tag", "v")) == "value" + assert finished == [True] + + +def test_dump_secret_valued_float_lambda_and_id() -> None: + """Float subclasses, lambdas and IDs holding a registered secret are !secret.""" + + class SecretFloat(float): + pass + + values = {"1.5": "float_secret", "return 1;": "lambda_secret", "my_id": "id_secret"} + with yaml_util.secret_values_registered(values): + dumped = yaml_util.dump( + { + "f": SecretFloat(1.5), + "l": core.Lambda("return 1;"), + "i": core.ID("my_id"), + } + ) + assert "!secret 'float_secret'" in dumped + assert "!secret 'lambda_secret'" in dumped + assert "!secret 'id_secret'" in dumped diff --git a/tests/unit_tests/test_zeroconf.py b/tests/unit_tests/test_zeroconf.py index ae801868acf..8398945f0c9 100644 --- a/tests/unit_tests/test_zeroconf.py +++ b/tests/unit_tests/test_zeroconf.py @@ -10,14 +10,24 @@ the callback signature, the ``import_state`` dict shape, or the from __future__ import annotations -from unittest.mock import MagicMock +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch +import pytest from zeroconf import ServiceStateChange +from esphome import zeroconf as zc from esphome.zeroconf import ( ESPHOME_SERVICE_TYPE, + AsyncEsphomeZeroconf, DashboardImportDiscovery, + DashboardStatus, DiscoveredImport, + EsphomeZeroconf, + async_discover_mdns_devices, + async_resolve_hosts, + discover_mdns_devices, ) @@ -253,3 +263,356 @@ def test_callback_is_optional() -> None: # No callback to assert against; just verify state landed. assert name in discovery.import_state + + +@pytest.mark.parametrize( + ("state_change", "expected"), + [ + (ServiceStateChange.Added, {"node": True}), + (ServiceStateChange.Updated, {"node": True}), + (ServiceStateChange.Removed, {"node": False}), + ], +) +def test_dashboard_status_callback( + state_change: ServiceStateChange, expected: dict[str, bool] +) -> None: + """Add/Update mark the node online, Remove marks it offline.""" + on_update = MagicMock() + status = DashboardStatus(on_update) + + status.browser_callback( + MagicMock(), ESPHOME_SERVICE_TYPE, f"node.{ESPHOME_SERVICE_TYPE}", state_change + ) + + on_update.assert_called_once_with(expected) + + +def test_browser_callback_uses_cached_info() -> None: + """A cache hit is processed synchronously with no background task.""" + discovery = DashboardImportDiscovery() + name = f"cached.{ESPHOME_SERVICE_TYPE}" + info = _make_service_info() + + with ( + patch("esphome.zeroconf.AsyncServiceInfo", return_value=info), + patch("esphome.zeroconf.asyncio.create_task") as create_task, + ): + discovery.browser_callback( + MagicMock(), ESPHOME_SERVICE_TYPE, name, ServiceStateChange.Added + ) + + create_task.assert_not_called() + assert name in discovery.import_state + + +@pytest.mark.asyncio +async def test_browser_callback_cache_miss_resolves_in_background() -> None: + """A cache miss schedules an async resolve that lands in ``import_state``.""" + discovery = DashboardImportDiscovery() + name = f"uncached.{ESPHOME_SERVICE_TYPE}" + info = _make_service_info() + info.load_from_cache.return_value = False + info.async_request = AsyncMock(return_value=True) + + with patch("esphome.zeroconf.AsyncServiceInfo", return_value=info): + discovery.browser_callback( + MagicMock(), ESPHOME_SERVICE_TYPE, name, ServiceStateChange.Added + ) + assert zc._BACKGROUND_TASKS + await asyncio.gather(*zc._BACKGROUND_TASKS) + + assert name in discovery.import_state + assert not zc._BACKGROUND_TASKS + + +@pytest.mark.asyncio +async def test_async_process_service_info_unresolved() -> None: + """A failed resolve leaves ``import_state`` untouched.""" + discovery = DashboardImportDiscovery() + info = MagicMock() + info.async_request = AsyncMock(return_value=False) + + await discovery._async_process_service_info( + MagicMock(), info, ESPHOME_SERVICE_TYPE, f"x.{ESPHOME_SERVICE_TYPE}" + ) + + assert discovery.import_state == {} + + +def test_process_service_info_none_is_ignored() -> None: + """A ``None`` info is dropped without raising.""" + discovery = DashboardImportDiscovery() + + discovery._process_service_info(f"x.{ESPHOME_SERVICE_TYPE}", None) # type: ignore[arg-type] + + assert discovery.import_state == {} + + +def test_version_only_service_updates_device_mdns() -> None: + """A non-import device with a ``version`` TXT record refreshes its storage.""" + discovery = DashboardImportDiscovery() + info = MagicMock() + info.properties = {b"version": b"2025.2.0"} + + with patch.object(discovery, "update_device_mdns") as update: + discovery._process_service_info(f"plain.{ESPHOME_SERVICE_TYPE}", info) + + update.assert_called_once_with("plain", "2025.2.0") + assert discovery.import_state == {} + + +def test_update_device_mdns_saves_changed_version(tmp_path: Path) -> None: + """A differing mDNS version is written back to the stored JSON.""" + storage = MagicMock() + storage.esphome_version = "2025.1.0" + path = tmp_path / "node.yaml.json" + + with ( + patch("esphome.zeroconf.ext_storage_path", return_value=path) as ext_path, + patch("esphome.zeroconf.StorageJSON.load", return_value=storage) as load, + ): + DashboardImportDiscovery().update_device_mdns("node", "2025.2.0") + + ext_path.assert_called_once_with("node.yaml") + load.assert_called_once_with(path) + assert storage.esphome_version == "2025.2.0" + storage.save.assert_called_once_with(path) + + +def test_update_device_mdns_same_version_not_saved() -> None: + """An unchanged version does not rewrite the storage file.""" + storage = MagicMock() + storage.esphome_version = "2025.1.0" + + with ( + patch("esphome.zeroconf.ext_storage_path", return_value=Path("x")), + patch("esphome.zeroconf.StorageJSON.load", return_value=storage), + ): + DashboardImportDiscovery().update_device_mdns("node", "2025.1.0") + + storage.save.assert_not_called() + + +def test_update_device_mdns_missing_storage() -> None: + """A device without stored JSON is skipped.""" + with ( + patch("esphome.zeroconf.ext_storage_path", return_value=Path("x")), + patch("esphome.zeroconf.StorageJSON.load", return_value=None), + ): + DashboardImportDiscovery().update_device_mdns("node", "2025.1.0") + + +@pytest.mark.parametrize( + ("cached", "requested", "timeout", "addresses", "expected"), + [ + (True, False, 10.0, ["192.168.1.2"], ["192.168.1.2"]), + (False, True, 10.0, ["192.168.1.2"], ["192.168.1.2"]), + (False, True, 10.0, [], None), + (False, False, 0, ["192.168.1.2"], None), + ], +) +def test_esphome_zeroconf_resolve_host( + cached: bool, + requested: bool, + timeout: float, + addresses: list[str], + expected: list[str] | None, +) -> None: + """``resolve_host`` tries the cache, then a network request if timeout allows.""" + resolver = MagicMock() + resolver.load_from_cache.return_value = cached + resolver.request.return_value = requested + resolver.parsed_scoped_addresses.return_value = addresses + + # Skip Zeroconf.__init__ so no sockets are opened + with patch("esphome.zeroconf.AddressResolver", return_value=resolver) as cls: + result = EsphomeZeroconf.__new__(EsphomeZeroconf).resolve_host( + "node.local", timeout + ) + + cls.assert_called_once_with("node.local.") + assert result == expected + + +@pytest.mark.asyncio +async def test_async_resolve_hosts_logs_exceptions() -> None: + """A resolver that raises is logged and yields an empty address list.""" + good = MagicMock() + good.load_from_cache.return_value = False + good.async_request = AsyncMock(return_value=True) + good.parsed_scoped_addresses.return_value = ["10.0.0.1"] + bad = MagicMock() + bad.load_from_cache.return_value = False + bad.async_request = AsyncMock(side_effect=OSError("boom")) + bad.parsed_scoped_addresses.return_value = [] + + with patch("esphome.zeroconf.AddressResolver", side_effect=[good, bad]): + result = await async_resolve_hosts(MagicMock(), ["good", "bad"]) + + assert result == {"good": ["10.0.0.1"], "bad": []} + + +@pytest.mark.asyncio +async def test_async_resolve_host_returns_none_when_unresolved() -> None: + """``async_resolve_host`` maps an empty address list to ``None``.""" + aiozc = AsyncEsphomeZeroconf.__new__(AsyncEsphomeZeroconf) + aiozc.zeroconf = MagicMock() + + with patch( + "esphome.zeroconf.async_resolve_hosts", + new=AsyncMock(return_value={"node": []}), + ): + assert await aiozc.async_resolve_host("node") is None + + with patch( + "esphome.zeroconf.async_resolve_hosts", + new=AsyncMock(return_value={"node": ["1.2.3.4"]}), + ): + assert await aiozc.async_resolve_host("node") == ["1.2.3.4"] + + +@pytest.mark.asyncio +async def test_async_discover_mdns_devices_init_failure() -> None: + """A Zeroconf init error is logged and yields an empty result.""" + with patch("esphome.zeroconf.AsyncEsphomeZeroconf", side_effect=OSError("no net")): + assert await async_discover_mdns_devices("node", timeout=0) == {} + + +@pytest.mark.asyncio +async def test_async_discover_mdns_devices_end_to_end() -> None: + """Browse handler filters names, dedupes hosts and results get resolved.""" + aiozc = MagicMock() + aiozc.async_close = AsyncMock() + browser = MagicMock() + browser.async_cancel = AsyncMock() + handlers: list = [] + + def make_browser(_zc: object, _type: str, handlers: list) -> MagicMock: # noqa: A002 + captured.extend(handlers) + return browser + + captured: list = handlers + svc = f".{ESPHOME_SERVICE_TYPE}" + + async def fake_sleep(_timeout: float) -> None: + handler = captured[0] + zc_ = MagicMock() + handler( + zc_, ESPHOME_SERVICE_TYPE, "node-aabbcc" + svc, ServiceStateChange.Added + ) + handler( + zc_, ESPHOME_SERVICE_TYPE, "node-aabbcc" + svc, ServiceStateChange.Updated + ) + handler( + zc_, ESPHOME_SERVICE_TYPE, "node-zzzzzz" + svc, ServiceStateChange.Added + ) + handler( + zc_, ESPHOME_SERVICE_TYPE, "node-ddeeff" + svc, ServiceStateChange.Removed + ) + handler( + zc_, ESPHOME_SERVICE_TYPE, "node-112233" + svc, ServiceStateChange.Added + ) + + with ( + patch("esphome.zeroconf.AsyncEsphomeZeroconf", return_value=aiozc), + patch("esphome.zeroconf.AsyncServiceBrowser", side_effect=make_browser), + patch("esphome.zeroconf.asyncio.sleep", new=fake_sleep), + patch( + "esphome.zeroconf.async_resolve_hosts", + new=AsyncMock( + return_value={ + "node-aabbcc.local": ["10.0.0.1"], + "node-112233.local": [], + } + ), + ) as resolve, + ): + result = await async_discover_mdns_devices("node", timeout=1) + + resolve.assert_awaited_once() + assert result == {"node-112233.local": [], "node-aabbcc.local": ["10.0.0.1"]} + browser.async_cancel.assert_awaited_once() + aiozc.async_close.assert_awaited_once() + + +@pytest.mark.parametrize( + ("device_name", "expected"), + [ + ("node-aabbcc", True), + ("node-aabbc", False), + ("node-aabbcg", False), + ("other-aabbcc", False), + ], +) +def test_is_mac_suffix_match(device_name: str, expected: bool) -> None: + """Only ``<6 hex>`` names match.""" + assert zc._is_mac_suffix_match(device_name, "node-") is expected + + +def test_await_discovery_branches() -> None: + """Timeout, exception and success results of ``_await_discovery``.""" + runner = MagicMock() + runner.event.wait.return_value = False + assert zc._await_discovery(runner, 1.0) == {} + + runner.event.wait.return_value = True + runner.exception = RuntimeError("bad") + assert zc._await_discovery(runner, 1.0) == {} + + runner.exception = None + runner.result = {"a.local": ["1.1.1.1"]} + assert zc._await_discovery(runner, 1.0) == {"a.local": ["1.1.1.1"]} + + runner.result = None + assert zc._await_discovery(runner, 1.0) == {} + + +def test_discover_mdns_devices_runs_runner() -> None: + """The sync wrapper starts the runner and returns its result.""" + runner = MagicMock() + with ( + patch("esphome.zeroconf.AsyncThreadRunner", return_value=runner) as cls, + patch("esphome.zeroconf._await_discovery", return_value={"a.local": []}) as aw, + ): + assert discover_mdns_devices("node", timeout=2.0) == {"a.local": []} + + with patch( + "esphome.zeroconf.async_discover_mdns_devices", new=MagicMock() + ) as discover: + cls.call_args.args[0]() + discover.assert_called_once_with("node", timeout=2.0) + runner.start.assert_called_once_with() + aw.assert_called_once_with(runner, 2.0) + + +def test_dashboard_status_ignores_unknown_state() -> None: + """A state change that is not Added/Updated/Removed is ignored.""" + on_update = MagicMock() + + DashboardStatus(on_update).browser_callback( + MagicMock(), + ESPHOME_SERVICE_TYPE, + f"node.{ESPHOME_SERVICE_TYPE}", + None, # type: ignore[arg-type] + ) + + on_update.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("cached", "timeout"), + [(True, 10.0), (False, 0)], +) +async def test_async_resolve_hosts_skips_request(cached: bool, timeout: float) -> None: + """Cached hosts, or a zero timeout, never issue a network request.""" + resolver = MagicMock() + resolver.load_from_cache.return_value = cached + resolver.async_request = AsyncMock() + resolver.parsed_scoped_addresses.return_value = ["10.0.0.1"] + + with patch("esphome.zeroconf.AddressResolver", return_value=resolver): + result = await async_resolve_hosts(MagicMock(), ["node"], timeout) + + resolver.async_request.assert_not_called() + assert result == {"node": ["10.0.0.1"]}