mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 22:06:12 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ecd7a20c | ||
|
|
cb9eda3681 | ||
|
|
4e27957c74 | ||
|
|
0856211b9b | ||
|
|
ff4ff5c5f5 | ||
|
|
980b336668 | ||
|
|
a3cb3732ee | ||
|
|
ab50b3830a | ||
|
|
3131cf28a3 | ||
|
|
13683fa163 | ||
|
|
01d29001bb | ||
|
|
9a5fcbce89 | ||
|
|
ce83a625c1 | ||
|
|
43732e382e | ||
|
|
6d1f68af8d | ||
|
|
adeea9da98 | ||
|
|
946de4223f | ||
|
|
0ccac0c291 | ||
|
|
fc1e4bf816 | ||
|
|
5df921b4e0 | ||
|
|
fb27e003f3 | ||
|
|
f277476de0 | ||
|
|
9673e73893 | ||
|
|
b722812bd8 | ||
|
|
8fde15e789 | ||
|
|
c48d219dc0 | ||
|
|
3bed3f3d4f | ||
|
|
4220cbe63e | ||
|
|
d7bc501095 | ||
|
|
a04032b345 | ||
|
|
611d8cfb4d | ||
|
|
7dace44d61 | ||
|
|
491f98f982 | ||
|
|
619283d74a | ||
|
|
18348b8794 | ||
|
|
f7dc2ecb51 | ||
|
|
ddcc5874ed | ||
|
|
28013a60e1 |
@@ -115,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/<component>
|
||||
(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set,
|
||||
API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the
|
||||
Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions), `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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,12 +4,13 @@ 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, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
|
||||
- [`nrf52.md`](nrf52.md): dongle board support
|
||||
@@ -20,7 +21,7 @@ One file per component, named after it:
|
||||
- [`scheduler.md`](scheduler.md): loop wake A/B
|
||||
- [`udp.md`](udp.md): datagrams, multicast, sockets, `udp.write` payload shapes
|
||||
- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP)
|
||||
- [`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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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: <resolver>` on its USB
|
||||
console; flashing over the air reboots it for repeat boots. Before the AP address and DHCP server moved ahead of the
|
||||
AP start (the fix in that PR), the first AP start failed on every boot with `esp_netif_dhcps_option failed: 20484`
|
||||
(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the
|
||||
retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix
|
||||
the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Plaintext native API client that lists user actions with their raw argument fields and executes one.
|
||||
|
||||
usage: rawactions.py HOST list
|
||||
rawactions.py HOST exec ACTION key=value [...] (every argument must be given; types from the listing)
|
||||
Fields of ListEntitiesServicesArgument: 1 name, 2 type, 3 description, 4 example, 5 optional, 6 default_value.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
HOST = sys.argv[1]
|
||||
CMD = sys.argv[2]
|
||||
ARGS = sys.argv[3:]
|
||||
TYPE_NAMES = {
|
||||
0: "bool",
|
||||
1: "int",
|
||||
2: "float",
|
||||
3: "string",
|
||||
4: "bool[]",
|
||||
5: "int[]",
|
||||
6: "float[]",
|
||||
7: "string[]",
|
||||
}
|
||||
|
||||
|
||||
def varint(v: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = v & 0x7F
|
||||
v >>= 7
|
||||
if v:
|
||||
out.append(b | 0x80)
|
||||
else:
|
||||
out.append(b)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def svarint(v: int) -> bytes:
|
||||
return varint((v << 1) ^ (v >> 63))
|
||||
|
||||
|
||||
def decode_varint(buf: bytes, pos: int) -> tuple[int, int]:
|
||||
value = shift = 0
|
||||
while True:
|
||||
b = buf[pos]
|
||||
pos += 1
|
||||
value |= (b & 0x7F) << shift
|
||||
if not b & 0x80:
|
||||
return value, pos
|
||||
shift += 7
|
||||
|
||||
|
||||
def fields(payload: bytes) -> list[tuple[int, int, object]]:
|
||||
out = []
|
||||
pos = 0
|
||||
while pos < len(payload):
|
||||
key, pos = decode_varint(payload, pos)
|
||||
fnum, wt = key >> 3, key & 7
|
||||
if wt == 0:
|
||||
v, pos = decode_varint(payload, pos)
|
||||
elif wt == 2:
|
||||
ln, pos = decode_varint(payload, pos)
|
||||
v = payload[pos : pos + ln]
|
||||
pos += ln
|
||||
elif wt == 5:
|
||||
v = payload[pos : pos + 4] # fixed32, caller unpacks
|
||||
pos += 4
|
||||
else:
|
||||
raise ValueError(f"wire type {wt}")
|
||||
out.append((fnum, wt, v))
|
||||
return out
|
||||
|
||||
|
||||
def frame(mtype: int, payload: bytes) -> bytes:
|
||||
return b"\x00" + varint(len(payload)) + varint(mtype) + payload
|
||||
|
||||
|
||||
def field_bytes(fnum: int, data: bytes) -> bytes:
|
||||
return varint((fnum << 3) | 2) + varint(len(data)) + data
|
||||
|
||||
|
||||
def field_varint(fnum: int, v: int) -> bytes:
|
||||
return varint((fnum << 3) | 0) + varint(v)
|
||||
|
||||
|
||||
def recv_frames(
|
||||
sock: socket.socket, until: set[int], limit: float = 5.0
|
||||
) -> list[tuple[int, bytes]]:
|
||||
sock.settimeout(limit)
|
||||
buf = bytearray()
|
||||
frames = []
|
||||
while True:
|
||||
try:
|
||||
chunk = sock.recv(4096)
|
||||
except TimeoutError:
|
||||
return frames
|
||||
if not chunk:
|
||||
return frames
|
||||
buf += chunk
|
||||
while True:
|
||||
if len(buf) < 3:
|
||||
break
|
||||
if buf[0] != 0:
|
||||
raise ValueError("not plaintext")
|
||||
try:
|
||||
ln, p = decode_varint(buf, 1)
|
||||
mt, p = decode_varint(buf, p)
|
||||
except IndexError:
|
||||
break
|
||||
if len(buf) < p + ln:
|
||||
break
|
||||
frames.append((mt, bytes(buf[p : p + ln])))
|
||||
del buf[: p + ln]
|
||||
if mt in until:
|
||||
return frames
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sock = socket.create_connection((HOST, 6053), timeout=5)
|
||||
sock.sendall(
|
||||
frame(
|
||||
1, field_bytes(1, b"rawactions") + field_varint(2, 1) + field_varint(3, 10)
|
||||
)
|
||||
) # HelloRequest
|
||||
recv_frames(sock, {2})
|
||||
sock.sendall(frame(3, b"")) # ConnectRequest, no password
|
||||
recv_frames(sock, {4})
|
||||
sock.sendall(frame(11, b"")) # ListEntitiesRequest
|
||||
frames = recv_frames(sock, {19})
|
||||
services = {}
|
||||
for mt, payload in frames:
|
||||
if mt != 41:
|
||||
continue
|
||||
name = key = None
|
||||
args = []
|
||||
for fnum, _, v in fields(payload):
|
||||
if fnum == 1:
|
||||
name = v.decode()
|
||||
elif fnum == 2:
|
||||
key = struct.unpack("<I", v)[0]
|
||||
elif fnum == 3:
|
||||
a = {
|
||||
"name": None,
|
||||
"type": 0,
|
||||
"description": None,
|
||||
"example": None,
|
||||
"optional": None,
|
||||
"default": None,
|
||||
}
|
||||
for af, _, av in fields(v):
|
||||
if af == 1:
|
||||
a["name"] = av.decode()
|
||||
elif af == 2:
|
||||
a["type"] = av
|
||||
elif af == 3:
|
||||
a["description"] = av.decode()
|
||||
elif af == 4:
|
||||
a["example"] = av.decode()
|
||||
elif af == 5:
|
||||
a["optional"] = bool(av)
|
||||
elif af == 6:
|
||||
a["default"] = av.decode()
|
||||
else:
|
||||
a[f"field{af}"] = av
|
||||
args.append(a)
|
||||
services[name] = (key, args)
|
||||
if CMD == "list":
|
||||
for name, (key, args) in services.items():
|
||||
print(f"action {name} key={key}")
|
||||
for a in args:
|
||||
extra = {
|
||||
k: v
|
||||
for k, v in a.items()
|
||||
if k not in ("name", "type") and v is not None
|
||||
}
|
||||
print(f" {a['name']}: {TYPE_NAMES.get(a['type'], a['type'])} {extra}")
|
||||
return
|
||||
name = ARGS[0]
|
||||
key, args = services[name]
|
||||
payload = varint((1 << 3) | 5) + struct.pack("<I", key)
|
||||
given = dict(kv.split("=", 1) for kv in ARGS[1:])
|
||||
for a in args:
|
||||
if a["name"] not in given:
|
||||
if os.environ.get("ALLOW_MISSING"):
|
||||
continue # leave the argument out of the request on purpose
|
||||
sys.exit(f"missing {a['name']}")
|
||||
raw = given[a["name"]]
|
||||
t = a["type"]
|
||||
if t == 0:
|
||||
av = field_varint(1, 1 if raw.lower() == "true" else 0)
|
||||
elif t == 1:
|
||||
av = varint((5 << 3) | 0) + svarint(int(raw)) # sint32 int_
|
||||
elif t == 2:
|
||||
av = varint((3 << 3) | 5) + struct.pack("<f", float(raw))
|
||||
elif t == 3:
|
||||
av = field_bytes(4, raw.encode())
|
||||
elif t == 5:
|
||||
av = b"".join(
|
||||
varint((7 << 3) | 0) + svarint(int(x)) for x in raw.split(",") if x
|
||||
) # repeated sint32 int_array
|
||||
else:
|
||||
sys.exit(f"type {t} not supported here")
|
||||
payload += field_bytes(2, av)
|
||||
sock.sendall(frame(42, payload)) # ExecuteServiceRequest
|
||||
print(f"sent {name} with {given}")
|
||||
sock.close()
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Drive an LD24xx harness over the API: press buttons, set selects and numbers, print state updates, save the log.
|
||||
|
||||
usage: lddrive.py HOST KEY LOGFILE OP [OP ...] (KEY of - means a plaintext API)
|
||||
OP: list | press:NAME | sel:NAME=OPTION | set:NAME=VALUE | wait:SECONDS
|
||||
Steps are 2 s apart: the API log stream drops lines while the hub blocks in send_command_.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
BinarySensorState,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SelectInfo,
|
||||
SelectState,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
)
|
||||
|
||||
HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
OPS = sys.argv[4:]
|
||||
T0 = time.time()
|
||||
STEP_GAP_S = 2.0
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:7.3f}"
|
||||
|
||||
|
||||
async def run(c: APIClient, log) -> None:
|
||||
info = await c.device_info()
|
||||
print(
|
||||
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
|
||||
)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
|
||||
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None:
|
||||
return
|
||||
missing = " (missing)" if getattr(s, "missing_state", False) else ""
|
||||
if isinstance(s, TextSensorState):
|
||||
print(f"{stamp()} TXT {e.name} = {s.state!r}{missing}", flush=True)
|
||||
elif isinstance(s, BinarySensorState):
|
||||
print(f"{stamp()} BIN {e.name} = {s.state}{missing}", flush=True)
|
||||
elif isinstance(s, NumberState):
|
||||
print(f"{stamp()} NUM {e.name} = {s.state:.0f}{missing}", flush=True)
|
||||
elif isinstance(s, SelectState):
|
||||
print(f"{stamp()} SEL {e.name} = {s.state!r}{missing}", flush=True)
|
||||
elif (
|
||||
isinstance(s, SensorState)
|
||||
and isinstance(e, SensorInfo)
|
||||
and "Count" in e.name
|
||||
):
|
||||
print(f"{stamp()} CNT {e.name} = {s.state:.0f}", flush=True)
|
||||
|
||||
def on_log(msg) -> None:
|
||||
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
|
||||
await asyncio.sleep(1.5)
|
||||
for op in OPS:
|
||||
if op == "list":
|
||||
for n in sorted(buttons):
|
||||
print("button:", n)
|
||||
for n in sorted(selects):
|
||||
print("select:", n, selects[n].options)
|
||||
for e in ents:
|
||||
if isinstance(e, TextSensorInfo):
|
||||
print("text_sensor:", e.name)
|
||||
continue
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
continue
|
||||
if kind == "press":
|
||||
if rest not in buttons:
|
||||
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
|
||||
print(f"{stamp()} >>> press {rest}", flush=True)
|
||||
log.write(f"{stamp()} >>> press {rest}\n")
|
||||
c.button_command(buttons[rest].key)
|
||||
elif kind == "set":
|
||||
name, _, value = rest.partition("=")
|
||||
if name not in numbers:
|
||||
sys.exit(f"no number named {name!r}; have {sorted(numbers)}")
|
||||
print(f"{stamp()} >>> set {name} = {value}", flush=True)
|
||||
log.write(f"{stamp()} >>> set {name} = {value}\n")
|
||||
c.number_command(numbers[name].key, float(value))
|
||||
elif kind == "sel":
|
||||
name, _, value = rest.partition("=")
|
||||
if name not in selects:
|
||||
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
|
||||
print(f"{stamp()} >>> select {name} = {value}", flush=True)
|
||||
log.write(f"{stamp()} >>> select {name} = {value}\n")
|
||||
c.select_command(selects[name].key, value)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(STEP_GAP_S)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
await c.connect(login=True)
|
||||
with Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
await run(c, log)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Summarize an lddrive.py log: for each step, the LD2450 TX frames, commands and publishes.
|
||||
|
||||
usage: txsum.py LOGFILE (diff the output of two builds)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
out: list[str] = []
|
||||
with Path(sys.argv[1]).open(encoding="utf-8", errors="replace") as log:
|
||||
for raw in log:
|
||||
line = ANSI.sub("", raw.rstrip("\n"))
|
||||
m = re.match(r"\s*[\d.]+ (.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
text = m.group(1)
|
||||
if text.startswith(">>>"):
|
||||
out.append(f"\n# {text[4:]}")
|
||||
elif "[uart_debug" in text:
|
||||
out.append(f" TX {text.split('>>> ')[1]}")
|
||||
elif "[ld2450" in text and (
|
||||
"Sending COMMAND" in text
|
||||
or "zone type" in text.lower()
|
||||
or "Query zone" in text
|
||||
):
|
||||
out.append(f" LOG {text.split(']: ', 1)[1]}")
|
||||
elif "[select:" in text and ">>" in text:
|
||||
out.append(f" SEL {text.split(']: ', 1)[1]}")
|
||||
elif "[text_sensor:098]" in text:
|
||||
out.append(f" TXT {text.split(']: ', 1)[1]}")
|
||||
print("\n".join(out))
|
||||
@@ -0,0 +1,65 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
actions:
|
||||
- action: play_buzzer
|
||||
description: Play a song or build one from parts
|
||||
variables:
|
||||
song_str:
|
||||
type: string
|
||||
description: Full RTTTL string
|
||||
example: "scale:d=4,o=5,b=100:c,d,e"
|
||||
required: false
|
||||
notes:
|
||||
type: string
|
||||
required: false
|
||||
octave:
|
||||
type: int
|
||||
default: 5
|
||||
bpm:
|
||||
type: int
|
||||
default: 100
|
||||
tempo:
|
||||
type: float
|
||||
default: 1.5
|
||||
loud:
|
||||
type: bool
|
||||
default: true
|
||||
label:
|
||||
type: string
|
||||
default: ""
|
||||
then:
|
||||
- lambda: |-
|
||||
char s[40], n[40], l[40];
|
||||
snprintf(s, sizeof(s), "%.*s", (int) song_str.size(), song_str.c_str());
|
||||
snprintf(n, sizeof(n), "%.*s", (int) notes.size(), notes.c_str());
|
||||
snprintf(l, sizeof(l), "%.*s", (int) label.size(), label.c_str());
|
||||
ESP_LOGD("main", "play_buzzer song=%s(%d) notes=%s(%d) octave=%d bpm=%d tempo=%.1f loud=%d label=%s(%d)", s,
|
||||
(int) song_str.size(), n, (int) notes.size(), octave, bpm, tempo, loud, l, (int) label.size());
|
||||
- action: plain_action
|
||||
variables:
|
||||
value: int
|
||||
then:
|
||||
- logger.log:
|
||||
format: "plain_action value=%d"
|
||||
args: ["value"]
|
||||
- action: list_action
|
||||
variables:
|
||||
values: int[]
|
||||
then:
|
||||
- logger.log:
|
||||
format: "list_action n=%d"
|
||||
args: ["values.size()"]
|
||||
@@ -0,0 +1,28 @@
|
||||
esphome:
|
||||
name: ${name}
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
actions:
|
||||
- action: str_action
|
||||
variables:
|
||||
song_str: string
|
||||
notes: string
|
||||
octave: int
|
||||
then:
|
||||
- lambda: |-
|
||||
char s[40], n[40];
|
||||
snprintf(s, sizeof(s), "%.*s", (int) song_str.size(), song_str.c_str());
|
||||
snprintf(n, sizeof(n), "%.*s", (int) notes.size(), notes.c_str());
|
||||
ESP_LOGD("main", "str_action song=%s(%d) notes=%s(%d) octave=%d", s, (int) song_str.size(), n, (int) notes.size(), octave);
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<BITS>`, `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<BITS>`, `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<LAYOUT>`
|
||||
and `encode_bcd_calendar<LAYOUT>` 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<N>`; 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_state()`, or
|
||||
`restore_state(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:**
|
||||
|
||||
@@ -700,6 +700,7 @@ 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
|
||||
|
||||
+124
-16
@@ -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<uint8_t>`` 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<uint8_t> 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<uint8_t[]>({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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1174,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;
|
||||
@@ -1785,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;
|
||||
|
||||
@@ -1131,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<enums::MediaPlayerFormatPurpose>(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);
|
||||
}
|
||||
|
||||
@@ -1369,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;
|
||||
}
|
||||
@@ -1382,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;
|
||||
}
|
||||
@@ -2373,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<uint32_t>(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) {
|
||||
@@ -2383,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,
|
||||
|
||||
@@ -1593,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 {
|
||||
@@ -2263,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);
|
||||
|
||||
@@ -1643,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();
|
||||
}
|
||||
@@ -2099,6 +2105,7 @@ 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<enums::MediaPlayerFormatPurpose>(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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,13 +40,20 @@ template<typename T> 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:
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -132,13 +132,4 @@ class CC1101Component final : public Component,
|
||||
bool enter_tx_();
|
||||
};
|
||||
|
||||
// Action Wrappers
|
||||
template<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<CC1101Component> {
|
||||
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
|
||||
|
||||
@@ -79,6 +79,10 @@ class ClimateCall {
|
||||
ClimateCall &set_fan_mode(optional<std::string> 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<std::string> 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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
@@ -2851,9 +2851,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 +3031,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])
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -14,15 +14,10 @@
|
||||
#include <cinttypes>
|
||||
extern "C" {
|
||||
#include <esp_hosted.h>
|
||||
#ifndef CONFIG_ESP_HOSTED_HOST_FEAT_BT
|
||||
#include <esp_hosted_misc.h>
|
||||
#include <esp_hosted_bluedroid.h>
|
||||
#endif
|
||||
}
|
||||
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
|
||||
#include <eh_host_bt.h>
|
||||
#include <esp_hosted_bt_host_stack.h>
|
||||
#endif
|
||||
#endif
|
||||
#include <esp_bt_device.h>
|
||||
#include <esp_bt_main.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,8 +24,8 @@ 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;
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -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<const struct sockaddr *>(&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<const sockaddr_in6 *>(&peer)->sin6_addr);
|
||||
IP_SET_TYPE_VAL(link.peer_ip, IPADDR_TYPE_V6);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typename T> ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
|
||||
template<typename T> inline ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
|
||||
atom.store(value, std::memory_order_release);
|
||||
}
|
||||
template<typename T> ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
|
||||
template<typename T> inline ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
|
||||
return atom.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<uint8_t>(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<uint16_t>(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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<TaskResult *>(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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -76,6 +76,8 @@ static void log_percent(const LogString *param, float value) {
|
||||
void LightCall::perform() {
|
||||
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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -168,8 +168,7 @@ 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.", LOG_STR_ARG(this->state_->get_log_name()), state_s);
|
||||
this->publish(this->get_state_topic_to_(topic_buf), state_s);
|
||||
bool failed = false;
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}"',
|
||||
")",
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,7 +59,7 @@ void RX8025TComponent::read_time() {
|
||||
return;
|
||||
}
|
||||
|
||||
ESPTime rtc_time = time::decode_bcd_calendar(date);
|
||||
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_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<time::EPSON_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));
|
||||
|
||||
@@ -60,7 +60,7 @@ void RX8111Component::read_time() {
|
||||
this->status_set_warning(LOG_STR("Invalid RTC time"));
|
||||
return;
|
||||
}
|
||||
ESPTime rtc_time = time::decode_bcd_calendar(buf);
|
||||
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
|
||||
if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) {
|
||||
ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock");
|
||||
this->status_set_warning(LOG_STR("Invalid RTC time"));
|
||||
@@ -88,7 +88,7 @@ void RX8111Component::write_time() {
|
||||
}
|
||||
|
||||
uint8_t buf[7];
|
||||
time::encode_bcd_calendar(now, buf);
|
||||
time::encode_bcd_calendar<time::EPSON_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);
|
||||
|
||||
@@ -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<time::EPSON_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<time::EPSON_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));
|
||||
|
||||
@@ -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()")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
idf_version,
|
||||
only_on_variant,
|
||||
)
|
||||
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
@@ -142,13 +143,19 @@ TYPE_CLASS = {
|
||||
}
|
||||
|
||||
|
||||
def _require_psram_dma_idf(value: Any) -> Any:
|
||||
if idf_version() < cv.Version(5, 5, 3):
|
||||
raise cv.Invalid("psram_dma requires ESP-IDF 5.5.3 or newer")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_psram_dma(value: Any) -> bool:
|
||||
value = cv.boolean(value)
|
||||
if not value:
|
||||
return value
|
||||
return cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.only_with_framework("esp-idf"),
|
||||
_require_psram_dma_idf,
|
||||
only_on_variant(
|
||||
supported=[
|
||||
VARIANT_ESP32C5,
|
||||
@@ -159,7 +166,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)
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ optional<bool> Switch::get_initial_state_with_restore_mode() {
|
||||
}
|
||||
return initial_state;
|
||||
}
|
||||
void Switch::restore_state() {
|
||||
if (auto initial = this->get_initial_state_with_restore_mode())
|
||||
this->control(*initial);
|
||||
}
|
||||
void Switch::restore_state(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;
|
||||
|
||||
@@ -110,6 +110,11 @@ class Switch : public EntityBase {
|
||||
*/
|
||||
optional<bool> 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 `when_disabled`, or nothing without one.
|
||||
void restore_state();
|
||||
void restore_state(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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<SX126x> {
|
||||
TEMPLATABLE_BYTES(data)
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
this->data_.template visit<SX126X_MAX_PACKET_SIZE>(
|
||||
[this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::sx126x
|
||||
@@ -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
|
||||
|
||||
@@ -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<typename... Ts> class SendPacketAction final : public Action<Ts...>, public Parented<SX127x> {
|
||||
TEMPLATABLE_BYTES(data)
|
||||
|
||||
public:
|
||||
void play(const Ts &...x) override {
|
||||
this->data_.template visit<SX127X_MAX_PACKET_SIZE>(
|
||||
[this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::sx127x
|
||||
@@ -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<uint8_t>(BCD_CALENDAR_MASKS >> (8 * i)));
|
||||
const uint8_t weekday = raw[3] & static_cast<uint8_t>(BCD_CALENDAR_MASKS >> 24);
|
||||
ESPTime time{
|
||||
.second = value[0],
|
||||
.minute = value[1],
|
||||
.hour = value[2],
|
||||
.day_of_week = static_cast<uint8_t>(weekday ? __builtin_ctz(weekday) + 1 : 1),
|
||||
.day_of_month = value[4],
|
||||
.day_of_year = 1,
|
||||
.month = value[5],
|
||||
.year = static_cast<uint16_t>(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<uint8_t>(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
|
||||
|
||||
@@ -44,10 +44,95 @@ class RealTimeClock : public PollingComponent {
|
||||
LazyCallbackManager<void()> 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<BcdCalendarLayout LAYOUT> __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<BcdCalendarLayout LAYOUT>
|
||||
__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<uint8_t>(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
|
||||
|
||||
@@ -51,8 +51,8 @@ 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) {
|
||||
@@ -64,7 +64,7 @@ void TMP1075Sensor::set_fault_count(const int 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,28 +75,28 @@ 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", LOG_STR_ARG(this->get_log_name()), config_.regvalue);
|
||||
log_config_();
|
||||
if (!this->write_byte(REG_CFGR, config_.regvalue)) {
|
||||
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", LOG_STR_ARG(this->get_log_name()), 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", LOG_STR_ARG(this->get_log_name()));
|
||||
return;
|
||||
@@ -104,8 +104,8 @@ void TMP1075Sensor::send_alert_limit_low_() {
|
||||
}
|
||||
|
||||
void TMP1075Sensor::send_alert_limit_high_() {
|
||||
ESP_LOGV(TAG, "'%s' - sending alert limit high %.3f °C", LOG_STR_ARG(this->get_log_name()), 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", LOG_STR_ARG(this->get_log_name()));
|
||||
return;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "uart.h"
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::uart {
|
||||
|
||||
template<typename... Ts> class UARTWriteAction final : public Action<Ts...>, public Parented<UARTComponent> {
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typename... Ts> class UDPWriteAction final : public Action<Ts...>, public Parented<UDPComponent> {
|
||||
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<STACK_PAYLOAD_SIZE>(
|
||||
[this](const uint8_t *data, size_t len) { this->parent_->send_packet(data, len); }, x...);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::udp
|
||||
|
||||
#endif
|
||||
@@ -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()")
|
||||
@@ -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 <algorithm>
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
|
||||
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<uint16_t>(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<uint8_t>(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<int>(this->text_len_), reinterpret_cast<const char *>(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<uint8_t>(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<int>(this->text_len_),
|
||||
reinterpret_cast<const char *>(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<int32_t>(read_be32(p + 8));
|
||||
this->rx_pos_ += 12;
|
||||
this->rects_left_--;
|
||||
if (encoding == ENCODING_RAW) {
|
||||
if (static_cast<uint32_t>(this->rect_x_) + this->rect_w_ > this->fb_w_ ||
|
||||
static_cast<uint32_t>(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<uint32_t>(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<uint16_t>(std::min<size_t>(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<uint32_t>(std::min<size_t>(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<int>(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<int>(this->fb_w_, disp_w);
|
||||
this->vis_h_ = std::min<int>(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<uint32_t>(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<uint8_t>(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
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
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<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callback_.add(std::forward<F>(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<void()> connect_callback_;
|
||||
LazyCallbackManager<void()> 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ from esphome.const import (
|
||||
CONF_ENABLE_BTM,
|
||||
CONF_ENABLE_ON_BOOT,
|
||||
CONF_ENABLE_RRM,
|
||||
CONF_ETHERNET,
|
||||
CONF_FAST_CONNECT,
|
||||
CONF_GATEWAY,
|
||||
CONF_HIDDEN,
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -431,8 +430,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 +464,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 +477,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 +597,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
|
||||
@@ -840,7 +872,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:
|
||||
|
||||
@@ -783,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) {
|
||||
@@ -797,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
|
||||
@@ -937,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.
|
||||
@@ -975,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() &&
|
||||
@@ -1013,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
|
||||
@@ -1098,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.
|
||||
|
||||
@@ -127,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
|
||||
@@ -726,7 +730,7 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &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;
|
||||
@@ -842,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
|
||||
@@ -1123,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));
|
||||
@@ -1397,10 +1405,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &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");
|
||||
@@ -1478,8 +1482,8 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &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;
|
||||
}
|
||||
@@ -1488,6 +1492,17 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &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;
|
||||
@@ -1524,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;
|
||||
}
|
||||
|
||||
@@ -1681,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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user